diff --git a/doccano_client/beta/controllers/__init__.py b/doccano_client/beta/controllers/__init__.py index aba0bae..28b10ed 100644 --- a/doccano_client/beta/controllers/__init__.py +++ b/doccano_client/beta/controllers/__init__.py @@ -9,6 +9,7 @@ ExamplesController, ) from .label import LabelController, LabelsController +from .member import MemberController, MembersController from .project import ProjectController, ProjectsController from .relation import RelationController, RelationsController from .relation_type import RelationTypeController, RelationTypesController @@ -33,6 +34,8 @@ "ExamplesController", "LabelController", "LabelsController", + "MemberController", + "MembersController", "ProjectController", "ProjectsController", "SpanController", diff --git a/doccano_client/beta/controllers/member.py b/doccano_client/beta/controllers/member.py new file mode 100644 index 0000000..b70c5f4 --- /dev/null +++ b/doccano_client/beta/controllers/member.py @@ -0,0 +1,85 @@ +from dataclasses import asdict, dataclass, fields +from typing import Iterable + +from requests import Session + +from ..models.members import Member +from ..utils.response import verbose_raise_for_status + + +@dataclass +class MemberController: + """Wraps a Member with fields used for interacting directly with Doccano client""" + + id: int + member: Member + members_url: str + client_session: Session + + @property + def member_url(self) -> str: + """Return an api url for this member""" + return f"{self.members_url}/{self.id}" + + +class MembersController: + """Controls the assignment and retrieval of MemberControllers for a project""" + + def __init__(self, project_url: str, client_session: Session) -> None: + """Initializes a MemberController instance""" + self._project_url = project_url + self.client_session = client_session + + @property + def members_url(self) -> str: + """Return an api url for members list""" + return f"{self._project_url}/members" + + def all(self) -> Iterable[MemberController]: + """Return a sequence of all members for a given controller, which maps to a project + + Yields: + MemberController: The next member controller. + """ + response = self.client_session.get(self.members_url) + verbose_raise_for_status(response) + member_dicts = response.json() + member_object_fields = set(member_field.name for member_field in fields(Member)) + + for member_dict in member_dicts: + # Sanitize member_dict before converting to Member + sanitized_member_dict = {member_key: member_dict[member_key] for member_key in member_object_fields} + + yield MemberController( + member=Member(**sanitized_member_dict), + id=member_dict["id"], + members_url=self.members_url, + client_session=self.client_session, + ) + + def create(self, member: Member) -> MemberController: + """Create new member for Doccano project, assign session variables to member, return the id""" + member_json = asdict(member) + + response = self.client_session.post(self.members_url, json=member_json) + verbose_raise_for_status(response) + response_id = response.json()["id"] + + return MemberController( + member=member, + id=response_id, + members_url=self.members_url, + client_session=self.client_session, + ) + + def update(self, member_controllers: Iterable[MemberController]) -> None: + """Updates the given members in the remote project""" + for member_controller in member_controllers: + member_json = asdict(member_controller.member) + member_json = { + member_key: member_value for member_key, member_value in member_json.items() if member_value is not None + } + member_json["id"] = member_controller.id + + response = self.client_session.put(member_controller.member_url, json=member_json) + verbose_raise_for_status(response) diff --git a/doccano_client/beta/controllers/project.py b/doccano_client/beta/controllers/project.py index 4f3015d..9c00fae 100644 --- a/doccano_client/beta/controllers/project.py +++ b/doccano_client/beta/controllers/project.py @@ -10,6 +10,7 @@ from .comment import CommentsController from .example import DocumentsController, ExamplesController from .label import LabelsController +from .member import MembersController from .relation_type import RelationTypesController from .span_type import SpanTypesController @@ -47,6 +48,11 @@ def comments(self) -> CommentsController: """Return a CommentsController mapped to this project""" return CommentsController(self.project_url, self.client_session) + @property + def members(self) -> MembersController: + """Return a MembersController mapped to this project""" + return MembersController(self.project_url, self.client_session) + @property def category_types(self) -> CategoryTypesController: """Return a CategoryTypesController mapped to this project""" diff --git a/doccano_client/beta/models/__init__.py b/doccano_client/beta/models/__init__.py index 4d3d532..d5c5655 100644 --- a/doccano_client/beta/models/__init__.py +++ b/doccano_client/beta/models/__init__.py @@ -9,6 +9,7 @@ from .comments import Comment from .examples import Document, Example from .labels import LABEL_COLOR_CYCLE, Label +from .members import Member from .projects import Project, ProjectTypes from .relation import Relation from .relation_type import RelationType @@ -28,6 +29,7 @@ "Example", "Label", "LABEL_COLOR_CYCLE", + "Member", "ProjectTypes", "Project", "Relation", diff --git a/doccano_client/beta/models/members.py b/doccano_client/beta/models/members.py new file mode 100644 index 0000000..2320867 --- /dev/null +++ b/doccano_client/beta/models/members.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass +class Member: + """Contains the data and operations relevant to a Member on a Doccano project""" + + user: int + role: int diff --git a/doccano_client/beta/tests/controllers/mock_api_responses/members.py b/doccano_client/beta/tests/controllers/mock_api_responses/members.py new file mode 100644 index 0000000..9d03768 --- /dev/null +++ b/doccano_client/beta/tests/controllers/mock_api_responses/members.py @@ -0,0 +1,54 @@ +import re + +import responses + +from . import projects + +members_get_json = [ + { + "id": 6, + "user": 1, + "role": 1, + "username": "user_a", + "rolename": "project_admin", + }, + { + "id": 7, + "user": 2, + "role": 2, + "username": "user_b", + "rolename": "annotator", + }, +] + +member_create_json = { + "id": 8, + "user": 3, + "role": 3, + "username": "user_c", + "rolename": "annotation_approver", +} + +members_regex = f".*/v1/projects/{projects.valid_project_ids_regex_insert}/members" + +members_get_empty_response = responses.Response(method="GET", url=re.compile(members_regex), json=[], status=200) + +members_get_response = responses.Response( + method="GET", url=re.compile(members_regex), json=members_get_json, status=200 +) + +member_create_response = projects_get_updated_response = responses.Response( + method="POST", + url=re.compile(members_regex), + json=member_create_json, + status=201, +) + +member_update_response = responses.Response( + method="PUT", + url=re.compile(rf"{members_regex}/\d+"), + # The json here in practice is way more complicated, but we don't need to test or use the + # response outside of the status code, so it is moot for testing. + json={"status": "accepted"}, + status=200, +) diff --git a/doccano_client/beta/tests/controllers/test_member.py b/doccano_client/beta/tests/controllers/test_member.py new file mode 100644 index 0000000..22d0a4c --- /dev/null +++ b/doccano_client/beta/tests/controllers/test_member.py @@ -0,0 +1,97 @@ +from unittest import TestCase + +import responses +from requests import Session + +from ...controllers import MemberController, MembersController +from ...models import Member +from ...utils.response import DoccanoAPIError +from .mock_api_responses import bad +from .mock_api_responses import members as mocks + + +class MemberControllerTest(TestCase): + def setUp(self): + self.member = Member(user=1, role=1) + self.member_controller = MemberController( + id=43, + member=self.member, + members_url="http://my_members_url", + client_session=Session(), + ) + + def test_urls(self): + self.assertEqual(self.member_controller.member_url, "http://my_members_url/43") + + +class MembersControllerTest(TestCase): + def setUp(self) -> None: + self.member_a = Member(user=3, role=3) + self.member_controller_a = MemberController( + id=43, + member=self.member_a, + members_url="http://my_members_url", + client_session=Session(), + ) + self.members_controller = MembersController( + project_url="http://my_members_url/v1/projects/23", + client_session=Session(), + ) + + def test_controller_urls(self): + self.assertEqual(self.members_controller.members_url, "http://my_members_url/v1/projects/23/members") + + @responses.activate + def test_all_with_no_members(self): + responses.add(mocks.members_get_empty_response) + member_controllers = self.members_controller.all() + self.assertEqual(len(list(member_controllers)), 0) + + @responses.activate + def test_all(self): + responses.add(mocks.members_get_response) + member_controllers = self.members_controller.all() + + total_members = 0 + expected_member_id_dict = {member_json["id"]: member_json for member_json in mocks.members_get_json} + for member_controller in member_controllers: + self.assertIn(member_controller.id, expected_member_id_dict) + self.assertEqual(member_controller.member.user, expected_member_id_dict[member_controller.id]["user"]) + self.assertEqual(member_controller.member.role, expected_member_id_dict[member_controller.id]["role"]) + self.assertIs(member_controller.client_session, self.members_controller.client_session) + total_members += 1 + + self.assertEqual(total_members, len(mocks.members_get_json)) + + @responses.activate + def test_all_with_bad_response(self): + responses.add(bad.bad_get_response) + with self.assertRaises(DoccanoAPIError): + list(self.members_controller.all()) + + @responses.activate + def test_create(self): + responses.add(mocks.member_create_response) + member_a_controller = self.members_controller.create(self.member_a) + + self.assertEqual(member_a_controller.id, mocks.member_create_json["id"]) + self.assertEqual(member_a_controller.member.user, mocks.member_create_json["user"]) + + @responses.activate + def test_create_with_bad_response(self): + responses.add(bad.bad_post_response) + with self.assertRaises(DoccanoAPIError): + list(self.members_controller.create(self.member_a)) + + @responses.activate + def test_update(self): + responses.add(mocks.members_get_response) + responses.add(mocks.member_update_response) + member_controllers = self.members_controller.all() + self.members_controller.update(member_controllers) + + @responses.activate + def test_update_with_bad_response(self): + responses.add(bad.bad_put_response) + with self.assertRaises(DoccanoAPIError): + list(self.members_controller.update([self.member_controller_a]))