summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-api/opendc
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2021-04-25 21:53:42 +0200
committerGitHub <noreply@github.com>2021-04-25 21:53:42 +0200
commit128f76f7fd7c8abb41a3bbbd9f1980cbc20ae7a5 (patch)
treeadd513890005233a7784466797bfe6f5052e9eeb /opendc-web/opendc-web-api/opendc
parent128a1db017545597a5c035b7960eb3fd36b5f987 (diff)
parent57b54b59ed74ec37338ae26b3864d051255aba49 (diff)
build: Flatten project structure
This change updates the project structure to become flattened. Previously, the simulator, frontend and API each lived into their own directory. With this change, all modules of the project live in the top-level directory of the repository.
Diffstat (limited to 'opendc-web/opendc-web-api/opendc')
-rw-r--r--opendc-web/opendc-web-api/opendc/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/__init__.py0
-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.py152
-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.py22
-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.py23
-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.py32
-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/authorizations/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py17
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py43
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py66
-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.py122
-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.py25
-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.py149
-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.py119
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py30
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py34
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py59
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py56
-rw-r--r--opendc-web/opendc-web-api/opendc/models/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/models/model.py64
-rw-r--r--opendc-web/opendc-web-api/opendc/models/portfolio.py24
-rw-r--r--opendc-web/opendc-web-api/opendc/models/prefab.py28
-rw-r--r--opendc-web/opendc-web-api/opendc/models/project.py31
-rw-r--r--opendc-web/opendc-web-api/opendc/models/scenario.py26
-rw-r--r--opendc-web/opendc-web-api/opendc/models/topology.py27
-rw-r--r--opendc-web/opendc-web-api/opendc/models/trace.py7
-rw-r--r--opendc-web/opendc-web-api/opendc/models/user.py36
-rw-r--r--opendc-web/opendc-web-api/opendc/util/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/util/database.py77
-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.py141
68 files changed, 2498 insertions, 0 deletions
diff --git a/opendc-web/opendc-web-api/opendc/__init__.py b/opendc-web/opendc-web-api/opendc/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/api/__init__.py b/opendc-web/opendc-web-api/opendc/api/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/paths.json b/opendc-web/opendc-web-api/opendc/api/v2/paths.json
new file mode 100644
index 00000000..652be5bc
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/paths.json
@@ -0,0 +1,19 @@
+[
+ "/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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py
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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py
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
new file mode 100644
index 00000000..0ba61a13
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py
@@ -0,0 +1,67 @@
+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.google_id, 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.google_id, 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.google_id, 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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py
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
new file mode 100644
index 00000000..2f042e06
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py
@@ -0,0 +1,49 @@
+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.google_id, True)
+
+ scenario = Scenario(request.params_body['scenario'])
+
+ topology = Topology.from_id(scenario.obj['topology']['topologyId'])
+ topology.check_exists()
+ topology.check_user_access(request.google_id, 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
new file mode 100644
index 00000000..e5982b7f
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py
@@ -0,0 +1,125 @@
+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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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
new file mode 100644
index 00000000..52f71aa4
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py
@@ -0,0 +1,152 @@
+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': [{
+ 'projectId': test_id_2,
+ 'authorizationLevel': 'OWN'
+ }]
+ })
+ 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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py
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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py
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
new file mode 100644
index 00000000..0d9ad5cd
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py
@@ -0,0 +1,22 @@
+from opendc.models.prefab import Prefab
+from opendc.util.database import DB
+from opendc.models.user import User
+from opendc.util.rest import Response
+
+
+def GET(request):
+ """Return all prefabs the user is authorized to access"""
+
+ user = User.from_google_id(request.google_id)
+
+ user.check_exists()
+
+ own_prefabs = DB.fetch_all({'authorId': user.get_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
new file mode 100644
index 00000000..6d36d428
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py
@@ -0,0 +1,71 @@
+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
new file mode 100644
index 00000000..723a2f0d
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py
@@ -0,0 +1,23 @@
+from datetime import datetime
+
+from opendc.models.prefab import Prefab
+from opendc.models.user import User
+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 = User.from_google_id(request.google_id)
+ prefab.set_property('authorId', user.get_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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py
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
new file mode 100644
index 00000000..7b81f546
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py
@@ -0,0 +1,50 @@
+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.google_id)
+
+ 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.google_id)
+
+ 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.google_id)
+
+ 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
new file mode 100644
index 00000000..2daeb6bf
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py
@@ -0,0 +1,145 @@
+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_id,
+ '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_id,
+ '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_id,
+ '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
new file mode 100644
index 00000000..39a78c21
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py
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
new file mode 100644
index 00000000..bf031382
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py
@@ -0,0 +1,32 @@
+from datetime import datetime
+
+from opendc.models.project import Project
+from opendc.models.topology import Topology
+from opendc.models.user import User
+from opendc.util.database import Database
+from opendc.util.rest import Response
+
+
+def POST(request):
+ """Create a new project, and return that new project."""
+
+ request.check_required_parameters(body={'project': {'name': 'string'}})
+
+ 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.insert()
+
+ topology.set_property('projectId', project.get_id())
+ topology.update()
+
+ user = User.from_google_id(request.google_id)
+ user.obj['authorizations'].append({'projectId': project.get_id(), 'authorizationLevel': 'OWN'})
+ user.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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py
new file mode 100644
index 00000000..9f6a60ec
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py
@@ -0,0 +1,17 @@
+from opendc.models.project import Project
+from opendc.util.rest import Response
+
+
+def GET(request):
+ """Find all authorizations for a Project."""
+
+ request.check_required_parameters(path={'projectId': 'string'})
+
+ project = Project.from_id(request.params_path['projectId'])
+
+ project.check_exists()
+ project.check_user_access(request.google_id, False)
+
+ authorizations = project.get_all_authorizations()
+
+ return Response(200, 'Successfully retrieved project authorizations', authorizations)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py
new file mode 100644
index 00000000..bebd6cff
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py
@@ -0,0 +1,43 @@
+from opendc.util.database import DB
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_authorizations_non_existing(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value=None)
+ mocker.patch.object(DB, 'fetch_all', return_value=None)
+ assert '404' in client.get(f'/v2/projects/{test_id}/authorizations').status
+
+
+def test_get_authorizations_not_authorized(client, mocker):
+ mocker.patch.object(DB,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'name': 'test trace',
+ 'authorizations': [{
+ 'projectId': test_id_2,
+ 'authorizationLevel': 'OWN'
+ }]
+ })
+ mocker.patch.object(DB, 'fetch_all', return_value=[])
+ res = client.get(f'/v2/projects/{test_id}/authorizations')
+ assert '403' in res.status
+
+
+def test_get_authorizations(client, mocker):
+ mocker.patch.object(DB,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'name': 'test trace',
+ 'authorizations': [{
+ 'projectId': test_id,
+ 'authorizationLevel': 'OWN'
+ }]
+ })
+ mocker.patch.object(DB, 'fetch_all', return_value=[])
+ res = client.get(f'/v2/projects/{test_id}/authorizations')
+ assert len(res.json['content']) == 0
+ assert '200' in res.status
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
new file mode 100644
index 00000000..caac37ca
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py
@@ -0,0 +1,66 @@
+from datetime import datetime
+
+from opendc.models.portfolio import Portfolio
+from opendc.models.project import Project
+from opendc.models.topology import Topology
+from opendc.models.user import User
+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.google_id, 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.google_id, 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.google_id, 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()
+
+ user = User.from_google_id(request.google_id)
+ user.obj['authorizations'] = list(
+ filter(lambda x: x['projectId'] != project.get_id(), user.obj['authorizations']))
+ user.update()
+
+ 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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py
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
new file mode 100644
index 00000000..2cdb1194
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py
@@ -0,0 +1,35 @@
+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.google_id, 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
new file mode 100644
index 00000000..04c699b5
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py
@@ -0,0 +1,85 @@
+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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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
new file mode 100644
index 00000000..f9ffaf37
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py
@@ -0,0 +1,122 @@
+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': [{
+ 'projectId': test_id_2,
+ 'authorizationLevel': 'OWN'
+ }]
+ })
+ 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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py
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
new file mode 100644
index 00000000..44a0d575
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py
@@ -0,0 +1,31 @@
+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.google_id, 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
new file mode 100644
index 00000000..71e88f00
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py
@@ -0,0 +1,52 @@
+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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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
new file mode 100644
index 00000000..9444b1e4
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py
@@ -0,0 +1,25 @@
+from opendc.util.database import DB
+
+test_id = 24 * '1'
+
+
+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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py
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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py
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
new file mode 100644
index 00000000..88a74e9c
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py
@@ -0,0 +1,59 @@
+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.google_id, 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.google_id, 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.google_id, 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
new file mode 100644
index 00000000..cd4bcdf8
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py
@@ -0,0 +1,149 @@
+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):
+ mocker.patch.object(DB, 'fetch_one', return_value={
+ 'portfolioId': '1',
+ 'authorizations': []
+ })
+ res = client.get(f'/v2/scenarios/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_scenario_not_authorized(client, mocker):
+ mocker.patch.object(DB,
+ 'fetch_one',
+ return_value={
+ 'projectId': test_id,
+ 'portfolioId': test_id,
+ '_id': test_id,
+ 'authorizations': [{
+ 'projectId': test_id_2,
+ 'authorizationLevel': 'OWN'
+ }]
+ })
+ 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',
+ return_value={
+ 'projectId': test_id,
+ 'portfolioId': test_id,
+ '_id': test_id,
+ 'authorizations': [{
+ 'projectId': test_id,
+ 'authorizationLevel': 'EDIT'
+ }]
+ })
+ 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',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'portfolioId': test_id,
+ 'authorizations': [{
+ 'projectId': test_id,
+ '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',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'portfolioId': test_id,
+ 'authorizations': [{
+ 'projectId': test_id,
+ 'authorizationLevel': 'OWN'
+ }],
+ 'targets': {
+ 'enabledMetrics': [],
+ 'repeatsPerScenario': 1
+ }
+ })
+ 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',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'portfolioId': test_id,
+ 'googleId': 'other_test',
+ 'authorizations': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py
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
new file mode 100644
index 00000000..f33159bf
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 00000000..4950ca4c
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py
@@ -0,0 +1,2 @@
+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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py
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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py
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
new file mode 100644
index 00000000..ea82b2e2
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py
@@ -0,0 +1,58 @@
+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.google_id, 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.google_id, 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.google_id, 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
new file mode 100644
index 00000000..4da0bc64
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py
@@ -0,0 +1,119 @@
+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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id_2,
+ 'authorizationLevel': 'OWN'
+ }]
+ })
+ 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': [{
+ 'projectId': test_id,
+ 'authorizationLevel': 'VIEW'
+ }]
+ })
+ 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': [{
+ 'projectId': test_id,
+ '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': [{
+ 'projectId': test_id,
+ '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/users/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/users/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/users/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py
new file mode 100644
index 00000000..0dcf2463
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py
@@ -0,0 +1,30 @@
+from opendc.models.user import User
+from opendc.util.rest import Response
+
+
+def GET(request):
+ """Search for a User using their email address."""
+
+ request.check_required_parameters(query={'email': 'string'})
+
+ user = User.from_email(request.params_query['email'])
+
+ user.check_exists()
+
+ return Response(200, 'Successfully retrieved user.', user.obj)
+
+
+def POST(request):
+ """Add a new User."""
+
+ request.check_required_parameters(body={'user': {'email': 'string'}})
+
+ user = User(request.params_body['user'])
+ user.set_property('googleId', request.google_id)
+ user.set_property('authorizations', [])
+
+ user.check_already_exists()
+
+ user.insert()
+
+ return Response(200, 'Successfully created user.', user.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py
new file mode 100644
index 00000000..13b63b20
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py
@@ -0,0 +1,34 @@
+from opendc.util.database import DB
+
+
+def test_get_user_by_email_missing_parameter(client):
+ assert '400' in client.get('/v2/users').status
+
+
+def test_get_user_by_email_non_existing(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value=None)
+ assert '404' in client.get('/v2/users?email=test@test.com').status
+
+
+def test_get_user_by_email(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value={'email': 'test@test.com'})
+ res = client.get('/v2/users?email=test@test.com')
+ assert 'email' in res.json['content']
+ assert '200' in res.status
+
+
+def test_add_user_missing_parameter(client):
+ assert '400' in client.post('/v2/users').status
+
+
+def test_add_user_existing(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value={'email': 'test@test.com'})
+ assert '409' in client.post('/v2/users', json={'user': {'email': 'test@test.com'}}).status
+
+
+def test_add_user(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value=None)
+ mocker.patch.object(DB, 'insert', return_value={'email': 'test@test.com'})
+ res = client.post('/v2/users', json={'user': {'email': 'test@test.com'}})
+ assert 'email' in res.json['content']
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py
new file mode 100644
index 00000000..be3462c0
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py
@@ -0,0 +1,59 @@
+from opendc.models.project import Project
+from opendc.models.user import User
+from opendc.util.rest import Response
+
+
+def GET(request):
+ """Get this User."""
+
+ request.check_required_parameters(path={'userId': 'string'})
+
+ user = User.from_id(request.params_path['userId'])
+
+ user.check_exists()
+
+ return Response(200, 'Successfully retrieved user.', user.obj)
+
+
+def PUT(request):
+ """Update this User's given name and/or family name."""
+
+ request.check_required_parameters(body={'user': {
+ 'givenName': 'string',
+ 'familyName': 'string'
+ }},
+ path={'userId': 'string'})
+
+ user = User.from_id(request.params_path['userId'])
+
+ user.check_exists()
+ user.check_correct_user(request.google_id)
+
+ user.set_property('givenName', request.params_body['user']['givenName'])
+ user.set_property('familyName', request.params_body['user']['familyName'])
+
+ user.update()
+
+ return Response(200, 'Successfully updated user.', user.obj)
+
+
+def DELETE(request):
+ """Delete this User."""
+
+ request.check_required_parameters(path={'userId': 'string'})
+
+ user = User.from_id(request.params_path['userId'])
+
+ user.check_exists()
+ user.check_correct_user(request.google_id)
+
+ for authorization in user.obj['authorizations']:
+ if authorization['authorizationLevel'] != 'OWN':
+ continue
+
+ project = Project.from_id(authorization['projectId'])
+ project.delete()
+
+ old_object = user.delete()
+
+ return Response(200, 'Successfully deleted user.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py
new file mode 100644
index 00000000..4085642f
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py
@@ -0,0 +1,56 @@
+from opendc.util.database import DB
+
+test_id = 24 * '1'
+
+
+def test_get_user_non_existing(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/v2/users/{test_id}').status
+
+
+def test_get_user(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value={'email': 'test@test.com'})
+ res = client.get(f'/v2/users/{test_id}')
+ assert 'email' in res.json['content']
+ assert '200' in res.status
+
+
+def test_update_user_missing_parameter(client):
+ assert '400' in client.put(f'/v2/users/{test_id}').status
+
+
+def test_update_user_non_existing(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/v2/users/{test_id}', json={'user': {'givenName': 'A', 'familyName': 'B'}}).status
+
+
+def test_update_user_different_user(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'other_test'})
+ assert '403' in client.put(f'/v2/users/{test_id}', json={'user': {'givenName': 'A', 'familyName': 'B'}}).status
+
+
+def test_update_user(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'test'})
+ mocker.patch.object(DB, 'update', return_value={'givenName': 'A', 'familyName': 'B'})
+ res = client.put(f'/v2/users/{test_id}', json={'user': {'givenName': 'A', 'familyName': 'B'}})
+ assert 'givenName' in res.json['content']
+ assert '200' in res.status
+
+
+def test_delete_user_non_existing(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value=None)
+ assert '404' in client.delete(f'/v2/users/{test_id}').status
+
+
+def test_delete_user_different_user(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'other_test'})
+ assert '403' in client.delete(f'/v2/users/{test_id}').status
+
+
+def test_delete_user(client, mocker):
+ mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'test', 'authorizations': []})
+ mocker.patch.object(DB, 'delete_one', return_value={'googleId': 'test'})
+ res = client.delete(f'/v2/users/{test_id}', )
+
+ assert 'googleId' in res.json['content']
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/models/__init__.py b/opendc-web/opendc-web-api/opendc/models/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/models/model.py b/opendc-web/opendc-web-api/opendc/models/model.py
new file mode 100644
index 00000000..f9dfc9ad
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/model.py
@@ -0,0 +1,64 @@
+from bson.objectid import ObjectId
+
+from opendc.util.database import DB
+from opendc.util.exceptions import ClientError
+from opendc.util.rest import Response
+
+
+class Model:
+ """Base class for all models."""
+
+ collection_name = '<specified in subclasses>'
+
+ @classmethod
+ def from_id(cls, _id):
+ """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))
+
+ @classmethod
+ def get_all(cls):
+ """Fetches all documents from the collection."""
+ return cls(DB.fetch_all({}, cls.collection_name))
+
+ def __init__(self, obj):
+ self.obj = obj
+
+ def get_id(self):
+ """Returns the ID of the enclosed object."""
+ return self.obj['_id']
+
+ 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.'))
+
+ def set_property(self, key, value):
+ """Sets the given property on the enclosed object, with support for simple nested access."""
+ if '.' in key:
+ keys = key.split('.')
+ self.obj[keys[0]][keys[1]] = value
+ else:
+ self.obj[key] = value
+
+ def insert(self):
+ """Inserts the enclosed object and generates a UUID for it."""
+ self.obj['_id'] = ObjectId()
+ 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)
+
+ def delete(self):
+ """Deletes the enclosed object in the database, if it existed."""
+ if self.obj is None:
+ return None
+
+ old_object = self.obj.copy()
+ 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
new file mode 100644
index 00000000..32961b63
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/portfolio.py
@@ -0,0 +1,24 @@
+from opendc.models.model import Model
+from opendc.models.user import User
+from opendc.util.exceptions import ClientError
+from opendc.util.rest import Response
+
+
+class Portfolio(Model):
+ """Model representing a Portfolio."""
+
+ collection_name = 'portfolios'
+
+ def check_user_access(self, google_id, edit_access):
+ """Raises an error if the user with given [google_id] has insufficient access.
+
+ Checks access on the parent project.
+
+ :param google_id: The Google ID of the user.
+ :param edit_access: True when edit access should be checked, otherwise view access.
+ """
+ user = User.from_google_id(google_id)
+ authorizations = list(
+ filter(lambda x: str(x['projectId']) == str(self.obj['projectId']), user.obj['authorizations']))
+ if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'):
+ raise ClientError(Response(403, 'Forbidden from retrieving/editing portfolio.'))
diff --git a/opendc-web/opendc-web-api/opendc/models/prefab.py b/opendc-web/opendc-web-api/opendc/models/prefab.py
new file mode 100644
index 00000000..edf1d4c4
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/prefab.py
@@ -0,0 +1,28 @@
+from opendc.models.model import Model
+from opendc.models.user import User
+from opendc.util.exceptions import ClientError
+from opendc.util.rest import Response
+
+
+class Prefab(Model):
+ """Model representing a Project."""
+
+ collection_name = 'prefabs'
+
+ def check_user_access(self, google_id):
+ """Raises an error if the user with given [google_id] has insufficient access to view this prefab.
+
+ :param google_id: The Google ID of the user.
+ """
+ user = User.from_google_id(google_id)
+
+ # TODO(Jacob) add special handling for OpenDC-provided prefabs
+
+ #try:
+
+ print(self.obj)
+ if self.obj['authorId'] != user.get_id() and self.obj['visibility'] == "private":
+ raise ClientError(Response(403, "Forbidden from retrieving prefab."))
+ #except KeyError:
+ # OpenDC-authored objects don't necessarily have an authorId
+ # return
diff --git a/opendc-web/opendc-web-api/opendc/models/project.py b/opendc-web/opendc-web-api/opendc/models/project.py
new file mode 100644
index 00000000..b57e9f77
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/project.py
@@ -0,0 +1,31 @@
+from opendc.models.model import Model
+from opendc.models.user import User
+from opendc.util.database import DB
+from opendc.util.exceptions import ClientError
+from opendc.util.rest import Response
+
+
+class Project(Model):
+ """Model representing a Project."""
+
+ collection_name = 'projects'
+
+ def check_user_access(self, google_id, edit_access):
+ """Raises an error if the user with given [google_id] has insufficient access.
+
+ :param google_id: The Google ID of the user.
+ :param edit_access: True when edit access should be checked, otherwise view access.
+ """
+ user = User.from_google_id(google_id)
+ authorizations = list(filter(lambda x: str(x['projectId']) == str(self.get_id()),
+ user.obj['authorizations']))
+ if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'):
+ raise ClientError(Response(403, "Forbidden from retrieving project."))
+
+ def get_all_authorizations(self):
+ """Get all user IDs having access to this project."""
+ return [
+ str(user['_id']) for user in DB.fetch_all({'authorizations': {
+ 'projectId': self.obj['_id']
+ }}, User.collection_name)
+ ]
diff --git a/opendc-web/opendc-web-api/opendc/models/scenario.py b/opendc-web/opendc-web-api/opendc/models/scenario.py
new file mode 100644
index 00000000..8d53e408
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/scenario.py
@@ -0,0 +1,26 @@
+from opendc.models.model import Model
+from opendc.models.portfolio import Portfolio
+from opendc.models.user import User
+from opendc.util.exceptions import ClientError
+from opendc.util.rest import Response
+
+
+class Scenario(Model):
+ """Model representing a Scenario."""
+
+ collection_name = 'scenarios'
+
+ def check_user_access(self, google_id, edit_access):
+ """Raises an error if the user with given [google_id] has insufficient access.
+
+ Checks access on the parent project.
+
+ :param google_id: The Google ID of the user.
+ :param edit_access: True when edit access should be checked, otherwise view access.
+ """
+ portfolio = Portfolio.from_id(self.obj['portfolioId'])
+ user = User.from_google_id(google_id)
+ authorizations = list(
+ filter(lambda x: str(x['projectId']) == str(portfolio.obj['projectId']), user.obj['authorizations']))
+ if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'):
+ raise ClientError(Response(403, 'Forbidden from retrieving/editing scenario.'))
diff --git a/opendc-web/opendc-web-api/opendc/models/topology.py b/opendc-web/opendc-web-api/opendc/models/topology.py
new file mode 100644
index 00000000..cb4c4bab
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/topology.py
@@ -0,0 +1,27 @@
+from opendc.models.model import Model
+from opendc.models.user import User
+from opendc.util.exceptions import ClientError
+from opendc.util.rest import Response
+
+
+class Topology(Model):
+ """Model representing a Project."""
+
+ collection_name = 'topologies'
+
+ def check_user_access(self, google_id, edit_access):
+ """Raises an error if the user with given [google_id] has insufficient access.
+
+ Checks access on the parent project.
+
+ :param google_id: The Google ID of the user.
+ :param edit_access: True when edit access should be checked, otherwise view access.
+ """
+ user = User.from_google_id(google_id)
+ if 'projectId' not in self.obj:
+ raise ClientError(Response(400, 'Missing projectId in topology.'))
+
+ authorizations = list(
+ filter(lambda x: str(x['projectId']) == str(self.obj['projectId']), user.obj['authorizations']))
+ if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'):
+ raise ClientError(Response(403, 'Forbidden from retrieving topology.'))
diff --git a/opendc-web/opendc-web-api/opendc/models/trace.py b/opendc-web/opendc-web-api/opendc/models/trace.py
new file mode 100644
index 00000000..2f6e4926
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/trace.py
@@ -0,0 +1,7 @@
+from opendc.models.model import Model
+
+
+class Trace(Model):
+ """Model representing a Trace."""
+
+ collection_name = 'traces'
diff --git a/opendc-web/opendc-web-api/opendc/models/user.py b/opendc-web/opendc-web-api/opendc/models/user.py
new file mode 100644
index 00000000..8e8ff945
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/models/user.py
@@ -0,0 +1,36 @@
+from opendc.models.model import Model
+from opendc.util.database import DB
+from opendc.util.exceptions import ClientError
+from opendc.util.rest import Response
+
+
+class User(Model):
+ """Model representing a User."""
+
+ collection_name = 'users'
+
+ @classmethod
+ def from_email(cls, email):
+ """Fetches the user with given email from the collection."""
+ return User(DB.fetch_one({'email': email}, User.collection_name))
+
+ @classmethod
+ def from_google_id(cls, google_id):
+ """Fetches the user with given Google ID from the collection."""
+ return User(DB.fetch_one({'googleId': google_id}, User.collection_name))
+
+ def check_correct_user(self, request_google_id):
+ """Raises an error if a user tries to modify another user.
+
+ :param request_google_id:
+ """
+ if request_google_id is not None and self.obj['googleId'] != request_google_id:
+ raise ClientError(Response(403, f'Forbidden from editing user with ID {self.obj["_id"]}.'))
+
+ def check_already_exists(self):
+ """Checks if the user already exists in the database."""
+
+ existing_user = DB.fetch_one({'googleId': self.obj['googleId']}, self.collection_name)
+
+ if existing_user is not None:
+ raise ClientError(Response(409, 'User already exists.'))
diff --git a/opendc-web/opendc-web-api/opendc/util/__init__.py b/opendc-web/opendc-web-api/opendc/util/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util/__init__.py
diff --git a/opendc-web/opendc-web-api/opendc/util/database.py b/opendc-web/opendc-web-api/opendc/util/database.py
new file mode 100644
index 00000000..dd26533d
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util/database.py
@@ -0,0 +1,77 @@
+import urllib.parse
+from datetime import datetime
+
+from pymongo import MongoClient
+
+DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S'
+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):
+ """Initializes the database connection."""
+
+ 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
+
+ def fetch_one(self, query, collection):
+ """Uses existing mongo connection to return a single (the first) document in a collection matching the given
+ query as a JSON object.
+
+ The query needs to be in json format, i.e.: `{'name': prefab_name}`.
+ """
+ return getattr(self.opendc_db, collection).find_one(query)
+
+ def fetch_all(self, query, collection):
+ """Uses existing mongo connection to return all documents matching a given query, as a list of JSON objects.
+
+ The query needs to be in json format, i.e.: `{'name': prefab_name}`.
+ """
+ cursor = getattr(self.opendc_db, collection).find(query)
+ return list(cursor)
+
+ def insert(self, obj, collection):
+ """Updates an existing object."""
+ bson = getattr(self.opendc_db, collection).insert(obj)
+
+ return bson
+
+ def update(self, _id, obj, collection):
+ """Updates an existing object."""
+ return getattr(self.opendc_db, collection).update({'_id': _id}, obj)
+
+ def delete_one(self, query, collection):
+ """Deletes one object matching the given query.
+
+ The query needs to be in json format, i.e.: `{'name': prefab_name}`.
+ """
+ getattr(self.opendc_db, collection).delete_one(query)
+
+ def delete_all(self, query, collection):
+ """Deletes all objects matching the given query.
+
+ The query needs to be in json format, i.e.: `{'name': prefab_name}`.
+ """
+ getattr(self.opendc_db, collection).delete_many(query)
+
+ @staticmethod
+ def datetime_to_string(datetime_to_convert):
+ """Return a database-compatible string representation of the given datetime object."""
+ return datetime_to_convert.strftime(DATETIME_STRING_FORMAT)
+
+ @staticmethod
+ 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/util/exceptions.py b/opendc-web/opendc-web-api/opendc/util/exceptions.py
new file mode 100644
index 00000000..7724a407
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util/exceptions.py
@@ -0,0 +1,64 @@
+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
new file mode 100644
index 00000000..2ef4f965
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util/json.py
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 00000000..14dd1dc0
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util/parameter_checker.py
@@ -0,0 +1,85 @@
+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
new file mode 100644
index 00000000..c8452f20
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util/path_parser.py
@@ -0,0 +1,36 @@
+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
new file mode 100644
index 00000000..c9e98295
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util/rest.py
@@ -0,0 +1,141 @@
+import importlib
+import json
+import os
+
+from oauth2client import client, crypt
+
+from opendc.util import exceptions, parameter_checker
+from opendc.util.exceptions import ClientError
+
+
+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))
+
+ # Verify the user
+
+ if "OPENDC_FLASK_TESTING" in os.environ:
+ self.google_id = 'test'
+ return
+
+ try:
+ self.google_id = self._verify_token(self.token)
+ except crypt.AppIdentityError as e:
+ raise exceptions.AuthorizationTokenError(e)
+
+ 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)
+
+ @staticmethod
+ def _verify_token(token):
+ """Return the ID of the signed-in user.
+
+ Or throw an Exception if the token is invalid.
+ """
+
+ try:
+ id_info = client.verify_id_token(token, os.environ['OPENDC_OAUTH_CLIENT_ID'])
+ except Exception as e:
+ print(e)
+ raise crypt.AppIdentityError('Exception caught trying to verify ID token: {}'.format(e))
+
+ if id_info['aud'] != os.environ['OPENDC_OAUTH_CLIENT_ID']:
+ raise crypt.AppIdentityError('Unrecognized client.')
+
+ if id_info['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
+ raise crypt.AppIdentityError('Wrong issuer.')
+
+ return id_info['sub']
+
+
+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)