From 24c40e53e33559c824fae8522e47cc489193b3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20F=C3=BCrneisen?= Date: Fri, 9 Aug 2024 16:10:33 +0200 Subject: [PATCH] Add backend endpoints for managing edit sessions. --- tests/conftest.py | 56 +++ tests/edit_session/__init__.py | 0 tests/edit_session/api/__init__.py | 0 .../edit_session/api/integration/__init__.py | 0 .../integration/delete_participant_test.py | 86 +++++ .../integration/get_sessions_owner_test.py | 63 ++++ .../get_sessions_participant_test.py | 76 ++++ .../integration/patch_edit_session_test.py | 73 ++++ .../api/integration/put_edit_session_test.py | 58 +++ .../api/integration/put_participant_test.py | 113 ++++++ .../edit_session/api/integration/requests.py | 66 ++++ .../integration/search_participants_test.py | 57 +++ tests/edit_session/common.py | 15 + tests/edit_session/conftest.py | 35 ++ tests/entity/queue_test.py | 3 + tests/user/api/integration/post_login_test.py | 13 + .../user/api/integration/post_refresh_test.py | 13 + .../api/integration/post_register_test.py | 6 + tests/user/api/integration/requests.py | 9 + .../api/integration/set_edit_session_test.py | 65 ++++ tests/user/conftest.py | 11 + vran/edit_session/api.py | 348 ++++++++++++++++++ vran/edit_session/models_django.py | 100 +++++ vran/entity/queue.py | 1 + ...tsession_vranuser_edit_session_and_more.py | 70 ++++ vran/migrations/0011_default_edit_session.py | 44 +++ vran/models.py | 1 + vran/urls.py | 2 + vran/user/api.py | 76 +++- vran/user/models_api/login.py | 2 + vran/util/__init__.py | 8 + 31 files changed, 1457 insertions(+), 13 deletions(-) create mode 100644 tests/edit_session/__init__.py create mode 100644 tests/edit_session/api/__init__.py create mode 100644 tests/edit_session/api/integration/__init__.py create mode 100644 tests/edit_session/api/integration/delete_participant_test.py create mode 100644 tests/edit_session/api/integration/get_sessions_owner_test.py create mode 100644 tests/edit_session/api/integration/get_sessions_participant_test.py create mode 100644 tests/edit_session/api/integration/patch_edit_session_test.py create mode 100644 tests/edit_session/api/integration/put_edit_session_test.py create mode 100644 tests/edit_session/api/integration/put_participant_test.py create mode 100644 tests/edit_session/api/integration/requests.py create mode 100644 tests/edit_session/api/integration/search_participants_test.py create mode 100644 tests/edit_session/common.py create mode 100644 tests/edit_session/conftest.py create mode 100644 tests/user/api/integration/set_edit_session_test.py create mode 100644 vran/edit_session/api.py create mode 100644 vran/edit_session/models_django.py create mode 100644 vran/migrations/0010_editsession_vranuser_edit_session_and_more.py create mode 100644 vran/migrations/0011_default_edit_session.py diff --git a/tests/conftest.py b/tests/conftest.py index 77158ba8..531eb219 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,10 +7,12 @@ from django.db import IntegrityError from pytest_redis import factories +from tests.edit_session import common as cs from tests.entity import common as ce from tests.tag import common as ct from tests.user import common as cu from tests.user.api.integration.requests import post_login, post_register +from vran.edit_session.models_django import EditSession, EditSessionParticipant from vran.entity.models_django import Entity, EntityJustification from vran.management.display_txt.util import DISPLAY_TXT_ORDER_CONFIG_KEY from vran.management.models_django import ConfigValue @@ -202,6 +204,11 @@ def auth_server_commissioner(live_server, user_commissioner): @pytest.fixture def user(db): # pylint: disable=unused-argument + session = EditSession.objects.create( + id_persistent=cs.id_session_user, + id_owner_persistent=cu.test_uuid, + name=cs.name_session_user, + ) try: user = VranUser.objects.create_user( username=cu.test_username, @@ -210,6 +217,13 @@ def user(db): # pylint: disable=unused-argument first_name=cu.test_names_personal, id_persistent=cu.test_uuid, permission_group=VranUser.CONTRIBUTOR, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -219,12 +233,24 @@ def user(db): # pylint: disable=unused-argument @pytest.fixture def user1(db): # pylint: disable=unused-argument try: + session = EditSession.objects.create( + id_persistent=cs.id_session_user1, + id_owner_persistent=cu.test_uuid1, + name=cs.name_session_user1, + ) user = VranUser.objects.create_user( username=cu.test_username1, password=cu.test_password1, email=cu.test_email1, first_name=cu.test_names_personal1, id_persistent=cu.test_uuid1, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -234,6 +260,11 @@ def user1(db): # pylint: disable=unused-argument @pytest.fixture def user_commissioner(db): # pylint: disable=unused-argument try: + session = EditSession.objects.create( + id_persistent=cs.id_session_commissioner, + id_owner_persistent=cu.test_uuid_commissioner, + name=cs.name_session_commissioner, + ) user = VranUser.objects.create_user( username=cu.test_username_commissioner, password=cu.test_password_commissioner, @@ -241,6 +272,13 @@ def user_commissioner(db): # pylint: disable=unused-argument first_name=cu.test_names_personal_commissioner, id_persistent=cu.test_uuid_commissioner, permission_group=VranUser.COMMISSIONER, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -252,6 +290,11 @@ def user_commissioner(db): # pylint: disable=unused-argument @pytest.fixture def user_editor(db): # pylint: disable=unused-argument try: + session = EditSession.objects.create( + id_persistent=cs.id_session_editor, + id_owner_persistent=cu.test_uuid_editor, + name=cs.name_session_editor, + ) user = VranUser.objects.create_user( username=cu.test_username_editor, password=cu.test_password_editor, @@ -259,6 +302,13 @@ def user_editor(db): # pylint: disable=unused-argument first_name=cu.test_names_personal_editor, id_persistent=cu.test_uuid_editor, permission_group=VranUser.EDITOR, + edit_session=session, + ) + EditSessionParticipant.add( + id_session_persistent=session.id_persistent, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, ) return user except IntegrityError: @@ -269,11 +319,17 @@ def user_editor(db): # pylint: disable=unused-argument @pytest.fixture def super_user(db): # pylint: disable=unused-argument + session = EditSession.objects.create( + id_persistent=cs.id_session_super, + id_owner_persistent=cu.test_uuid_super, + name=cs.name_session_super, + ) super_user = VranUser.objects.create_superuser( email=cu.test_email_super, username=cu.test_username_super, password=cu.test_password, id_persistent=cu.test_uuid_super, + edit_session=session, ) return super_user diff --git a/tests/edit_session/__init__.py b/tests/edit_session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/edit_session/api/__init__.py b/tests/edit_session/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/edit_session/api/integration/__init__.py b/tests/edit_session/api/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/edit_session/api/integration/delete_participant_test.py b/tests/edit_session/api/integration/delete_participant_test.py new file mode 100644 index 00000000..c8e186ce --- /dev/null +++ b/tests/edit_session/api/integration/delete_participant_test.py @@ -0,0 +1,86 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements,duplicate-code + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.edit_session.models_django import EditSessionParticipant +from vran.exception import NotAuthenticatedException + +new_name = "patched name for user session" + + +def test_unknown_user(auth_server, session_participant_commissioner): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server, session_participant_commissioner): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=None + ) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant, session_participant_commissioner): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_wrong_user(auth_server1, session_participant_commissioner): + server, _cookies, cookies = auth_server1 + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_remove_as_owner(auth_server, session_participant_commissioner): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 2 + ) + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 200 + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 1 + ) + + +def test_remove_as_participant( + user, auth_server_commissioner, session_participant_commissioner +): + "Make sure it can retrieve sessions owned by a user" + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 2 + ) + server, cookies = auth_server_commissioner + rsp = req.delete_participant( + server.url, c.id_session_user, cu.test_uuid_commissioner, cookies=cookies + ) + assert rsp.status_code == 200 + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 1 + ) diff --git a/tests/edit_session/api/integration/get_sessions_owner_test.py b/tests/edit_session/api/integration/get_sessions_owner_test.py new file mode 100644 index 00000000..aa3fcf74 --- /dev/null +++ b/tests/edit_session/api/integration/get_sessions_owner_test.py @@ -0,0 +1,63 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.get_sessions_owner(server.url, cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.get_sessions_owner(server.url, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.get_sessions_owner(server.url, cookies=cookies) + assert rsp.status_code == 403 + + +def test_get_sessions_owner(auth_server, other_session): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server + rsp = req.get_sessions_owner(server.url, cookies=cookies) + assert rsp.status_code == 200 + participant_json = { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + owner_json = participant_json.copy() + owner_json.pop("name_participant") + json = rsp.json() + session_list = json["edit_session_list"] + assert session_list == [ + { + "id_persistent": c.id_session_user, + "owner": owner_json, + "name": c.name_session_user, + "participant_list": [participant_json], + }, + { + "id_persistent": c.id_session_user_changed, + "owner": owner_json, + "name": c.name_session_user_changed, + "participant_list": [participant_json], + }, + ] diff --git a/tests/edit_session/api/integration/get_sessions_participant_test.py b/tests/edit_session/api/integration/get_sessions_participant_test.py new file mode 100644 index 00000000..9f277c6c --- /dev/null +++ b/tests/edit_session/api/integration/get_sessions_participant_test.py @@ -0,0 +1,76 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.get_sessions_participant(server.url, cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.get_sessions_participant(server.url, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.get_sessions_participant(server.url, cookies=cookies) + assert rsp.status_code == 403 + + +def test_get_sessions_participant( + auth_server_commissioner, + other_session, + session_participant_commissioner, + other_session_participant_commissioner, +): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server_commissioner + rsp = req.get_sessions_participant(server.url, cookies=cookies) + assert rsp.status_code == 200 + participant_json = { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + owner_json = participant_json.copy() + owner_json.pop("name_participant") + participant_list = [ + participant_json, + { + "id_participant": cu.test_uuid_commissioner, + "type_participant": "INTERNAL", + "name_participant": cu.test_username_commissioner, + }, + ] + json = rsp.json() + session_list = json["edit_session_list"] + assert session_list == [ + { + "id_persistent": c.id_session_user, + "owner": owner_json, + "name": c.name_session_user, + "participant_list": participant_list, + }, + { + "id_persistent": c.id_session_user_changed, + "owner": owner_json, + "name": c.name_session_user_changed, + "participant_list": participant_list, + }, + ] diff --git a/tests/edit_session/api/integration/patch_edit_session_test.py b/tests/edit_session/api/integration/patch_edit_session_test.py new file mode 100644 index 00000000..1476038f --- /dev/null +++ b/tests/edit_session/api/integration/patch_edit_session_test.py @@ -0,0 +1,73 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements,duplicate-code + + +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + +new_name = "patched name for user session" + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.patch_edit_session(server.url, c.id_session_user, new_name, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_wrong_user(auth_server1): + server, _cookies, cookies = auth_server1 + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 403 + + +def test_change_name(auth_server): + "Make sure it can retrieve sessions owned by a user" + server, cookies = auth_server + rsp = req.patch_edit_session( + server.url, c.id_session_user, new_name, cookies=cookies + ) + assert rsp.status_code == 200 + participant_json = { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + owner_json = participant_json.copy() + owner_json.pop("name_participant") + participant_list = [ + participant_json, + ] + json = rsp.json() + assert json == { + "id_persistent": c.id_session_user, + "owner": owner_json, + "name": new_name, + "participant_list": participant_list, + } diff --git a/tests/edit_session/api/integration/put_edit_session_test.py b/tests/edit_session/api/integration/put_edit_session_test.py new file mode 100644 index 00000000..1367b93e --- /dev/null +++ b/tests/edit_session/api/integration/put_edit_session_test.py @@ -0,0 +1,58 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException +from vran.util import VranUser + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.put_edit_session(server.url, cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.put_edit_session(server.url, cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicants can not create sessions" + server, cookies = auth_server_applicant + rsp = req.put_edit_session(server.url, cookies=cookies) + assert rsp.status_code == 403 + + +def test_create_edit_session(auth_server): + mock = MagicMock() + mock.return_value = c.id_session_user_changed + server, cookies = auth_server + with patch("vran.edit_session.api.uuid4", mock): + rsp = req.put_edit_session(server.url, cookies=cookies) + user = VranUser.objects.filter(id_persistent=cu.test_uuid).get() + assert user.edit_session_id == c.id_session_user_changed + assert rsp.status_code == 200 + assert rsp.json() == { + "name": "Edit Session 1", + "id_persistent": c.id_session_user_changed, + "owner": { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + }, + "participant_list": [ + { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + "name_participant": user.username, + } + ], + } diff --git a/tests/edit_session/api/integration/put_participant_test.py b/tests/edit_session/api/integration/put_participant_test.py new file mode 100644 index 00000000..8a19671e --- /dev/null +++ b/tests/edit_session/api/integration/put_participant_test.py @@ -0,0 +1,113 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as c +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.edit_session.models_django import EditSessionParticipant +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Test response when cookies are missing" + server, _cookies = auth_server + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=None, + ) + assert rsp.status_code == 401 + + +def test_wrong_user(auth_server1, user): + "Make sure you can not edit others edit sessions." + server, _cookies, cookies = auth_server1 + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 403 + + +def test_applicant(auth_server_applicant): + "Make sure applicants can not add edit session participants" + server, cookies = auth_server_applicant + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 403 + + +def test_can_add(auth_server): + "Make sure you can add to your own session." + server, cookies = auth_server + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid1, + cu.test_username1, + cookies=cookies, + ) + assert rsp.status_code == 200 + assert rsp.json() == { + "id_participant": cu.test_uuid1, + "name_participant": cu.test_username1, + "type_participant": "INTERNAL", + } + + +def test_idempotent(auth_server): + "Make sure participant is not added twice." + server, cookies = auth_server + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid, + cu.test_username, + cookies=cookies, + ) + assert rsp.status_code == 200 + rsp = req.put_participant( + server.url, + c.id_session_user, + "INTERNAL", + cu.test_uuid, + cu.test_username, + cookies=cookies, + ) + assert rsp.status_code == 200 + assert ( + len(EditSessionParticipant.objects.filter(edit_session_id=c.id_session_user)) + == 1 + ) diff --git a/tests/edit_session/api/integration/requests.py b/tests/edit_session/api/integration/requests.py new file mode 100644 index 00000000..a7518c7f --- /dev/null +++ b/tests/edit_session/api/integration/requests.py @@ -0,0 +1,66 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + +import requests + + +def put_edit_session(url, cookies=None): + return requests.put(url + "/vran/api/edit_sessions", cookies=cookies, timeout=900) + + +def put_participant( + url, + id_edit_session_persistent, + type_participant, + id_participant, + name_participant, + cookies=None, +): + return requests.put( + url + f"/vran/api/edit_sessions/{id_edit_session_persistent}/participants", + json={ + "type_participant": type_participant, + "id_participant": id_participant, + "name_participant": name_participant, + }, + cookies=cookies, + timeout=900, + ) + + +def post_search_participant(url, search_term, cookies=None): + return requests.post( + url + "/vran/api/edit_sessions/search", + json={"search_term": search_term}, + cookies=cookies, + timeout=900, + ) + + +def get_sessions_owner(url, cookies=None): + return requests.get( + url + "/vran/api/edit_sessions/owner", cookies=cookies, timeout=900 + ) + + +def get_sessions_participant(url, cookies=None): + return requests.get( + url + "/vran/api/edit_sessions/participant", cookies=cookies, timeout=900 + ) + + +def patch_edit_session(url, id_edit_session_persistent, name, cookies=None): + return requests.patch( + url + f"/vran/api/edit_sessions/{id_edit_session_persistent}", + json={"name": name}, + cookies=cookies, + timeout=900, + ) + + +def delete_participant(url, id_edit_session, id_participant, cookies=None): + return requests.delete( + url + f"/vran/api/edit_sessions/{id_edit_session}/participants", + json={"id_participant": id_participant, "type_participant": "INTERNAL"}, + cookies=cookies, + timeout=900, + ) diff --git a/tests/edit_session/api/integration/search_participants_test.py b/tests/edit_session/api/integration/search_participants_test.py new file mode 100644 index 00000000..1d17f923 --- /dev/null +++ b/tests/edit_session/api/integration/search_participants_test.py @@ -0,0 +1,57 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements + + +from unittest.mock import MagicMock, patch + +import tests.user.common as cu +from tests.edit_session.api.integration import requests as req +from vran.exception import NotAuthenticatedException + + +def test_unknown_user(auth_server): + "Test response when user can not be authenticated" + server, cookies = auth_server + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + with patch("vran.edit_session.api.check_user", mock): + rsp = req.post_search_participant(server.url, "", cookies=cookies) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Check error code for missing cookies" + server, _cookies = auth_server + rsp = req.post_search_participant(server.url, "", cookies=None) + assert rsp.status_code == 401 + + +def test_applicant(auth_server_applicant): + "Make sure applicant can not search for participants." + server, cookies = auth_server_applicant + rsp = req.post_search_participant(server.url, "", cookies=cookies) + assert rsp.status_code == 403 + + +def test_search_participants(auth_server, user1, user_editor): + server, cookies = auth_server + rsp = req.post_search_participant(server.url, "", cookies=cookies) + assert rsp.status_code == 200 + json = rsp.json() + search_result_list = json["search_result_list"] + assert len(search_result_list) == 3 + search_result_map = {user["id_participant"]: user for user in search_result_list} + assert search_result_map[cu.test_uuid] == { + "id_participant": cu.test_uuid, + "type_participant": "INTERNAL", + "name_participant": cu.test_username, + } + assert search_result_map[cu.test_uuid1] == { + "id_participant": cu.test_uuid1, + "type_participant": "INTERNAL", + "name_participant": cu.test_username1, + } + assert search_result_map[cu.test_uuid_editor] == { + "id_participant": cu.test_uuid_editor, + "type_participant": "INTERNAL", + "name_participant": cu.test_username_editor, + } diff --git a/tests/edit_session/common.py b/tests/edit_session/common.py new file mode 100644 index 00000000..5230ffe9 --- /dev/null +++ b/tests/edit_session/common.py @@ -0,0 +1,15 @@ +# pylint: disable=missing-module-docstring, missing-function-docstring,redefined-outer-name,invalid-name,unused-argument +id_session_user = "40815019-eed0-455b-9b19-cd19f5c119e7" +id_session_user_changed = "28415c17-0bba-4648-b67e-dd3904d54e40" +id_session_user1 = "93b1cac1-e958-4b4f-8d2e-e7323d4d0633" +id_session_editor = "a88b5c90-36c6-4a46-ae89-f6e29531f00f" +id_session_commissioner = "5086f3b3-4306-417d-b51f-180365a60adc" +id_session_super = "0a16ad58-853d-49fc-b2db-7c42ae99ddca" + + +name_session_user = "Test Edit Session User" +name_session_user_changed = "Changed Test Edit Session User" +name_session_user1 = "Test Edit Session User 1" +name_session_editor = "Test Edit Session Editor" +name_session_commissioner = "Test Edit Session Commissioner" +name_session_super = "Test Edit Session Super User" diff --git a/tests/edit_session/conftest.py b/tests/edit_session/conftest.py new file mode 100644 index 00000000..bf83ffb8 --- /dev/null +++ b/tests/edit_session/conftest.py @@ -0,0 +1,35 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,no-member,redefined-outer-name,unused-argument + +import pytest + +import tests.edit_session.common as c +from vran.edit_session.models_django import EditSession, EditSessionParticipant + + +@pytest.fixture() +def other_session(user): + return EditSession.create( + id_persistent=c.id_session_user_changed, + name=c.name_session_user_changed, + user=user, + ) + + +@pytest.fixture() +def session_participant_commissioner(user, user_commissioner): + return EditSessionParticipant.add( + id_session_persistent=c.id_session_user, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user_commissioner.id_persistent, + name_participant=user_commissioner.username, + ) + + +@pytest.fixture() +def other_session_participant_commissioner(user, user_commissioner): + return EditSessionParticipant.add( + id_session_persistent=c.id_session_user_changed, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user_commissioner.id_persistent, + name_participant=user_commissioner.username, + ) diff --git a/tests/entity/queue_test.py b/tests/entity/queue_test.py index bd762cc0..4a5d073a 100644 --- a/tests/entity/queue_test.py +++ b/tests/entity/queue_test.py @@ -92,6 +92,7 @@ def test_without_display_txt_but_relevant_tag_instance( "permission_group": "APPLICANT", "id_persistent": user1.id_persistent, }, + "description": None, "curated": False, "hidden": False, "disabled": False, @@ -182,6 +183,7 @@ def test_contribution(contribution_instance_without_display_txt, display_txt_ord "id_persistent": cu.test_uuid1, "permission_group": "APPLICANT", }, + "description": None, "curated": False, "hidden": False, "disabled": False, @@ -208,6 +210,7 @@ def test_db_to_dict(tag_def): "curated": False, "hidden": False, "disabled": True, + "description": None, }, version_key="id", ) diff --git a/tests/user/api/integration/post_login_test.py b/tests/user/api/integration/post_login_test.py index 8a3b0363..1e20a4ac 100644 --- a/tests/user/api/integration/post_login_test.py +++ b/tests/user/api/integration/post_login_test.py @@ -1,4 +1,5 @@ # pylint: disable=missing-module-docstring, missing-function-docstring,redefined-outer-name,invalid-name,duplicate-code +import tests.edit_session.common as cs import tests.user.common as c from tests.user.api.integration.requests import post_login @@ -26,4 +27,16 @@ def test_valid_credentials(auth_server): "tag_definition_list": [], "id_persistent": c.test_uuid, "permission_group": "CONTRIBUTOR", + "edit_session": { + "id_persistent": cs.id_session_user, + "name": cs.name_session_user, + "owner": {"type_participant": "INTERNAL", "id_participant": c.test_uuid}, + "participant_list": [ + { + "id_participant": c.test_uuid, + "type_participant": "INTERNAL", + "name_participant": c.test_username, + } + ], + }, } diff --git a/tests/user/api/integration/post_refresh_test.py b/tests/user/api/integration/post_refresh_test.py index 6e79e1c5..462a9809 100644 --- a/tests/user/api/integration/post_refresh_test.py +++ b/tests/user/api/integration/post_refresh_test.py @@ -1,4 +1,5 @@ # pylint: disable=missing-module-docstring, missing-function-docstring,redefined-outer-name,invalid-name,duplicate-code +import tests.edit_session.common as cs import tests.user.common as c from tests.user.api.integration.requests import get_refresh @@ -21,4 +22,16 @@ def test_logged_in(auth_server): "tag_definition_list": [], "id_persistent": c.test_uuid, "permission_group": "CONTRIBUTOR", + "edit_session": { + "id_persistent": cs.id_session_user, + "name": cs.name_session_user, + "owner": {"type_participant": "INTERNAL", "id_participant": c.test_uuid}, + "participant_list": [ + { + "id_participant": c.test_uuid, + "type_participant": "INTERNAL", + "name_participant": c.test_username, + } + ], + }, } diff --git a/tests/user/api/integration/post_register_test.py b/tests/user/api/integration/post_register_test.py index 1d48ace8..9b65e2dc 100644 --- a/tests/user/api/integration/post_register_test.py +++ b/tests/user/api/integration/post_register_test.py @@ -61,6 +61,12 @@ def test_same_names(auth_server): "tag_definition_list": [], "id_persistent": str(uuid), "permission_group": "APPLICANT", + "edit_session": { + "id_persistent": str(uuid), + "name": "Default Edit Session", + "owner": {"type_participant": "INTERNAL", "id_participant": str(uuid)}, + "participant_list": [], + }, } diff --git a/tests/user/api/integration/requests.py b/tests/user/api/integration/requests.py index 55b02eaa..03b70145 100644 --- a/tests/user/api/integration/requests.py +++ b/tests/user/api/integration/requests.py @@ -64,3 +64,12 @@ def put_permission_group(url, id_user_persistent, permission_group, cookies=None cookies=cookies, timeout=900, ) + + +def post_edit_session(url, id_edit_session_persistent, cookies=None): + return requests.post( + url + "/vran/api/user/edit_session", + json={"id_edit_session_persistent": id_edit_session_persistent}, + cookies=cookies, + timeout=900, + ) diff --git a/tests/user/api/integration/set_edit_session_test.py b/tests/user/api/integration/set_edit_session_test.py new file mode 100644 index 00000000..d09c1993 --- /dev/null +++ b/tests/user/api/integration/set_edit_session_test.py @@ -0,0 +1,65 @@ +# pylint: disable=missing-module-docstring,redefined-outer-name,invalid-name,unused-argument,too-many-locals,too-many-arguments,too-many-statements +from unittest.mock import MagicMock, patch + +import tests.edit_session.common as ce +import tests.user.api.integration.requests as req +import tests.user.common as cu +from vran.exception import NotAuthenticatedException +from vran.util import VranUser + + +def test_unknown_user(auth_server): + "Test response, when user can not be authenticated" + mock = MagicMock() + mock.side_effect = NotAuthenticatedException() + server, cookies = auth_server + with patch("vran.user.api.check_user", mock): + rsp = req.post_edit_session( + server.url, ce.id_session_user_changed, cookies=cookies + ) + assert rsp.status_code == 401 + + +def test_no_cookies(auth_server): + "Test response when cookies are missing" + server, _cookies = auth_server + rsp = req.post_edit_session(server.url, ce.id_session_user_changed) + assert rsp.status_code == 401 + + +def test_not_owner(auth_server_commissioner, other_session): + "Test response when setting session where the user is not the owner" + server, cookies = auth_server_commissioner + rsp = req.post_edit_session(server.url, ce.id_session_user_changed, cookies=cookies) + assert rsp.status_code == 403 + + +def test_no_session(auth_server): + "Test response when session does not exist" + server, cookies = auth_server + rsp = req.post_edit_session(server.url, ce.id_session_user_changed, cookies=cookies) + assert rsp.status_code == 404 + + +def test_can_change_session(auth_server, other_session): + "Test that session is changed correctly" + server, cookies = auth_server + rsp = req.post_edit_session(server.url, ce.id_session_user_changed, cookies=cookies) + user = VranUser.objects.filter(id_persistent=cu.test_uuid).get() + assert user.edit_session_id == other_session.id_persistent + assert rsp.status_code == 200 + assert rsp.json() == { + "name": ce.name_session_user_changed, + "id_persistent": ce.id_session_user_changed, + "owner": { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + }, + "participant_list": [ + { + "id_participant": user.id_persistent, + "type_participant": "INTERNAL", + "name_participant": user.username, + } + ], + } diff --git a/tests/user/conftest.py b/tests/user/conftest.py index fe1386d9..d78e5706 100644 --- a/tests/user/conftest.py +++ b/tests/user/conftest.py @@ -1,7 +1,9 @@ # pylint: disable=missing-module-docstring,missing-function-docstring,no-member,redefined-outer-name import pytest +import tests.edit_session.common as cs import tests.user.common as c +from vran.edit_session.models_django import EditSession from vran.tag.models_django import TagDefinition, TagDefinitionHistory @@ -72,3 +74,12 @@ def user_with_tag_defs( ] user.save() return user + + +@pytest.fixture() +def other_session(user): + return EditSession.create( + id_persistent=cs.id_session_user_changed, + name=cs.name_session_user_changed, + user=user, + ) diff --git a/vran/edit_session/api.py b/vran/edit_session/api.py new file mode 100644 index 00000000..5e8f8f93 --- /dev/null +++ b/vran/edit_session/api.py @@ -0,0 +1,348 @@ +"API models for edit sessions" +from typing import List +from uuid import uuid4 + +from django.http import HttpRequest +from ninja import Router, Schema + +from vran.edit_session.models_django import EditSession as EditSessionDb +from vran.edit_session.models_django import ( + EditSessionParticipant as EditSessionParticipantDb, +) +from vran.exception import ApiError, NotAuthenticatedException +from vran.util import VranUser +from vran.util.auth import check_user + +router = Router() + + +class EditSessionParticipant(Schema): + # pylint: disable=too-few-public-methods + "API model for edit session participants" + type_participant: str + id_participant: str + + +class EditSessionParticipantWithName(EditSessionParticipant): + # pylint: disable=too-few-public-methods + "Edit session participant with name." + name_participant: str + + +class EditSession(Schema): + # pylint: disable=too-few-public-methods + "API model for edit sessions." + id_persistent: str + owner: EditSessionParticipant + participant_list: List[EditSessionParticipantWithName] + name: str + + +class EditSessionList(Schema): + # pylint: disable=too-few-public-methods + "API model for multiple edit sessions." + edit_session_list: List[EditSession] + + +class EditSessionPatch(Schema): + "API model for changing edit sessions." + # pylint: disable=too-few-public-methods + name: str | None = None + + +class EditSessionPut(Schema): + "API model for creating edit sessions" + # pylint: disable=too-few-public-methods + name: str | None = None + + +class ParticipantSearchPost(Schema): + "API model for searching participants" + search_term: str + + +class ParticipantSearchResponse(Schema): + "API model for participant search results" + search_result_list: List[EditSessionParticipantWithName] + + +@router.post( + "search", + response={ + 200: ParticipantSearchResponse, + 401: ApiError, + 403: ApiError, + 500: ApiError, + }, +) +def search_participants(request: HttpRequest, request_data: ParticipantSearchPost): + "API method for searching for edit session participants" + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + if user.permission_group == VranUser.APPLICANT: + return 403, ApiError(msg="Insufficient permissions.") + try: + users = VranUser.search_username(request_data.search_term) + return 200, ParticipantSearchResponse( + search_result_list=[ + EditSessionParticipantWithName( + type_participant="INTERNAL", + id_participant=user_db.id_persistent, + name_participant=user_db.username, + ) + for user_db in users + ] + ) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not get possible contributors.") + + +@router.get( + "owner", + response={200: EditSessionList, 401: ApiError, 403: ApiError, 500: ApiError}, +) +def get_edit_sessions_owner(request: HttpRequest): + "API method for retrieving all edit sessions a user owns." + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + try: + if user.permission_group in {VranUser.APPLICANT, VranUser.READER}: + return 403, ApiError(msg="Insufficient permissions") + sessions_db = EditSessionDb.owned_by_user(user) + return 200, EditSessionList( + edit_session_list=[ + edit_session_db_to_api(session) for session in sessions_db + ] + ) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not get edit sessions.") + + +@router.get( + "participant", + response={200: EditSessionList, 401: ApiError, 403: ApiError, 500: ApiError}, +) +def get_edit_sessions_participant(request: HttpRequest): + "API method for retrieving all edit sessions a user participates in." + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + try: + if user.permission_group in {VranUser.APPLICANT, VranUser.READER}: + return 403, ApiError(msg="Insufficient permissions") + sessions_db = EditSessionDb.user_participates(user) + return 200, EditSessionList( + edit_session_list=[ + edit_session_db_to_api(session) for session in sessions_db + ] + ) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not get edit sessions.") + + +@router.put( + "", + response={ + 200: EditSession, + 400: ApiError, + 401: ApiError, + 403: ApiError, + 500: ApiError, + }, +) +def put_edit_session(request: HttpRequest, body: EditSessionPut): + "API method for creating a new edit session" + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + if user.permission_group in {VranUser.APPLICANT, VranUser.READER}: + return 403, ApiError(msg="Insufficient permissions") + name = body.name + if name is None: + name = ( + "Edit Session " + f"{len(EditSessionDb.objects.filter(id_owner_persistent=user.id_persistent))}" + ) + try: + id_session = str(uuid4()) + session_db = EditSessionDb.create( + id_persistent=id_session, + name=name, + user=user, + ) + user.set_current_edit_session(session_db) + return 200, edit_session_db_to_api(session_db) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not create edit session") + + +def check_session(request, id_edit_session_persistent, allow_participant=False): + "Check whether the user of a request owns a session." + try: + user = check_user(request) + except NotAuthenticatedException: + return None, (401, ApiError(msg="Not authenticated")) + if user.permission_group == VranUser.APPLICANT: + return None, (403, ApiError(msg="Insufficient permissions")) + try: + session = EditSessionDb.objects.filter( + id_persistent=id_edit_session_persistent + ).get() + if session.id_owner_persistent != user.id_persistent: + if ( + allow_participant + and len( + EditSessionParticipantDb.objects.filter( + edit_session_id=id_edit_session_persistent, + id_participant=user.id_persistent, + type_participant=EditSessionParticipantDb.INTERNAL, + ) + ) + > 0 + ): + return session, None + return None, ( + 403, + ApiError(msg="You are not the owner of this edit session."), + ) + return session, None + except EditSessionDb.DoesNotExist: + return None, (404, ApiError(msg="Edit session not found")) + + +@router.patch( + "{id_edit_session_persistent}", + response={ + 200: EditSession, + 401: ApiError, + 403: ApiError, + 404: ApiError, + 500: ApiError, + }, +) +def patch_edit_session( + request: HttpRequest, id_edit_session_persistent: str, patch_info: EditSessionPatch +): + "API method for changing an edit session." + try: + session, err = check_session(request, id_edit_session_persistent) + if err is not None: + return err + if patch_info.name is not None: + session.name = patch_info.name + session.save() + return 200, edit_session_db_to_api(session) + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not change edit session") + + +@router.put( + "{id_edit_session_persistent}/participants", + response={ + 200: EditSessionParticipantWithName, + 400: ApiError, + 401: ApiError, + 403: ApiError, + 500: ApiError, + }, +) +def put_participant( + request: HttpRequest, + id_edit_session_persistent: str, + put_body: EditSessionParticipantWithName, +): + "API method for adding a participant to an edit session." + try: + session, err = check_session(request, id_edit_session_persistent) + if err is not None: + return err + type_participant = PARTICIPANT_TYPE_API_TO_DB_DICT[put_body.type_participant] + participant = EditSessionParticipantDb.add( + type_participant=type_participant, + id_participant=put_body.id_participant, + name_participant=put_body.name_participant, + id_session_persistent=session.id_persistent, + ) + return 200, edit_session_participant_db_to_api(participant) + except KeyError: + return 400, ApiError(msg="Participant type not known.") + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not add contributor") + + +@router.delete( + "{id_edit_session_persistent}/participants", + response={200: None, 400: ApiError, 401: ApiError, 403: ApiError, 500: ApiError}, +) +def delete_participant( + request: HttpRequest, + id_edit_session_persistent: str, + delete_body: EditSessionParticipant, +): + "API method for adding a participant to an edit session." + try: + session, err = check_session( + request, id_edit_session_persistent, allow_participant=True + ) + if err is not None: + return err + type_participant = PARTICIPANT_TYPE_API_TO_DB_DICT[delete_body.type_participant] + if ( + type_participant == EditSessionParticipantDb.INTERNAL + and session.id_owner_persistent == delete_body.id_participant + ): + return 400, ApiError( + msg="You can not remove yourself from a session you own." + ) + EditSessionParticipantDb.objects.filter( + type_participant=type_participant, + id_participant=delete_body.id_participant, + edit_session=session, + ).delete() + return 200, None + except KeyError: + return 400, ApiError(msg="Participant type not known.") + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not add contributor") + + +PARTICIPANT_TYPE_DB_TO_API_DICT = { + EditSessionParticipantDb.INTERNAL: "INTERNAL", + EditSessionParticipantDb.ORCID: "ORCID", +} + +PARTICIPANT_TYPE_API_TO_DB_DICT = { + "INTERNAL": EditSessionParticipantDb.INTERNAL, + "ORCID": EditSessionParticipantDb.ORCID, +} + + +def edit_session_participant_db_to_api(participant: EditSessionParticipantDb): + "Transform and edit session participant from DB to API model" + return EditSessionParticipantWithName( + id_participant=participant.id_participant, + type_participant=PARTICIPANT_TYPE_DB_TO_API_DICT[participant.type_participant], + name_participant=participant.name_participant, + ) + + +def edit_session_db_to_api(edit_session: EditSessionDb): + "Convert an edit session from db to API format." + return EditSession( + name=edit_session.name, + id_persistent=edit_session.id_persistent, + owner=EditSessionParticipant( + id_participant=edit_session.id_owner_persistent, + type_participant="INTERNAL", + ), + participant_list=[ + edit_session_participant_db_to_api(participant) + for participant in edit_session.editsessionparticipant_set.all() + ], + ) diff --git a/vran/edit_session/models_django.py b/vran/edit_session/models_django.py new file mode 100644 index 00000000..b0b124a2 --- /dev/null +++ b/vran/edit_session/models_django.py @@ -0,0 +1,100 @@ +"Django ORM models for handling edit sessions" +from django.db import models +from django.db.utils import IntegrityError + + +class EditSession(models.Model): + "Django ORM model for edit sessions" + + id_persistent = models.CharField(max_length=36, primary_key=True) + id_owner_persistent = models.CharField(max_length=36) + name = models.TextField() + + @classmethod + def owned_by_user(cls, user): + "Get all edit sessions owned by a user" + return cls.objects.filter(id_owner_persistent=user.id_persistent).annotate( + type_participant=models.Value( + EditSessionParticipant.INTERNAL, + output_field=models.CharField(max_length=3), + ) + ) + + @classmethod + def user_participates(cls, user): + "Get all edit sessions where a user participates." + return cls.objects.annotate( + type_participant=models.Subquery( + EditSessionParticipant.objects.filter( + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + edit_session_id=models.OuterRef("id_persistent"), + ).values("type_participant") + ) + ).filter( + ~models.Q(id_owner_persistent=user.id_persistent), + type_participant__isnull=False, + ) + + @classmethod + def create(cls, id_persistent, name, user): + "Create a new edit session and add the owner as user" + session = cls.objects.create( + id_persistent=id_persistent, + id_owner_persistent=user.id_persistent, + name=name, + ) + participant = EditSessionParticipant.objects.create( + edit_session=session, + type_participant=EditSessionParticipant.INTERNAL, + id_participant=user.id_persistent, + name_participant=user.username, + ) + session.editsessionparticipantset = {participant} + return session + + +class EditSessionParticipant(models.Model): + "Django ORM model for edit session participants" + edit_session = models.ForeignKey(EditSession, on_delete=models.CASCADE) + INTERNAL = "INT" + ORCID = "ORC" + type_participant = models.CharField( + max_length=3, choices=[(INTERNAL, "internal"), (ORCID, "ORCID")] + ) + id_participant = models.CharField(max_length=36) + name_participant = models.TextField() + + class Meta: + "Meta model for edit session participants." + constraints = [ + models.UniqueConstraint( + fields=["edit_session", "id_participant"], + name="Unique Edit Session Membership", + ) + ] + + @classmethod + def add( + cls, + id_session_persistent, + type_participant, + id_participant, + name_participant, + ): + "Add a participant to an edit session" + try: + return cls.objects.create( + edit_session_id=id_session_persistent, + type_participant=type_participant, + id_participant=id_participant, + name_participant=name_participant, + ) + except IntegrityError as exc: + # Return membership already exists + try: + return cls.objects.filter( + edit_session_id=id_session_persistent, id_participant=id_participant + ).get() + except Exception: # pylint: disable=broad-except + raise exc from exc diff --git a/vran/entity/queue.py b/vran/entity/queue.py index 81d64460..757486f8 100644 --- a/vran/entity/queue.py +++ b/vran/entity/queue.py @@ -78,6 +78,7 @@ def tag_def_db_to_dict(tag_definition): "type": tag_definition.type, "owner": user_db_to_public_user_info_dict(tag_definition.owner), "curated": tag_definition.curated, + "description": tag_definition.description, "hidden": tag_definition.hidden, "disabled": tag_definition.disabled, } diff --git a/vran/migrations/0010_editsession_vranuser_edit_session_and_more.py b/vran/migrations/0010_editsession_vranuser_edit_session_and_more.py new file mode 100644 index 00000000..021e09cc --- /dev/null +++ b/vran/migrations/0010_editsession_vranuser_edit_session_and_more.py @@ -0,0 +1,70 @@ +# Generated by Django 5.0.4 on 2024-07-30 08:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vran", "0009_contributioncandidate_justification"), + ] + + operations = [ + migrations.CreateModel( + name="EditSession", + fields=[ + ( + "id_persistent", + models.CharField(max_length=36, primary_key=True, serialize=False), + ), + ("id_owner_persistent", models.CharField(max_length=36)), + ("name", models.TextField()), + ], + ), + migrations.AddField( + model_name="vranuser", + name="edit_session", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.RESTRICT, + to="vran.editsession", + ), + ), + migrations.CreateModel( + name="EditSessionParticipant", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type_participant", + models.CharField( + choices=[("INT", "internal"), ("ORC", "ORCID")], max_length=3 + ), + ), + ("id_participant", models.CharField(max_length=36)), + ("name_participant", models.TextField()), + ( + "edit_session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="vran.editsession", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="editsessionparticipant", + constraint=models.UniqueConstraint( + fields=("edit_session", "id_participant"), + name="Unique Edit Session Membership", + ), + ), + ] diff --git a/vran/migrations/0011_default_edit_session.py b/vran/migrations/0011_default_edit_session.py new file mode 100644 index 00000000..aeb0d7c3 --- /dev/null +++ b/vran/migrations/0011_default_edit_session.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.4 on 2024-07-04 11:30 + +from uuid import uuid4 + +from django.db import migrations + + +def create_and_set_initial_edit_session(apps, schema_editor): + "Create default edit sessions" + user_model = apps.get_model("vran", "vranuser") + session_model = apps.get_model("vran", "editsession") + participant_model = apps.get_model("vran", "editsessionparticipant") + db_alias = schema_editor.connection.alias + for user in user_model.objects.using(db_alias).filter(is_superuser=False): + session = session_model.objects.using(db_alias).create( + id_persistent=str(uuid4()), + id_owner_persistent=user.id_persistent, + name="Default Edit Session", + ) + user.edit_session = session + session.save() + user.save() + participant = participant_model.objects.using(db_alias).create( + edit_session_id=session.id_persistent, + type_participant="INT", + id_participant=user.id_persistent, + name_participant=user.username, + ) + participant.save() + + +def reverse(app, schema_editor): # pylint: disable=unused-argument + "empty reverse migration" + + +class Migration(migrations.Migration): + + dependencies = [ + ("vran", "0010_editsession_vranuser_edit_session_and_more"), + ] + + operations = [ + migrations.RunPython(create_and_set_initial_edit_session, reverse_code=reverse) + ] diff --git a/vran/models.py b/vran/models.py index a26b43c5..33c31cb3 100644 --- a/vran/models.py +++ b/vran/models.py @@ -8,6 +8,7 @@ TagDefinitionContribution, TagInstanceContribution, ) +from vran.edit_session.models_django import EditSession, EditSessionParticipant from vran.entity.models_django import Entity, EntityJustification from vran.management.models_django import ConfigValue from vran.merge_request.entity.models_django import ( diff --git a/vran/urls.py b/vran/urls.py index 0737ade4..ef6a1303 100644 --- a/vran/urls.py +++ b/vran/urls.py @@ -12,6 +12,7 @@ from vran.comments.api import router as comment_router from vran.contribution.api import router as contribution_router +from vran.edit_session.api import router as edit_session_router from vran.management import router as management_router from vran.merge_request.router import router from vran.person.api import router as person_router @@ -43,6 +44,7 @@ class JsonRendererWithDateTime(JSONRenderer): ninja_api.add_router("merge_requests", router, auth=vran_auth) ninja_api.add_router("manage", management_router, auth=vran_auth) ninja_api.add_router("comments", comment_router, auth=vran_auth) +ninja_api.add_router("edit_sessions", edit_session_router, auth=vran_auth) class LoginRequest(Schema): diff --git a/vran/user/api.py b/vran/user/api.py index 20de8e00..2dc4e95a 100644 --- a/vran/user/api.py +++ b/vran/user/api.py @@ -7,11 +7,13 @@ from django.conf import settings from django.contrib.auth import authenticate, login, logout from django.contrib.auth.models import AnonymousUser, Group -from django.db import DatabaseError, IntegrityError +from django.db import DatabaseError, IntegrityError, transaction from django.http import HttpRequest from ninja import Router, Schema from ninja.constants import NOT_SET +from vran.edit_session.api import EditSession, edit_session_db_to_api +from vran.edit_session.models_django import EditSession as EditSessionDb from vran.exception import ApiError, NotAuthenticatedException from vran.tag.api.models_conversion import tag_definition_db_to_api from vran.tag.models_django import TagDefinition as TagDefinitionDb @@ -36,6 +38,12 @@ class PutGroupRequest(Schema): permission_group: str +class SetEditSessionRequest(Schema): + # pylint: disable=too-few-public-methods + "Request body for setting the current edit session." + id_edit_session_persistent: str + + router = Router() @@ -81,18 +89,26 @@ def register_post( if not (settings.DEBUG or settings.IS_UNITTEST): if len(VranUser.objects.exclude(is_superuser=True)) == 0: permission_group = VranUser.COMMISSIONER - user = VranUser.objects.create_user( - username=registration_info.username, - email=registration_info.email, - password=registration_info.password, - first_name=registration_info.names_personal, - id_persistent=uuid4(), - permission_group=permission_group, - ) - if user.last_name and user.last_name != "": - user.last_name = registration_info.names_family - user.groups.set([Group.objects.get(name=str(VranGroup.APPLICANT))]) - user.save() + id_user = uuid4() + with transaction.atomic(): + session = EditSessionDb.objects.create( + id_persistent=str(uuid4()), + id_owner_persistent=str(id_user), + name="Default Edit Session", + ) + user = VranUser.objects.create_user( + username=registration_info.username, + email=registration_info.email, + password=registration_info.password, + first_name=registration_info.names_personal, + id_persistent=id_user, + permission_group=permission_group, + edit_session=session, + ) + if user.last_name and user.last_name != "": + user.last_name = registration_info.names_family + user.groups.set([Group.objects.get(name=str(VranGroup.APPLICANT))]) + user.save() return 200, user_db_to_login_response(user) except IntegrityError as exc: error_msg = exc.args[0] @@ -105,6 +121,39 @@ def register_post( return 500, ApiError(msg="Could not create user.") +@router.post( + "/edit_session", + response={ + 200: EditSession, + 400: ApiError, + 401: ApiError, + 403: ApiError, + 404: ApiError, + 500: ApiError, + }, +) +def set_edit_session(request: HttpRequest, body: SetEditSessionRequest): + "API method for setting the current edit session of a user." + try: + user = check_user(request) + except NotAuthenticatedException: + return 401, ApiError(msg="Not authenticated") + if user.permission_group == VranUser.APPLICANT: + return 403, ApiError(msg="Insufficient permissions") + try: + session = EditSessionDb.objects.filter( + id_persistent=body.id_edit_session_persistent + ).get() + if session.id_owner_persistent != user.id_persistent: + return 403, ApiError(msg="You do not own this session.") + user.set_current_edit_session(session) + return 200, edit_session_db_to_api(session) + except EditSessionDb.DoesNotExist: + return 404, ApiError(msg="Session does not exist.") + except Exception: # pylint: disable=broad-except + return 500, ApiError(msg="Could not set edit session") + + @router.post( "/tag_definitions/append/{id_tag_definition_persistent}", response={200: None, 400: ApiError, 401: ApiError, 500: ApiError}, @@ -321,4 +370,5 @@ def user_db_to_login_response(user: VranUser): email=user.email, tag_definition_list=tag_definitions, permission_group=permission_group_db_to_api[user.permission_group], + edit_session=edit_session_db_to_api(user.edit_session), ) diff --git a/vran/user/models_api/login.py b/vran/user/models_api/login.py index 71f4efc8..d3fdc63b 100644 --- a/vran/user/models_api/login.py +++ b/vran/user/models_api/login.py @@ -4,6 +4,7 @@ from ninja import Schema from pydantic import Field +from vran.edit_session.api import EditSession from vran.tag.api.models_api import TagDefinitionResponse from vran.user.models_api.public import PublicUserInfo @@ -28,6 +29,7 @@ class LoginResponse(Schema): email: str tag_definition_list: List[TagDefinitionResponse] permission_group: str + edit_session: EditSession class LoginResponseList(Schema): diff --git a/vran/util/__init__.py b/vran/util/__init__.py index 37d69704..8147bc04 100644 --- a/vran/util/__init__.py +++ b/vran/util/__init__.py @@ -33,6 +33,9 @@ class VranUser(AbstractUser): permission_group = models.TextField( choices=PERMISSION_GROUP_CHOICES, default=APPLICANT, max_length=4 ) + edit_session = models.ForeignKey( + "editsession", null=True, on_delete=models.RESTRICT + ) @classmethod def search_username(cls, search_term: str): @@ -89,6 +92,11 @@ def has_elevated_rights(self): "Method for checking if a user has elevated rights." return self.permission_group in {VranUser.EDITOR, VranUser.COMMISSIONER} + def set_current_edit_session(self, edit_session): + "Set the current edit session for a user." + self.edit_session = edit_session + self.save() + def timestamp(): "Create a timezone aware timestamp"