diff --git a/.isort.cfg b/.isort.cfg index f70ec56..1b1378b 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,4 +1,4 @@ [settings] profile = black combine_as_imports = true -known_third_party = backoff,cachetools,requests,requests_mock,typing_extensions +known_third_party = backoff,cachetools,pydantic,pytest,requests,requests_mock,typing_extensions diff --git a/VERSION b/VERSION index 38f77a6..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 +2.1.0 diff --git a/caplena/errors.py b/caplena/errors.py new file mode 100644 index 0000000..ddb748e --- /dev/null +++ b/caplena/errors.py @@ -0,0 +1,18 @@ +from typing import Any, Dict + + +class DuplicatedTopicsError(Exception): + """Exception that is thrown when there are two or more topics with identical names + provided for TTACell. + + :param message: A brief human-readable message providing more details about the error + that has occurred. Please note that error messages might change and are therefore + not suitable for programmatic error handling. + :param duplicates: A dict which specifies which topic ids had duplicates. + Keys are topic ids and values are number of occurences. + """ + + def __init__(self, message: str, duplicates: Dict[str, int], *args: Any) -> None: + self.message = message + self.duplicates = duplicates + super().__init__(message, *args) diff --git a/caplena/models/__init__.py b/caplena/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/caplena/models/projects.py b/caplena/models/projects.py new file mode 100644 index 0000000..bf5e045 --- /dev/null +++ b/caplena/models/projects.py @@ -0,0 +1,182 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Union, cast + +import pydantic + +from caplena.validations.projects import validate_no_duplicated_topics + + +class NonTTAColumnType(Enum): + numerical = "numerical" + date = "date" + boolean = "boolean" + text = "text" + + +class TTAColumnType(Enum): + text_to_analyze = "text_to_analyze" + + +class NonTTAColumnDefinition(pydantic.BaseModel): + """Column definition for NonTextToAnalyze column.""" + + ref: str + type: NonTTAColumnType + name: str + convertor: ClassVar[Dict[str, Callable[[str], Union[int, datetime, bool, str]]]] = { + NonTTAColumnType.numerical.value: int, + NonTTAColumnType.date.value: datetime.fromisoformat, + NonTTAColumnType.boolean.value: bool, + NonTTAColumnType.text.value: str, + } + + # we want the enum value when serialising to .dict() + model_config = pydantic.ConfigDict(use_enum_values=True) + + def convert_to_type(self, val: str) -> Union[int, datetime, bool, str]: + # we use the enum's values here since the class is configured to always use enum's values + type_val = cast(str, self.type) # Config.use_enum_values converts type to .value (str) + return self.convertor[type_val](val) + + def build_cell(self, ref: str, value: str) -> "NonTTACell": + return NonTTACell(ref=ref, value=self.convert_to_type(value)) + + +class Sentiment(pydantic.BaseModel): + code: Optional[int] = None + label: Optional[str] = None + + +class TopicSentiment(Enum): + NEUTRAL = "neutral" + POSITIVE = "positive" + NEGATIVE = "negative" + ANY = "any" + + +class TopicDefinition(pydantic.BaseModel): + label: str + sentiment_enabled: bool + category: str + color: Optional[str] = None + description: Optional[str] = None + sentiment_neutral: Optional[Sentiment] = None + sentiment_negative: Optional[Sentiment] = None + sentiment_positive: Optional[Sentiment] = None + + def build_topic(self, id: str, sentiment: TopicSentiment = TopicSentiment.ANY) -> "Topic": + _sentiment = sentiment if self.sentiment_enabled else TopicSentiment.ANY + return Topic(id=id, sentiment=_sentiment) + + +class TTAColumnDefinition(pydantic.BaseModel): + """Column definition for TextToAnalyze column.""" + + ref: str + name: str + topics: List[TopicDefinition] + type: TTAColumnType = TTAColumnType.text_to_analyze + description: Optional[str] = None + model_config = pydantic.ConfigDict(use_enum_values=True) + + def convert_to_type(self, val: Any) -> str: + return str(val) + + def build_cell( + self, ref: str, topics: List["Topic"], value: str, was_reviewed: bool + ) -> "TTACell": + return TTACell( + ref=ref, topics=topics, value=self.convert_to_type(value), was_reviewed=was_reviewed + ) + + +ColumnDefinition = Union[TTAColumnDefinition, NonTTAColumnDefinition] + + +class Topic(pydantic.BaseModel): + id: str + sentiment: TopicSentiment + model_config = pydantic.ConfigDict(use_enum_values=True) + + +class TTACell(pydantic.BaseModel): + """Cell definition for TextToAnalyze cell.""" + + ref: str + topics: List[Topic] + value: str + was_reviewed: bool = True + + @pydantic.validator("topics") + def topics_shouldnt_have_duplicates(cls, topics: List[Topic]) -> List[Topic]: + validate_no_duplicated_topics(topics) + return topics + + +class NonTTACell(pydantic.BaseModel): + """Cell definition for NonTextToAnalyze cell.""" + + ref: str + value: Optional[Union[int, str, bool, datetime]] = None + + +Cell = Union[TTACell, NonTTACell] + + +class MultipleCellPayload(pydantic.BaseModel): + cells: List[Cell] + + +class RowPayload(pydantic.BaseModel): + columns: List[Cell] + + +class MultipleRowPayload(pydantic.BaseModel): + rows: List[RowPayload] + + +class ProjectLanguage(Enum): + AF = "af" + SQ = "sq" + EU = "eu" + CA = "ca" + CS = "cs" + DA = "da" + NL = "nl" + EN = "en" + ET = "et" + FI = "fi" + FR = "fr" + GL = "gl" + DE = "de" + EL = "el" + HU = "hu" + IS = "is" + IT = "it" + LB = "lb" + LT = "lt" + LV = "lv" + MK = "mk" + NO = "no" + PL = "pl" + PT = "pt" + RO = "ro" + SR = "sr" + SK = "sk" + SL = "sl" + ES = "es" + SV = "sv" + TR = "tr" + + +class ProjectSettings(pydantic.BaseModel): + """Projects settings for project creation.""" + + name: str + language: ProjectLanguage + columns: Sequence[ColumnDefinition] + tags: List[str] = pydantic.Field(default_factory=list) + translation_engine: Optional[str] = None + anonymize_pii: Optional[Any] = pydantic.Field(default=None) + model_config = pydantic.ConfigDict(use_enum_values=True) diff --git a/caplena/validations/__init__.py b/caplena/validations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/caplena/validations/projects.py b/caplena/validations/projects.py new file mode 100644 index 0000000..078363f --- /dev/null +++ b/caplena/validations/projects.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING, Dict, List + +from caplena.errors import DuplicatedTopicsError + +# avoid circular dependency when not type-checking +if TYPE_CHECKING: + from caplena.models.projects import Topic + + +def validate_no_duplicated_topics(topics: List["Topic"]) -> None: + """Validates whether there are duplicate topics in a list provided. + + :param topics: A list of topics to check for duplicates. + :raises caplena.errors.DuplicatedTopicsError: An exception raised if there are duplicate topics. + """ + id_to_occurences: Dict[str, List[Topic]] = {} + for topic in topics: + id_to_occurences.setdefault(topic.id, []).append(topic) + duplicates = {id: len(topics) for id, topics in id_to_occurences.items() if len(topics) > 1} + if duplicates: + raise DuplicatedTopicsError( + "A cell cannot contain duplicated topics. The topics with following ids have been" + "provided multiple times. `.duplicates` attribute of this exception contains: " + f"{duplicates}", + duplicates, + ) diff --git a/caplena/version.py b/caplena/version.py index 159d48b..9aa3f90 100644 --- a/caplena/version.py +++ b/caplena/version.py @@ -1 +1 @@ -__version__ = "2.0.1" +__version__ = "2.1.0" diff --git a/docs/source/conf.py b/docs/source/conf.py index a190422..90bc6f8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,8 +22,8 @@ project = "Caplena" copyright = "2024, Caplena" author = "Caplena" -version = "2.0.1" -release = "2.0.1" +version = "2.1.0" +release = "2.1.0" # -- General configuration --------------------------------------------------- diff --git a/docs/source/creating-projects.rst b/docs/source/creating-projects.rst index 86f45e5..e848846 100644 --- a/docs/source/creating-projects.rst +++ b/docs/source/creating-projects.rst @@ -17,25 +17,43 @@ Next, we'll build the project's columns which defines the schema of the rows to .. code-block:: python - columns = [ - { - "name": "Survey Response ID", # name is what is shown in the User Interface - "ref": "id", # ref is a unique identifier for the column in the project - "type": "numerical" - }, - { - "name": "Why did you give this rating?", - "ref": "nps_why", - "type": "text_to_analyze", - "description": "Please explain the rating in a few sentences." - } + from caplena.models.projects import ( + NonTTAColumnDefinition, + NonTTAColumnType, + TTAColumnDefinition, + TTAColumnType, + ) + + columns=[ + NonTTAColumnDefinition( + ref="id", # ref is a unique identifier for the column in the project + name="Survey Response ID", # name is what is shown in the User Interface + type=NonTTAColumnType.numerical, + ), + TTAColumnDefinition( + ref="nps_why", + name="Why did you give this rating?", + type=TTAColumnType.text_to_analyze, + description="Please explain the rating in a few sentences.", + topics=[], + ), ] + Now we're ready to create the project: .. code-block:: python - new_project = client.projects.create(name="NPS Study", language='en', columns=columns, tags=["NPS"]) + from caplena.models.projects import ProjectLanguage, ProjectSettings + + project_settings = ProjectSettings( + name="NPS Study", + language=ProjectLanguage.EN, + columns=columns, + tags=["NPS"], + ).model_dump(exclude_none=True) + + new_project = client.projects.create(**project_settings) Optionally, we can pass :code:`translation_engine=google_translate` to translate rows automatically using Google Translate. @@ -53,11 +71,25 @@ The ordering of columns within a row does not matter as columns are referenced u .. code-block:: python + from caplena.models.projects import ( + MultipleRowPayload, + RowPayload, + NonTTACell, + TTACell, + ) + # generate fake rows - rows = [ - {"columns": [{"ref": "id", "value": i}, {"ref":"nps_why", "value": f"Row {i}"}]} - for i in range(100) - ] + rows = MultipleRowPayload( + rows=[ + RowPayload( + columns=[ + NonTTACell(ref="id", value=i), + TTACell(ref="nps_why", value=f"Row {i}", topics=[]), + ] + ) for i in range(100) + ] + ).model_dump()["rows"] + # batch rows, we'll use numpy for this import numpy as np n_batches = np.ceil(len(rows)/20) # compute the number of batches needed diff --git a/pyproject.toml b/pyproject.toml index 1e364dc..a77f6d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "caplena" -version = "2.0.1" +version = "2.1.0" authors = [{name = "Caplena", email = "support@caplena.com"}] maintainers = [{name = "Pascal de Buren", email = "pascal@caplena.com"}] keywords = ["caplena", "api", "nlp", "customer feedback"] @@ -33,6 +33,7 @@ dependencies = [ "typing-extensions >=4.0.0", "backoff >=2.2.0", "cachetools>=5.0.0", + "pydantic>=2.0.0", ] [project.optional-dependencies] @@ -42,6 +43,9 @@ test = [ "requests-mock", "types-cachetools", "types-six", + "types-Pygments", + "types-colorama", + "types-setuptools", ] doc = [ "sphinx", diff --git a/tests/controllers/test_projects_controller.py b/tests/controllers/test_projects_controller.py index 6c2d2ef..40de525 100644 --- a/tests/controllers/test_projects_controller.py +++ b/tests/controllers/test_projects_controller.py @@ -1,18 +1,38 @@ +import re import time -import unittest from datetime import datetime, timezone -from typing import Any, ClassVar, Dict, List, Optional, cast +from typing import Any, Dict, Generator, List, Optional, cast from uuid import uuid4 +import pytest import requests_mock +from typing_extensions import Protocol from caplena.api.api_exception import ApiException from caplena.controllers import ProjectsController from caplena.filters.projects_filter import ProjectsFilter, RowsFilter +from caplena.models.projects import ( + MultipleCellPayload, + MultipleRowPayload, + NonTTACell, + NonTTAColumnDefinition, + NonTTAColumnType, + ProjectLanguage, + ProjectSettings, + RowPayload, + TopicDefinition, + TTACell, + TTAColumnDefinition, + TTAColumnType, +) from caplena.resources import ProjectDetail, Row from tests.common import common_config +class CreateProjectFunctionType(Protocol): + def __call__(self, payload: Optional[Dict[str, Any]] = None) -> ProjectDetail: ... + + def project_create_payload() -> Dict[str, Any]: return { "name": "Project Name", @@ -49,6 +69,65 @@ def project_create_payload() -> Dict[str, Any]: } +def project_create_payload_model() -> Dict[str, Any]: + return ProjectSettings( + name="Project Name", + tags=["my-tag"], + language=ProjectLanguage.EN, + translation_engine=None, + columns=[ + NonTTAColumnDefinition( + ref="customer_age", + type=NonTTAColumnType.numerical, + name="Age of the customer", + ), + TTAColumnDefinition( + ref="our_strengths", + name="Do you like us?", + type=TTAColumnType.text_to_analyze, + topics=[ + TopicDefinition( + label="price", + sentiment_enabled=True, + category="SERVICE", + ), + TopicDefinition( + label="network quality", + sentiment_enabled=False, + category="SERVICE", + ), + ], + ), + NonTTAColumnDefinition( + type=NonTTAColumnType.boolean, ref="boolean_col", name="Some example boolean" + ), + NonTTAColumnDefinition( + type=NonTTAColumnType.text, ref="text_col", name="Some auxiliary text" + ), + NonTTAColumnDefinition( + type=NonTTAColumnType.date, ref="date_col", name="Some date time values." + ), + TTAColumnDefinition( + ref="another_tta", + name="Do you want more tta?", + type=TTAColumnType.text_to_analyze, + topics=[ + TopicDefinition( + label="label1", + sentiment_enabled=True, + category="ANOTHER", + ), + TopicDefinition( + label="label2", + sentiment_enabled=False, + category="ANOTHER", + ), + ], + ), + ], + ).model_dump(exclude_none=True) + + def project_rows_create_payload() -> List[Dict[str, Any]]: return [ { @@ -75,454 +154,651 @@ def project_rows_create_payload() -> List[Dict[str, Any]]: ] -class ProjectsControllerTests(unittest.TestCase): - controller: ClassVar[ProjectsController] +def project_rows_create_payload_model() -> List[Dict[str, Any]]: + return MultipleRowPayload( # type: ignore[no-any-return] + rows=[ + RowPayload( + columns=[ + NonTTACell(ref="customer_age", value=120), + TTACell(ref="our_strengths", value="This is nice.", topics=[]), + NonTTACell(ref="boolean_col", value=False), + NonTTACell(ref="text_col", value="iphone"), + NonTTACell( + ref="date_col", value=datetime(year=2020, month=10, day=10, hour=17) + ), + ] + ), + RowPayload( + columns=[ + NonTTACell(ref="customer_age", value=None), + TTACell(ref="our_strengths", value="Some other text.", topics=[]), + NonTTACell(ref="boolean_col", value=True), + NonTTACell(ref="text_col", value="oneplus"), + NonTTACell( + ref="date_col", + value=datetime(year=2022, month=3, day=31, hour=14, minute=14, second=14), + ), + ] + ), + ] + ).model_dump()["rows"] - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.controller = ProjectsController(config=common_config) +def project_row_cells_payload() -> List[Dict[str, Any]]: + return [ + {"ref": "customer_age", "value": None}, + {"ref": "our_strengths", "value": "Good price."}, + {"ref": "boolean_col", "value": False}, + {"ref": "text_col", "value": None}, + { + "ref": "date_col", + "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), + }, + ] - def setUp(self) -> None: - self.created_projects: List[str] = [] - def tearDown(self) -> None: - for project_id in self.created_projects: - try: - self.controller.remove(id=project_id) - except ApiException: - print("Could not remove project with id:", project_id) +def project_row_cells_payload_model() -> List[Dict[str, Any]]: + return MultipleCellPayload( # type: ignore[no-any-return] + cells=[ + NonTTACell(ref="customer_age", value=None), + TTACell(ref="our_strengths", value="Good price.", topics=[], was_reviewed=False), + NonTTACell(ref="boolean_col", value=False), + NonTTACell(ref="text_col", value=None), + NonTTACell( + ref="date_col", + value=datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), + ), + ] + ).model_dump()["cells"] - def create_project(self, *, payload: Optional[Dict[str, Any]] = None) -> ProjectDetail: + +@pytest.fixture(scope="session") +def controller() -> ProjectsController: + controller = ProjectsController(config=common_config) + return controller + + +@pytest.fixture(scope="function") +def create_project( + controller: ProjectsController, +) -> Generator[CreateProjectFunctionType, None, None]: + created_projects: List[str] = [] + + def create(payload: Optional[Dict[str, Any]] = None) -> ProjectDetail: if payload is None: payload = project_create_payload() - project = self.controller.create(**payload) - self.created_projects.append(project.id) + project = controller.create(**payload) + created_projects.append(project.id) return project - # ---- General Functionality ---- # - - def test_creating_a_project_succeeds(self) -> None: - project = self.create_project() - - self.assertIsInstance(project.id, str) - self.assertEqual("Project Name", project.name) - self.assertIsInstance(project.owner, str) - self.assertListEqual(["my-tag"], project.tags) - self.assertEqual("pending", project.upload_status) - self.assertEqual("en", project.language) - self.assertEqual(None, project.translation_status) - self.assertEqual(None, project.translation_engine) - self.assertIsInstance(project.created, datetime) - self.assertIsInstance(project.last_modified, datetime) - - self.assertEqual(5, len(project.columns)) - self.assertIsInstance(project.columns[0], ProjectDetail.TextToAnalyze) - self.assertIsInstance(project.columns[1], ProjectDetail.Auxiliary) - self.assertIsInstance(project.columns[2], ProjectDetail.Auxiliary) - self.assertIsInstance(project.columns[3], ProjectDetail.Auxiliary) - self.assertIsInstance(project.columns[4], ProjectDetail.Auxiliary) - our_strengths = cast(ProjectDetail.TextToAnalyze, project.columns[0]) - customer_age = cast(ProjectDetail.Auxiliary, project.columns[1]) - boolean_col = cast(ProjectDetail.Auxiliary, project.columns[2]) - text_col = cast(ProjectDetail.Auxiliary, project.columns[3]) - date_col = cast(ProjectDetail.Auxiliary, project.columns[4]) - - self.assertEqual("our_strengths", our_strengths.ref) - self.assertEqual("Do you like us?", our_strengths.name) - self.assertEqual("text_to_analyze", our_strengths.type) - self.assertEqual("", our_strengths.description) - self.assertDictEqual( - { - "reviewed_count": 0, - "learns_from": None, - }, - our_strengths.metadata.dict(), - ) - self.assertEqual(2, len(our_strengths.topics)) - - topic1, topic2 = our_strengths.topics[0], our_strengths.topics[1] - self.assertRegex(topic1.id, r"^cd_") - self.assertEqual("price", topic1.label) - self.assertEqual("SERVICE", topic1.category) - self.assertEqual("", topic1.color) - self.assertEqual("", topic1.description) - self.assertEqual(True, topic1.sentiment_enabled) - self.assertDictEqual({"code": 0, "label": ""}, topic1.sentiment_neutral.dict()) - self.assertDictEqual({"code": 1, "label": ""}, topic1.sentiment_positive.dict()) - self.assertDictEqual({"code": 2, "label": ""}, topic1.sentiment_negative.dict()) - - self.assertRegex(topic2.id, r"^cd_") - self.assertEqual("network quality", topic2.label) - self.assertEqual("SERVICE", topic2.category) - self.assertEqual("", topic2.color) - self.assertEqual("", topic2.description) - self.assertEqual(False, topic2.sentiment_enabled) - self.assertDictEqual({"code": 3, "label": ""}, topic2.sentiment_neutral.dict()) - self.assertDictEqual({"code": -1, "label": ""}, topic2.sentiment_negative.dict()) - self.assertDictEqual({"code": -1, "label": ""}, topic2.sentiment_positive.dict()) - - self.assertEqual("customer_age", customer_age.ref) - self.assertEqual("Age of the customer", customer_age.name) - self.assertEqual("numerical", customer_age.type) - - self.assertEqual("boolean_col", boolean_col.ref) - self.assertEqual("Some example boolean", boolean_col.name) - self.assertEqual("boolean", boolean_col.type) - - self.assertEqual("text_col", text_col.ref) - self.assertEqual("Some auxiliary text", text_col.name) - self.assertEqual("text", text_col.type) - - self.assertEqual("date_col", date_col.ref) - self.assertEqual("Some date time values.", date_col.name) - self.assertEqual("date", date_col.type) - - def test_retrieving_a_project_succeeds(self) -> None: - project = self.create_project() - retrieved = self.controller.retrieve(id=project.id) - - self.assertDictEqual(project.dict(), retrieved.dict()) - - def test_removing_a_project_succeeds(self) -> None: - old_num_projects = self.controller.list(limit=1).count - project = self.create_project() - interim_num_projects = self.controller.list(limit=1).count - self.controller.remove(id=project.id) - new_num_projects = self.controller.list(limit=1).count - - self.assertEqual(old_num_projects, new_num_projects) - self.assertEqual(old_num_projects + 1, interim_num_projects) - - def test_updating_a_project_succeeds(self) -> None: - project_to_learn_from = self.create_project() - project = self.create_project() - - # test: updating properties succeeds - expected_dict = project.dict() - project.name = "MY SUPER NOVEL PROJECT NAME" - project.tags = ["new", "tags", "are", "cool"] - our_strengths: ProjectDetail.TextToAnalyze = project.columns[0] # type: ignore - our_strengths.name = "Do you still like us?" - our_strengths.description = "Please explain." - our_strengths.metadata.learns_from = self.controller.build( - ProjectDetail.TextToAnalyze.Metadata.LearnsForm, - {"project": project_to_learn_from.id, "ref": project_to_learn_from.columns[0].ref}, - ) - project.columns[1].name = "COOL NAME" - project.save() - expected_dict["name"] = "MY SUPER NOVEL PROJECT NAME" - expected_dict["tags"] = ["new", "tags", "are", "cool"] - expected_dict["columns"][0].update( - {"name": "Do you still like us?", "description": "Please explain."} - ) - expected_dict["columns"][0]["metadata"].update( - { - "learns_from": { - "project": project_to_learn_from.id, - "ref": project_to_learn_from.columns[0].ref, - } + yield create + + for project_id in created_projects: + try: + controller.remove(id=project_id) + except ApiException: + print("Could not remove project with id:", project_id) + + +def test_creating_a_project_succeeds(create_project: CreateProjectFunctionType) -> None: + project = create_project() + + assert isinstance(project.id, str) + assert "Project Name" == project.name + assert isinstance(project.owner, str) + assert ["my-tag"] == project.tags + assert "pending" == project.upload_status + assert "en" == project.language + assert project.translation_status is None + assert project.translation_engine is None + assert isinstance(project.created, datetime) + assert isinstance(project.last_modified, datetime) + + assert 5 == len(project.columns) + assert isinstance(project.columns[0], ProjectDetail.TextToAnalyze) + assert isinstance(project.columns[1], ProjectDetail.Auxiliary) + assert isinstance(project.columns[2], ProjectDetail.Auxiliary) + assert isinstance(project.columns[3], ProjectDetail.Auxiliary) + assert isinstance(project.columns[4], ProjectDetail.Auxiliary) + our_strengths = cast(ProjectDetail.TextToAnalyze, project.columns[0]) # type: ignore + customer_age = cast(ProjectDetail.Auxiliary, project.columns[1]) # type: ignore + boolean_col = cast(ProjectDetail.Auxiliary, project.columns[2]) # type: ignore + text_col = cast(ProjectDetail.Auxiliary, project.columns[3]) # type: ignore + date_col = cast(ProjectDetail.Auxiliary, project.columns[4]) # type: ignore + + # our strenghts column + assert "our_strengths" == our_strengths.ref + assert "Do you like us?" == our_strengths.name + assert "text_to_analyze" == our_strengths.type + assert "" == our_strengths.description + assert {"reviewed_count": 0, "learns_from": None} == our_strengths.metadata.dict() + assert 2 == len(our_strengths.topics) + + topic1, topic2 = our_strengths.topics[0], our_strengths.topics[1] + assert re.search(r"^cd_", topic1.id) + assert "price" == topic1.label + assert "SERVICE" == topic1.category + assert "" == topic1.color + assert "" == topic1.description + assert topic1.sentiment_enabled is True + assert {"code": 0, "label": ""} == topic1.sentiment_neutral.dict() + assert {"code": 1, "label": ""} == topic1.sentiment_positive.dict() + assert {"code": 2, "label": ""} == topic1.sentiment_negative.dict() + + assert re.search(r"^cd_", topic2.id) + assert "network quality" == topic2.label + assert "SERVICE" == topic2.category + assert "" == topic2.color + assert "" == topic2.description + assert topic2.sentiment_enabled is False + assert {"code": 3, "label": ""} == topic2.sentiment_neutral.dict() + assert {"code": -1, "label": ""} == topic2.sentiment_negative.dict() + assert {"code": -1, "label": ""} == topic2.sentiment_positive.dict() + + # auxiliary columns + assert "customer_age" == customer_age.ref + assert "Age of the customer" == customer_age.name + assert "numerical" == customer_age.type + + assert "boolean_col" == boolean_col.ref + assert "Some example boolean" == boolean_col.name + assert "boolean" == boolean_col.type + + assert "text_col" == text_col.ref + assert "Some auxiliary text" == text_col.name + assert "text" == text_col.type + + assert "date_col" == date_col.ref + assert "Some date time values." == date_col.name + assert "date" == date_col.type + + +def test_creating_a_project_with_settings_succeeds( + create_project: CreateProjectFunctionType, +) -> None: + project = create_project(project_create_payload_model()) + + assert isinstance(project.id, str) + assert "Project Name" == project.name + assert isinstance(project.owner, str) + assert ["my-tag"] == project.tags + assert "pending" == project.upload_status + assert "en" == project.language + assert project.translation_status is None + assert project.translation_engine is None + assert isinstance(project.created, datetime) + assert isinstance(project.last_modified, datetime) + + assert 6 == len(project.columns) + assert isinstance(project.columns[0], ProjectDetail.TextToAnalyze) + assert isinstance(project.columns[1], ProjectDetail.TextToAnalyze) + assert isinstance(project.columns[2], ProjectDetail.Auxiliary) + assert isinstance(project.columns[3], ProjectDetail.Auxiliary) + assert isinstance(project.columns[4], ProjectDetail.Auxiliary) + assert isinstance(project.columns[5], ProjectDetail.Auxiliary) + our_strengths = cast(ProjectDetail.TextToAnalyze, project.columns[0]) # type: ignore + another_tta = cast(ProjectDetail.TextToAnalyze, project.columns[1]) # type: ignore + customer_age = cast(ProjectDetail.Auxiliary, project.columns[2]) # type: ignore + boolean_col = cast(ProjectDetail.Auxiliary, project.columns[3]) # type: ignore + text_col = cast(ProjectDetail.Auxiliary, project.columns[4]) # type: ignore + date_col = cast(ProjectDetail.Auxiliary, project.columns[5]) # type: ignore + + # our strenghts column + assert "our_strengths" == our_strengths.ref + assert "Do you like us?" == our_strengths.name + assert "text_to_analyze" == our_strengths.type + assert "" == our_strengths.description + assert {"reviewed_count": 0, "learns_from": None} == our_strengths.metadata.dict() + assert 2 == len(our_strengths.topics) + + topic1, topic2 = our_strengths.topics[0], our_strengths.topics[1] + assert re.search(r"^cd_", topic1.id) + assert "price" == topic1.label + assert "SERVICE" == topic1.category + assert "" == topic1.color + assert "" == topic1.description + assert topic1.sentiment_enabled is True + assert {"code": 0, "label": ""} == topic1.sentiment_neutral.dict() + assert {"code": 1, "label": ""} == topic1.sentiment_positive.dict() + assert {"code": 2, "label": ""} == topic1.sentiment_negative.dict() + + assert re.search(r"^cd_", topic2.id) + assert "network quality" == topic2.label + assert "SERVICE" == topic2.category + assert "" == topic2.color + assert "" == topic2.description + assert topic2.sentiment_enabled is False + assert {"code": 3, "label": ""} == topic2.sentiment_neutral.dict() + assert {"code": -1, "label": ""} == topic2.sentiment_negative.dict() + assert {"code": -1, "label": ""} == topic2.sentiment_positive.dict() + + # another tta column + assert "another_tta" == another_tta.ref + assert "Do you want more tta?" == another_tta.name + assert "text_to_analyze" == another_tta.type + assert "" == another_tta.description + assert {"reviewed_count": 0, "learns_from": None} == another_tta.metadata.dict() + assert 2 == len(another_tta.topics) + + topic1, topic2 = another_tta.topics[0], another_tta.topics[1] + assert re.search(r"^cd_", topic1.id) + assert "label1" == topic1.label + assert "ANOTHER" == topic1.category + assert "" == topic1.color + assert "" == topic1.description + assert topic1.sentiment_enabled is True + assert {"code": 0, "label": ""} == topic1.sentiment_neutral.dict() + assert {"code": 1, "label": ""} == topic1.sentiment_positive.dict() + assert {"code": 2, "label": ""} == topic1.sentiment_negative.dict() + + assert re.search(r"^cd_", topic2.id) + assert "label2" == topic2.label + assert "ANOTHER" == topic2.category + assert "" == topic2.color + assert "" == topic2.description + assert topic2.sentiment_enabled is False + assert {"code": 3, "label": ""} == topic2.sentiment_neutral.dict() + assert {"code": -1, "label": ""} == topic2.sentiment_negative.dict() + assert {"code": -1, "label": ""} == topic2.sentiment_positive.dict() + + # auxiliary columns + assert "customer_age" == customer_age.ref + assert "Age of the customer" == customer_age.name + assert "numerical" == customer_age.type + + assert "boolean_col" == boolean_col.ref + assert "Some example boolean" == boolean_col.name + assert "boolean" == boolean_col.type + + assert "text_col" == text_col.ref + assert "Some auxiliary text" == text_col.name + assert "text" == text_col.type + + assert "date_col" == date_col.ref + assert "Some date time values." == date_col.name + assert "date" == date_col.type + + +def test_retrieving_a_project_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + retrieved = controller.retrieve(id=project.id) + + assert project.dict() == retrieved.dict() + + +def test_removing_a_project_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + old_num_projects = controller.list(limit=1).count + project = create_project() + interim_num_projects = controller.list(limit=1).count + controller.remove(id=project.id) + new_num_projects = controller.list(limit=1).count + + assert old_num_projects == new_num_projects + assert old_num_projects + 1 == interim_num_projects + + +def test_updating_a_project_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project_to_learn_from = create_project() + project = create_project() + + # test: updating properties succeeds + expected_dict = project.dict() + project.name = "MY SUPER NOVEL PROJECT NAME" + project.tags = ["new", "tags", "are", "cool"] + our_strengths: ProjectDetail.TextToAnalyze = project.columns[0] # type: ignore + our_strengths.name = "Do you still like us?" + our_strengths.description = "Please explain." + our_strengths.metadata.learns_from = controller.build( + ProjectDetail.TextToAnalyze.Metadata.LearnsForm, + {"project": project_to_learn_from.id, "ref": project_to_learn_from.columns[0].ref}, + ) + project.columns[1].name = "COOL NAME" + project.save() + expected_dict["name"] = "MY SUPER NOVEL PROJECT NAME" + expected_dict["tags"] = ["new", "tags", "are", "cool"] + expected_dict["columns"][0].update( + {"name": "Do you still like us?", "description": "Please explain."} + ) + expected_dict["columns"][0]["metadata"].update( + { + "learns_from": { + "project": project_to_learn_from.id, + "ref": project_to_learn_from.columns[0].ref, } - ) - expected_dict["columns"][1]["name"] = "COOL NAME" - self.assertDictEqual(project.dict(), expected_dict) - - # test: resetting learns_from succeeds - our_strengths: ProjectDetail.TextToAnalyze = project.columns[0] # type: ignore - our_strengths.metadata.learns_from = None - project.save() - expected_dict["columns"][0]["metadata"]["learns_from"] = None - - # last modified is updated - project_dict = project.dict() - project_dict.pop("last_modified") - expected_dict.pop("last_modified") - self.assertDictEqual(project_dict, expected_dict) - - def test_listing_all_projects_succeeds(self) -> None: - project = self.create_project() - projects = self.controller.list(limit=1) - - self.assertIsInstance(projects.count, int) - self.assertGreaterEqual(projects.count, 0) - self.assertEqual(len(projects), 1) - retrieved = (list(projects))[0] - - project_dict = project.dict() - project_dict.pop("columns") - self.assertDictEqual(project_dict, retrieved.dict()) - - def test_filtering_projects_succeeds(self) -> None: - filt = ProjectsFilter.language("tr") - projects = self.controller.list(filter=filt) - - self.assertEqual(0, len(projects)) - self.assertEqual(0, projects.count) - - def test_appending_multiple_rows_succeeds(self) -> None: - project = self.create_project() - response = self.controller.append_rows(id=project.id, rows=project_rows_create_payload()) - - self.assertEqual("pending", response.status) - self.assertEqual(2, response.queued_rows_count) - self.assertEqual(1.02, response.estimated_minutes) - self.assertEqual(2, len(response.results)) - self.assertTrue(all([isinstance(row.id, str) for row in response.results])) - - def test_getting_status_of_multiple_rows_upload_task(self) -> None: - project = self.create_project() - row_1 = project.append_rows(rows=project_rows_create_payload()) - row_2 = project.append_rows(rows=project_rows_create_payload()) - - all_tasks_status = self.controller.get_append_status(project_id=project.id) - self.assertIsNotNone(all_tasks_status.tasks) - all_tasks_ids = [task["id"] for task in all_tasks_status.tasks] # type: ignore[index,union-attr] - self.assertIn(row_1.task_id, all_tasks_ids) - self.assertIn(row_2.task_id, all_tasks_ids) - self.assertIn(all_tasks_status.status, ["in_progress", "succeeded"]) - # As we do not re-play api responses in tests here we do not know if status is already finished or no - self.assertEqual(len(all_tasks_status.dict()["tasks"]), 2) - - tasks_ids = [task["id"] for task in all_tasks_status.tasks] # type: ignore[index,union-attr] - for task_id in tasks_ids: - task_data = self.controller.get_append_status(project_id=project.id, task_id=task_id) - self.assertIn(task_data.status, ["in_progress", "succeeded"]) - - def test_appending_single_row_succeeds(self) -> None: - project = self.create_project() - columns: List[Dict[str, Any]] = [ + } + ) + expected_dict["columns"][1]["name"] = "COOL NAME" + assert project.dict() == expected_dict + + # test: resetting learns_from succeeds + our_strengths: ProjectDetail.TextToAnalyze = project.columns[0] # type: ignore + our_strengths.metadata.learns_from = None + project.save() + expected_dict["columns"][0]["metadata"]["learns_from"] = None + + # last modified is updated + project_dict = project.dict() + project_dict.pop("last_modified") + expected_dict.pop("last_modified") + assert project_dict == expected_dict + + +def test_listing_all_projects_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + projects = controller.list(limit=1) + + assert isinstance(projects.count, int) + assert projects.count >= 0 + assert len(projects) == 1 + retrieved = (list(projects))[0] + + project_dict = project.dict() + project_dict.pop("columns") + assert project_dict == retrieved.dict() + + +def test_filtering_projects_succeeds(controller: ProjectsController) -> None: + filt = ProjectsFilter.language("tr") + projects = controller.list(filter=filt) + + assert 0 == len(projects) + assert 0 == projects.count + + +@pytest.mark.parametrize( + # "payload", [project_rows_create_payload(), project_rows_create_payload_model()] + "payload", + [project_rows_create_payload_model()], +) +def test_appending_multiple_rows_succeeds( + controller: ProjectsController, + create_project: CreateProjectFunctionType, + payload: List[Dict[str, Any]], +) -> None: + project = create_project() + response = controller.append_rows(id=project.id, rows=payload) + + assert "pending" == response.status + assert 2 == response.queued_rows_count + assert 1.02 == response.estimated_minutes + assert 2 == len(response.results) + assert all([isinstance(row.id, str) for row in response.results]) + + +def test_getting_status_of_multiple_rows_upload_task( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + row_1 = project.append_rows(rows=project_rows_create_payload()) + row_2 = project.append_rows(rows=project_rows_create_payload()) + + all_tasks_status = controller.get_append_status(project_id=project.id) + assert all_tasks_status.tasks is not None + all_tasks_ids = [task["id"] for task in all_tasks_status.tasks] # type: ignore[index] + assert row_1.task_id in all_tasks_ids + assert row_2.task_id in all_tasks_ids + assert all_tasks_status.status in ["in_progress", "succeeded"] + # As we do not re-play api responses in tests here we do not know if status is already finished or no + assert len(all_tasks_status.dict()["tasks"]) == 2 + + tasks_ids = [task["id"] for task in all_tasks_status.tasks] # type: ignore[index] + for task_id in tasks_ids: + task_data = controller.get_append_status(project_id=project.id, task_id=task_id) + assert task_data.status in ["in_progress", "succeeded"] + + +@pytest.mark.parametrize( + "payload", [project_row_cells_payload(), project_row_cells_payload_model()] +) +def test_appending_single_row_succeeds( + controller: ProjectsController, + create_project: CreateProjectFunctionType, + payload: List[Dict[str, Any]], +) -> None: + project = create_project() + row = controller.append_row(id=project.id, columns=payload) + + assert isinstance(row.id, str) + assert isinstance(row.created, datetime) + assert isinstance(row.last_modified, datetime) + + assert 5 == len(row.columns) + assert isinstance(row.columns[0], Row.TextToAnalyzeColumn) + assert isinstance(row.columns[1], Row.NumericalColumn) + our_strengths = cast(Row.TextToAnalyzeColumn, row.columns[0]) # type: ignore + customer_age = cast(Row.NumericalColumn, row.columns[1]) # type: ignore + boolean_col = cast(Row.BooleanColumn, row.columns[2]) + text_col = cast(Row.TextColumn, row.columns[3]) + date_col = cast(Row.DateColumn, row.columns[4]) + + assert "our_strengths" == our_strengths.ref + assert "text_to_analyze" == our_strengths.type + assert "Good price." == our_strengths.value + assert our_strengths.was_reviewed is False + assert our_strengths.source_language is None + assert our_strengths.translated_value is None + assert 1 == len(our_strengths.topics) + topic = our_strengths.topics[0] + assert re.search(r"^cd_", topic.id) + assert topic.label == "price" + assert topic.category == "SERVICE" + assert topic.code == 1 + assert topic.sentiment_label == "" + assert topic.sentiment == "positive" + + assert "customer_age" == customer_age.ref + assert "numerical" == customer_age.type + assert customer_age.value is None + + assert "boolean_col" == boolean_col.ref + assert "boolean" == boolean_col.type + assert boolean_col.value is False + + assert "text_col" == text_col.ref + assert "text" == text_col.type + assert "" == text_col.value + + assert "date_col" == date_col.ref + assert "date" == date_col.type + assert datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc) == date_col.value + + +def test_listing_all_rows_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + row1 = controller.append_row( + id=project.id, + columns=[ {"ref": "customer_age", "value": None}, - {"ref": "our_strengths", "value": "Good price."}, + {"ref": "our_strengths", "value": "Some other text."}, {"ref": "boolean_col", "value": False}, - {"ref": "text_col", "value": None}, + {"ref": "text_col", "value": "iphone"}, { "ref": "date_col", "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), }, - ] - row = self.controller.append_row(id=project.id, columns=columns) - - self.assertIsInstance(row.id, str) - self.assertIsInstance(row.created, datetime) - self.assertIsInstance(row.last_modified, datetime) - - self.assertEqual(5, len(row.columns)) - self.assertIsInstance(row.columns[0], Row.TextToAnalyzeColumn) - self.assertIsInstance(row.columns[1], Row.NumericalColumn) - our_strengths = cast(Row.TextToAnalyzeColumn, row.columns[0]) - customer_age = cast(Row.NumericalColumn, row.columns[1]) - boolean_col = cast(Row.BooleanColumn, row.columns[2]) - text_col = cast(Row.TextColumn, row.columns[3]) - date_col = cast(Row.DateColumn, row.columns[4]) - - self.assertEqual("our_strengths", our_strengths.ref) - self.assertEqual("text_to_analyze", our_strengths.type) - self.assertEqual("Good price.", our_strengths.value) - self.assertEqual(False, our_strengths.was_reviewed) - self.assertEqual(None, our_strengths.source_language) - self.assertEqual(None, our_strengths.translated_value) - self.assertEqual(1, len(our_strengths.topics)) - topic = our_strengths.topics[0] - self.assertRegex(topic.id, r"^cd_") - self.assertEqual(topic.label, "price") - self.assertEqual(topic.category, "SERVICE") - self.assertEqual(topic.code, 1) - self.assertEqual(topic.sentiment_label, "") - self.assertEqual(topic.sentiment, "positive") - - self.assertEqual("customer_age", customer_age.ref) - self.assertEqual("numerical", customer_age.type) - self.assertEqual(None, customer_age.value) - - self.assertEqual("boolean_col", boolean_col.ref) - self.assertEqual("boolean", boolean_col.type) - self.assertEqual(False, boolean_col.value) - - self.assertEqual("text_col", text_col.ref) - self.assertEqual("text", text_col.type) - self.assertEqual("", text_col.value) - - self.assertEqual("date_col", date_col.ref) - self.assertEqual("date", date_col.type) - self.assertEqual( - datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), date_col.value - ) + ], + ) + row2 = controller.append_row( + id=project.id, + columns=[ + {"ref": "customer_age", "value": 12}, + {"ref": "our_strengths", "value": "This is my review. Very nice."}, + {"ref": "boolean_col", "value": True}, + {"ref": "text_col", "value": "samsung"}, + { + "ref": "date_col", + "value": datetime(year=2000, month=4, day=4, hour=4, tzinfo=timezone.utc), + }, + ], + ) + rows = [row1.dict(), row2.dict()] + retrieved = controller.list_rows(id=project.id, limit=2) - def test_listing_all_rows_succeeds(self) -> None: - project = self.create_project() - row1 = self.controller.append_row( - id=project.id, - columns=[ - {"ref": "customer_age", "value": None}, - {"ref": "our_strengths", "value": "Some other text."}, - {"ref": "boolean_col", "value": False}, - {"ref": "text_col", "value": "iphone"}, - { - "ref": "date_col", - "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), - }, - ], - ) - row2 = self.controller.append_row( - id=project.id, - columns=[ - {"ref": "customer_age", "value": 12}, - {"ref": "our_strengths", "value": "This is my review. Very nice."}, - {"ref": "boolean_col", "value": True}, - {"ref": "text_col", "value": "samsung"}, - { - "ref": "date_col", - "value": datetime(year=2000, month=4, day=4, hour=4, tzinfo=timezone.utc), - }, - ], - ) - rows = [row1.dict(), row2.dict()] - retrieved = self.controller.list_rows(id=project.id, limit=2) + assert 2 == retrieved.count + assert 2 == len(retrieved) - self.assertEqual(2, retrieved.count) - self.assertEqual(2, len(retrieved)) + retrieved_dict = [row.dict() for row in retrieved] + assert rows == retrieved_dict - retrieved_dict = [row.dict() for row in retrieved] - self.assertListEqual(rows, retrieved_dict) - def test_filtering_rows_succeeds(self) -> None: - project = self.create_project() - self.controller.append_row( - id=project.id, - columns=[ - {"ref": "customer_age", "value": None}, - {"ref": "our_strengths", "value": "Some other text."}, - {"ref": "boolean_col", "value": False}, - {"ref": "text_col", "value": "iphone"}, - { - "ref": "date_col", - "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), - }, - ], - ) +def test_filtering_rows_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + controller.append_row( + id=project.id, + columns=[ + {"ref": "customer_age", "value": None}, + {"ref": "our_strengths", "value": "Some other text."}, + {"ref": "boolean_col", "value": False}, + {"ref": "text_col", "value": "iphone"}, + { + "ref": "date_col", + "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), + }, + ], + ) - with_results = self.controller.list_rows(id=project.id) - self.assertEqual(1, with_results.count) + with_results = controller.list_rows(id=project.id) + assert 1 == with_results.count - filt = RowsFilter.Columns.text_to_analyze(ref="our_strengths", contains__i=["some"]) - filtered_results = self.controller.list_rows(id=project.id, filter=filt) - self.assertEqual(1, filtered_results.count) + filt = RowsFilter.Columns.text_to_analyze(ref="our_strengths", contains__i=["some"]) + filtered_results = controller.list_rows(id=project.id, filter=filt) + assert 1 == filtered_results.count - filt = RowsFilter.Columns.text_to_analyze(ref="our_strengths", was_reviewed=True) - no_results = self.controller.list_rows(id=project.id, filter=filt) - self.assertEqual(0, no_results.count) + filt = RowsFilter.Columns.text_to_analyze(ref="our_strengths", was_reviewed=True) + no_results = controller.list_rows(id=project.id, filter=filt) + assert 0 == no_results.count - def test_retrieving_a_row_succeeds(self) -> None: - project = self.create_project() - row = self.controller.append_row( - id=project.id, - columns=[ - {"ref": "customer_age", "value": 400}, - {"ref": "our_strengths", "value": "Some other text."}, - {"ref": "boolean_col", "value": False}, - {"ref": "text_col", "value": "iphone"}, - { - "ref": "date_col", - "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), - }, - ], - ) - retrieved = self.controller.retrieve_row(p_id=project.id, r_id=row.id) - self.assertDictEqual(row.dict(), retrieved.dict()) - self.assertDictEqual(row._metadata, {"project": project.id}) +def test_retrieving_a_row_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + row = controller.append_row( + id=project.id, + columns=[ + {"ref": "customer_age", "value": 400}, + {"ref": "our_strengths", "value": "Some other text."}, + {"ref": "boolean_col", "value": False}, + {"ref": "text_col", "value": "iphone"}, + { + "ref": "date_col", + "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), + }, + ], + ) + retrieved = controller.retrieve_row(p_id=project.id, r_id=row.id) - def test_removing_a_row_succeeds(self) -> None: - project = self.create_project() + assert row.dict() == retrieved.dict() + assert row._metadata == {"project": project.id} - old_num_rows = self.controller.list_rows(id=project.id, limit=1).count - row = self.controller.append_row( - id=project.id, - columns=[ - {"ref": "customer_age", "value": 400}, - {"ref": "our_strengths", "value": "Some other text."}, - {"ref": "boolean_col", "value": False}, - {"ref": "text_col", "value": "iphone"}, - { - "ref": "date_col", - "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), - }, - ], + +def test_removing_a_row_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + + old_num_rows = controller.list_rows(id=project.id, limit=1).count + row = controller.append_row( + id=project.id, + columns=[ + {"ref": "customer_age", "value": 400}, + {"ref": "our_strengths", "value": "Some other text."}, + {"ref": "boolean_col", "value": False}, + {"ref": "text_col", "value": "iphone"}, + { + "ref": "date_col", + "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), + }, + ], + ) + interim_num_rows = controller.list_rows(id=project.id, limit=1).count + controller.remove_row(p_id=project.id, r_id=row.id) + new_num_rows = controller.list_rows(id=project.id, limit=1).count + + assert old_num_rows == new_num_rows + assert old_num_rows + 1 == interim_num_rows + + +def test_updating_a_row_succeeds( + controller: ProjectsController, create_project: CreateProjectFunctionType +) -> None: + project = create_project() + row = controller.append_row( + id=project.id, + columns=[ + {"ref": "customer_age", "value": 400}, + {"ref": "our_strengths", "value": "Some other text."}, + {"ref": "boolean_col", "value": False}, + {"ref": "text_col", "value": "iphone"}, + { + "ref": "date_col", + "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), + }, + ], + ) + expected_dict = row.dict() + our_strengths: Row.TextToAnalyzeColumn = row.columns[0] # type: ignore + our_strengths.value = "this is a new text value." + our_strengths.was_reviewed = True + customer_age: Row.NumericalColumn = row.columns[1] # type: ignore + customer_age.value = 100000 + + row.save() + row_dict = row.dict() + expected_dict["columns"][0].update({"value": "this is a new text value.", "was_reviewed": True}) + computed_row_fields = {"last_modified"} + # computed fields are updated when the value is changed, so don't compare them + for field in computed_row_fields: + expected_dict.pop(field) + row_dict.pop(field) + computed_tta_column_fields = {"sentiment_overall", "translated_value", "topics"} + for computed_field in computed_tta_column_fields: + expected_dict["columns"][0].pop(computed_field) + row_dict["columns"][0].pop(computed_field) + expected_dict["columns"][1].update({"value": 100000}) + assert row_dict == expected_dict + + +def test_limit_calls_to_backend_on_upload_task(controller: ProjectsController) -> None: + task_uuid = uuid4() + api_base_uri = controller.config.api_base_uri.value + with requests_mock.Mocker() as mocked_project_page: + pr1_mock = mocked_project_page.get( + f"{api_base_uri}/projects/1/rows/bulk", json={"tasks": [], "status": ""} ) - interim_num_rows = self.controller.list_rows(id=project.id, limit=1).count - self.controller.remove_row(p_id=project.id, r_id=row.id) - new_num_rows = self.controller.list_rows(id=project.id, limit=1).count - - self.assertEqual(old_num_rows, new_num_rows) - self.assertEqual(old_num_rows + 1, interim_num_rows) - - def test_updating_a_row_succeeds(self) -> None: - project = self.create_project() - row = self.controller.append_row( - id=project.id, - columns=[ - {"ref": "customer_age", "value": 400}, - {"ref": "our_strengths", "value": "Some other text."}, - {"ref": "boolean_col", "value": False}, - {"ref": "text_col", "value": "iphone"}, - { - "ref": "date_col", - "value": datetime(year=2020, month=10, day=10, hour=17, tzinfo=timezone.utc), - }, - ], + pr2_mock = mocked_project_page.get( + f"{api_base_uri}/projects/2/rows/bulk", json={"tasks": [], "status": ""} ) - expected_dict = row.dict() - our_strengths: Row.TextToAnalyzeColumn = row.columns[0] # type: ignore - our_strengths.value = "this is a new text value." - our_strengths.was_reviewed = True - customer_age: Row.NumericalColumn = row.columns[1] # type: ignore - customer_age.value = 100000 - - row.save() - row_dict = row.dict() - expected_dict["columns"][0].update( - {"value": "this is a new text value.", "was_reviewed": True} + task_mock = mocked_project_page.get( + f"{api_base_uri}/projects/1/rows/bulk/{task_uuid}", + json={"tasks": [], "status": ""}, ) - computed_row_fields = {"last_modified"} - # computed fields are updated when the value is changed, so don't compare them - for field in computed_row_fields: - expected_dict.pop(field) - row_dict.pop(field) - computed_tta_column_fields = {"sentiment_overall", "translated_value", "topics"} - for computed_field in computed_tta_column_fields: - expected_dict["columns"][0].pop(computed_field) - row_dict["columns"][0].pop(computed_field) - expected_dict["columns"][1].update({"value": 100000}) - self.assertDictEqual(row_dict, expected_dict) - - def test_limit_calls_to_backend_on_upload_task(self) -> None: - task_uuid = uuid4() - with requests_mock.Mocker() as mocked_project_page: - pr1_mock = mocked_project_page.get( - "http://localhost:8000/v2/projects/1/rows/bulk", json={"tasks": [], "status": ""} - ) - pr2_mock = mocked_project_page.get( - "http://localhost:8000/v2/projects/2/rows/bulk", json={"tasks": [], "status": ""} - ) - task_mock = mocked_project_page.get( - f"http://localhost:8000/v2/projects/1/rows/bulk/{task_uuid}", - json={"tasks": [], "status": ""}, - ) - self.controller.get_append_status(project_id="1") - self.assertEqual(pr1_mock.call_count, 1) - self.controller.get_append_status(project_id="1") - self.controller.get_append_status(project_id="1") - self.controller.get_append_status(project_id="1") - self.assertEqual(pr1_mock.call_count, 1) - - self.controller.get_append_status(project_id="2") - self.assertEqual(pr2_mock.call_count, 1) - - time.sleep(10) - self.controller.get_append_status(project_id="1") - self.assertEqual(pr1_mock.call_count, 2) - self.controller.get_append_status(project_id="1", task_id=task_uuid) - self.assertEqual(task_mock.call_count, 1) + controller.get_append_status(project_id="1") + assert pr1_mock.call_count == 1 + controller.get_append_status(project_id="1") + controller.get_append_status(project_id="1") + controller.get_append_status(project_id="1") + assert pr1_mock.call_count == 1 + + controller.get_append_status(project_id="2") + assert pr2_mock.call_count == 1 + + time.sleep(10) + controller.get_append_status(project_id="1") + assert pr1_mock.call_count == 2 + controller.get_append_status(project_id="1", task_id=task_uuid) + assert task_mock.call_count == 1 diff --git a/tests/models/test_projects_models.py b/tests/models/test_projects_models.py new file mode 100644 index 0000000..bce4655 --- /dev/null +++ b/tests/models/test_projects_models.py @@ -0,0 +1,132 @@ +from datetime import datetime +from typing import List, Optional, Union + +import pytest + +from caplena.errors import DuplicatedTopicsError +from caplena.models.projects import ( + NonTTACell, + NonTTAColumnDefinition, + NonTTAColumnType, + Topic, + TopicDefinition, + TopicSentiment, + TTACell, +) + + +class TestTTACell: + @pytest.mark.parametrize( + "topics", + [ + [ + Topic(id="one", sentiment=TopicSentiment.ANY), + Topic(id="two", sentiment=TopicSentiment.ANY), + ] + ], + ) + def test_cell_without_duplicated_topics_succeeds(self, topics: List[Topic]) -> None: + actual = TTACell(ref="_", value="_", topics=topics) + assert actual + + @pytest.mark.parametrize( + "topics", + [ + [ + Topic(id="one", sentiment=TopicSentiment.ANY), + Topic(id="one", sentiment=TopicSentiment.ANY), + Topic(id="two", sentiment=TopicSentiment.ANY), + ] + ], + ) + def test_cell_with_duplicated_topics_raises(self, topics: List[Topic]) -> None: + with pytest.raises(DuplicatedTopicsError): + TTACell(ref="_", value="_", topics=topics) + + +class TestNonTTACell: + NOW_DT = datetime.now() + + @pytest.fixture + def cell(self, cell_value: Optional[Union[int, str, bool, datetime]]) -> NonTTACell: + return NonTTACell(ref="_", value=cell_value) + + @pytest.mark.parametrize( + "cell_value, expected", + [ + (1, 1), + ("1", "1"), + ("True", "True"), + (True, True), + ("2023-06-29 11:11:43.377780", "2023-06-29 11:11:43.377780"), + (NOW_DT, NOW_DT), + ], + ) + def test_values_preserve_their_types( + self, cell: NonTTACell, expected: Union[int, str, bool, datetime] + ) -> None: + actual = cell + assert actual.value == expected + + +NOT_PROVIDED = object() + + +class TestTopicDefinition: + @pytest.fixture + def topic_definition(self, sentiment_enabled: bool) -> TopicDefinition: + return TopicDefinition(label="_", sentiment_enabled=sentiment_enabled, category="_") + + @pytest.mark.parametrize( + "sentiment, sentiment_enabled, expected", + [ + (NOT_PROVIDED, False, TopicSentiment.ANY), + (TopicSentiment.NEUTRAL, False, TopicSentiment.ANY), + (TopicSentiment.POSITIVE, False, TopicSentiment.ANY), + (TopicSentiment.NEGATIVE, False, TopicSentiment.ANY), + (NOT_PROVIDED, True, TopicSentiment.ANY), + (TopicSentiment.NEUTRAL, True, TopicSentiment.NEUTRAL), + (TopicSentiment.POSITIVE, True, TopicSentiment.POSITIVE), + (TopicSentiment.NEGATIVE, True, TopicSentiment.NEGATIVE), + ], + ) + def test_build_topic_respects_sentiment_enabled( + self, topic_definition: TopicDefinition, sentiment: TopicSentiment, expected: TopicSentiment + ) -> None: + if sentiment == NOT_PROVIDED: + actual = topic_definition.build_topic(id="_") + else: + actual = topic_definition.build_topic(id="_", sentiment=sentiment) + + assert actual.sentiment == expected.value # type: ignore[comparison-overlap] + + +class TestNonTTAColumnDefinition: + @pytest.mark.parametrize( + "type, value, expected", + [ + (NonTTAColumnType.numerical, 1, 1), + (NonTTAColumnType.numerical, "1", 1), + ( + NonTTAColumnType.date, + str(datetime(2023, 6, 29, 11, 11, 11)), + datetime(2023, 6, 29, 11, 11, 11), + ), + (NonTTAColumnType.boolean, True, True), + (NonTTAColumnType.boolean, "True", True), + (NonTTAColumnType.text, True, "True"), + (NonTTAColumnType.text, 1, "1"), + (NonTTAColumnType.text, "1", "1"), + ( + NonTTAColumnType.text, + str(datetime(2023, 6, 29, 11, 11, 11)), + str(datetime(2023, 6, 29, 11, 11, 11)), + ), + ], + ) + def test_build_cell( + self, type: NonTTAColumnType, value: str, expected: Union[int, datetime, bool, str] + ) -> None: + non_tta_col_def = NonTTAColumnDefinition(ref="_", type=type, name="_") + actual = non_tta_col_def.build_cell(ref="_", value=value) + assert actual.value == expected diff --git a/tests/validations/test_projects_validations.py b/tests/validations/test_projects_validations.py new file mode 100644 index 0000000..7b5016c --- /dev/null +++ b/tests/validations/test_projects_validations.py @@ -0,0 +1,37 @@ +from typing import List + +import pytest + +from caplena.errors import DuplicatedTopicsError +from caplena.models.projects import Topic, TopicSentiment +from caplena.validations.projects import validate_no_duplicated_topics + + +@pytest.fixture +def topics(topic_ids: List[str]) -> List[Topic]: + return [Topic(id=tid, sentiment=TopicSentiment.ANY) for tid in topic_ids] + + +@pytest.mark.parametrize( + "topic_ids", + [ + ["one", "two", "one"], + ["one", "one"], + ], +) +def test_validate_no_duplicated_topics_raises(topics: List[Topic]) -> None: + with pytest.raises(DuplicatedTopicsError): + validate_no_duplicated_topics(topics) + + +@pytest.mark.parametrize( + "topic_ids", + [ + [], + ["one"], + ["one", "two", "three"], + ], +) +def test_validate_no_duplicated_topics_succeeds(topics: List[Topic]) -> None: + validate_no_duplicated_topics(topics) + assert True