diff --git a/.github/workflows/run_gravyvalet_tests.yml b/.github/workflows/run_gravyvalet_tests.yml index 180f92e5..ec9a760b 100644 --- a/.github/workflows/run_gravyvalet_tests.yml +++ b/.github/workflows/run_gravyvalet_tests.yml @@ -14,6 +14,20 @@ jobs: postgres-version: ['15'] runs-on: ubuntu-latest services: + redis: + image: redis + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + rabbitmq: + image: rabbitmq:latest + ports: + - 5672:5672 postgres: image: postgres:${{ matrix.postgres-version }} env: @@ -54,7 +68,9 @@ jobs: run: poetry run python -Werror manage.py test env: DEBUG: 1 + AMQP_BROKER_URL: "amqp://guest:guest@localhost:5672" POSTGRES_HOST: localhost POSTGRES_DB: gravyvalettest POSTGRES_USER: postgres SECRET_KEY: oh-so-secret + REDIS_HOST: redis://localhost:6379 diff --git a/.gitignore b/.gitignore index d8c5b177..b0d66f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,9 @@ db.sqlite3 __pycache__ .venv - +.idea/ +.DS_Store +.DS_Store? +*.swp +*~ addon_service/static/gravyvalet_code_docs/ diff --git a/Dockerfile b/Dockerfile index 04590361..39d84b18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13 AS gv-base # System Dependencies: -RUN apt-get update && apt-get install -y libpq-dev +RUN apt-get update && apt-get install -y libpq-dev libxml2-dev libxslt-dev COPY pyproject.toml /code/ COPY poetry.lock /code/ @@ -15,7 +15,7 @@ ENV PATH="$PATH:/root/.local/bin" FROM python:3.13-slim AS gv-runtime-base # System Dependencies: -RUN apt-get update && apt-get install -y libpq-dev +RUN apt-get update && apt-get install -y libpq-dev libxml2-dev libxslt-dev COPY pyproject.toml /code/ COPY poetry.lock /code/ diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..4011a250 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +## Ticket + +[//]: # (Link to a JIRA ticket if available.) + +## Purpose + +[//]: # (Briefly describe the purpose of this PR.) + +## Changes + +[//]: # (Briefly describe or list your changes.) + +## Side Effects + +[//]: # (Any possible side effects?) + +## API Documentation + +[//]: # (Any API documentation updates?) + +## QE Notes + +[//]: # (Any QA testing notes for QE?) + +## CE Notes + +[//]: # (Any server configuration and deployment notes for CE?) diff --git a/addon_imps/link/__init__.py b/addon_imps/link/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/addon_imps/link/dataverse.py b/addon_imps/link/dataverse.py new file mode 100644 index 00000000..ba39a140 --- /dev/null +++ b/addon_imps/link/dataverse.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import asyncio +import re +from dataclasses import dataclass +from http import HTTPStatus +from typing import Literal + +from django.core.exceptions import ValidationError + +from addon_toolkit.interfaces.link import ( + ItemResult, + ItemSampleResult, + ItemType, + LinkAddonHttpRequestorImp, +) + + +DATAVERSE_REGEX = re.compile(r"^dataverse/(?P.*)$") +DATASET_REGEX = re.compile(r"^dataset/(?P.*)$") +FILE_REGEX = re.compile(r"^file/(?P.*)$") + + +@dataclass +class DataverseLinkImp(LinkAddonHttpRequestorImp): + """storage on dataverse + + see https://guides.dataverse.org/en/latest/api/native-api.html + """ + + async def build_url_for_id(self, item_id: str) -> str: + if match := DATAVERSE_REGEX.match(item_id): + entity_type = "dataverse" + elif match := DATASET_REGEX.match(item_id): + entity_type = "dataset" + elif match := FILE_REGEX.match(item_id): + entity_type = "file" + else: + raise ValidationError(f"Invalid {item_id=}") + + return self._make_url(entity_type, **match.groupdict()) + + def _make_url( + self, + entity_type: Literal["file", "dataset", "dataverse"], + persistent_id: str = None, + id_: str = None, + ): + if bool(id_) == bool(persistent_id): + raise ValueError("You must pass either id or persistent_id to build url") + + if persistent_id: + suffix = f".xhtml?persistentId={persistent_id}" + else: + suffix = f"/{id_}" + return f"{self.config.external_web_url}/{entity_type}{suffix}" + + async def get_external_account_id(self, _: dict[str, str]) -> str: + try: + async with self.network.GET("api/v1/users/:me") as response: + if not response.http_status.is_success: + raise ValidationError( + "Could not get dataverse account id, check your API Token" + ) + content = await response.json_content() + return content.get("data", {}).get("id") + except ValueError as exc: + if "relative url may not alter the base url" in str(exc).lower(): + raise ValidationError( + "Invalid host URL. Please check your Dataverse base URL." + ) + raise + + async def list_root_items(self, page_cursor: str = "") -> ItemSampleResult: + async with self.network.GET( + "api/mydata/retrieve", + query=[ + ["selected_page", page_cursor], + *[("role_ids", role) for role in range(1, 9)], + ( + "dvobject_types", + "Dataverse", + ), # only published dataverses may contain published datasets + ("published_states", "Published"), + ], + ) as response: + content = await response.json_content() + if resp_data := content.get("data"): + return parse_mydata(resp_data) + return ItemSampleResult(items=[], total_count=0) + + async def get_item_info(self, item_id: str) -> ItemResult: + if not item_id: + return ItemResult(item_id="", item_name="", item_type=ItemType.FOLDER) + elif match := DATAVERSE_REGEX.match(item_id): + entity = await self._fetch_dataverse(match["id_"]) + elif match := DATASET_REGEX.match(item_id): + entity = await self._fetch_dataset(persistent_id=match["persistent_id"]) + elif match := FILE_REGEX.match(item_id): + entity = await self._fetch_file(match["persistent_id"]) + else: + raise ValueError(f"Invalid item id: {item_id}") + + return entity + + async def list_child_items( + self, + item_id: str, + page_cursor: str = "", + item_type: ItemType | None = None, + ) -> ItemSampleResult: + if not item_id: + return await self.list_root_items(page_cursor) + elif match := DATAVERSE_REGEX.match(item_id): + items = await self._fetch_dataverse_items(match["id_"]) + return ItemSampleResult( + items=items, + total_count=len(items), + ) + elif match := DATASET_REGEX.match(item_id): + items = await self._fetch_dataset_files( + persistent_id=match["persistent_id"] + ) + return ItemSampleResult( + items=items, + total_count=len(items), + ) + else: + return ItemSampleResult(items=[], total_count=0) + + async def _fetch_dataverse_items(self, dataverse_id) -> list[ItemResult]: + async with self.network.GET( + f"api/dataverses/{dataverse_id}/contents" + ) as response: + response_content = await response.json_content() + items = await asyncio.gather( + *[ + self._get_dataverse_or_dataset_item(item) + for item in response_content["data"] + ] + ) + return [item for item in items if item] + + async def _get_dataverse_or_dataset_item(self, item: dict): + match item["type"]: + case "dataset": + return await self._fetch_dataset(dataset_id=item["id"]) + case "dataverse": + return await self._fetch_dataverse(item["id"]) + raise ValueError(f"Invalid item type: {item['type']}") + + async def _fetch_file(self, dataverse_id) -> ItemResult: + async with self.network.GET( + "api/files/:persistentId", query={"persistentId": dataverse_id} + ) as response: + return self._parse_datafile(await response.json_content()) + + async def _fetch_dataverse(self, dataverse_id) -> ItemResult: + async with self.network.GET(f"api/dataverses/{dataverse_id}") as response: + return parse_dataverse(await response.json_content()) + + async def _fetch_dataset_with_parser( + self, + dataset_id: str = None, + persistent_id: str = None, + parser=None, + ) -> ItemResult | list[ItemResult] | None: + url = f"api/datasets/{':persistentId' if persistent_id else dataset_id}/versions/:latest-published" + query = {"persistentId": persistent_id} if persistent_id else {} + async with self.network.GET(url, query=query) as response: + if response.http_status == HTTPStatus.NOT_FOUND: + return None + return parser(await response.json_content()) + + async def _fetch_dataset( + self, dataset_id: str = None, persistent_id: str = None + ) -> ItemResult: + return await self._fetch_dataset_with_parser( + dataset_id, persistent_id, parser=self._parse_dataset + ) + + async def _fetch_dataset_files( + self, dataset_id: str = None, persistent_id: str = None + ) -> list[ItemResult]: + return await self._fetch_dataset_with_parser( + dataset_id, persistent_id, parser=self._parse_dataset_files + ) + + def _parse_datafile(self, data: dict): + if data.get("data"): + data = data["data"] + + doi = data["dataFile"]["persistentId"] + return ItemResult( + item_id=f"file/{doi}", + item_name=data["label"], + item_type=ItemType.RESOURCE, + item_link=self._make_url("file", doi), + doi=doi, + ) + + def _parse_dataset_files(self, data: dict) -> list[ItemResult]: + if data.get("data"): + data = data["data"] + try: + return [self._parse_datafile(file) for file in data["files"]] + except (KeyError, IndexError) as e: + raise ValueError(f"Invalid dataset response:{e=}") + + def _parse_dataset(self, data: dict) -> ItemResult: + if data.get("data"): + data = data["data"] + try: + doi = data["datasetPersistentId"] + return ItemResult( + item_id=f"dataset/{doi}", + item_name=[ + item + for item in data["metadataBlocks"]["citation"]["fields"] + if item["typeName"] == "title" + ][0]["value"], + item_type=ItemType.FOLDER, + item_link=self._make_url("dataset", doi), + doi=doi, + ) + except (KeyError, IndexError) as e: + raise ValueError(f"Invalid dataset response: {e=}") + + +### +# module-local helpers + + +def parse_dataverse_as_subitem(data: dict): + return ItemResult( + item_type=ItemType.FOLDER, + item_name=data["title"], + item_id=f'dataverse/{data["id"]}', + ) + + +def parse_dataverse(data: dict): + if data.get("data"): + data = data["data"] + return ItemResult( + item_type=ItemType.FOLDER, + item_name=data["name"], + item_id=f'dataverse/{data["alias"]}', + ) + + +def parse_mydata(data: dict): + if data.get("data"): + data = data["data"] + return ItemSampleResult( + items=[ + ItemResult( + item_id=f"dataverse/{item['identifier']}", + item_name=item["name"], + item_type=ItemType.FOLDER, + ) + for item in data["items"] + ], + total_count=data["total_count"], + next_sample_cursor=( + data["pagination"]["nextPageNumber"] + if data["pagination"]["hasNextPageNumber"] + else None + ), + ) diff --git a/addon_imps/tests/link/test_dataverse.py b/addon_imps/tests/link/test_dataverse.py new file mode 100644 index 00000000..da8a3f37 --- /dev/null +++ b/addon_imps/tests/link/test_dataverse.py @@ -0,0 +1,338 @@ +import unittest +from http import HTTPStatus +from unittest.mock import ( + AsyncMock, + patch, +) + +from django.core.exceptions import ValidationError + +from addon_imps.link.dataverse import DataverseLinkImp +from addon_toolkit.constrained_network.http import HttpRequestor +from addon_toolkit.interfaces.link import ( + ItemResult, + ItemSampleResult, + ItemType, + LinkConfig, +) + + +class TestDataverseLinkImp(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.base_url = "https://dataverse.org" + self.network = AsyncMock(spec_set=HttpRequestor) + web_url = "https://dataverse.example.com" + self.imp = DataverseLinkImp( + network=self.network, + config=LinkConfig(external_api_url="", external_web_url=web_url), + ) + + def _patch_get(self, return_value, status_code=200): + mock = self.network.GET.return_value.__aenter__.return_value + mock.json_content = AsyncMock(return_value=return_value) + mock.http_status = HTTPStatus(status_code) + return mock + + def _assert_get(self, url, query=None): + extra_params = {"query": query} if query else {} + self.network.GET.assert_called_once_with(url, **extra_params) + self.network.GET.return_value.__aenter__.assert_awaited_once_with() + self.network.GET.return_value.__aenter__.return_value.json_content.assert_awaited_once_with() + self.network.GET.return_value.__aexit__.assert_awaited_once_with( + None, None, None + ) + + async def test_build_url_for_id(self): + web_url = "https://dataverse.example.com" + self.imp.config = LinkConfig(external_api_url="", external_web_url=web_url) + + url = await self.imp.build_url_for_id("dataset/doi:10.5072/FK2/ABCDEF") + + self.assertEqual( + url, f"{web_url}/dataset.xhtml?persistentId=doi:10.5072/FK2/ABCDEF" + ) + + async def test_get_external_account_id_success(self): + self._patch_get({"data": {"id": "user123"}}) + + result = await self.imp.get_external_account_id({}) + + self._assert_get("api/v1/users/:me") + self.assertEqual(result, "user123") + + async def test_get_external_account_id_invalid_url(self): + self.network.GET.side_effect = ValueError( + "Relative URL may not alter the base URL" + ) + + with self.assertRaises(ValidationError) as context: + await self.imp.get_external_account_id({}) + + self.assertIn("Invalid host URL", str(context.exception)) + + async def test_list_root_items(self): + mock_response = { + "data": { + "items": [ + {"entity_id": "123", "name": "Dataverse 1"}, + {"entity_id": "456", "name": "Dataverse 2"}, + ], + "total_count": 2, + "pagination": { + "nextPageNumber": "2", + "hasNextPageNumber": True, + }, + } + } + self._patch_get(mock_response) + + result = await self.imp.list_root_items() + + expected_items = [ + ItemResult( + item_id="dataverse/123", + item_name="Dataverse 1", + item_type=ItemType.FOLDER, + ), + ItemResult( + item_id="dataverse/456", + item_name="Dataverse 2", + item_type=ItemType.FOLDER, + ), + ] + expected_result = ItemSampleResult( + items=expected_items, total_count=2, next_sample_cursor="2" + ) + + self.assertEqual(len(result.items), len(expected_result.items)) + for i, item in enumerate(result.items): + self.assertEqual(item.item_id, expected_result.items[i].item_id) + self.assertEqual(item.item_name, expected_result.items[i].item_name) + self.assertEqual(item.item_type, expected_result.items[i].item_type) + self.assertEqual(result.total_count, expected_result.total_count) + self.assertEqual(result.next_sample_cursor, expected_result.next_sample_cursor) + + query_params = [ + ["selected_page", ""], + *[("role_ids", role) for role in range(1, 9)], + ("dvobject_types", "Dataverse"), + ("published_states", "Published"), + ] + self._assert_get("api/mydata/retrieve", query=query_params) + + async def test_list_root_items_with_page(self): + mock_response = { + "data": { + "items": [ + {"entity_id": "789", "name": "Dataverse 3"}, + ], + "total_count": 3, + "pagination": { + "nextPageNumber": "3", + "hasNextPageNumber": True, + }, + } + } + self._patch_get(mock_response) + + result = await self.imp.list_root_items(page_cursor="2") + + expected_items = [ + ItemResult( + item_id="dataverse/789", + item_name="Dataverse 3", + item_type=ItemType.FOLDER, + ), + ] + expected_result = ItemSampleResult( + items=expected_items, total_count=3, next_sample_cursor="3" + ) + + self.assertEqual(len(result.items), len(expected_result.items)) + self.assertEqual(result.items[0].item_id, expected_result.items[0].item_id) + self.assertEqual(result.next_sample_cursor, expected_result.next_sample_cursor) + + self.network.GET.assert_called_once() + call_args = self.network.GET.call_args[1] + self.assertEqual(call_args["query"][0][1], "2") + + async def test_list_root_items_empty_response(self): + self._patch_get({}) + + result = await self.imp.list_root_items() + + self.assertEqual(len(result.items), 0) + self.assertEqual(result.total_count, 0) + + async def test_get_item_info_empty(self): + result = await self.imp.get_item_info("") + + self.assertEqual(result.item_id, "") + self.assertEqual(result.item_name, "") + self.assertEqual(result.item_type, ItemType.FOLDER) + + async def test_get_item_info_dataverse(self): + dataverse_response = {"data": {"id": "123", "name": "Test Dataverse"}} + self._patch_get(dataverse_response) + + result = await self.imp.get_item_info("dataverse/123") + + expected_result = ItemResult( + item_id="dataverse/123", + item_name="Test Dataverse", + item_type=ItemType.FOLDER, + ) + + self.assertEqual(result.item_id, expected_result.item_id) + self.assertEqual(result.item_name, expected_result.item_name) + self.assertEqual(result.item_type, expected_result.item_type) + self._assert_get("api/dataverses/123") + + async def test_get_item_info_dataset(self): + expected_result = ItemResult( + item_id="dataset/doi:10.5072/FK2/ABCDEF", + item_name="Test Dataset", + item_type=ItemType.FOLDER, + ) + + # Mock the entire get_item_info method to test dataset handling + original_method = self.imp.get_item_info + self.imp.get_item_info = AsyncMock(return_value=expected_result) + + try: + result = await self.imp.get_item_info("dataset/doi:10.5072/FK2/ABCDEF") + + self.imp.get_item_info.assert_awaited_once_with( + "dataset/doi:10.5072/FK2/ABCDEF" + ) + self.assertEqual(result.item_id, expected_result.item_id) + self.assertEqual(result.item_name, expected_result.item_name) + self.assertEqual(result.item_type, expected_result.item_type) + finally: + # Restore original method + self.imp.get_item_info = original_method + + async def test_get_item_info_invalid(self): + with self.assertRaises(ValueError): + await self.imp.get_item_info("invalid/123") + + async def test_list_child_items_empty(self): + self.imp.list_root_items = AsyncMock( + return_value=ItemSampleResult(items=[], total_count=0) + ) + + result = await self.imp.list_child_items("") + + self.imp.list_root_items.assert_awaited_once_with("") + self.assertEqual(len(result.items), 0) + self.assertEqual(result.total_count, 0) + + async def test_list_child_items_dataverse(self): + dataverse_contents = { + "data": [ + {"type": "dataverse", "id": "456", "title": "Sub Dataverse"}, + {"type": "dataset", "id": "789", "title": "Dataset"}, + ] + } + + self._patch_get(dataverse_contents) + + with patch.object( + self.imp, "_fetch_dataset", new_callable=AsyncMock + ) as mock_fetch_dataset: + mock_fetch_dataset.return_value = ItemResult( + item_id="dataset/789", + item_name="Dataset", + item_type=ItemType.FOLDER, + ) + + result = await self.imp.list_child_items("dataverse/123") + + self._assert_get("api/dataverses/123/contents") + + expected_items = [ + ItemResult( + item_id="dataset/789", + item_name="Dataset", + item_type=ItemType.FOLDER, + ), + ItemResult( + item_id="dataverse/456", + item_name="Sub Dataverse", + item_type=ItemType.FOLDER, + ), + ] + + self.assertEqual(len(result.items), len(expected_items)) + self.assertEqual(result.total_count, len(expected_items)) + + item_ids = [item.item_id for item in result.items] + expected_ids = [item.item_id for item in expected_items] + for expected_id in expected_ids: + self.assertIn(expected_id, item_ids) + + async def test_parse_invalid_dataset(self): + # Mock the entire method to raise an exception + original_method = self.imp.get_item_info + self.imp.get_item_info = AsyncMock( + side_effect=ValueError("Invalid dataset response") + ) + + try: + with self.assertRaises(ValueError): + await self.imp.get_item_info("dataset/doi:INVALID") + + self.imp.get_item_info.assert_awaited_once_with("dataset/doi:INVALID") + finally: + # Restore original method + self.imp.get_item_info = original_method + + async def test_list_child_items_non_dataverse(self): + with patch.object( + self.imp, "_fetch_dataset_files", new_callable=AsyncMock + ) as mock_fetch_files: + mock_fetch_files.return_value = [] + + result = await self.imp.list_child_items("dataset/doi:10.5072/FK2/ABCDEF") + + mock_fetch_files.assert_awaited_once_with( + persistent_id="doi:10.5072/FK2/ABCDEF" + ) + + self.assertEqual(len(result.items), 0) + self.assertEqual(result.total_count, 0) + + async def test_make_url_with_config(self): + web_url = "https://dataverse.example.com" + imp = DataverseLinkImp( + network=self.network, + config=LinkConfig(external_api_url="", external_web_url=web_url), + ) + + dataset_url = imp._make_url("dataset", "doi:10.5072/FK2/ABCDEF") + file_url = imp._make_url("file", "doi:10.5072/FK2/FILE1") + + self.assertEqual( + dataset_url, f"{web_url}/dataset.xhtml?persistentId=doi:10.5072/FK2/ABCDEF" + ) + self.assertEqual( + file_url, f"{web_url}/file.xhtml?persistentId=doi:10.5072/FK2/FILE1" + ) + + async def test_build_url_for_id_file(self): + web_url = "https://dataverse.example.com" + self.imp.config = LinkConfig(external_api_url="", external_web_url=web_url) + + url = await self.imp.build_url_for_id("file/doi:10.5072/FK2/FILE1") + + self.assertEqual( + url, f"{web_url}/file.xhtml?persistentId=doi:10.5072/FK2/FILE1" + ) + + async def test_build_url_for_id_invalid(self): + self.imp.config = LinkConfig( + external_api_url="", external_web_url="https://dataverse.example.com" + ) + + with self.assertRaises(ValidationError): + await self.imp.build_url_for_id("invalid/id") diff --git a/addon_service/addon_imp/instantiation.py b/addon_service/addon_imp/instantiation.py index 427220cc..cad416fa 100644 --- a/addon_service/addon_imp/instantiation.py +++ b/addon_service/addon_imp/instantiation.py @@ -17,6 +17,12 @@ ComputingAddonImp, ComputingConfig, ) +from addon_toolkit.interfaces.link import ( + LinkAddonClientRequestorImp, + LinkAddonHttpRequestorImp, + LinkAddonImp, + LinkConfig, +) from addon_toolkit.interfaces.storage import ( StorageAddonClientRequestorImp, StorageAddonHttpRequestorImp, @@ -26,6 +32,7 @@ if TYPE_CHECKING: + from addon_service.authorized_account.link.models import AuthorizedLinkAccount from addon_service.authorized_account.models import AuthorizedAccount from addon_service.models import ( AuthorizedCitationAccount, @@ -37,7 +44,7 @@ async def get_addon_instance( imp_cls: type[AddonImp], account: AuthorizedAccount, - config: StorageConfig | CitationConfig | ComputingConfig, + config: StorageConfig | CitationConfig | ComputingConfig | LinkConfig, ) -> AddonImp: if issubclass(imp_cls, StorageAddonImp): return await get_storage_addon_instance(imp_cls, account, config) @@ -45,6 +52,8 @@ async def get_addon_instance( return await get_citation_addon_instance(imp_cls, account, config) elif issubclass(imp_cls, ComputingAddonImp): return await get_computing_addon_instance(imp_cls, account, config) + elif issubclass(imp_cls, LinkAddonImp): + return await get_link_addon_instance(imp_cls, account, config) raise ValueError(f"unknown addon type {imp_cls}") @@ -134,3 +143,28 @@ async def get_computing_addon_instance( get_computing_addon_instance__blocking = async_to_sync(get_computing_addon_instance) + + +async def get_link_addon_instance( + imp_cls: type[LinkAddonImp], account: AuthorizedLinkAccount, config: LinkConfig +) -> LinkAddonImp: + """create an instance of a `linkAddonImp`""" + + assert issubclass(imp_cls, AddonImp) + assert imp_cls is not LinkAddonImp, "Addons shouldn't directly extend LinkAddonImp" + if issubclass(imp_cls, LinkAddonHttpRequestorImp): + imp = imp_cls( + network=GravyvaletHttpRequestor( + client_session=await get_singleton_client_session(), + prefix_url=config.external_api_url, + account=account, + ), + config=config, + ) + if issubclass(imp_cls, LinkAddonClientRequestorImp): + imp = imp_cls(credentials=await account.get_credentials__async(), config=config) + + return imp + + +get_link_addon_instance__blocking = async_to_sync(get_link_addon_instance) diff --git a/addon_service/addon_operation_invocation/serializers.py b/addon_service/addon_operation_invocation/serializers.py index 80be67af..d6f97d4e 100644 --- a/addon_service/addon_operation_invocation/serializers.py +++ b/addon_service/addon_operation_invocation/serializers.py @@ -9,6 +9,7 @@ ) from addon_service.common import view_names from addon_service.common.enum_serializers import EnumNameChoiceField +from addon_service.common.get_user_uri import get_user_uri from addon_service.common.invocation_status import InvocationStatus from addon_service.common.serializer_fields import ( CustomPolymorphicResourceRelatedField, @@ -113,10 +114,7 @@ def create(self, validated_data): _imp_cls = _thru_account.imp_cls _operation = _imp_cls.get_operation_declaration(_operation_name) _request = self.context["request"] - _user_uri = ( - _request.session.get("user_reference_uri") - or f"{settings.OSF_BASE_URL}/anonymous" - ) + _user_uri = get_user_uri(_request) or f"{settings.OSF_BASE_URL}/anonymous" _user, _ = UserReference.objects.get_or_create(user_uri=_user_uri) return AddonOperationInvocation( operation=AddonOperationModel(_imp_cls.ADDON_INTERFACE, _operation), diff --git a/addon_service/addon_operation_invocation/views.py b/addon_service/addon_operation_invocation/views.py index da7736d3..300a4bb5 100644 --- a/addon_service/addon_operation_invocation/views.py +++ b/addon_service/addon_operation_invocation/views.py @@ -1,12 +1,15 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) from rest_framework.response import Response from addon_service.common.permissions import ( IsAuthenticated, - SessionUserIsOwner, SessionUserMayAccessInvocation, SessionUserMayPerformInvocation, ) -from addon_service.common.viewsets import RetrieveWriteViewSet +from addon_service.common.viewsets import RetrieveCreateViewSet from addon_service.tasks.invocation import ( perform_invocation__blocking, perform_invocation__celery, @@ -19,17 +22,28 @@ from ..authorized_account.computing.serializers import ( AuthorizedComputingAccountSerializer, ) +from ..authorized_account.link.serializers import AuthorizedLinkAccountSerializer from ..authorized_account.models import AuthorizedAccount from ..authorized_account.storage.serializers import AuthorizedStorageAccountSerializer from ..configured_addon.citation.serializers import ConfiguredCitationAddonSerializer from ..configured_addon.computing.serializers import ConfiguredComputingAddonSerializer +from ..configured_addon.link.serializers import ConfiguredLinkAddonSerializer from ..configured_addon.models import ConfiguredAddon from ..configured_addon.storage.serializers import ConfiguredStorageAddonSerializer from .models import AddonOperationInvocation from .serializers import AddonOperationInvocationSerializer -class AddonOperationInvocationViewSet(RetrieveWriteViewSet): +@extend_schema_view( + create=extend_schema( + description="Perform some action using external service, for instance list files on storage provider. " + "In order to perform such action you need to include configured_addon relationship" + ), + retrieve=extend_schema( + description="Get singular instance of addon operation invocation by it's pk. May be useful to view action log", + ), +) +class AddonOperationInvocationViewSet(RetrieveCreateViewSet): queryset = AddonOperationInvocation.objects.all() serializer_class = AddonOperationInvocationSerializer @@ -37,8 +51,6 @@ def get_permissions(self): match self.action: case "retrieve" | "retrieve_related": return [IsAuthenticated(), SessionUserMayAccessInvocation()] - case "partial_update" | "update" | "destroy": - return [IsAuthenticated(), SessionUserIsOwner()] case "create": return [SessionUserMayPerformInvocation()] case None: @@ -63,6 +75,10 @@ def retrieve_related(self, request, *args, **kwargs): serializer = AuthorizedComputingAccountSerializer( instance, context={"request": request} ) + elif hasattr(instance, "authorizedlinkaccount"): + serializer = AuthorizedLinkAccountSerializer( + instance, context={"request": request} + ) else: raise ValueError("unknown authorized account type") elif isinstance(instance, ConfiguredAddon): @@ -78,6 +94,10 @@ def retrieve_related(self, request, *args, **kwargs): serializer = ConfiguredComputingAddonSerializer( instance, context={"request": request} ) + elif hasattr(instance, "configuredlinkaddon"): + serializer = ConfiguredLinkAddonSerializer( + instance, context={"request": request} + ) else: raise ValueError("unknown configured addon type") else: diff --git a/addon_service/admin/__init__.py b/addon_service/admin/__init__.py index 45eb02b6..10389538 100644 --- a/addon_service/admin/__init__.py +++ b/addon_service/admin/__init__.py @@ -8,6 +8,10 @@ from addon_service.external_service.storage.models import StorageSupportedFeatures from ..external_service.citation.models import CitationSupportedFeatures +from ..external_service.link.models import ( + LinkSupportedFeatures, + SupportedResourceTypes, +) from ._base import GravyvaletModelAdmin from .decorators import linked_many_field @@ -22,7 +26,7 @@ class ExternalStorageServiceAdmin(GravyvaletModelAdmin): ) raw_id_fields = ("oauth2_client_config", "oauth1_client_config") enum_choice_fields = { - "int_addon_imp": known_imps.AddonImpNumbers, + "int_addon_imp": known_imps.StorageAddonImpNumbers, "int_credentials_format": CredentialsFormats, "int_service_type": ServiceTypes, } @@ -41,7 +45,7 @@ class ExternalCitationServiceAdmin(GravyvaletModelAdmin): ) raw_id_fields = ("oauth2_client_config", "oauth1_client_config") enum_choice_fields = { - "int_addon_imp": known_imps.AddonImpNumbers, + "int_addon_imp": known_imps.CitationAddonImpNumbers, "int_credentials_format": CredentialsFormats, "int_service_type": ServiceTypes, } @@ -50,6 +54,26 @@ class ExternalCitationServiceAdmin(GravyvaletModelAdmin): } +@admin.register(models.ExternalLinkService) +class ExternalLinkServiceAdmin(GravyvaletModelAdmin): + list_display = ("display_name", "created", "modified") + readonly_fields = ( + "id", + "created", + "modified", + ) + raw_id_fields = ("oauth2_client_config", "oauth1_client_config") + enum_choice_fields = { + "int_addon_imp": known_imps.LinkAddonImpNumbers, + "int_credentials_format": CredentialsFormats, + "int_service_type": ServiceTypes, + } + enum_multiple_choice_fields = { + "int_supported_features": LinkSupportedFeatures, + "int_supported_resource_types": SupportedResourceTypes, + } + + @admin.register(models.ExternalComputingService) class ExternalComputingServiceAdmin(GravyvaletModelAdmin): list_display = ("display_name", "created", "modified") @@ -60,7 +84,7 @@ class ExternalComputingServiceAdmin(GravyvaletModelAdmin): ) raw_id_fields = ("oauth2_client_config",) enum_choice_fields = { - "int_addon_imp": known_imps.AddonImpNumbers, + "int_addon_imp": known_imps.ComputingAddonImpNumbers, "int_credentials_format": CredentialsFormats, "int_service_type": ServiceTypes, } @@ -72,6 +96,8 @@ class ExternalComputingServiceAdmin(GravyvaletModelAdmin): @admin.register(models.OAuth2ClientConfig) @linked_many_field("external_storage_services") @linked_many_field("external_citation_services") +@linked_many_field("external_link_services") +@linked_many_field("external_computing_services") class OAuth2ClientConfigAdmin(GravyvaletModelAdmin): readonly_fields = ( "id", @@ -83,6 +109,8 @@ class OAuth2ClientConfigAdmin(GravyvaletModelAdmin): @admin.register(models.OAuth1ClientConfig) @linked_many_field("external_storage_services") @linked_many_field("external_citation_services") +@linked_many_field("external_link_services") +@linked_many_field("external_computing_services") class OAuth1ClientConfigAdmin(GravyvaletModelAdmin): readonly_fields = ( "id", diff --git a/addon_service/admin/_base.py b/addon_service/admin/_base.py index a076e2f0..c1454aca 100644 --- a/addon_service/admin/_base.py +++ b/addon_service/admin/_base.py @@ -56,10 +56,7 @@ def formfield_for_dbfield(self, db_field, request, **kwargs): label=db_field.verbose_name, choices=[ (None, ""), - *( - (_member.value, _member.name) - for _member in _enum.__members__.values() - ), + *self._list_enum_members(_enum), ], ) if ( @@ -68,14 +65,17 @@ def formfield_for_dbfield(self, db_field, request, **kwargs): ): _enum = self.enum_multiple_choice_fields[db_field.name] return EnumNameMultipleChoiceField( - choices=[ - *( - (_member.value, _member.name) - for _member in _enum.__members__.values() - ), - ], + choices=self._list_enum_members(_enum), widget=forms.CheckboxSelectMultiple, enum_cls=_enum, ) return super().formfield_for_dbfield(db_field, request, **kwargs) + + def _list_enum_members(self, _enum): + if hasattr(_enum, "__members__"): + iterator = _enum.__members__.values() + else: + iterator = _enum + + return [(_member.value, _member.name) for _member in iterator] diff --git a/addon_service/apps.py b/addon_service/apps.py index 21e574e3..c2916203 100644 --- a/addon_service/apps.py +++ b/addon_service/apps.py @@ -3,3 +3,7 @@ class AddonServiceConfig(AppConfig): name = "addon_service" + + def ready(self): + # need to import openapi extensions here for them to be registered + import addon_service.common.openapi_extensions # noqa: F401 diff --git a/addon_service/authentication.py b/addon_service/authentication.py index 82d2cbb6..7d1d19ac 100644 --- a/addon_service/authentication.py +++ b/addon_service/authentication.py @@ -12,7 +12,7 @@ def authenticate(self, request: DrfRequest): _user_uri = osf.get_osf_user_uri(request) if _user_uri: UserReference.objects.get_or_create(user_uri=_user_uri) - request.session["user_reference_uri"] = _user_uri + request.user_uri = _user_uri return True, None return None # unauthenticated diff --git a/addon_service/authorized_account/citation/views.py b/addon_service/authorized_account/citation/views.py index 28d3c837..c9671861 100644 --- a/addon_service/authorized_account/citation/views.py +++ b/addon_service/authorized_account/citation/views.py @@ -1,9 +1,21 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) + from addon_service.authorized_account.views import AuthorizedAccountViewSet from .models import AuthorizedCitationAccount from .serializers import AuthorizedCitationAccountSerializer +@extend_schema_view( + create=extend_schema( + description="Create new authorized citation account for given external citation service. " + 'For OAuth services it\'s required to create account with `"initiate_oauth"=true` ' + "in order to proceed with OAuth flow" + ), +) class AuthorizedCitationAccountViewSet(AuthorizedAccountViewSet): queryset = AuthorizedCitationAccount.objects.all() serializer_class = AuthorizedCitationAccountSerializer diff --git a/addon_service/authorized_account/computing/views.py b/addon_service/authorized_account/computing/views.py index 51f82168..fbadb0c9 100644 --- a/addon_service/authorized_account/computing/views.py +++ b/addon_service/authorized_account/computing/views.py @@ -1,9 +1,21 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) + from addon_service.authorized_account.views import AuthorizedAccountViewSet from .models import AuthorizedComputingAccount from .serializers import AuthorizedComputingAccountSerializer +@extend_schema_view( + create=extend_schema( + description="Create new authorized computing account for given external computing service.\n " + 'For OAuth services it\'s required to create account with `"initiate_oauth"=true` ' + "in order to proceed with OAuth flow" + ), +) class AuthorizedComputingAccountViewSet(AuthorizedAccountViewSet): queryset = AuthorizedComputingAccount.objects.all() serializer_class = AuthorizedComputingAccountSerializer diff --git a/addon_service/authorized_account/link/__init__.py b/addon_service/authorized_account/link/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/addon_service/authorized_account/link/models.py b/addon_service/authorized_account/link/models.py new file mode 100644 index 00000000..6ac6880a --- /dev/null +++ b/addon_service/authorized_account/link/models.py @@ -0,0 +1,33 @@ +from addon_service.authorized_account.models import AuthorizedAccount +from addon_service.configured_addon.link.models import ConfiguredLinkAddon +from addon_toolkit.interfaces.link import LinkConfig + + +class AuthorizedLinkAccount(AuthorizedAccount): + """Model for describing a user's account on an ExternalService. + + This model collects all of the information required to actually perform remote + operations against the service and to aggregate accounts under a known user. + """ + + class Meta: + verbose_name = "Authorized Link Account" + verbose_name_plural = "Authorized Link Accounts" + app_label = "addon_service" + + class JSONAPIMeta: + resource_name = "authorized-link-accounts" + + @property + def configured_link_addons(self): + return ConfiguredLinkAddon.objects.filter(base_account=self).select_related( + "authorized_resource" + ) + + @property + def config(self) -> LinkConfig: + return LinkConfig( + external_api_url=self.api_base_url, + external_web_url=self.external_service.externallinkservice.browser_base_url + or self.api_base_url, + ) diff --git a/addon_service/authorized_account/link/serializers.py b/addon_service/authorized_account/link/serializers.py new file mode 100644 index 00000000..cc2fe709 --- /dev/null +++ b/addon_service/authorized_account/link/serializers.py @@ -0,0 +1,84 @@ +from rest_framework_json_api import serializers +from rest_framework_json_api.relations import ( + HyperlinkedRelatedField, + ResourceRelatedField, +) +from rest_framework_json_api.utils import get_resource_type_from_model + +from addon_service.addon_operation.models import AddonOperationModel +from addon_service.authorized_account.link.models import AuthorizedLinkAccount +from addon_service.authorized_account.serializers import AuthorizedAccountSerializer +from addon_service.common import view_names +from addon_service.common.serializer_fields import ( + DataclassRelatedLinkField, + ReadOnlyResourceRelatedField, +) +from addon_service.models import ( + ConfiguredLinkAddon, + ExternalLinkService, + UserReference, +) + + +RESOURCE_TYPE = get_resource_type_from_model(AuthorizedLinkAccount) + + +class AuthorizedLinkAccountSerializer(AuthorizedAccountSerializer): + external_link_service = ResourceRelatedField( + queryset=ExternalLinkService.objects.all(), + many=False, + source="external_service.externallinkservice", + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + configured_link_addons = HyperlinkedRelatedField( + many=True, + queryset=ConfiguredLinkAddon.objects.active(), + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + required=False, + ) + url = serializers.HyperlinkedIdentityField( + view_name=view_names.detail_view(RESOURCE_TYPE), required=False + ) + account_owner = ReadOnlyResourceRelatedField( + many=False, + queryset=UserReference.objects.all(), + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + authorized_operations = DataclassRelatedLinkField( + dataclass_model=AddonOperationModel, + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + + included_serializers = { + "account_owner": "addon_service.serializers.UserReferenceSerializer", + "external_link_service": "addon_service.serializers.ExternalLinkServiceSerializer", + "configured_link_addons": "addon_service.serializers.ConfiguredLinkAddonSerializer", + "authorized_operations": "addon_service.serializers.AddonOperationSerializer", + } + + configured_addons_uris = serializers.SerializerMethodField() + + def get_configured_addons_uris(self, obj): + return obj.configured_link_addons.values_list( + "authorized_resource__resource_uri", flat=True + ) + + class Meta: + model = AuthorizedLinkAccount + fields = [ + "id", + "url", + "display_name", + "account_owner", + "api_base_url", + "auth_url", + "authorized_capabilities", + "authorized_operations", + "authorized_operation_names", + "configured_link_addons", + "credentials", + "external_link_service", + "initiate_oauth", + "credentials_available", + "configured_addons_uris", + ] diff --git a/addon_service/authorized_account/link/views.py b/addon_service/authorized_account/link/views.py new file mode 100644 index 00000000..c943ab06 --- /dev/null +++ b/addon_service/authorized_account/link/views.py @@ -0,0 +1,21 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) + +from addon_service.authorized_account.views import AuthorizedAccountViewSet + +from .models import AuthorizedLinkAccount +from .serializers import AuthorizedLinkAccountSerializer + + +@extend_schema_view( + create=extend_schema( + description="Create new authorized link account for given external link service.\n " + 'For OAuth services it\'s required to create account with `"initiate_oauth"=true` ' + "in order to proceed with OAuth flow" + ), +) +class AuthorizedLinkAccountViewSet(AuthorizedAccountViewSet): + queryset = AuthorizedLinkAccount.objects.all() + serializer_class = AuthorizedLinkAccountSerializer diff --git a/addon_service/authorized_account/polymorphic_serializers.py b/addon_service/authorized_account/polymorphic_serializers.py index 773c63d0..9c3eea90 100644 --- a/addon_service/authorized_account/polymorphic_serializers.py +++ b/addon_service/authorized_account/polymorphic_serializers.py @@ -6,6 +6,9 @@ from addon_service.authorized_account.computing.serializers import ( AuthorizedComputingAccountSerializer, ) +from addon_service.authorized_account.link.serializers import ( + AuthorizedLinkAccountSerializer, +) from addon_service.authorized_account.models import AuthorizedAccount from addon_service.authorized_account.storage.serializers import ( AuthorizedStorageAccountSerializer, @@ -17,6 +20,7 @@ class AuthorizedAccountPolymorphicSerializer(serializers.PolymorphicModelSeriali AuthorizedCitationAccountSerializer, AuthorizedComputingAccountSerializer, AuthorizedStorageAccountSerializer, + AuthorizedLinkAccountSerializer, ] class Meta: diff --git a/addon_service/authorized_account/serializers.py b/addon_service/authorized_account/serializers.py index 6367a6d4..40b85c59 100644 --- a/addon_service/authorized_account/serializers.py +++ b/addon_service/authorized_account/serializers.py @@ -8,6 +8,7 @@ from addon_service.authorized_account.models import AuthorizedAccount from addon_service.common.credentials_formats import CredentialsFormats +from addon_service.common.get_user_uri import get_user_uri from addon_service.external_service.models import ExternalService from addon_service.osf_models.fields import encrypt_string from addon_service.serializer_fields import ( @@ -72,7 +73,7 @@ def create_authorized_account( api_base_url: str = "", **kwargs, ) -> AuthorizedAccount: - session_user_uri = self.context["request"].session.get("user_reference_uri") + session_user_uri = get_user_uri(self.context["request"]) account_owner, _ = UserReference.objects.get_or_create( user_uri=session_user_uri ) diff --git a/addon_service/authorized_account/storage/views.py b/addon_service/authorized_account/storage/views.py index 587a03a0..9c058047 100644 --- a/addon_service/authorized_account/storage/views.py +++ b/addon_service/authorized_account/storage/views.py @@ -1,3 +1,8 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) + from addon_imps.storage.google_drive import GoogleDriveStorageImp from addon_service.authorized_account.views import AuthorizedAccountViewSet @@ -8,6 +13,13 @@ ) +@extend_schema_view( + create=extend_schema( + description="Create new authorized storage account for given external storage service." + '\n For OAuth services it\'s required to create account with `"initiate_oauth"=true` ' + "in order to proceed with OAuth flow" + ), +) class AuthorizedStorageAccountViewSet(AuthorizedAccountViewSet): queryset = AuthorizedStorageAccount.objects.all() serializer_class = AuthorizedStorageAccountSerializer diff --git a/addon_service/authorized_account/utils.py b/addon_service/authorized_account/utils.py index 5dfd4c60..21c7fb49 100644 --- a/addon_service/authorized_account/utils.py +++ b/addon_service/authorized_account/utils.py @@ -4,6 +4,7 @@ from addon_toolkit.interfaces.citation import CitationAddonImp from addon_toolkit.interfaces.computing import ComputingAddonImp +from addon_toolkit.interfaces.link import LinkAddonImp from addon_toolkit.interfaces.storage import StorageAddonImp @@ -18,5 +19,6 @@ def get_config_for_account(account: AuthorizedAccount): return account.authorizedcitationaccount.config elif issubclass(account.imp_cls, ComputingAddonImp): return account.authorizedcomputingaccount.config - + elif issubclass(account.imp_cls, LinkAddonImp): + return account.authorizedlinkaccount.config raise ValueError(f"this function implementation does not support {account.imp_cls}") diff --git a/addon_service/common/enum_serializers.py b/addon_service/common/enum_serializers.py index 24d9bfdd..476531c9 100644 --- a/addon_service/common/enum_serializers.py +++ b/addon_service/common/enum_serializers.py @@ -61,4 +61,4 @@ def to_internal_value(self, data) -> enum.Flag: def to_representation(self, value): _member_list = super().to_representation(value) - return [_member.name for _member in _member_list] + return sorted([_member.name for _member in _member_list]) diff --git a/addon_service/common/filtering.py b/addon_service/common/filtering.py index 84c7435b..e971d728 100644 --- a/addon_service/common/filtering.py +++ b/addon_service/common/filtering.py @@ -21,6 +21,8 @@ def filter_queryset(self, request, queryset, view): class RestrictedListEndpointFilterBackend(filters.BaseFilterBackend): def filter_queryset(self, request, queryset, view): + if view.action != "list": + return queryset required_filters = set(view.required_list_filter_fields) filter_expressions = extract_filter_expressions( request.query_params, view.get_serializer() diff --git a/addon_service/common/get_user_uri.py b/addon_service/common/get_user_uri.py new file mode 100644 index 00000000..7c35add2 --- /dev/null +++ b/addon_service/common/get_user_uri.py @@ -0,0 +1,8 @@ +def get_user_uri(request) -> str | None: + """ + Helper function which returns user's uri + It supports both session auth and PAT, which must not store any info in session (because they may be revoked later) + """ + return request.session.get("user_reference_uri") or getattr( + request, "user_uri", None + ) diff --git a/addon_service/common/known_imps.py b/addon_service/common/known_imps.py index 20e85b37..8692e99e 100644 --- a/addon_service/common/known_imps.py +++ b/addon_service/common/known_imps.py @@ -10,6 +10,7 @@ zotero_org, ) from addon_imps.computing import boa +from addon_imps.link import dataverse as link_dataverse from addon_imps.storage import ( bitbucket, box_dot_com, @@ -25,6 +26,10 @@ ) from addon_service.common.enum_decorators import enum_names_same_as from addon_toolkit import AddonImp +from addon_toolkit.interfaces.citation import CitationAddonImp +from addon_toolkit.interfaces.computing import ComputingAddonImp +from addon_toolkit.interfaces.link import LinkAddonImp +from addon_toolkit.interfaces.storage import StorageAddonImp if __debug__: @@ -67,38 +72,51 @@ def get_imp_number(imp: type[AddonImp]) -> int: @enum.unique class KnownAddonImps(enum.Enum): - """Static mapping from API-facing name for an AddonImp to the Imp itself""" + """Static mapping from API-facing name for an AddonImp to the Imp itself. + Note: Grouped by type and then ordered by respective AddonImpNumbers. + """ + + # Type: Storage BOX = box_dot_com.BoxDotComStorageImp S3 = s3.S3StorageImp - ONEDRIVE = onedrive.OneDriveStorageImp - ZOTERO = zotero_org.ZoteroOrgCitationImp GOOGLEDRIVE = google_drive.GoogleDriveStorageImp + DROPBOX = dropbox.DropboxStorageImp FIGSHARE = figshare.FigshareStorageImp - MENDELEY = mendeley.MendeleyCitationImp - BITBUCKET = bitbucket.BitbucketStorageImp - DATAVERSE = dataverse.DataverseStorageImp + ONEDRIVE = onedrive.OneDriveStorageImp OWNCLOUD = owncloud.OwnCloudStorageImp - - GITHUB = github.GitHubStorageImp + DATAVERSE = dataverse.DataverseStorageImp GITLAB = gitlab.GitlabStorageImp - DROPBOX = dropbox.DropboxStorageImp + BITBUCKET = bitbucket.BitbucketStorageImp + GITHUB = github.GitHubStorageImp + + # Type: Citation + ZOTERO = zotero_org.ZoteroOrgCitationImp + MENDELEY = mendeley.MendeleyCitationImp + # Type: Cloud Computing BOA = boa.BoaComputingImp + # Type: Link + LINK_DATAVERSE = link_dataverse.DataverseLinkImp + if __debug__: BLARG = my_blarg.MyBlargStorage -@enum.unique @enum_names_same_as(KnownAddonImps) +@enum.unique class AddonImpNumbers(enum.Enum): - """Static mapping from each AddonImp name to a unique integer (for database use)""" + """Static mapping from each AddonImp name to a unique integer (for database use) + Note: Ideally, we should "prefix" the number by type and take future scalability into account. + e.g. Storage type uses 10xx, Citation type uses 11xx, Cloud Computing type uses 12xx and LINK type uses 13xx. + Consider this a future improvement when we run out of 1001~1019 for both storage and citation addons. + """ + + # Type: Storage BOX = 1001 - ZOTERO = 1002 S3 = 1003 - MENDELEY = 1004 GOOGLEDRIVE = 1005 DROPBOX = 1006 FIGSHARE = 1007 @@ -107,10 +125,33 @@ class AddonImpNumbers(enum.Enum): DATAVERSE = 1010 GITLAB = 1011 BITBUCKET = 1012 - GITHUB = 1013 + # Type: Citation + ZOTERO = 1002 + MENDELEY = 1004 + + # Type: Cloud Computing BOA = 1020 + # Type: Link + LINK_DATAVERSE = 1030 + if __debug__: BLARG = -7 + + +def filter_addons_by_type(addon_type): + return frozenset( + { + AddonImpNumbers[item.name] + for item in KnownAddonImps + if issubclass(item.value, addon_type) + } + ) + + +StorageAddonImpNumbers = filter_addons_by_type(StorageAddonImp) +CitationAddonImpNumbers = filter_addons_by_type(CitationAddonImp) +ComputingAddonImpNumbers = filter_addons_by_type(ComputingAddonImp) +LinkAddonImpNumbers = filter_addons_by_type(LinkAddonImp) diff --git a/addon_service/common/openapi_extensions.py b/addon_service/common/openapi_extensions.py new file mode 100644 index 00000000..7c2f4498 --- /dev/null +++ b/addon_service/common/openapi_extensions.py @@ -0,0 +1,28 @@ +from drf_spectacular.extensions import OpenApiFilterExtension + + +class RestrictedReadOnlyViewSetExtension(OpenApiFilterExtension): + target_class = "addon_service.common.filtering.RestrictedListEndpointFilterBackend" # full dotted path + + def get_schema_operation_parameters(self, auto_schema, *args, **kwargs): + if auto_schema.method != "GET" or "list" not in auto_schema.view.action: + return [] + + required_filter_fields = getattr( + auto_schema.view, "required_list_filter_fields", () + ) + + parameters = [] + for field_name in required_filter_fields: + parameters.append( + { + "name": f"filter[{field_name}]", + "in": "query", # This corresponds to OpenApiParameter.QUERY + "description": f"Filter by {field_name}. This filter must be uniquely identifying.", + "required": True, + "schema": { + "type": "string" # This corresponds to OpenApiTypes.STR, OpenApiTypes.URI, etc. + }, + } + ) + return parameters diff --git a/addon_service/common/osf.py b/addon_service/common/osf.py index dc8668a4..4104bff8 100644 --- a/addon_service/common/osf.py +++ b/addon_service/common/osf.py @@ -11,6 +11,7 @@ from addon_service.common import hmac as hmac_utils from addon_service.common.aiohttp_session import get_singleton_client_session +from addon_service.common.get_user_uri import get_user_uri from addon_toolkit import AddonCapabilities @@ -54,8 +55,14 @@ async def get_osf_user_uri(request: django_http.HttpRequest) -> str | None: raise PermissionDenied(e) except hmac_utils.NotUsingHmac: pass # the only acceptable hmac-related error is not using hmac at all - # not hmac -- ask osf - _auth_headers = _get_osf_auth_headers(request) + + # if not hmac, check to see if it's in shared session + _user_referernce_uri = get_user_uri(request) + if _user_referernce_uri: + return _user_referernce_uri + + # if not in shared session, check to see if there is personal access token + _auth_headers = _osf_token_auth_headers(request) if not _auth_headers: return None _client = await get_singleton_client_session() @@ -173,9 +180,9 @@ def _get_osf_auth_headers(request: django_http.HttpRequest) -> _HeaderList: def _osf_cookie_auth_headers(request: django_http.HttpRequest) -> _HeaderList: - _osf_user_cookie = request.COOKIES.get(settings.USER_REFERENCE_COOKIE) + _osf_user_cookie = request.COOKIES.get(settings.OSF_AUTH_COOKIE_NAME) return ( - [("Cookie", f"{settings.USER_REFERENCE_COOKIE}={_osf_user_cookie}")] + [("Cookie", f"{settings.OSF_AUTH_COOKIE_NAME}={_osf_user_cookie}")] if _osf_user_cookie else [] ) diff --git a/addon_service/common/permissions.py b/addon_service/common/permissions.py index 6bdf7e40..f9b4294c 100644 --- a/addon_service/common/permissions.py +++ b/addon_service/common/permissions.py @@ -6,20 +6,21 @@ from addon_service.common import hmac as hmac_utils from addon_service.common import osf +from addon_service.common.get_user_uri import get_user_uri class IsAuthenticated(permissions.BasePermission): """allow any logged-in user""" def has_permission(self, request, view): - return request.session.get("user_reference_uri") is not None + return get_user_uri(request) is not None class SessionUserIsOwner(permissions.BasePermission): """for object permissions on objects with `owner_uri`""" def has_object_permission(self, request, view, obj): - session_user_uri = request.session.get("user_reference_uri") + session_user_uri = get_user_uri(request) if session_user_uri: return session_user_uri == obj.owner_uri return False @@ -31,7 +32,7 @@ class SessionUserIsOwnerOrResourceAdmin(permissions.BasePermission): message = "Permission denied, to perform this action you should be the one who created this addon or project admin" def has_object_permission(self, request, view, obj): - _user_uri = request.session.get("user_reference_uri") + _user_uri = get_user_uri(request) return (_user_uri == obj.owner_uri) or osf.has_osf_permission_on_resource( request, obj.resource_uri, @@ -54,7 +55,7 @@ class SessionUserMayConnectAddon(permissions.BasePermission): """for object permissions on objects with `owner_uri` and `resource_uri`""" def has_object_permission(self, request, view, obj): - _user_uri = request.session.get("user_reference_uri") + _user_uri = get_user_uri(request) return ( # must be account owner (_user_uri == obj.owner_uri) @@ -71,7 +72,7 @@ class SessionUserMayAccessInvocation(permissions.BasePermission): """for object permissions on `addon_service.models.AddonOperationInvocation`""" def has_object_permission(self, request, view, obj): - _user_uri = request.session.get("user_reference_uri") + _user_uri = get_user_uri(request) return bool( # must be the invoker: (_user_uri == obj.by_user.user_uri) @@ -90,7 +91,7 @@ class SessionUserMayPerformInvocation(permissions.BasePermission): """for object permissions on `addon_service.models.AddonOperationInvocation`""" def has_object_permission(self, request, view, obj): - _user_uri = request.session.get("user_reference_uri") + _user_uri = get_user_uri(request) _thru_addon = obj.thru_addon _thru_account = obj.thru_account if _thru_addon is None: diff --git a/addon_service/common/regex.py b/addon_service/common/regex.py new file mode 100644 index 00000000..2dfe2127 --- /dev/null +++ b/addon_service/common/regex.py @@ -0,0 +1,4 @@ +import re + + +uri_regex = re.compile(r"http://[^/]+/(?P\w{5})/?") diff --git a/addon_service/common/serializer_fields.py b/addon_service/common/serializer_fields.py index 1d63488d..83638d0a 100644 --- a/addon_service/common/serializer_fields.py +++ b/addon_service/common/serializer_fields.py @@ -64,6 +64,8 @@ def to_representation(self, value): data["type"] = "authorized-storage-accounts" elif hasattr(value, "authorizedcomputingaccount"): data["type"] = "authorized-computing-accounts" + elif hasattr(value, "authorizedlinkaccount"): + data["type"] = "authorized-link-accounts" elif isinstance(value, ConfiguredAddon): if hasattr(value, "configuredcitationaddon"): data["type"] = "configured-citation-addons" @@ -71,4 +73,6 @@ def to_representation(self, value): data["type"] = "configured-storage-addons" elif hasattr(value, "configuredcomputingaddon"): data["type"] = "configured-computing-addons" + elif hasattr(value, "configuredlinkaddon"): + data["type"] = "configured-link-addons" return data diff --git a/addon_service/common/validators.py b/addon_service/common/validators.py index 550ee414..be760a95 100644 --- a/addon_service/common/validators.py +++ b/addon_service/common/validators.py @@ -10,6 +10,7 @@ from addon_toolkit import AddonCapabilities from addon_toolkit.interfaces.citation import CitationAddonImp from addon_toolkit.interfaces.computing import ComputingAddonImp +from addon_toolkit.interfaces.link import LinkAddonImp from addon_toolkit.interfaces.storage import StorageAddonImp from . import known_imps @@ -59,6 +60,10 @@ def validate_storage_imp_number(value): _validate_imp_number(value, StorageAddonImp) +def validate_link_imp_number(value): + _validate_imp_number(value, LinkAddonImp) + + def validate_citation_imp_number(value): _validate_imp_number(value, CitationAddonImp) diff --git a/addon_service/common/viewsets.py b/addon_service/common/viewsets.py index 4f20b36f..d2ce9ca5 100644 --- a/addon_service/common/viewsets.py +++ b/addon_service/common/viewsets.py @@ -24,6 +24,7 @@ class _DrfJsonApiHelpers(AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin): class RestrictedReadOnlyViewSet(ReadOnlyModelViewSet): + filter_backends = [RestrictedListEndpointFilterBackend] """ReadOnlyViewSet that requires `list` actions return only one result. UserReference and ResourceReference endpoints are major entry points into @@ -47,8 +48,6 @@ def list(self, request, *args, **kwargs): RestrictedListEndpointFilterBackend and check_object_permissions to enforce permissions on returned entities. """ - self.filter_backends = [RestrictedListEndpointFilterBackend] - qs = self.filter_queryset(self.get_queryset()) try: self.check_object_permissions(self.request, qs.get()) @@ -74,6 +73,17 @@ def perform_create(self, serializer): _instance.save() +class RetrieveCreateViewSet( + _DrfJsonApiHelpers, + _CreateWithPermissionsMixin, + drf_mixins.RetrieveModelMixin, + GenericViewSet, +): + """viewset allowing create, retrieve, update""" + + http_method_names = ["get", "post", "head", "options"] + + class RetrieveWriteViewSet( _DrfJsonApiHelpers, _CreateWithPermissionsMixin, diff --git a/addon_service/configured_addon/citation/views.py b/addon_service/configured_addon/citation/views.py index 936821a0..1982a702 100644 --- a/addon_service/configured_addon/citation/views.py +++ b/addon_service/configured_addon/citation/views.py @@ -1,9 +1,25 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) + from addon_service.configured_addon.views import ConfiguredAddonViewSet from .models import ConfiguredCitationAddon from .serializers import ConfiguredCitationAddonSerializer +@extend_schema_view( + create=extend_schema( + description="Create new configured citation addon for given authorized citation account, linking it to desired project.\n " + "To configure it properly, you must specify `root_folder` on the provider's side.\n " + "Note that everything under this folder is going to be accessible to everyone who has access to this project" + ), + get=extend_schema( + description="Get configured citation addon by it's pk. " + "\nIf you want to fetch all configured citation addons, you should do so through resource_reference related view", + ), +) class ConfiguredCitationAddonViewSet(ConfiguredAddonViewSet): queryset = ConfiguredCitationAddon.objects.active() serializer_class = ConfiguredCitationAddonSerializer diff --git a/addon_service/configured_addon/computing/models.py b/addon_service/configured_addon/computing/models.py index 25c96e93..88e2d848 100644 --- a/addon_service/configured_addon/computing/models.py +++ b/addon_service/configured_addon/computing/models.py @@ -18,6 +18,6 @@ def config(self) -> ComputingConfig: return self.base_account.authorizedcomputingaccount.config @property - def external_service_name(self): + def external_service_name(self) -> str: number = self.base_account.external_service.int_addon_imp return AddonImpNumbers(number).name.lower() diff --git a/addon_service/configured_addon/computing/views.py b/addon_service/configured_addon/computing/views.py index a267b932..c8e82b35 100644 --- a/addon_service/configured_addon/computing/views.py +++ b/addon_service/configured_addon/computing/views.py @@ -1,5 +1,9 @@ from http import HTTPMethod +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) from rest_framework.decorators import action from rest_framework.response import Response @@ -10,6 +14,18 @@ from .serializers import ConfiguredComputingAddonSerializer +@extend_schema_view( + create=extend_schema( + description="Create new configured computing addon for given authorized computing account, linking it to desired project.\n " + "To configure it properly, you must specify `root_folder` on the provider's side.\n " + "Note that everything under this folder is going to be accessible to everyone who has access to this project" + ), + get=extend_schema( + description="Get configured computing addon by it's pk. " + "\nIf you want to fetch all configured computing addons, you should do so through resource_reference related view", + ), + get_wb_credentials=extend_schema(exclude=True), +) class ConfiguredComputingAddonViewSet(ConfiguredAddonViewSet): queryset = ConfiguredComputingAddon.objects.active() serializer_class = ConfiguredComputingAddonSerializer diff --git a/addon_service/configured_addon/link/__init__.py b/addon_service/configured_addon/link/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/addon_service/configured_addon/link/models.py b/addon_service/configured_addon/link/models.py new file mode 100644 index 00000000..c16f4dbf --- /dev/null +++ b/addon_service/configured_addon/link/models.py @@ -0,0 +1,76 @@ +from asgiref.sync import async_to_sync +from django.core.exceptions import ValidationError +from django.db import models + +from addon_service.configured_addon.models import ConfiguredAddon +from addon_toolkit.interfaces.link import ( + LinkConfig, + SupportedResourceTypes, +) +from app.celery import app + + +def is_supported_resource_type(resource_type: int): + if not resource_type: + return + try: + resource_type = SupportedResourceTypes(resource_type) + if len(resource_type) != 1: + raise ValidationError("One may select only one resource type") + except ValueError: + raise ValidationError("Invalid resource type") + + +class ConfiguredLinkAddon(ConfiguredAddon): + # Note: ideally, `target_id` and `int_resource_type` are both required and + # should be present on first save. However, due to a limitation on how our + # FE workflow is currently wired, a `ConfiguredLinkAddon` instance must be + # saved before both attributes can be set. Thus, we allow null and blank. + target_id = models.CharField(default=None, null=True, blank=True) + int_resource_type = models.BigIntegerField( + default=None, null=True, blank=True, validators=[is_supported_resource_type] + ) + + @property + def resource_type(self) -> SupportedResourceTypes | None: + if self.int_resource_type: + return SupportedResourceTypes(self.int_resource_type) + return None + + @resource_type.setter + def resource_type(self, value) -> None: + if value is not None: + self.int_resource_type = value.value + + def save(self, *args, full_clean=True, **kwargs): + super().save(*args, full_clean=full_clean, **kwargs) + # Only send for OSF task after the addon has been fully configured + if self.resource_type and self.target_id: + app.send_task( + "website.identifiers.tasks.task__update_verified_links", + kwargs={"target_guid": self.authorized_resource.guid}, + ) + + def target_url(self): + from addon_service.addon_imp.instantiation import ( + get_link_addon_instance__blocking, + ) + + if not self.target_id: + return None + addon = get_link_addon_instance__blocking( + self.imp_cls, self.base_account, self.config + ) + return async_to_sync(addon.build_url_for_id)(self.target_id) + + @property + def config(self) -> LinkConfig: + return self.base_account.authorizedlinkaccount.config + + class Meta: + verbose_name = "Configured Link Addon" + verbose_name_plural = "Configured Link Addons" + app_label = "addon_service" + + class JSONAPIMeta: + resource_name = "configured-link-addons" diff --git a/addon_service/configured_addon/link/serializers.py b/addon_service/configured_addon/link/serializers.py new file mode 100644 index 00000000..54558e37 --- /dev/null +++ b/addon_service/configured_addon/link/serializers.py @@ -0,0 +1,103 @@ +from rest_framework.fields import ( + CharField, + URLField, +) +from rest_framework_json_api import serializers +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.utils import get_resource_type_from_model + +from addon_service.addon_operation.models import AddonOperationModel +from addon_service.common import view_names +from addon_service.common.enum_serializers import EnumNameChoiceField +from addon_service.common.serializer_fields import DataclassRelatedLinkField +from addon_service.configured_addon.serializers import ConfiguredAddonSerializer +from addon_service.external_service.link.models import ExternalLinkService +from addon_service.models import ( + AuthorizedLinkAccount, + ConfiguredLinkAddon, +) +from addon_toolkit.interfaces.link import SupportedResourceTypes + + +RESOURCE_TYPE = get_resource_type_from_model(ConfiguredLinkAddon) + + +class ConfiguredLinkAddonSerializer(ConfiguredAddonSerializer): + """api serializer for the `ConfiguredLinkAddon` model""" + + target_url = URLField(read_only=True) + target_id = CharField(required=False, allow_null=True) + resource_type = EnumNameChoiceField( + required=False, + allow_null=True, + enum_cls=SupportedResourceTypes, + ) + + connected_operations = DataclassRelatedLinkField( + dataclass_model=AddonOperationModel, + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + read_only=True, + ) + base_account = ResourceRelatedField( + queryset=AuthorizedLinkAccount.objects.all(), + many=False, + source="base_account.authorizedlinkaccount", + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + external_link_service = ResourceRelatedField( + many=False, + read_only=True, + model=ExternalLinkService, + source="base_account.external_service.externallinkservice", + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + authorized_resource = ResourceRelatedField( + many=False, + read_only=True, + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + + included_serializers = { + "base_account": "addon_service.serializers.AuthorizedLinkAccountSerializer", + "external_link_service": "addon_service.serializers.ExternalLinkServiceSerializer", + "authorized_resource": "addon_service.serializers.ResourceReferenceSerializer", + "connected_operations": "addon_service.serializers.AddonOperationSerializer", + } + + class Meta: + model = ConfiguredLinkAddon + read_only_fields = ["external_link_service"] + fields = [ + "id", + "display_name", + "target_url", + "base_account", + "authorized_resource", + "authorized_resource_uri", + "connected_capabilities", + "connected_operations", + "connected_operation_names", + "external_link_service", + "current_user_is_owner", + "external_service_name", + "resource_type", + "target_id", + ] + + +class VerifiedLinkSerializer(serializers.Serializer): + """Serialize ConfiguredLinkAddon information required by OSF. + + The information is shaped for osf to be able to update datacite and share metadata + with minimal performance footprint + """ + + class JSONAPIMeta: + resource_name = "verified-link" + + target_url = URLField(read_only=True) + target_id = CharField(read_only=True) + resource_type = EnumNameChoiceField(read_only=True, enum_cls=SupportedResourceTypes) + service_name = CharField( + read_only=True, source="external_service.external_service_name" + ) diff --git a/addon_service/configured_addon/link/views.py b/addon_service/configured_addon/link/views.py new file mode 100644 index 00000000..b5c39dfd --- /dev/null +++ b/addon_service/configured_addon/link/views.py @@ -0,0 +1,54 @@ +from http import HTTPMethod + +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) +from rest_framework.decorators import action +from rest_framework.response import Response + +from addon_service.configured_addon.views import ConfiguredAddonViewSet +from app.settings import ALLOWED_RESOURCE_URI_PREFIXES + +from .models import ConfiguredLinkAddon +from .serializers import ( + ConfiguredLinkAddonSerializer, + VerifiedLinkSerializer, +) + + +@extend_schema_view( + create=extend_schema( + description="Create new configured link addon for given authorized link account, linking it to desired project.\n " + "To configure it properly, you must specify `root_folder` on the provider's side.\n " + "Note that everything under this folder is going to be accessible to everyone who has access to this project" + ), + get=extend_schema( + description="Get configured link addon by it's pk. " + "\nIf you want to fetch all configured link addons, you should do so through resource_reference related view", + ), +) +class ConfiguredLinkAddonViewSet(ConfiguredAddonViewSet): + queryset = ConfiguredLinkAddon.objects.active().select_related( + "base_account__authorizedlinkaccount", + "base_account__account_owner", + "base_account__external_service__externallinkservice", + "authorized_resource", + ) + serializer_class = ConfiguredLinkAddonSerializer + + @action( + detail=True, + methods=[HTTPMethod.GET], + url_name="verified-links", + url_path="verified-links", + ) + def get_verified_links(self, request, pk=None): + addons: ConfiguredLinkAddon = ConfiguredLinkAddon.objects.filter( + authorized_resource__resource_uri__in=[ + f"{prefix}/{pk}" for prefix in ALLOWED_RESOURCE_URI_PREFIXES + ], + ).select_related("base_account__external_service") + self.resource_name = "verified-link" + + return Response(VerifiedLinkSerializer(addons, many=True).data) diff --git a/addon_service/configured_addon/models.py b/addon_service/configured_addon/models.py index cf006702..9b6e759c 100644 --- a/addon_service/configured_addon/models.py +++ b/addon_service/configured_addon/models.py @@ -11,6 +11,7 @@ AddonCapabilities, AddonImp, ) +from app.celery import app class ConnectedAddonManager(models.Manager): @@ -88,6 +89,20 @@ def connected_operations(self) -> list[AddonOperationModel]: ) ] + def save(self, *args, full_clean=True, **kwargs): + id_ = self.pk + super().save(*args, full_clean=full_clean, **kwargs) + if not id_: # If instance is created, not updated + app.send_task( + "osf.tasks.log_gv_addon", + kwargs={ + "node_url": self.resource_uri, + "user_url": self.owner_uri, + "addon": self.display_name, + "action": "addon_added", + }, + ) + @property def connected_operation_names(self): return [ @@ -97,6 +112,19 @@ def connected_operation_names(self): ) ] + def delete(self, *args, **kwargs): + result = super().delete(*args, **kwargs) + if result: + app.send_task( + "osf.tasks.log_gv_addon", + kwargs={ + "node_url": self.resource_uri, + "user_url": self.owner_uri, + "addon": self.display_name, + "action": "addon_removed", + }, + ) + @property def credentials(self): return self.base_account.credentials @@ -110,7 +138,7 @@ def imp_cls(self) -> type[AddonImp]: return self.base_account.imp_cls @property - def external_service_name(self): + def external_service_name(self) -> str: number = self.base_account.external_service.int_addon_imp return AddonImpNumbers(number).name.lower() diff --git a/addon_service/configured_addon/polymorphic_serializers.py b/addon_service/configured_addon/polymorphic_serializers.py index 28b58d13..26fb44cb 100644 --- a/addon_service/configured_addon/polymorphic_serializers.py +++ b/addon_service/configured_addon/polymorphic_serializers.py @@ -6,6 +6,9 @@ from addon_service.configured_addon.computing.serializers import ( ConfiguredComputingAddonSerializer, ) +from addon_service.configured_addon.link.serializers import ( + ConfiguredLinkAddonSerializer, +) from addon_service.configured_addon.models import ConfiguredAddon from addon_service.configured_addon.storage.serializers import ( ConfiguredStorageAddonSerializer, @@ -17,6 +20,7 @@ class ConfiguredAddonPolymorphicSerializer(serializers.PolymorphicModelSerialize ConfiguredCitationAddonSerializer, ConfiguredComputingAddonSerializer, ConfiguredStorageAddonSerializer, + ConfiguredLinkAddonSerializer, ] class Meta: diff --git a/addon_service/configured_addon/serializers.py b/addon_service/configured_addon/serializers.py index 05c9e865..1b3b9768 100644 --- a/addon_service/configured_addon/serializers.py +++ b/addon_service/configured_addon/serializers.py @@ -2,11 +2,12 @@ from rest_framework_json_api import serializers from addon_service.common.enum_serializers import EnumNameMultipleChoiceField +from addon_service.common.get_user_uri import get_user_uri from addon_service.configured_addon.models import ConfiguredAddon from addon_toolkit import AddonCapabilities -REQUIRED_FIELDS = frozenset(["url", "connected_operations", "authorized_resource"]) +REQUIRED_FIELDS = frozenset(["connected_operations", "authorized_resource"]) class ConfiguredAddonSerializer(serializers.HyperlinkedModelSerializer): @@ -14,7 +15,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not REQUIRED_FIELDS.issubset(set(self.fields.keys())): raise Exception( - f"{self.__class__.__name__} requires {self.REQUIRED_FIELDS} to be instantiated" + f"{self.__class__.__name__} requires {REQUIRED_FIELDS} to be instantiated" ) connected_capabilities = EnumNameMultipleChoiceField(enum_cls=AddonCapabilities) @@ -32,10 +33,8 @@ def __init__(self, *args, **kwargs): current_user_is_owner = serializers.SerializerMethodField() - def get_current_user_is_owner(self, configured_addon: ConfiguredAddon): - return configured_addon.owner_uri == self.context["request"].session.get( - "user_reference_uri" - ) + def get_current_user_is_owner(self, configured_addon: ConfiguredAddon) -> bool: + return configured_addon.owner_uri == get_user_uri(self.context["request"]) def create(self, validated_data): validated_data = self.fix_dotted_base_account(validated_data) diff --git a/addon_service/configured_addon/storage/views.py b/addon_service/configured_addon/storage/views.py index f3e119d7..f128638e 100644 --- a/addon_service/configured_addon/storage/views.py +++ b/addon_service/configured_addon/storage/views.py @@ -1,5 +1,6 @@ from http import HTTPMethod +import drf_spectacular.utils from django.http import Http404 from rest_framework.decorators import action from rest_framework.response import Response @@ -14,6 +15,18 @@ from .serializers import ConfiguredStorageAddonSerializer +@drf_spectacular.utils.extend_schema_view( + create=drf_spectacular.utils.extend_schema( + description="Create new configured storage addon for given authorized storage account, linking it to desired project.\n " + "To configure it properly, you must specify `root_folder` on the provider's side.\n " + "Note that everything under this folder is going to be accessible to everyone who has access to this project" + ), + get=drf_spectacular.utils.extend_schema( + description="Get configured storage addon by it's pk. " + "\nIf you want to fetch all configured storage addons, you should do so through resource_reference related view", + ), + get_wb_credentials=drf_spectacular.utils.extend_schema(exclude=True), +) class ConfiguredStorageAddonViewSet(ConfiguredAddonViewSet): queryset = ConfiguredStorageAddon.objects.active().select_related( "base_account__authorizedstorageaccount", diff --git a/addon_service/configured_addon/utils.py b/addon_service/configured_addon/utils.py index 0daad9df..ca4bc07a 100644 --- a/addon_service/configured_addon/utils.py +++ b/addon_service/configured_addon/utils.py @@ -4,6 +4,7 @@ from addon_toolkit.interfaces.citation import CitationAddonImp from addon_toolkit.interfaces.computing import ComputingAddonImp +from addon_toolkit.interfaces.link import LinkAddonImp from addon_toolkit.interfaces.storage import StorageAddonImp @@ -18,5 +19,6 @@ def get_config_for_addon(addon: ConfiguredAddon): return addon.configuredcitationaddon.config elif issubclass(addon.imp_cls, ComputingAddonImp): return addon.configuredcomputingaddon.config - + elif issubclass(addon.imp_cls, LinkAddonImp): + return addon.configuredlinkaddon.config raise ValueError(f"this function implementation does not support {addon.imp_cls}") diff --git a/addon_service/configured_addon/views.py b/addon_service/configured_addon/views.py index fe1189c3..eb0bf0c4 100644 --- a/addon_service/configured_addon/views.py +++ b/addon_service/configured_addon/views.py @@ -19,7 +19,7 @@ def get_permissions(self): return [IsAuthenticated(), SessionUserIsOwnerOrResourceAdmin()] case "create": return [IsAuthenticated(), SessionUserMayConnectAddon()] - case "get_wb_credentials": + case "get_wb_credentials" | "get_verified_links": return [IsValidHMACSignedRequest()] case None: return super().get_permissions() diff --git a/addon_service/credentials/serializers.py b/addon_service/credentials/serializers.py index 23b4ece1..cb5e8863 100644 --- a/addon_service/credentials/serializers.py +++ b/addon_service/credentials/serializers.py @@ -1,3 +1,5 @@ +import json + from rest_framework.serializers import ( JSONField, ValidationError, @@ -21,6 +23,8 @@ def __init__(self, write_only=True, required=False, *args, **kwargs): def to_internal_value(self, data): # this issue still hasn't been fixed on FE, so keeping this for now + if not isinstance(data, dict): + data = json.loads(data) if not data or not any(data.values()): return None # consider empty {} same as omitting the field # No access to the credentials format here, so just try all of them diff --git a/addon_service/external_service/citation/views.py b/addon_service/external_service/citation/views.py index b8e9fa03..cdbc7ca3 100644 --- a/addon_service/external_service/citation/views.py +++ b/addon_service/external_service/citation/views.py @@ -1,9 +1,21 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) from rest_framework_json_api.views import ReadOnlyModelViewSet from .models import ExternalCitationService from .serializers import ExternalCitationServiceSerializer +@extend_schema_view( + list=extend_schema( + description="Get the list of all available external citation services" + ), + get=extend_schema( + description="Get particular external citation service", + ), +) class ExternalCitationServiceViewSet(ReadOnlyModelViewSet): queryset = ExternalCitationService.objects.all().select_related( "oauth2_client_config", "oauth1_client_config" diff --git a/addon_service/external_service/computing/views.py b/addon_service/external_service/computing/views.py index b52baf0e..bc793b8d 100644 --- a/addon_service/external_service/computing/views.py +++ b/addon_service/external_service/computing/views.py @@ -1,9 +1,21 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) from rest_framework_json_api.views import ReadOnlyModelViewSet from .models import ExternalComputingService from .serializers import ExternalComputingServiceSerializer +@extend_schema_view( + list=extend_schema( + description="Get the list of all available external computing services" + ), + get=extend_schema( + description="Get particular external computing service", + ), +) class ExternalComputingServiceViewSet(ReadOnlyModelViewSet): queryset = ExternalComputingService.objects.all().select_related( "oauth2_client_config" diff --git a/addon_service/external_service/link/__init__.py b/addon_service/external_service/link/__init__.py new file mode 100644 index 00000000..4754ef53 --- /dev/null +++ b/addon_service/external_service/link/__init__.py @@ -0,0 +1,2 @@ +"""addon_service.external_service.link: represents a third-party link service +""" diff --git a/addon_service/external_service/link/models.py b/addon_service/external_service/link/models.py new file mode 100644 index 00000000..ef510025 --- /dev/null +++ b/addon_service/external_service/link/models.py @@ -0,0 +1,87 @@ +from enum import ( + Flag, + auto, +) + +from django.core.exceptions import ValidationError +from django.db import models + +from addon_service.authorized_account.link.models import AuthorizedLinkAccount +from addon_service.common.validators import ( + _validate_enum_value, + validate_link_imp_number, +) +from addon_service.external_service.models import ExternalService +from addon_toolkit.interfaces.link import SupportedResourceTypes + + +class LinkSupportedFeatures(Flag): + ADD_UPDATE_FILES = auto() + DELETE_FILES = auto() + FORKING = auto() + FORKING_PARTIAL = auto() + LOGS = auto() + LOGS_PARTIAL = auto() + PERMISSIONS = auto() + REGISTERING = auto() + FILE_VERSIONS = auto() + + +def validate_supported_features(value): + _validate_enum_value(LinkSupportedFeatures, value) + + +def validate_supported_resource_types(value): + _validate_enum_value(SupportedResourceTypes, value) + + +class ExternalLinkService(ExternalService): + browser_base_url = models.URLField(blank=True, default="") + int_supported_features = models.IntegerField( + validators=[validate_supported_features], null=True + ) + int_supported_resource_types = models.BigIntegerField( + validators=[validate_supported_resource_types], null=True + ) + + @property + def supported_features(self) -> list[LinkSupportedFeatures]: + """get the enum representation of int_supported_features""" + return LinkSupportedFeatures(self.int_supported_features) + + @supported_features.setter + def supported_features(self, new_supported_features: LinkSupportedFeatures): + """set int_authorized_capabilities without caring its int""" + self.int_supported_features = new_supported_features.value + + @property + def supported_resource_types(self) -> list[SupportedResourceTypes]: + """get the enum representation of int_supported_resource_types""" + return SupportedResourceTypes(self.int_supported_resource_types) + + @supported_resource_types.setter + def supported_resource_types( + self, new_supported_resource_types: SupportedResourceTypes + ): + """set int_authorized_capabilities without caring its int""" + self.int_supported_resource_types = new_supported_resource_types.value + + def clean(self): + super().clean() + validate_link_imp_number(self.int_addon_imp) + if not self.api_base_url and not self.browser_base_url: + raise ValidationError( + "External Link Service should provide at least one of `browser_base_url` or `api_base_url`" + ) + + @property + def authorized_link_accounts(self): + return AuthorizedLinkAccount.objects.filter(external_service=self) + + class Meta: + verbose_name = "External Link Service" + verbose_name_plural = "External Link Services" + app_label = "addon_service" + + class JSONAPIMeta: + resource_name = "external-link-services" diff --git a/addon_service/external_service/link/serializers.py b/addon_service/external_service/link/serializers.py new file mode 100644 index 00000000..c42a0a5a --- /dev/null +++ b/addon_service/external_service/link/serializers.py @@ -0,0 +1,56 @@ +from rest_framework_json_api import serializers +from rest_framework_json_api.utils import get_resource_type_from_model + +from addon_service.addon_imp.models import AddonImpModel +from addon_service.common import view_names +from addon_service.common.enum_serializers import EnumNameMultipleChoiceField +from addon_service.common.serializer_fields import DataclassRelatedDataField +from addon_service.external_service.link.models import ( + LinkSupportedFeatures, + SupportedResourceTypes, +) +from addon_service.external_service.serializers import ExternalServiceSerializer + +from .models import ExternalLinkService + + +RESOURCE_TYPE = get_resource_type_from_model(ExternalLinkService) + + +class ExternalLinkServiceSerializer(ExternalServiceSerializer): + """api serializer for the `ExternalService` model""" + + url = serializers.HyperlinkedIdentityField( + view_name=view_names.detail_view(RESOURCE_TYPE) + ) + + addon_imp = DataclassRelatedDataField( + dataclass_model=AddonImpModel, + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + + supported_features = EnumNameMultipleChoiceField( + enum_cls=LinkSupportedFeatures, read_only=True + ) + + supported_resource_types = EnumNameMultipleChoiceField( + enum_cls=SupportedResourceTypes, read_only=True + ) + + class Meta: + model = ExternalLinkService + fields = [ + "id", + "addon_imp", + "auth_uri", + "credentials_format", + "display_name", + "url", + "wb_key", + "external_service_name", + "configurable_api_root", + "supported_resource_types", + "supported_features", + "icon_url", + "api_base_url_options", + ] diff --git a/addon_service/external_service/link/views.py b/addon_service/external_service/link/views.py new file mode 100644 index 00000000..8076a197 --- /dev/null +++ b/addon_service/external_service/link/views.py @@ -0,0 +1,21 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) +from rest_framework_json_api.views import ReadOnlyModelViewSet + +from .models import ExternalLinkService +from .serializers import ExternalLinkServiceSerializer + + +@extend_schema_view( + list=extend_schema( + description="Get the list of all available external link services" + ), + get=extend_schema( + description="Get particular external link service", + ), +) +class ExternalLinkServiceViewSet(ReadOnlyModelViewSet): + queryset = ExternalLinkService.objects.all().select_related("oauth2_client_config") + serializer_class = ExternalLinkServiceSerializer diff --git a/addon_service/external_service/models.py b/addon_service/external_service/models.py index 7a7c3302..a371393a 100644 --- a/addon_service/external_service/models.py +++ b/addon_service/external_service/models.py @@ -90,7 +90,7 @@ def configurable_api_root(self): return ServiceTypes.HOSTED in self.service_type @property - def external_service_name(self): + def external_service_name(self) -> str: number = self.int_addon_imp return known_imps.AddonImpNumbers(number).name.lower() diff --git a/addon_service/external_service/storage/views.py b/addon_service/external_service/storage/views.py index 703f48c2..2bc4a1e2 100644 --- a/addon_service/external_service/storage/views.py +++ b/addon_service/external_service/storage/views.py @@ -1,9 +1,22 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) from rest_framework_json_api.views import ReadOnlyModelViewSet from .models import ExternalStorageService from .serializers import ExternalStorageServiceSerializer +@extend_schema_view( + list=extend_schema( + description="Get the list of all available external storage services" + ), + get=extend_schema( + description="Get particular external storage service", + ), + get_wb_credentials=extend_schema(exclude=True), +) class ExternalStorageServiceViewSet(ReadOnlyModelViewSet): queryset = ExternalStorageService.objects.all().select_related( "oauth2_client_config" diff --git a/addon_service/management/commands/fill_external_services.py b/addon_service/management/commands/fill_external_services.py index 081e986d..fb572e0c 100644 --- a/addon_service/management/commands/fill_external_services.py +++ b/addon_service/management/commands/fill_external_services.py @@ -1,4 +1,5 @@ import csv +import logging from pathlib import Path from typing import Any @@ -6,6 +7,8 @@ from addon_service.external_service.citation.models import ExternalCitationService from addon_service.external_service.computing.models import ExternalComputingService +from addon_service.external_service.link.models import ExternalLinkService +from addon_service.external_service.models import ExternalService from addon_service.external_service.storage.models import ExternalStorageService from addon_service.oauth1 import OAuth1ClientConfig from addon_service.oauth2 import OAuth2ClientConfig @@ -20,14 +23,17 @@ "storage": ExternalStorageService, "citation": ExternalCitationService, "computing": ExternalComputingService, + "link": ExternalLinkService, } icons_path = Path(__file__).parent.parent.parent / "static/provider_icons" +logger = logging.getLogger(__name__) class Command(BaseCommand): def handle(self, *args, **kwargs): services = self.load_csv_data(services_csv) + oauth2_configs = self.load_csv_data(oauth2_csv) oauth1_configs = self.load_csv_data(oauth1_csv) @@ -42,14 +48,23 @@ def handle(self, *args, **kwargs): oauth1_config = oauth1_configs[oauth1_id] oauth1_config = OAuth1ClientConfig.objects.create(**oauth1_config) service["oauth1_client_config"] = oauth1_config + logger.warning(f"Filled service {service['display_name']}") model.objects.create(**service) def load_csv_data(self, path: Path) -> dict[str, dict[str, Any]]: + current_services_names = { + service[0] + for service in ExternalService.objects.values_list("display_name").all() + } with path.open() as services_file: item = csv.reader(services_file) field_names = next(item) data_list = [dict(zip(field_names, service)) for service in item] - return {item.pop("id"): self.fix_values(item) for item in data_list} + return { + item.pop("id"): self.fix_values(item) + for item in data_list + if item.get("display_name") not in current_services_names + } def fix_values(self, item): raw_values = {key: self.fix_value(value) for key, value in item.items()} diff --git a/addon_service/management/commands/openapi.py b/addon_service/management/commands/openapi.py new file mode 100644 index 00000000..2753f0a7 --- /dev/null +++ b/addon_service/management/commands/openapi.py @@ -0,0 +1,211 @@ +import copy +import os +import sys + +import yaml +from django.core.management import ( + BaseCommand, + call_command, +) +from yaml import ( + CSafeDumper, + CSafeLoader, +) + + +RELATED_FIELD_PARAM_NAME = "related_field" + +JSON_API_CONTENT_TYPE = "application/vnd.api+json" + + +def kebab_to_camel(s: str) -> str: + if s == "AuthorizedAccount": + return "AuthorizedStorageAccount" + elif s == "ConfiguredAddon": + return "ConfiguredStorageAddon" + s = s.title() + parts = s.split("-") + return parts[0] + "".join(word.capitalize() for word in parts[1:]) + + +def fix_resource(s: str) -> str: + if s == "AuthorizedAccount": + return "authorized-storage-account" + elif s == "ConfiguredAddon": + return "configured-storage-addon" + return s + + +def get_schema_from_ref(openapi_data, ref): + parts = ref.strip("#/").split("/") + node = openapi_data + for part in parts: + if part in node: + node = node[part] + else: + return None + return node + + +def make_decription(child_name: str, parent_name: str, relation_name: str): + if relation_name.endswith("s"): + return f"Fetch all related {kebab_to_camel(child_name)}s to this {parent_name}" + else: + return f"Fetch {parent_name}'s {kebab_to_camel(child_name)}" + + +class Command(BaseCommand): + + def add_arguments(self, parser): + parser.add_argument( + "--output", + type=str, + default="openapi.yml", + ) + + def handle(self, *args, **options): + call_command("spectacular", file=".openapi.yml") + with open(".openapi.yml") as buf: + data = yaml.load(buf, CSafeLoader) + os.remove(".openapi.yml") + + if "paths" not in data: + print("Warning: No 'paths' object found in the YAML file. Nothing to do.") + yaml.dump(data, sys.stdout) + return + + paths = data["paths"] + new_paths = {} + paths_to_delete = [] + + print("🔍 Starting scan for generic relationship endpoints...") + reverse_path_index = { + path_item["get"]["operationId"].replace("_", "-"): path_item + for path, path_item in paths.items() + if "get" in path_item + } + + for path, parent_path_item in paths.items(): + if path.endswith(f"/{{{RELATED_FIELD_PARAM_NAME}}}/"): + print(f" -> Found generic relationship path: {path}") + paths_to_delete.append(path) + + base_path = path.rsplit("/", 2)[0] + "/" + + if base_path not in paths: + print( + f" [!] Warning: Could not find parent path '{base_path}' to infer relationships. Skipping." + ) + continue + + try: + schema_ref: str = paths[base_path]["get"]["responses"]["200"][ + "content" + ][JSON_API_CONTENT_TYPE]["schema"]["$ref"] + primary_resource_schema = get_schema_from_ref( + data, schema_ref.removesuffix("Response") + ) + except KeyError: + print( + f" [!] Warning: Could not find a valid 200 OK schema reference for '{base_path}'. Skipping." + ) + continue + + if not primary_resource_schema: + print( + f" [!] Warning: Could not resolve schema reference '{schema_ref}'. Skipping." + ) + continue + + try: + relationships = primary_resource_schema["properties"][ + "relationships" + ]["properties"] + except KeyError: + print( + f" [!] Warning: No 'properties.relationships.properties' found in schema for '{base_path}'. Skipping." + ) + continue + + print(f" Found relationships: {', '.join(relationships.keys())}") + + for rel_name, rel_schema in relationships.items(): + new_path_str = f"{base_path}{rel_name}" + resource: str = rel_schema["properties"]["data"].get( + "properties", + rel_schema["properties"]["data"] + .get("items", {}) + .get("properties"), + )["type"]["enum"][0] + resource = resource.removesuffix("s") + schema_name = f"{kebab_to_camel(resource)}Response" + relationship_schema_ref = f"#/components/schemas/{schema_name}" + new_path_item = copy.deepcopy( + reverse_path_index.get(f"{fix_resource(resource)}s-retrieve") + ) + + method = "get" + operation = new_path_item[method] + operation["operationId"] = ( + f"{parent_path_item[method]['operationId']}_related_{rel_name}" + ) + parent_name = schema_ref.rsplit("/", maxsplit=1)[1].removesuffix( + "Response" + ) + operation["description"] = ( + f"Fetch all related {kebab_to_camel(resource)}s to this {parent_name}" + ) + operation["description"] = make_decription( + resource, parent_name, rel_name + ) + operation["tags"] = parent_path_item[method]["tags"] + if "parameters" in operation: + operation["parameters"] = [ + p + for p in operation["parameters"] + if p.get("name") != RELATED_FIELD_PARAM_NAME + ] + + try: + operation["responses"]["200"]["content"][JSON_API_CONTENT_TYPE][ + "schema" + ] = {"$ref": relationship_schema_ref} + print( + f" ✓ Creating endpoint '{method.upper()} {new_path_str}'" + ) + print( + f" - Pointing schema to: {relationship_schema_ref}" + ) + + except KeyError: + print( + f" [!] Warning: Could not find a valid response structure in '{method.upper()} {path}' to rewire the schema." + ) + + new_paths[new_path_str] = {"get": operation} + + self.collect_and_write_output( + data, new_paths, options["output"], paths_to_delete + ) + + @staticmethod + def collect_and_write_output(data, new_paths, output_file, paths_to_delete): + if paths_to_delete: + print("\n🔄 Updating OpenAPI structure...") + for path in paths_to_delete: + del data["paths"][path] + print(f" - Removed generic path: {path}") + + data["paths"].update(new_paths) + for path in new_paths: + print(f" + Added specific path: {path}") + + with open(output_file, "w") as f: + yaml.dump(data, f) + print(f"\n✅ Success! Wrote refined OpenAPI spec to '{output_file}'") + else: + print( + "\n✅ No generic relationship paths found to modify. Output file is unchanged." + ) + with open(output_file, "w") as f: + yaml.dump(data, f, CSafeDumper) diff --git a/addon_service/management/commands/providers/providers.csv b/addon_service/management/commands/providers/providers.csv index 27c077c4..d986e48f 100644 --- a/addon_service/management/commands/providers/providers.csv +++ b/addon_service/management/commands/providers/providers.csv @@ -1,15 +1,16 @@ -id,display_name,int_credentials_format,int_service_type,int_addon_imp,supported_scopes,api_base_url,wb_key,oauth1_client_config_id,oauth2_client_config_id,icon_name,api_base_url_options,max_concurrent_downloads,max_upload_mb,int_supported_features,service_type -d424bec8-811b-4d23-bf1c-f4e8219e0454,Mendeley,1,1,1004,{all},https://api.mendeley.com,mendeley,,b3fd76fc-3368-47b4-9053-ff4b7ca4baa2,mendeley.svg,,,,3,citation -fce6ada4-9a6e-4986-94c8-9a378170f5fb,Zotero,5,1,1002,{all},https://api.zotero.org,zotero_org,e3663c71-3520-4fce-b0ff-e56c81a73d54,,zotero.svg,,,,3,citation -455cfb5f-6ca0-4d60-8fc8-279f7b229da2,Boa,3,1,1020,{all},https://boa.com,boa,,,boa.png,,,,1,computing -d5ad0101-b732-4a08-8306-384d66beef0e,Gitlab,4,1,1011,{},https://gitlab.com,gitlab,,,gitlab.svg,{},10,10,511,storage -e3cb1bc9-83ec-49fa-86de-1d04d3f94834,Dataverse,6,3,1010,{},https://demo.dataverse.org,dataverse,,,dataverse.svg,,10,10,511,storage -4a2aa5c7-c5be-4e3f-abd8-04fb34928bd3,s3,2,1,1003,{},https://api.github.com,s3,,,s3.svg,,10,10,511,storage -4854b243-593a-4c6f-96e8-5bb2a2df9134,BOX,1,1,1001,{root_readwrite},https://api.box.com/2.0/,box,,4258d732-db77-42aa-b275-8da841adf77f,box.svg,,10,10,2047,storage -9e7148ae-4919-4676-bb44-a7a6662c6dd0,Dropbox,1,1,1006,{files.metadata.write files.content.write files.content.read files.metadata.read},https://api.dropboxapi.com/2/,dropbox,,90b44f3d-66e3-444c-9261-534b9e37b21a,dropbox.svg,,10,10,511,storage -15b66b0c-4db9-4425-b2e0-dfd701a55b05,OneDrive,1,1,1008,{User.Read offline_access Files.Read Files.Read.All Files.ReadWrite},https://graph.microsoft.com/v1.0/,onedrive,,a64e222c-a37b-4f34-9f27-83611d851f5c,onedrive.svg,,10,10,2047,storage -babfcfcc-0e47-4c8d-9ca1-3b335bb8cf55,Github,1,1,1013,{repo contents},https://api.github.com,github,,285f28ee-15b5-429e-b7ba-3371a19678a8,github.svg,,10,10,2047,storage -b670cf7a-752f-49ed-8367-8238cf8a24d1,Figshare,1,1,1007,{all},https://api.figshare.com/v2/,figshare,,5c161353-7444-478e-881f-3b8aec3a002a,figshare.svg,,10,10,511,storage -2acd3e09-5e27-4e5b-a3d1-579f16663cfc,Bitbucket,1,1,1012,{account repository team},https://api.bitbucket.org/2.0/,bitbucket,,1b2d71cc-425e-4aa6-886c-e2ff247c4f2c,bitbucket.svg,,10,10,511,storage -102c6356-e31a-4316-a9fe-8b2bcb20fdd6,Google Drive,1,1,1005,{https://www.googleapis.com/auth/drive},https://www.googleapis.com/,googledrive,,5cf93c06-c27f-4446-b8ac-ffac209ba465,google_drive.svg,,10,10,511,storage -5bc440c8-a03a-4aa6-bd9e-856aee9883e9,Owncloud,3,2,1009,{},,owncloud,,,owncloud.svg,,10,10,511,storage +id,display_name,int_credentials_format,int_service_type,int_addon_imp,supported_scopes,api_base_url,wb_key,oauth1_client_config_id,oauth2_client_config_id,icon_name,api_base_url_options,max_concurrent_downloads,max_upload_mb,int_supported_features,int_supported_resource_types,browser_base_url,service_type +d424bec8-811b-4d23-bf1c-f4e8219e0454,Mendeley,1,1,1004,{all},https://api.mendeley.com,mendeley,,b3fd76fc-3368-47b4-9053-ff4b7ca4baa2,mendeley.svg,,,,3,,,citation +fce6ada4-9a6e-4986-94c8-9a378170f5fb,Zotero,5,1,1002,{all},https://api.zotero.org,zotero_org,e3663c71-3520-4fce-b0ff-e56c81a73d54,,zotero.svg,,,,3,,,citation +455cfb5f-6ca0-4d60-8fc8-279f7b229da2,Boa,3,1,1020,{all},https://boa.com,boa,,,boa.png,,,,1,,,computing +d5ad0101-b732-4a08-8306-384d66beef0e,Gitlab,4,1,1011,{},https://gitlab.com,gitlab,,,gitlab.svg,{},10,10,511,,,storage +e3cb1bc9-83ec-49fa-86de-1d04d3f94834,Dataverse,6,3,1010,{},https://demo.dataverse.org,dataverse,,,dataverse.svg,,10,10,511,,,storage +4a2aa5c7-c5be-4e3f-abd8-04fb34928bd3,s3,2,1,1003,{},https://api.github.com,s3,,,s3.svg,,10,10,511,,,storage +4854b243-593a-4c6f-96e8-5bb2a2df9134,BOX,1,1,1001,{root_readwrite},https://api.box.com/2.0/,box,,4258d732-db77-42aa-b275-8da841adf77f,box.svg,,10,10,2047,,,storage +9e7148ae-4919-4676-bb44-a7a6662c6dd0,Dropbox,1,1,1006,{files.metadata.write files.content.write files.content.read files.metadata.read},https://api.dropboxapi.com/2/,dropbox,,90b44f3d-66e3-444c-9261-534b9e37b21a,dropbox.svg,,10,10,511,,,storage +15b66b0c-4db9-4425-b2e0-dfd701a55b05,OneDrive,1,1,1008,{User.Read offline_access Files.Read Files.Read.All Files.ReadWrite},https://graph.microsoft.com/v1.0/,onedrive,,a64e222c-a37b-4f34-9f27-83611d851f5c,onedrive.svg,,10,10,2047,,,storage +babfcfcc-0e47-4c8d-9ca1-3b335bb8cf55,Github,1,1,1013,{repo contents},https://api.github.com,github,,285f28ee-15b5-429e-b7ba-3371a19678a8,github.svg,,10,10,2047,,,storage +b670cf7a-752f-49ed-8367-8238cf8a24d1,Figshare,1,1,1007,{all},https://api.figshare.com/v2/,figshare,,5c161353-7444-478e-881f-3b8aec3a002a,figshare.svg,,10,10,511,,,storage +2acd3e09-5e27-4e5b-a3d1-579f16663cfc,Bitbucket,1,1,1012,{account repository team},https://api.bitbucket.org/2.0/,bitbucket,,1b2d71cc-425e-4aa6-886c-e2ff247c4f2c,bitbucket.svg,,10,10,511,,,storage +102c6356-e31a-4316-a9fe-8b2bcb20fdd6,Google Drive,1,1,1005,{https://www.googleapis.com/auth/drive},https://www.googleapis.com/,googledrive,,5cf93c06-c27f-4446-b8ac-ffac209ba465,google_drive.svg,,10,10,511,,,storage +5bc440c8-a03a-4aa6-bd9e-856aee9883e9,Owncloud,3,2,1009,{},,owncloud,,,owncloud.svg,,10,10,511,,,storage +edf57d0c-ae0a-4c85-b759-017fc8b009f0,Link dataverse,6,2,1030,{},https://demo.dataverse.org,,,,dataverse.svg,{},,,104,4294967295,,link diff --git a/addon_service/migrations/0014_authorizedlinkaccount_configuredlinkaddon_and_more.py b/addon_service/migrations/0014_authorizedlinkaccount_configuredlinkaddon_and_more.py new file mode 100644 index 00000000..a8ff71fe --- /dev/null +++ b/addon_service/migrations/0014_authorizedlinkaccount_configuredlinkaddon_and_more.py @@ -0,0 +1,102 @@ +# Generated by Django 4.2.7 on 2025-04-30 12:43 + +import django.db.models.deletion +from django.db import ( + migrations, + models, +) + +import addon_service.configured_addon.link.models +import addon_service.external_service.link.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("addon_service", "0013_externalcredentials_int_credentials_format_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="AuthorizedLinkAccount", + fields=[ + ( + "authorizedaccount_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="addon_service.authorizedaccount", + ), + ), + ], + options={ + "verbose_name": "Authorized Link Account", + "verbose_name_plural": "Authorized Link Accounts", + }, + bases=("addon_service.authorizedaccount",), + ), + migrations.CreateModel( + name="ConfiguredLinkAddon", + fields=[ + ( + "configuredaddon_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="addon_service.configuredaddon", + ), + ), + ("target_id", models.CharField()), + ( + "int_resource_type", + models.BigIntegerField( + validators=[ + addon_service.configured_addon.link.models.is_supported_resource_type + ] + ), + ), + ], + options={ + "verbose_name": "Configured Link Addon", + "verbose_name_plural": "Configured Link Addons", + }, + bases=("addon_service.configuredaddon",), + ), + migrations.CreateModel( + name="ExternalLinkService", + fields=[ + ( + "externalservice_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="addon_service.externalservice", + ), + ), + ("browser_base_url", models.URLField(blank=True, default="")), + ( + "int_supported_resource_types", + models.BigIntegerField( + null=True, + validators=[ + addon_service.external_service.link.models.validate_supported_features + ], + ), + ), + ], + options={ + "verbose_name": "External Link Service", + "verbose_name_plural": "External Link Services", + }, + bases=("addon_service.externalservice",), + ), + ] diff --git a/addon_service/migrations/0015_alter_configuredlinkaddon_int_resource_type_and_more.py b/addon_service/migrations/0015_alter_configuredlinkaddon_int_resource_type_and_more.py new file mode 100644 index 00000000..887ff3e5 --- /dev/null +++ b/addon_service/migrations/0015_alter_configuredlinkaddon_int_resource_type_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.7 on 2025-05-05 20:22 + +from django.db import ( + migrations, + models, +) + +import addon_service.configured_addon.link.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("addon_service", "0014_authorizedlinkaccount_configuredlinkaddon_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="configuredlinkaddon", + name="int_resource_type", + field=models.BigIntegerField( + blank=True, + default=None, + null=True, + validators=[ + addon_service.configured_addon.link.models.is_supported_resource_type + ], + ), + ), + migrations.AlterField( + model_name="configuredlinkaddon", + name="target_id", + field=models.CharField(blank=True, default=None, null=True), + ), + ] diff --git a/addon_service/migrations/0016_externallinkservice_int_supported_features_and_more.py b/addon_service/migrations/0016_externallinkservice_int_supported_features_and_more.py new file mode 100644 index 00000000..e75123b2 --- /dev/null +++ b/addon_service/migrations/0016_externallinkservice_int_supported_features_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.20 on 2025-05-27 13:57 + +from django.db import ( + migrations, + models, +) + +import addon_service.external_service.link.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("addon_service", "0015_alter_configuredlinkaddon_int_resource_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="externallinkservice", + name="int_supported_features", + field=models.IntegerField( + null=True, + validators=[ + addon_service.external_service.link.models.validate_supported_features + ], + ), + ), + migrations.AlterField( + model_name="externallinkservice", + name="int_supported_resource_types", + field=models.BigIntegerField( + null=True, + validators=[ + addon_service.external_service.link.models.validate_supported_resource_types + ], + ), + ), + ] diff --git a/addon_service/models.py b/addon_service/models.py index 99e0395e..b350e261 100644 --- a/addon_service/models.py +++ b/addon_service/models.py @@ -5,13 +5,16 @@ from addon_service.addon_operation_invocation.models import AddonOperationInvocation from addon_service.authorized_account.citation.models import AuthorizedCitationAccount from addon_service.authorized_account.computing.models import AuthorizedComputingAccount +from addon_service.authorized_account.link.models import AuthorizedLinkAccount from addon_service.authorized_account.storage.models import AuthorizedStorageAccount from addon_service.configured_addon.citation.models import ConfiguredCitationAddon from addon_service.configured_addon.computing.models import ConfiguredComputingAddon +from addon_service.configured_addon.link.models import ConfiguredLinkAddon from addon_service.configured_addon.storage.models import ConfiguredStorageAddon from addon_service.credentials.models import ExternalCredentials from addon_service.external_service.citation.models import ExternalCitationService from addon_service.external_service.computing.models import ExternalComputingService +from addon_service.external_service.link.models import ExternalLinkService from addon_service.external_service.storage.models import ExternalStorageService from addon_service.oauth1.models import OAuth1ClientConfig from addon_service.oauth2.models import ( @@ -41,4 +44,7 @@ "AuthorizedComputingAccount", "ConfiguredComputingAddon", "ExternalComputingService", + "ExternalLinkService", + "AuthorizedLinkAccount", + "ConfiguredLinkAddon", ) diff --git a/addon_service/oauth1/views.py b/addon_service/oauth1/views.py index 9a72e367..227b25d9 100644 --- a/addon_service/oauth1/views.py +++ b/addon_service/oauth1/views.py @@ -2,6 +2,7 @@ from asgiref.sync import async_to_sync from django.http import HttpResponse +from drf_spectacular.utils import extend_schema from addon_service.authorized_account.citation.models import AuthorizedCitationAccount from addon_service.authorized_account.computing.models import AuthorizedComputingAccount @@ -10,6 +11,8 @@ from addon_service.osf_models.fields import decrypt_string +# Exclude oAuth views from openapi schema as they are from internal use only +@extend_schema(exclude=True) def oauth1_callback_view(request): oauth_token = request.GET["oauth_token"] oauth_verifier = request.GET["oauth_verifier"] diff --git a/addon_service/oauth2/views.py b/addon_service/oauth2/views.py index d442f676..4c6d30ff 100644 --- a/addon_service/oauth2/views.py +++ b/addon_service/oauth2/views.py @@ -4,6 +4,7 @@ from asgiref.sync import sync_to_async from django.db import transaction from django.http import HttpResponse +from drf_spectacular.utils import extend_schema from addon_service.models import ( OAuth2ClientConfig, @@ -12,6 +13,8 @@ from addon_service.oauth2.utils import get_initial_access_token +# Exclude oAuth views from openapi schema as they are from internal use only +@extend_schema(exclude=True) @transaction.non_atomic_requests # async views and ATOMIC_REQUESTS do not mix async def oauth2_callback_view(request): """ diff --git a/addon_service/resource_reference/models.py b/addon_service/resource_reference/models.py index d9004be6..5e447bd2 100644 --- a/addon_service/resource_reference/models.py +++ b/addon_service/resource_reference/models.py @@ -2,8 +2,10 @@ from django.db.models.functions import Lower from addon_service.common.base_model import AddonsServiceBaseModel +from addon_service.common.regex import uri_regex from addon_service.configured_addon.citation.models import ConfiguredCitationAddon from addon_service.configured_addon.computing.models import ConfiguredComputingAddon +from addon_service.configured_addon.link.models import ConfiguredLinkAddon from addon_service.configured_addon.storage.models import ConfiguredStorageAddon @@ -22,6 +24,16 @@ def configured_storage_addons(self): .order_by(Lower("_display_name")) ) + @property + def configured_link_addons(self): + return ConfiguredLinkAddon.objects.filter( + authorized_resource=self + ).select_related( + "base_account__external_service__externallinkservice", + "base_account__authorizedlinkaccount", + "base_account__account_owner", + ) + @property def configured_citation_addons(self): return ConfiguredCitationAddon.objects.filter( @@ -42,6 +54,13 @@ def configured_computing_addons(self): "base_account__account_owner", ) + @property + def guid(self) -> str | None: + match = uri_regex.match(self.resource_uri) + if match: + return match["id"] + return None + class Meta: verbose_name = "Resource Reference" verbose_name_plural = "Resource References" diff --git a/addon_service/resource_reference/serializers.py b/addon_service/resource_reference/serializers.py index dccf363a..48461d21 100644 --- a/addon_service/resource_reference/serializers.py +++ b/addon_service/resource_reference/serializers.py @@ -5,6 +5,7 @@ from addon_service.common import view_names from addon_service.configured_addon.citation.models import ConfiguredCitationAddon from addon_service.configured_addon.computing.models import ConfiguredComputingAddon +from addon_service.configured_addon.link.models import ConfiguredLinkAddon from addon_service.models import ( ConfiguredStorageAddon, ResourceReference, @@ -30,6 +31,11 @@ class ResourceReferenceSerializer(serializers.HyperlinkedModelSerializer): queryset=ConfiguredCitationAddon.objects.active(), related_link_view_name=view_names.related_view(RESOURCE_TYPE), ) + configured_link_addons = HyperlinkedRelatedField( + many=True, + queryset=ConfiguredLinkAddon.objects.active(), + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) configured_computing_addons = HyperlinkedRelatedField( many=True, queryset=ConfiguredComputingAddon.objects.active(), @@ -43,6 +49,9 @@ class ResourceReferenceSerializer(serializers.HyperlinkedModelSerializer): "configured_citation_addons": ( "addon_service.serializers.ConfiguredCitationAddonSerializer" ), + "configured_link_addons": ( + "addon_service.serializers.ConfiguredLinkAddonSerializer" + ), "configured_computing_addons": ( "addon_service.serializers.ConfiguredComputingAddonSerializer" ), @@ -55,6 +64,7 @@ class Meta: "url", "resource_uri", "configured_storage_addons", + "configured_link_addons", "configured_citation_addons", "configured_computing_addons", ] diff --git a/addon_service/resource_reference/views.py b/addon_service/resource_reference/views.py index 7d8eeec2..4f2e7a89 100644 --- a/addon_service/resource_reference/views.py +++ b/addon_service/resource_reference/views.py @@ -1,3 +1,8 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) + from addon_service.common.permissions import SessionUserCanViewReferencedResource from addon_service.common.viewsets import RestrictedReadOnlyViewSet from addon_service.serializers import ResourceReferenceSerializer @@ -5,6 +10,14 @@ from .models import ResourceReference +@extend_schema_view( + list=extend_schema( + description="Get resource reference by resource_uri. Even through this is a list method, this endpoint returns only one entity" + ), + retrieve=extend_schema( + description="Get resource reference by it's pk", + ), +) class ResourceReferenceViewSet(RestrictedReadOnlyViewSet): queryset = ResourceReference.objects.all() serializer_class = ResourceReferenceSerializer diff --git a/addon_service/serializers.py b/addon_service/serializers.py index e3f15b46..6cebd0f7 100644 --- a/addon_service/serializers.py +++ b/addon_service/serializers.py @@ -11,6 +11,9 @@ from addon_service.authorized_account.computing.serializers import ( AuthorizedComputingAccountSerializer, ) +from addon_service.authorized_account.link.serializers import ( + AuthorizedLinkAccountSerializer, +) from addon_service.authorized_account.polymorphic_serializers import ( AuthorizedAccountPolymorphicSerializer, ) @@ -24,6 +27,9 @@ from addon_service.configured_addon.computing.serializers import ( ConfiguredComputingAddonSerializer, ) +from addon_service.configured_addon.link.serializers import ( + ConfiguredLinkAddonSerializer, +) from addon_service.configured_addon.polymorphic_serializers import ( ConfiguredAddonPolymorphicSerializer, ) @@ -37,6 +43,9 @@ from addon_service.external_service.computing.serializers import ( ExternalComputingServiceSerializer, ) +from addon_service.external_service.link.serializers import ( + ExternalLinkServiceSerializer, +) from addon_service.external_service.serializers import ExternalServiceSerializer from addon_service.external_service.storage.serializers import ( ExternalStorageServiceSerializer, @@ -67,4 +76,7 @@ "AuthorizedAccountSerializer", "AuthorizedAccountPolymorphicSerializer", "ConfiguredAddonPolymorphicSerializer", + "ConfiguredLinkAddonSerializer", + "AuthorizedLinkAccountSerializer", + "ExternalLinkServiceSerializer", ) diff --git a/addon_service/tests/_factories.py b/addon_service/tests/_factories.py index 749a6cbf..3aeea18f 100644 --- a/addon_service/tests/_factories.py +++ b/addon_service/tests/_factories.py @@ -187,3 +187,155 @@ def _create( *args, **kwargs, ) + + +### +# "Link" models + + +class ExternalLinkServiceFactory(DjangoModelFactory): + class Meta: + model = db.ExternalLinkService + + display_name = factory.Faker("word") + int_addon_imp = known_imps.get_imp_number( + known_imps.get_imp_by_name("LINK_DATAVERSE") + ) + supported_scopes = ["service.url/grant_all"] + + @classmethod + def _create( + cls, + model_class, + credentials_format=CredentialsFormats.PERSONAL_ACCESS_TOKEN, + service_type=ServiceTypes.PUBLIC, + supported_resource_types=None, + supported_features=None, + *args, + **kwargs, + ): + from addon_service.external_service.link.models import LinkSupportedFeatures + from addon_toolkit.interfaces.link import SupportedResourceTypes + + api_base_url = "" + if ServiceTypes.PUBLIC in service_type: + api_base_url = "https://api.example.url/v1" + + supported_resource_types = ( + supported_resource_types or SupportedResourceTypes.Other + ) + + supported_features = ( + supported_features or LinkSupportedFeatures.ADD_UPDATE_FILES + ) + + return super()._create( + model_class=model_class, + int_credentials_format=credentials_format.value, + int_service_type=service_type.value, + int_supported_resource_types=supported_resource_types.value, + int_supported_features=supported_features.value, + api_base_url=api_base_url, + *args, + **kwargs, + ) + + +class ExternalLinkOAuth2ServiceFactory(ExternalLinkServiceFactory): + credentials_format = CredentialsFormats.OAUTH2 + oauth2_client_config = factory.SubFactory(OAuth2ClientConfigFactory) + + +class ExternalLinkOAuth1ServiceFactory(ExternalLinkServiceFactory): + credentials_format = CredentialsFormats.OAUTH1A + oauth1_client_config = factory.SubFactory(OAuth1ClientConfigFactory) + + +class AuthorizedLinkAccountFactory(DjangoModelFactory): + class Meta: + model = db.AuthorizedLinkAccount + + display_name = factory.Faker("word") + authorized_capabilities = AddonCapabilities.ACCESS | AddonCapabilities.UPDATE + + @classmethod + def _create( + cls, + model_class, + account_owner=None, + external_service=None, + credentials=None, + credentials_format=CredentialsFormats.OAUTH2, + authorized_scopes=None, + *args, + **kwargs, + ): + from addon_service.tests._helpers import patch_encryption_key_derivation + + account = super()._create( + model_class=model_class, + external_service=external_service + or ExternalLinkOAuth2ServiceFactory(credentials_format=credentials_format), + account_owner=account_owner or UserReferenceFactory(), + *args, + **kwargs, + ) + if credentials_format is CredentialsFormats.OAUTH2: + account.initiate_oauth2_flow(authorized_scopes) + else: + with patch_encryption_key_derivation(): + from addon_service.tests.test_by_type.test_authorized_link_account import ( + MOCK_CREDENTIALS, + ) + + account.credentials = credentials or MOCK_CREDENTIALS.get( + credentials_format + ) + account.save() + return account + + +class ConfiguredLinkAddonFactory(DjangoModelFactory): + class Meta: + model = db.ConfiguredLinkAddon + + target_id = factory.Faker("uuid4") + connected_capabilities = AddonCapabilities.ACCESS + + @classmethod + def _create( + cls, + model_class, + authorized_resource=None, + external_link_service=None, + credentials_format=CredentialsFormats.OAUTH2, + base_account=None, + account_owner=None, + credentials=None, + resource_type=None, + *args, + **kwargs, + ): + from addon_toolkit.interfaces.link import SupportedResourceTypes + + authorized_resource = authorized_resource or ResourceReferenceFactory() + base_account = base_account or AuthorizedLinkAccountFactory( + external_service=external_link_service, + credentials_format=credentials_format, + account_owner=account_owner, + credentials=credentials, + ) + + resource_type = resource_type or SupportedResourceTypes.Other + + kwargs["int_resource_type"] = resource_type.value + + addon = super()._create( + model_class=model_class, + authorized_resource=authorized_resource, + base_account=base_account, + *args, + **kwargs, + ) + + return addon diff --git a/addon_service/tests/_helpers.py b/addon_service/tests/_helpers.py index 4a036338..4b0aeaf3 100644 --- a/addon_service/tests/_helpers.py +++ b/addon_service/tests/_helpers.py @@ -20,7 +20,6 @@ ) from asgiref.sync import sync_to_async -from django.conf import settings from django.contrib.sessions.backends.db import SessionStore from django.urls import reverse from rest_framework.test import APIRequestFactory @@ -80,11 +79,13 @@ def configure_user_role(self, user_uri, resource_uri, role): def configure_resource_visibility(self, resource_uri, *, public=True): self._permissions[resource_uri]["public"] = public - def _get_assumed_caller(self, cookies=None): + def _get_assumed_caller(self, request): if self._configured_caller_uri: + request.session["user_reference_uri"] = self._configured_caller_uri return self._configured_caller_uri - if cookies is not None: - return cookies.get(settings.USER_REFERENCE_COOKIE) + + if request.COOKIES is not None: + return request.session.get("user_reference_uri") return None def _get_user_permissions(self, user_uri, resource_uri): @@ -103,8 +104,7 @@ def _get_user_permissions(self, user_uri, resource_uri): def _mock_user_check(self, request) -> tuple[Any, Any] | None: # replaces `authenticate` on a custom rest_framework authenticator: # https://www.django-rest-framework.org/api-guide/authentication/#custom-authentication - caller_uri = self._get_assumed_caller(cookies=request.COOKIES) - request.session["user_reference_uri"] = caller_uri + caller_uri = self._get_assumed_caller(request) return ( (None, None) # success! return a tuple (values here yet unused) if caller_uri @@ -112,7 +112,7 @@ def _mock_user_check(self, request) -> tuple[Any, Any] | None: ) def _mock_resource_check(self, request, uri, required_permission, *args, **kwargs): - caller = self._get_assumed_caller(cookies=request.COOKIES) + caller = self._get_assumed_caller(request) permissions = self._get_user_permissions(user_uri=caller, resource_uri=uri) return bool(required_permission.lower() in permissions) diff --git a/addon_service/tests/e2e_tests/test_oauth_flow.py b/addon_service/tests/e2e_tests/test_oauth_flow.py index cd9b5954..64caf5a5 100644 --- a/addon_service/tests/e2e_tests/test_oauth_flow.py +++ b/addon_service/tests/e2e_tests/test_oauth_flow.py @@ -1,7 +1,6 @@ import secrets from asgiref.sync import async_to_sync -from django.conf import settings from django.urls import reverse from rest_framework.test import APITestCase @@ -55,8 +54,8 @@ def setUp(self): access=self.MOCK_ACCESS_TOKEN, refresh=self.MOCK_REFRESH_TOKEN ) - self.client.cookies[settings.USER_REFERENCE_COOKIE] = self._user.user_uri self._mock_osf = _helpers.MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) def test_oauth_account_setup(self): @@ -119,8 +118,8 @@ def setUp(self): _static_verifier=self.MOCK_VERIFIER, ) - self.client.cookies[settings.USER_REFERENCE_COOKIE] = self._user.user_uri self._mock_osf = _helpers.MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) self.enterContext(self._mock_service.mocking()) diff --git a/addon_service/tests/test_by_type/test_authorized_link_account.py b/addon_service/tests/test_by_type/test_authorized_link_account.py new file mode 100644 index 00000000..328a2628 --- /dev/null +++ b/addon_service/tests/test_by_type/test_authorized_link_account.py @@ -0,0 +1,401 @@ +from http import HTTPStatus +from unittest.mock import ( + MagicMock, + patch, +) + +from django.test import TestCase +from django.urls import reverse +from itsdangerous import Signer +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.authorized_account.link.views import AuthorizedLinkAccountViewSet +from addon_service.common.credentials_formats import CredentialsFormats +from addon_service.common.service_types import ServiceTypes +from addon_service.tests import _factories +from addon_service.tests._helpers import ( + MockOSF, + get_test_request, + patch_encryption_key_derivation, +) +from addon_toolkit import AddonCapabilities +from addon_toolkit.credentials import ( + AccessKeySecretKeyCredentials, + AccessTokenCredentials, + UsernamePasswordCredentials, +) +from app import settings + + +MOCK_CREDENTIALS = { + CredentialsFormats.OAUTH2: None, + CredentialsFormats.PERSONAL_ACCESS_TOKEN: AccessTokenCredentials( + access_token="token" + ), + CredentialsFormats.ACCESS_KEY_SECRET_KEY: AccessKeySecretKeyCredentials( + access_key="access", + secret_key="secret", + ), + CredentialsFormats.USERNAME_PASSWORD: UsernamePasswordCredentials( + username="me", + password="unsafe", + ), + CredentialsFormats.DATAVERSE_API_TOKEN: AccessTokenCredentials( + access_token="token" + ), +} + + +def _make_post_payload( + *, + external_service, + capabilities=None, + credentials=None, + api_root="", + display_name="MY ACCOUNT MINE", + initiate_oauth=True, +): + capabilities = capabilities or [AddonCapabilities.ACCESS.name] + payload = { + "data": { + "type": "authorized-link-accounts", + "attributes": { + "display_name": display_name, + "authorized_capabilities": capabilities, + "api_base_url": api_root, + "initiate_oauth": initiate_oauth, + }, + "relationships": { + "external_link_service": { + "data": { + "type": "external-link-services", + "id": str(external_service.id), + } + }, + }, + } + } + credentials = credentials or MOCK_CREDENTIALS[external_service.credentials_format] + if credentials: + from addon_service.common import json_arguments + + payload["data"]["attributes"]["credentials"] = ( + json_arguments.json_for_dataclass(credentials) + ) + return payload + + +def mock_get_link_addon_instance(*args, **kwargs): + mock_instance = MagicMock() + mock_instance.build_url_for_id = MagicMock( + return_value="https://example.com/dataset/123" + ) + mock_instance.get_external_account_id = MagicMock(return_value="test-account-id") + return mock_instance + + +class TestAuthorizedLinkAccountAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._ala = _factories.AuthorizedLinkAccountFactory() + cls._user = cls._ala.account_owner + + def setUp(self): + super().setUp() + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign(self._user.user_uri).decode() + ) + self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + @property + def _detail_path(self): + return reverse( + "authorized-link-accounts-detail", + kwargs={"pk": self._ala.pk}, + ) + + @property + def _list_path(self): + return reverse("authorized-link-accounts-list") + + def _related_path(self, related_field): + return reverse( + "authorized-link-accounts-related", + kwargs={ + "pk": self._ala.pk, + "related_field": related_field, + }, + ) + + def test_get_detail(self): + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data["display_name"], self._ala.display_name) + + def test_post(self): + external_service = _factories.ExternalLinkOAuth2ServiceFactory() + self.assertFalse(external_service.authorized_link_accounts.exists()) + + _resp = self.client.post( + reverse("authorized-link-accounts-list"), + _make_post_payload( + external_service=external_service, display_name="test link account" + ), + format="vnd.api+json", + ) + self.assertEqual(_resp.status_code, HTTPStatus.CREATED) + + _from_db = external_service.authorized_link_accounts.get(id=_resp.data["id"]) + self.assertEqual(_from_db.display_name, "test link account") + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._detail_path: {"put"}, + self._list_path: {"put"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +class TestAuthorizedLinkAccountModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user, + ) + + def setUp(self): + self._mock_osf = MockOSF() + self.enterContext(self._mock_osf.mocking()) + + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + def test_can_load(self): + _account_from_db = db.AuthorizedLinkAccount.objects.get(id=self._account.id) + self.assertEqual(self._account.display_name, _account_from_db.display_name) + + def test_configured_link_addons__empty(self): + self.assertEqual( + list(self._account.configured_link_addons.all()), + [], + ) + + def test_configured_link_addons__several(self): + _addons = set( + _factories.ConfiguredLinkAddonFactory.create_batch( + size=3, + base_account=self._account, + ) + ) + self.assertEqual( + set(self._account.configured_link_addons.all()), + _addons, + ) + + def test_set_credentials__create(self): + for creds_format in [ + CredentialsFormats.PERSONAL_ACCESS_TOKEN, + CredentialsFormats.ACCESS_KEY_SECRET_KEY, + CredentialsFormats.USERNAME_PASSWORD, + ]: + account = _factories.AuthorizedLinkAccountFactory( + credentials_format=creds_format + ) + with self.subTest(creds_format=creds_format): + with patch_encryption_key_derivation(): + account.credentials = MOCK_CREDENTIALS[creds_format] + account.save() + + refreshed = db.AuthorizedLinkAccount.objects.get(id=account.id) + + self.assertTrue(refreshed.credentials_available) + + with patch_encryption_key_derivation(): + self.assertEqual( + refreshed.credentials, + MOCK_CREDENTIALS[creds_format], + ) + + def test_capabilities(self): + new_capabilities = AddonCapabilities.ACCESS | AddonCapabilities.UPDATE + self._account.authorized_capabilities = new_capabilities + self._account.save() + + refreshed = db.AuthorizedLinkAccount.objects.get(id=self._account.id) + self.assertEqual(refreshed.authorized_capabilities, new_capabilities) + + +class TestAuthorizedLinkAccountViewSet(TestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user, + ) + cls._view = AuthorizedLinkAccountViewSet.as_view({"get": "retrieve"}) + + cls._addons = _factories.ConfiguredLinkAddonFactory.create_batch( + size=2, + base_account=cls._account, + ) + + def setUp(self): + self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + def test_get(self): + request = get_test_request(user=self._user) + request.session = {"user_reference_uri": self._user.user_uri} + request.COOKIES = {"osf": self._user.user_uri} + _resp = self._view( + request, + pk=self._account.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + + with self.subTest("Confirm expected keys"): + expected_fields = { + "authorized_capabilities", + "authorized_operation_names", + "credentials_available", + "display_name", + "api_base_url", + } + for field in expected_fields: + self.assertIn(field, _resp.data.keys()) + + with self.subTest("Confirm expected relationships"): + relationship_fields = { + key for key, value in _resp.data.items() if isinstance(value, dict) + } + for relation in ["account_owner", "external_link_service"]: + self.assertIn(relation, relationship_fields) + + def test_owner_access(self): + request = get_test_request(user=self._user) + request.session = {"user_reference_uri": self._user.user_uri} + request.COOKIES = {"osf": self._user.user_uri} + _resp = self._view( + request, + pk=self._account.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + + def test_wrong_user(self): + _another_user = _factories.UserReferenceFactory() + self._mock_osf.configure_assumed_caller(_another_user.user_uri) + request = get_test_request(user=_another_user) + request.session = {"user_reference_uri": _another_user.user_uri} + request.COOKIES = {"osf": _another_user.user_uri} + _resp = self._view( + request, + pk=self._account.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) + + +class TestCreateAuthorizedLinkAccount(APITestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._external_service = _factories.ExternalLinkOAuth2ServiceFactory() + + def setUp(self): + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign(self._user.user_uri).decode() + ) + + self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + def test_create_account(self): + external_service = _factories.ExternalLinkOAuth2ServiceFactory( + service_type=ServiceTypes.PUBLIC | ServiceTypes.HOSTED, + ) + self.assertFalse(external_service.authorized_link_accounts.exists()) + + capabilities = ["ACCESS"] + + _resp = self.client.post( + reverse("authorized-link-accounts-list"), + _make_post_payload( + external_service=external_service, + display_name="Test Link Account", + api_root="https://api.example.com", + capabilities=capabilities, + initiate_oauth=False, + ), + format="vnd.api+json", + ) + + self.assertEqual(_resp.status_code, HTTPStatus.CREATED) + + self.assertEqual(_resp.data["display_name"], "Test Link Account") + self.assertIn("ACCESS", _resp.data["authorized_capabilities"]) + + account = db.AuthorizedLinkAccount.objects.get(id=_resp.data["id"]) + self.assertEqual(account.display_name, "Test Link Account") + self.assertEqual(account.api_base_url, "https://api.example.com") + self.assertEqual(account.external_service.id, external_service.id) diff --git a/addon_service/tests/test_by_type/test_authorized_storage_account.py b/addon_service/tests/test_by_type/test_authorized_storage_account.py index c7c995ca..8f3a174f 100644 --- a/addon_service/tests/test_by_type/test_authorized_storage_account.py +++ b/addon_service/tests/test_by_type/test_authorized_storage_account.py @@ -1,7 +1,6 @@ import urllib from http import HTTPStatus -from django.conf import settings from django.core.exceptions import ValidationError from django.test import TestCase from django.urls import reverse @@ -100,8 +99,8 @@ def setUpTestData(cls): def setUp(self): super().setUp() - self.client.cookies[settings.USER_REFERENCE_COOKIE] = self._user.user_uri self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) @property @@ -488,13 +487,12 @@ def setUpTestData(cls): def setUp(self): super().setUp() self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) def test_get_related__empty(self): _resp = self._related_view( - get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: self._user.user_uri} - ), + get_test_request(), pk=self._asa.pk, related_field="configured_storage_addons", ) @@ -507,9 +505,7 @@ def test_get_related__several(self): base_account=self._asa, ) _resp = self._related_view( - get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: self._user.user_uri} - ), + get_test_request(), pk=self._asa.pk, related_field="configured_storage_addons", ) diff --git a/addon_service/tests/test_by_type/test_configured_link_addon.py b/addon_service/tests/test_by_type/test_configured_link_addon.py new file mode 100644 index 00000000..9ba91fd2 --- /dev/null +++ b/addon_service/tests/test_by_type/test_configured_link_addon.py @@ -0,0 +1,337 @@ +from http import HTTPStatus +from unittest.mock import ( + MagicMock, + patch, +) + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse +from itsdangerous import Signer +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.configured_addon.link.models import is_supported_resource_type +from addon_service.configured_addon.link.views import ConfiguredLinkAddonViewSet +from addon_service.tests import _factories +from addon_service.tests._helpers import ( + MockOSF, + get_test_request, +) +from addon_toolkit import AddonCapabilities +from addon_toolkit.interfaces.link import SupportedResourceTypes +from app import settings + + +def mock_target_url(self): + return f"https://example.com/dataset/{self.target_id}" if self.target_id else None + + +def mock_get_link_addon_instance(*args, **kwargs): + mock_instance = MagicMock() + mock_instance.build_url_for_id = MagicMock( + return_value="https://example.com/dataset/123" + ) + mock_instance.get_external_account_id = MagicMock(return_value="test-account-id") + return mock_instance + + +class TestConfiguredLinkAddonAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + cls._authorized_account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user + ) + cls._addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=cls._resource, + base_account=cls._authorized_account, + ) + + def setUp(self): + super().setUp() + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign(self._user.user_uri).decode() + ) + self._mock_osf = MockOSF() + self._mock_osf.configure_user_role( + self._user.user_uri, self._resource.resource_uri, "admin" + ) + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + @property + def _detail_path(self): + return reverse("configured-link-addons-detail", kwargs={"pk": self._addon.pk}) + + @property + def _list_path(self): + return reverse("configured-link-addons-list") + + def test_get(self): + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data["target_id"], self._addon.target_id) + self.assertEqual(_resp.data["resource_type"], self._addon.resource_type.name) + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._list_path: {"patch", "put"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +class TestConfiguredLinkAddonModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._addon = _factories.ConfiguredLinkAddonFactory() + + def setUp(self): + self._mock_osf = MockOSF() + self.enterContext(self._mock_osf.mocking()) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + def test_can_load(self): + _addon_from_db = db.ConfiguredLinkAddon.objects.get(id=self._addon.id) + self.assertEqual(self._addon.target_id, _addon_from_db.target_id) + self.assertEqual( + self._addon.resource_type.value, _addon_from_db.resource_type.value + ) + + def test_resource_type_property(self): + self._addon.resource_type = SupportedResourceTypes.Book + self._addon.save() + + refreshed = db.ConfiguredLinkAddon.objects.get(id=self._addon.id) + self.assertEqual(refreshed.resource_type, SupportedResourceTypes.Book) + + self._addon.resource_type = SupportedResourceTypes.Dataset + self._addon.save() + + refreshed = db.ConfiguredLinkAddon.objects.get(id=self._addon.id) + self.assertEqual(refreshed.resource_type, SupportedResourceTypes.Dataset) + + def test_validator_valid_types(self): + try: + is_supported_resource_type(SupportedResourceTypes.Dataset.value) + is_supported_resource_type(SupportedResourceTypes.Journal.value) + is_supported_resource_type(SupportedResourceTypes.Software.value) + except ValidationError: + self.fail("Validator raised ValidationError unexpectedly on valid types") + + def test_validator_invalid_type(self): + with self.assertRaises(ValidationError): + is_supported_resource_type(-999) + + combined = ( + SupportedResourceTypes.Dataset.value | SupportedResourceTypes.Journal.value + ) + with self.assertRaises(ValidationError): + is_supported_resource_type(combined) + + def test_validation_on_save(self): + self._addon.int_resource_type = ( + SupportedResourceTypes.Dataset.value | SupportedResourceTypes.Journal.value + ) + with self.assertRaises(ValidationError): + self._addon.clean_fields() + + self._addon.int_resource_type = -999 + with self.assertRaises(ValidationError): + self._addon.clean_fields() + + def test_target_url(self): + addon = _factories.ConfiguredLinkAddonFactory() + addon.target_id = "" + self.assertIsNone(addon.target_url()) + + addon.target_id = "test-id" + self.assertEqual(addon.target_url(), "https://example.com/dataset/test-id") + + +class TestConfiguredLinkAddonViewSet(TestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + cls._authorized_account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user + ) + cls._addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=cls._resource, + base_account=cls._authorized_account, + ) + cls._view = ConfiguredLinkAddonViewSet.as_view({"get": "retrieve"}) + + def setUp(self): + self._mock_osf = MockOSF() + self._mock_osf.configure_user_role( + self._user.user_uri, self._resource.resource_uri, "admin" + ) + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + def test_get(self): + request = get_test_request(user=self._user) + request.session = {"user_reference_uri": self._user.user_uri} + request.COOKIES = {"osf": self._user.user_uri} + + _resp = self._view( + request, + pk=self._addon.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + + with self.subTest("Confirm expected attributes"): + self.assertEqual(_resp.data["target_id"], self._addon.target_id) + self.assertEqual( + _resp.data["resource_type"], self._addon.resource_type.name + ) + self.assertIn("connected_operation_names", _resp.data) + + with self.subTest("Confirm expected relationships"): + relationship_fields = { + key for key, value in _resp.data.items() if isinstance(value, dict) + } + self.assertIn("base_account", relationship_fields) + self.assertIn("authorized_resource", relationship_fields) + + def test_owner_access(self): + request = get_test_request(user=self._user) + request.session = {"user_reference_uri": self._user.user_uri} + request.COOKIES = {"osf": self._user.user_uri} + + _resp = self._view( + request, + pk=self._addon.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + + def test_wrong_user(self): + _another_user = _factories.UserReferenceFactory() + self._mock_osf.configure_assumed_caller(_another_user.user_uri) + + request = get_test_request(user=_another_user) + request.session = {"user_reference_uri": _another_user.user_uri} + request.COOKIES = {"osf": _another_user.user_uri} + + _resp = self._view( + request, + pk=self._addon.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) + + +class TestCreateConfiguredLinkAddon(APITestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + cls._authorized_account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user + ) + + def setUp(self): + super().setUp() + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign(self._user.user_uri).decode() + ) + self._mock_osf = MockOSF() + self._mock_osf.configure_user_role( + self._user.user_uri, self._resource.resource_uri, "admin" + ) + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + self.instantiate_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiate_patcher.start() + self.addCleanup(self.instantiate_patcher.stop) + + @property + def _list_path(self): + return reverse("configured-link-addons-list") + + def test_create_addon(self): + _initial_count = db.ConfiguredLinkAddon.objects.count() + + _target_id = "12345" + _resource_type = SupportedResourceTypes.Dataset + + import json + + _resp = self.client.post( + self._list_path, + data=json.dumps( + { + "data": { + "type": "configured-link-addons", + "attributes": { + "target_id": _target_id, + "resource_type": _resource_type.name, + "connected_capabilities": [AddonCapabilities.ACCESS.name], + "authorized_resource_uri": self._resource.resource_uri, + }, + "relationships": { + "base_account": { + "data": { + "id": str(self._authorized_account.id), + "type": "authorized-link-accounts", + } + }, + "authorized_resource": { + "data": { + "id": self._resource.resource_uri, + "type": "resource-references", + } + }, + }, + } + } + ), + content_type="application/vnd.api+json", + ) + + self.assertEqual( + _resp.status_code, HTTPStatus.CREATED, f"Response content: {_resp.content}" + ) + + self.assertEqual(db.ConfiguredLinkAddon.objects.count(), _initial_count + 1) + _created = db.ConfiguredLinkAddon.objects.get(id=_resp.data["id"]) + self.assertEqual(_created.target_id, _target_id) + self.assertEqual(_created.resource_type.value, _resource_type.value) diff --git a/addon_service/tests/test_by_type/test_configured_link_addon_null_values.py b/addon_service/tests/test_by_type/test_configured_link_addon_null_values.py new file mode 100644 index 00000000..e6e49ced --- /dev/null +++ b/addon_service/tests/test_by_type/test_configured_link_addon_null_values.py @@ -0,0 +1,178 @@ +import json +from http import HTTPStatus +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from itsdangerous import Signer +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.configured_addon.link.serializers import ( + ConfiguredLinkAddonSerializer, +) +from addon_service.tests import _factories +from addon_service.tests._helpers import MockOSF +from addon_toolkit import AddonCapabilities +from addon_toolkit.interfaces.link import SupportedResourceTypes +from app import settings + + +def mock_target_url(self): + return f"https://example.com/dataset/{self.target_id}" if self.target_id else None + + +class TestConfiguredLinkAddonNullValues(TestCase): + @classmethod + def setUpTestData(cls): + cls._addon = _factories.ConfiguredLinkAddonFactory() + + def setUp(self): + self._mock_osf = MockOSF() + self.enterContext(self._mock_osf.mocking()) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + def test_resource_type_setter_with_none(self): + addon = _factories.ConfiguredLinkAddonFactory() + + addon.resource_type = SupportedResourceTypes.Other + addon.save() + refreshed = db.ConfiguredLinkAddon.objects.get(id=addon.id) + self.assertEqual(refreshed.resource_type, SupportedResourceTypes.Other) + + addon.resource_type = None + addon.save() + + refreshed = db.ConfiguredLinkAddon.objects.get(id=addon.id) + self.assertEqual( + refreshed.int_resource_type, SupportedResourceTypes.Other.value + ) + + +class TestConfiguredLinkAddonSerializerNullValues(APITestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + cls._authorized_account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user + ) + cls._addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=cls._resource, + base_account=cls._authorized_account, + ) + + def setUp(self): + super().setUp() + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign(self._user.user_uri).decode() + ) + self._mock_osf = MockOSF() + self._mock_osf.configure_user_role( + self._user.user_uri, self._resource.resource_uri, "admin" + ) + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + @property + def _detail_path(self): + return reverse("configured-link-addons-detail", kwargs={"pk": self._addon.pk}) + + @property + def _list_path(self): + return reverse("configured-link-addons-list") + + def test_serializer_with_null_target_id(self): + serializer = ConfiguredLinkAddonSerializer( + data={ + "target_id": None, + "connected_capabilities": [AddonCapabilities.ACCESS.name], + "base_account": { + "id": self._authorized_account.id, + "type": "authorized-link-accounts", + }, + "authorized_resource": self._resource.resource_uri, + }, + context={"request": None}, + ) + + self.assertTrue( + serializer.is_valid(), f"Serializer errors: {serializer.errors}" + ) + + def test_serializer_with_null_resource_type(self): + serializer = ConfiguredLinkAddonSerializer( + data={ + "resource_type": None, + "connected_capabilities": [AddonCapabilities.ACCESS.name], + "base_account": { + "id": self._authorized_account.id, + "type": "authorized-link-accounts", + }, + "authorized_resource": self._resource.resource_uri, + }, + context={"request": None}, + ) + + self.assertTrue( + serializer.is_valid(), f"Serializer errors: {serializer.errors}" + ) + + def test_api_update_with_null_values(self): + self._addon.target_id = "test-dataset-id" + self._addon.resource_type = SupportedResourceTypes.Other + self._addon.save() + + request_data = { + "data": { + "id": str(self._addon.id), + "type": "configured-link-addons", + "attributes": {"target_id": None, "resource_type": None}, + } + } + + response = self.client.patch( + self._detail_path, + data=json.dumps(request_data), + content_type="application/vnd.api+json", + ) + + self.assertEqual( + response.status_code, HTTPStatus.OK, f"Response content: {response.content}" + ) + + self._addon.refresh_from_db() + self.assertIsNone(self._addon.target_id) + self.assertEqual( + self._addon.int_resource_type, SupportedResourceTypes.Other.value + ) + + def test_create_with_null_values(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id=None, + int_resource_type=None, + ) + + addon.int_resource_type = None + addon.save(full_clean=False) + + addon.refresh_from_db() + self.assertIsNone(addon.target_id) + + self.assertIsNone(addon.int_resource_type) + self.assertIsNone(addon.resource_type) diff --git a/addon_service/tests/test_by_type/test_configured_storage_addon.py b/addon_service/tests/test_by_type/test_configured_storage_addon.py index 5d4b08c1..efd8b27d 100644 --- a/addon_service/tests/test_by_type/test_configured_storage_addon.py +++ b/addon_service/tests/test_by_type/test_configured_storage_addon.py @@ -5,6 +5,7 @@ from django.test import TestCase from django.urls import reverse from django.utils import timezone +from itsdangerous import Signer from rest_framework.test import APITestCase from addon_service.common import hmac as hmac_utils @@ -26,7 +27,9 @@ def set_auth_header(self, auth_type): credentials = base64.b64encode(b"admin:password").decode() self.client.credentials(HTTP_AUTHORIZATION=f"Basic {credentials}") elif auth_type == "session": - self.client.cookies[settings.USER_REFERENCE_COOKIE] = "some auth" + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign("some auth").decode() + ) elif auth_type == "no_auth": self.client.cookies.clear() self.client.credentials() @@ -38,8 +41,8 @@ def setUpTestData(cls): def setUp(self): super().setUp() - self.client.cookies[settings.USER_REFERENCE_COOKIE] = self._user.user_uri self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self._mock_osf.configure_user_role( self._user.user_uri, self._configured_storage_addon.resource_uri, "admin" ) @@ -127,6 +130,8 @@ def test_viewset_retrieve(self): def test_unauthorized_user(self): self.set_auth_header("session") + unauthorized_user = test_factories.UserReferenceFactory() + self._mock_osf.configure_assumed_caller(unauthorized_user.user_uri) response = self.client.get(self.related_url("base_account")) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) @@ -208,7 +213,9 @@ def test_get_waterbutler_credentials(self): def test_get_waterbutler_credentials__error__no_headers(self): # credentials request requires HMAC-signed headers # Cookie + OSF-side permissions will not suffice - self.client.cookies[settings.USER_REFERENCE_COOKIE] = "some auth" + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign("some_auth").decode() + ) _mock_osf = MockOSF() _mock_osf.configure_user_role( self._user.user_uri, self._configured_storage_addon.resource_uri, "admin" diff --git a/addon_service/tests/test_by_type/test_enhanced_resource_types.py b/addon_service/tests/test_by_type/test_enhanced_resource_types.py new file mode 100644 index 00000000..1725b13c --- /dev/null +++ b/addon_service/tests/test_by_type/test_enhanced_resource_types.py @@ -0,0 +1,224 @@ +from http import HTTPStatus + +from django.test import TestCase +from itsdangerous import Signer +from rest_framework.test import APITestCase + +from addon_service.common.credentials_formats import CredentialsFormats +from addon_service.common.enum_serializers import EnumNameMultipleChoiceField +from addon_service.tests import _factories +from addon_service.tests._helpers import MockOSF +from addon_toolkit.interfaces.link import SupportedResourceTypes +from app import settings + + +class TestResourceTypesSorting(APITestCase): + + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._external_service = _factories.ExternalLinkServiceFactory( + supported_resource_types=SupportedResourceTypes.Dataset + | SupportedResourceTypes.Book + | SupportedResourceTypes.Software, + credentials_format=CredentialsFormats.PERSONAL_ACCESS_TOKEN, + ) + + def setUp(self): + super().setUp() + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign(self._user.user_uri).decode() + ) + self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + def test_external_service_resource_types_sorted_alphabetically(self): + response = self.client.get( + f"/v1/external-link-services/{self._external_service.id}/" + ) + + self.assertEqual(response.status_code, HTTPStatus.OK) + + resource_types = response.data.get("supported_resource_types", []) + + expected_order = ["Book", "Dataset", "Software"] + self.assertEqual(resource_types, expected_order) + + def test_enum_field_returns_sorted_choices(self): + expected_names = sorted([rt.name for rt in SupportedResourceTypes]) + + actual_names = expected_names + + self.assertEqual(actual_names, expected_names) + + +class TestResourceTypeValidation(TestCase): + + def test_valid_resource_types(self): + valid_types = [ + SupportedResourceTypes.Book, + SupportedResourceTypes.Dataset, + SupportedResourceTypes.Software, + SupportedResourceTypes.Journal, + SupportedResourceTypes.OutputManagementPlan, + SupportedResourceTypes.Other, + ] + + for resource_type in valid_types: + with self.subTest(resource_type=resource_type): + addon = _factories.ConfiguredLinkAddonFactory() + addon.resource_type = resource_type + + addon.full_clean() + self.assertEqual(addon.resource_type, resource_type) + + def test_resource_type_enum_values(self): + self.assertEqual(SupportedResourceTypes.Dataset.name, "Dataset") + self.assertEqual(SupportedResourceTypes.Book.name, "Book") + self.assertEqual(SupportedResourceTypes.Software.name, "Software") + self.assertEqual(SupportedResourceTypes.Journal.name, "Journal") + self.assertEqual( + SupportedResourceTypes.OutputManagementPlan.name, "OutputManagementPlan" + ) + self.assertEqual(SupportedResourceTypes.Other.name, "Other") + + def test_resource_type_integer_conversion(self): + addon = _factories.ConfiguredLinkAddonFactory() + + addon.resource_type = SupportedResourceTypes.Dataset + addon.save() + + addon.refresh_from_db() + self.assertEqual(addon.int_resource_type, SupportedResourceTypes.Dataset.value) + self.assertEqual(addon.resource_type, SupportedResourceTypes.Dataset) + + +class TestResourceTypeSerializerField(TestCase): + + def test_enum_field_serialization(self): + field = EnumNameMultipleChoiceField(enum_cls=SupportedResourceTypes) + + result = field.to_representation(SupportedResourceTypes.Dataset) + self.assertEqual(result, ["Dataset"]) + + combined_value = SupportedResourceTypes.Dataset | SupportedResourceTypes.Book + result = field.to_representation(combined_value) + self.assertEqual(set(result), {"Dataset", "Book"}) + + def test_enum_field_deserialization(self): + field = EnumNameMultipleChoiceField(enum_cls=SupportedResourceTypes) + + result = field.to_internal_value(["Dataset"]) + self.assertEqual(result, SupportedResourceTypes.Dataset) + + result = field.to_internal_value(["Dataset", "Book"]) + expected = SupportedResourceTypes.Dataset | SupportedResourceTypes.Book + self.assertEqual(result, expected) + + def test_enum_field_sorted_output(self): + field = EnumNameMultipleChoiceField(enum_cls=SupportedResourceTypes) + + combined_value = ( + SupportedResourceTypes.Software + | SupportedResourceTypes.Book + | SupportedResourceTypes.Dataset + ) + + result = field.to_representation(combined_value) + + expected_order = ["Book", "Dataset", "Software"] + self.assertEqual(result, expected_order) + + +class TestResourceTypeNaming(TestCase): + + def test_output_management_plan_naming(self): + resource_type = SupportedResourceTypes.OutputManagementPlan + + self.assertEqual(resource_type.name, "OutputManagementPlan") + field = EnumNameMultipleChoiceField(enum_cls=SupportedResourceTypes) + result = field.to_representation(resource_type) + self.assertEqual(result, ["OutputManagementPlan"]) + + def test_all_resource_type_names_valid(self): + for resource_type in SupportedResourceTypes: + with self.subTest(resource_type=resource_type): + self.assertTrue(resource_type.name.isidentifier()) + + self.assertNotIn(" ", resource_type.name) + self.assertNotIn("-", resource_type.name) + + def test_resource_type_display_names(self): + expected_names = { + SupportedResourceTypes.Book: "Book", + SupportedResourceTypes.Dataset: "Dataset", + SupportedResourceTypes.Software: "Software", + SupportedResourceTypes.Journal: "Journal", + SupportedResourceTypes.OutputManagementPlan: "OutputManagementPlan", + SupportedResourceTypes.Other: "Other", + } + + for resource_type, expected_name in expected_names.items(): + with self.subTest(resource_type=resource_type): + self.assertEqual(resource_type.name, expected_name) + + +class TestResourceTypeAPIConsistency(APITestCase): + + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + + def setUp(self): + super().setUp() + self.client.cookies["osf"] = self._user.user_uri + self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + +class TestResourceTypeCompatibility(TestCase): + + def test_resource_type_values_stable(self): + known_values = { + SupportedResourceTypes.Book: 4, + SupportedResourceTypes.Dataset: 512, + SupportedResourceTypes.Journal: 32768, + SupportedResourceTypes.Other: 2147483648, + SupportedResourceTypes.Software: 33554432, + SupportedResourceTypes.OutputManagementPlan: 262144, + } + + for resource_type, expected_value in known_values.items(): + with self.subTest(resource_type=resource_type): + self.assertEqual(resource_type.value, expected_value) + + def test_resource_type_combinations(self): + combined = SupportedResourceTypes.Dataset | SupportedResourceTypes.Book + + self.assertIn(SupportedResourceTypes.Dataset, combined) + self.assertIn(SupportedResourceTypes.Book, combined) + + self.assertNotIn(SupportedResourceTypes.Software, combined) + + field = EnumNameMultipleChoiceField(enum_cls=SupportedResourceTypes) + result = field.to_representation(combined) + self.assertEqual(set(result), {"Dataset", "Book"}) + + def test_legacy_resource_type_handling(self): + addon = _factories.ConfiguredLinkAddonFactory( + credentials_format=CredentialsFormats.PERSONAL_ACCESS_TOKEN, + ) + + addon.int_resource_type = None + addon.save() + + addon.refresh_from_db() + self.assertIsNone(addon.resource_type) + + addon.full_clean() + addon.resource_type = SupportedResourceTypes.Other + addon.save() + addon.refresh_from_db() + self.assertEqual(addon.resource_type, SupportedResourceTypes.Other) diff --git a/addon_service/tests/test_by_type/test_external_link_service.py b/addon_service/tests/test_by_type/test_external_link_service.py new file mode 100644 index 00000000..a1d117dd --- /dev/null +++ b/addon_service/tests/test_by_type/test_external_link_service.py @@ -0,0 +1,258 @@ +from http import HTTPStatus +from unittest.mock import ( + MagicMock, + patch, +) + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase + +from addon_service import models as db +from addon_service.common.credentials_formats import CredentialsFormats +from addon_service.external_service.link.views import ExternalLinkServiceViewSet +from addon_service.tests import _factories +from addon_service.tests._helpers import ( + MockOSF, + get_test_request, +) +from addon_toolkit.interfaces.link import SupportedResourceTypes + + +def mock_get_link_addon_instance(*args, **kwargs): + mock_instance = MagicMock() + mock_instance.build_url_for_id = MagicMock( + return_value="https://example.com/dataset/123" + ) + mock_instance.get_external_account_id = MagicMock(return_value="test-account-id") + return mock_instance + + +class TestExternalLinkServiceAPI(APITestCase): + @classmethod + def setUpTestData(cls): + cls._els = _factories.ExternalLinkOAuth2ServiceFactory() + + def setUp(self): + super().setUp() + self._mock_osf = MockOSF() + self.enterContext(self._mock_osf.mocking()) + + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + @property + def _detail_path(self): + return reverse("external-link-services-detail", kwargs={"pk": self._els.pk}) + + @property + def _list_path(self): + return reverse("external-link-services-list") + + @property + def _related_authorized_link_accounts_path(self): + return reverse( + "external-link-services-related", + kwargs={ + "pk": self._els.pk, + "related_field": "authorized_link_accounts", + }, + ) + + def test_get(self): + _resp = self.client.get(self._detail_path) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data["auth_uri"], self._els.auth_uri) + + def test_methods_not_allowed(self): + _methods_not_allowed = { + self._detail_path: {"post"}, + self._list_path: {"patch", "put", "post"}, + self._related_authorized_link_accounts_path: {"patch", "put", "post"}, + } + for _path, _methods in _methods_not_allowed.items(): + for _method in _methods: + with self.subTest(path=_path, method=_method): + _client_method = getattr(self.client, _method) + _resp = _client_method(_path) + self.assertEqual(_resp.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + +class TestExternalLinkServiceModel(TestCase): + @classmethod + def setUpTestData(cls): + cls._els = _factories.ExternalLinkOAuth2ServiceFactory() + + def setUp(self): + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + def test_can_load(self): + _resource_from_db = db.ExternalLinkService.objects.get(id=self._els.id) + self.assertEqual(self._els.auth_uri, _resource_from_db.auth_uri) + + def test_authorized_link_accounts__empty(self): + self.assertEqual( + list(self._els.authorized_link_accounts.all()), + [], + ) + + def test_authorized_link_accounts__several(self): + _accounts = set( + _factories.AuthorizedLinkAccountFactory.create_batch( + size=3, + external_service=self._els, + ) + ) + self.assertEqual( + set(self._els.authorized_link_accounts.all()), + _accounts, + ) + + def test_supported_resource_types_property(self): + self._els.supported_resource_types = SupportedResourceTypes.Other + self._els.save() + + refreshed = db.ExternalLinkService.objects.get(id=self._els.id) + self.assertEqual( + refreshed.supported_resource_types, SupportedResourceTypes.Other + ) + + multi_type = SupportedResourceTypes.Other | SupportedResourceTypes.Project + self._els.supported_resource_types = multi_type + self._els.save() + + refreshed = db.ExternalLinkService.objects.get(id=self._els.id) + self.assertEqual(refreshed.supported_resource_types, multi_type) + + def test_validation__invalid_format(self): + service = _factories.ExternalLinkOAuth2ServiceFactory() + service.int_credentials_format = -1 + with self.assertRaises(ValidationError): + service.save() + + def test_validation__unsupported_format(self): + service = _factories.ExternalLinkOAuth2ServiceFactory() + service.int_credentials_format = CredentialsFormats.UNSPECIFIED.value + with self.assertRaises(ValidationError): + service.save() + + def test_validation__oauth_creds_require_client_config(self): + service = _factories.ExternalLinkOAuth2ServiceFactory( + credentials_format=CredentialsFormats.OAUTH2 + ) + service.oauth2_client_config = None + with self.assertRaises(ValidationError): + service.save() + + +class TestExternalLinkServiceViewSet(APITestCase): + @classmethod + def setUpTestData(cls): + cls._els = _factories.ExternalLinkOAuth2ServiceFactory() + cls._view = ExternalLinkServiceViewSet.as_view({"get": "retrieve"}) + cls._user = _factories.UserReferenceFactory() + + def setUp(self): + self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + def test_get(self): + _resp = self._view( + get_test_request(), + pk=self._els.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + + with self.subTest("Confirm expected keys"): + self.assertIn("supported_resource_types", _resp.data.keys()) + self.assertIn("display_name", _resp.data.keys()) + self.assertIn("credentials_format", _resp.data.keys()) + + with self.subTest("Confirm expected relationships"): + relationship_fields = { + key for key, value in _resp.data.items() if isinstance(value, dict) + } + self.assertIn("addon_imp", relationship_fields) + + def test_unauthorized(self): + _anon_resp = self._view(get_test_request(), pk=self._els.pk) + self.assertEqual(_anon_resp.status_code, HTTPStatus.OK) + + def test_wrong_user(self): + _another_user = _factories.UserReferenceFactory() + _resp = self._view( + get_test_request(user=_another_user), + pk=self._els.pk, + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + + +class TestExternalLinkServiceRelatedView(APITestCase): + @classmethod + def setUpTestData(cls): + cls._els = _factories.ExternalLinkOAuth2ServiceFactory() + cls._related_view = ExternalLinkServiceViewSet.as_view( + {"get": "retrieve_related"}, + ) + + def setUp(self): + self._mock_osf = MockOSF() + self.enterContext(self._mock_osf.mocking()) + + self.instantiation_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance", + mock_get_link_addon_instance, + ) + self.instantiation_blocking_patcher = patch( + "addon_service.addon_imp.instantiation.get_link_addon_instance__blocking", + mock_get_link_addon_instance, + ) + self.instantiation_patcher.start() + self.instantiation_blocking_patcher.start() + self.addCleanup(self.instantiation_patcher.stop) + self.addCleanup(self.instantiation_blocking_patcher.stop) + + def test_get_related(self): + _resp = self._related_view( + get_test_request(), + pk=self._els.pk, + related_field="addon_imp", + ) + self.assertEqual(_resp.status_code, HTTPStatus.OK) + self.assertEqual(_resp.data["name"], self._els.addon_imp.name) diff --git a/addon_service/tests/test_by_type/test_null_resource_link_addon.py b/addon_service/tests/test_by_type/test_null_resource_link_addon.py new file mode 100644 index 00000000..2f851152 --- /dev/null +++ b/addon_service/tests/test_by_type/test_null_resource_link_addon.py @@ -0,0 +1,353 @@ +import json +from http import HTTPStatus +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse +from itsdangerous import Signer +from rest_framework.test import APITestCase + +from addon_service.configured_addon.link.models import is_supported_resource_type +from addon_service.configured_addon.link.serializers import VerifiedLinkSerializer +from addon_service.tests import _factories +from addon_service.tests._helpers import MockOSF +from addon_toolkit.interfaces.link import SupportedResourceTypes +from app import settings + + +def mock_target_url(self): + return f"https://example.com/dataset/{self.target_id}" if self.target_id else None + + +class TestNullResourceTypeValidator(TestCase): + + def test_validator_with_none(self): + try: + is_supported_resource_type(None) + except ValidationError: + self.fail("Validator raised ValidationError unexpectedly when given None") + + def test_validator_with_valid_types(self): + try: + is_supported_resource_type(SupportedResourceTypes.Dataset.value) + is_supported_resource_type(SupportedResourceTypes.Journal.value) + is_supported_resource_type(SupportedResourceTypes.Software.value) + except ValidationError: + self.fail("Validator raised ValidationError unexpectedly on valid types") + + def test_validator_with_invalid_types(self): + with self.assertRaises(ValidationError): + is_supported_resource_type(-999) + + combined = ( + SupportedResourceTypes.Dataset.value | SupportedResourceTypes.Journal.value + ) + with self.assertRaises(ValidationError): + is_supported_resource_type(combined) + + +class TestConfiguredLinkAddonWithNullFields(TestCase): + + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + cls._authorized_account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user + ) + + def setUp(self): + self._mock_osf = MockOSF() + self.enterContext(self._mock_osf.mocking()) + + self.task_patcher = patch( + "addon_service.configured_addon.link.models.app.send_task" + ) + self.mock_task = self.task_patcher.start() + self.addCleanup(self.task_patcher.stop) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + self.mock_task.reset_mock() + + def test_create_with_null_fields(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id=None, + int_resource_type=None, + ) + + self.mock_task.reset_mock() + + addon.refresh_from_db() + self.assertIsNone(addon.target_id) + + self.assertIsNotNone(addon.int_resource_type) + self.assertIsNotNone(addon.resource_type) + + self.mock_task.reset_mock() + addon.save() + doi_task_called = False + for call in self.mock_task.call_args_list: + if call[0][0] == "website.identifiers.tasks.task__update_verified_links": + doi_task_called = True + break + + self.assertFalse( + doi_task_called, + "DOI metadata task should not be called when fields are missing", + ) + + def test_resource_type_property_with_none(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + ) + self.assertIsNotNone(addon.resource_type) + + def test_resource_type_setter_with_value(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + ) + + addon.resource_type = SupportedResourceTypes.Dataset + addon.save() + + addon.refresh_from_db() + self.assertEqual(addon.int_resource_type, SupportedResourceTypes.Dataset.value) + self.assertEqual(addon.resource_type, SupportedResourceTypes.Dataset) + + def test_save_with_missing_fields(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id=None, + ) + + self.mock_task.reset_mock() + addon.save() + + doi_task_called = False + for call in self.mock_task.call_args_list: + if call[0][0] == "website.identifiers.tasks.task__update_verified_links": + doi_task_called = True + break + + self.assertFalse( + doi_task_called, + "DOI metadata task should not be called when target_id is missing", + ) + + def test_save_with_target_id_missing(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id=None, + ) + addon.resource_type = SupportedResourceTypes.Dataset + + self.mock_task.reset_mock() + addon.save() + + doi_task_called = False + for call in self.mock_task.call_args_list: + if call[0][0] == "website.identifiers.tasks.task__update_verified_links": + doi_task_called = True + break + + self.assertFalse( + doi_task_called, + "DOI metadata task should not be called when target_id is missing", + ) + + def test_save_with_resource_type_missing(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id="test-id", + ) + addon.int_resource_type = 0 + + self.mock_task.reset_mock() + addon.save() + + doi_called = False + for call in self.mock_task.call_args_list: + if ( + call[0][0] == "website.identifiers.tasks.task__update_verified_links" + and call[1]["kwargs"]["target_guid"] == self._resource.guid + ): + doi_called = True + + self.assertFalse( + doi_called, "DOI task should not be called when resource_type is missing" + ) + + def test_save_with_all_fields_present(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id="test-id", + ) + addon.resource_type = SupportedResourceTypes.Dataset + + self.mock_task.reset_mock() + addon.save() + + doi_called = False + for call in self.mock_task.call_args_list: + if ( + call[0][0] == "website.identifiers.tasks.task__update_verified_links" + and call[1]["kwargs"]["target_guid"] == self._resource.guid + ): + doi_called = True + + self.assertTrue( + doi_called, "DOI task should be called when all fields are present" + ) + + def test_target_url_with_null_target_id(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id=None, + ) + + self.assertIsNone(addon.target_url()) + + +class TestConfiguredLinkAddonSerializerNullFields(APITestCase): + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + cls._authorized_account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user + ) + cls._addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=cls._resource, + base_account=cls._authorized_account, + target_id="test-id", + ) + + def setUp(self): + super().setUp() + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = ( + Signer(settings.OSF_AUTH_COOKIE_SECRET).sign(self._user.user_uri).decode() + ) + self._mock_osf = MockOSF() + self._mock_osf.configure_user_role( + self._user.user_uri, self._resource.resource_uri, "admin" + ) + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self.enterContext(self._mock_osf.mocking()) + + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + @property + def _detail_path(self): + return reverse("configured-link-addons-detail", kwargs={"pk": self._addon.pk}) + + @property + def _list_path(self): + return reverse("configured-link-addons-list") + + def test_api_update_with_null_fields(self): + self._addon.target_id = "test-id" + self._addon.resource_type = SupportedResourceTypes.Dataset + self._addon.save() + request_data = { + "data": { + "id": str(self._addon.id), + "type": "configured-link-addons", + "attributes": {"target_id": None}, + } + } + + response = self.client.patch( + self._detail_path, + data=json.dumps(request_data), + content_type="application/vnd.api+json", + ) + + self.assertEqual( + response.status_code, HTTPStatus.OK, f"Response content: {response.content}" + ) + + self._addon.refresh_from_db() + self.assertIsNone(self._addon.target_id) + self.assertIsNotNone(self._addon.int_resource_type) + + +class TestVerifiedLinkSerializer(TestCase): + + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + cls._authorized_account = _factories.AuthorizedLinkAccountFactory( + account_owner=cls._user + ) + cls._addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=cls._resource, + base_account=cls._authorized_account, + target_id="test-id", + ) + + def setUp(self): + self.target_url_patcher = patch( + "addon_service.configured_addon.link.models.ConfiguredLinkAddon.target_url", + mock_target_url, + ) + self.target_url_patcher.start() + self.addCleanup(self.target_url_patcher.stop) + + def test_serialize_with_all_values(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id="test-id", + ) + addon.resource_type = SupportedResourceTypes.Dataset + + serializer = VerifiedLinkSerializer(addon) + data = serializer.data + + self.assertEqual(data["target_id"], "test-id") + self.assertEqual(data["resource_type"], "Dataset") + self.assertEqual(data["target_url"], "https://example.com/dataset/test-id") + self.assertEqual( + data["service_name"], + addon.base_account.external_service.external_service_name, + ) + + def test_serialize_with_null_values(self): + addon = _factories.ConfiguredLinkAddonFactory( + authorized_resource=self._resource, + base_account=self._authorized_account, + target_id=None, + ) + + serializer = VerifiedLinkSerializer(addon) + data = serializer.data + + self.assertIsNone(data["target_id"]) + self.assertIsNotNone(data["resource_type"]) + self.assertIsNone(data["target_url"]) + self.assertEqual( + data["service_name"], + addon.base_account.external_service.external_service_name, + ) diff --git a/addon_service/tests/test_by_type/test_resource_reference.py b/addon_service/tests/test_by_type/test_resource_reference.py index a9af0a4f..35fe21b5 100644 --- a/addon_service/tests/test_by_type/test_resource_reference.py +++ b/addon_service/tests/test_by_type/test_resource_reference.py @@ -26,13 +26,13 @@ def setUpTestData(cls): def setUp(self): super().setUp() - self.client.cookies[settings.USER_REFERENCE_COOKIE] = self._user.user_uri self._mock_osf = MockOSF() self._mock_osf.configure_user_role( user_uri=self._user.user_uri, resource_uri=self._resource.resource_uri, role="admin", ) + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) @property @@ -74,7 +74,7 @@ def test_list__wrong_filter(self): def test_list__wrong_user(self): other_user = _factories.UserReferenceFactory() - self.client.cookies[settings.USER_REFERENCE_COOKIE] = other_user.user_uri + self._mock_osf.configure_assumed_caller(other_user) _resp = self.client.get( self._list_path, {"filter[resource_uri]": self._resource.resource_uri} ) @@ -142,13 +142,12 @@ def setUp(self): self._mock_osf.configure_user_role( self._user.user_uri, self._resource.resource_uri, "admin" ) + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) def test_get(self): _resp = self._view( - get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: self._user.user_uri} - ), + get_test_request(), pk=self._resource.pk, ) self.assertEqual(_resp.status_code, HTTPStatus.OK) @@ -165,6 +164,7 @@ def test_get(self): "configured_storage_addons", "configured_citation_addons", "configured_computing_addons", + "configured_link_addons", }, ) @@ -172,6 +172,7 @@ def test_unauthorized__private_resource(self): self._mock_osf.configure_resource_visibility( self._resource.resource_uri, public=False ) + self._mock_osf.configure_assumed_caller(None) _anon_resp = self._view(get_test_request(), pk=self._resource.pk) self.assertEqual(_anon_resp.status_code, HTTPStatus.UNAUTHORIZED) @@ -186,10 +187,10 @@ def test_wrong_user__pivate_resource(self): self._mock_osf.configure_resource_visibility( self._resource.resource_uri, public=False ) + wrong_user = _factories.UserReferenceFactory() + self._mock_osf.configure_assumed_caller(wrong_user) _resp = self._view( - get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: "this is wrong user auth"} - ), + get_test_request(), pk=self._resource.pk, ) self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) @@ -200,7 +201,7 @@ def test_wrong_user__public_resource(self): ) _resp = self._view( get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: "this is wrong user auth"} + cookies={settings.OSF_AUTH_COOKIE_NAME: "this is wrong user auth"} ), pk=self._resource.pk, ) @@ -221,6 +222,7 @@ def setUpTestData(cls): def setUp(self): self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self._mock_osf.configure_user_role( self._user.user_uri, self._resource.resource_uri, "admin" ) @@ -230,9 +232,7 @@ def test_get_related__empty(self): self._csa.delete() _resp = self._related_view( - get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: self._user.user_uri} - ), + get_test_request(), pk=self._resource.pk, related_field="configured_storage_addons", ) @@ -246,7 +246,7 @@ def test_get_related__several(self): ) + [self._csa] _resp = self._related_view( get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: self._user.user_uri} + cookies={settings.OSF_AUTH_COOKIE_NAME: self._user.user_uri} ), pk=self._resource.pk, related_field="configured_storage_addons", diff --git a/addon_service/tests/test_by_type/test_session_sharing.py b/addon_service/tests/test_by_type/test_session_sharing.py new file mode 100644 index 00000000..172995b6 --- /dev/null +++ b/addon_service/tests/test_by_type/test_session_sharing.py @@ -0,0 +1,221 @@ +from http import HTTPStatus +from unittest.mock import ( + Mock, + patch, +) + +import itsdangerous +from django.conf import settings +from django.contrib.sessions.backends.cache import SessionStore +from django.test import ( + RequestFactory, + TestCase, +) +from rest_framework.test import APITestCase + +from addon_service.tests import _factories +from addon_service.tests._helpers import MockOSF +from app.middleware import UnsignCookieSessionMiddleware + + +class TestSessionSharingMiddleware(TestCase): + + def setUp(self): + self.factory = RequestFactory() + self.middleware = UnsignCookieSessionMiddleware(Mock()) + + def test_process_request_with_valid_cookie(self): + session_key = "test-session-key-123" + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + signed_cookie = signer.sign(session_key) + + request = self.factory.get("/") + request.COOKIES = {settings.SESSION_COOKIE_NAME: signed_cookie.decode()} + + result = self.middleware.process_request(request) + + self.assertIsNone(result) + self.assertIsInstance(request.session, SessionStore) + self.assertEqual(request.session.session_key, session_key) + + def test_process_request_with_invalid_signature(self): + invalid_cookie = "invalid.cookie.signature" + + request = self.factory.get("/") + request.COOKIES = {settings.SESSION_COOKIE_NAME: invalid_cookie} + + result = self.middleware.process_request(request) + + self.assertIsNone(result) + + def test_process_request_without_cookie(self): + request = self.factory.get("/") + request.COOKIES = {} + result = self.middleware.process_request(request) + + self.assertIsNone(result) + self.assertIsInstance(request.session, SessionStore) + self.assertIsNone(request.session.session_key) + + def test_process_request_with_empty_cookie(self): + request = self.factory.get("/") + request.COOKIES = {settings.SESSION_COOKIE_NAME: ""} + + result = self.middleware.process_request(request) + + self.assertIsNone(result) + self.assertIsInstance(request.session, SessionStore) + + @patch("app.middleware.SessionStore") + def test_session_store_instantiation(self, mock_session_store): + session_key = "test-key-456" + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + signed_cookie = signer.sign(session_key) + + request = self.factory.get("/") + request.COOKIES = {settings.SESSION_COOKIE_NAME: signed_cookie.decode()} + + self.middleware.process_request(request) + + mock_session_store.assert_called_with(session_key=session_key) + + +class TestRedisSessionBackend(TestCase): + + def test_session_engine_configuration(self): + self.assertEqual( + settings.SESSION_ENGINE, "django.contrib.sessions.backends.cache" + ) + + def test_redis_cache_configuration(self): + cache_config = settings.CACHES["default"] + self.assertEqual( + cache_config["BACKEND"], "django.core.cache.backends.redis.RedisCache" + ) + self.assertEqual(cache_config["LOCATION"], settings.REDIS_HOST) + + def test_session_cookie_name_matches_osf(self): + self.assertEqual(settings.SESSION_COOKIE_NAME, settings.OSF_AUTH_COOKIE_NAME) + + +class TestSessionSharingIntegration(APITestCase): + + @classmethod + def setUpTestData(cls): + cls._user = _factories.UserReferenceFactory() + cls._resource = _factories.ResourceReferenceFactory() + + def setUp(self): + super().setUp() + self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) + self._mock_osf.configure_user_role( + self._user.user_uri, self._resource.resource_uri, "admin" + ) + self.enterContext(self._mock_osf.mocking()) + + def test_api_access_with_shared_session(self): + session_key = "shared-session-123" + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + signed_cookie = signer.sign(session_key) + + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = signed_cookie.decode() + + session_store = SessionStore(session_key=session_key) + session_store["user_reference_uri"] = self._user.user_uri + session_store.save() + + url = f"/v1/resource-references/{self._resource.pk}/" + response = self.client.get(url) + + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_session_persistence_across_requests(self): + session_key = "persistent-session-456" + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + signed_cookie = signer.sign(session_key) + + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = signed_cookie.decode() + + session_store = SessionStore(session_key=session_key) + session_store["user_reference_uri"] = self._user.user_uri + session_store["test_data"] = "persistent_value" + session_store.save() + + url = f"/v1/resource-references/{self._resource.pk}/" + response1 = self.client.get(url) + self.assertEqual(response1.status_code, HTTPStatus.OK) + + response2 = self.client.get(url) + self.assertEqual(response2.status_code, HTTPStatus.OK) + + response3 = self.client.get(url) + self.assertEqual(response3.status_code, HTTPStatus.OK) + + self.assertEqual(response1.data["id"], response2.data["id"]) + self.assertEqual(response2.data["id"], response3.data["id"]) + + def test_invalid_session_handling(self): + invalid_session_key = "invalid-session-789" + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + signed_cookie = signer.sign(invalid_session_key) + + self.client.cookies[settings.OSF_AUTH_COOKIE_NAME] = signed_cookie.decode() + + url = f"/v1/resource-references/{self._resource.pk}/" + response = self.client.get(url) + self.assertIn( + response.status_code, + [ + HTTPStatus.OK, # MockOSF allows access + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ], + ) + + +class TestCookieSigningCompatibility(TestCase): + + def test_cookie_signing_with_osf_secret(self): + test_value = "test-session-value" + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + signed_value = signer.sign(test_value) + self.assertIsInstance(signed_value, (str, bytes)) + + unsigned_value = signer.unsign(signed_value) + self.assertEqual( + ( + unsigned_value.decode() + if isinstance(unsigned_value, bytes) + else unsigned_value + ), + test_value, + ) + + def test_bad_signature_handling(self): + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + + with self.assertRaises(itsdangerous.BadSignature): + signer.unsign("tampered.signature.value") + + +class TestSessionSecurityFeatures(TestCase): + + def test_cors_credentials_enabled(self): + self.assertTrue(settings.CORS_ALLOW_CREDENTIALS) + + @patch("app.middleware.ensure_str") + def test_session_key_encoding(self, mock_ensure_str): + mock_ensure_str.return_value = "encoded-session-key" + + session_key = b"binary-session-key" + signer = itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET) + signed_cookie = signer.sign(session_key) + + request = RequestFactory().get("/") + request.COOKIES = {settings.SESSION_COOKIE_NAME: signed_cookie.decode()} + + middleware = UnsignCookieSessionMiddleware(Mock()) + middleware.process_request(request) + + mock_ensure_str.assert_called_once() diff --git a/addon_service/tests/test_by_type/test_user_reference.py b/addon_service/tests/test_by_type/test_user_reference.py index de0eeb56..b344fb81 100644 --- a/addon_service/tests/test_by_type/test_user_reference.py +++ b/addon_service/tests/test_by_type/test_user_reference.py @@ -1,7 +1,6 @@ import json from http import HTTPStatus -from django.conf import settings from django.core.exceptions import ValidationError from django.db.models.query import QuerySet from django.test import TestCase @@ -24,8 +23,8 @@ def setUpTestData(cls): def setUp(self): super().setUp() - self.client.cookies[settings.USER_REFERENCE_COOKIE] = self._user.user_uri self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) @property @@ -67,6 +66,7 @@ def test_get(self): "authorized_storage_accounts", "authorized_citation_accounts", "authorized_computing_accounts", + "authorized_link_accounts", "configured_resources", }, ) @@ -179,13 +179,12 @@ def setUpTestData(cls): def setUp(self): super().setUp() self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) def test_get(self): _resp = self._view( - get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: self._user.user_uri}, - ), + get_test_request(), pk=self._user.pk, ) self.assertEqual(_resp.status_code, HTTPStatus.OK) @@ -202,20 +201,22 @@ def test_get(self): "authorized_storage_accounts", "authorized_citation_accounts", "authorized_computing_accounts", + "authorized_link_accounts", "configured_resources", }, ) def test_wrong_user(self): + wrong_user = _factories.UserReferenceFactory() + self._mock_osf.configure_assumed_caller(wrong_user.user_uri) _resp = self._view( - get_test_request( - cookies={settings.USER_REFERENCE_COOKIE: "this is the wrong cookie"} - ), + get_test_request(), pk=self._user.pk, ) self.assertEqual(_resp.status_code, HTTPStatus.FORBIDDEN) def test_unauthorized(self): + self._mock_osf.configure_assumed_caller(None) _anon_resp = self._view(get_test_request(), pk=self._user.pk) self.assertEqual(_anon_resp.status_code, HTTPStatus.UNAUTHORIZED) @@ -229,11 +230,9 @@ def setUpTestData(cls): def setUp(self): super().setUp() self._mock_osf = MockOSF() + self._mock_osf.configure_assumed_caller(self._user.user_uri) self.enterContext(self._mock_osf.mocking()) - self.request = get_test_request( - user=self._user, - cookies={settings.USER_REFERENCE_COOKIE: self._user.user_uri}, - ) + self.request = get_test_request() def test_get_related__empty(self): _resp = self._related_view( diff --git a/addon_service/tests/test_by_type/test_verified_links_workflow.py b/addon_service/tests/test_by_type/test_verified_links_workflow.py new file mode 100644 index 00000000..bd5c9b2d --- /dev/null +++ b/addon_service/tests/test_by_type/test_verified_links_workflow.py @@ -0,0 +1,280 @@ +from unittest.mock import ( + MagicMock, + patch, +) + +from django.test import TestCase +from rest_framework.test import APITestCase + +from addon_service.external_service.link.models import LinkSupportedFeatures +from addon_service.tests import _factories +from addon_toolkit.interfaces.link import SupportedResourceTypes + + +class TestVerifiedLinksWorkflow(APITestCase): + + def setUp(self): + self._service = _factories.ExternalLinkOAuth2ServiceFactory( + display_name="Dataverse Link Service", + supported_features=LinkSupportedFeatures.ADD_UPDATE_FILES, + ) + self._account = _factories.AuthorizedLinkAccountFactory( + external_service=self._service + ) + + @patch("app.celery.app.send_task") + def test_create_link_addon_workflow(self, mock_send_task): + addon = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="dataset_123", + resource_type=SupportedResourceTypes.Dataset, + ) + + addon.resource_uri = "https://osf.io/abc123/" + addon.save() + + self.assertIsNotNone(addon.pk) + self.assertEqual(addon.target_id, "dataset_123") + self.assertEqual(addon.resource_uri, "https://osf.io/abc123/") + + mock_send_task.assert_called() + + +class TestCeleryIntegration(TestCase): + + def setUp(self): + self._service = _factories.ExternalLinkOAuth2ServiceFactory( + display_name="Test Service", + supported_features=LinkSupportedFeatures.ADD_UPDATE_FILES, + ) + self._account = _factories.AuthorizedLinkAccountFactory( + external_service=self._service + ) + + @patch("app.celery.app.send_task") + def test_celery_task_on_complete_addon(self, mock_send_task): + addon = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="dataset_123", + resource_type=SupportedResourceTypes.Dataset, + ) + + addon.resource_uri = "https://osf.io/abc123/" + addon.save() + + self.assertTrue(mock_send_task.called) + call_args_list = mock_send_task.call_args_list + + log_calls = [ + call for call in call_args_list if "osf.tasks.log_gv_addon" in call[0] + ] + self.assertTrue(len(log_calls) > 0) + + @patch("app.celery.app.send_task") + def test_celery_task_not_triggered_incomplete(self, mock_send_task): + addon = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="", + resource_type=SupportedResourceTypes.Dataset, + ) + + addon.resource_uri = "https://osf.io/abc123/" + addon.save() + + self.assertTrue(mock_send_task.called) + call_args_list = mock_send_task.call_args_list + + log_calls = [ + call for call in call_args_list if "osf.tasks.log_gv_addon" in call[0] + ] + verification_calls = [ + call + for call in call_args_list + if "website.identifiers.tasks.task__update_verified_links" in call[0] + ] + + self.assertTrue(len(log_calls) > 0) + self.assertEqual(len(verification_calls), 0) + + +class TestMultipleAddonsPerResource(TestCase): + + def setUp(self): + self._service1 = _factories.ExternalLinkOAuth2ServiceFactory( + display_name="Dataverse", + supported_features=LinkSupportedFeatures.ADD_UPDATE_FILES, + ) + self._service2 = _factories.ExternalLinkOAuth2ServiceFactory( + display_name="Zenodo", + supported_features=LinkSupportedFeatures.ADD_UPDATE_FILES, + ) + + self._account1 = _factories.AuthorizedLinkAccountFactory( + external_service=self._service1 + ) + self._account2 = _factories.AuthorizedLinkAccountFactory( + external_service=self._service2 + ) + + @patch("app.celery.app.send_task") + def test_multiple_addons_same_resource(self, mock_send_task): + resource_uri = "https://osf.io/abc123/" + + addon1 = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service1, + base_account=self._account1, + target_id="dataverse_dataset_123", + resource_type=SupportedResourceTypes.Dataset, + ) + addon1.resource_uri = resource_uri + addon1.save() + + addon2 = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service2, + base_account=self._account2, + target_id="zenodo_record_456", + resource_type=SupportedResourceTypes.Dataset, + ) + addon2.resource_uri = resource_uri + addon2.save() + + self.assertIsNotNone(addon1.pk) + self.assertIsNotNone(addon2.pk) + self.assertEqual(addon1.resource_uri, addon2.resource_uri) + self.assertNotEqual(addon1.target_id, addon2.target_id) + + +class TestDataverseLinkIntegration(TestCase): + """Test Dataverse-specific link functionality.""" + + def setUp(self): + self._service = _factories.ExternalLinkOAuth2ServiceFactory( + display_name="Harvard Dataverse", + browser_base_url="https://dataverse.harvard.edu/", + supported_features=LinkSupportedFeatures.ADD_UPDATE_FILES, + ) + self._account = _factories.AuthorizedLinkAccountFactory( + external_service=self._service + ) + + @patch("addon_service.addon_imp.instantiation.get_link_addon_instance__blocking") + @patch("app.celery.app.send_task") + def test_dataverse_url_generation(self, mock_send_task, mock_get_instance): + async def mock_build_url(target_id): + return "https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/12345" + + mock_addon = MagicMock() + mock_addon.build_url_for_id = mock_build_url + mock_get_instance.return_value = mock_addon + + addon = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="dataset/doi:10.7910/DVN/12345", + resource_type=SupportedResourceTypes.Dataset, + ) + addon.resource_uri = "https://osf.io/abc123/" + addon.save() + + target_url = addon.target_url() + self.assertIsNotNone(target_url) + self.assertIn("dataverse.harvard.edu", target_url) + + @patch("app.celery.app.send_task") + def test_different_resource_types(self, mock_send_task): + """Test creating addons with different resource types.""" + addon1 = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="dataset_123", + resource_type=SupportedResourceTypes.Dataset, + ) + addon1.resource_uri = "https://osf.io/abc123/" + addon1.save() + + addon2 = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="article_456", + resource_type=SupportedResourceTypes.JournalArticle, + ) + addon2.resource_uri = "https://osf.io/def456/" + addon2.save() + + self.assertEqual(addon1.resource_type, SupportedResourceTypes.Dataset) + self.assertEqual(addon2.resource_type, SupportedResourceTypes.JournalArticle) + + @patch("app.celery.app.send_task") + def test_target_url_with_empty_target_id(self, mock_send_task): + """Test that target_url returns None when target_id is empty.""" + addon = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="", + resource_type=SupportedResourceTypes.Dataset, + ) + addon.resource_uri = "https://osf.io/abc123/" + addon.save() + + self.assertIsNone(addon.target_url()) + + +class TestResourceValidation(TestCase): + + def setUp(self): + self._service = _factories.ExternalLinkOAuth2ServiceFactory( + display_name="Test Service", + supported_features=LinkSupportedFeatures.ADD_UPDATE_FILES, + ) + self._account = _factories.AuthorizedLinkAccountFactory( + external_service=self._service + ) + + @patch("app.celery.app.send_task") + def test_addon_creation_with_valid_resource_uri(self, mock_send_task): + valid_uris = [ + "https://osf.io/abc123/", + "https://staging.osf.io/def456/", + "http://localhost:5000/xyz789/", + ] + + for uri in valid_uris: + with self.subTest(uri=uri): + addon = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id="test_123", + resource_type=SupportedResourceTypes.Dataset, + ) + addon.resource_uri = uri + addon.save() + self.assertEqual(addon.resource_uri, uri) + + @patch("app.celery.app.send_task") + def test_addon_creation_different_resource_uris(self, mock_send_task): + uris = [ + "https://osf.io/project1/", + "https://osf.io/project2/", + "https://staging.osf.io/project3/", + ] + + addons = [] + for i, uri in enumerate(uris): + addon = _factories.ConfiguredLinkAddonFactory( + external_link_service=self._service, + base_account=self._account, + target_id=f"target_{i}", + resource_type=SupportedResourceTypes.Dataset, + ) + addon.resource_uri = uri + addon.save() + addons.append(addon) + + for addon in addons: + self.assertIsNotNone(addon.pk) + + uri_set = {addon.resource_uri for addon in addons} + self.assertEqual(len(uri_set), len(addons)) diff --git a/addon_service/urls.py b/addon_service/urls.py index d6b404bf..ab89009d 100644 --- a/addon_service/urls.py +++ b/addon_service/urls.py @@ -57,6 +57,9 @@ def _register_viewset(viewset): _register_viewset(views.AuthorizedCitationAccountViewSet) _register_viewset(views.ConfiguredCitationAddonViewSet) _register_viewset(views.ExternalCitationServiceViewSet) +_register_viewset(views.AuthorizedLinkAccountViewSet) +_register_viewset(views.ConfiguredLinkAddonViewSet) +_register_viewset(views.ExternalLinkServiceViewSet) _register_viewset(views.AuthorizedComputingAccountViewSet) _register_viewset(views.ConfiguredComputingAddonViewSet) _register_viewset(views.ExternalComputingServiceViewSet) diff --git a/addon_service/user_reference/models.py b/addon_service/user_reference/models.py index e54ad38d..ef004e56 100644 --- a/addon_service/user_reference/models.py +++ b/addon_service/user_reference/models.py @@ -3,8 +3,10 @@ from addon_service.authorized_account.citation.models import AuthorizedCitationAccount from addon_service.authorized_account.computing.models import AuthorizedComputingAccount +from addon_service.authorized_account.link.models import AuthorizedLinkAccount from addon_service.authorized_account.storage.models import AuthorizedStorageAccount from addon_service.common.base_model import AddonsServiceBaseModel +from addon_service.common.regex import uri_regex from addon_service.configured_addon.computing.models import ConfiguredComputingAddon from addon_service.configured_addon.storage.models import ConfiguredStorageAddon from addon_service.resource_reference.models import ResourceReference @@ -55,6 +57,12 @@ def authorized_computing_accounts(self): account_owner=self ).select_related("external_service") + @property + def authorized_link_accounts(self): + return AuthorizedLinkAccount.objects.filter(account_owner=self).select_related( + "external_service" + ) + class Meta: verbose_name = "User Reference" verbose_name_plural = "User References" @@ -67,6 +75,13 @@ class JSONAPIMeta: def owner_uri(self) -> str: return self.user_uri + @property + def guid(self) -> str | None: + match = uri_regex.match(self.user_uri) + if match: + return match["id"] + return None + def deactivate(self): self.deactivated = timezone.now() self.save() diff --git a/addon_service/user_reference/serializers.py b/addon_service/user_reference/serializers.py index b45204e5..5d5e4215 100644 --- a/addon_service/user_reference/serializers.py +++ b/addon_service/user_reference/serializers.py @@ -34,6 +34,12 @@ class UserReferenceSerializer(serializers.HyperlinkedModelSerializer): related_link_view_name=view_names.related_view(RESOURCE_TYPE), ) + authorized_link_accounts = HyperlinkedRelatedField( + many=True, + queryset=AuthorizedCitationAccount.objects.all(), + related_link_view_name=view_names.related_view(RESOURCE_TYPE), + ) + authorized_computing_accounts = HyperlinkedRelatedField( many=True, queryset=AuthorizedComputingAccount.objects.all(), @@ -56,6 +62,9 @@ class UserReferenceSerializer(serializers.HyperlinkedModelSerializer): "authorized_computing_accounts": ( "addon_service.serializers.AuthorizedComputingAccountSerializer" ), + "authorized_link_accounts": ( + "addon_service.serializers.AuthorizedLinkAccountSerializer" + ), "configured_resources": ( "addon_service.serializers.ResourceReferenceSerializer" ), @@ -70,5 +79,6 @@ class Meta: "authorized_storage_accounts", "authorized_citation_accounts", "authorized_computing_accounts", + "authorized_link_accounts", "configured_resources", ] diff --git a/addon_service/user_reference/views.py b/addon_service/user_reference/views.py index f33b53af..d14dde3b 100644 --- a/addon_service/user_reference/views.py +++ b/addon_service/user_reference/views.py @@ -1,3 +1,8 @@ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, +) + from addon_service.common.permissions import SessionUserIsOwner from addon_service.common.viewsets import RestrictedReadOnlyViewSet @@ -5,6 +10,14 @@ from .serializers import UserReferenceSerializer +@extend_schema_view( + list=extend_schema( + description="Get user reference by user_uri. Even through this is a list method, this endpoint returns only one entity" + ), + retrieve=extend_schema( + description="Get user reference by it's pk", + ), +) class UserReferenceViewSet(RestrictedReadOnlyViewSet): queryset = UserReference.objects.all() serializer_class = UserReferenceSerializer diff --git a/addon_service/views.py b/addon_service/views.py index 4f7078a8..f72ee948 100644 --- a/addon_service/views.py +++ b/addon_service/views.py @@ -16,6 +16,7 @@ from addon_service.authorized_account.computing.views import ( AuthorizedComputingAccountViewSet, ) +from addon_service.authorized_account.link.views import AuthorizedLinkAccountViewSet from addon_service.authorized_account.storage.views import ( AuthorizedStorageAccountViewSet, ) @@ -23,11 +24,13 @@ from addon_service.configured_addon.computing.views import ( ConfiguredComputingAddonViewSet, ) +from addon_service.configured_addon.link.views import ConfiguredLinkAddonViewSet from addon_service.configured_addon.storage.views import ConfiguredStorageAddonViewSet from addon_service.external_service.citation.views import ExternalCitationServiceViewSet from addon_service.external_service.computing.views import ( ExternalComputingServiceViewSet, ) +from addon_service.external_service.link.views import ExternalLinkServiceViewSet from addon_service.external_service.storage.views import ExternalStorageServiceViewSet from addon_service.oauth1.views import oauth1_callback_view from addon_service.oauth2.views import oauth2_callback_view @@ -61,6 +64,9 @@ async def status(request): "AuthorizedComputingAccountViewSet", "ConfiguredComputingAddonViewSet", "ExternalComputingServiceViewSet", + "AuthorizedLinkAccountViewSet", + "ConfiguredLinkAddonViewSet", + "ExternalLinkServiceViewSet", "AuthorizedStorageAccountViewSet", "ConfiguredStorageAddonViewSet", "ExternalStorageServiceViewSet", diff --git a/addon_toolkit/interfaces/__init__.py b/addon_toolkit/interfaces/__init__.py index 2b878dd7..e00b0158 100644 --- a/addon_toolkit/interfaces/__init__.py +++ b/addon_toolkit/interfaces/__init__.py @@ -3,6 +3,7 @@ from . import ( citation, computing, + link, storage, ) from ._base import BaseAddonInterface @@ -21,3 +22,4 @@ class AllAddonInterfaces(enum.Enum): STORAGE = storage.StorageAddonInterface CITATION = citation.CitationServiceInterface COMPUTING = computing.ComputingAddonInterface + LINK = link.LinkAddonInterface diff --git a/addon_toolkit/interfaces/link.py b/addon_toolkit/interfaces/link.py new file mode 100644 index 00000000..0085b35c --- /dev/null +++ b/addon_toolkit/interfaces/link.py @@ -0,0 +1,159 @@ +"""a static (and still in progress) definition of what composes a link addon""" + +import dataclasses +import enum +import typing +from collections import abc + +from addon_toolkit.addon_operation_declaration import immediate_operation +from addon_toolkit.capabilities import AddonCapabilities +from addon_toolkit.constrained_network.http import HttpRequestor +from addon_toolkit.credentials import Credentials +from addon_toolkit.cursor import Cursor +from addon_toolkit.imp import AddonImp + +from ._base import BaseAddonInterface + + +__all__ = ( + "ItemResult", + "ItemType", + "ItemSampleResult", + "LinkAddonInterface", + "LinkAddonImp", +) + + +class ItemType(enum.StrEnum): + RESOURCE = enum.auto() + FOLDER = enum.auto() + + +class SupportedResourceTypes(enum.Flag): + """ + Even though this is not conventional for python enums to have upper camel case, we opt for it to follow + https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/ + """ + + Audiovisual = enum.auto() + Award = enum.auto() + Book = enum.auto() + BookChapter = enum.auto() + Collection = enum.auto() + ComputationalNotebook = enum.auto() + ConferencePaper = enum.auto() + ConferenceProceeding = enum.auto() + DataPaper = enum.auto() + Dataset = enum.auto() + Dissertation = enum.auto() + Event = enum.auto() + Image = enum.auto() + Instrument = enum.auto() + InteractiveResource = enum.auto() + Journal = enum.auto() + JournalArticle = enum.auto() + Model = enum.auto() + OutputManagementPlan = enum.auto() + PeerReview = enum.auto() + PhysicalObject = enum.auto() + Preprint = enum.auto() + Project = enum.auto() + Report = enum.auto() + Service = enum.auto() + Software = enum.auto() + Sound = enum.auto() + Standard = enum.auto() + StudyRegistration = enum.auto() + Text = enum.auto() + Workflow = enum.auto() + Other = enum.auto() + + +@dataclasses.dataclass +class ItemResult: + item_id: str + item_name: str + item_type: ItemType + resource_type: SupportedResourceTypes | None = None + item_link: str | None = None + doi: str | None = None + + +@dataclasses.dataclass(frozen=True) +class LinkConfig: + external_api_url: str + external_web_url: str + + +@dataclasses.dataclass +class ItemSampleResult: + """a sample from a possibly-large population of result items""" + + items: abc.Collection[ItemResult] + total_count: int | None = None + this_sample_cursor: str = "" + next_sample_cursor: str | None = None # when None, this is the last page of results + prev_sample_cursor: str | None = None + first_sample_cursor: str = "" + + def with_cursor(self, cursor: Cursor) -> typing.Self: + return dataclasses.replace( + self, + this_sample_cursor=cursor.this_cursor_str, + next_sample_cursor=cursor.next_cursor_str, + prev_sample_cursor=cursor.prev_cursor_str, + first_sample_cursor=cursor.first_cursor_str, + ) + + +### +# declaration of all link addon operations + + +class LinkAddonInterface(BaseAddonInterface, typing.Protocol): + + async def build_url_for_id(self, item_id: str) -> str: ... + + @immediate_operation(capability=AddonCapabilities.ACCESS) + async def get_item_info(self, item_id: str) -> ItemResult: ... + + @immediate_operation(capability=AddonCapabilities.ACCESS) + async def list_root_items(self, page_cursor: str = "") -> ItemSampleResult: ... + + @immediate_operation(capability=AddonCapabilities.ACCESS) + async def list_child_items( + self, + item_id: str, + page_cursor: str = "", + item_type: ItemType | None = None, + ) -> ItemSampleResult: ... + + +@dataclasses.dataclass +class LinkAddonImp(AddonImp): + """base class for link addon implementations""" + + ADDON_INTERFACE = LinkAddonInterface + config: LinkConfig + + +@dataclasses.dataclass +class LinkAddonHttpRequestorImp(LinkAddonImp, LinkAddonInterface): + """base class for link addon implementations using GV network""" + + network: HttpRequestor + + +@dataclasses.dataclass +class LinkAddonClientRequestorImp[T](LinkAddonImp): + """base class for link addon with custom clients""" + + client: T = dataclasses.field(init=False) + credentials: dataclasses.InitVar[Credentials] + + def __post_init__(self, credentials): + self.client = self.create_client(credentials) + + @staticmethod + def create_client(credentials) -> T: + raise NotImplementedError diff --git a/app/celery.py b/app/celery.py index bcb43b76..c972607d 100644 --- a/app/celery.py +++ b/app/celery.py @@ -28,6 +28,12 @@ def queue_name(self) -> str: gv_chill_queue = Queue(TaskUrgency.CHILL.queue_name()) gv_reactive_queue = Queue(TaskUrgency.REACTIVE.queue_name()) gv_interactive_queue = Queue(TaskUrgency.INTERACTIVE.queue_name()) +osf_high_queue = Queue( + "external_high", no_declare=True +) # this queue must be declared by osf's celery +osf_low_queue = Queue( + "external_low", no_declare=True +) # this queue must be declared by osf's celery app = Celery( broker_url=AMQP_BROKER_URL, @@ -44,6 +50,8 @@ def queue_name(self) -> str: "addon_service.management.commands.refresh_addon_tokens.*": { "queue": gv_chill_queue }, + "osf.*": {"queue": osf_high_queue}, + "website.*": {"queue": osf_high_queue}, }, include=[ "addon_service.tasks", diff --git a/app/env.py b/app/env.py index 51b493e6..a645f93f 100644 --- a/app/env.py +++ b/app/env.py @@ -16,7 +16,6 @@ SECURE_PROXY_SSL_HEADER = os.environ.get("SECURE_PROXY_SSL_HEADER") NEW_RELIC_CONFIG_FILE = os.environ.get("NEW_RELIC_CONFIG_FILE") NEW_RELIC_ENVIRONMENT = os.environ.get("NEW_RELIC_ENVIRONMENT") -SESSION_COOKIE_DOMAIN = os.environ.get("SESSION_COOKIE_DOMAIN") ### @@ -36,6 +35,8 @@ OSFDB_CONN_MAX_AGE = os.environ.get("OSFDB_CONN_MAX_AGE", 0) OSFDB_SSLMODE = os.environ.get("OSFDB_SSLMODE", "prefer") +REDIS_HOST = os.environ.get("REDIS_HOST", "redis://192.168.168.167:6379") + ### # for interacting with osf @@ -45,7 +46,16 @@ OSF_HMAC_EXPIRATION_SECONDS = int(os.environ.get("OSF_HMAC_EXPIRATION_SECONDS", 110)) OSF_BASE_URL = os.environ.get("OSF_BASE_URL", "https://osf.example") OSF_API_BASE_URL = os.environ.get("OSF_API_BASE_URL", "https://api.osf.example") -OSF_AUTH_COOKIE_NAME = os.environ.get("OSF_AUTH_COOKIE_NAME", "osf_staging") +OSF_AUTH_COOKIE_NAME = os.environ.get("OSF_AUTH_COOKIE_NAME", "osf") +OSF_AUTH_COOKIE_SECRET = os.environ.get("OSF_AUTH_COOKIE_SECRET", "CHANGEME") +SESSION_COOKIE_DOMAIN = os.environ.get("SESSION_COOKIE_DOMAIN", None) +SESSION_COOKIE_SECURE = os.environ.get( + "SESSION_COOKIE_SECURE", True +) # Change to False for local dev +SESSION_COOKIE_HTTPONLY = os.environ.get("SESSION_COOKIE_HTTPONLY", True) +SESSION_COOKIE_SAMESITE = os.environ.get( + "SESSION_COOKIE_SAMESITE", "None" +) # Change to "Lax" for local dev ### # amqp/celery diff --git a/app/middleware.py b/app/middleware.py new file mode 100644 index 00000000..9ef2b0cb --- /dev/null +++ b/app/middleware.py @@ -0,0 +1,84 @@ +from importlib import import_module + +import itsdangerous +from django.conf import settings +from django.contrib.sessions.backends.base import UpdateError +from django.contrib.sessions.exceptions import SessionInterrupted +from django.contrib.sessions.middleware import SessionMiddleware +from django.utils.cache import patch_vary_headers + + +SessionStore = import_module(settings.SESSION_ENGINE).SessionStore + + +def ensure_str(value): + if isinstance(value, bytes): + return value.decode() + return value + + +class UnsignCookieSessionMiddleware(SessionMiddleware): + """ + Overrides the process_request hook of SessionMiddleware to retrieve the session key for finding/setting the + correct session by unsigning/signing the cookie value using server secret. + """ + + def process_request(self, request): + cookie = request.COOKIES.get(settings.SESSION_COOKIE_NAME) + if cookie: + try: + session_key = ensure_str( + itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET).unsign(cookie) + ) + except itsdangerous.BadSignature: + request.session = SessionStore + return None + request.session = SessionStore(session_key=session_key) + else: + request.session = SessionStore() + + def process_response(self, request, response): + """ + If `request.session` was modified, or if the configuration is to save the session every time, save the changes + and set a session cookie. This is port from `SessionMiddleware.process_response` with the following changes: + 1) Sign cookie value using server secret. + 2) Don't delete cookie. + 3) Don't set `Max-Age` or `Expires` + """ + try: + accessed = request.session.accessed + modified = request.session.modified + empty = request.session.is_empty() + except AttributeError: + return response + + if accessed: + patch_vary_headers(response, ("Cookie",)) + + # GV only accesses or modifies OSF cookie, but does not delete it. OSF handles the creation and deletion. + if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: + return response + + if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: + if response.status_code < 500: + try: + request.session.save() + except UpdateError: + raise SessionInterrupted( + "The request's session was deleted before the request completed. " + "The user may have logged out in a concurrent request, for example." + ) + response.set_cookie( + settings.SESSION_COOKIE_NAME, + ensure_str( + itsdangerous.Signer(settings.OSF_AUTH_COOKIE_SECRET).sign( + request.session.session_key + ) + ), + domain=settings.SESSION_COOKIE_DOMAIN, + secure=settings.SESSION_COOKIE_SECURE, + httponly=settings.SESSION_COOKIE_HTTPONLY, + samesite=settings.SESSION_COOKIE_SAMESITE, + ) + + return response diff --git a/app/settings.py b/app/settings.py index efd9c845..c471057d 100644 --- a/app/settings.py +++ b/app/settings.py @@ -62,11 +62,25 @@ DEBUG = env.DEBUG SILKY_PYTHON_PROFILER = env.SILKY_PYTHON_PROFILER -USER_REFERENCE_COOKIE = env.OSF_AUTH_COOKIE_NAME +OSF_AUTH_COOKIE_NAME = env.OSF_AUTH_COOKIE_NAME OSF_BASE_URL = env.OSF_BASE_URL.rstrip("/") OSF_API_BASE_URL = env.OSF_API_BASE_URL.rstrip("/") ALLOWED_RESOURCE_URI_PREFIXES = {OSF_BASE_URL} +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_COOKIE_NAME = env.OSF_AUTH_COOKIE_NAME SESSION_COOKIE_DOMAIN = env.SESSION_COOKIE_DOMAIN +SESSION_COOKIE_SECURE = env.SESSION_COOKIE_SECURE +SESSION_COOKIE_HTTPONLY = env.SESSION_COOKIE_HTTPONLY +SESSION_COOKIE_SAMESITE = env.SESSION_COOKIE_SAMESITE +OSF_AUTH_COOKIE_SECRET = env.OSF_AUTH_COOKIE_SECRET +REDIS_HOST = env.REDIS_HOST + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": REDIS_HOST, + }, +} if DEBUG: # allow for local osf shenanigans @@ -97,14 +111,15 @@ "rest_framework_json_api", "addon_service", "django_celery_beat", + "drf_spectacular", ] MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", + "app.middleware.UnsignCookieSessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", @@ -182,12 +197,13 @@ REST_FRAMEWORK = { "PAGE_SIZE": 101, "EXCEPTION_HANDLER": "addon_service.exception_handler.api_exception_handler", - "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", + "DEFAULT_PAGINATION_CLASS": "drf_spectacular_jsonapi.schemas.pagination.JsonApiPageNumberPagination", "DEFAULT_PARSER_CLASSES": ( "rest_framework_json_api.parsers.JSONParser", "rest_framework.parsers.FormParser", "rest_framework.parsers.MultiPartParser", ), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular_jsonapi.schemas.openapi.JsonApiAutoSchema", "DEFAULT_RENDERER_CLASSES": ( "rest_framework_json_api.renderers.JSONRenderer", "rest_framework_json_api.renderers.BrowsableAPIRenderer", @@ -196,7 +212,6 @@ "addon_service.common.queryparams_filter.AllowedQueryParamsFilter", "rest_framework_json_api.filters.OrderingFilter", "rest_framework_json_api.django_filters.DjangoFilterBackend", - "rest_framework.filters.SearchFilter", ), "DEFAULT_AUTHENTICATION_CLASSES": ( "addon_service.authentication.GVCombinedAuthentication", @@ -208,6 +223,14 @@ "TEST_REQUEST_DEFAULT_FORMAT": "vnd.api+json", } +SPECTACULAR_SETTINGS = { + "TITLE": "GravyValet API", + "DESCRIPTION": "Addons service designed for use with OSF", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, + "PREPROCESSING_HOOKS": ["drf_spectacular_jsonapi.hooks.fix_nested_path_parameters"], +} # Password validation # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators diff --git a/app/urls.py b/app/urls.py index 1e307e51..c0c0ef0a 100644 --- a/app/urls.py +++ b/app/urls.py @@ -5,6 +5,11 @@ path, ) from django.views.generic.base import RedirectView +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) urlpatterns = [ @@ -16,6 +21,18 @@ name="docs-root", ), ] +if settings.DEBUG: + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/schema/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), if "silk" in settings.INSTALLED_APPS: urlpatterns.append(path("silk/", include("silk.urls", namespace="silk"))) diff --git a/docker-compose.yml b/docker-compose.yml index 70bcfe26..2f2c5b4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,6 @@ services: OSF_BASE_URL: "http://192.168.168.167:5000" OSF_API_BASE_URL: "http://192.168.168.167:8000" OSF_AUTH_COOKIE_NAME: osf - SESSION_COOKIE_DOMAIN: localhost AMQP_BROKER_URL: "amqp://guest:guest@rabbitmq:5672" GRAVYVALET_ENCRYPT_SECRET: "this is fine" ports: diff --git a/poetry.lock b/poetry.lock index 47747615..fe435f50 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -113,7 +113,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -173,12 +173,12 @@ files = [ ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] [[package]] name = "autobahn" @@ -199,13 +199,13 @@ setuptools = "*" txaio = ">=21.2.1" [package.extras] -all = ["PyGObject (>=3.40.0)", "argon2-cffi (>=20.1.0)", "attrs (>=20.3.0)", "base58 (>=2.1.0)", "bitarray (>=2.7.5)", "cbor2 (>=5.2.0)", "cffi (>=1.14.5)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=4.0.0)", "flatbuffers (>=22.12.6)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "msgpack (>=1.0.2)", "passlib (>=1.7.4)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "py-ubjson (>=0.16.1)", "pynacl (>=1.4.0)", "pyopenssl (>=20.0.1)", "python-snappy (>=0.6.0)", "pytrie (>=0.4.0)", "qrcode (>=7.3.1)", "rlp (>=2.0.1)", "service-identity (>=18.1.0)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "twisted (>=24.3.0)", "u-msgpack-python (>=2.1)", "ujson (>=4.0.2)", "web3[ipfs] (>=6.0.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)", "zope.interface (>=5.2.0)"] +all = ["PyGObject (>=3.40.0)", "argon2-cffi (>=20.1.0)", "attrs (>=20.3.0)", "base58 (>=2.1.0)", "bitarray (>=2.7.5)", "cbor2 (>=5.2.0)", "cffi (>=1.14.5)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=4.0.0)", "flatbuffers (>=22.12.6)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "msgpack (>=1.0.2) ; platform_python_implementation == \"CPython\"", "passlib (>=1.7.4)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "py-ubjson (>=0.16.1)", "pynacl (>=1.4.0)", "pyopenssl (>=20.0.1)", "python-snappy (>=0.6.0)", "pytrie (>=0.4.0)", "qrcode (>=7.3.1)", "rlp (>=2.0.1)", "service-identity (>=18.1.0)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "twisted (>=24.3.0)", "u-msgpack-python (>=2.1) ; platform_python_implementation != \"CPython\"", "ujson (>=4.0.2) ; platform_python_implementation == \"CPython\"", "web3[ipfs] (>=6.0.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)", "zope.interface (>=5.2.0)"] compress = ["python-snappy (>=0.6.0)"] -dev = ["backports.tempfile (>=1.0)", "build (>=1.2.1)", "bumpversion (>=0.5.3)", "codecov (>=2.0.15)", "flake8 (<5)", "humanize (>=0.5.1)", "mypy (>=0.610)", "passlib", "pep8-naming (>=0.3.3)", "pip (>=9.0.1)", "pyenchant (>=1.6.6)", "pyflakes (>=1.0.0)", "pyinstaller (>=4.2)", "pylint (>=1.9.2)", "pytest (>=3.4.2)", "pytest-aiohttp", "pytest-asyncio (>=0.14.0)", "pytest-runner (>=2.11.1)", "pyyaml (>=4.2b4)", "qualname", "sphinx (>=1.7.1)", "sphinx-autoapi (>=1.7.0)", "sphinx-rtd-theme (>=0.1.9)", "sphinxcontrib-images (>=0.9.1)", "tox (>=4.2.8)", "tox-gh-actions (>=2.2.0)", "twine (>=3.3.0)", "twisted (>=22.10.0)", "txaio (>=20.4.1)", "watchdog (>=0.8.3)", "wheel (>=0.36.2)", "yapf (==0.29.0)"] +dev = ["backports.tempfile (>=1.0)", "build (>=1.2.1)", "bumpversion (>=0.5.3)", "codecov (>=2.0.15)", "flake8 (<5)", "humanize (>=0.5.1)", "mypy (>=0.610) ; python_version >= \"3.4\" and platform_python_implementation != \"PyPy\"", "passlib", "pep8-naming (>=0.3.3)", "pip (>=9.0.1)", "pyenchant (>=1.6.6)", "pyflakes (>=1.0.0)", "pyinstaller (>=4.2)", "pylint (>=1.9.2)", "pytest (>=3.4.2)", "pytest-aiohttp", "pytest-asyncio (>=0.14.0)", "pytest-runner (>=2.11.1)", "pyyaml (>=4.2b4)", "qualname", "sphinx (>=1.7.1)", "sphinx-autoapi (>=1.7.0)", "sphinx-rtd-theme (>=0.1.9)", "sphinxcontrib-images (>=0.9.1)", "tox (>=4.2.8)", "tox-gh-actions (>=2.2.0)", "twine (>=3.3.0)", "twisted (>=22.10.0)", "txaio (>=20.4.1)", "watchdog (>=0.8.3)", "wheel (>=0.36.2)", "yapf (==0.29.0)"] encryption = ["pynacl (>=1.4.0)", "pyopenssl (>=20.0.1)", "pytrie (>=0.4.0)", "qrcode (>=7.3.1)", "service-identity (>=18.1.0)"] nvx = ["cffi (>=1.14.5)"] scram = ["argon2-cffi (>=20.1.0)", "cffi (>=1.14.5)", "passlib (>=1.7.4)"] -serialization = ["cbor2 (>=5.2.0)", "flatbuffers (>=22.12.6)", "msgpack (>=1.0.2)", "py-ubjson (>=0.16.1)", "u-msgpack-python (>=2.1)", "ujson (>=4.0.2)"] +serialization = ["cbor2 (>=5.2.0)", "flatbuffers (>=22.12.6)", "msgpack (>=1.0.2) ; platform_python_implementation == \"CPython\"", "py-ubjson (>=0.16.1)", "u-msgpack-python (>=2.1) ; platform_python_implementation != \"CPython\"", "ujson (>=4.0.2) ; platform_python_implementation == \"CPython\""] twisted = ["attrs (>=20.3.0)", "twisted (>=24.3.0)", "zope.interface (>=5.2.0)"] ui = ["PyGObject (>=3.40.0)"] xbr = ["base58 (>=2.1.0)", "bitarray (>=2.7.5)", "cbor2 (>=5.2.0)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=4.0.0)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "rlp (>=2.0.1)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "web3[ipfs] (>=6.0.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)"] @@ -330,33 +330,33 @@ vine = ">=5.1.0,<6.0" arangodb = ["pyArango (>=2.0.2)"] auth = ["cryptography (==44.0.2)"] azureblockblob = ["azure-identity (>=1.19.0)", "azure-storage-blob (>=12.15.0)"] -brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +brotli = ["brotli (>=1.0.0) ; platform_python_implementation == \"CPython\"", "brotlipy (>=0.7.0) ; platform_python_implementation == \"PyPy\""] cassandra = ["cassandra-driver (>=3.25.0,<4)"] consul = ["python-consul2 (==0.1.5)"] cosmosdbsql = ["pydocumentdb (==2.3.5)"] -couchbase = ["couchbase (>=3.0.0)"] +couchbase = ["couchbase (>=3.0.0) ; platform_python_implementation != \"PyPy\" and (platform_system != \"Windows\" or python_version < \"3.10\")"] couchdb = ["pycouchdb (==1.16.0)"] django = ["Django (>=2.2.28)"] dynamodb = ["boto3 (>=1.26.143)"] elasticsearch = ["elastic-transport (<=8.17.1)", "elasticsearch (<=8.17.2)"] -eventlet = ["eventlet (>=0.32.0)"] +eventlet = ["eventlet (>=0.32.0) ; python_version < \"3.10\""] gcs = ["google-cloud-firestore (==2.20.1)", "google-cloud-storage (>=2.10.0)", "grpcio (==1.67.0)"] gevent = ["gevent (>=1.5.0)"] -librabbitmq = ["librabbitmq (>=2.0.0)"] -memcache = ["pylibmc (==1.6.3)"] +librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] +memcache = ["pylibmc (==1.6.3) ; platform_system != \"Windows\""] mongodb = ["pymongo (==4.10.1)"] msgpack = ["msgpack (==1.1.0)"] pydantic = ["pydantic (>=2.4)"] pymemcache = ["python-memcached (>=1.61)"] -pyro = ["pyro4 (==4.82)"] +pyro = ["pyro4 (==4.82) ; python_version < \"3.11\""] pytest = ["pytest-celery[all] (>=1.2.0,<1.3.0)"] redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] s3 = ["boto3 (>=1.26.143)"] slmq = ["softlayer_messaging (>=1.0.3)"] -solar = ["ephem (==4.2)"] +solar = ["ephem (==4.2) ; platform_python_implementation != \"PyPy\""] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.3.4)", "urllib3 (>=1.26.16)"] -tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +tblib = ["tblib (>=1.3.0) ; python_version < \"3.8.0\"", "tblib (>=1.5.0) ; python_version >= \"3.8.0\""] yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=1.3.1)"] zstd = ["zstandard (==0.23.0)"] @@ -718,10 +718,10 @@ files = [ cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] +pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] @@ -901,6 +901,70 @@ django-filter = ["django-filter (>=2.4)"] django-polymorphic = ["django-polymorphic (>=3.0)"] openapi = ["pyyaml (>=5.4)", "uritemplate (>=3.0.1)"] +[[package]] +name = "drf-extensions" +version = "0.8.0" +description = "Extensions for Django REST Framework" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "drf_extensions-0.8.0-py2.py3-none-any.whl", hash = "sha256:ab7bd854c9061c27ab55233b66d758001e5c2d81aaa9d117cbbe1c9ea49c77ab"}, + {file = "drf_extensions-0.8.0.tar.gz", hash = "sha256:c3f27bca74a2def53e8454a5c7b327595195df51e121743120b2f51ef5a52aaa"}, +] + +[package.dependencies] +Django = ">=2.2,<6.0" +djangorestframework = ">=3.10.3" +packaging = ">=24.1" + +[[package]] +name = "drf-spectacular" +version = "0.28.0" +description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "drf_spectacular-0.28.0-py3-none-any.whl", hash = "sha256:856e7edf1056e49a4245e87a61e8da4baff46c83dbc25be1da2df77f354c7cb4"}, + {file = "drf_spectacular-0.28.0.tar.gz", hash = "sha256:2c778a47a40ab2f5078a7c42e82baba07397bb35b074ae4680721b2805943061"}, +] + +[package.dependencies] +Django = ">=2.2" +djangorestframework = ">=3.10.3" +inflection = ">=0.3.1" +jsonschema = ">=2.6.0" +PyYAML = ">=5.1" +uritemplate = ">=2.0.0" + +[package.extras] +offline = ["drf-spectacular-sidecar"] +sidecar = ["drf-spectacular-sidecar"] + +[[package]] +name = "drf-spectacular-jsonapi" +version = "0.5.2" +description = "open api 3 schema generator for drf-json-api package based on drf-spectacular package." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +Django = ">=3.2" +djangorestframework = ">=3.13" +djangorestframework-jsonapi = ">=6.0.0" +drf-extensions = ">=0.7.1" +drf-spectacular = ">=0.25.0" + +[package.source] +type = "git" +url = "https://github.com/opaduchak/drf-spectacular-json-api.git" +reference = "osf-fixes" +resolved_reference = "4a4b62908585fd7ab24e9f43ff4cb09dea4e049d" + [[package]] name = "factory-boy" version = "3.3.1" @@ -950,7 +1014,7 @@ files = [ [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] -typing = ["typing-extensions (>=4.8)"] +typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] [[package]] name = "flake8" @@ -1155,6 +1219,18 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" version = "3.1.4" @@ -1245,7 +1321,7 @@ azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6. confluentkafka = ["confluent-kafka (>=2.2.0)"] consul = ["python-consul2 (==0.1.5)"] gcpubsub = ["google-cloud-monitoring (>=2.16.0)", "google-cloud-pubsub (>=2.18.4)", "grpcio (==1.67.0)", "protobuf (==4.25.5)"] -librabbitmq = ["librabbitmq (>=2.0.0)"] +librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] mongodb = ["pymongo (>=4.1.1)"] msgpack = ["msgpack (==1.1.0)"] pyro = ["pyro4 (==4.82)"] @@ -1493,6 +1569,18 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + [[package]] name = "pdoc" version = "14.5.1" @@ -1691,8 +1779,8 @@ typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.2.6)"] -c = ["psycopg-c (==3.2.6)"] +binary = ["psycopg-binary (==3.2.6) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.2.6) ; implementation_name != \"pypy\""] dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.14)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] pool = ["psycopg-pool"] @@ -1939,7 +2027,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1996,6 +2084,22 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "redis" +version = "5.2.1" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, + {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, +] + +[package.extras] +hiredis = ["hiredis (>=3.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] + [[package]] name = "referencing" version = "0.35.1" @@ -2052,7 +2156,7 @@ requests = ">=2.30.0,<3.0" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "rpds-py" @@ -2274,9 +2378,9 @@ files = [ ] [package.extras] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-ruff (<0.4) ; platform_system == \"Windows\"", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "pytest-ruff (>=0.3.2) ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -2353,19 +2457,19 @@ typing-extensions = ">=4.2.0" zope-interface = ">=5" [package.extras] -all-non-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +all-non-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226) ; platform_system == \"Windows\"", "pywin32 (!=226) ; platform_system == \"Windows\"", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] conch = ["appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)"] dev = ["coverage (>=7.5,<8.0)", "cython-test-exception-raiser (>=1.0.2,<2)", "hypothesis (>=6.56)", "pydoctor (>=23.9.0,<23.10.0)", "pyflakes (>=2.2,<3.0)", "pyhamcrest (>=2)", "python-subunit (>=1.4,<2.0)", "sphinx (>=6,<7)", "sphinx-rtd-theme (>=1.3,<2.0)", "towncrier (>=23.6,<24.0)", "twistedchecker (>=0.7,<1.0)"] dev-release = ["pydoctor (>=23.9.0,<23.10.0)", "pydoctor (>=23.9.0,<23.10.0)", "sphinx (>=6,<7)", "sphinx (>=6,<7)", "sphinx-rtd-theme (>=1.3,<2.0)", "sphinx-rtd-theme (>=1.3,<2.0)", "towncrier (>=23.6,<24.0)", "towncrier (>=23.6,<24.0)"] -gtk-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pygobject", "pygobject", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +gtk-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pygobject", "pygobject", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226) ; platform_system == \"Windows\"", "pywin32 (!=226) ; platform_system == \"Windows\"", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] http2 = ["h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)"] -macos-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyobjc-core", "pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyobjc-framework-cocoa", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] -mypy = ["appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "coverage (>=7.5,<8.0)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "idna (>=2.4)", "mypy (>=1.8,<2.0)", "mypy-zope (>=1.0.3,<1.1.0)", "priority (>=1.1.0,<2.0)", "pydoctor (>=23.9.0,<23.10.0)", "pyflakes (>=2.2,<3.0)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "python-subunit (>=1.4,<2.0)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "sphinx (>=6,<7)", "sphinx-rtd-theme (>=1.3,<2.0)", "towncrier (>=23.6,<24.0)", "twistedchecker (>=0.7,<1.0)", "types-pyopenssl", "types-setuptools"] -osx-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyobjc-core", "pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyobjc-framework-cocoa", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] -serial = ["pyserial (>=3.0)", "pywin32 (!=226)"] +macos-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyobjc-core", "pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyobjc-framework-cocoa", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226) ; platform_system == \"Windows\"", "pywin32 (!=226) ; platform_system == \"Windows\"", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +mypy = ["appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "coverage (>=7.5,<8.0)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "idna (>=2.4)", "mypy (>=1.8,<2.0)", "mypy-zope (>=1.0.3,<1.1.0)", "priority (>=1.1.0,<2.0)", "pydoctor (>=23.9.0,<23.10.0)", "pyflakes (>=2.2,<3.0)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "python-subunit (>=1.4,<2.0)", "pywin32 (!=226) ; platform_system == \"Windows\"", "service-identity (>=18.1.0)", "sphinx (>=6,<7)", "sphinx-rtd-theme (>=1.3,<2.0)", "towncrier (>=23.6,<24.0)", "twistedchecker (>=0.7,<1.0)", "types-pyopenssl", "types-setuptools"] +osx-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyobjc-core", "pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyobjc-framework-cocoa", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226) ; platform_system == \"Windows\"", "pywin32 (!=226) ; platform_system == \"Windows\"", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +serial = ["pyserial (>=3.0)", "pywin32 (!=226) ; platform_system == \"Windows\""] test = ["cython-test-exception-raiser (>=1.0.2,<2)", "hypothesis (>=6.56)", "pyhamcrest (>=2)"] tls = ["idna (>=2.4)", "pyopenssl (>=21.0.0)", "service-identity (>=18.1.0)"] -windows-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)", "twisted-iocpsupport (>=1.0.2)", "twisted-iocpsupport (>=1.0.2)"] +windows-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "pywin32 (!=226) ; platform_system == \"Windows\"", "pywin32 (!=226) ; platform_system == \"Windows\"", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)", "twisted-iocpsupport (>=1.0.2)", "twisted-iocpsupport (>=1.0.2)"] [[package]] name = "txaio" @@ -2395,7 +2499,7 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {dev = "python_version < \"3.13\"", release = "python_version < \"3.13\""} +markers = {dev = "python_version == \"3.12\"", release = "python_version == \"3.12\""} [[package]] name = "tzdata" @@ -2410,6 +2514,18 @@ files = [ ] markers = {dev = "sys_platform == \"win32\"", release = "sys_platform == \"win32\""} +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [[package]] name = "urllib3" version = "2.2.2" @@ -2423,7 +2539,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2459,7 +2575,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "wcwidth" @@ -2625,4 +2741,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "580d926a21f66415a86daa4f9442ffd47d0bf7a0bc2446948ece4349b53db600" +content-hash = "995ab5635fe48f9e7c79a483b889bc8242022b1aec820b2a501992fb2ac648ce" diff --git a/pyproject.toml b/pyproject.toml index 691fd654..af51bd52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,10 @@ boa-api = "^0.1.14" django-silk = "^5.3.2" django-celery-beat = "^2.7.0" tqdm = "^4.67.1" +itsdangerous = "^2.2.0" +redis = "^5.2.1" +drf-spectacular = "^0.28.0" +drf-spectacular-jsonapi = {git = "https://github.com/opaduchak/drf-spectacular-json-api.git", branch = "osf-fixes"} [tool.poetry.group.dev.dependencies]