From dc142bca0c136221d01dde06787e19841a5555e3 Mon Sep 17 00:00:00 2001 From: Tobias Date: Fri, 31 Jul 2020 16:33:02 +0200 Subject: [PATCH] some changes to virtual command api, but its not yet clear how this should function --- backend/api/auth_api.py | 5 +- backend/api/models.py | 11 +++- backend/api/virtual_command_api.py | 52 ++++++++++------ backend/config.py | Bin 5228 -> 5644 bytes backend/manage.py | 23 +++++++ backend/models/user_model.py | 88 +++++++++++++++++--------- migrations/README | 1 + migrations/alembic.ini | 45 ++++++++++++++ migrations/env.py | 96 +++++++++++++++++++++++++++++ migrations/script.py.mako | 24 ++++++++ 10 files changed, 294 insertions(+), 51 deletions(-) create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index 516b9e5..82ceff5 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -109,6 +109,7 @@ def logout(): # Endpoint for revoking the current users refresh token @auth_api_bp.route('/logout2', methods=['GET', 'DELETE']) +@auth_api_bp.route('/revokeRefreshToken', methods=['GET', 'DELETE']) @jwt_refresh_token_required def logout2(): jti = get_raw_jwt()['jti'] @@ -137,6 +138,7 @@ def create_or_retrieve_user_from_userinfo(userinfo): logger.error("email is missing in OIDC userinfo! Can't create user!") return None + pprint(userinfo) user_groups = check_and_create_groups(groups=userinfo.get("memberOf", [])) user = User.get_by_identifier(email) @@ -145,6 +147,7 @@ def create_or_retrieve_user_from_userinfo(userinfo): pprint(user.to_dict()) user.first_name = userinfo.get("given_name", "") user.last_name = userinfo.get("family_name", "") + user.external_user_id = userinfo.get("eduperson_principal_name", None) for g in user_groups: user.groups.append(g) db.session.commit() @@ -152,7 +155,7 @@ def create_or_retrieve_user_from_userinfo(userinfo): user = User(email=email, first_name=userinfo.get("given_name", ""), last_name=userinfo.get("family_name", ""), external_user=True, - groups=user_groups) + groups=user_groups, external_user_id=userinfo.get("eduperson_principal_name", None)) logger.info("creating new user") diff --git a/backend/api/models.py b/backend/api/models.py index e82a41e..a35f81d 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -12,9 +12,15 @@ user_model = api_user.model('User', { 'nickname': fields.String(required=False, description='The user\'s nick name'), 'last_seen': fields.DateTime(required=False, description='Last time user logged in'), 'last_time_modified': fields.DateTime(required=False, description='Last time user was modified'), + 'external_user': fields.Boolean(required=True, description='Indicates whether the user is external (OIDC) or not'), + 'external_user_id': fields.String(required=False, description='External ID of a user (EPPN, etc.)'), 'role': fields.String(required=False, description='Role a user might have (in addition to group memberships)'), 'effective_permissions': fields.List( - fields.String(required=True), required=False, description="List of permissions (groups + (optional) role)." + fields.Nested(api_user.model('effective_permission', + {'id': fields.Integer(required=True), + 'name': fields.String(required=True) + }), + required=False, description="List of permissions (groups + (optional) role).") ), 'groups': fields.List( fields.Nested(api_user.model('user_group', {'id': fields.Integer(), 'name': fields.String()})), @@ -45,7 +51,8 @@ recorder_model = api_recorder.model('Recorder', { 'additional_camera_connected': fields.Boolean(required=False, description='Indicates whether an additional camera is connected'), 'ip': fields.String(required=False, description='The recorder\'s IP address'), - 'mac': fields.String(required=False, description='The recorder\'s IP address'), + 'ip6': fields.String(required=False, description='The recorder\'s IP v6 address'), + 'mac': fields.String(required=False, description='The recorder\'s MAC address'), 'network_name': fields.String(required=False, description='The recorder\'s network name'), 'ssh_port': fields.Integer(required=True, default=22, description='The recorder\'s SSH port number'), 'telnet_port': fields.Integer(required=True, default=23, description='The recorder\'s telnet port number'), diff --git a/backend/api/virtual_command_api.py b/backend/api/virtual_command_api.py index 3aec647..e3b6661 100644 --- a/backend/api/virtual_command_api.py +++ b/backend/api/virtual_command_api.py @@ -14,6 +14,7 @@ from flask_restx import fields, Resource from backend import db, app from backend.api import api_virtual_command +from backend.models import VirtualCommand from backend.models.recorder_model import Recorder, RecorderModel, RecorderCommand from backend.models.room_model import Room import backend.recorder_adapters as r_a @@ -109,26 +110,37 @@ class RecorderList(Resource): @api_virtual_command.expect(virtual_command_model_parser) @api_virtual_command.marshal_with(virtual_command_model, skip_none=False, code=201) def post(self): - if "room_id" in api_virtual_command.payload: - if api_virtual_command.payload["room_id"] is None: - api_virtual_command.payload["room"] = None + pprint(api_virtual_command.payload) + room_id = api_virtual_command.payload.pop('recorder_id', None) + if room_id is None: + api_virtual_command.payload["room"] = None + else: + room = Room.query.get(room_id) + if room is not None: + api_virtual_command.payload["room"] = room else: - room = Room.query.get(api_virtual_command.payload["room_id"]) - if room is not None: - api_virtual_command.payload["room"] = room - else: - return "specified room (id: {}) does not exist!".format(api_virtual_command.payload["room_id"]), 404 - if "recorder_model_id" in api_virtual_command.payload: - if api_virtual_command.payload["recorder_model_id"] is None: - api_virtual_command.payload["recorder_model"] = None + return "specified room (id: {}) does not exist!".format(api_virtual_command.payload["room_id"]), 404 + recorder_model_id = api_virtual_command.payload.pop('recorder_model_id', None) + if recorder_model_id is None: + api_virtual_command.payload["recorder_model"] = None + else: + rec_model = RecorderModel.query.get(recorder_model_id) + if rec_model is not None: + api_virtual_command.payload["recorder_model"] = rec_model else: - rec_model = RecorderModel.query.get(api_virtual_command.payload["recorder_model_id"]) - if rec_model is not None: - api_virtual_command.payload["recorder_model"] = rec_model - else: - return "specified recorder model (id: {}) does not exist!".format( - api_virtual_command.payload["recorder_model_id"]), 404 - recorder = Recorder(**api_virtual_command.payload) - db.session.add(recorder) + return "specified recorder model (id: {}) does not exist!".format( + api_virtual_command.payload["recorder_model_id"]), 404 + recorder_id = api_virtual_command.payload.pop('recorder_id', None) + if recorder_id is None: + api_virtual_command.payload["recorder"] = None + else: + recorder = Recorder.query.get(recorder_id) + if recorder is not None: + api_virtual_command.payload["recorder"] = recorder + else: + return "specified recorder (id: {}) does not exist!".format( + api_virtual_command.payload["recorder_id"]), 404 + virtual_command = VirtualCommand(**api_virtual_command.payload) + db.session.add(virtual_command) db.session.commit() - return recorder + return virtual_command diff --git a/backend/config.py b/backend/config.py index 3525ee4f9b84bd04ba91493e3c22cafb68c4fef9..d2a63a96adbbe3bf02134803834bb33f5be12c47 100644 GIT binary patch literal 5644 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

hO*0BUC7@E&8ULJWdMR1Fs5aY0km53OUfU@e~W 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