diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ff4c2e5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +default_install_hook_types: +- pre-commit +- pre-push +- commit-msg + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + # - id: check-yaml + - id: end-of-file-fixer + stages: [commit] + - id: trailing-whitespace + stages: [commit] + - id: debug-statements + stages: [push] + +# ## Isort +# - repo: https://github.com/pycqa/isort +# rev: 5.12.0 +# hooks: +# - id: isort +# name: isort +# stages: [pre-commit] + +# ## Black +# - repo: https://github.com/psf/black +# rev: 23.3.0 +# hooks: +# - id: black +# stages: [pre-commit] + +# ## Ruff +# - repo: https://github.com/charliermarsh/ruff-pre-commit +# rev: v0.0.260 +# hooks: +# - id: ruff +# stages: [pre-commit] diff --git a/README.md b/README.md index 81ad98f..089cdbe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Tests](https://github.com//ckanext-relationship/workflows/Tests/badge.svg?branch=main)](https://github.com//ckanext-relationship/actions) +[![Tests](https://github.com/DataShades/ckanext-relationship/workflows/Tests/badge.svg)](https://github.com/DataShades/ckanext-relationship/actions/workflows/test.yml) # ckanext-relationship diff --git a/ckanext/relationship/i18n/.gitignore b/ckanext/relationship/i18n/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/ckanext/relationship/logic/auth.py b/ckanext/relationship/logic/auth.py index db0b668..87e4f60 100644 --- a/ckanext/relationship/logic/auth.py +++ b/ckanext/relationship/logic/auth.py @@ -24,3 +24,8 @@ def relationship_relations_ids_list(context, data_dict): @tk.auth_allow_anonymous_access def relationship_get_entity_list(context, data_dict): return {"success": True} + + +@tk.auth_allow_anonymous_access +def relationship_relationship_autocomplete(context, data_dict): + return {"success": True} diff --git a/ckanext/relationship/migration/relationship/versions/520e6ea9f57c_create_relationship_table.py b/ckanext/relationship/migration/relationship/versions/520e6ea9f57c_create_relationship_table.py index a19ee07..103f286 100644 --- a/ckanext/relationship/migration/relationship/versions/520e6ea9f57c_create_relationship_table.py +++ b/ckanext/relationship/migration/relationship/versions/520e6ea9f57c_create_relationship_table.py @@ -5,6 +5,7 @@ Create Date: 2021-07-02 14:40:37.719003 """ + import sqlalchemy as sa from alembic import op diff --git a/ckanext/relationship/tests/conftest.py b/ckanext/relationship/tests/conftest.py new file mode 100644 index 0000000..9d43ed8 --- /dev/null +++ b/ckanext/relationship/tests/conftest.py @@ -0,0 +1,7 @@ +import pytest + + +@pytest.fixture +def clean_db(reset_db, migrate_db_for, with_plugins): + reset_db() + migrate_db_for("relationship") diff --git a/ckanext/relationship/fanstatic/.gitignore b/ckanext/relationship/tests/logic/__init__.py similarity index 100% rename from ckanext/relationship/fanstatic/.gitignore rename to ckanext/relationship/tests/logic/__init__.py diff --git a/ckanext/relationship/tests/logic/test_action.py b/ckanext/relationship/tests/logic/test_action.py new file mode 100644 index 0000000..406d986 --- /dev/null +++ b/ckanext/relationship/tests/logic/test_action.py @@ -0,0 +1,374 @@ +import pytest + +import ckan.plugins.toolkit as tk +from ckan.tests import factories + +from ckanext.relationship.model.relationship import Relationship + + +@pytest.mark.usefixtures("clean_db") +class TestRelationCreate: + def test_create_new_relation(self): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + relation_type = "related_to" + + result = tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + assert result[0]["subject_id"] == subject_id + assert result[0]["object_id"] == object_id + assert result[0]["relation_type"] == relation_type + + assert result[1]["subject_id"] == object_id + assert result[1]["object_id"] == subject_id + assert result[1]["relation_type"] == relation_type + + def test_does_not_create_duplicate_relation(self): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + result = tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + assert result is None + + def test_relation_is_added_to_db(self): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + relation_straight = Relationship.by_object_id( + subject_id, object_id, relation_type + ) + relation_reverse = Relationship.by_object_id( + object_id, subject_id, relation_type + ) + + assert relation_straight.subject_id == subject_id + assert relation_straight.object_id == object_id + assert relation_straight.relation_type == relation_type + + assert relation_reverse.subject_id == object_id + assert relation_reverse.object_id == subject_id + assert relation_reverse.relation_type == relation_type + + @pytest.mark.parametrize( + "relation_type", + [ + "related_to", + "parent_of", + "child_of", + ], + ) + def test_different_relation_types(self, relation_type): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + + result = tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + assert result[0]["relation_type"] == relation_type + assert ( + result[1]["relation_type"] + == Relationship.reverse_relation_type[relation_type] + ) + + +@pytest.mark.usefixtures("clean_db") +class TestRelationDelete: + def test_relation_delete(self): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + result = tk.get_action("relationship_relation_delete")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + assert result[0]["subject_id"] == subject_id + assert result[0]["object_id"] == object_id + assert result[0]["relation_type"] == relation_type + + assert result[1]["subject_id"] == object_id + assert result[1]["object_id"] == subject_id + assert result[1]["relation_type"] == relation_type + + def test_relation_deleted_from_db(self): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + tk.get_action("relationship_relation_delete")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + relation_straight = Relationship.by_object_id( + subject_id, object_id, relation_type + ) + relation_reverse = Relationship.by_object_id( + object_id, subject_id, relation_type + ) + + assert not relation_straight + assert not relation_reverse + + def test_relation_delete_after_dataset_delete(self): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + tk.get_action("package_delete")({"ignore_auth": True}, {"id": subject_id}) + + relation_straight = Relationship.by_object_id( + subject_id, object_id, relation_type + ) + relation_reverse = Relationship.by_object_id( + object_id, subject_id, relation_type + ) + + assert not relation_straight + assert not relation_reverse + + +@pytest.mark.usefixtures("clean_db") +class TestRelationList: + @pytest.mark.parametrize( + "subject_factory, object_factory, object_entity, object_type", + [ + (factories.Dataset, factories.Dataset, "package", "dataset"), + (factories.Dataset, factories.Organization, "organization", "organization"), + (factories.Dataset, factories.Group, "group", "group"), + (factories.Organization, factories.Dataset, "package", "dataset"), + ( + factories.Organization, + factories.Organization, + "organization", + "organization", + ), + (factories.Organization, factories.Group, "group", "group"), + (factories.Group, factories.Dataset, "package", "dataset"), + (factories.Group, factories.Organization, "organization", "organization"), + (factories.Group, factories.Group, "group", "group"), + ], + ) + def test_relation_list( + self, subject_factory, object_factory, object_entity, object_type + ): + subject = subject_factory() + object = object_factory() + + subject_id = subject["id"] + object_id = object["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + result = tk.get_action("relationship_relations_list")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_entity": object_entity, + "object_type": object_type, + "relation_type": relation_type, + }, + ) + + assert result[0]["subject_id"] == subject_id + assert result[0]["object_id"] == object_id + assert result[0]["relation_type"] == relation_type + + def test_relation_list_empty(self): + subject_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + relation_type = "related_to" + + result = tk.get_action("relationship_relations_list")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_entity": "package", + "object_type": "dataset", + "relation_type": relation_type, + }, + ) + + assert result == [] + + def test_relation_list_after_dataset_delete(self): + subject_dataset = factories.Dataset() + object_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object_id = object_dataset["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object_id, + "relation_type": relation_type, + }, + ) + + tk.get_action("package_delete")({"ignore_auth": True}, {"id": subject_id}) + + result = tk.get_action("relationship_relations_list")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_entity": "package", + "object_type": "dataset", + "relation_type": relation_type, + }, + ) + + assert result == [] + + +@pytest.mark.usefixtures("clean_db") +class TestRelationsIdsList: + def test_relations_ids_list(self): + subject_dataset = factories.Dataset() + object1_dataset = factories.Dataset() + object2_dataset = factories.Dataset() + + subject_id = subject_dataset["id"] + object1_id = object1_dataset["id"] + object2_id = object2_dataset["id"] + relation_type = "related_to" + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object1_id, + "relation_type": relation_type, + }, + ) + + tk.get_action("relationship_relation_create")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_id": object2_id, + "relation_type": relation_type, + }, + ) + + result = tk.get_action("relationship_relations_ids_list")( + {"ignore_auth": True}, + { + "subject_id": subject_id, + "object_entity": "package", + "object_type": "dataset", + "relation_type": relation_type, + }, + ) + + assert object1_id in result + assert object2_id in result diff --git a/ckanext/relationship/tests/test_utils.py b/ckanext/relationship/tests/test_utils.py new file mode 100644 index 0000000..e9df981 --- /dev/null +++ b/ckanext/relationship/tests/test_utils.py @@ -0,0 +1,23 @@ +import pytest + +from ckan.tests import factories + +from ckanext.relationship.utils import entity_name_by_id + + +@pytest.mark.usefixtures("clean_db") +class TestEntityNameById: + def test_entity_name_by_id_when_package_exists(self): + dataset = factories.Dataset() + assert entity_name_by_id(dataset["id"]) == dataset["name"] + + def test_entity_name_by_id_when_organization_exists(self): + organization = factories.Organization() + assert entity_name_by_id(organization["id"]) == organization["name"] + + def test_entity_name_by_id_when_group_exists(self): + group = factories.Group() + assert entity_name_by_id(group["id"]) == group["name"] + + def test_entity_name_by_id_when_no_entity_exists(self): + assert entity_name_by_id("nonexistent") is None diff --git a/ckanext/relationship/utils.py b/ckanext/relationship/utils.py index 242e78d..6e52a6f 100644 --- a/ckanext/relationship/utils.py +++ b/ckanext/relationship/utils.py @@ -39,7 +39,7 @@ def get_relation_field( """ schema = sch.scheming_get_schema("dataset", pkg_type) if not schema: - return [] + return {} for field in schema["dataset_fields"]: if ( field.get("related_entity") == object_entity @@ -50,30 +50,16 @@ def get_relation_field( return {} -def entity_name_by_id(entity_id): - """ - Returns pkg name by its id +def entity_name_by_id(entity_id: str) -> str: + """Retrieves the name of an entity given its ID. + The entity can be a package, organization, or group. """ + actions = ["package_show", "organization_show", "group_show"] - try: - pkg = tk.get_action("package_show")({"ignore_auth": True}, {"id": entity_id}) - if pkg: - return pkg.get("name") - except NotFound: - pass - - try: - org = tk.get_action("organization_show")( - {"ignore_auth": True}, {"id": entity_id} - ) - if org: - return org.get("name") - except NotFound: - pass - - try: - group = tk.get_action("group_show")({"ignore_auth": True}, {"id": entity_id}) - if group: - return group.get("name") - except NotFound: - pass + for action in actions: + try: + entity = tk.get_action(action)({"ignore_auth": True}, {"id": entity_id}) + if entity: + return entity.get("name") + except NotFound: + pass diff --git a/pyproject.toml b/pyproject.toml index e0e71d2..411dda1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,46 @@ [tool.ruff] target-version = "py38" +select = [ + # "B", # likely bugs and design problems + # "BLE", # do not catch blind exception + # "C40", # better list/set/dict comprehensions + # "C90", # check McCabe complexity + # "COM", # trailing commas + "E", # pycodestyle error + # "W", # pycodestyle warning + "F", # pyflakes + # "G", # format strings for logging statements + # "N", # naming conventions + # "PL", # pylint + # "PT", # pytest style + # "PIE", # misc lints + # "Q", # preferred quoting style + # "RET", # improvements for return statements + # "RSE", # improvements for rise statements + # "S", # security testing + # "SIM", # simplify code + # "T10", # debugging statements + # "T20", # print statements + # "TID", # tidier imports + # "TRY", # better exceptions + # "UP", # upgrade syntax for newer versions of the language +] +ignore = [ + "E712", # comparison to bool: violated by SQLAlchemy filters + "PT004", # fixture does not return anything, add leading underscore: violated by clean_db + "PLC1901", # simplify comparison to empty string: violated by SQLAlchemy filters +] + +[tool.ruff.per-file-ignores] +"ckanext/relationship/tests*" = ["S", "PL"] [tool.isort] known_ckan = "ckan" known_ckanext = "ckanext" known_self = "ckanext.relationship" sections = "FUTURE,STDLIB,FIRSTPARTY,THIRDPARTY,CKAN,CKANEXT,SELF,LOCALFOLDER" +profile = "black" [tool.pytest.ini_options] addopts = "--ckan-ini test.ini" @@ -19,8 +53,13 @@ filterwarnings = [ "ignore::DeprecationWarning", ] +[tool.git-changelog] +output = "CHANGELOG.md" +convention = "conventional" +parse-trailers = true + [tool.pyright] -pythonVersion = "3.7" +pythonVersion = "3.8" include = ["ckanext"] exclude = [ "**/test*", @@ -28,7 +67,7 @@ exclude = [ ] strict = [] -strictParameterNoneValue = true # type must be Optional if default value is None +strictParameterNoneValue = true # Check the meaning of rules here # https://github.com/microsoft/pyright/blob/main/docs/configuration.md @@ -48,7 +87,7 @@ reportOptionalCall = true reportOptionalIterable = true reportOptionalContextManager = true reportOptionalOperand = true -reportTypedDictNotRequiredAccess = false # We are using Context in a way that conflicts with this check +reportTypedDictNotRequiredAccess = false # Context won't work with this rule reportConstantRedefinition = true reportIncompatibleMethodOverride = true reportIncompatibleVariableOverride = true @@ -68,12 +107,12 @@ reportUnnecessaryCast = true reportUnnecessaryComparison = true reportAssertAlwaysTrue = true reportSelfClsParameterName = true -reportUnusedCallResult = false # allow function calls for side-effect only (like logic.check_acces) +reportUnusedCallResult = false # allow function calls for side-effect only useLibraryCodeForTypes = true reportGeneralTypeIssues = true reportPropertyTypeMismatch = true reportWildcardImportFromLibrary = true -reportUntypedClassDecorator = false # authenticator relies on repoze.who class-decorator +reportUntypedClassDecorator = false reportUntypedNamedTuple = true reportPrivateUsage = true reportPrivateImportUsage = true @@ -91,3 +130,7 @@ reportUnsupportedDunderAll = true reportUnusedCoroutine = true reportUnnecessaryTypeIgnoreComment = true reportMatchNotExhaustive = true + +[tool.coverage.run] +branch = true +omit = ["ckanext/relationship/tests/*"]