diff --git a/AGENTS.md b/AGENTS.md index bf0e6a45..7dab863a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Fetch the endpoint spec first (see Crowdin API reference below). Then: - List endpoints call `self._get_entire_data(method="get", path=..., params=...)` so `with_fetch_all()` pagination works; everything else calls `self.requester.request(...)`. - Project-scoped methods take `projectId: Optional[int] = None` and resolve it via `projectId or self.get_project_id()`. - Request body shapes go in `types.py` as TypedDicts; enum values in `enums.py`. Enums and `Sorting` objects can be passed straight into `params`/`request_data` — the custom JSON encoder serializes them, and `None` values are stripped before sending. - - End the docstring with `Link to documentation:` and the developer.crowdin.com operation URL (pdoc publishes these). + - End the docstring with `Link to documentation:` and the support.crowdin.com operation URL, e.g. `https://support.crowdin.com/developer/api/v2/#operation/api.projects.getMany` (Enterprise: `https://support.crowdin.com/developer/enterprise/api/v2/#operation/...`). pdoc publishes these. 2. For a new resource, register it in three places: an import plus `__all__` entry in `crowdin_api/api_resources/__init__.py` (alphabetical), a `@property` on `CrowdinClient` in `client.py` (copy an existing property; use the enterprise-guard or per-platform variant when the API is Enterprise-only or differs by platform), and one tuple in each of the two parametrize lists in `crowdin_api/tests/test_client.py`. Some resource classes exist but were never registered (e.g. `BranchesResource`, `StringCorrectionsResource`) — "adding" one of those is exactly this registration work. 3. Test in the resource's `tests/` dir: patch the requester with `@mock.patch("crowdin_api.requester.APIRequester.request")`, call the method, then `m_request.assert_called_once_with(method=..., path=..., ...)` with the exact kwargs. The `base_absolut_url` fixture (spelled without the second "e") provides the base URL. No test performs real HTTP. diff --git a/README.md b/README.md index 2ddfebc1..5e09fa73 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Crowdin API is a full-featured RESTful API that helps you to integrate localizat
[**`API Client Docs`**](https://crowdin.github.io/crowdin-api-client-python/)  |  -[**`Crowdin API`**](https://developer.crowdin.com/api/v2/)  |  -[**`Crowdin Enterprise API`**](https://developer.crowdin.com/enterprise/api/v2/) +[**`Crowdin API`**](https://support.crowdin.com/developer/api/v2/)  |  +[**`Crowdin Enterprise API`**](https://support.crowdin.com/developer/enterprise/api/v2/) [![PyPI](https://img.shields.io/pypi/v/crowdin-api-client?cacheSeconds=3600)](https://pypi.org/project/crowdin-api-client/) [![Downloads](https://pepy.tech/badge/crowdin-api-client)](https://pepy.tech/project/crowdin-api-client) @@ -185,7 +185,7 @@ class FirstCrowdinClient(CrowdinClient): ### GraphQL API -This library also provides the possibility to use [GraphQL API](https://developer.crowdin.com/graphql-api/): +This library also provides the possibility to use [GraphQL API](https://support.crowdin.com/developer/graphql-api/): ```python from crowdin_api import CrowdinClient diff --git a/crowdin_api/api_resources/__init__.py b/crowdin_api/api_resources/__init__.py index 9d164792..008bee04 100644 --- a/crowdin_api/api_resources/__init__.py +++ b/crowdin_api/api_resources/__init__.py @@ -1,24 +1,38 @@ +from .advisors.resource import AdvisorsResource from .ai.resource import AIResource, EnterpriseAIResource from .application.resource import ApplicationResource +from .branches.resource import BranchesResource from .bundles.resource import BundlesResource +from .clients.resource import ClientsResource +from .custom_placeholders.resource import CustomPlaceholdersResource +from .custom_spellcheckers.resource import CustomSpellcheckersResource from .dictionaries.resource import DictionariesResource from .distributions.resource import DistributionsResource +from .external_qa_checks.resource import ExternalQaChecksResource from .fields.resource import FieldsResource from .glossaries.resource import GlossariesResource from .groups.resource import GroupsResource from .labels.resource import LabelsResource from .languages.resource import LanguagesResource -from .machine_translation_engines.resource import MachineTranslationEnginesResource +from .machine_translation_engines.resource import ( + EnterpriseMachineTranslationEnginesResource, + MachineTranslationEnginesResource, +) +from .notifications.resource import NotificationResource +from .organization.resource import OrganizationResource +from .project_placeholders.resource import ProjectPlaceholdersResource from .projects.resource import ProjectsResource from .reports.resource import EnterpriseReportsResource, ReportsResource from .screenshots.resource import ScreenshotsResource from .security_logs.resource import SecurityLogsResource from .source_files.resource import SourceFilesResource -from .source_strings.resource import SourceStringsResource +from .source_strings.resource import EnterpriseSourceStringsResource, SourceStringsResource from .storages.resource import StoragesResource from .string_comments.resource import StringCommentsResource +from .string_corrections.resource import StringCorrectionsResource from .string_translations.resource import StringTranslationsResource from .style_guides.resource import StyleGuidesResource +from .system_placeholders.resource import SystemPlaceholdersResource from .tasks.resource import EnterpriseTasksResource, TasksResource from .teams.resource import TeamsResource from .translation_memory.resource import TranslationMemoryResource @@ -26,22 +40,34 @@ from .translations.resource import TranslationsResource from .users.resource import EnterpriseUsersResource, UsersResource from .vendors.resource import VendorsResource +from .webhooks.organization.resource import OrganizationWebhooksResource from .webhooks.resource import WebhooksResource from .workflows.resource import WorkflowsResource __all__ = [ + "AdvisorsResource", "AIResource", "EnterpriseAIResource", "ApplicationResource", + "BranchesResource", "BundlesResource", + "ClientsResource", + "CustomPlaceholdersResource", + "CustomSpellcheckersResource", "DictionariesResource", "DistributionsResource", + "ExternalQaChecksResource", "FieldsResource", "GlossariesResource", "GroupsResource", "LabelsResource", "LanguagesResource", "MachineTranslationEnginesResource", + "EnterpriseMachineTranslationEnginesResource", + "NotificationResource", + "OrganizationResource", + "OrganizationWebhooksResource", + "ProjectPlaceholdersResource", "ProjectsResource", "ReportsResource", "EnterpriseReportsResource", @@ -49,16 +75,19 @@ "SecurityLogsResource", "SourceFilesResource", "SourceStringsResource", + "EnterpriseSourceStringsResource", "StoragesResource", "StringCommentsResource", + "StringCorrectionsResource", "StringTranslationsResource", "StyleGuidesResource", + "SystemPlaceholdersResource", "TasksResource", "EnterpriseTasksResource", "TeamsResource", "TranslationMemoryResource", - "TranslationStatusResource", "TranslationsResource", + "TranslationStatusResource", "UsersResource", "EnterpriseUsersResource", "VendorsResource", diff --git a/crowdin_api/api_resources/advisors/__init__.py b/crowdin_api/api_resources/advisors/__init__.py new file mode 100644 index 00000000..9f77d639 --- /dev/null +++ b/crowdin_api/api_resources/advisors/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {'tests': False} diff --git a/crowdin_api/api_resources/advisors/enums.py b/crowdin_api/api_resources/advisors/enums.py new file mode 100644 index 00000000..f6ef9057 --- /dev/null +++ b/crowdin_api/api_resources/advisors/enums.py @@ -0,0 +1,40 @@ +from enum import Enum + + +class AdvisorInspectorMode(Enum): + AUTO = "auto" + ALL = "all" + + +class AdvisorInsightStatus(Enum): + PENDING = "pending" + CHECKING = "checking" + OUTDATED = "outdated" + DONE = "done" + + +class AdvisorInsightOutcome(Enum): + FLAGGED = "flagged" + CLEAR = "clear" + NOT_APPLICABLE = "not_applicable" + + +class AdvisorInsightPatchPath(Enum): + IS_DISMISSED = "/isDismissed" + + +class AdvisorInsightMetricUnit(Enum): + PERCENT = "percent" + COUNT = "count" + + +class AdvisorInsightMetricTone(Enum): + DEFAULT = "default" + SUCCESS = "success" + DANGER = "danger" + + +class AdvisorInsightMetricSource(Enum): + DETERMINISTIC = "deterministic" + AI = "ai" + APP = "app" diff --git a/crowdin_api/api_resources/advisors/resource.py b/crowdin_api/api_resources/advisors/resource.py new file mode 100644 index 00000000..8d4078d8 --- /dev/null +++ b/crowdin_api/api_resources/advisors/resource.py @@ -0,0 +1,187 @@ +from datetime import datetime +from typing import Any, Dict, Iterable, Optional, Union + +from crowdin_api.api_resources.abstract.resources import BaseResource +from crowdin_api.api_resources.advisors.enums import ( + AdvisorInsightOutcome, + AdvisorInsightStatus, +) +from crowdin_api.api_resources.advisors.types import ( + AdvisorCheckInspector, + AdvisorInsightMetric, + AdvisorInsightPatchRequest, + AdvisorInsightRecommendation, +) +from crowdin_api.utils import convert_to_query_list + + +class AdvisorsResource(BaseResource): + """ + Resource for Advisors. + + Advisors run inspectors against a project and report insights about its localization health. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Advisors + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Advisors + """ + + def get_advisor_checks_path(self, projectId: int, checkId: Optional[str] = None): + if checkId is not None: + return f"projects/{projectId}/advisors/checks/{checkId}" + return f"projects/{projectId}/advisors/checks" + + def get_advisor_insights_path(self, projectId: int, insightId: Optional[int] = None): + if insightId is not None: + return f"projects/{projectId}/advisors/insights/{insightId}" + return f"projects/{projectId}/advisors/insights" + + def create_advisor_check( + self, + category: Optional[str] = None, + inspectors: Optional[Iterable[AdvisorCheckInspector]] = None, + projectId: Optional[int] = None, + ): + """ + Create Advisor Check. + + At most one of `category` or `inspectors` may be set; if neither is set, every + inspector is re-checked. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.advisors.checks.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.advisors.checks.post + """ + if category is not None and inspectors is not None: + raise ValueError("You can set only one of category or inspectors.") + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_advisor_checks_path(projectId=projectId), + request_data={"category": category, "inspectors": inspectors}, + ) + + def get_advisor_check_status(self, checkId: str, projectId: Optional[int] = None): + """ + Get Advisor Check Status. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.advisors.checks.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.advisors.checks.get + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_advisor_checks_path(projectId=projectId, checkId=checkId), + ) + + def list_advisor_insights( + self, + projectId: Optional[int] = None, + isDismissed: Optional[bool] = None, + status: Optional[ + Union[str, AdvisorInsightStatus, Iterable[Union[str, AdvisorInsightStatus]]] + ] = None, + outcome: Optional[ + Union[str, AdvisorInsightOutcome, Iterable[Union[str, AdvisorInsightOutcome]]] + ] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List Advisor Insights. + + `status` and `outcome` accept a single value or a list of values (sent comma-separated). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.advisors.insights.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.advisors.insights.getMany + """ + projectId = projectId or self.get_project_id() + params = { + "isDismissed": isDismissed, + "status": convert_to_query_list(status), + "outcome": convert_to_query_list(outcome), + } + params.update(self.get_page_params(offset=offset, limit=limit)) + + return self._get_entire_data( + method="get", + path=self.get_advisor_insights_path(projectId=projectId), + params=params, + ) + + def edit_advisor_insight( + self, + insightId: int, + data: Iterable[AdvisorInsightPatchRequest], + projectId: Optional[int] = None, + ): + """ + Edit Advisor Insight. + + Currently only `/isDismissed` is patchable. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.advisors.insights.patch + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.advisors.insights.patch + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="patch", + path=self.get_advisor_insights_path(projectId=projectId, insightId=insightId), + request_data=data, + ) + + def create_or_update_application_advisor_insight( + self, + applicationIdentifier: str, + moduleKey: str, + outcome: AdvisorInsightOutcome, + checkedAt: Optional[Union[datetime, str]] = None, + metrics: Optional[Iterable[AdvisorInsightMetric]] = None, + recommendations: Optional[Iterable[AdvisorInsightRecommendation]] = None, + payload: Optional[Dict[str, Any]] = None, + projectId: Optional[int] = None, + ): + """ + Create or Update Application Advisor Insight. + + Called by an installed application's `advisor-inspector` module to publish its check result. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.applications.modules.advisors.insights.put + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.applications.modules.advisors.insights.put + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="put", + path=( + f"projects/{projectId}/applications/{applicationIdentifier}" + f"/modules/{moduleKey}/advisors/insights" + ), + request_data={ + "outcome": outcome, + "checkedAt": checkedAt, + "metrics": metrics, + "recommendations": recommendations, + "payload": payload, + }, + ) diff --git a/crowdin_api/api_resources/advisors/tests/test_advisors_resources.py b/crowdin_api/api_resources/advisors/tests/test_advisors_resources.py new file mode 100644 index 00000000..b6f9ad39 --- /dev/null +++ b/crowdin_api/api_resources/advisors/tests/test_advisors_resources.py @@ -0,0 +1,254 @@ +from unittest import mock + +import pytest + +from crowdin_api.api_resources.advisors.enums import ( + AdvisorInspectorMode, + AdvisorInsightMetricSource, + AdvisorInsightMetricTone, + AdvisorInsightMetricUnit, + AdvisorInsightOutcome, + AdvisorInsightPatchPath, + AdvisorInsightStatus, +) +from crowdin_api.api_resources.advisors.resource import AdvisorsResource +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.requester import APIRequester + + +class TestAdvisorsResource: + resource_class = AdvisorsResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"projectId": 1}, "projects/1/advisors/checks"), + ({"projectId": 1, "checkId": "abc"}, "projects/1/advisors/checks/abc"), + ), + ) + def test_get_advisor_checks_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_advisor_checks_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"projectId": 1}, "projects/1/advisors/insights"), + ({"projectId": 1, "insightId": 2}, "projects/1/advisors/insights/2"), + ), + ) + def test_get_advisor_insights_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_advisor_insights_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ({}, {"category": None, "inspectors": None}), + ({"category": "strings"}, {"category": "strings", "inspectors": None}), + ( + { + "inspectors": [ + { + "key": "string_context_relevance", + "options": {"mode": AdvisorInspectorMode.AUTO, "promptId": 3}, + } + ] + }, + { + "category": None, + "inspectors": [ + { + "key": "string_context_relevance", + "options": {"mode": AdvisorInspectorMode.AUTO, "promptId": 3}, + } + ], + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_create_advisor_check(self, m_request, in_params, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.create_advisor_check(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/advisors/checks", + request_data=request_data, + ) + + def test_create_advisor_check_invalid(self, base_absolut_url): + resource = self.get_resource(base_absolut_url) + with pytest.raises(ValueError): + resource.create_advisor_check( + category="strings", inspectors=[{"key": "k"}], projectId=1 + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_advisor_check_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_advisor_check_status("abc", projectId=1) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/advisors/checks/abc", + ) + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ( + {}, + { + "isDismissed": None, + "status": None, + "outcome": None, + "offset": 0, + "limit": 25, + }, + ), + ( + { + "isDismissed": False, + "status": [AdvisorInsightStatus.DONE, "outdated"], + "outcome": AdvisorInsightOutcome.FLAGGED, + "offset": 10, + "limit": 5, + }, + { + "isDismissed": False, + "status": "done,outdated", + "outcome": AdvisorInsightOutcome.FLAGGED, + "offset": 10, + "limit": 5, + }, + ), + ( + {"status": "pending,checking"}, + { + "isDismissed": None, + "status": "pending,checking", + "outcome": None, + "offset": 0, + "limit": 25, + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_advisor_insights( + self, m_request, in_params, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_advisor_insights(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/advisors/insights", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_advisor_insight(self, m_request, base_absolut_url): + m_request.return_value = "response" + data = [ + { + "op": PatchOperation.REPLACE, + "path": AdvisorInsightPatchPath.IS_DISMISSED, + "value": True, + } + ] + + resource = self.get_resource(base_absolut_url) + assert resource.edit_advisor_insight(2, data, projectId=1) == "response" + m_request.assert_called_once_with( + method="patch", + path="projects/1/advisors/insights/2", + request_data=data, + ) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {"outcome": AdvisorInsightOutcome.CLEAR}, + { + "outcome": AdvisorInsightOutcome.CLEAR, + "checkedAt": None, + "metrics": None, + "recommendations": None, + "payload": None, + }, + ), + ( + { + "outcome": AdvisorInsightOutcome.FLAGGED, + "checkedAt": "2026-01-01T00:00:00+00:00", + "metrics": [ + { + "key": "quality_score", + "value": 12, + "unit": AdvisorInsightMetricUnit.PERCENT, + "tone": AdvisorInsightMetricTone.DANGER, + "source": AdvisorInsightMetricSource.APP, + } + ], + "recommendations": [{"id": "r1", "primary": True, "params": {"a": 1}}], + "payload": {"x": "y"}, + }, + { + "outcome": AdvisorInsightOutcome.FLAGGED, + "checkedAt": "2026-01-01T00:00:00+00:00", + "metrics": [ + { + "key": "quality_score", + "value": 12, + "unit": AdvisorInsightMetricUnit.PERCENT, + "tone": AdvisorInsightMetricTone.DANGER, + "source": AdvisorInsightMetricSource.APP, + } + ], + "recommendations": [{"id": "r1", "primary": True, "params": {"a": 1}}], + "payload": {"x": "y"}, + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_create_or_update_application_advisor_insight( + self, m_request, in_params, request_data, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.create_or_update_application_advisor_insight( + "my-app", "my-module", projectId=1, **in_params + ) + == "response" + ) + m_request.assert_called_once_with( + method="put", + path="projects/1/applications/my-app/modules/my-module/advisors/insights", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_default_project_id(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.resource_class( + requester=APIRequester(base_url=base_absolut_url), project_id=5 + ) + assert resource.get_advisor_check_status("abc") == "response" + m_request.assert_called_once_with( + method="get", + path="projects/5/advisors/checks/abc", + ) diff --git a/crowdin_api/api_resources/advisors/types.py b/crowdin_api/api_resources/advisors/types.py new file mode 100644 index 00000000..c7202e7e --- /dev/null +++ b/crowdin_api/api_resources/advisors/types.py @@ -0,0 +1,47 @@ +from datetime import datetime +from typing import Any, Dict, Optional, Union + +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.typing import TypedDict +from crowdin_api.api_resources.advisors.enums import ( + AdvisorInspectorMode, + AdvisorInsightMetricSource, + AdvisorInsightMetricTone, + AdvisorInsightMetricUnit, + AdvisorInsightPatchPath, +) + + +class AdvisorInspectorOptions(TypedDict, total=False): + mode: AdvisorInspectorMode + promptId: int + + +class _AdvisorCheckInspectorRequired(TypedDict): + key: str + + +class AdvisorCheckInspector(_AdvisorCheckInspectorRequired, total=False): + options: Optional[AdvisorInspectorOptions] + + +class AdvisorInsightPatchRequest(TypedDict): + op: PatchOperation + path: AdvisorInsightPatchPath + value: bool + + +class AdvisorInsightMetric(TypedDict, total=False): + key: str + value: Union[int, float] + unit: AdvisorInsightMetricUnit + threshold: Optional[Union[int, float]] + tone: AdvisorInsightMetricTone + source: AdvisorInsightMetricSource + checkedAt: Optional[Union[datetime, str]] + + +class AdvisorInsightRecommendation(TypedDict, total=False): + id: str + primary: bool + params: Dict[str, Any] diff --git a/crowdin_api/api_resources/ai/enums.py b/crowdin_api/api_resources/ai/enums.py index 920c9d03..e3d18ff4 100644 --- a/crowdin_api/api_resources/ai/enums.py +++ b/crowdin_api/api_resources/ai/enums.py @@ -17,6 +17,7 @@ class EditAIPromptPath(Enum): ACTION = "/action" AI_PROVIDER_ID = "/aiProviderId" AI_MODEL_ID = "/aiModelId" + # Deprecated in Crowdin API (still supported by Crowdin Enterprise API). IS_ENABLED = "/isEnabled" ENABLED_PROJECT_IDS = "/enabledProjectIds" CONFIG = "/config" @@ -26,8 +27,13 @@ class AIProviderType(Enum): OPEN_AI = "open_ai" AZURE_OPEN_AI = "azure_open_ai" GOOGLE_GEMINI = "google_gemini" + GOOGLE_GEMINI_AI_STUDIO = "google_gemini_ai_studio" MISTRAL_AI = "mistral_ai" ANTHROPIC = "anthropic" + X_AI = "x_ai" + WATSONX = "watsonx" + DEEPSEEK = "deepseek" + MICROSOFT_FOUNDRY = "microsoft_foundry" CUSTOM_AI = "custom_ai" @@ -46,6 +52,14 @@ class DatasetPurpose(Enum): class EditAiCustomPlaceholderPatchPath(Enum): + """Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `EditAiSnippetPatchPath`.""" + + DESCRIPTION = "/description" + PLACEHOLDER = "/placeholder" + VALUE = "/value" + + +class EditAiSnippetPatchPath(Enum): DESCRIPTION = "/description" PLACEHOLDER = "/placeholder" VALUE = "/value" @@ -65,10 +79,30 @@ class AiToolType(Enum): class AiReportType(Enum): TOKENS_USAGE_RAW_DATA = "tokens-usage-raw-data" + COSTS_BY_USERS = "costs-by-users" class EditAiSettingsPatchPath(Enum): + """ + Paths containing `{userId}` are templates: replace `{userId}` with the actual user identifier, + e.g. `EditAiSettingsPatchPath.PER_USER_OVERRIDE_DAILY_COST_LIMIT.value.format(userId=12)`. + """ + + PRE_TRANSLATION_AI_PROMPT_ID = "/preTranslationAiPromptId" EDITOR_SUGGESTION_AI_PROMPT_ID = "/editorSuggestionAiPromptId" + # Crowdin Enterprise only. + ALIGNMENT_ACTION_AI_PROMPT_ID = "/alignmentActionAiPromptId" + QA_CHECK_ACTION_AI_PROMPT_ID = "/qaCheckActionAiPromptId" + CONTEXT_REVIEW_AI_PROMPT_ID = "/contextReviewAiPromptId" + DAILY_COST_LIMIT = "/dailyCostLimit" + MONTHLY_COST_LIMIT = "/monthlyCostLimit" + USER_DAILY_COST_LIMIT = "/userDailyCostLimit" + USER_MONTHLY_COST_LIMIT = "/userMonthlyCostLimit" + PER_USER_OVERRIDE = "/perUserOverrides/{userId}" + PER_USER_OVERRIDE_COST_LIMIT_MODE = "/perUserOverrides/{userId}/costLimitMode" + PER_USER_OVERRIDE_DAILY_COST_LIMIT = "/perUserOverrides/{userId}/dailyCostLimit" + PER_USER_OVERRIDE_MONTHLY_COST_LIMIT = "/perUserOverrides/{userId}/monthlyCostLimit" + # Deprecated: no longer supported by the API. SHORTCUTS = "/shortcuts" @@ -108,3 +142,7 @@ class AiRequestLogSourceAction(Enum): QA_CHECK = "qa_check" AI_SUGGESTION = "ai_suggestion" ADVISOR = "advisor" + + +class AiRequestLogExportFormat(Enum): + CSV = "csv" diff --git a/crowdin_api/api_resources/ai/resource.py b/crowdin_api/api_resources/ai/resource.py index 92ce2f34..f4fd6142 100644 --- a/crowdin_api/api_resources/ai/resource.py +++ b/crowdin_api/api_resources/ai/resource.py @@ -1,10 +1,13 @@ from datetime import datetime from typing import Dict, Iterable, Optional, Union +from deprecated import deprecated + from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.ai.enums import ( AIPromptAction, AiPromptFineTuningJobStatus, + AiRequestLogExportFormat, AiRequestLogSourceAction, AiRequestLogStatus, AIProviderType, @@ -13,6 +16,7 @@ AddAiCustomPlaceholderRequest, AddAIPromptRequestScheme, AddAIProviderReqeustScheme, + AddAiSnippetRequest, AiFileTranslationRequest, AiTranslateStringsRequest, CreateAIPromptFineTuningJobRequest, @@ -20,6 +24,7 @@ EditAIPromptScheme, EditAIProviderRequestScheme, EditAiSettingsPatch, + EditAiSnippetPatch, GenerateAiPromptCompletionRequest, GenerateAIPromptFineTuningDatasetRequest, GenerateAiReportRequest, @@ -30,6 +35,7 @@ from crowdin_api.utils import ( convert_enum_collection_to_string_if_exists, convert_enum_to_string_if_exists, + convert_to_query_string, ) @@ -38,7 +44,7 @@ class AIResource(BaseResource): Resource for AI. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/AI + https://support.crowdin.com/developer/api/v2/#tag/AI """ def get_ai_path(self, userId: int, aiPromptId: Optional[int] = None): @@ -63,7 +69,7 @@ def list_ai_prompts( List AI Prompts Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.ai.prompts.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.ai.prompts.getMany """ params = {"projectId": projectId, "action": action} params.update(self.get_page_params(limit=limit, offset=offset)) @@ -77,7 +83,7 @@ def add_ai_prompt(self, userId: int, request_data: AddAIPromptRequestScheme): Add AI Prompt Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.prompts.post + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.prompts.post """ return self.requester.request( @@ -91,7 +97,7 @@ def get_ai_prompt(self, userId: int, aiPromptId: int): Get AI Prompt Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.prompts.get + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.prompts.get """ return self.requester.request( @@ -104,7 +110,7 @@ def delete_ai_prompt(self, userId: int, aiPromptId: int): Delete AI Prompt Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.prompts.delete + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.prompts.delete """ return self.requester.request( @@ -119,7 +125,7 @@ def edit_ai_prompt( Edit AI Prompt Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.prompts.patch + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.prompts.patch """ return self.requester.request( @@ -138,7 +144,7 @@ def list_ai_providers( List AI Providers Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.ai.providers.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.ai.providers.getMany """ params = self.get_page_params(limit=limit, offset=offset) return self.requester.request( @@ -150,7 +156,7 @@ def add_ai_provider(self, userId: int, request_data: AddAIProviderReqeustScheme) Add AI Provider Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.providers.post + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.providers.post """ return self.requester.request( method="post", @@ -163,7 +169,7 @@ def get_ai_provider(self, userId: int, aiProviderId: int): Get AI Provider Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.providers.get + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.providers.get """ return self.requester.request( method="get", @@ -175,7 +181,7 @@ def delete_ai_provider(self, userId: int, aiProviderId: int): Delete AI Provider Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.providers.delete + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.providers.delete """ return self.requester.request( method="delete", @@ -189,7 +195,7 @@ def edit_ai_provider( Edit AI Provider Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.providers.patch + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.providers.patch """ return self.requester.request( method="patch", @@ -202,7 +208,7 @@ def list_ai_provider_models(self, userId: int, aiProviderId: int): List AI Provider Models Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.ai.providers.models.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.ai.providers.models.getMany """ return self.requester.request( method="get", @@ -210,6 +216,21 @@ def list_ai_provider_models(self, userId: int, aiProviderId: int): + "/models", ) + def list_all_ai_provider_models(self, user_id: int): + """ + List AI Provider Models + + Lists the models of all AI providers available to the user. + To list the models of a single AI provider, use `list_ai_provider_models`. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.providers.models.crowdin.getMany + """ + return self.requester.request( + method="get", + path=f"users/{user_id}/ai/providers/models", + ) + def create_ai_proxy_chat_completion( self, userId: int, @@ -223,7 +244,7 @@ def create_ai_proxy_chat_completion( Please refer to the documentation for the specific provider you use to determine the required payload format. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.ai.providers.chat.completions.post + https://support.crowdin.com/developer/api/v2/#operation/api.users.ai.providers.chat.completions.post """ return self.requester.request( method="post", @@ -396,10 +417,13 @@ def get_ai_custom_placeholders_path(self, user_id: int, custom_placeholder_id: O return f"users/{user_id}/ai/settings/custom-placeholders" + @deprecated("Use `list_ai_snippets` instead") def list_ai_custom_placeholders(self, user_id: int): """ List AI Custom Placeholders + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `list_ai_snippets` instead. + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/AI/operation/api.ai.prompt.custom.placeholders.getMany """ @@ -409,10 +433,13 @@ def list_ai_custom_placeholders(self, user_id: int): path=self.get_ai_custom_placeholders_path(user_id) ) + @deprecated("Use `add_ai_snippet` instead") def add_ai_custom_placeholder(self, user_id: int, body: AddAiCustomPlaceholderRequest): """ Add AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `add_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/AI/operation/api.users.ai.settings.custom-placeholders.post """ @@ -423,10 +450,13 @@ def add_ai_custom_placeholder(self, user_id: int, body: AddAiCustomPlaceholderRe request_data=body, ) + @deprecated("Use `get_ai_snippet` instead") def get_ai_custom_placeholder(self, user_id: int, ai_custom_placeholder_id: int): """ Get AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `get_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/AI/operation/api.users.ai.settings.custom-placeholders.get """ @@ -436,10 +466,13 @@ def get_ai_custom_placeholder(self, user_id: int, ai_custom_placeholder_id: int) path=self.get_ai_custom_placeholders_path(user_id, ai_custom_placeholder_id), ) + @deprecated("Use `delete_ai_snippet` instead") def delete_ai_custom_placeholder(self, user_id: int, ai_custom_placeholder_id: int): """ Delete AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `delete_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/AI/operation/api.users.ai.settings.custom-placeholders.delete """ @@ -449,6 +482,7 @@ def delete_ai_custom_placeholder(self, user_id: int, ai_custom_placeholder_id: i path=self.get_ai_custom_placeholders_path(user_id, ai_custom_placeholder_id), ) + @deprecated("Use `edit_ai_snippet` instead") def edit_ai_custom_placeholder( self, user_id: int, @@ -458,6 +492,8 @@ def edit_ai_custom_placeholder( """ Edit AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `edit_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/AI/operation/api.users.ai.settings.custom-placeholders.patch """ @@ -468,6 +504,84 @@ def edit_ai_custom_placeholder( request_data=patches, ) + def get_ai_snippets_path(self, user_id: int, ai_snippet_id: Optional[int] = None): + if ai_snippet_id is not None: + return f"users/{user_id}/ai/settings/snippets/{ai_snippet_id}" + return f"users/{user_id}/ai/settings/snippets" + + def list_ai_snippets( + self, + user_id: int, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List AI Snippets + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.prompts.snippets.getMany + """ + return self._get_entire_data( + method="get", + path=self.get_ai_snippets_path(user_id), + params=self.get_page_params(limit=limit, offset=offset), + ) + + def add_ai_snippet(self, user_id: int, request_data: AddAiSnippetRequest): + """ + Add AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.prompts.snippets.post + """ + return self.requester.request( + method="post", + path=self.get_ai_snippets_path(user_id), + request_data=request_data, + ) + + def get_ai_snippet(self, user_id: int, ai_snippet_id: int): + """ + Get AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.prompts.snippets.get + """ + return self.requester.request( + method="get", + path=self.get_ai_snippets_path(user_id, ai_snippet_id=ai_snippet_id), + ) + + def delete_ai_snippet(self, user_id: int, ai_snippet_id: int): + """ + Delete AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.prompts.snippets.delete + """ + return self.requester.request( + method="delete", + path=self.get_ai_snippets_path(user_id, ai_snippet_id=ai_snippet_id), + ) + + def edit_ai_snippet( + self, + user_id: int, + ai_snippet_id: int, + patches: Iterable[EditAiSnippetPatch], + ): + """ + Edit AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.prompts.snippets.patch + """ + return self.requester.request( + method="patch", + path=self.get_ai_snippets_path(user_id, ai_snippet_id=ai_snippet_id), + request_data=patches, + ) + def clone_ai_prompt( self, user_id: int, @@ -656,7 +770,7 @@ def list_ai_request_logs( List AI Request Logs Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.ai.requestLogs.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.ai.requestLogs.getMany """ params = { @@ -683,6 +797,86 @@ def list_ai_request_logs( params=params, ) + def export_ai_request_logs( + self, + user_id: int, + export_format: Optional[AiRequestLogExportFormat] = None, + request_id: Optional[str] = None, + project_id: Optional[int] = None, + request_user_id: Optional[int] = None, + ai_provider_id: Optional[int] = None, + model: Optional[str] = None, + source_action: Optional[AiRequestLogSourceAction] = None, + prompt_action: Optional[str] = None, + statuses: Optional[Iterable[AiRequestLogStatus]] = None, + system_credentials: Optional[bool] = None, + is_auto_triggered: Optional[bool] = None, + token_name: Optional[str] = None, + oauth_client_id: Optional[str] = None, + created_after: Optional[datetime] = None, + created_before: Optional[datetime] = None, + ): + """ + Export AI Request Logs + + Starts an asynchronous export of the AI request logs matching the filters. + Poll `check_ai_request_logs_export_status` until the status is `finished`, + then call `download_ai_request_logs_export`. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.requestLogs.exports.post + """ + + request_data = { + "format": export_format, + "requestId": request_id, + "projectId": project_id, + "userId": request_user_id, + "aiProviderId": ai_provider_id, + "model": model, + "sourceAction": source_action, + "promptAction": prompt_action, + "statuses": statuses, + "systemCredentials": system_credentials, + "isAutoTriggered": is_auto_triggered, + "tokenName": token_name, + "oauthClientId": oauth_client_id, + "createdAfter": created_after, + "createdBefore": created_before, + } + + return self.requester.request( + method="post", + path=self.get_ai_request_logs_path(user_id) + "/exports", + request_data=request_data, + ) + + def check_ai_request_logs_export_status(self, user_id: int, export_id: str): + """ + Check AI Request Logs Export Status + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.requestLogs.exports.get + """ + + return self.requester.request( + method="get", + path=self.get_ai_request_logs_path(user_id) + f"/exports/{export_id}", + ) + + def download_ai_request_logs_export(self, user_id: int, export_id: str): + """ + Download AI Request Logs Export + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.requestLogs.exports.download + """ + + return self.requester.request( + method="get", + path=self.get_ai_request_logs_path(user_id) + f"/exports/{export_id}/download", + ) + def get_ai_settings_path(self, user_id: int): return f"users/{user_id}/ai/settings" @@ -720,6 +914,68 @@ def edit_ai_settings( request_data=patches, ) + def get_ai_usage_members_path(self, user_id: int, member_id: Optional[int] = None): + if member_id is not None: + return f"users/{user_id}/ai/usage/members/{member_id}" + return f"users/{user_id}/ai/usage/members" + + def list_ai_usage_members( + self, + user_id: int, + user_ids: Optional[Iterable[int]] = None, + order_by: Optional[Sorting] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List AI Usage Members + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.usage.members.getMany + """ + + params = { + "userIds": convert_to_query_string(user_ids), + "orderBy": order_by, + } + params.update(self.get_page_params(limit=limit, offset=offset)) + + return self._get_entire_data( + method="get", + path=self.get_ai_usage_members_path(user_id), + params=params, + ) + + def get_ai_usage_member(self, user_id: int, member_id: int): + """ + Get AI Usage Member + + `member_id` is the identifier of the project member (or your own user id for own usage). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.ai.usage.members.get + """ + + return self.requester.request( + method="get", + path=self.get_ai_usage_members_path(user_id, member_id=member_id), + ) + + def get_project_ai_settings(self, projectId: Optional[int] = None): + """ + Get Project AI Settings + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.ai.settings.get + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=f"projects/{projectId}/ai/settings", + ) + def list_supported_ai_provider_models( self, user_id: int, @@ -957,7 +1213,7 @@ class EnterpriseAIResource(BaseResource): Enterprise Resource for AI. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/AI + https://support.crowdin.com/developer/enterprise/api/v2/#tag/AI """ def get_ai_path(self, aiPromptId: Optional[int] = None): @@ -981,7 +1237,7 @@ def list_ai_prompts( List AI Prompts Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.prompts.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.getMany """ params = {"projectId": projectId, "action": action} params.update(self.get_page_params(limit=limit, offset=offset)) @@ -995,7 +1251,7 @@ def add_ai_prompt(self, request_data: AddAIPromptRequestScheme): Add AI Prompt Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.prompts.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.post """ return self.requester.request( @@ -1009,7 +1265,7 @@ def get_ai_prompt(self, aiPromptId: int): Get AI Prompt Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.prompts.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.get """ return self.requester.request( @@ -1022,7 +1278,7 @@ def delete_ai_prompt(self, aiPromptId: int): Delete AI Prompt Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.prompts.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.delete """ return self.requester.request( @@ -1037,7 +1293,7 @@ def edit_ai_prompt( Edit AI Prompt Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.prompts.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.patch """ return self.requester.request( @@ -1055,7 +1311,7 @@ def list_ai_providers( List AI Providers Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.providers.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.getMany """ params = self.get_page_params(limit=limit, offset=offset) return self.requester.request( @@ -1067,7 +1323,7 @@ def add_ai_provider(self, request_data: AddAIProviderReqeustScheme): Add AI Provider Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.providers.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.post """ return self.requester.request( method="post", @@ -1080,7 +1336,7 @@ def get_ai_provider(self, aiProviderId: int): Get AI Provider Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.providers.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.get """ return self.requester.request( method="get", @@ -1092,7 +1348,7 @@ def delete_ai_provider(self, aiProviderId: int): Delete AI Provider Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.providers.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.delete """ return self.requester.request( method="delete", @@ -1106,7 +1362,7 @@ def edit_ai_provider( Edit AI Provider Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.providers.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.patch """ return self.requester.request( method="patch", @@ -1119,13 +1375,28 @@ def list_ai_provider_models(self, aiProviderId: int): List AI Provider Models Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.ai.providers.models.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.models.getMany """ return self.requester.request( method="get", path=self.get_ai_provider_path(aiProviderId=aiProviderId) + "/models", ) + def list_all_ai_provider_models(self): + """ + List AI Provider Models + + Lists the models of all AI providers available in the organization. + To list the models of a single AI provider, use `list_ai_provider_models`. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.models.enterprise.getMany + """ + return self.requester.request( + method="get", + path="ai/providers/models", + ) + def create_ai_proxy_chat_completion( self, aiProviderId: int, @@ -1138,7 +1409,7 @@ def create_ai_proxy_chat_completion( Please refer to the documentation for the specific provider you use to determine the required payload format. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.providers.chat.completions.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.providers.chat.completions.post """ return self.requester.request( method="post", @@ -1152,10 +1423,13 @@ def get_ai_custom_placeholders_path(self, ai_custom_placeholder_id: Optional[int return f"ai/settings/custom-placeholders/{ai_custom_placeholder_id}" return "ai/settings/custom-placeholders" + @deprecated("Use `list_ai_snippets` instead") def list_ai_custom_placeholders(self): """ List AI Custom Placeholders + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `list_ai_snippets` instead. + Link to documentation: https://support.crowdin.com/developer/enterprise/api/v2/#tag/AI/operation/api.ai.prompts.custom.placeholders.getMany """ @@ -1165,10 +1439,13 @@ def list_ai_custom_placeholders(self): path=self.get_ai_custom_placeholders_path() ) + @deprecated("Use `add_ai_snippet` instead") def add_ai_custom_placeholder(self, body: AddAiCustomPlaceholderRequest): """ Add AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `add_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/enterprise/api/v2/#tag/AI/operation/api.ai.settings.custom-placeholders.post """ @@ -1179,10 +1456,13 @@ def add_ai_custom_placeholder(self, body: AddAiCustomPlaceholderRequest): request_data=body, ) + @deprecated("Use `get_ai_snippet` instead") def get_ai_custom_placeholder(self, ai_custom_placeholder_id: int): """ Get AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `get_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/enterprise/api/v2/#tag/AI/operation/api.ai.settings.custom-placeholders.get """ @@ -1192,10 +1472,13 @@ def get_ai_custom_placeholder(self, ai_custom_placeholder_id: int): path=self.get_ai_custom_placeholders_path(ai_custom_placeholder_id), ) + @deprecated("Use `delete_ai_snippet` instead") def delete_ai_custom_placeholder(self, ai_custom_placeholder_id: int): """ Delete AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `delete_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/enterprise/api/v2/#tag/AI/operation/api.ai.settings.custom-placeholders.delete """ @@ -1205,6 +1488,7 @@ def delete_ai_custom_placeholder(self, ai_custom_placeholder_id: int): path=self.get_ai_custom_placeholders_path(ai_custom_placeholder_id), ) + @deprecated("Use `edit_ai_snippet` instead") def edit_ai_custom_placeholder( self, ai_custom_placeholder_id: int, @@ -1213,6 +1497,8 @@ def edit_ai_custom_placeholder( """ Edit AI Custom Placeholder + Deprecated: AI Custom Placeholders were replaced by AI Snippets, use `edit_ai_snippet` instead. + Link to documentation: https://support.crowdin.com/developer/enterprise/api/v2/#tag/AI/operation/api.ai.settings.custom-placeholders.patch """ @@ -1223,6 +1509,82 @@ def edit_ai_custom_placeholder( request_data=patches, ) + def get_ai_snippets_path(self, ai_snippet_id: Optional[int] = None): + if ai_snippet_id is not None: + return f"ai/settings/snippets/{ai_snippet_id}" + return "ai/settings/snippets" + + def list_ai_snippets( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List AI Snippets + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.snippets.getMany + """ + return self._get_entire_data( + method="get", + path=self.get_ai_snippets_path(), + params=self.get_page_params(limit=limit, offset=offset), + ) + + def add_ai_snippet(self, request_data: AddAiSnippetRequest): + """ + Add AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.snippets.post + """ + return self.requester.request( + method="post", + path=self.get_ai_snippets_path(), + request_data=request_data, + ) + + def get_ai_snippet(self, ai_snippet_id: int): + """ + Get AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.snippets.get + """ + return self.requester.request( + method="get", + path=self.get_ai_snippets_path(ai_snippet_id=ai_snippet_id), + ) + + def delete_ai_snippet(self, ai_snippet_id: int): + """ + Delete AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.snippets.delete + """ + return self.requester.request( + method="delete", + path=self.get_ai_snippets_path(ai_snippet_id=ai_snippet_id), + ) + + def edit_ai_snippet( + self, + ai_snippet_id: int, + patches: Iterable[EditAiSnippetPatch], + ): + """ + Edit AI Snippet + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.prompts.snippets.patch + """ + return self.requester.request( + method="patch", + path=self.get_ai_snippets_path(ai_snippet_id=ai_snippet_id), + request_data=patches, + ) + def get_ai_prompt_fine_tuning_datasets_path( self, ai_prompt_id: int, @@ -1550,7 +1912,7 @@ def list_ai_request_logs( List AI Request Logs Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.ai.requestLogs.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.requestLogs.getMany """ params = { @@ -1577,6 +1939,85 @@ def list_ai_request_logs( params=params, ) + def export_ai_request_logs( + self, + export_format: Optional[AiRequestLogExportFormat] = None, + request_id: Optional[str] = None, + project_id: Optional[int] = None, + request_user_id: Optional[int] = None, + ai_provider_id: Optional[int] = None, + model: Optional[str] = None, + source_action: Optional[AiRequestLogSourceAction] = None, + prompt_action: Optional[str] = None, + statuses: Optional[Iterable[AiRequestLogStatus]] = None, + system_credentials: Optional[bool] = None, + is_auto_triggered: Optional[bool] = None, + token_name: Optional[str] = None, + oauth_client_id: Optional[str] = None, + created_after: Optional[datetime] = None, + created_before: Optional[datetime] = None, + ): + """ + Export AI Request Logs + + Starts an asynchronous export of the AI request logs matching the filters. + Poll `check_ai_request_logs_export_status` until the status is `finished`, + then call `download_ai_request_logs_export`. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.requestLogs.exports.post + """ + + request_data = { + "format": export_format, + "requestId": request_id, + "projectId": project_id, + "userId": request_user_id, + "aiProviderId": ai_provider_id, + "model": model, + "sourceAction": source_action, + "promptAction": prompt_action, + "statuses": statuses, + "systemCredentials": system_credentials, + "isAutoTriggered": is_auto_triggered, + "tokenName": token_name, + "oauthClientId": oauth_client_id, + "createdAfter": created_after, + "createdBefore": created_before, + } + + return self.requester.request( + method="post", + path=self.get_ai_request_logs_path() + "/exports", + request_data=request_data, + ) + + def check_ai_request_logs_export_status(self, export_id: str): + """ + Check AI Request Logs Export Status + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.requestLogs.exports.get + """ + + return self.requester.request( + method="get", + path=self.get_ai_request_logs_path() + f"/exports/{export_id}", + ) + + def download_ai_request_logs_export(self, export_id: str): + """ + Download AI Request Logs Export + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.requestLogs.exports.download + """ + + return self.requester.request( + method="get", + path=self.get_ai_request_logs_path() + f"/exports/{export_id}/download", + ) + def get_ai_settings(self): """ Get AI Settings @@ -1607,6 +2048,67 @@ def edit_ai_settings( request_data=patches, ) + def get_ai_usage_members_path(self, member_id: Optional[int] = None): + if member_id is not None: + return f"ai/usage/members/{member_id}" + return "ai/usage/members" + + def list_ai_usage_members( + self, + user_ids: Optional[Iterable[int]] = None, + order_by: Optional[Sorting] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List AI Usage Members + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.usage.members.getMany + """ + + params = { + "userIds": convert_to_query_string(user_ids), + "orderBy": order_by, + } + params.update(self.get_page_params(limit=limit, offset=offset)) + + return self._get_entire_data( + method="get", + path=self.get_ai_usage_members_path(), + params=params, + ) + + def get_ai_usage_member(self, member_id: int): + """ + Get AI Usage Member + + `member_id` is the identifier of the organization user. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.ai.usage.members.get + """ + + return self.requester.request( + method="get", + path=self.get_ai_usage_members_path(member_id=member_id), + ) + + def get_project_ai_settings(self, projectId: Optional[int] = None): + """ + Get Project AI Settings + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.ai.settings.get + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=f"projects/{projectId}/ai/settings", + ) + def list_supported_ai_provider_models( self, limit: Optional[int] = None, diff --git a/crowdin_api/api_resources/ai/tests/test_ai_resources.py b/crowdin_api/api_resources/ai/tests/test_ai_resources.py index 5c467b82..71978252 100644 --- a/crowdin_api/api_resources/ai/tests/test_ai_resources.py +++ b/crowdin_api/api_resources/ai/tests/test_ai_resources.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from enum import Enum from unittest import mock import pytest @@ -10,9 +11,11 @@ AIProviderType, AiReportFormat, AiToolType, + AiRequestLogExportFormat, DatasetPurpose, EditAiCustomPlaceholderPatchPath, EditAiSettingsPatchPath, + EditAiSnippetPatchPath, ListAiPromptFineTuningJobsOrderBy, ListSupportedAiModelsOrderBy, ) @@ -42,6 +45,13 @@ from crowdin_api.sorting import Sorting, SortingOrder, SortingRule +class _UsageMembersOrderBy(Enum): + MONTHLY_COST_SPENT = "monthlyCostSpent" + + +ORDER_BY = Sorting([SortingRule(_UsageMembersOrderBy.MONTHLY_COST_SPENT, SortingOrder.DESC)]) + + class TestAIResources: resource_class = AIResource @@ -742,7 +752,8 @@ def test_list_ai_custom_placeholders(self, m_request, base_absolut_url): user_id = 1 resource = self.get_resource(base_absolut_url) - assert resource.list_ai_custom_placeholders(user_id) == "response" + with pytest.warns(DeprecationWarning): + assert resource.list_ai_custom_placeholders(user_id) == "response" m_request.assert_called_once_with( method="get", @@ -773,7 +784,8 @@ def test_add_ai_custom_placeholder(self, m_request, incoming_data, request_param user_id = 1 resource = self.get_resource(base_absolut_url) - assert resource.add_ai_custom_placeholder(user_id, incoming_data) == "response" + with pytest.warns(DeprecationWarning): + assert resource.add_ai_custom_placeholder(user_id, incoming_data) == "response" m_request.assert_called_once_with( method="post", @@ -789,7 +801,8 @@ def test_get_ai_custom_placeholder(self, m_request, base_absolut_url): ai_custom_placeholder_id = 2 resource = self.get_resource(base_absolut_url) - assert resource.get_ai_custom_placeholder(user_id, ai_custom_placeholder_id) == "response" + with pytest.warns(DeprecationWarning): + assert resource.get_ai_custom_placeholder(user_id, ai_custom_placeholder_id) == "response" m_request.assert_called_once_with( method="get", @@ -804,7 +817,8 @@ def test_delete_ai_custom_placeholder(self, m_request, base_absolut_url): ai_custom_placeholder_id = 2 resource = self.get_resource(base_absolut_url) - assert resource.delete_ai_custom_placeholder(user_id, ai_custom_placeholder_id) == "response" + with pytest.warns(DeprecationWarning): + assert resource.delete_ai_custom_placeholder(user_id, ai_custom_placeholder_id) == "response" m_request.assert_called_once_with( method="delete", @@ -850,7 +864,8 @@ def test_edit_ai_custom_placeholder(self, m_request, incoming_data, request_para ai_custom_placeholder_id = 2 resource = self.get_resource(base_absolut_url) - assert resource.edit_ai_custom_placeholder(user_id, ai_custom_placeholder_id, incoming_data) == "response" + with pytest.warns(DeprecationWarning): + assert resource.edit_ai_custom_placeholder(user_id, ai_custom_placeholder_id, incoming_data) == "response" m_request.assert_called_once_with( method="patch", @@ -1251,6 +1266,40 @@ def test_get_ai_settings(self, m_request, base_absolut_url): } ], ), + ( + [ + { + "op": PatchOperation.REPLACE, + "path": EditAiSettingsPatchPath.MONTHLY_COST_LIMIT, + "value": 100, + }, + { + "op": PatchOperation.REPLACE, + "path": EditAiSettingsPatchPath.PER_USER_OVERRIDE_DAILY_COST_LIMIT.value.format(userId=12), + "value": 5, + }, + { + "op": PatchOperation.REMOVE, + "path": EditAiSettingsPatchPath.PER_USER_OVERRIDE.value.format(userId=13), + }, + ], + [ + { + "op": PatchOperation.REPLACE, + "path": EditAiSettingsPatchPath.MONTHLY_COST_LIMIT, + "value": 100, + }, + { + "op": PatchOperation.REPLACE, + "path": "/perUserOverrides/12/dailyCostLimit", + "value": 5, + }, + { + "op": PatchOperation.REMOVE, + "path": "/perUserOverrides/13", + }, + ], + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -1656,6 +1705,268 @@ def test_edit_ai_provider_gateway_no_body(self, m_request, base_absolut_url): request_data=None, ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_all_ai_provider_models(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_all_ai_provider_models(1) == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/ai/providers/models", + ) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"user_id": 1}, "users/1/ai/settings/snippets"), + ({"user_id": 1, "ai_snippet_id": 2}, "users/1/ai/settings/snippets/2"), + ), + ) + def test_get_ai_snippets_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_snippets_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"limit": 25, "offset": 0}), + ({"limit": 10, "offset": 5}, {"limit": 10, "offset": 5}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_ai_snippets(self, m_request, in_params, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_ai_snippets(1, **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/ai/settings/snippets", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + request_data = { + "description": "Product description", + "placeholder": "%custom:productDescription%", + "value": "The product is the professional consulting service", + } + resource = self.get_resource(base_absolut_url) + assert resource.add_ai_snippet(1, request_data) == "response" + m_request.assert_called_once_with( + method="post", + path="users/1/ai/settings/snippets", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_snippet(1, 2) == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/ai/settings/snippets/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_ai_snippet(1, 2) == "response" + m_request.assert_called_once_with( + method="delete", + path="users/1/ai/settings/snippets/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + patches = [ + { + "op": PatchOperation.REPLACE, + "path": EditAiSnippetPatchPath.VALUE, + "value": "New value", + } + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_ai_snippet(1, 2, patches) == "response" + m_request.assert_called_once_with( + method="patch", + path="users/1/ai/settings/snippets/2", + request_data=patches, + ) + + @pytest.mark.parametrize( + "incoming_data, request_data", + ( + ( + {}, + { + "format": None, + "requestId": None, + "projectId": None, + "userId": None, + "aiProviderId": None, + "model": None, + "sourceAction": None, + "promptAction": None, + "statuses": None, + "systemCredentials": None, + "isAutoTriggered": None, + "tokenName": None, + "oauthClientId": None, + "createdAfter": None, + "createdBefore": None, + }, + ), + ( + { + "export_format": AiRequestLogExportFormat.CSV, + "request_id": "9d3b1c4e-2f3a-4b5c-8d6e-7f8a9b0c1d2e", + "project_id": 8, + "request_user_id": 42, + "ai_provider_id": 3, + "model": "gpt-5.6-sol", + "source_action": AiRequestLogSourceAction.AI_GATEWAY, + "prompt_action": "qa_check", + "statuses": [AiRequestLogStatus.SUCCESS, AiRequestLogStatus.ERROR], + "system_credentials": True, + "is_auto_triggered": False, + "token_name": "Token name", + "oauth_client_id": "gpbccUFxAKZDrLm5Nq8t", + "created_after": datetime(2026, 7, 1, tzinfo=timezone.utc), + "created_before": datetime(2026, 7, 31, tzinfo=timezone.utc), + }, + { + "format": AiRequestLogExportFormat.CSV, + "requestId": "9d3b1c4e-2f3a-4b5c-8d6e-7f8a9b0c1d2e", + "projectId": 8, + "userId": 42, + "aiProviderId": 3, + "model": "gpt-5.6-sol", + "sourceAction": AiRequestLogSourceAction.AI_GATEWAY, + "promptAction": "qa_check", + "statuses": [AiRequestLogStatus.SUCCESS, AiRequestLogStatus.ERROR], + "systemCredentials": True, + "isAutoTriggered": False, + "tokenName": "Token name", + "oauthClientId": "gpbccUFxAKZDrLm5Nq8t", + "createdAfter": datetime(2026, 7, 1, tzinfo=timezone.utc), + "createdBefore": datetime(2026, 7, 31, tzinfo=timezone.utc), + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_export_ai_request_logs(self, m_request, incoming_data, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.export_ai_request_logs(1, **incoming_data) == "response" + m_request.assert_called_once_with( + method="post", + path="users/1/ai/request-logs/exports", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_ai_request_logs_export_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.check_ai_request_logs_export_status(1, "abc") == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/ai/request-logs/exports/abc", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_download_ai_request_logs_export(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.download_ai_request_logs_export(1, "abc") == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/ai/request-logs/exports/abc/download", + ) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"user_id": 1}, "users/1/ai/usage/members"), + ({"user_id": 1, "member_id": 2}, "users/1/ai/usage/members/2"), + ), + ) + def test_get_ai_usage_members_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_usage_members_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"userIds": None, "orderBy": None, "limit": 25, "offset": 0}), + ( + {"user_ids": [1, 2], "order_by": ORDER_BY, "limit": 10, "offset": 5}, + {"userIds": "1,2", "orderBy": ORDER_BY, "limit": 10, "offset": 5}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_ai_usage_members(self, m_request, in_params, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_ai_usage_members(1, **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/ai/usage/members", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_ai_usage_member(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_usage_member(1, 2) == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/ai/usage/members/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_project_ai_settings(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_project_ai_settings(projectId=5) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/5/ai/settings", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_project_ai_settings_default_project(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.resource_class( + requester=APIRequester(base_url=base_absolut_url), project_id=7 + ) + assert resource.get_project_ai_settings() == "response" + m_request.assert_called_once_with( + method="get", + path="projects/7/ai/settings", + ) + class TestEnterpriseAIResources: resource_class = EnterpriseAIResource @@ -2302,7 +2613,8 @@ def test_list_ai_custom_placeholders(self, m_request, base_absolut_url): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - assert resource.list_ai_custom_placeholders() == "response" + with pytest.warns(DeprecationWarning): + assert resource.list_ai_custom_placeholders() == "response" m_request.assert_called_once_with( method="get", @@ -2331,7 +2643,8 @@ def test_add_ai_custom_placeholder(self, m_request, incoming_data, request_param m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - assert resource.add_ai_custom_placeholder(incoming_data) == "response" + with pytest.warns(DeprecationWarning): + assert resource.add_ai_custom_placeholder(incoming_data) == "response" m_request.assert_called_once_with( method="post", @@ -2346,7 +2659,8 @@ def test_get_ai_custom_placeholder(self, m_request, base_absolut_url): ai_custom_placeholder_id = 1 resource = self.get_resource(base_absolut_url) - assert resource.get_ai_custom_placeholder(ai_custom_placeholder_id) == "response" + with pytest.warns(DeprecationWarning): + assert resource.get_ai_custom_placeholder(ai_custom_placeholder_id) == "response" m_request.assert_called_once_with( method="get", @@ -2360,7 +2674,8 @@ def test_delete_ai_custom_placeholder(self, m_request, base_absolut_url): ai_custom_placeholder_id = 1 resource = self.get_resource(base_absolut_url) - assert resource.delete_ai_custom_placeholder(ai_custom_placeholder_id) == "response" + with pytest.warns(DeprecationWarning): + assert resource.delete_ai_custom_placeholder(ai_custom_placeholder_id) == "response" m_request.assert_called_once_with( method="delete", @@ -2405,7 +2720,8 @@ def test_edit_ai_custom_placeholder(self, m_request, incoming_data, request_para ai_custom_placeholder_id = 1 resource = self.get_resource(base_absolut_url) - assert resource.edit_ai_custom_placeholder(ai_custom_placeholder_id, incoming_data) == "response" + with pytest.warns(DeprecationWarning): + assert resource.edit_ai_custom_placeholder(ai_custom_placeholder_id, incoming_data) == "response" m_request.assert_called_once_with( method="patch", @@ -2794,6 +3110,40 @@ def test_get_ai_settings(self, m_request, base_absolut_url): } ], ), + ( + [ + { + "op": PatchOperation.REPLACE, + "path": EditAiSettingsPatchPath.MONTHLY_COST_LIMIT, + "value": 100, + }, + { + "op": PatchOperation.REPLACE, + "path": EditAiSettingsPatchPath.PER_USER_OVERRIDE_DAILY_COST_LIMIT.value.format(userId=12), + "value": 5, + }, + { + "op": PatchOperation.REMOVE, + "path": EditAiSettingsPatchPath.PER_USER_OVERRIDE.value.format(userId=13), + }, + ], + [ + { + "op": PatchOperation.REPLACE, + "path": EditAiSettingsPatchPath.MONTHLY_COST_LIMIT, + "value": 100, + }, + { + "op": PatchOperation.REPLACE, + "path": "/perUserOverrides/12/dailyCostLimit", + "value": 5, + }, + { + "op": PatchOperation.REMOVE, + "path": "/perUserOverrides/13", + }, + ], + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -3175,3 +3525,265 @@ def test_edit_ai_provider_gateway_no_body(self, m_request, base_absolut_url): path=resource.get_ai_provider_gateway_path(ai_provider_id, path), request_data=None, ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_all_ai_provider_models(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_all_ai_provider_models() == "response" + m_request.assert_called_once_with( + method="get", + path="ai/providers/models", + ) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({}, "ai/settings/snippets"), + ({"ai_snippet_id": 2}, "ai/settings/snippets/2"), + ), + ) + def test_get_ai_snippets_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_snippets_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"limit": 25, "offset": 0}), + ({"limit": 10, "offset": 5}, {"limit": 10, "offset": 5}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_ai_snippets(self, m_request, in_params, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_ai_snippets(**in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="ai/settings/snippets", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + request_data = { + "description": "Product description", + "placeholder": "%custom:productDescription%", + "value": "The product is the professional consulting service", + } + resource = self.get_resource(base_absolut_url) + assert resource.add_ai_snippet(request_data) == "response" + m_request.assert_called_once_with( + method="post", + path="ai/settings/snippets", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_snippet(2) == "response" + m_request.assert_called_once_with( + method="get", + path="ai/settings/snippets/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_ai_snippet(2) == "response" + m_request.assert_called_once_with( + method="delete", + path="ai/settings/snippets/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_ai_snippet(self, m_request, base_absolut_url): + m_request.return_value = "response" + + patches = [ + { + "op": PatchOperation.REPLACE, + "path": EditAiSnippetPatchPath.VALUE, + "value": "New value", + } + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_ai_snippet(2, patches) == "response" + m_request.assert_called_once_with( + method="patch", + path="ai/settings/snippets/2", + request_data=patches, + ) + + @pytest.mark.parametrize( + "incoming_data, request_data", + ( + ( + {}, + { + "format": None, + "requestId": None, + "projectId": None, + "userId": None, + "aiProviderId": None, + "model": None, + "sourceAction": None, + "promptAction": None, + "statuses": None, + "systemCredentials": None, + "isAutoTriggered": None, + "tokenName": None, + "oauthClientId": None, + "createdAfter": None, + "createdBefore": None, + }, + ), + ( + { + "export_format": AiRequestLogExportFormat.CSV, + "request_id": "9d3b1c4e-2f3a-4b5c-8d6e-7f8a9b0c1d2e", + "project_id": 8, + "request_user_id": 42, + "ai_provider_id": 3, + "model": "gpt-5.6-sol", + "source_action": AiRequestLogSourceAction.AI_GATEWAY, + "prompt_action": "qa_check", + "statuses": [AiRequestLogStatus.SUCCESS, AiRequestLogStatus.ERROR], + "system_credentials": True, + "is_auto_triggered": False, + "token_name": "Token name", + "oauth_client_id": "gpbccUFxAKZDrLm5Nq8t", + "created_after": datetime(2026, 7, 1, tzinfo=timezone.utc), + "created_before": datetime(2026, 7, 31, tzinfo=timezone.utc), + }, + { + "format": AiRequestLogExportFormat.CSV, + "requestId": "9d3b1c4e-2f3a-4b5c-8d6e-7f8a9b0c1d2e", + "projectId": 8, + "userId": 42, + "aiProviderId": 3, + "model": "gpt-5.6-sol", + "sourceAction": AiRequestLogSourceAction.AI_GATEWAY, + "promptAction": "qa_check", + "statuses": [AiRequestLogStatus.SUCCESS, AiRequestLogStatus.ERROR], + "systemCredentials": True, + "isAutoTriggered": False, + "tokenName": "Token name", + "oauthClientId": "gpbccUFxAKZDrLm5Nq8t", + "createdAfter": datetime(2026, 7, 1, tzinfo=timezone.utc), + "createdBefore": datetime(2026, 7, 31, tzinfo=timezone.utc), + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_export_ai_request_logs(self, m_request, incoming_data, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.export_ai_request_logs(**incoming_data) == "response" + m_request.assert_called_once_with( + method="post", + path="ai/request-logs/exports", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_ai_request_logs_export_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.check_ai_request_logs_export_status("abc") == "response" + m_request.assert_called_once_with( + method="get", + path="ai/request-logs/exports/abc", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_download_ai_request_logs_export(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.download_ai_request_logs_export("abc") == "response" + m_request.assert_called_once_with( + method="get", + path="ai/request-logs/exports/abc/download", + ) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({}, "ai/usage/members"), + ({"member_id": 2}, "ai/usage/members/2"), + ), + ) + def test_get_ai_usage_members_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_usage_members_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"userIds": None, "orderBy": None, "limit": 25, "offset": 0}), + ( + {"user_ids": [1, 2], "order_by": ORDER_BY, "limit": 10, "offset": 5}, + {"userIds": "1,2", "orderBy": ORDER_BY, "limit": 10, "offset": 5}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_ai_usage_members(self, m_request, in_params, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_ai_usage_members(**in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="ai/usage/members", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_ai_usage_member(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_ai_usage_member(2) == "response" + m_request.assert_called_once_with( + method="get", + path="ai/usage/members/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_project_ai_settings(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_project_ai_settings(projectId=5) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/5/ai/settings", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_project_ai_settings_default_project(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.resource_class( + requester=APIRequester(base_url=base_absolut_url), project_id=7 + ) + assert resource.get_project_ai_settings() == "response" + m_request.assert_called_once_with( + method="get", + path="projects/7/ai/settings", + ) diff --git a/crowdin_api/api_resources/ai/types.py b/crowdin_api/api_resources/ai/types.py index d155ba31..c5db8a58 100644 --- a/crowdin_api/api_resources/ai/types.py +++ b/crowdin_api/api_resources/ai/types.py @@ -6,6 +6,7 @@ AIProviderType, AiToolType, EditAiCustomPlaceholderPatchPath, + EditAiSnippetPatchPath, EditAIPromptPath, EditAIProviderPath, EditAiSettingsPatchPath, @@ -16,38 +17,64 @@ class OtherLanguageTranslation(TypedDict): isEnabled: Optional[bool] - languageIds: Optional[Iterable[int]] + # Language identifiers, e.g. "uk", "de". + languageIds: Optional[Iterable[Union[str, int]]] class BasicModePreTranslateActionConfig(TypedDict): mode: str - companyDescription: Optional[bool] - projectDescription: Optional[bool] - audienceDescription: Optional[bool] + # Deprecated. + companyDescription: Optional[Union[str, bool]] + # Deprecated. + projectDescription: Optional[Union[str, bool]] + # Deprecated. + audienceDescription: Optional[Union[str, bool]] + # Deprecated: use `snippets` instead. + customPlaceholders: Optional[Iterable[str]] + snippets: Optional[Iterable[str]] + # Deprecated: misspelled key, use `otherLanguageTranslations` instead. otherLanguageTranslation: Optional[OtherLanguageTranslation] + otherLanguageTranslations: Optional[OtherLanguageTranslation] glossaryTerms: Optional[bool] tmSuggestions: Optional[bool] + # Deprecated. fileContent: Optional[bool] fileContext: Optional[bool] + generateFileSummary: Optional[bool] screenshots: Optional[bool] # Deprecated: use `projectContext` instead. publicProjectDescription: Optional[bool] projectContext: Optional[bool] + # Crowdin Enterprise only. organizationContext: Optional[bool] + siblingsStrings: Optional[bool] + retryOnQaIssues: Optional[bool] class BasicModeAlignmentActionConfig(TypedDict): mode: str + # Deprecated. + companyDescription: Optional[str] + # Deprecated. + projectDescription: Optional[str] + # Deprecated. + audienceDescription: Optional[str] + # Deprecated: use `snippets` instead. customPlaceholders: Optional[Iterable[str]] + snippets: Optional[Iterable[str]] # Deprecated: use `projectContext` instead. publicProjectDescription: Optional[bool] projectContext: Optional[bool] + # Crowdin Enterprise only. organizationContext: Optional[bool] class BasicModeQaCheckActionConfig(TypedDict): mode: str evaluationSteps: Iterable[str] + # Deprecated: use `snippets` instead. + customPlaceholders: Optional[Iterable[str]] + snippets: Optional[Iterable[str]] glossaryTerms: Optional[bool] tmSuggestions: Optional[bool] fileContext: Optional[bool] @@ -55,20 +82,29 @@ class BasicModeQaCheckActionConfig(TypedDict): # Deprecated: use `projectContext` instead. publicProjectDescription: Optional[bool] projectContext: Optional[bool] + # Crowdin Enterprise only. organizationContext: Optional[bool] class AdvancedModeConfig(TypedDict): mode: str + generateFileSummary: Optional[bool] + glossaryTerms: Optional[bool] + tmSuggestions: Optional[bool] screenshots: Optional[bool] prompt: str + otherLanguageTranslations: Optional[OtherLanguageTranslation] + retryOnQaIssues: Optional[bool] class ExternalMode(TypedDict): + mode: str + # Deprecated: not part of the API schema. name: str identifier: str key: str options: Dict + retryOnQaIssues: Optional[bool] class AddAIPromptRequestScheme(TypedDict): @@ -76,6 +112,7 @@ class AddAIPromptRequestScheme(TypedDict): action: AIPromptAction aiProviderId: int aiModelId: str + # Deprecated. isEnabled: Optional[bool] enabledProjectIds: Optional[Iterable[int]] config: Union[ @@ -95,6 +132,9 @@ class EditAIPromptScheme(TypedDict): class OpenAICredential(TypedDict): apiKey: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] class AzureOpenAICredential(TypedDict): @@ -102,20 +142,78 @@ class AzureOpenAICredential(TypedDict): apiKey: str deploymentName: str apiVersion: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] class GoogleGeminiCredential(TypedDict): + """Google Gemini (Vertex AI) credentials.""" + project: str region: str - serviceAccountKey: Dict + serviceAccountKey: Optional[Dict] + workloadIdentityFederationAudience: Optional[str] + serviceAccountEmail: Optional[str] + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] + + +class GoogleGeminiAIStudioCredential(TypedDict): + apiKey: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] class MistralAICredential(TypedDict): apiKey: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] class AnthropicCredential(TypedDict): apiKey: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] + + +class XAICredential(TypedDict): + apiKey: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] + + +class WatsonxCredential(TypedDict): + apiKey: str + projectId: str + region: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] + + +class DeepSeekCredential(TypedDict): + apiKey: str + baseUrl: Optional[str] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] + + +class MicrosoftFoundryDeployment(TypedDict): + deploymentName: str + targetUri: str + apiKey: str + + +class MicrosoftFoundryCredential(TypedDict): + deployments: Iterable[MicrosoftFoundryDeployment] + headers: Optional[Dict[str, str]] + sendCustomHeadersOnly: Optional[bool] class CustomAICredential(TypedDict): @@ -125,7 +223,7 @@ class CustomAICredential(TypedDict): class ActionRule(TypedDict): action: AIPromptAction - availableAiModelIds: Iterable[int] + availableAiModelIds: Iterable[Union[str, int]] class ActionRules(TypedDict): @@ -140,8 +238,13 @@ class AddAIProviderReqeustScheme(TypedDict): OpenAICredential, AzureOpenAICredential, GoogleGeminiCredential, + GoogleGeminiAIStudioCredential, MistralAICredential, AnthropicCredential, + XAICredential, + WatsonxCredential, + DeepSeekCredential, + MicrosoftFoundryCredential, CustomAICredential, ] ] @@ -209,6 +312,18 @@ class CreateAIPromptFineTuningJobRequest(TypedDict): validationOptions: Optional[ValidationOptions] +class AddAiSnippetRequest(TypedDict): + description: str + placeholder: str + value: str + + +class EditAiSnippetPatch(TypedDict): + op: PatchOperation + path: EditAiSnippetPatchPath + value: Any + + class AddAiCustomPlaceholderRequest(TypedDict): description: str placeholder: str diff --git a/crowdin_api/api_resources/application/enums.py b/crowdin_api/api_resources/application/enums.py index 649e03e8..433bfc58 100644 --- a/crowdin_api/api_resources/application/enums.py +++ b/crowdin_api/api_resources/application/enums.py @@ -27,3 +27,28 @@ class ApplicationConsentStatus(Enum): class ApplicationConsentPatchPath(Enum): STATUS = "/status" SCOPES = "/scopes" + + +class ApplicationInstallationPatchPath(Enum): + PERMISSIONS = "/permissions" + MANIFEST = "/manifest" + + +class ApplicationManifestEnvironment(Enum): + CROWDIN = "crowdin" + CROWDIN_ENTERPRISE = "crowdin-enterprise" + + +class ApplicationBundleMode(Enum): + INTERNAL = "internal" + EXTERNAL = "external" + + +class ApplicationKVRecordPatchPath(Enum): + VALUE = "/value" + TTL = "/ttl" + + +class IntegrationSyncProvider(Enum): + CROWDIN = "crowdin" + INTEGRATION = "integration" diff --git a/crowdin_api/api_resources/application/resource.py b/crowdin_api/api_resources/application/resource.py index ef4a9625..c5e61b04 100644 --- a/crowdin_api/api_resources/application/resource.py +++ b/crowdin_api/api_resources/application/resource.py @@ -1,11 +1,19 @@ -from typing import Iterable, Optional +from typing import Any, Dict, Iterable, Optional, Union +from urllib.parse import quote -from crowdin_api.parser import dumps +from deprecated import deprecated + +from crowdin_api.api_resources.application.enums import IntegrationSyncProvider from crowdin_api.api_resources.application.types import ( AddApplicationConsentRequest, ApplicationConsentPatchRequest, + ApplicationKVRecordPatchRequest, + ApplicationManifest, + ApplicationModulePermissions, ApplicationPermissions, ApplicationInstallationPatchRequest, + IntegrationCrowdinUpdateFile, + IntegrationSyncSettingsFile, ) from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.sorting import Sorting @@ -18,10 +26,10 @@ class ApplicationResource(BaseResource): Use the API to manage the necessary app data. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Applications + https://support.crowdin.com/developer/api/v2/#tag/Applications Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#tag/Applications + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Applications """ def get_application_path(self, applicationIdentifier: str, path: str): @@ -36,29 +44,61 @@ def list_application_installations( self, offset: Optional[int] = None, limit: Optional[int] = None, + installedBy: Optional[int] = None, + orderBy: Optional[Sorting] = None, ): """ List Application Installations - Link to documentaion: - https://developer.crowdin.com/api/v2/#operation/api.applications.installations.getMany + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.getMany """ - return self.requester.request( + params = {"installedBy": installedBy, "orderBy": orderBy} + params.update(self.get_page_params(offset=offset, limit=limit)) + + return self._get_entire_data( method="get", path=self.get_application_installations_path(), - params=self.get_page_params(offset=offset, limit=limit), + params=params, ) def install_application( - self, url: str, permissions: Optional[ApplicationPermissions] = None + self, + url: Optional[str] = None, + permissions: Optional[ApplicationPermissions] = None, + manifest: Optional[ApplicationManifest] = None, + modules: Optional[Iterable[ApplicationModulePermissions]] = None, + assignAgent: Optional[bool] = None, ): """ Install Application + Install an application either from a hosted manifest URL (`url`) or from its + manifest content (`manifest`, serverless apps only). Exactly one of `url` or + `manifest` must be provided. + + `permissions.user` is deprecated by the API, use `modules` permissions instead. + `assignAgent` is supported only when installing from a manifest URL. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.installations.post + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.post """ - request_data = {"url": url, "permissions": permissions} + if (url is None) == (manifest is None): + raise ValueError("You must set either url or manifest.") + + request_data = { + "url": url, + "manifest": manifest, + "permissions": permissions, + "modules": modules, + "assignAgent": assignAgent, + } return self.requester.request( method="post", path=self.get_application_installations_path(), @@ -70,7 +110,10 @@ def get_application_installation(self, identifier: str): Get Application Installation Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.installations.get + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.get """ return self.requester.request( method="get", @@ -81,10 +124,13 @@ def delete_application_installation( self, identifier: str, force: Optional[bool] = None ): """ - Delete Applcation Installation + Delete Application Installation Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.installations.delete + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.delete + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.delete """ params = {"force": force} @@ -94,11 +140,20 @@ def delete_application_installation( params=params, ) - def edit_applicatoin_installation( + def edit_application_installation( self, identifier: str, data: Iterable[ApplicationInstallationPatchRequest] ): """ Edit Application Installation + + Supported paths: `/permissions`, `/modules/{moduleKey}/permissions` and `/manifest` + (the latter only for applications installed from manifest content). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.patch + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.patch """ return self.requester.request( method="patch", @@ -106,6 +161,73 @@ def edit_applicatoin_installation( request_data=data, ) + @deprecated("Use `edit_application_installation` instead") + def edit_applicatoin_installation( + self, identifier: str, data: Iterable[ApplicationInstallationPatchRequest] + ): + """ + Edit Application Installation + + Deprecated: use `edit_application_installation` instead. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.patch + """ + return self.edit_application_installation(identifier=identifier, data=data) + + def upload_application_bundle(self, identifier: str, storageId: int): + """ + Upload Application Bundle + + Upload a bundle archive (ZIP with a non-empty `app.js` at its root) for a + serverless app installed from manifest content. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.bundles.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.bundles.post + """ + return self.requester.request( + method="post", + path=f"{self.get_application_installations_path(identifier=identifier)}/bundles", + request_data={"storageId": storageId}, + ) + + def get_application_installation_update(self, identifier: str): + """ + Get Application Installation Update + + Returns the diff between the installed application and the latest cached manifest, + tagged with `manifestHash`. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.update.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.update.get + """ + return self.requester.request( + method="get", + path=f"{self.get_application_installations_path(identifier=identifier)}/update", + ) + + def apply_application_installation_update(self, identifier: str, manifestHash: str): + """ + Apply Application Installation Update + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.installations.update.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.installations.update.post + """ + return self.requester.request( + method="post", + path=f"{self.get_application_installations_path(identifier=identifier)}/update", + request_data={"manifestHash": manifestHash}, + ) + def get_application_consents_path(self, consent_id: Optional[int] = None): if consent_id is not None: return f"applications/consents/{consent_id}" @@ -124,7 +246,7 @@ def list_application_consents( Available for Crowdin.com only (not supported in Crowdin Enterprise). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.consents.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.applications.consents.getMany """ params = { "identifier": identifier, @@ -145,7 +267,7 @@ def add_application_consent(self, request_data: AddApplicationConsentRequest): Available for Crowdin.com only (not supported in Crowdin Enterprise). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.consents.post + https://support.crowdin.com/developer/api/v2/#operation/api.applications.consents.post """ return self.requester.request( method="post", @@ -164,7 +286,7 @@ def edit_application_consent( Available for Crowdin.com only (not supported in Crowdin Enterprise). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.consents.patch + https://support.crowdin.com/developer/api/v2/#operation/api.applications.consents.patch """ return self.requester.request( method="patch", @@ -179,95 +301,602 @@ def delete_application_consent(self, consent_id: int): Available for Crowdin.com only (not supported in Crowdin Enterprise). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.consents.delete + https://support.crowdin.com/developer/api/v2/#operation/api.applications.consents.delete """ return self.requester.request( method="delete", path=self.get_application_consents_path(consent_id=consent_id), ) - def get_application_data(self, applicationIdentifier: str, path: str): + def get_application_kv_records_path( + self, applicationIdentifier: str, key: Optional[str] = None + ): + path = f"applications/{applicationIdentifier}/storage/kv/records" + if key is not None: + return f"{path}/{quote(key, safe='')}" + return path + + def list_application_kv_records( + self, + applicationIdentifier: str, + prefix: Optional[str] = None, + orderBy: Optional[Sorting] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List Application KV Records + + Requires the application's own access token (personal access tokens are not supported). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.storage.kv.records.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.storage.kv.records.getMany + """ + params = {"prefix": prefix, "orderBy": orderBy} + params.update(self.get_page_params(offset=offset, limit=limit)) + + return self._get_entire_data( + method="get", + path=self.get_application_kv_records_path(applicationIdentifier), + params=params, + ) + + def add_application_kv_record( + self, + applicationIdentifier: str, + key: str, + value: Any, + secret: Optional[bool] = None, + ttl: Optional[int] = None, + ): + """ + Add Application KV Record + + Requires the application's own access token (personal access tokens are not supported). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.storage.kv.records.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.storage.kv.records.post + """ + return self.requester.request( + method="post", + path=self.get_application_kv_records_path(applicationIdentifier), + request_data={"key": key, "value": value, "secret": secret, "ttl": ttl}, + ) + + def get_application_kv_record(self, applicationIdentifier: str, key: str): + """ + Get Application KV Record + + Requires the application's own access token (personal access tokens are not supported). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.storage.kv.records.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.storage.kv.records.get + """ + return self.requester.request( + method="get", + path=self.get_application_kv_records_path(applicationIdentifier, key), + ) + + def edit_application_kv_record( + self, + applicationIdentifier: str, + key: str, + data: Iterable[ApplicationKVRecordPatchRequest], + ): + """ + Edit Application KV Record + + Requires the application's own access token (personal access tokens are not supported). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.storage.kv.records.patch + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.storage.kv.records.patch + """ + return self.requester.request( + method="patch", + path=self.get_application_kv_records_path(applicationIdentifier, key), + request_data=data, + ) + + def delete_application_kv_record(self, applicationIdentifier: str, key: str): + """ + Delete Application KV Record + + Requires the application's own access token (personal access tokens are not supported). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.storage.kv.records.delete + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.storage.kv.records.delete + """ + return self.requester.request( + method="delete", + path=self.get_application_kv_records_path(applicationIdentifier, key), + ) + + def get_application_data( + self, applicationIdentifier: str, path: str, params: Optional[Dict] = None + ): """ Get Application Data. + `params` are application-specific query parameters. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.api.get + https://support.crowdin.com/developer/api/v2/#operation/api.applications.api.get Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.applications.api.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.api.get """ return self.requester.request( method="get", path=self.get_application_path(applicationIdentifier, path), + params=params, ) - def update_application_data(self, applicationIdentifier: str, path: str, data: dict): + def update_application_data( + self, applicationIdentifier: str, path: str, data: dict, params: Optional[Dict] = None + ): """ Update or Restore Application Data. + `params` are application-specific query parameters. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.api.put + https://support.crowdin.com/developer/api/v2/#operation/api.applications.api.put Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.applications.api.put + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.api.put """ - json_data = dumps(data) return self.requester.request( method="put", path=self.get_application_path(applicationIdentifier, path), - request_data=json_data, + params=params, + request_data=data, ) - def add_application_data(self, applicationIdentifier: str, path: str, data: dict): + def add_application_data( + self, applicationIdentifier: str, path: str, data: dict, params: Optional[Dict] = None + ): """ Add Application Data. + `params` are application-specific query parameters. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.api.post + https://support.crowdin.com/developer/api/v2/#operation/api.applications.api.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.applications.api.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.api.post """ - json_data = dumps(data) return self.requester.request( method="post", path=self.get_application_path(applicationIdentifier, path), - request_data=json_data, + params=params, + request_data=data, ) - def delete_application_data(self, applicationIdentifier: str, path: str): + def delete_application_data( + self, applicationIdentifier: str, path: str, params: Optional[Dict] = None + ): """ Delete Application Data. + `params` are application-specific query parameters. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.api.delete + https://support.crowdin.com/developer/api/v2/#operation/api.applications.api.delete Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.applications.api.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.api.delete """ return self.requester.request( - method='delete', - path=self.get_application_path(applicationIdentifier, path) + method="delete", + path=self.get_application_path(applicationIdentifier, path), + params=params, ) - def edit_application_data(self, applicationIdentifier: str, path: str, data: dict): + def edit_application_data( + self, applicationIdentifier: str, path: str, data: dict, params: Optional[Dict] = None + ): """ Edit Application Data. + `params` are application-specific query parameters. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.applications.api.patch + https://support.crowdin.com/developer/api/v2/#operation/api.applications.api.patch Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.applications.api.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.api.patch """ - json_data = dumps(data) return self.requester.request( method="patch", path=self.get_application_path(applicationIdentifier, path), - request_data=json_data, + params=params, + request_data=data, + ) + + # Integrations API (file-based projects only) + + def list_integration_crowdin_files( + self, applicationIdentifier: str, projectId: Optional[int] = None + ): + """ + List Crowdin Files + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.crowdin.files + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.crowdin.files + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "crowdin-files"), + params={"projectId": projectId}, + ) + + def update_integration_crowdin_files( + self, + applicationIdentifier: str, + files: Iterable[IntegrationCrowdinUpdateFile], + uploadTranslations: Optional[bool] = None, + projectId: Optional[int] = None, + ): + """ + Update Crowdin Files + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.crowdin.update + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.crowdin.update + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_application_path(applicationIdentifier, "crowdin-update"), + request_data={ + "projectId": projectId, + "files": files, + "uploadTranslations": uploadTranslations, + }, + ) + + def get_integration_file_progress( + self, applicationIdentifier: str, fileId: int, projectId: Optional[int] = None + ): + """ + Get File Progress + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.file.progress + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.file.progress + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "file-progress"), + params={"projectId": projectId, "fileId": fileId}, + ) + + def get_integration_login_fields(self, applicationIdentifier: str): + """ + Get Integration Login Form Fields + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.integration.fields + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.integration.fields + """ + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "login-fields"), + ) + + def list_integration_files( + self, applicationIdentifier: str, projectId: Optional[int] = None + ): + """ + List Integration Files + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.integration.files + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.integration.files + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "integration-files"), + params={"projectId": projectId}, + ) + + def integration_login( + self, + applicationIdentifier: str, + credentials: Dict[str, Any], + projectId: Optional[int] = None, + ): + """ + Integration Login + + `credentials` are the login form fields, see `get_integration_login_fields`. + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.integration.login + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.integration.login + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_application_path(applicationIdentifier, "login"), + request_data={"projectId": projectId, "credentials": credentials}, + ) + + def update_integration_files( + self, + applicationIdentifier: str, + files: Dict[Union[int, str], Iterable[str]], + projectId: Optional[int] = None, + ): + """ + Update Integration Files + + `files` maps Crowdin file ids to lists of language ids, e.g. `{102: ["de", "fr"]}`. + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.integration.update + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.integration.update + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_application_path(applicationIdentifier, "integration-update"), + request_data={"projectId": projectId, "files": files}, + ) + + def get_integration_job( + self, + applicationIdentifier: str, + jobId: Optional[str] = None, + projectId: Optional[int] = None, + ): + """ + Get Job + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.job.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.job.get + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "jobs"), + params={"projectId": projectId, "jobId": jobId}, + ) + + def cancel_integration_job( + self, applicationIdentifier: str, jobId: str, projectId: Optional[int] = None + ): + """ + Cancel Job + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.job.cancel + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.job.cancel + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="delete", + path=self.get_application_path(applicationIdentifier, "jobs"), + params={"projectId": projectId, "jobId": jobId}, + ) + + def get_integration_job_info( + self, applicationIdentifier: str, jobId: str, projectId: Optional[int] = None + ): + """ + Get Job Info + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.job.info + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.job.info + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "job-info"), + params={"projectId": projectId, "jobId": jobId}, + ) + + def list_integration_jobs( + self, + applicationIdentifier: str, + projectId: Optional[int] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List Jobs + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.job.list + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.job.list + """ + projectId = projectId or self.get_project_id() + + params = {"projectId": projectId} + params.update(self.get_page_params(offset=offset, limit=limit)) + + return self._get_entire_data( + method="get", + path=self.get_application_path(applicationIdentifier, "all-jobs"), + params=params, + ) + + def get_integration_settings( + self, applicationIdentifier: str, projectId: Optional[int] = None + ): + """ + Get Application Settings + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.settings.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.settings.get + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "settings"), + params={"projectId": projectId}, + ) + + def update_integration_settings( + self, + applicationIdentifier: str, + config: Dict[str, Any], + projectId: Optional[int] = None, + ): + """ + Update Application Settings + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.settings.update + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.settings.update + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_application_path(applicationIdentifier, "settings"), + request_data={"projectId": projectId, "config": config}, + ) + + def get_integration_sync_settings( + self, + applicationIdentifier: str, + provider: Union[IntegrationSyncProvider, str], + projectId: Optional[int] = None, + ): + """ + Get Sync Settings + + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.sync.settings.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.sync.settings.get + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_application_path(applicationIdentifier, "sync-settings"), + params={"projectId": projectId, "provider": provider}, + ) + + def update_integration_sync_settings( + self, + applicationIdentifier: str, + provider: IntegrationSyncProvider, + files: Union[ + Dict[Union[int, str], Iterable[str]], Iterable[IntegrationSyncSettingsFile] + ], + projectId: Optional[int] = None, + ): + """ + Update Sync Settings + + `files` is either a mapping of Crowdin file ids to language ids + (e.g. `{102: ["uk", "de"]}`) or a list of integration file objects. + Available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.applications.integrations.sync.settings.update + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.applications.integrations.sync.settings.update + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_application_path(applicationIdentifier, "sync-settings"), + request_data={"projectId": projectId, "provider": provider, "files": files}, ) diff --git a/crowdin_api/api_resources/application/tests/test_applications_resources.py b/crowdin_api/api_resources/application/tests/test_applications_resources.py index 34f57ca7..bf76e304 100644 --- a/crowdin_api/api_resources/application/tests/test_applications_resources.py +++ b/crowdin_api/api_resources/application/tests/test_applications_resources.py @@ -4,8 +4,13 @@ from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.api_resources.application.resource import ApplicationResource from crowdin_api.api_resources.application.enums import ( + ApplicationBundleMode, ApplicationConsentPatchPath, ApplicationConsentStatus, + ApplicationInstallationPatchPath, + ApplicationKVRecordPatchPath, + ApplicationManifestEnvironment, + IntegrationSyncProvider, ListApplicationConsentsOrderBy, UserPermissions, ProjectPermissions, @@ -50,7 +55,25 @@ def test_list_application_installations(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="get", path=resource.get_application_installations_path(), - params=resource.get_page_params() + params={"installedBy": None, "orderBy": None, "offset": 0, "limit": 25}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_application_installations_with_filters(self, m_request, base_absolut_url): + m_request.return_value = "response" + order_by = Sorting([SortingRule(ListApplicationConsentsOrderBy.ID, SortingOrder.ASC)]) + + resource = self.get_resource(base_absolut_url) + assert ( + resource.list_application_installations( + offset=5, limit=10, installedBy=12, orderBy=order_by + ) + == "response" + ) + m_request.assert_called_once_with( + method="get", + path=resource.get_application_installations_path(), + params={"installedBy": 12, "orderBy": order_by, "offset": 5, "limit": 10}, ) @pytest.mark.parametrize( @@ -62,7 +85,10 @@ def test_list_application_installations(self, m_request, base_absolut_url): }, { "url": "https://localhost.dev/crowdin.json", - "permissions": None + "manifest": None, + "permissions": None, + "modules": None, + "assignAgent": None, } ), ( @@ -81,6 +107,7 @@ def test_list_application_installations(self, m_request, base_absolut_url): }, { "url": "https://localhost.dev/crowdin.json", + "manifest": None, "permissions": { "user": { "value": UserPermissions.OWNER, @@ -90,7 +117,76 @@ def test_list_application_installations(self, m_request, base_absolut_url): "value": ProjectPermissions.OWN, "ids": [4, 5, 6] } - } + }, + "modules": None, + "assignAgent": None, + } + ), + ( + { + "url": "https://localhost.dev/crowdin.json", + "modules": [ + { + "key": "some-module-key", + "permissions": { + "user": {"value": UserPermissions.RESTRICTED, "ids": [1]} + }, + } + ], + "assignAgent": True, + }, + { + "url": "https://localhost.dev/crowdin.json", + "manifest": None, + "permissions": None, + "modules": [ + { + "key": "some-module-key", + "permissions": { + "user": {"value": UserPermissions.RESTRICTED, "ids": [1]} + }, + } + ], + "assignAgent": True, + } + ), + ( + { + "manifest": { + "name": "My App", + "scopes": ["project"], + "modules": { + "project-tools": [ + { + "key": "my-module", + "name": "My Module", + "environments": [ApplicationManifestEnvironment.CROWDIN], + } + ] + }, + "bundle": {"mode": ApplicationBundleMode.INTERNAL}, + }, + "permissions": {"project": {"value": ProjectPermissions.OWN, "ids": []}}, + }, + { + "url": None, + "manifest": { + "name": "My App", + "scopes": ["project"], + "modules": { + "project-tools": [ + { + "key": "my-module", + "name": "My Module", + "environments": [ApplicationManifestEnvironment.CROWDIN], + } + ] + }, + "bundle": {"mode": ApplicationBundleMode.INTERNAL}, + }, + "permissions": {"project": {"value": ProjectPermissions.OWN, "ids": []}}, + "modules": None, + "assignAgent": None, } ), ), @@ -107,6 +203,18 @@ def test_install_application(self, m_request, in_params, request_data, base_abso request_data=request_data ) + @pytest.mark.parametrize( + "in_params", + ( + {}, + {"url": "https://localhost.dev/crowdin.json", "manifest": {"name": "a", "modules": {}}}, + ), + ) + def test_install_application_invalid(self, in_params, base_absolut_url): + resource = self.get_resource(base_absolut_url) + with pytest.raises(ValueError): + resource.install_application(**in_params) + @mock.patch("crowdin_api.requester.APIRequester.request") def test_get_applcation_installation(self, m_request, base_absolut_url): m_request.return_value = "response" @@ -154,10 +262,34 @@ def test_edit_application_installation( identifier = "exmaple-application" data = [{"op": "replace", "path": "/permissions", "value": "test"}] resource = self.get_resource(base_absolut_url) - assert resource.edit_applicatoin_installation( + assert resource.edit_application_installation( identifier=identifier, data=data, + ) == "response" + m_request.assert_called_once_with( + method="patch", + path=resource.get_application_installations_path(identifier=identifier), + request_data=data, ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_applicatoin_installation_deprecated(self, m_request, base_absolut_url): + m_request.return_value = "response" + + identifier = "exmaple-application" + data = [ + { + "op": PatchOperation.REPLACE, + "path": ApplicationInstallationPatchPath.MANIFEST, + "value": {"name": "My App", "modules": {}}, + } + ] + resource = self.get_resource(base_absolut_url) + with pytest.deprecated_call(): + assert resource.edit_applicatoin_installation( + identifier=identifier, + data=data, + ) == "response" m_request.assert_called_once_with( method="patch", path=resource.get_application_installations_path(identifier=identifier), @@ -304,6 +436,7 @@ def test_get_application_data(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="get", path=resource.get_application_path(applicationIdentifier="abc", path="test"), + params=None, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -316,7 +449,8 @@ def test_add_application_data(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="post", path=resource.get_application_path(applicationIdentifier="abc", path="test"), - request_data='{"key2": 2}' + params=None, + request_data={"key2": 2}, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -328,6 +462,7 @@ def test_delete_application_data(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="delete", path=resource.get_application_path(applicationIdentifier="abc", path="test"), + params=None, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -339,7 +474,8 @@ def test_edit_application_data(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="patch", path=resource.get_application_path(applicationIdentifier="abc", path="test"), - request_data='{"key2": 2}', + params=None, + request_data={"key2": 2}, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -351,5 +487,304 @@ def test_update_application_data(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="put", path=resource.get_application_path(applicationIdentifier="abc", path="test"), - request_data='{"key2": 2}', + params=None, + request_data={"key2": 2}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_application_data_with_params(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert ( + resource.get_application_data("abc", "test", params={"projectId": 1}) == "response" + ) + m_request.assert_called_once_with( + method="get", + path="applications/abc/api/test", + params={"projectId": 1}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_upload_application_bundle(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert resource.upload_application_bundle("example-app", storageId=12) == "response" + m_request.assert_called_once_with( + method="post", + path="applications/installations/example-app/bundles", + request_data={"storageId": 12}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_application_installation_update(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert resource.get_application_installation_update("example-app") == "response" + m_request.assert_called_once_with( + method="get", + path="applications/installations/example-app/update", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_apply_application_installation_update(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert ( + resource.apply_application_installation_update("example-app", manifestHash="abc") + == "response" + ) + m_request.assert_called_once_with( + method="post", + path="applications/installations/example-app/update", + request_data={"manifestHash": "abc"}, + ) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"applicationIdentifier": "app"}, "applications/app/storage/kv/records"), + ( + {"applicationIdentifier": "app", "key": "user:1:settings"}, + "applications/app/storage/kv/records/user%3A1%3Asettings", + ), + ), + ) + def test_get_application_kv_records_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_application_kv_records_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"prefix": None, "orderBy": None, "offset": 0, "limit": 25}), + ( + {"prefix": "user:", "offset": 10, "limit": 5}, + {"prefix": "user:", "orderBy": None, "offset": 10, "limit": 5}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_application_kv_records( + self, m_request, in_params, request_params, base_absolut_url + ): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert resource.list_application_kv_records("app", **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="applications/app/storage/kv/records", + params=request_params, + ) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {"key": "k", "value": {"a": 1}}, + {"key": "k", "value": {"a": 1}, "secret": None, "ttl": None}, + ), + ( + {"key": "k", "value": "v", "secret": True, "ttl": 3600}, + {"key": "k", "value": "v", "secret": True, "ttl": 3600}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_application_kv_record( + self, m_request, in_params, request_data, base_absolut_url + ): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert resource.add_application_kv_record("app", **in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="applications/app/storage/kv/records", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_application_kv_record(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert resource.get_application_kv_record("app", "k") == "response" + m_request.assert_called_once_with( + method="get", + path="applications/app/storage/kv/records/k", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_application_kv_record(self, m_request, base_absolut_url): + m_request.return_value = "response" + data = [ + {"op": PatchOperation.REPLACE, "path": ApplicationKVRecordPatchPath.VALUE, "value": 1}, + {"op": PatchOperation.REPLACE, "path": ApplicationKVRecordPatchPath.TTL, "value": 60}, + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_application_kv_record("app", "k", data) == "response" + m_request.assert_called_once_with( + method="patch", + path="applications/app/storage/kv/records/k", + request_data=data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_application_kv_record(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert resource.delete_application_kv_record("app", "k") == "response" + m_request.assert_called_once_with( + method="delete", + path="applications/app/storage/kv/records/k", + ) + + @pytest.mark.parametrize( + "method_name, in_params, http_method, api_path, call_kwargs", + ( + ( + "list_integration_crowdin_files", + {}, + "get", + "crowdin-files", + {"params": {"projectId": 1}}, + ), + ( + "update_integration_crowdin_files", + {"files": [{"id": "1", "name": "Landing pages"}], "uploadTranslations": True}, + "post", + "crowdin-update", + { + "request_data": { + "projectId": 1, + "files": [{"id": "1", "name": "Landing pages"}], + "uploadTranslations": True, + } + }, + ), + ( + "get_integration_file_progress", + {"fileId": 2}, + "get", + "file-progress", + {"params": {"projectId": 1, "fileId": 2}}, + ), + ( + "list_integration_files", + {}, + "get", + "integration-files", + {"params": {"projectId": 1}}, + ), + ( + "integration_login", + {"credentials": {"email": "user@crowdin.com"}}, + "post", + "login", + {"request_data": {"projectId": 1, "credentials": {"email": "user@crowdin.com"}}}, + ), + ( + "update_integration_files", + {"files": {102: ["de", "fr"]}}, + "post", + "integration-update", + {"request_data": {"projectId": 1, "files": {102: ["de", "fr"]}}}, + ), + ( + "get_integration_job", + {"jobId": "j"}, + "get", + "jobs", + {"params": {"projectId": 1, "jobId": "j"}}, + ), + ( + "cancel_integration_job", + {"jobId": "j"}, + "delete", + "jobs", + {"params": {"projectId": 1, "jobId": "j"}}, + ), + ( + "get_integration_job_info", + {"jobId": "j"}, + "get", + "job-info", + {"params": {"projectId": 1, "jobId": "j"}}, + ), + ( + "list_integration_jobs", + {"offset": 0, "limit": 10}, + "get", + "all-jobs", + {"params": {"projectId": 1, "offset": 0, "limit": 10}}, + ), + ( + "get_integration_settings", + {}, + "get", + "settings", + {"params": {"projectId": 1}}, + ), + ( + "update_integration_settings", + {"config": {"schedule": "0"}}, + "post", + "settings", + {"request_data": {"projectId": 1, "config": {"schedule": "0"}}}, + ), + ( + "get_integration_sync_settings", + {"provider": IntegrationSyncProvider.CROWDIN}, + "get", + "sync-settings", + {"params": {"projectId": 1, "provider": IntegrationSyncProvider.CROWDIN}}, + ), + ( + "update_integration_sync_settings", + {"provider": IntegrationSyncProvider.INTEGRATION, "files": {102: ["uk"]}}, + "post", + "sync-settings", + { + "request_data": { + "projectId": 1, + "provider": IntegrationSyncProvider.INTEGRATION, + "files": {102: ["uk"]}, + } + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_integration_methods( + self, m_request, method_name, in_params, http_method, api_path, call_kwargs, + base_absolut_url, + ): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert getattr(resource, method_name)("app", projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method=http_method, + path=f"applications/app/api/{api_path}", + **call_kwargs, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_integration_methods_default_project(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.resource_class( + requester=APIRequester(base_url=base_absolut_url), project_id=7 + ) + assert resource.list_integration_files("app") == "response" + m_request.assert_called_once_with( + method="get", + path="applications/app/api/integration-files", + params={"projectId": 7}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_integration_login_fields(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + assert resource.get_integration_login_fields("app") == "response" + m_request.assert_called_once_with( + method="get", + path="applications/app/api/login-fields", ) diff --git a/crowdin_api/api_resources/application/types.py b/crowdin_api/api_resources/application/types.py index 53604db8..90253db0 100644 --- a/crowdin_api/api_resources/application/types.py +++ b/crowdin_api/api_resources/application/types.py @@ -1,10 +1,14 @@ -from typing import Any, Iterable, Optional +from typing import Any, Dict, Iterable, Optional, Union from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.typing import TypedDict from crowdin_api.api_resources.application.enums import ( + ApplicationBundleMode, ApplicationConsentPatchPath, ApplicationConsentStatus, + ApplicationInstallationPatchPath, + ApplicationKVRecordPatchPath, + ApplicationManifestEnvironment, UserPermissions, ProjectPermissions, ) @@ -20,15 +24,60 @@ class ApplicationProject(TypedDict): ids: Iterable[int] -class ApplicationPermissions(TypedDict): - user: ApplicationUser +class ApplicationPermissions(TypedDict, total=False): + user: ApplicationUser # deprecated by the API, use module permissions instead project: ApplicationProject +class ApplicationModuleUserPermissions(TypedDict, total=False): + user: ApplicationUser + + +class ApplicationModulePermissions(TypedDict, total=False): + key: str + permissions: ApplicationModuleUserPermissions + + +class _ApplicationManifestModuleRequired(TypedDict): + key: str + name: str + + +class ApplicationManifestModule(_ApplicationManifestModuleRequired, total=False): + logo: str + description: str + environments: Iterable[ApplicationManifestEnvironment] + permissions: ApplicationModuleUserPermissions + + +class ApplicationManifestDefaultPermissions(TypedDict, total=False): + user: UserPermissions + project: ProjectPermissions + + +class ApplicationManifestBundle(TypedDict, total=False): + mode: ApplicationBundleMode + url: str # external mode only + + +class _ApplicationManifestRequired(TypedDict): + name: str + modules: Dict[str, Iterable[ApplicationManifestModule]] + + +class ApplicationManifest(_ApplicationManifestRequired, total=False): + description: str + logo: str + scopes: Iterable[str] + stringBasedAvailable: bool + default_permissions: ApplicationManifestDefaultPermissions + bundle: ApplicationManifestBundle + + class ApplicationInstallationPatchRequest(TypedDict): - op: str - path: str - value: str + op: Union[PatchOperation, str] + path: Union[ApplicationInstallationPatchPath, str] + value: Any class AddApplicationConsentRequest(TypedDict): @@ -42,3 +91,27 @@ class ApplicationConsentPatchRequest(TypedDict): op: PatchOperation path: ApplicationConsentPatchPath value: Any + + +class ApplicationKVRecordPatchRequest(TypedDict): + op: PatchOperation + path: ApplicationKVRecordPatchPath + value: Any + + +class IntegrationCrowdinUpdateFile(TypedDict, total=False): + id: str + name: str + parent_id: str + parentId: str + type: str + node_type: str + + +class IntegrationSyncSettingsFile(TypedDict, total=False): + id: str + name: str + parentId: str + type: str + node_type: str + schedule: bool diff --git a/crowdin_api/api_resources/branches/enums.py b/crowdin_api/api_resources/branches/enums.py index 727c599d..8198e20f 100644 --- a/crowdin_api/api_resources/branches/enums.py +++ b/crowdin_api/api_resources/branches/enums.py @@ -4,7 +4,9 @@ class EditBranchPatchPath(Enum): NAME = "/name" TITLE = "/title" + EXPORT_PATTERN = "/exportPattern" # file-based projects only PRIORITY = "/priority" + IS_PROTECTED = "/isProtected" # string-based projects only class ListBranchesOrderBy(Enum): diff --git a/crowdin_api/api_resources/branches/resource.py b/crowdin_api/api_resources/branches/resource.py index 524dfcd3..f73653eb 100644 --- a/crowdin_api/api_resources/branches/resource.py +++ b/crowdin_api/api_resources/branches/resource.py @@ -1,4 +1,4 @@ -from typing import Optional, Iterable +from typing import Iterable, Optional, Union from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.branches.types import ( @@ -12,12 +12,17 @@ class BranchesResource(BaseResource): """ - Resource for Bundles + Resource for Branches. + + Branch CRUD and deletion status endpoints are available for both file-based and + string-based projects. Clones and merges are available for string-based projects only. Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Branches https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Branches https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches """ @@ -30,6 +35,8 @@ def get_cloned_branch( """ Get Cloned Branch + String-based projects only. + Link to documentation: https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.branch.get @@ -56,6 +63,8 @@ def clone_branch( """ Clone Branch + String-based projects only. + Link to documentation: https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.post @@ -78,6 +87,8 @@ def check_branch_clone_status( """ Check Branch Clone Status + String-based projects only. + Link to documentation: https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.get @@ -112,9 +123,11 @@ def list_branches( List Branches Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Branches/operation/api.projects.branches.getMany https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.getMany Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Branches/operation/api.projects.branches.getMany https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.getMany """ @@ -124,7 +137,7 @@ def list_branches( } params.update(self.get_page_params(limit=limit, offset=offset)) - return self.requester.request( + return self._get_entire_data( method="get", path=self.get_branches_path(project_id), params=params, @@ -138,10 +151,15 @@ def add_branch( """ Add Branch + `exportPattern` and `priority` are for file-based projects only, + `isProtected` is for string-based projects only. + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Branches/operation/api.projects.branches.post https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.post Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Branches/operation/api.projects.branches.post https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.post """ @@ -156,9 +174,11 @@ def get_branch(self, project_id: int, branch_id: int): Get Branch Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Branches/operation/api.projects.branches.get https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.get Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Branches/operation/api.projects.branches.get https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.get """ @@ -167,30 +187,61 @@ def get_branch(self, project_id: int, branch_id: int): path=self.get_branches_path(project_id, branch_id), ) - def delete_branch(self, project_id: int, branch_id: int): + def delete_branch(self, project_id: int, branch_id: int, prefer: Optional[str] = None): """ Delete Branch + Pass `prefer="respond-async"` to delete the branch asynchronously (recommended); the API + then returns a job to poll via `check_branch_deletion_status`. + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Branches/operation/api.projects.branches.delete https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.delete Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Branches/operation/api.projects.branches.delete https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.delete """ + headers = {"Prefer": prefer} if prefer is not None else None + return self.requester.request( method="delete", + headers=headers, path=self.get_branches_path(project_id, branch_id), ) + def check_branch_deletion_status(self, project_id: int, branch_id: int, job_identifier: str): + """ + Check Branch Deletion Status + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Branches/operation/api.projects.branches.jobs.get + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.jobs.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Branches/operation/api.projects.branches.jobs.get + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.jobs.get + """ + + return self.requester.request( + method="get", + path=f"{self.get_branches_path(project_id, branch_id)}/jobs/{job_identifier}", + ) + def edit_branch(self, project_id: int, branch_id: int, patches: Iterable[EditBranchPatch]): """ Edit Branch + `/exportPattern` is for file-based projects only, `/isProtected` is for string-based + projects only. + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Branches/operation/api.projects.branches.patch https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.patch Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Branches/operation/api.projects.branches.patch https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.patch """ @@ -200,7 +251,7 @@ def edit_branch(self, project_id: int, branch_id: int, patches: Iterable[EditBra request_data=patches, ) - def get_branch_merges_path(self, project_id: int, branch_id: int, merge_id: Optional[int] = None): + def get_branch_merges_path(self, project_id: int, branch_id: int, merge_id: Optional[Union[int, str]] = None): if merge_id is not None: return f"projects/{project_id}/branches/{branch_id}/merges/{merge_id}" @@ -210,6 +261,8 @@ def merge_branch(self, project_id: int, branch_id: int, request: MergeBranchRequ """ Merge Branch + String-based projects only. + Link to documentation: https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.post @@ -223,10 +276,12 @@ def merge_branch(self, project_id: int, branch_id: int, request: MergeBranchRequ request_data=request, ) - def check_branch_merge_status(self, project_id: int, branch_id: int, merge_id: int): + def check_branch_merge_status(self, project_id: int, branch_id: int, merge_id: Union[int, str]): """ Check Branch Merge Status + String-based projects only. + Link to documentation: https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.get @@ -239,10 +294,12 @@ def check_branch_merge_status(self, project_id: int, branch_id: int, merge_id: i path=self.get_branch_merges_path(project_id, branch_id, merge_id), ) - def get_branch_merge_summary(self, project_id: int, branch_id: int, merge_id: int): + def get_branch_merge_summary(self, project_id: int, branch_id: int, merge_id: Union[int, str]): """ Get Branch Merge Summary + String-based projects only. + Link to documentation: https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.summary.get diff --git a/crowdin_api/api_resources/branches/tests/test_branches.py b/crowdin_api/api_resources/branches/tests/test_branches.py index 11f505dd..9ae12723 100644 --- a/crowdin_api/api_resources/branches/tests/test_branches.py +++ b/crowdin_api/api_resources/branches/tests/test_branches.py @@ -7,6 +7,7 @@ from crowdin_api.api_resources.branches.types import CloneBranchRequest, AddBranchRequest, EditBranchPatch, \ MergeBranchRequest from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.source_files.enums import Priority from crowdin_api.requester import APIRequester from crowdin_api.sorting import SortingRule, Sorting, SortingOrder @@ -45,6 +46,16 @@ def test_get_cloned_branch(self, m_request, base_absolut_url): "title": "Branch title" } ), + ( + CloneBranchRequest( + name="Branch name", + isProtected=True + ), + { + "name": "Branch name", + "isProtected": True + } + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -127,6 +138,22 @@ def test_list_branches(self, m_request, in_params, query_params, base_absolut_ur "title": "Title of new branch" } ), + ( + AddBranchRequest( + name="New branch", + exportPattern="%three_letters_code%", + priority=Priority.HIGH, + ), + { + "name": "New branch", + "exportPattern": "%three_letters_code%", + "priority": Priority.HIGH, + } + ), + ( + AddBranchRequest(name="New branch", isProtected=True), + {"name": "New branch", "isProtected": True} + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -168,9 +195,55 @@ def test_delete_branch(self, m_request, base_absolut_url): assert resource.delete_branch(project_id, branch_id) == "response" m_request.assert_called_once_with( method="delete", + headers=None, path=f"projects/{project_id}/branches/{branch_id}", ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_branch_async(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_branch(1, 2, prefer="respond-async") == "response" + m_request.assert_called_once_with( + method="delete", + headers={"Prefer": "respond-async"}, + path="projects/1/branches/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_branch_deletion_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.check_branch_deletion_status(1, 2, "job") == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/branches/2/jobs/job", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_branch_merge_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.check_branch_merge_status(1, 2, "merge") == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/branches/2/merges/merge", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_branch_merge_summary(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_branch_merge_summary(1, 2, "merge") == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/branches/2/merges/merge/summary", + ) + @pytest.mark.parametrize( "in_body, request_body", ( @@ -190,6 +263,22 @@ def test_delete_branch(self, m_request, base_absolut_url): } ] ), + ( + [ + EditBranchPatch( + op=PatchOperation.REPLACE, + path=EditBranchPatchPath.IS_PROTECTED, + value=True + ) + ], + [ + { + "op": PatchOperation.REPLACE, + "path": EditBranchPatchPath.IS_PROTECTED, + "value": True + } + ] + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") diff --git a/crowdin_api/api_resources/branches/types.py b/crowdin_api/api_resources/branches/types.py index fa3c617a..239ec45e 100644 --- a/crowdin_api/api_resources/branches/types.py +++ b/crowdin_api/api_resources/branches/types.py @@ -1,17 +1,36 @@ -from typing import TypedDict, Optional, Any +from typing import Optional, Any from crowdin_api.api_resources.branches.enums import EditBranchPatchPath from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.source_files.enums import Priority +from crowdin_api.typing import TypedDict -class CloneBranchRequest(TypedDict): +class _CloneBranchRequestRequired(TypedDict): name: str + + +class CloneBranchRequest(_CloneBranchRequestRequired, total=False): + """String-based projects only.""" + title: Optional[str] + isProtected: Optional[bool] -class AddBranchRequest(TypedDict): +class _AddBranchRequestRequired(TypedDict): name: str + + +class AddBranchRequest(_AddBranchRequestRequired, total=False): + """ + `exportPattern` and `priority` are for file-based projects only, + `isProtected` is for string-based projects only. + """ + title: Optional[str] + exportPattern: Optional[str] + priority: Optional[Priority] + isProtected: Optional[bool] class EditBranchPatch(TypedDict): @@ -20,8 +39,13 @@ class EditBranchPatch(TypedDict): value: Any -class MergeBranchRequest(TypedDict): - deleteAfterMerge: Optional[bool] +class _MergeBranchRequestRequired(TypedDict): sourceBranchId: int + + +class MergeBranchRequest(_MergeBranchRequestRequired, total=False): + """String-based projects only.""" + + deleteAfterMerge: Optional[bool] dryRun: Optional[bool] acceptSourceChanges: Optional[bool] diff --git a/crowdin_api/api_resources/bundles/enums.py b/crowdin_api/api_resources/bundles/enums.py index 0679bce1..41797491 100644 --- a/crowdin_api/api_resources/bundles/enums.py +++ b/crowdin_api/api_resources/bundles/enums.py @@ -7,5 +7,16 @@ class BundlePatchPath(Enum): SOURCE_PATTERNS = "/sourcePatterns" IGNORE_PATTERNS = "/ignorePatterns" EXPORT_PATTERNS = "/exportPattern" - DESCRIPTION = "/description" + DESCRIPTION = "/description" # no longer listed in the API spec, kept for backward compatibility + IS_MULTILINGUAL = "/isMultilingual" + INCLUDE_PROJECT_SOURCE_LANGUAGE = "/includeProjectSourceLanguage" + INCLUDE_IN_CONTEXT_PSEUDO_LANGUAGE = "/includeInContextPseudoLanguage" LABEL_IDS = "/labelIds" + EXCLUDE_LABEL_IDS = "/excludeLabelIds" + LABEL_MATCH_RULE = "/labelMatchRule" + EXCLUDE_LABEL_MATCH_RULE = "/excludeLabelMatchRule" + + +class BundleLabelMatchRule(Enum): + ALL = "all" + ANY = "any" diff --git a/crowdin_api/api_resources/bundles/resource.py b/crowdin_api/api_resources/bundles/resource.py index ae6c8608..db776a37 100644 --- a/crowdin_api/api_resources/bundles/resource.py +++ b/crowdin_api/api_resources/bundles/resource.py @@ -1,6 +1,7 @@ from typing import Optional, Iterable from crowdin_api.api_resources.abstract.resources import BaseResource +from crowdin_api.api_resources.bundles.enums import BundleLabelMatchRule from crowdin_api.api_resources.bundles.types import BundlePatchRequest @@ -9,10 +10,10 @@ class BundlesResource(BaseResource): Resource for Bundles. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Bundles + https://support.crowdin.com/developer/api/v2/#tag/Bundles Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#tag/Bundles + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Bundles """ def get_bundles_path(self, projectId: int, bundleId: Optional[int] = None): if bundleId: @@ -36,10 +37,10 @@ def list_bundles( List Bundles. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.getMany Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.getMany """ params = self.get_page_params(offset=offset, limit=limit) @@ -54,24 +55,33 @@ def list_bundles( def add_bundle( self, name: str, - format: str, + format: Optional[str], sourcePatterns: Iterable[str], - exportPattern: str, + exportPattern: Optional[str], projectId: Optional[int] = None, ignorePatterns: Optional[Iterable[str]] = None, isMultilingual: Optional[bool] = None, includeProjectSourceLanguage: Optional[bool] = None, labelIds: Optional[Iterable[int]] = None, excludeLabelIds: Optional[Iterable[int]] = None, + sourceLanguageExportPattern: Optional[str] = None, + includeInContextPseudoLanguage: Optional[bool] = None, + labelMatchRule: Optional[BundleLabelMatchRule] = None, + excludeLabelMatchRule: Optional[BundleLabelMatchRule] = None, + languageIds: Optional[Iterable[str]] = None, ): """ Add Bundles. + `format` and `exportPattern` may be `None`: if `format` is not provided, files are exported + in their original format (`format` is required for string-based projects), and + `exportPattern` is required only if `format` is specified. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.post """ projectId = projectId or self.get_project_id() @@ -89,6 +99,11 @@ def add_bundle( "includeProjectSourceLanguage": includeProjectSourceLanguage, "labelIds": labelIds, "excludeLabelIds": excludeLabelIds, + "sourceLanguageExportPattern": sourceLanguageExportPattern, + "includeInContextPseudoLanguage": includeInContextPseudoLanguage, + "labelMatchRule": labelMatchRule, + "excludeLabelMatchRule": excludeLabelMatchRule, + "languageIds": languageIds, } ) @@ -97,10 +112,10 @@ def get_bundle(self, bundleId: int, projectId: Optional[int] = None): Get Bundle. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.get Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.get """ projectId = projectId or self.get_project_id() @@ -115,10 +130,10 @@ def delete_bundle(self, bundleId: int, projectId: Optional[int] = None): Delete Bundle. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.delete Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.delete """ projectId = projectId or self.get_project_id() @@ -138,10 +153,10 @@ def edit_bundle( Edit Bundle. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.patch Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.patch """ projectId = projectId or self.get_project_id() @@ -159,10 +174,10 @@ def download_bundle( Download bundle. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.exports.download.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.exports.download.get Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.exports.download.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.exports.download.get """ projectId = projectId or self.get_project_id() @@ -186,11 +201,15 @@ def export_bundle( """ Export bundle. + `skipUntranslatedFiles` is for file-based projects only, `exportApprovedOnly` is for + Crowdin only, `exportWithMinApprovalsCount` and `exportStringsThatPassedWorkflow` are for + Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.exports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.exports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.exports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.exports.post """ projectId = projectId or self.get_project_id() @@ -215,10 +234,10 @@ def check_bundle_export_status( Check Bundle Export Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.exports.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.exports.get Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.exports.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.exports.get """ projectId = projectId or self.get_project_id() @@ -239,10 +258,10 @@ def get_bundle_list_files( Get Bundle List Files. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.bundles.files.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.bundles.files.getMany Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.bundles.files.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.bundles.files.getMany """ params = self.get_page_params(offset=offset, limit=limit) @@ -253,3 +272,31 @@ def get_bundle_list_files( path=f"{self.get_bundles_path(projectId=projectId, bundleId=bundleId)}/files", params=params, ) + + def list_bundle_branches( + self, + bundleId: int, + projectId: Optional[int] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + Get Bundle List Branches. + + String-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#operation/api.projects.bundles.branches.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#operation/api.projects.bundles.branches.getMany + """ + + params = self.get_page_params(offset=offset, limit=limit) + projectId = projectId or self.get_project_id() + + return self._get_entire_data( + method="get", + path=f"{self.get_bundles_path(projectId=projectId, bundleId=bundleId)}/branches", + params=params, + ) diff --git a/crowdin_api/api_resources/bundles/tests/test_bundles_resources.py b/crowdin_api/api_resources/bundles/tests/test_bundles_resources.py index 9fbeceec..fde09b9b 100644 --- a/crowdin_api/api_resources/bundles/tests/test_bundles_resources.py +++ b/crowdin_api/api_resources/bundles/tests/test_bundles_resources.py @@ -3,7 +3,7 @@ import pytest from crowdin_api.api_resources import BundlesResource -from crowdin_api.api_resources.bundles.enums import BundlePatchPath +from crowdin_api.api_resources.bundles.enums import BundleLabelMatchRule, BundlePatchPath from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.requester import APIRequester @@ -155,7 +155,60 @@ def test_add_bundle(self, m_request, incoming_data, request_data, base_absolut_u m_request.assert_called_once_with( method="post", path=resource.get_bundles_path(projectId=1), - request_data=request_data, + request_data={ + "sourceLanguageExportPattern": None, + "includeInContextPseudoLanguage": None, + "labelMatchRule": None, + "excludeLabelMatchRule": None, + "languageIds": None, + **request_data, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_bundle_with_all_params(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_bundle( + projectId=1, + name="test_name", + format=None, + sourcePatterns=["/master/"], + exportPattern=None, + ignorePatterns=["/master/environments/"], + isMultilingual=True, + includeProjectSourceLanguage=True, + labelIds=[2], + excludeLabelIds=[3], + sourceLanguageExportPattern="source.resx", + includeInContextPseudoLanguage=False, + labelMatchRule=BundleLabelMatchRule.ANY, + excludeLabelMatchRule=BundleLabelMatchRule.ALL, + languageIds=["uk", "de"], + ) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path=resource.get_bundles_path(projectId=1), + request_data={ + "name": "test_name", + "format": None, + "sourcePatterns": ["/master/"], + "exportPattern": None, + "ignorePatterns": ["/master/environments/"], + "isMultilingual": True, + "includeProjectSourceLanguage": True, + "labelIds": [2], + "excludeLabelIds": [3], + "sourceLanguageExportPattern": "source.resx", + "includeInContextPseudoLanguage": False, + "labelMatchRule": BundleLabelMatchRule.ANY, + "excludeLabelMatchRule": BundleLabelMatchRule.ALL, + "languageIds": ["uk", "de"], + }, ) @pytest.mark.parametrize( @@ -291,3 +344,47 @@ def test_get_bundle_list_files( path="projects/1/bundles/1/files", params=request_params, ) + + @pytest.mark.parametrize( + "incoming_data, request_params", + ( + ({}, {"limit": 25, "offset": 0}), + ({"limit": 10, "offset": 2}, {"limit": 10, "offset": 2}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_bundle_branches(self, m_request, incoming_data, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_bundle_branches(projectId=1, bundleId=2, **incoming_data) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/bundles/2/branches", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_bundle_label_match_rule(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": BundlePatchPath.LABEL_MATCH_RULE, + "value": BundleLabelMatchRule.ANY, + }, + { + "op": PatchOperation.REPLACE, + "path": BundlePatchPath.INCLUDE_IN_CONTEXT_PSEUDO_LANGUAGE, + "value": False, + }, + ] + + resource = self.get_resource(base_absolut_url) + assert resource.edit_bundle(projectId=1, bundleId=2, data=data) == "response" + m_request.assert_called_once_with( + method="patch", + path=resource.get_bundles_path(projectId=1, bundleId=2), + request_data=data, + ) diff --git a/crowdin_api/api_resources/bundles/types.py b/crowdin_api/api_resources/bundles/types.py index f894bef0..8e61ef52 100644 --- a/crowdin_api/api_resources/bundles/types.py +++ b/crowdin_api/api_resources/bundles/types.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Any from crowdin_api.api_resources.bundles.enums import BundlePatchPath from crowdin_api.api_resources.enums import PatchOperation @@ -6,6 +6,6 @@ class BundlePatchRequest(TypedDict): - value: Union[str, int] + value: Any op: PatchOperation path: BundlePatchPath diff --git a/crowdin_api/api_resources/clients/resource.py b/crowdin_api/api_resources/clients/resource.py index 428644f3..c67d3ece 100644 --- a/crowdin_api/api_resources/clients/resource.py +++ b/crowdin_api/api_resources/clients/resource.py @@ -7,10 +7,15 @@ class ClientsResource(BaseResource): """ Resource for Clients. - Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#tag/Clients + Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Clients """ + def get_clients_path(self): + return "clients" + def list_clients( self, limit: Optional[int] = None, @@ -19,12 +24,12 @@ def list_clients( """ List Clients - Link to documentation for enterprise: - https://support.crowdin.com/developer/enterprise/api/v2/#tag/Clients/operation/api.clients.getMany + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.clients.getMany """ - return self.requester.request( + return self._get_entire_data( method="get", - path="/clients", - params=self.get_page_params(offset=offset, limit=limit) + path=self.get_clients_path(), + params=self.get_page_params(offset=offset, limit=limit), ) diff --git a/crowdin_api/api_resources/clients/tests/test_clients_resources.py b/crowdin_api/api_resources/clients/tests/test_clients_resources.py new file mode 100644 index 00000000..9e78ca16 --- /dev/null +++ b/crowdin_api/api_resources/clients/tests/test_clients_resources.py @@ -0,0 +1,36 @@ +from unittest import mock + +import pytest + +from crowdin_api.api_resources.clients.resource import ClientsResource +from crowdin_api.requester import APIRequester + + +class TestClientsResource: + resource_class = ClientsResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + def test_get_clients_path(self, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_clients_path() == "clients" + + @pytest.mark.parametrize( + "incoming_data, request_params", + ( + ({}, {"limit": 25, "offset": 0}), + ({"limit": 10, "offset": 2}, {"limit": 10, "offset": 2}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_clients(self, m_request, incoming_data, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_clients(**incoming_data) == "response" + m_request.assert_called_once_with( + method="get", + path="clients", + params=request_params, + ) diff --git a/crowdin_api/api_resources/custom_placeholders/__init__.py b/crowdin_api/api_resources/custom_placeholders/__init__.py new file mode 100644 index 00000000..9f77d639 --- /dev/null +++ b/crowdin_api/api_resources/custom_placeholders/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {'tests': False} diff --git a/crowdin_api/api_resources/custom_placeholders/enums.py b/crowdin_api/api_resources/custom_placeholders/enums.py new file mode 100644 index 00000000..e7a30034 --- /dev/null +++ b/crowdin_api/api_resources/custom_placeholders/enums.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class CustomPlaceholderPatchPath(Enum): + DESCRIPTION = "/description" + DEFINITION = "/definition" + ARGUMENT_DELIMITER = "/argumentDelimiter" diff --git a/crowdin_api/api_resources/custom_placeholders/resource.py b/crowdin_api/api_resources/custom_placeholders/resource.py new file mode 100644 index 00000000..044e01e1 --- /dev/null +++ b/crowdin_api/api_resources/custom_placeholders/resource.py @@ -0,0 +1,104 @@ +from typing import Iterable, Optional + +from crowdin_api.api_resources.abstract.resources import BaseResource +from crowdin_api.api_resources.custom_placeholders.types import CustomPlaceholderPatchRequest + + +class CustomPlaceholdersResource(BaseResource): + """ + Resource for Custom Placeholders. + + Custom placeholders of the organization. Assign them to a project with + Add Project Placeholder. + + Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Custom-Placeholders + """ + + def get_custom_placeholders_path(self, customPlaceholderId: Optional[int] = None): + if customPlaceholderId is not None: + return f"custom-placeholders/{customPlaceholderId}" + + return "custom-placeholders" + + def list_custom_placeholders( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List Custom Placeholders. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.custom-placeholders.getMany + """ + return self._get_entire_data( + method="get", + path=self.get_custom_placeholders_path(), + params=self.get_page_params(offset=offset, limit=limit), + ) + + def add_custom_placeholder( + self, + definition: str, + description: Optional[str] = None, + argumentDelimiter: Optional[str] = None, + ): + """ + Add Custom Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.custom-placeholders.post + """ + return self.requester.request( + method="post", + path=self.get_custom_placeholders_path(), + request_data={ + "definition": definition, + "description": description, + "argumentDelimiter": argumentDelimiter, + }, + ) + + def get_custom_placeholder(self, customPlaceholderId: int): + """ + Get Custom Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.custom-placeholders.get + """ + return self.requester.request( + method="get", + path=self.get_custom_placeholders_path(customPlaceholderId=customPlaceholderId), + ) + + def edit_custom_placeholder( + self, + customPlaceholderId: int, + data: Iterable[CustomPlaceholderPatchRequest], + ): + """ + Edit Custom Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.custom-placeholders.patch + """ + return self.requester.request( + method="patch", + path=self.get_custom_placeholders_path(customPlaceholderId=customPlaceholderId), + request_data=data, + ) + + def delete_custom_placeholder(self, customPlaceholderId: int): + """ + Delete Custom Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.custom-placeholders.delete + """ + return self.requester.request( + method="delete", + path=self.get_custom_placeholders_path(customPlaceholderId=customPlaceholderId), + ) diff --git a/crowdin_api/api_resources/custom_placeholders/tests/test_custom_placeholders_resources.py b/crowdin_api/api_resources/custom_placeholders/tests/test_custom_placeholders_resources.py new file mode 100644 index 00000000..53e30c97 --- /dev/null +++ b/crowdin_api/api_resources/custom_placeholders/tests/test_custom_placeholders_resources.py @@ -0,0 +1,119 @@ +from unittest import mock + +import pytest + +from crowdin_api.api_resources.custom_placeholders.enums import CustomPlaceholderPatchPath +from crowdin_api.api_resources.custom_placeholders.resource import CustomPlaceholdersResource +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.requester import APIRequester + + +class TestCustomPlaceholdersResource: + resource_class = CustomPlaceholdersResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({}, "custom-placeholders"), + ({"customPlaceholderId": 1}, "custom-placeholders/1"), + ), + ) + def test_get_custom_placeholders_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_custom_placeholders_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"offset": 0, "limit": 25}), + ({"offset": 10, "limit": 5}, {"offset": 10, "limit": 5}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_custom_placeholders( + self, m_request, in_params, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_custom_placeholders(**in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="custom-placeholders", + params=request_params, + ) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {"definition": "start, then \"http\", end"}, + { + "definition": "start, then \"http\", end", + "description": None, + "argumentDelimiter": None, + }, + ), + ( + { + "definition": "start, then \"http\", end", + "description": "URL validation", + "argumentDelimiter": "'", + }, + { + "definition": "start, then \"http\", end", + "description": "URL validation", + "argumentDelimiter": "'", + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_custom_placeholder(self, m_request, in_params, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.add_custom_placeholder(**in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="custom-placeholders", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_custom_placeholder(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_custom_placeholder(customPlaceholderId=1) == "response" + m_request.assert_called_once_with(method="get", path="custom-placeholders/1") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_custom_placeholder(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": CustomPlaceholderPatchPath.DESCRIPTION, + "value": "test", + } + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_custom_placeholder(customPlaceholderId=1, data=data) == "response" + m_request.assert_called_once_with( + method="patch", + path="custom-placeholders/1", + request_data=data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_custom_placeholder(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_custom_placeholder(customPlaceholderId=1) == "response" + m_request.assert_called_once_with(method="delete", path="custom-placeholders/1") diff --git a/crowdin_api/api_resources/custom_placeholders/types.py b/crowdin_api/api_resources/custom_placeholders/types.py new file mode 100644 index 00000000..f3677722 --- /dev/null +++ b/crowdin_api/api_resources/custom_placeholders/types.py @@ -0,0 +1,11 @@ +from typing import Any + +from crowdin_api.api_resources.custom_placeholders.enums import CustomPlaceholderPatchPath +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.typing import TypedDict + + +class CustomPlaceholderPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: CustomPlaceholderPatchPath diff --git a/crowdin_api/api_resources/custom_spellcheckers/__init__.py b/crowdin_api/api_resources/custom_spellcheckers/__init__.py new file mode 100644 index 00000000..9f77d639 --- /dev/null +++ b/crowdin_api/api_resources/custom_spellcheckers/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {'tests': False} diff --git a/crowdin_api/api_resources/custom_spellcheckers/resource.py b/crowdin_api/api_resources/custom_spellcheckers/resource.py new file mode 100644 index 00000000..31909935 --- /dev/null +++ b/crowdin_api/api_resources/custom_spellcheckers/resource.py @@ -0,0 +1,49 @@ +from typing import Optional + +from crowdin_api.api_resources.abstract.resources import BaseResource + + +class CustomSpellcheckersResource(BaseResource): + """ + Resource for Custom Spellcheckers. + + Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Custom-Spellcheckers + """ + + def get_custom_spellcheckers_path(self, customSpellcheckerId: Optional[int] = None): + if customSpellcheckerId is not None: + return f"custom-spellcheckers/{customSpellcheckerId}" + + return "custom-spellcheckers" + + def list_custom_spellcheckers( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List Custom Spellcheckers. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.custom-spellcheckers.getMany + """ + return self._get_entire_data( + method="get", + path=self.get_custom_spellcheckers_path(), + params=self.get_page_params(offset=offset, limit=limit), + ) + + def get_custom_spellchecker(self, customSpellcheckerId: int): + """ + Get Custom Spellchecker. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.custom-spellcheckers.get + """ + return self.requester.request( + method="get", + path=self.get_custom_spellcheckers_path(customSpellcheckerId=customSpellcheckerId), + ) diff --git a/crowdin_api/api_resources/custom_spellcheckers/tests/test_custom_spellcheckers_resources.py b/crowdin_api/api_resources/custom_spellcheckers/tests/test_custom_spellcheckers_resources.py new file mode 100644 index 00000000..379441e4 --- /dev/null +++ b/crowdin_api/api_resources/custom_spellcheckers/tests/test_custom_spellcheckers_resources.py @@ -0,0 +1,53 @@ +from unittest import mock + +import pytest + +from crowdin_api.api_resources.custom_spellcheckers.resource import CustomSpellcheckersResource +from crowdin_api.requester import APIRequester + + +class TestCustomSpellcheckersResource: + resource_class = CustomSpellcheckersResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({}, "custom-spellcheckers"), + ({"customSpellcheckerId": 1}, "custom-spellcheckers/1"), + ), + ) + def test_get_custom_spellcheckers_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_custom_spellcheckers_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"offset": 0, "limit": 25}), + ({"offset": 10, "limit": 5}, {"offset": 10, "limit": 5}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_custom_spellcheckers( + self, m_request, in_params, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_custom_spellcheckers(**in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="custom-spellcheckers", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_custom_spellchecker(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_custom_spellchecker(customSpellcheckerId=1) == "response" + m_request.assert_called_once_with(method="get", path="custom-spellcheckers/1") diff --git a/crowdin_api/api_resources/dictionaries/resource.py b/crowdin_api/api_resources/dictionaries/resource.py index 6a13466f..eeeda9f5 100644 --- a/crowdin_api/api_resources/dictionaries/resource.py +++ b/crowdin_api/api_resources/dictionaries/resource.py @@ -1,7 +1,7 @@ from typing import Iterable, Optional from crowdin_api.api_resources.abstract.resources import BaseResource -from crowdin_api.api_resources.dictionaries.types import DictionaryPatchPath +from crowdin_api.api_resources.dictionaries.types import DictionaryPatchRequest class DictionariesResource(BaseResource): @@ -13,7 +13,7 @@ class DictionariesResource(BaseResource): Use API to get the list of organization dictionaries and to edit a specific dictionary. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Dictionaries + https://support.crowdin.com/developer/api/v2/#tag/Dictionaries """ def list_dictionaries( @@ -28,7 +28,7 @@ def list_dictionaries( List Dictionaries. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.dictionaries.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.dictionaries.getMany """ params = self.get_page_params(page=page, offset=offset, limit=limit) @@ -44,14 +44,19 @@ def list_dictionaries( def edit_dictionary( self, languageId: str, - data: Iterable[DictionaryPatchPath], + data: Iterable[DictionaryPatchRequest], projectId: Optional[int] = None, ): """ Edit Dictionary. + :param data: JSON Patch operations ("add"/"remove") on "/words/{index}" paths, + e.g. {"op": "add", "path": "/words/-", "value": "word"} or + {"op": "remove", "path": "/words/0"}. To remove several words in one request, + specify the word indexes in reverse order. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.dictionaries.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.dictionaries.patch """ projectId = projectId or self.get_project_id() diff --git a/crowdin_api/api_resources/dictionaries/tests/test_dictionaries_resources.py b/crowdin_api/api_resources/dictionaries/tests/test_dictionaries_resources.py index 3bf8d908..44c2b13f 100644 --- a/crowdin_api/api_resources/dictionaries/tests/test_dictionaries_resources.py +++ b/crowdin_api/api_resources/dictionaries/tests/test_dictionaries_resources.py @@ -58,9 +58,14 @@ def test_edit_custom_language(self, m_request, base_absolut_url): data = [ { - "op": PatchOperation.REPLACE, + "op": PatchOperation.ADD, + "path": "/words/-", + "value": "word", + }, + { + "op": PatchOperation.REMOVE, "path": "/words/0", - } + }, ] resource = self.get_resource(base_absolut_url) diff --git a/crowdin_api/api_resources/dictionaries/types.py b/crowdin_api/api_resources/dictionaries/types.py index 988654a1..9fa1c03a 100644 --- a/crowdin_api/api_resources/dictionaries/types.py +++ b/crowdin_api/api_resources/dictionaries/types.py @@ -1,3 +1,5 @@ +from typing import Iterable, Union + from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.typing import TypedDict @@ -5,3 +7,15 @@ class DictionaryPatchPath(TypedDict): op: PatchOperation path: str + + +class DictionaryPatchRequest(DictionaryPatchPath, total=False): + """ + Dictionary JSON Patch operation. + + `op` is "add" or "remove". `path` points to a word, e.g. "/words/0" (word index) or + "/words/-" (append, with "add"). `value` is required for "add" (a word or a list of words). + To remove several words in one request, specify the word indexes in reverse order. + """ + + value: Union[str, Iterable[str]] diff --git a/crowdin_api/api_resources/distributions/enums.py b/crowdin_api/api_resources/distributions/enums.py index e0a50447..057f1aba 100644 --- a/crowdin_api/api_resources/distributions/enums.py +++ b/crowdin_api/api_resources/distributions/enums.py @@ -2,9 +2,9 @@ class DistributionPatchPath(Enum): - EXPORT_MODE = "/exportMode" + EXPORT_MODE = "/exportMode" # file-based projects only; `exportMode` is deprecated NAME = "/name" - FILE_IDS = "/fileIds" + FILE_IDS = "/fileIds" # file-based projects only; `fileIds` is deprecated, use `/bundleIds` BUNDLE_IDS = "/bundleIds" diff --git a/crowdin_api/api_resources/distributions/resource.py b/crowdin_api/api_resources/distributions/resource.py index 3fd89ff9..79890bd3 100644 --- a/crowdin_api/api_resources/distributions/resource.py +++ b/crowdin_api/api_resources/distributions/resource.py @@ -11,7 +11,7 @@ class DistributionsResource(BaseResource): Resource for Distributions. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Distributions + https://support.crowdin.com/developer/api/v2/#tag/Distributions """ def get_distributions_path(self, projectId: int, hash: Optional[str] = None): @@ -30,7 +30,7 @@ def list_distributions( List Distributions. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.distributions.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.distributions.getMany """ projectId = projectId or self.get_project_id() @@ -52,8 +52,13 @@ def add_distribution( """ Add Distribution. + :param fileIds: Deprecated, use `bundleIds` instead. File-based projects only. + :param bundleIds: Bundles ids. Get via List Bundles. Required for string-based projects. + :param exportMode: Deprecated, omit it to use the API default behavior. + File-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.distributions.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.distributions.post """ projectId = projectId or self.get_project_id() @@ -86,7 +91,7 @@ def get_distribution(self, hash: str, projectId: Optional[int] = None): Get Distribution. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.distributions.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.distributions.get """ projectId = projectId or self.get_project_id() @@ -101,7 +106,7 @@ def delete_distribution(self, hash: str, projectId: Optional[int] = None): Delete Distribution. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.distributions.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.distributions.delete """ projectId = projectId or self.get_project_id() @@ -121,7 +126,7 @@ def edit_distribution( Edit Distribution. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.distributions.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.distributions.patch """ projectId = projectId or self.get_project_id() @@ -137,7 +142,7 @@ def get_distribution_release(self, hash: str, projectId: Optional[int] = None): Get Distribution Release. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.distributions.release.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.distributions.release.get """ projectId = projectId or self.get_project_id() @@ -152,7 +157,7 @@ def release_distribution(self, hash: str, projectId: Optional[int] = None): Release Distribution. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.distributions.release.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.distributions.release.post """ projectId = projectId or self.get_project_id() diff --git a/crowdin_api/api_resources/external_qa_checks/__init__.py b/crowdin_api/api_resources/external_qa_checks/__init__.py new file mode 100644 index 00000000..9f77d639 --- /dev/null +++ b/crowdin_api/api_resources/external_qa_checks/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {'tests': False} diff --git a/crowdin_api/api_resources/external_qa_checks/resource.py b/crowdin_api/api_resources/external_qa_checks/resource.py new file mode 100644 index 00000000..298924a6 --- /dev/null +++ b/crowdin_api/api_resources/external_qa_checks/resource.py @@ -0,0 +1,55 @@ +from typing import Optional + +from crowdin_api.api_resources.abstract.resources import BaseResource + + +class ExternalQaChecksResource(BaseResource): + """ + Resource for External QA Checks. + + Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/External-QA-Checks + """ + + def get_external_qa_checks_path(self, externalQaCheckId: Optional[int] = None): + if externalQaCheckId is not None: + return f"external-qa-checks/{externalQaCheckId}" + + return "external-qa-checks" + + def list_external_qa_checks( + self, + projectId: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List External QA Checks. + + :param projectId: Filter External QA Checks by Project Identifier + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.external-qa-checks.getMany + """ + params = {"projectId": projectId} + params.update(self.get_page_params(offset=offset, limit=limit)) + + return self._get_entire_data( + method="get", + path=self.get_external_qa_checks_path(), + params=params, + ) + + def get_external_qa_check(self, externalQaCheckId: int): + """ + Get External QA Check. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.external-qa-checks.get + """ + return self.requester.request( + method="get", + path=self.get_external_qa_checks_path(externalQaCheckId=externalQaCheckId), + ) diff --git a/crowdin_api/api_resources/external_qa_checks/tests/test_external_qa_checks_resources.py b/crowdin_api/api_resources/external_qa_checks/tests/test_external_qa_checks_resources.py new file mode 100644 index 00000000..04051581 --- /dev/null +++ b/crowdin_api/api_resources/external_qa_checks/tests/test_external_qa_checks_resources.py @@ -0,0 +1,54 @@ +from unittest import mock + +import pytest + +from crowdin_api.api_resources.external_qa_checks.resource import ExternalQaChecksResource +from crowdin_api.requester import APIRequester + + +class TestExternalQaChecksResource: + resource_class = ExternalQaChecksResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({}, "external-qa-checks"), + ({"externalQaCheckId": 1}, "external-qa-checks/1"), + ), + ) + def test_get_external_qa_checks_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_external_qa_checks_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"projectId": None, "offset": 0, "limit": 25}), + ( + {"projectId": 1, "offset": 10, "limit": 5}, + {"projectId": 1, "offset": 10, "limit": 5}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_external_qa_checks(self, m_request, in_params, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_external_qa_checks(**in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="external-qa-checks", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_external_qa_check(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_external_qa_check(externalQaCheckId=1) == "response" + m_request.assert_called_once_with(method="get", path="external-qa-checks/1") diff --git a/crowdin_api/api_resources/fields/enums.py b/crowdin_api/api_resources/fields/enums.py index b289904b..7d29a776 100644 --- a/crowdin_api/api_resources/fields/enums.py +++ b/crowdin_api/api_resources/fields/enums.py @@ -8,6 +8,8 @@ class FieldEntity(Enum): FILE = "file" TRANSLATION = "translation" STRING = "string" + TERM = "term" + CONCEPT = "concept" class FieldType(Enum): @@ -32,6 +34,7 @@ class FieldPlace(Enum): PROJECT_SETTINGS = "projectSettings" PROJECT_TASK_EDIT_CREATE = "projectTaskEditCreate" PROJECT_TASK_DETAILS = "projectTaskDetails" + PROJECT_TASK_BOARD_CARD = "projectTaskBoardCard" FILE_DETAILS = "fileDetails" FILE_SETTINGS = "fileSettings" USER_EDIT_MODAL = "userEditModal" @@ -40,6 +43,10 @@ class FieldPlace(Enum): STRING_EDIT_MODAL = "stringEditModal" STRING_DETAILS = "stringDetails" TRANSLATION_UNDER_CONTENT = "translationUnderContent" + TERM_DETAILS = "termDetails" + TERM_EDIT_MODAL = "termEditModal" + CONCEPT_DETAILS = "conceptDetails" + CONCEPT_EDIT_MODAL = "conceptEditModal" class FieldOperations(Enum): @@ -48,3 +55,6 @@ class FieldOperations(Enum): class FieldsPatchPath(Enum): NAME = "/name" + DESCRIPTION = "/description" + CONFIG = "/config" + ENTITIES = "/entities" diff --git a/crowdin_api/api_resources/fields/resource.py b/crowdin_api/api_resources/fields/resource.py index b526796e..6874d2b9 100644 --- a/crowdin_api/api_resources/fields/resource.py +++ b/crowdin_api/api_resources/fields/resource.py @@ -15,7 +15,7 @@ class FieldsResource(BaseResource): Resource for Fields. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Fields + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Fields """ def get_fields_path(self, fieldId: Optional[int] = None): @@ -35,7 +35,7 @@ def list_fields( List Fields Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.fields.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.fields.getMany """ params = {"search": search, "entity": entity, "type": type} params.update(self.get_page_params(limit=limit, offset=offset)) @@ -59,7 +59,7 @@ def add_field( Add Field Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.fields.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.fields.post """ data = { "name": name, @@ -78,8 +78,8 @@ def get_field(self, fieldId: int): """ Get Field - Link to documentaion: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.fields.get + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.fields.get """ return self.requester.request( @@ -90,8 +90,8 @@ def delete_field(self, fieldId: int): """ Delete Field - Link to documetation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.fields.delete + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.fields.delete """ return self.requester.request( @@ -103,7 +103,7 @@ def edit_field(self, fieldId: int, data: Iterable[FieldPatchRequest]): Edit Field Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.fields.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.fields.patch """ return self.requester.request( diff --git a/crowdin_api/api_resources/fields/tests/test_fields_resources.py b/crowdin_api/api_resources/fields/tests/test_fields_resources.py index 87d9b20c..44287fe4 100644 --- a/crowdin_api/api_resources/fields/tests/test_fields_resources.py +++ b/crowdin_api/api_resources/fields/tests/test_fields_resources.py @@ -218,7 +218,17 @@ def test_edit_field(self, m_request, base_absolut_url): "op": FieldOperations.REPLACE, "path": FieldsPatchPath.NAME, "value": "test", - } + }, + { + "op": FieldOperations.REPLACE, + "path": FieldsPatchPath.ENTITIES, + "value": [FieldEntity.TERM, FieldEntity.CONCEPT], + }, + { + "op": FieldOperations.REPLACE, + "path": FieldsPatchPath.CONFIG, + "value": {"locations": [{"place": FieldPlace.TERM_DETAILS}]}, + }, ] fieldId = 1 diff --git a/crowdin_api/api_resources/fields/types.py b/crowdin_api/api_resources/fields/types.py index 22892a52..0e33875f 100644 --- a/crowdin_api/api_resources/fields/types.py +++ b/crowdin_api/api_resources/fields/types.py @@ -18,7 +18,7 @@ class FieldLocation(TypedDict): class ListFieldConfig(TypedDict): options: Iterable[FieldOptions] - locations: Iterable[FieldPlace] + locations: Iterable[FieldLocation] class NumberFieldConfig(TypedDict): diff --git a/crowdin_api/api_resources/glossaries/enums.py b/crowdin_api/api_resources/glossaries/enums.py index f655c58a..a74075b6 100644 --- a/crowdin_api/api_resources/glossaries/enums.py +++ b/crowdin_api/api_resources/glossaries/enums.py @@ -3,6 +3,9 @@ class GlossaryPatchPath(Enum): NAME = "/name" + LANGUAGE_ID = "/languageId" + IS_SHARED = "/isShared" + GROUP_ID = "/groupId" # Enterprise only class GlossaryFormat(Enum): @@ -21,6 +24,11 @@ class GlossaryExportFields(Enum): GENDER = "gender" NOTE = "note" URL = "url" + CONCEPT_DEFINITION = "conceptDefinition" + CONCEPT_SUBJECT = "conceptSubject" + CONCEPT_NOTE = "conceptNote" + CONCEPT_URL = "conceptUrl" + CONCEPT_FIGURE = "conceptFigure" class GlossaryExportType(Enum): @@ -81,6 +89,7 @@ class TermPatchPath(Enum): GENDER = "/gender" URL = "/url" NOTE = "/note" + FIELDS = "/fields" # Enterprise only class TermPartOfSpeech(Enum): @@ -106,6 +115,7 @@ class TermStatus(Enum): ADMITTED = "admitted" NOT_RECOMMEND = "not recommended" OBSOLETE = "obsolete" + DRAFT = "draft" class TermType(Enum): diff --git a/crowdin_api/api_resources/glossaries/resource.py b/crowdin_api/api_resources/glossaries/resource.py index 8a1b393c..671f008a 100644 --- a/crowdin_api/api_resources/glossaries/resource.py +++ b/crowdin_api/api_resources/glossaries/resource.py @@ -1,4 +1,4 @@ -from typing import Dict, Iterable, Optional +from typing import Any, Dict, Iterable, Optional from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.glossaries.enums import ( @@ -28,7 +28,7 @@ class GlossariesResource(BaseResource): operations and shall be completed with sequence of API methods. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Glossaries + https://support.crowdin.com/developer/api/v2/#tag/Glossaries """ # Glossaries @@ -45,15 +45,22 @@ def list_glossaries( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + userId: Optional[int] = None, + filter: Optional[str] = None, ): """ List Glossaries. + :param groupId: Group Identifier. Set 0 to see glossaries of root group. + Crowdin Enterprise only. + :param userId: List user glossaries. Crowdin only. + :param filter: Filter glossaries by `name`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.getMany """ - params = {"orderBy": orderBy, "groupId": groupId} + params = {"orderBy": orderBy, "groupId": groupId, "userId": userId, "filter": filter} params.update(self.get_page_params(page=page, offset=offset, limit=limit)) return self._get_entire_data( @@ -62,18 +69,34 @@ def list_glossaries( params=params, ) - def add_glossary(self, name: str, languageId: str): + def add_glossary( + self, + name: str, + languageId: str, + isShared: Optional[bool] = None, + groupId: Optional[int] = None, + ): """ Add Glossary. + :param isShared: Whether the glossary should be shared to all projects within the account + (Crowdin) or within the group (Crowdin Enterprise). + :param groupId: Group Identifier. If 0 – the glossary will be available for all projects + and groups in the workspace. Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.post + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.post """ return self.requester.request( method="post", path=self.get_glossaries_path(), - request_data={"name": name, "languageId": languageId}, + request_data={ + "name": name, + "languageId": languageId, + "isShared": isShared, + "groupId": groupId, + }, ) def get_glossary(self, glossaryId: int): @@ -81,7 +104,7 @@ def get_glossary(self, glossaryId: int): Get Glossary. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.get + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.get """ return self.requester.request( @@ -94,7 +117,7 @@ def delete_glossary(self, glossaryId: int): Delete Glossary. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.delete + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.delete """ return self.requester.request( @@ -106,8 +129,10 @@ def edit_glossary(self, glossaryId: int, data: Iterable[GlossaryPatchRequest]): """ Edit Glossary. + `GlossaryPatchPath.GROUP_ID` (`/groupId`) is Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.patch + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.patch """ return self.requester.request( @@ -128,7 +153,7 @@ def export_glossary(self, glossaryId: int, data: Optional[GlossarySchemaRequest] Export Glossary. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.exports.post + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.exports.post """ return self.requester.request( @@ -142,7 +167,7 @@ def check_glossary_export_status(self, glossaryId: int, exportId: str): Check Glossary Export Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.exports.get + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.exports.get """ return self.requester.request( @@ -155,7 +180,7 @@ def download_glossary(self, glossaryId: int, exportId: str): Download Glossary. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.exports.download.download + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.exports.download.download """ glossary_export_path = self.get_glossary_export_path( @@ -179,7 +204,7 @@ def import_glossary( Import Glossary. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.imports.post + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.imports.post """ return self.requester.request( @@ -197,7 +222,7 @@ def check_glossary_import_status(self, glossaryId: int, importId: str): Check Glossary Import Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.imports.get + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.imports.get """ return self.requester.request( @@ -216,7 +241,7 @@ def concordance_search_in_glossaries( Concordance search in Glossaries Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.glossaries.concordance.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.glossaries.concordance.post """ data = { "sourceLanguageId": sourceLanguageId, @@ -237,10 +262,10 @@ def organization_concordance_search(self, request_data: OrganizationConcordanceS Concordance search in organization glossaries. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.concordance.post + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.concordance.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.glossaries.concordance.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.glossaries.concordance.post """ return self.requester.request( @@ -266,12 +291,16 @@ def list_terms( croql: Optional[str] = None, offset: Optional[int] = None, limit: Optional[int] = None, + translationOfTermId: Optional[int] = None, ): """ List Terms. + :param translationOfTermId: Filter terms by `termId`. Use for terms that have translations. + :param croql: Filter terms by CroQL. Can be used only with `orderBy`, `offset` and `limit`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.terms.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.terms.getMany """ params = { @@ -279,6 +308,7 @@ def list_terms( "userId": userId, "languageId": languageId, "conceptId": conceptId, + "translationOfTermId": translationOfTermId, "croql": croql, } @@ -303,12 +333,17 @@ def add_term( note: Optional[str] = None, url: Optional[str] = None, conceptId: Optional[int] = None, + fields: Optional[Dict[str, Any]] = None, ): """ Add Term. + :param conceptId: Defines whether to add translation to the existing term. If not + specified, a new concept will be automatically created for the term. + :param fields: Custom fields values. Keys get via List Fields. Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.terms.post + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.terms.post """ return self.requester.request( @@ -325,6 +360,7 @@ def add_term( "note": note, "url": url, "conceptId": conceptId, + "fields": fields, }, ) @@ -333,12 +369,18 @@ def clear_glossary( glossaryId: int, languageId: Optional[str] = None, conceptId: Optional[int] = None, + translationOfTermId: Optional[int] = None, ): """ Clear Glossary. + Without a filter every term is deleted. + + :param translationOfTermId: Defines whether to delete specific term along with its + translations. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.terms.deleteMany + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.terms.deleteMany """ return self.requester.request( @@ -347,6 +389,7 @@ def clear_glossary( params={ "languageId": languageId, "conceptId": conceptId, + "translationOfTermId": translationOfTermId, }, ) @@ -355,7 +398,7 @@ def get_term(self, glossaryId: int, termId: int): Get Term. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.terms.get + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.terms.get """ return self.requester.request( @@ -368,7 +411,7 @@ def delete_term(self, glossaryId: int, termId: int): Delete Term. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.terms.delete + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.terms.delete """ return self.requester.request( @@ -380,8 +423,11 @@ def edit_term(self, glossaryId: int, termId: int, data: Iterable[TermPatchReques """ Edit Term. + `TermPatchPath.FIELDS` (`/fields`) is Crowdin Enterprise only. A `replace` on `/fields` + writes the whole set of custom field values. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.terms.patch + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.terms.patch """ return self.requester.request( @@ -407,7 +453,7 @@ def list_concepts( List Concepts. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.concepts.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.concepts.getMany """ params = {"orderBy": orderBy} @@ -424,7 +470,7 @@ def get_concept(self, glossaryId: int, conceptId: int): Get Concept. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.concepts.get + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.concepts.get """ return self.requester.request( @@ -436,18 +482,23 @@ def update_concept( self, glossaryId: int, conceptId: int, - languagesDetails: Iterable[LanguagesDetails], + languagesDetails: Optional[Iterable[LanguagesDetails]] = None, subject: Optional[str] = None, definition: Optional[str] = None, note: Optional[str] = None, url: Optional[str] = None, figure: Optional[str] = None, + translatable: Optional[bool] = None, + fields: Optional[Dict[str, Any]] = None, ): """ - Get Concept. + Update Concept. + + :param fields: Custom fields values. Keys get via List Fields. Writes the whole set of + custom field values. Crowdin Enterprise only. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.concepts.put + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.concepts.put """ return self.requester.request( @@ -460,6 +511,8 @@ def update_concept( "note": note, "url": url, "figure": figure, + "translatable": translatable, + "fields": fields, }, ) @@ -468,7 +521,7 @@ def delete_concept(self, glossaryId: int, conceptId: int): Delete Concept. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.glossaries.concepts.delete + https://support.crowdin.com/developer/api/v2/#operation/api.glossaries.concepts.delete """ return self.requester.request( diff --git a/crowdin_api/api_resources/glossaries/tests/test_glossaries_resources.py b/crowdin_api/api_resources/glossaries/tests/test_glossaries_resources.py index 5b4095a0..f6c267ab 100644 --- a/crowdin_api/api_resources/glossaries/tests/test_glossaries_resources.py +++ b/crowdin_api/api_resources/glossaries/tests/test_glossaries_resources.py @@ -59,6 +59,8 @@ def test_get_screenshots_path(self, in_params, path, base_absolut_url): { "orderBy": None, "groupId": None, + "userId": None, + "filter": None, "offset": 0, "limit": 25, }, @@ -83,6 +85,8 @@ def test_get_screenshots_path(self, in_params, path, base_absolut_url): ] ), "groupId": 1, + "userId": None, + "filter": None, "offset": 0, "limit": 25, }, @@ -107,10 +111,23 @@ def test_get_screenshots_path(self, in_params, path, base_absolut_url): ] ), "groupId": 1, + "userId": None, + "filter": None, + "offset": 0, + "limit": 25, + }, + ), + ( + {"userId": 2, "filter": "name"}, + { + "orderBy": None, + "groupId": None, + "userId": 2, + "filter": "name", "offset": 0, "limit": 25, }, - ) + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -125,16 +142,29 @@ def test_list_glossaries(self, m_request, incoming_data, request_params, base_ab params=request_params, ) + @pytest.mark.parametrize( + "incoming_data, request_data", + ( + ( + {"name": "test", "languageId": "fr"}, + {"name": "test", "languageId": "fr", "isShared": None, "groupId": None}, + ), + ( + {"name": "test", "languageId": "fr", "isShared": True, "groupId": 2}, + {"name": "test", "languageId": "fr", "isShared": True, "groupId": 2}, + ), + ), + ) @mock.patch("crowdin_api.requester.APIRequester.request") - def test_add_glossary(self, m_request, base_absolut_url): + def test_add_glossary(self, m_request, incoming_data, request_data, base_absolut_url): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - assert resource.add_glossary(name="test", languageId="fr") == "response" + assert resource.add_glossary(**incoming_data) == "response" m_request.assert_called_once_with( method="post", path=resource.get_glossaries_path(), - request_data={"name": "test", "languageId": "fr"}, + request_data=request_data, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -166,7 +196,22 @@ def test_edit_glossary(self, m_request, base_absolut_url): "value": "test", "op": PatchOperation.REPLACE, "path": GlossaryPatchPath.NAME, - } + }, + { + "value": "uk", + "op": PatchOperation.REPLACE, + "path": GlossaryPatchPath.LANGUAGE_ID, + }, + { + "value": True, + "op": PatchOperation.REPLACE, + "path": GlossaryPatchPath.IS_SHARED, + }, + { + "value": 1, + "op": PatchOperation.REPLACE, + "path": GlossaryPatchPath.GROUP_ID, + }, ] resource = self.get_resource(base_absolut_url) @@ -258,6 +303,32 @@ def test_get_glossary_export_path(self, in_params, path, base_absolut_url): "authorId": 12, }, ), + ( + { + "text": "value", + "caseSensitive": True, + "searchStrict": True, + "searchFullMatch": False, + "dateFrom": "2024-01-23T07:00:14+00:00", + "dateTo": "2024-09-27T07:00:14+00:00", + "exportFields": [ + GlossaryExportFields.CONCEPT_DEFINITION, + "field_custom-field", + ], + }, + { + "text": "value", + "caseSensitive": True, + "searchStrict": True, + "searchFullMatch": False, + "dateFrom": "2024-01-23T07:00:14+00:00", + "dateTo": "2024-09-27T07:00:14+00:00", + "exportFields": [ + GlossaryExportFields.CONCEPT_DEFINITION, + "field_custom-field", + ], + }, + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -404,6 +475,7 @@ def test_get_terms_path(self, in_params, path, base_absolut_url): "userId": None, "languageId": None, "conceptId": None, + "translationOfTermId": None, "croql": None, "offset": 0, "limit": 25, @@ -417,6 +489,7 @@ def test_get_terms_path(self, in_params, path, base_absolut_url): "userId": 1, "languageId": "ua", "conceptId": 2, + "translationOfTermId": 3, "croql": "status = 'preferred'", }, { @@ -426,6 +499,7 @@ def test_get_terms_path(self, in_params, path, base_absolut_url): "userId": 1, "languageId": "ua", "conceptId": 2, + "translationOfTermId": 3, "croql": "status = 'preferred'", "offset": 0, "limit": 25, @@ -460,6 +534,7 @@ def test_list_terms(self, m_request, incoming_data, request_params, base_absolut "note": None, "url": None, "conceptId": None, + "fields": None, }, ), ( @@ -468,24 +543,26 @@ def test_list_terms(self, m_request, incoming_data, request_params, base_absolut "text": "text", "description": "description", "partOfSpeech": TermPartOfSpeech.PARTICLE, - "status": TermStatus.ADMITTED, + "status": TermStatus.DRAFT, "type": TermType.SHORT_FORM, "gender": TermGender.MASCULINE, "note": "text", "url": "https://test.test.com", "conceptId": 1, + "fields": {"some-field": "value"}, }, { "languageId": "ua", "text": "text", "description": "description", "partOfSpeech": TermPartOfSpeech.PARTICLE, - "status": TermStatus.ADMITTED, + "status": TermStatus.DRAFT, "type": TermType.SHORT_FORM, "gender": TermGender.MASCULINE, "note": "text", "url": "https://test.test.com", "conceptId": 1, + "fields": {"some-field": "value"}, }, ), ), @@ -507,11 +584,11 @@ def test_add_term(self, m_request, incoming_data, request_data, base_absolut_url ( ( {}, - {"languageId": None, "conceptId": None}, + {"languageId": None, "conceptId": None, "translationOfTermId": None}, ), ( - {"languageId": "ua", "conceptId": 1}, - {"languageId": "ua", "conceptId": 1}, + {"languageId": "ua", "conceptId": 1, "translationOfTermId": 2}, + {"languageId": "ua", "conceptId": 1, "translationOfTermId": 2}, ), ), ) @@ -556,7 +633,12 @@ def test_edit_term(self, m_request, base_absolut_url): "value": "test", "op": PatchOperation.REPLACE, "path": TermPatchPath.TEXT, - } + }, + { + "value": {"some-field": "value"}, + "op": PatchOperation.REPLACE, + "path": TermPatchPath.FIELDS, + }, ] resource = self.get_resource(base_absolut_url) @@ -648,6 +730,21 @@ def test_get_concept(self, m_request, base_absolut_url): "note": None, "url": None, "figure": None, + "translatable": None, + "fields": None, + }, + ), + ( + {}, + { + "languagesDetails": None, + "subject": None, + "definition": None, + "note": None, + "url": None, + "figure": None, + "translatable": None, + "fields": None, }, ), ( @@ -660,6 +757,8 @@ def test_get_concept(self, m_request, base_absolut_url): "note": "Any concept-level note information", "url": "https://test.test.com", "figure": "string", + "translatable": False, + "fields": {"some-field": 12}, }, { "languagesDetails": [ @@ -670,6 +769,8 @@ def test_get_concept(self, m_request, base_absolut_url): "note": "Any concept-level note information", "url": "https://test.test.com", "figure": "string", + "translatable": False, + "fields": {"some-field": 12}, }, ), ), diff --git a/crowdin_api/api_resources/glossaries/types.py b/crowdin_api/api_resources/glossaries/types.py index df5861e5..c6b42fe9 100644 --- a/crowdin_api/api_resources/glossaries/types.py +++ b/crowdin_api/api_resources/glossaries/types.py @@ -1,4 +1,4 @@ -from typing import Any, Iterable, Optional +from typing import Any, Iterable, Optional, Union from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.api_resources.glossaries.enums import ( @@ -23,7 +23,8 @@ class GlossaryPatchRequest(TypedDict): class GlossarySchemaRequest(TypedDict): format: Optional[GlossaryFormat] - exportFields: Optional[Iterable[GlossaryExportFields]] + # Enterprise also accepts custom field slugs: `field_{fieldSlug}` / `conceptField_{fieldSlug}` + exportFields: Optional[Iterable[Union[GlossaryExportFields, str]]] exportType: Optional[GlossaryExportType] statuses: Optional[Iterable[GlossaryExportStatus]] partsOfSpeech: Optional[Iterable[GlossaryExportPartOfSpeech]] @@ -31,6 +32,14 @@ class GlossarySchemaRequest(TypedDict): genders: Optional[Iterable[GlossaryExportGender]] authorIds: Optional[Iterable[int]] languageIds: Optional[Iterable[str]] + text: Optional[str] + # caseSensitive, searchStrict and searchFullMatch must be used together with `text` + caseSensitive: Optional[bool] + searchStrict: Optional[bool] + searchFullMatch: Optional[bool] + # UTC, ISO 8601, e.g. "2024-01-23T07:00:14+00:00" + dateFrom: Optional[str] + dateTo: Optional[str] # Deprecated in favor of the plural filters above; the API rejects requests combining a # singular filter with its plural counterpart. status: Optional[GlossaryExportStatus] @@ -44,7 +53,7 @@ class OrganizationConcordanceSearchRequest(TypedDict): sourceLanguageId: str targetLanguageId: str expressions: Iterable[str] - userId: Optional[int] + userId: Optional[int] # Crowdin only class TermPatchRequest(TypedDict): diff --git a/crowdin_api/api_resources/groups/enums.py b/crowdin_api/api_resources/groups/enums.py index c2f09b43..dd469ecf 100644 --- a/crowdin_api/api_resources/groups/enums.py +++ b/crowdin_api/api_resources/groups/enums.py @@ -5,6 +5,7 @@ class GroupPatchPath(Enum): NAME = "/name" DESCRIPTION = "/description" PARENT_ID = "/parentId" + SAVINGS_REPORT_SETTINGS_TEMPLATE_ID = "/savingsReportSettingsTemplateId" class ListGroupsOrderBy(Enum): diff --git a/crowdin_api/api_resources/groups/resource.py b/crowdin_api/api_resources/groups/resource.py index 339e4dbf..a5d72cf6 100644 --- a/crowdin_api/api_resources/groups/resource.py +++ b/crowdin_api/api_resources/groups/resource.py @@ -16,7 +16,7 @@ class GroupsResource(BaseResource): organization if required. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Projects-and-Groups + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Projects-and-Groups """ # Glossaries @@ -31,7 +31,7 @@ def get_group(self, groupId: int): Get Group. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.get """ return self.requester.request( @@ -43,13 +43,14 @@ def add_group( self, name: str, parentId: Optional[int] = None, - description: Optional[str] = None + description: Optional[str] = None, + savingsReportSettingsTemplateId: Optional[int] = None, ): """ Add Group. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.post """ return self.requester.request( @@ -58,7 +59,8 @@ def add_group( request_data={ "name": name, "parentId": parentId, - "description": description + "description": description, + "savingsReportSettingsTemplateId": savingsReportSettingsTemplateId, } ) @@ -67,16 +69,19 @@ def list_groups( orderBy: Optional[Sorting] = None, parentId: Optional[int] = None, limit: Optional[int] = None, - offset: Optional[int] = None + offset: Optional[int] = None, + filter: Optional[str] = None, ): """ List Groups. + :param filter: Filter groups by `name` + Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.getMany """ - params = {"orderBy": orderBy, "parentId": parentId} + params = {"orderBy": orderBy, "parentId": parentId, "filter": filter} params.update(self.get_page_params(offset=offset, limit=limit)) return self._get_entire_data( @@ -90,7 +95,7 @@ def edit_group(self, groupId: int, data: Iterable[GroupPatchRequest]): Edit Group. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.patch """ return self.requester.request( @@ -104,7 +109,7 @@ def delete_group(self, groupId: int): Delete Group. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.delete """ return self.requester.request( diff --git a/crowdin_api/api_resources/groups/tests/test_groups_resources.py b/crowdin_api/api_resources/groups/tests/test_groups_resources.py index 3ec8adcf..35910a99 100644 --- a/crowdin_api/api_resources/groups/tests/test_groups_resources.py +++ b/crowdin_api/api_resources/groups/tests/test_groups_resources.py @@ -54,6 +54,7 @@ def test_delete_group(self, m_request, base_absolut_url): { "orderBy": None, "parentId": None, + "filter": None, "limit": 25, "offset": 0, }, @@ -64,6 +65,7 @@ def test_delete_group(self, m_request, base_absolut_url): [SortingRule(ListGroupsOrderBy.NAME, SortingOrder.DESC)] ), "parentId": "test", + "filter": "name", "limit": 10, "offset": 2, }, @@ -72,6 +74,7 @@ def test_delete_group(self, m_request, base_absolut_url): [SortingRule(ListGroupsOrderBy.NAME, SortingOrder.DESC)] ), "parentId": "test", + "filter": "name", "limit": 10, "offset": 2, }, @@ -99,6 +102,7 @@ def test_list_groups(self, m_request, incoming_data, request_params, base_absolu "name": "test_name", "parentId": None, "description": None, + "savingsReportSettingsTemplateId": None, }, ), ( @@ -106,11 +110,13 @@ def test_list_groups(self, m_request, incoming_data, request_params, base_absolu "name": "test_name", "parentId": 2, "description": "some text", + "savingsReportSettingsTemplateId": 3, }, { "name": "test_name", "parentId": 2, "description": "some text", + "savingsReportSettingsTemplateId": 3, }, ), ), diff --git a/crowdin_api/api_resources/labels/resource.py b/crowdin_api/api_resources/labels/resource.py index 2caef915..9729e678 100644 --- a/crowdin_api/api_resources/labels/resource.py +++ b/crowdin_api/api_resources/labels/resource.py @@ -1,8 +1,9 @@ -from typing import Iterable, Optional +from typing import Iterable, Optional, Union from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.labels.types import LabelsPatchRequest from crowdin_api.sorting import Sorting +from crowdin_api.utils import convert_to_query_list class LabelsResource(BaseResource): @@ -10,7 +11,10 @@ class LabelsResource(BaseResource): Resource for Labels. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Labels + https://support.crowdin.com/developer/api/v2/#tag/Labels + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Labels """ def get_labels_path(self, projectId: int, labelId: Optional[int] = None): @@ -26,16 +30,23 @@ def list_labels( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + isSystem: Optional[Union[bool, int]] = None, ): """ List Labels. + `isSystem` (filter by system labels) is for string-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.getMany + https://support.crowdin.com/developer/api/v2/string-based/#operation/api.projects.labels.getMany """ projectId = projectId or self.get_project_id() - params = {"orderBy": orderBy} + params = { + "orderBy": orderBy, + "isSystem": None if isSystem is None else int(isSystem), + } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) return self._get_entire_data( @@ -49,7 +60,7 @@ def add_label(self, title: str, projectId: Optional[int] = None): Add Label. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.post """ projectId = projectId or self.get_project_id() @@ -65,7 +76,7 @@ def get_label(self, labelId: int, projectId: Optional[int] = None): Get Label. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.get """ projectId = projectId or self.get_project_id() @@ -80,7 +91,7 @@ def delete_label(self, labelId: int, projectId: Optional[int] = None): Delete Label. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.delete """ projectId = projectId or self.get_project_id() @@ -100,7 +111,7 @@ def edit_label( Edit Label. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.patch """ projectId = projectId or self.get_project_id() @@ -117,15 +128,15 @@ def get_screenshots_path(self, project_id: int, label_id: int): def assign_label_to_screenshots( self, label_id: int, - screenshot_ids: Iterable[int], + screenshot_ids: Union[int, Iterable[int]], project_id: Optional[int] = None, ): """ Assign Label to Screenshots Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.screenshots.post - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.labels.screenshots.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.screenshots.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.labels.screenshots.post """ project_id = project_id or self.get_project_id() @@ -134,64 +145,78 @@ def assign_label_to_screenshots( method="post", path=self.get_screenshots_path(project_id, label_id), request_data={ - "screenshotIds": screenshot_ids + "screenshotIds": [screenshot_ids] if isinstance(screenshot_ids, int) else screenshot_ids } ) def unassign_label_from_screenshots( self, label_id: int, - screenshot_ids: Iterable[int], + screenshot_ids: Union[int, Iterable[int]], project_id: Optional[int] = None, ): """ Unassign Label from Screenshots + `screenshot_ids` can be a single screenshot identifier or an iterable of them + (up to 500 at a time). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.screenshots.deleteMany - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.labels.screenshots.deleteMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.screenshots.deleteMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.labels.screenshots.deleteMany """ project_id = project_id or self.get_project_id() - query = ",".join(str(screenshot_id) for screenshot_id in screenshot_ids) return self.requester.request( method="delete", - path=f"{self.get_screenshots_path(project_id, label_id)}?screenshotIds={query}" + params={"screenshotIds": convert_to_query_list(screenshot_ids)}, + path=self.get_screenshots_path(project_id, label_id), ) def assign_label_to_strings( - self, labelId: int, stringIds: Iterable[int], projectId: Optional[int] = None + self, + labelId: int, + stringIds: Union[int, Iterable[int]], + projectId: Optional[int] = None, ): """ Assign Label to Strings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.strings.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.strings.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.labels.strings.post """ projectId = projectId or self.get_project_id() return self.requester.request( method="post", - request_data={"stringIds": stringIds}, + request_data={"stringIds": [stringIds] if isinstance(stringIds, int) else stringIds}, path=f"{self.get_labels_path(projectId=projectId, labelId=labelId)}/strings", ) def unassign_label_from_strings( - self, labelId: int, stringIds: Iterable[int], projectId: Optional[int] = None + self, + labelId: int, + stringIds: Union[int, Iterable[int]], + projectId: Optional[int] = None, ): """ Unassign Label from Strings. + `stringIds` can be a single string identifier or an iterable of them + (up to 500 at a time). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.labels.strings.deleteMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.labels.strings.deleteMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.labels.strings.deleteMany """ projectId = projectId or self.get_project_id() return self.requester.request( method="delete", - params={"stringIds": ",".join(str(stringId) for stringId in stringIds)}, + params={"stringIds": convert_to_query_list(stringIds)}, path=f"{self.get_labels_path(projectId=projectId, labelId=labelId)}/strings", ) diff --git a/crowdin_api/api_resources/labels/tests/test_labels_resources.py b/crowdin_api/api_resources/labels/tests/test_labels_resources.py index 035dfd30..a0288c9e 100644 --- a/crowdin_api/api_resources/labels/tests/test_labels_resources.py +++ b/crowdin_api/api_resources/labels/tests/test_labels_resources.py @@ -39,6 +39,25 @@ def test_get_labels_path(self, in_params, path, base_absolut_url): {}, { "orderBy": None, + "isSystem": None, + "limit": 25, + "offset": 0, + }, + ), + ( + {"isSystem": True}, + { + "orderBy": None, + "isSystem": 1, + "limit": 25, + "offset": 0, + }, + ), + ( + {"isSystem": 0}, + { + "orderBy": None, + "isSystem": 0, "limit": 25, "offset": 0, }, @@ -55,6 +74,7 @@ def test_get_labels_path(self, in_params, path, base_absolut_url): "orderBy": Sorting( [SortingRule(ListLabelsOrderBy.ID, SortingOrder.DESC)] ), + "isSystem": None, "limit": 25, "offset": 0, }, @@ -62,7 +82,7 @@ def test_get_labels_path(self, in_params, path, base_absolut_url): ), ) @mock.patch("crowdin_api.requester.APIRequester.request") - def test_list_translation_approvals( + def test_list_labels( self, m_request, incoming_data, request_params, base_absolut_url ): m_request.return_value = "response" @@ -135,7 +155,13 @@ def test_edit_edit_label(self, m_request, base_absolut_url): { "screenshotIds": [1, 2, 3] } - ) + ), + ( + 5, + { + "screenshotIds": [5] + } + ), ] ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -158,7 +184,15 @@ def test_assign_label_to_screenshots(self, m_request, in_params, body, base_abso ( [1, 2, 3], "1,2,3" - ) + ), + ( + (4, 5), + "4,5" + ), + ( + 7, + 7 + ), ] ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -171,7 +205,8 @@ def test_unassign_label_to_screenshots(self, m_request, in_params, query_string, ) m_request.assert_called_once_with( method="delete", - path=f"{resource.get_screenshots_path(1, 2)}?screenshotIds={query_string}" + params={"screenshotIds": query_string}, + path=resource.get_screenshots_path(1, 2), ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -202,3 +237,39 @@ def test_unassign_label_from_strings(self, m_request, base_absolut_url): method="delete", path=f"{resource.get_labels_path(projectId=1, labelId=2)}/strings", ) + + @pytest.mark.parametrize( + "in_params, query_string", + [ + ([1, 2], "1,2"), + (3, 3), + ] + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_unassign_label_from_strings_single_and_many( + self, m_request, in_params, query_string, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.unassign_label_from_strings(projectId=1, labelId=2, stringIds=in_params) + == "response" + ) + m_request.assert_called_once_with( + params={"stringIds": query_string}, + method="delete", + path="projects/1/labels/2/strings", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_assign_label_to_strings_single_id(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.assign_label_to_strings(projectId=1, labelId=2, stringIds=3) == "response" + m_request.assert_called_once_with( + request_data={"stringIds": [3]}, + method="post", + path="projects/1/labels/2/strings", + ) diff --git a/crowdin_api/api_resources/languages/resource.py b/crowdin_api/api_resources/languages/resource.py index bc245743..2189cc95 100644 --- a/crowdin_api/api_resources/languages/resource.py +++ b/crowdin_api/api_resources/languages/resource.py @@ -14,7 +14,7 @@ class LanguagesResource(BaseResource): Use API to get the list of all supported languages and retrieve additional details (e.g. text direction, internal code) on specific language. - Link to documentation: https://developer.crowdin.com/api/v2/#tag/Languages + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/Languages """ def get_languages_path(self, languageId: Optional[str] = None): @@ -33,7 +33,7 @@ def list_supported_languages( List Supported Languages. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.languages.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.languages.getMany """ return self._get_entire_data( @@ -57,7 +57,7 @@ def add_custom_language( Add Custom Language. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.languages.post + https://support.crowdin.com/developer/api/v2/#operation/api.languages.post """ return self.requester.request( @@ -80,7 +80,7 @@ def get_language(self, languageId: str): Get Language. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.languages.get + https://support.crowdin.com/developer/api/v2/#operation/api.languages.get """ return self.requester.request( @@ -92,7 +92,7 @@ def delete_custom_language(self, languageId: str): Delete Custom Language. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.languages.delete + https://support.crowdin.com/developer/api/v2/#operation/api.languages.delete """ return self.requester.request( @@ -104,7 +104,7 @@ def edit_custom_language(self, languageId: str, data: Iterable[LanguagesPatchReq Edit Custom Language. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.languages.patch + https://support.crowdin.com/developer/api/v2/#operation/api.languages.patch """ return self.requester.request( diff --git a/crowdin_api/api_resources/machine_translation_engines/enums.py b/crowdin_api/api_resources/machine_translation_engines/enums.py index 35749430..08edc4db 100644 --- a/crowdin_api/api_resources/machine_translation_engines/enums.py +++ b/crowdin_api/api_resources/machine_translation_engines/enums.py @@ -4,3 +4,22 @@ class LanguageRecognitionProvider(Enum): CROWDIN = "crowdin" ENGINE = "engine" + + +class MachineTranslationEngineType(Enum): + GOOGLE = "google" + GOOGLE_AUTOML = "google_automl" + MICROSOFT = "microsoft" + DEEPL = "deepl" + AMAZON = "amazon" + MODERNMT = "modernmt" + CUSTOM_MT = "custom_mt" + + +class MachineTranslationEnginePatchPath(Enum): + NAME = "/name" + TYPE = "/type" + CREDENTIALS = "/credentials" + ENABLED_LANGUAGE_IDS = "/enabledLanguageIds" + ENABLED_PROJECT_IDS = "/enabledProjectIds" + IS_ENABLED = "/isEnabled" diff --git a/crowdin_api/api_resources/machine_translation_engines/resource.py b/crowdin_api/api_resources/machine_translation_engines/resource.py index affc3ca9..f43b0a34 100644 --- a/crowdin_api/api_resources/machine_translation_engines/resource.py +++ b/crowdin_api/api_resources/machine_translation_engines/resource.py @@ -1,7 +1,8 @@ from typing import Optional, Iterable from crowdin_api.api_resources.abstract.resources import BaseResource -from .enums import LanguageRecognitionProvider +from .enums import LanguageRecognitionProvider, MachineTranslationEngineType +from .types import MachineTranslationEngineCredentials, MachineTranslationEnginePatchRequest class MachineTranslationEnginesResource(BaseResource): @@ -13,7 +14,7 @@ class MachineTranslationEnginesResource(BaseResource): Use API to add, update, and delete specific MTE. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Machine-Translation-Engines + https://support.crowdin.com/developer/api/v2/#tag/Machine-Translation-Engines """ def get_mts_path(self, mtId: Optional[int] = None): @@ -27,7 +28,7 @@ def list_mts(self, limit: Optional[int] = None, offset: Optional[int] = None): List MTs. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.mts.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.mts.getMany """ return self._get_entire_data( @@ -41,7 +42,7 @@ def get_mt(self, mtId: int): Get MT. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.mts.get + https://support.crowdin.com/developer/api/v2/#operation/api.mts.get """ return self.requester.request(method="get", path=self.get_mts_path(mtId=mtId)) @@ -58,7 +59,7 @@ def translate_via_mt( Create Translate via MT. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.mts.translations.post + https://support.crowdin.com/developer/api/v2/#operation/api.mts.translations.post """ return self.requester.request( method="post", @@ -70,3 +71,94 @@ def translate_via_mt( "strings": strings, }, ) + + +class EnterpriseMachineTranslationEnginesResource(MachineTranslationEnginesResource): + """ + Resource for Machine Translation Engines (Crowdin Enterprise). + + Machine Translation Engines (MTE) are the sources for pre-translations. + + Use API to add, update, and delete specific MTE. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Machine-Translation-Engines + """ + + def list_mts( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + groupId: Optional[int] = None, + ): + """ + List MTs. + + :param groupId: Group Identifier. Set 0 to see MTs of root group + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.mts.getMany + """ + params = {"groupId": groupId} + params.update(self.get_page_params(offset=offset, limit=limit)) + + return self._get_entire_data( + method="get", + path=self.get_mts_path(), + params=params, + ) + + def add_mt( + self, + name: str, + type: MachineTranslationEngineType, + credentials: MachineTranslationEngineCredentials, + groupId: Optional[int] = None, + enabledLanguageIds: Optional[Iterable[str]] = None, + enabledProjectIds: Optional[Iterable[int]] = None, + isEnabled: Optional[bool] = None, + ): + """ + Add MT. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.mts.post + """ + return self.requester.request( + method="post", + path=self.get_mts_path(), + request_data={ + "name": name, + "type": type, + "credentials": credentials, + "groupId": groupId, + "enabledLanguageIds": enabledLanguageIds, + "enabledProjectIds": enabledProjectIds, + "isEnabled": isEnabled, + }, + ) + + def edit_mt(self, mtId: int, data: Iterable[MachineTranslationEnginePatchRequest]): + """ + Edit MT. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.mts.patch + """ + return self.requester.request( + method="patch", + path=self.get_mts_path(mtId=mtId), + request_data=data, + ) + + def delete_mt(self, mtId: int): + """ + Delete MT. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.mts.delete + """ + return self.requester.request( + method="delete", + path=self.get_mts_path(mtId=mtId), + ) diff --git a/crowdin_api/api_resources/machine_translation_engines/tests/test_machine_translation_engines_resources.py b/crowdin_api/api_resources/machine_translation_engines/tests/test_machine_translation_engines_resources.py index 54e48012..22023fb4 100644 --- a/crowdin_api/api_resources/machine_translation_engines/tests/test_machine_translation_engines_resources.py +++ b/crowdin_api/api_resources/machine_translation_engines/tests/test_machine_translation_engines_resources.py @@ -3,6 +3,14 @@ import pytest from crowdin_api.api_resources import MachineTranslationEnginesResource +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.machine_translation_engines.enums import ( + MachineTranslationEnginePatchPath, + MachineTranslationEngineType, +) +from crowdin_api.api_resources.machine_translation_engines.resource import ( + EnterpriseMachineTranslationEnginesResource, +) from crowdin_api.requester import APIRequester @@ -87,3 +95,112 @@ def test_translate_via_mt( path=resource.get_mts_path(mtId=1) + "/translations", request_data=request_data, ) + + +class TestEnterpriseMachineTranslationEnginesResource: + resource_class = EnterpriseMachineTranslationEnginesResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"groupId": None, "offset": 0, "limit": 25}), + ( + {"groupId": 0, "offset": 10, "limit": 5}, + {"groupId": 0, "offset": 10, "limit": 5}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_mts(self, m_request, in_params, request_params, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_mts(**in_params) == "response" + m_request.assert_called_once_with( + method="get", + params=request_params, + path="mts", + ) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + { + "name": "DeepL", + "type": MachineTranslationEngineType.DEEPL, + "credentials": {"apiKey": "key"}, + }, + { + "name": "DeepL", + "type": MachineTranslationEngineType.DEEPL, + "credentials": {"apiKey": "key"}, + "groupId": None, + "enabledLanguageIds": None, + "enabledProjectIds": None, + "isEnabled": None, + }, + ), + ( + { + "name": "Amazon", + "type": MachineTranslationEngineType.AMAZON, + "credentials": {"accessKey": "a", "secretKey": "s"}, + "groupId": 2, + "enabledLanguageIds": ["uk"], + "enabledProjectIds": [22], + "isEnabled": True, + }, + { + "name": "Amazon", + "type": MachineTranslationEngineType.AMAZON, + "credentials": {"accessKey": "a", "secretKey": "s"}, + "groupId": 2, + "enabledLanguageIds": ["uk"], + "enabledProjectIds": [22], + "isEnabled": True, + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_mt(self, m_request, in_params, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.add_mt(**in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="mts", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_mt(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": MachineTranslationEnginePatchPath.IS_ENABLED, + "value": False, + } + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_mt(mtId=1, data=data) == "response" + m_request.assert_called_once_with( + method="patch", + path="mts/1", + request_data=data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_mt(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_mt(mtId=1) == "response" + m_request.assert_called_once_with(method="delete", path="mts/1") diff --git a/crowdin_api/api_resources/machine_translation_engines/types.py b/crowdin_api/api_resources/machine_translation_engines/types.py new file mode 100644 index 00000000..4d0f05d2 --- /dev/null +++ b/crowdin_api/api_resources/machine_translation_engines/types.py @@ -0,0 +1,48 @@ +from typing import Any, Union + +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.machine_translation_engines.enums import ( + MachineTranslationEnginePatchPath, +) +from crowdin_api.typing import TypedDict + + +class MachineTranslationEngineApiKeyCredentials(TypedDict): + """Google Translate, DeepL Pro and ModernMT credentials.""" + + apiKey: str + + +class MachineTranslationEngineGoogleAutoMLCredentials(TypedDict): + """Google AutoML Translate credentials (JSON file, base64 encoded).""" + + credentials: str + + +class MachineTranslationEngineMicrosoftCredentials(TypedDict, total=False): + apiKey: str + model: str + + +class MachineTranslationEngineAmazonCredentials(TypedDict): + accessKey: str + secretKey: str + + +class MachineTranslationEngineCustomMTCredentials(TypedDict): + url: str + + +MachineTranslationEngineCredentials = Union[ + MachineTranslationEngineApiKeyCredentials, + MachineTranslationEngineGoogleAutoMLCredentials, + MachineTranslationEngineMicrosoftCredentials, + MachineTranslationEngineAmazonCredentials, + MachineTranslationEngineCustomMTCredentials, +] + + +class MachineTranslationEnginePatchRequest(TypedDict): + op: PatchOperation + path: MachineTranslationEnginePatchPath + value: Any diff --git a/crowdin_api/api_resources/notifications/enums.py b/crowdin_api/api_resources/notifications/enums.py index 166ca50a..24bb27b7 100644 --- a/crowdin_api/api_resources/notifications/enums.py +++ b/crowdin_api/api_resources/notifications/enums.py @@ -2,5 +2,18 @@ class MemberRole(Enum): + """ + Roles for project notifications (`POST /projects/{projectId}/notify`). + """ + OWNER = "owner" MANAGER = "manager" + + +class OrganizationMemberRole(Enum): + """ + Roles for organization notifications (`POST /notify`, Crowdin Enterprise only). + """ + + OWNER = "owner" + ADMIN = "admin" diff --git a/crowdin_api/api_resources/notifications/resource.py b/crowdin_api/api_resources/notifications/resource.py index 8d1013a2..7a811b05 100644 --- a/crowdin_api/api_resources/notifications/resource.py +++ b/crowdin_api/api_resources/notifications/resource.py @@ -4,15 +4,22 @@ from crowdin_api.api_resources.notifications.types import ( ByRoleRequestScehme, ByUserIdsRequestScheme, + ByOrganizationRoleRequestScheme, ) class NotificationResource(BaseResource): """ - Resource for Notifications + Resource for Notifications. - Link to documetation: - https://developer.crowdin.com/api/v2/#tag/Notifications + Available on both Crowdin and Crowdin Enterprise. + `send_notification_to_organization_members` is Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Notifications + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Notifications """ def send_notification_to_authenticated_user(self, message: str): @@ -20,7 +27,10 @@ def send_notification_to_authenticated_user(self, message: str): Send Notification to Authenticated User Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.notify.post + https://support.crowdin.com/developer/api/v2/#operation/api.notify.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.notify.post """ return self.requester.request( method="post", path="notify", request_data={"message": message} @@ -35,11 +45,10 @@ def send_notification_to_project_members( Send Notification To Project Members Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.notify.post - + https://support.crowdin.com/developer/api/v2/#operation/api.projects.notify.post Link to documentation (Enterprise): - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.notify.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.notify.post """ projectId = projectId or self.get_project_id() @@ -52,13 +61,16 @@ def send_notification_to_project_members( def send_notification_to_organization_members( self, - request_data: Union[ByUserIdsRequestScheme, ByRoleRequestScehme], + request_data: Union[ByUserIdsRequestScheme, ByOrganizationRoleRequestScheme], ): """ Send Notification To Organization Members + Crowdin Enterprise only. Notify organization members by `userIds` or by `role` + (`owner` or `admin`). + Link to documentation (Enterprise): - https://developer.crowdin.com/enterprise/api/v2/#operation/api.notify.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.notify.post """ return self.requester.request( method="post", path="notify", request_data=request_data diff --git a/crowdin_api/api_resources/notifications/tests/test_notification_resources.py b/crowdin_api/api_resources/notifications/tests/test_notification_resources.py index f619b4ba..03b3ba2d 100644 --- a/crowdin_api/api_resources/notifications/tests/test_notification_resources.py +++ b/crowdin_api/api_resources/notifications/tests/test_notification_resources.py @@ -3,7 +3,7 @@ from crowdin_api.requester import APIRequester from crowdin_api.api_resources.notifications.resource import NotificationResource -from crowdin_api.api_resources.notifications.enums import MemberRole +from crowdin_api.api_resources.notifications.enums import MemberRole, OrganizationMemberRole class TestNotificationResource: @@ -114,21 +114,21 @@ def test_send_notification_to_project_members( ), ( { - "role": MemberRole.OWNER, + "role": OrganizationMemberRole.OWNER, "message": "TEST MESSAGE", }, { - "role": MemberRole.OWNER, + "role": OrganizationMemberRole.OWNER, "message": "TEST MESSAGE", }, ), ( { - "role": MemberRole.MANAGER, + "role": OrganizationMemberRole.ADMIN, "message": "TEST MESSAGE", }, { - "role": MemberRole.MANAGER, + "role": OrganizationMemberRole.ADMIN, "message": "TEST MESSAGE", }, ), diff --git a/crowdin_api/api_resources/notifications/types.py b/crowdin_api/api_resources/notifications/types.py index aab665be..885fbbe0 100644 --- a/crowdin_api/api_resources/notifications/types.py +++ b/crowdin_api/api_resources/notifications/types.py @@ -1,5 +1,7 @@ -from typing import TypedDict, Iterable -from crowdin_api.api_resources.notifications.enums import MemberRole +from typing import Iterable + +from crowdin_api.api_resources.notifications.enums import MemberRole, OrganizationMemberRole +from crowdin_api.typing import TypedDict class ByRoleRequestScehme(TypedDict): @@ -10,3 +12,8 @@ class ByRoleRequestScehme(TypedDict): class ByUserIdsRequestScheme(TypedDict): userIds: Iterable[int] message: str + + +class ByOrganizationRoleRequestScheme(TypedDict): + role: OrganizationMemberRole + message: str diff --git a/crowdin_api/api_resources/organization/__init__.py b/crowdin_api/api_resources/organization/__init__.py new file mode 100644 index 00000000..9f77d639 --- /dev/null +++ b/crowdin_api/api_resources/organization/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {'tests': False} diff --git a/crowdin_api/api_resources/organization/resource.py b/crowdin_api/api_resources/organization/resource.py new file mode 100644 index 00000000..467ba1a6 --- /dev/null +++ b/crowdin_api/api_resources/organization/resource.py @@ -0,0 +1,36 @@ +from crowdin_api.api_resources.abstract.resources import BaseResource + + +class OrganizationResource(BaseResource): + """ + Resource for Organization. + + Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Organization + """ + + def get_organization_path(self): + return "organization" + + def get_organization(self): + """ + Get Organization. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.organization.get + """ + return self.requester.request(method="get", path=self.get_organization_path()) + + def get_organization_auth_settings(self): + """ + Get Organization Auth Settings. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.organization.auth-settings.get + """ + return self.requester.request( + method="get", + path=f"{self.get_organization_path()}/auth-settings", + ) diff --git a/crowdin_api/api_resources/organization/tests/test_organization_resources.py b/crowdin_api/api_resources/organization/tests/test_organization_resources.py new file mode 100644 index 00000000..a7c6b24b --- /dev/null +++ b/crowdin_api/api_resources/organization/tests/test_organization_resources.py @@ -0,0 +1,31 @@ +from unittest import mock + +from crowdin_api.api_resources.organization.resource import OrganizationResource +from crowdin_api.requester import APIRequester + + +class TestOrganizationResource: + resource_class = OrganizationResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + def test_get_organization_path(self, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_organization_path() == "organization" + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_organization(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_organization() == "response" + m_request.assert_called_once_with(method="get", path="organization") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_organization_auth_settings(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_organization_auth_settings() == "response" + m_request.assert_called_once_with(method="get", path="organization/auth-settings") diff --git a/crowdin_api/api_resources/project_placeholders/__init__.py b/crowdin_api/api_resources/project_placeholders/__init__.py new file mode 100644 index 00000000..f5406199 --- /dev/null +++ b/crowdin_api/api_resources/project_placeholders/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {"tests": False} diff --git a/crowdin_api/api_resources/project_placeholders/enums.py b/crowdin_api/api_resources/project_placeholders/enums.py new file mode 100644 index 00000000..2a18c673 --- /dev/null +++ b/crowdin_api/api_resources/project_placeholders/enums.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class ProjectPlaceholderType(Enum): + HIGH = "high" + LOW = "low" + + +class ProjectPlaceholderPatchPath(Enum): + INDEX = "/index" + TYPE = "/type" + IS_BLOCKING = "/isBlocking" + FORMATS = "/formats" diff --git a/crowdin_api/api_resources/project_placeholders/resource.py b/crowdin_api/api_resources/project_placeholders/resource.py new file mode 100644 index 00000000..4933d58d --- /dev/null +++ b/crowdin_api/api_resources/project_placeholders/resource.py @@ -0,0 +1,136 @@ +from typing import Iterable, Optional + +from crowdin_api.api_resources.abstract.resources import BaseResource +from crowdin_api.api_resources.project_placeholders.enums import ProjectPlaceholderType +from crowdin_api.api_resources.project_placeholders.types import ProjectPlaceholderPatchRequest + + +class ProjectPlaceholdersResource(BaseResource): + """ + Resource for Project Placeholders. + + Custom placeholders of the organization assigned to a project. Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Placeholders + """ + + def get_project_placeholders_path( + self, projectId: int, projectPlaceholderId: Optional[int] = None + ): + if projectPlaceholderId is not None: + return f"projects/{projectId}/placeholders/{projectPlaceholderId}" + + return f"projects/{projectId}/placeholders" + + def list_project_placeholders( + self, + projectId: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List Project Placeholders. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.placeholders.getMany + """ + + projectId = projectId or self.get_project_id() + + return self._get_entire_data( + method="get", + path=self.get_project_placeholders_path(projectId=projectId), + params=self.get_page_params(limit=limit, offset=offset), + ) + + def add_project_placeholder( + self, + customPlaceholderId: int, + projectId: Optional[int] = None, + type: Optional[ProjectPlaceholderType] = None, + index: Optional[int] = None, + isBlocking: Optional[bool] = None, + formats: Optional[Iterable[str]] = None, + ): + """ + Add Project Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.placeholders.post + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_project_placeholders_path(projectId=projectId), + request_data={ + "customPlaceholderId": customPlaceholderId, + "type": type, + "index": index, + "isBlocking": isBlocking, + "formats": formats, + }, + ) + + def get_project_placeholder( + self, projectPlaceholderId: int, projectId: Optional[int] = None + ): + """ + Get Project Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.placeholders.get + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_project_placeholders_path( + projectId=projectId, projectPlaceholderId=projectPlaceholderId + ), + ) + + def delete_project_placeholder( + self, projectPlaceholderId: int, projectId: Optional[int] = None + ): + """ + Delete Project Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.placeholders.delete + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="delete", + path=self.get_project_placeholders_path( + projectId=projectId, projectPlaceholderId=projectPlaceholderId + ), + ) + + def edit_project_placeholder( + self, + projectPlaceholderId: int, + data: Iterable[ProjectPlaceholderPatchRequest], + projectId: Optional[int] = None, + ): + """ + Edit Project Placeholder. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.placeholders.patch + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="patch", + path=self.get_project_placeholders_path( + projectId=projectId, projectPlaceholderId=projectPlaceholderId + ), + request_data=data, + ) diff --git a/crowdin_api/api_resources/project_placeholders/tests/test_project_placeholders_resources.py b/crowdin_api/api_resources/project_placeholders/tests/test_project_placeholders_resources.py new file mode 100644 index 00000000..07710ac4 --- /dev/null +++ b/crowdin_api/api_resources/project_placeholders/tests/test_project_placeholders_resources.py @@ -0,0 +1,135 @@ +from unittest import mock + +import pytest +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.project_placeholders.enums import ( + ProjectPlaceholderPatchPath, + ProjectPlaceholderType, +) +from crowdin_api.api_resources.project_placeholders.resource import ProjectPlaceholdersResource +from crowdin_api.requester import APIRequester + + +class TestProjectPlaceholdersResource: + resource_class = ProjectPlaceholdersResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"projectId": 1}, "projects/1/placeholders"), + ({"projectId": 1, "projectPlaceholderId": 2}, "projects/1/placeholders/2"), + ), + ) + def test_get_project_placeholders_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_project_placeholders_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"limit": 25, "offset": 0}), + ({"limit": 10, "offset": 2}, {"limit": 10, "offset": 2}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_project_placeholders( + self, m_request, in_params, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_project_placeholders(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/placeholders", + params=request_params, + ) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {"customPlaceholderId": 3}, + { + "customPlaceholderId": 3, + "type": None, + "index": None, + "isBlocking": None, + "formats": None, + }, + ), + ( + { + "customPlaceholderId": 3, + "type": ProjectPlaceholderType.HIGH, + "index": 1, + "isBlocking": True, + "formats": ["docbook", "adoc"], + }, + { + "customPlaceholderId": 3, + "type": ProjectPlaceholderType.HIGH, + "index": 1, + "isBlocking": True, + "formats": ["docbook", "adoc"], + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_project_placeholder(self, m_request, in_params, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.add_project_placeholder(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/placeholders", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_project_placeholder(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_project_placeholder(projectId=1, projectPlaceholderId=2) == "response" + m_request.assert_called_once_with(method="get", path="projects/1/placeholders/2") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_project_placeholder(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_project_placeholder(projectId=1, projectPlaceholderId=2) == "response" + m_request.assert_called_once_with(method="delete", path="projects/1/placeholders/2") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_project_placeholder(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": ProjectPlaceholderPatchPath.IS_BLOCKING, + "value": True, + }, + { + "op": PatchOperation.REPLACE, + "path": ProjectPlaceholderPatchPath.FORMATS, + "value": ["adoc"], + }, + ] + + resource = self.get_resource(base_absolut_url) + assert resource.edit_project_placeholder( + projectId=1, projectPlaceholderId=2, data=data + ) == "response" + m_request.assert_called_once_with( + method="patch", + path="projects/1/placeholders/2", + request_data=data, + ) diff --git a/crowdin_api/api_resources/project_placeholders/types.py b/crowdin_api/api_resources/project_placeholders/types.py new file mode 100644 index 00000000..a1747d1a --- /dev/null +++ b/crowdin_api/api_resources/project_placeholders/types.py @@ -0,0 +1,15 @@ +from typing import Any + +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.project_placeholders.enums import ProjectPlaceholderPatchPath +from crowdin_api.typing import TypedDict + + +class ProjectPlaceholderPatchRequest(TypedDict): + """ + `op` accepts `replace` or `test` only. + """ + + op: PatchOperation + path: ProjectPlaceholderPatchPath + value: Any diff --git a/crowdin_api/api_resources/projects/enums.py b/crowdin_api/api_resources/projects/enums.py index ebaf414a..c71651a4 100644 --- a/crowdin_api/api_resources/projects/enums.py +++ b/crowdin_api/api_resources/projects/enums.py @@ -22,6 +22,22 @@ class ProjectLanguageAccessPolicy(Enum): class ProjectPatchPath(Enum): + """ + JSON Patch paths for Edit Project. + + Crowdin only: `/visibility`, `/languageAccessPolicy`, `/exportApprovedOnly`, `/useGlobalTm`, + `/tmPreTranslate`, `/mtPreTranslate`, `/aiPreTranslate`. + + Crowdin Enterprise only: `/groupId`, `/taskReviewerIds`, `/exportWithMinApprovalsCount`, + `/exportStringsThatPassedWorkflow`, `/qaApprovalsCount`, `/fields`, `/fields/{fieldSlug}`, `/tm`, + `/alignmentActionAiPromptId`, `/templateId`, `/steps`, `/vendorId`, `/mtEngineId`. + + File-based projects only: `/skipUntranslatedFiles`, `/saveMetaInfoInSource`, `/tmContextType`. + + Deprecated: `/glossaryAccess` (use `/glossaryAccessOption` instead) and + `/preTranslationAiPromptId`. + """ + NAME = "/name" TARGET_LANGUAGE_IDS = "/targetLanguageIds" CNAME = "/cname" @@ -37,8 +53,8 @@ class ProjectPatchPath(Enum): AUTO_TRANSLATE_DIALECTS = "/autoTranslateDialects" PUBLIC_DOWNLOADS = "/publicDownloads" USE_GLOBAL_TM = "/useGlobalTm" - NORMALIZE_PLACEHOLDER = "normalizePlaceholder" - SAVE_META_INFO_IN_SOURCE = "saveMetaInfoInSource" + NORMALIZE_PLACEHOLDER = "/normalizePlaceholder" + SAVE_META_INFO_IN_SOURCE = "/saveMetaInfoInSource" IN_CONTEXT = "/inContext" IN_CONTEXT_PSEUDO_LANGUAGE_ID = "/inContextPseudoLanguageId" QA_CHECK_IS_ACTIVE = "/qaCheckIsActive" @@ -51,6 +67,47 @@ class ProjectPatchPath(Enum): LANGUAGE_MAPPING_KEY = "/languageMapping/{languageId}/{mappingKey}" DEFAULT_TM_ID = "/defaultTmId" DEFAULT_GLOSSARY_ID = "/defaultGlossaryId" + TASK_BASED_ACCESS_CONTROL = "/taskBasedAccessControl" + SHOW_TM_SUGGESTIONS_DIALECTS = "/showTmSuggestionsDialects" + TM_APPROVED_SUGGESTIONS_ONLY = "/tmApprovedSuggestionsOnly" + GLOSSARY_ACCESS = "/glossaryAccess" # deprecated, use GLOSSARY_ACCESS_OPTION instead + GLOSSARY_ACCESS_OPTION = "/glossaryAccessOption" + HIDDEN_STRINGS_PROOFREADERS_ACCESS = "/hiddenStringsProofreadersAccess" + IN_CONTEXT_PROCESS_HIDDEN_STRINGS = "/inContextProcessHiddenStrings" + NOTIFICATION_SETTINGS_TRANSLATOR_NEW_STRINGS = "/notificationSettings/translatorNewStrings" + NOTIFICATION_SETTINGS_MANAGER_NEW_STRINGS = "/notificationSettings/managerNewStrings" + NOTIFICATION_SETTINGS_MANAGER_LANGUAGE_COMPLETED = ( + "/notificationSettings/managerLanguageCompleted" + ) + PSEUDO_LANGUAGE_ID = "/pseudoLanguageId" + ASSIGNED_STYLE_GUIDES = "/assignedStyleGuides" + ASSIGNED_GLOSSARIES = "/assignedGlossaries" + ASSIGNED_TMS = "/assignedTms" + ASSIGNED_TM = "/assignedTms/{tmId}" + TM_PENALTIES_KEY = "/tmPenalties/{penaltyKey}" + TM_CONTEXT_TYPE = "/tmContextType" + TM_PRE_TRANSLATE = "/tmPreTranslate" + MT_PRE_TRANSLATE = "/mtPreTranslate" + AI_PRE_TRANSLATE = "/aiPreTranslate" + PRE_TRANSLATION_AI_PROMPT_ID = "/preTranslationAiPromptId" # deprecated + EDITOR_SUGGESTION_AI_PROMPT_ID = "/editorSuggestionAiPromptId" + QA_CHECK_ACTION_AI_PROMPT_ID = "/qaCheckActionAiPromptId" + CONTEXT_REVIEW_AI_PROMPT_ID = "/contextReviewAiPromptId" + SAVINGS_REPORT_SETTINGS_TEMPLATE_ID = "/savingsReportSettingsTemplateId" + # Crowdin Enterprise only + GROUP_ID = "/groupId" + TASK_REVIEWER_IDS = "/taskReviewerIds" + EXPORT_WITH_MIN_APPROVALS_COUNT = "/exportWithMinApprovalsCount" + EXPORT_STRINGS_THAT_PASSED_WORKFLOW = "/exportStringsThatPassedWorkflow" + QA_APPROVALS_COUNT = "/qaApprovalsCount" + FIELDS = "/fields" + FIELD = "/fields/{fieldSlug}" + TM = "/tm" + ALIGNMENT_ACTION_AI_PROMPT_ID = "/alignmentActionAiPromptId" + TEMPLATE_ID = "/templateId" + STEPS = "/steps" + VENDOR_ID = "/vendorId" + MT_ENGINE_ID = "/mtEngineId" class ProjectTranslateDuplicates(Enum): @@ -103,3 +160,43 @@ class ListProjectsOrderBy(Enum): CREATED_AT = "createdAt" UPDATED_AT = "updatedAt" LAST_ACTIVITY = "lastActivity" + + +class ProjectTagsDetection(Enum): + AUTO = 0 + COUNT_TAGS = 1 + SKIP_TAGS = 2 + + +class ProjectGlossaryAccessOption(Enum): + READ_ONLY = "readOnly" + FULL_ACCESS = "fullAccess" + MANAGE_DRAFTS = "manageDrafts" + + +class ProjectTmContextType(Enum): + """ + TM perfect match searching mode (file-based projects only). + """ + + SEGMENT_CONTEXT = "segmentContext" + AUTO = "auto" + PREV_AND_NEXT_SEGMENT = "prevAndNextSegment" + + +class TmPreTranslateAutoApproveOption(Enum): + ALL = "all" + PERFECT_MATCH_ONLY = "perfectMatchOnly" + EXCEPT_AUTO_SUBSTITUTED = "exceptAutoSubstituted" + PERFECT_MATCH_APPROVED_ONLY = "perfectMatchApprovedOnly" + NONE = "none" + + +class TmPreTranslateMinimumMatchRatio(Enum): + PERFECT = "perfect" + HUNDRED = "100" + + +class StringsExporterSettingsPatchPath(Enum): + FORMAT = "/format" + SETTINGS = "/settings" diff --git a/crowdin_api/api_resources/projects/resource.py b/crowdin_api/api_resources/projects/resource.py index 9ee5b532..cea538f1 100644 --- a/crowdin_api/api_resources/projects/resource.py +++ b/crowdin_api/api_resources/projects/resource.py @@ -1,14 +1,31 @@ -from typing import Dict, Iterable, Optional, Union +import warnings +from typing import Any, Dict, Iterable, Optional, Union from crowdin_api.api_resources.abstract.resources import BaseResource +from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.api_resources.projects.enums import ( HasManagerAccess, + ProjectGlossaryAccessOption, ProjectLanguageAccessPolicy, + ProjectTagsDetection, + ProjectTmContextType, ProjectTranslateDuplicates, ProjectType, ProjectVisibility, + StringsExporterSettingsPatchPath, ) from crowdin_api.api_resources.projects.types import ( + AiPreTranslate, + MtPreTranslate, + TmPreTranslate, + WorkflowStepSettings, + MdFileFormatSettings, + MdxFileFormatSettings, + JsonFileFormatSettings, + JavaScriptFileFormatSettings, + StringCatalogFileFormatSettings, + VdfFileFormatSettings, + StringsExporterSettingsPatchRequest, NotificationSettings, ProjectPatchRequest, QACheckCategories, @@ -28,6 +45,23 @@ from crowdin_api.sorting import Sorting +def _warn_deprecated_project_params( + glossaryAccess: Optional[bool] = None, + preTranslationAiPromptId: Optional[int] = None, +): + if glossaryAccess is not None: + warnings.warn( + "`glossaryAccess` is deprecated, use `glossaryAccessOption` instead", + DeprecationWarning, + ) + + if preTranslationAiPromptId is not None: + warnings.warn( + "`preTranslationAiPromptId` is deprecated, use `aiPreTranslate` instead", + DeprecationWarning, + ) + + class ProjectsResource(BaseResource): """ Resource for Storages. @@ -36,7 +70,7 @@ class ProjectsResource(BaseResource): Use API to manage projects, change their settings, or remove them if required. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Projects + https://support.crowdin.com/developer/api/v2/#tag/Projects """ @@ -55,13 +89,17 @@ def list_projects( groupId: Optional[int] = None, userId: Optional[Union[int, str]] = None, hasManagerAccess: Optional[HasManagerAccess] = None, - type: Optional[ProjectType] = None + type: Optional[ProjectType] = None, + filter: Optional[str] = None, ): """ List Projects. + Note: `groupId` is supported by Crowdin Enterprise only, `userId` and `type` by Crowdin only. + `filter` filters projects by `name`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.getMany """ params = { @@ -69,7 +107,8 @@ def list_projects( "userId": userId, "hasManagerAccess": hasManagerAccess, "groupId": groupId, - "type": type.value if type is not None else None + "type": type.value if type is not None else None, + "filter": filter, } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) @@ -80,7 +119,7 @@ def add_project(self, request_data: Dict): Add Project. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.post """ return self.requester.request( @@ -109,16 +148,70 @@ def add_file_based_project( skipUntranslatedFiles: Optional[bool] = None, exportApprovedOnly: Optional[bool] = None, defaultTmId: Optional[int] = None, - defaultGlossaryId: Optional[None] = None, + defaultGlossaryId: Optional[int] = None, tmApprovedSuggestionsOnly: Optional[bool] = None, + tagsDetection: Optional[ProjectTagsDetection] = None, + taskBasedAccessControl: Optional[bool] = None, + publicDownloads: Optional[bool] = None, + hiddenStringsProofreadersAccess: Optional[bool] = None, + useGlobalTm: Optional[bool] = None, + showTmSuggestionsDialects: Optional[bool] = None, + qaCheckIsActive: Optional[bool] = None, + qaCheckCategories: Optional[QACheckCategories] = None, + qaChecksIgnorableCategories: Optional[QAChecksIgnorableCategories] = None, + languageMapping: Optional[Dict] = None, + glossaryAccess: Optional[bool] = None, + glossaryAccessOption: Optional[ProjectGlossaryAccessOption] = None, + tmPreTranslate: Optional[TmPreTranslate] = None, + mtPreTranslate: Optional[MtPreTranslate] = None, + aiPreTranslate: Optional[AiPreTranslate] = None, + preTranslationAiPromptId: Optional[int] = None, + editorSuggestionAiPromptId: Optional[int] = None, + qaCheckActionAiPromptId: Optional[int] = None, + contextReviewAiPromptId: Optional[int] = None, + savingsReportSettingsTemplateId: Optional[int] = None, + assignedStyleGuides: Optional[Iterable[int]] = None, + inContext: Optional[bool] = None, + inContextProcessHiddenStrings: Optional[bool] = None, + inContextPseudoLanguageId: Optional[str] = None, + tmContextType: Optional[ProjectTmContextType] = None, + groupId: Optional[int] = None, + templateId: Optional[int] = None, + steps: Optional[Iterable[WorkflowStepSettings]] = None, + vendorId: Optional[int] = None, + mtEngineId: Optional[int] = None, + taskReviewerIds: Optional[Iterable[int]] = None, + delayedWorkflowStart: Optional[bool] = None, + exportWithMinApprovalsCount: Optional[int] = None, + exportStringsThatPassedWorkflow: Optional[bool] = None, + qaApprovalsCount: Optional[int] = None, + customQaCheckIds: Optional[Iterable[int]] = None, + externalQaCheckIds: Optional[Iterable[int]] = None, + fields: Optional[Dict[str, Any]] = None, + alignmentActionAiPromptId: Optional[int] = None, ): """ Add Project(Files Based Project Form). + Crowdin only: `identifier`, `visibility`, `languageAccessPolicy`, `cname`, + `exportApprovedOnly`, `useGlobalTm`, `tmPreTranslate`, `mtPreTranslate`, `aiPreTranslate`. + + Crowdin Enterprise only: `groupId`, `templateId`, `steps`, `vendorId`, `mtEngineId`, + `taskReviewerIds`, `delayedWorkflowStart`, `exportWithMinApprovalsCount`, + `exportStringsThatPassedWorkflow`, `qaApprovalsCount`, `customQaCheckIds`, + `externalQaCheckIds`, `fields`, `alignmentActionAiPromptId`. + + Deprecated: `glossaryAccess` (use `glossaryAccessOption` instead) and + `preTranslationAiPromptId` (use `aiPreTranslate` instead). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.post """ + _warn_deprecated_project_params( + glossaryAccess=glossaryAccess, preTranslationAiPromptId=preTranslationAiPromptId + ) + return self.add_project( request_data={ "name": name, @@ -143,6 +236,45 @@ def add_file_based_project( "defaultTmId": defaultTmId, "defaultGlossaryId": defaultGlossaryId, "tmApprovedSuggestionsOnly": tmApprovedSuggestionsOnly, + "tagsDetection": tagsDetection, + "taskBasedAccessControl": taskBasedAccessControl, + "publicDownloads": publicDownloads, + "hiddenStringsProofreadersAccess": hiddenStringsProofreadersAccess, + "useGlobalTm": useGlobalTm, + "showTmSuggestionsDialects": showTmSuggestionsDialects, + "qaCheckIsActive": qaCheckIsActive, + "qaCheckCategories": qaCheckCategories, + "qaChecksIgnorableCategories": qaChecksIgnorableCategories, + "languageMapping": languageMapping, + "glossaryAccess": glossaryAccess, + "glossaryAccessOption": glossaryAccessOption, + "tmPreTranslate": tmPreTranslate, + "mtPreTranslate": mtPreTranslate, + "aiPreTranslate": aiPreTranslate, + "preTranslationAiPromptId": preTranslationAiPromptId, + "editorSuggestionAiPromptId": editorSuggestionAiPromptId, + "qaCheckActionAiPromptId": qaCheckActionAiPromptId, + "contextReviewAiPromptId": contextReviewAiPromptId, + "savingsReportSettingsTemplateId": savingsReportSettingsTemplateId, + "assignedStyleGuides": assignedStyleGuides, + "inContext": inContext, + "inContextProcessHiddenStrings": inContextProcessHiddenStrings, + "inContextPseudoLanguageId": inContextPseudoLanguageId, + "tmContextType": tmContextType, + "groupId": groupId, + "templateId": templateId, + "steps": steps, + "vendorId": vendorId, + "mtEngineId": mtEngineId, + "taskReviewerIds": taskReviewerIds, + "delayedWorkflowStart": delayedWorkflowStart, + "exportWithMinApprovalsCount": exportWithMinApprovalsCount, + "exportStringsThatPassedWorkflow": exportStringsThatPassedWorkflow, + "qaApprovalsCount": qaApprovalsCount, + "customQaCheckIds": customQaCheckIds, + "externalQaCheckIds": externalQaCheckIds, + "fields": fields, + "alignmentActionAiPromptId": alignmentActionAiPromptId, }, ) @@ -176,16 +308,60 @@ def add_strings_based_project( glossaryAccess: Optional[bool] = None, notificationSettings: Optional[NotificationSettings] = None, defaultTmId: Optional[int] = None, - defaultGlossaryId: Optional[None] = None, + defaultGlossaryId: Optional[int] = None, tmApprovedSuggestionsOnly: Optional[bool] = None, + tagsDetection: Optional[ProjectTagsDetection] = None, + taskBasedAccessControl: Optional[bool] = None, + showTmSuggestionsDialects: Optional[bool] = None, + normalizePlaceholder: Optional[bool] = None, + glossaryAccessOption: Optional[ProjectGlossaryAccessOption] = None, + tmPreTranslate: Optional[TmPreTranslate] = None, + mtPreTranslate: Optional[MtPreTranslate] = None, + aiPreTranslate: Optional[AiPreTranslate] = None, + preTranslationAiPromptId: Optional[int] = None, + editorSuggestionAiPromptId: Optional[int] = None, + qaCheckActionAiPromptId: Optional[int] = None, + contextReviewAiPromptId: Optional[int] = None, + savingsReportSettingsTemplateId: Optional[int] = None, + assignedStyleGuides: Optional[Iterable[int]] = None, + inContext: Optional[bool] = None, + groupId: Optional[int] = None, + templateId: Optional[int] = None, + steps: Optional[Iterable[WorkflowStepSettings]] = None, + vendorId: Optional[int] = None, + mtEngineId: Optional[int] = None, + taskReviewerIds: Optional[Iterable[int]] = None, + delayedWorkflowStart: Optional[bool] = None, + exportWithMinApprovalsCount: Optional[int] = None, + exportStringsThatPassedWorkflow: Optional[bool] = None, + qaApprovalsCount: Optional[int] = None, + customQaCheckIds: Optional[Iterable[int]] = None, + externalQaCheckIds: Optional[Iterable[int]] = None, + fields: Optional[Dict[str, Any]] = None, + alignmentActionAiPromptId: Optional[int] = None, ): """ Add Project(Strings Based Project Form). + Crowdin only: `identifier`, `visibility`, `languageAccessPolicy`, `cname`, + `exportApprovedOnly`, `useGlobalTm`, `tmPreTranslate`, `mtPreTranslate`, `aiPreTranslate`. + + Crowdin Enterprise only: `groupId`, `templateId`, `steps`, `vendorId`, `mtEngineId`, + `taskReviewerIds`, `delayedWorkflowStart`, `exportWithMinApprovalsCount`, + `exportStringsThatPassedWorkflow`, `qaApprovalsCount`, `customQaCheckIds`, + `externalQaCheckIds`, `fields`, `alignmentActionAiPromptId`. + + Deprecated: `glossaryAccess` (use `glossaryAccessOption` instead) and + `preTranslationAiPromptId` (use `aiPreTranslate` instead). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.post + https://support.crowdin.com/developer/api/v2/string-based/#operation/api.projects.post """ + _warn_deprecated_project_params( + glossaryAccess=glossaryAccess, preTranslationAiPromptId=preTranslationAiPromptId + ) + return self.add_project( request_data={ "name": name, @@ -218,6 +394,35 @@ def add_strings_based_project( "defaultTmId": defaultTmId, "defaultGlossaryId": defaultGlossaryId, "tmApprovedSuggestionsOnly": tmApprovedSuggestionsOnly, + "tagsDetection": tagsDetection, + "taskBasedAccessControl": taskBasedAccessControl, + "showTmSuggestionsDialects": showTmSuggestionsDialects, + "normalizePlaceholder": normalizePlaceholder, + "glossaryAccessOption": glossaryAccessOption, + "tmPreTranslate": tmPreTranslate, + "mtPreTranslate": mtPreTranslate, + "aiPreTranslate": aiPreTranslate, + "preTranslationAiPromptId": preTranslationAiPromptId, + "editorSuggestionAiPromptId": editorSuggestionAiPromptId, + "qaCheckActionAiPromptId": qaCheckActionAiPromptId, + "contextReviewAiPromptId": contextReviewAiPromptId, + "savingsReportSettingsTemplateId": savingsReportSettingsTemplateId, + "assignedStyleGuides": assignedStyleGuides, + "inContext": inContext, + "groupId": groupId, + "templateId": templateId, + "steps": steps, + "vendorId": vendorId, + "mtEngineId": mtEngineId, + "taskReviewerIds": taskReviewerIds, + "delayedWorkflowStart": delayedWorkflowStart, + "exportWithMinApprovalsCount": exportWithMinApprovalsCount, + "exportStringsThatPassedWorkflow": exportStringsThatPassedWorkflow, + "qaApprovalsCount": qaApprovalsCount, + "customQaCheckIds": customQaCheckIds, + "externalQaCheckIds": externalQaCheckIds, + "fields": fields, + "alignmentActionAiPromptId": alignmentActionAiPromptId, }, ) @@ -226,7 +431,7 @@ def get_project(self, projectId: Optional[int] = None): Get Project. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.get """ projectId = projectId or self.get_project_id() @@ -240,7 +445,7 @@ def delete_project(self, projectId: Optional[int] = None): Delete Project. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.delete """ projectId = projectId or self.get_project_id() @@ -255,8 +460,11 @@ def edit_project( """ Edit Project. + See `ProjectPatchPath` for the available paths, including the Crowdin Enterprise only + and file-based only ones. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.patch """ projectId = projectId or self.get_project_id() @@ -284,7 +492,7 @@ def download_project_file_custom_segmentation( Download Project File Format Settings Custom Segmentation. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.file-format-settings.custom-segmentations.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.file-format-settings.custom-segmentations.get """ projectId = projectId or self.get_project_id() @@ -306,7 +514,7 @@ def reset_project_file_custom_segmentation( Reset Project File Format Settings Custom Segmentation. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.file-format-settings.custom-segmentations.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.file-format-settings.custom-segmentations.delete """ projectId = projectId or self.get_project_id() @@ -331,7 +539,7 @@ def list_project_file_format_settings( List Project File Format Settings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.file-format-settings.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.file-format-settings.getMany """ projectId = projectId or self.get_project_id() @@ -349,7 +557,9 @@ def add_project_file_format_settings( settings: Union[ PropertyFileFormatSettings, XmlFileFormatSettings, SpecificFileFormatSettings, DocxFileFormatSettings, MediaWikiFileFormatSettings, TxtFileFormatSettings, - OtherFileFormatSettings + OtherFileFormatSettings, MdFileFormatSettings, MdxFileFormatSettings, + JsonFileFormatSettings, JavaScriptFileFormatSettings, StringCatalogFileFormatSettings, + VdfFileFormatSettings, Dict[str, Any] ], projectId: Optional[int] = None, ): @@ -357,7 +567,7 @@ def add_project_file_format_settings( Add Project File Format Settings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.file-format-settings.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.file-format-settings.post """ projectId = projectId or self.get_project_id() @@ -375,7 +585,7 @@ def get_project_file_format_settings( Get Project File Format Settings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.file-format-settings.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.file-format-settings.get """ projectId = projectId or self.get_project_id() @@ -395,7 +605,7 @@ def delete_project_file_format_settings( Delete Project File Format Settings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.file-format-settings.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.file-format-settings.delete """ projectId = projectId or self.get_project_id() @@ -418,7 +628,7 @@ def edit_project_file_format_settings( Edit Project File Format Settings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.file-format-settings.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.file-format-settings.patch """ projectId = projectId or self.get_project_id() @@ -448,8 +658,8 @@ def list_project_strings_exporter_settings( """ List Project Strings Exporter Settings. - Link to documetation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings-exporter-settings.getMany + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings-exporter-settings.getMany """ projectId = projectId or self.get_project_id() @@ -472,8 +682,8 @@ def add_project_strings_exporter_settings( """ Add Project Strings Exporter Settings. - Link to documetation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings-exporter-settings.post + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings-exporter-settings.post """ projectId = projectId or self.get_project_id() @@ -490,8 +700,8 @@ def get_project_strings_exporter_settings( """ Get Project Strings Exporter Settings - Link to documetation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings-exporter-settings.get + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings-exporter-settings.get """ projectId = projectId or self.get_project_id() @@ -510,8 +720,8 @@ def delete_project_strings_exporter_settings( """ Delete Project Strings Exporter Settings. - Link to documetation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings-exporter-settings.delete + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings-exporter-settings.delete """ projectId = projectId or self.get_project_id() @@ -527,28 +737,51 @@ def delete_project_strings_exporter_settings( def edit_project_strings_exporter_settings( self, systemStringExporterSettingsId: int, - format: str, - settings: Union[ + format: Optional[str] = None, + settings: Optional[Union[ AndroidStringsExporterSettings, MacOSXStringsExporterSettings, XliffStringsExporterSettings, - ], + ]] = None, projectId: Optional[int] = None, + data: Optional[Iterable[StringsExporterSettingsPatchRequest]] = None, ): """ Edit Project Strings Exporter Settings. - Link to documetation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings-exporter-settings.patch + The API expects a JSON Patch document. Pass it via `data`, or pass `format` and/or + `settings` and they will be sent as `replace` operations on `/format` and `/settings`. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings-exporter-settings.patch """ projectId = projectId or self.get_project_id() + if data is None: + data = [] + if format is not None: + data.append( + { + "op": PatchOperation.REPLACE.value, + "path": StringsExporterSettingsPatchPath.FORMAT.value, + "value": format, + } + ) + if settings is not None: + data.append( + { + "op": PatchOperation.REPLACE.value, + "path": StringsExporterSettingsPatchPath.SETTINGS.value, + "value": settings, + } + ) + return self.requester.request( method="patch", path=self.get_strings_exporter_path( projectId=projectId, systemStringExporterSettingsId=systemStringExporterSettingsId ), - request_data={"format": format, "settings": settings}, + request_data=data, ) diff --git a/crowdin_api/api_resources/projects/tests/test_projects_resources.py b/crowdin_api/api_resources/projects/tests/test_projects_resources.py index 21d1e496..1080840d 100644 --- a/crowdin_api/api_resources/projects/tests/test_projects_resources.py +++ b/crowdin_api/api_resources/projects/tests/test_projects_resources.py @@ -6,6 +6,12 @@ from crowdin_api.api_resources.projects.enums import ( HasManagerAccess, ListProjectsOrderBy, + ProjectGlossaryAccessOption, + ProjectTagsDetection, + ProjectTmContextType, + StringsExporterSettingsPatchPath, + TmPreTranslateAutoApproveOption, + TmPreTranslateMinimumMatchRatio, ProjectLanguageAccessPolicy, ProjectPatchPath, ProjectTranslateDuplicates, @@ -21,6 +27,134 @@ from crowdin_api.requester import APIRequester from crowdin_api.sorting import Sorting, SortingOrder, SortingRule +FILE_BASED_NEW_FIELDS = { + "tagsDetection": None, + "taskBasedAccessControl": None, + "showTmSuggestionsDialects": None, + "glossaryAccessOption": None, + "tmPreTranslate": None, + "mtPreTranslate": None, + "aiPreTranslate": None, + "preTranslationAiPromptId": None, + "editorSuggestionAiPromptId": None, + "qaCheckActionAiPromptId": None, + "contextReviewAiPromptId": None, + "savingsReportSettingsTemplateId": None, + "assignedStyleGuides": None, + "inContext": None, + "groupId": None, + "templateId": None, + "steps": None, + "vendorId": None, + "mtEngineId": None, + "taskReviewerIds": None, + "delayedWorkflowStart": None, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, + "qaApprovalsCount": None, + "customQaCheckIds": None, + "externalQaCheckIds": None, + "fields": None, + "alignmentActionAiPromptId": None, + "publicDownloads": None, + "hiddenStringsProofreadersAccess": None, + "useGlobalTm": None, + "qaCheckIsActive": None, + "qaCheckCategories": None, + "qaChecksIgnorableCategories": None, + "languageMapping": None, + "glossaryAccess": None, + "inContextProcessHiddenStrings": None, + "inContextPseudoLanguageId": None, + "tmContextType": None, +} + +STRINGS_BASED_NEW_FIELDS = { + "tagsDetection": None, + "taskBasedAccessControl": None, + "showTmSuggestionsDialects": None, + "glossaryAccessOption": None, + "tmPreTranslate": None, + "mtPreTranslate": None, + "aiPreTranslate": None, + "preTranslationAiPromptId": None, + "editorSuggestionAiPromptId": None, + "qaCheckActionAiPromptId": None, + "contextReviewAiPromptId": None, + "savingsReportSettingsTemplateId": None, + "assignedStyleGuides": None, + "inContext": None, + "groupId": None, + "templateId": None, + "steps": None, + "vendorId": None, + "mtEngineId": None, + "taskReviewerIds": None, + "delayedWorkflowStart": None, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, + "qaApprovalsCount": None, + "customQaCheckIds": None, + "externalQaCheckIds": None, + "fields": None, + "alignmentActionAiPromptId": None, + "normalizePlaceholder": None, +} + +ADD_FILE_BASED_PROJECT_BASE_FIELDS = ( + "identifier", + "type", + "normalizePlaceholder", + "saveMetaInfoInSource", + "notificationSettings", + "targetLanguageIds", + "visibility", + "languageAccessPolicy", + "cname", + "description", + "skipUntranslatedStrings", + "skipUntranslatedFiles", + "exportApprovedOnly", + "translateDuplicates", + "isMtAllowed", + "autoSubstitution", + "autoTranslateDialects", + "defaultTmId", + "defaultGlossaryId", + "tmApprovedSuggestionsOnly", +) + +ADD_STRINGS_BASED_PROJECT_BASE_FIELDS = ( + "identifier", + "type", + "targetLanguageIds", + "visibility", + "languageAccessPolicy", + "cname", + "description", + "skipUntranslatedStrings", + "skipUntranslatedFiles", + "exportApprovedOnly", + "translateDuplicates", + "isMtAllowed", + "autoSubstitution", + "autoTranslateDialects", + "publicDownloads", + "hiddenStringsProofreadersAccess", + "useGlobalTm", + "inContextProcessHiddenStrings", + "inContextPseudoLanguageId", + "qaCheckIsActive", + "qaCheckCategories", + "qaChecksIgnorableCategories", + "languageMapping", + "glossaryAccess", + "notificationSettings", + "defaultTmId", + "defaultGlossaryId", + "tmApprovedSuggestionsOnly", +) + class TestProjectsResource: resource_class = ProjectsResource @@ -60,7 +194,8 @@ def test_get_projects_path(self, projectId, path, base_absolut_url): "userId": 1, "groupId": 1, "hasManagerAccess": HasManagerAccess.TRUE, - "type": ProjectType.STRING_BASED + "type": ProjectType.STRING_BASED, + "filter": "name", }, { "orderBy": Sorting( @@ -71,7 +206,8 @@ def test_get_projects_path(self, projectId, path, base_absolut_url): "userId": 1, "groupId": 1, "hasManagerAccess": HasManagerAccess.TRUE, - "type": 1 + "type": 1, + "filter": "name", }, ), ( @@ -83,7 +219,8 @@ def test_get_projects_path(self, projectId, path, base_absolut_url): "userId": None, "groupId": None, "hasManagerAccess": None, - "type": None + "type": None, + "filter": None, }, ), ), @@ -121,6 +258,7 @@ def test_add_project(self, m_request, base_absolut_url): "sourceLanguageId": "ua", }, { + **FILE_BASED_NEW_FIELDS, "name": "name", "sourceLanguageId": "ua", "identifier": None, @@ -175,6 +313,7 @@ def test_add_project(self, m_request, base_absolut_url): "tmApprovedSuggestionsOnly": True, }, { + **FILE_BASED_NEW_FIELDS, "name": "name", "sourceLanguageId": "ua", "identifier": "identifier", @@ -222,6 +361,7 @@ def test_add_file_based_project(self, m_add_project, in_params, request_data, ba "sourceLanguageId": "ua", }, { + **STRINGS_BASED_NEW_FIELDS, "name": "name", "sourceLanguageId": "ua", "identifier": None, @@ -279,36 +419,36 @@ def test_add_file_based_project(self, m_add_project, in_params, request_data, ba "inContextPseudoLanguageId": "ua", "qaCheckIsActive": True, "qaCheckCategories": QACheckCategories( - EMPTY=True, - SIZE=True, - TAGS=True, - SPACES=True, - VARIABLES=True, - PUNCTUATION=True, - SYMBOLREGISTER=True, - SPECIALSYMBOLS=True, - WRONGTRANSLATION=True, - SPELLCHECK=True, - ICU=True, - TERMS=True, - DUPLICATE=True, + empty=True, + size=True, + tags=True, + spaces=True, + variables=True, + punctuation=True, + symbolRegister=True, + specialSymbols=True, + wrongTranslation=True, + spellcheck=True, + icu=True, + terms=True, + duplicate=True, ), "qaChecksIgnorableCategories": QAChecksIgnorableCategories( - EMPTY=True, - SIZE=True, - TAGS=True, - SPACES=True, - VARIABLES=True, - PUNCTUATION=True, - SYMBOLREGISTER=True, - SPECIALSYMBOLS=True, - WRONGTRANSLATION=True, - SPELLCHECK=True, - ICU=True, - TERMS=True, - DUPLICATE=True, - FTL=True, - ANDROID=True + empty=True, + size=True, + tags=True, + spaces=True, + variables=True, + punctuation=True, + symbolRegister=True, + specialSymbols=True, + wrongTranslation=True, + spellcheck=True, + icu=True, + terms=True, + duplicate=True, + ftl=True, + android=True ), "languageMapping": {}, "glossaryAccess": True, @@ -322,6 +462,7 @@ def test_add_file_based_project(self, m_add_project, in_params, request_data, ba "tmApprovedSuggestionsOnly": True, }, { + **STRINGS_BASED_NEW_FIELDS, "name": "name", "sourceLanguageId": "ua", "identifier": "identifier", @@ -345,36 +486,36 @@ def test_add_file_based_project(self, m_add_project, in_params, request_data, ba "inContextPseudoLanguageId": "ua", "qaCheckIsActive": True, "qaCheckCategories": QACheckCategories( - EMPTY=True, - SIZE=True, - TAGS=True, - SPACES=True, - VARIABLES=True, - PUNCTUATION=True, - SYMBOLREGISTER=True, - SPECIALSYMBOLS=True, - WRONGTRANSLATION=True, - SPELLCHECK=True, - ICU=True, - TERMS=True, - DUPLICATE=True, + empty=True, + size=True, + tags=True, + spaces=True, + variables=True, + punctuation=True, + symbolRegister=True, + specialSymbols=True, + wrongTranslation=True, + spellcheck=True, + icu=True, + terms=True, + duplicate=True, ), "qaChecksIgnorableCategories": QAChecksIgnorableCategories( - EMPTY=True, - SIZE=True, - TAGS=True, - SPACES=True, - VARIABLES=True, - PUNCTUATION=True, - SYMBOLREGISTER=True, - SPECIALSYMBOLS=True, - WRONGTRANSLATION=True, - SPELLCHECK=True, - ICU=True, - TERMS=True, - DUPLICATE=True, - FTL=True, - ANDROID=True, + empty=True, + size=True, + tags=True, + spaces=True, + variables=True, + punctuation=True, + symbolRegister=True, + specialSymbols=True, + wrongTranslation=True, + spellcheck=True, + icu=True, + terms=True, + duplicate=True, + ftl=True, + android=True, ), "languageMapping": {}, "glossaryAccess": True, @@ -400,6 +541,136 @@ def test_add_strings_based_projectt( assert resource.add_strings_based_project(**in_params) == "response" m_add_project.assert_called_once_with(request_data=request_data) + @mock.patch("crowdin_api.api_resources.projects.resource.ProjectsResource.add_project") + def test_add_file_based_project_new_fields(self, m_add_project, base_absolut_url): + m_add_project.return_value = "response" + + new_fields = { + "tagsDetection": ProjectTagsDetection.SKIP_TAGS, + "taskBasedAccessControl": True, + "publicDownloads": True, + "hiddenStringsProofreadersAccess": False, + "useGlobalTm": True, + "showTmSuggestionsDialects": True, + "qaCheckIsActive": True, + "qaCheckCategories": QACheckCategories(empty=True, unifiedPlaceholders=False), + "qaChecksIgnorableCategories": QAChecksIgnorableCategories(numbers=True), + "languageMapping": {"uk": {"locale": "uk-UA"}}, + "glossaryAccessOption": ProjectGlossaryAccessOption.MANAGE_DRAFTS, + "tmPreTranslate": { + "enabled": True, + "autoApproveOption": TmPreTranslateAutoApproveOption.ALL, + "minimumMatchRatio": TmPreTranslateMinimumMatchRatio.PERFECT, + }, + "mtPreTranslate": {"enabled": True, "mts": [{"mtId": 1, "languageIds": ["uk"]}]}, + "aiPreTranslate": {"enabled": True, "aiPrompts": [{"aiPromptId": 2, "languageIds": ["uk"]}]}, + "editorSuggestionAiPromptId": 3, + "qaCheckActionAiPromptId": 4, + "contextReviewAiPromptId": 5, + "savingsReportSettingsTemplateId": 6, + "assignedStyleGuides": [7, 8], + "inContext": True, + "inContextProcessHiddenStrings": True, + "inContextPseudoLanguageId": "ach", + "tmContextType": ProjectTmContextType.PREV_AND_NEXT_SEGMENT, + "groupId": 9, + "templateId": 10, + "steps": [{"id": 1, "languages": ["uk"], "config": {"assignees": {"uk": [1]}}}], + "vendorId": 11, + "mtEngineId": 12, + "taskReviewerIds": [13], + "delayedWorkflowStart": True, + "exportWithMinApprovalsCount": 1, + "exportStringsThatPassedWorkflow": False, + "qaApprovalsCount": 2, + "customQaCheckIds": [14], + "externalQaCheckIds": [15], + "fields": {"some-field": "value"}, + "alignmentActionAiPromptId": 16, + } + + resource = self.get_resource(base_absolut_url) + assert resource.add_file_based_project( + name="name", sourceLanguageId="en", **new_fields + ) == "response" + + expected = {key: None for key in ADD_FILE_BASED_PROJECT_BASE_FIELDS} + expected.update(FILE_BASED_NEW_FIELDS) + expected.update({"name": "name", "sourceLanguageId": "en"}) + expected.update(new_fields) + m_add_project.assert_called_once_with(request_data=expected) + + @mock.patch("crowdin_api.api_resources.projects.resource.ProjectsResource.add_project") + def test_add_strings_based_project_new_fields(self, m_add_project, base_absolut_url): + m_add_project.return_value = "response" + + new_fields = { + "tagsDetection": ProjectTagsDetection.AUTO, + "taskBasedAccessControl": True, + "showTmSuggestionsDialects": False, + "normalizePlaceholder": True, + "glossaryAccessOption": ProjectGlossaryAccessOption.READ_ONLY, + "tmPreTranslate": {"enabled": False}, + "mtPreTranslate": {"enabled": False}, + "aiPreTranslate": {"enabled": False}, + "editorSuggestionAiPromptId": 1, + "qaCheckActionAiPromptId": 2, + "contextReviewAiPromptId": 3, + "savingsReportSettingsTemplateId": 4, + "assignedStyleGuides": [5], + "inContext": False, + "groupId": 6, + "templateId": 7, + "steps": [{"id": 1, "mtId": 2}], + "vendorId": 8, + "mtEngineId": 9, + "taskReviewerIds": [10], + "delayedWorkflowStart": False, + "exportWithMinApprovalsCount": 0, + "exportStringsThatPassedWorkflow": True, + "qaApprovalsCount": 1, + "customQaCheckIds": [11], + "externalQaCheckIds": [12], + "fields": {"some-field": 1}, + "alignmentActionAiPromptId": 13, + } + + resource = self.get_resource(base_absolut_url) + assert resource.add_strings_based_project( + name="name", sourceLanguageId="en", **new_fields + ) == "response" + + expected = {key: None for key in ADD_STRINGS_BASED_PROJECT_BASE_FIELDS} + expected.update(STRINGS_BASED_NEW_FIELDS) + expected.update({"name": "name", "sourceLanguageId": "en"}) + expected.update(new_fields) + m_add_project.assert_called_once_with(request_data=expected) + + @pytest.mark.parametrize( + "method_name, deprecated_params", + ( + ("add_file_based_project", {"glossaryAccess": True}), + ("add_file_based_project", {"preTranslationAiPromptId": 1}), + ("add_strings_based_project", {"glossaryAccess": False}), + ("add_strings_based_project", {"preTranslationAiPromptId": 1}), + ), + ) + @mock.patch("crowdin_api.api_resources.projects.resource.ProjectsResource.add_project") + def test_add_project_deprecated_params( + self, m_add_project, method_name, deprecated_params, base_absolut_url + ): + m_add_project.return_value = "response" + + resource = self.get_resource(base_absolut_url) + with pytest.warns(DeprecationWarning): + assert getattr(resource, method_name)( + name="name", sourceLanguageId="en", **deprecated_params + ) == "response" + + request_data = m_add_project.call_args.kwargs["request_data"] + for key, value in deprecated_params.items(): + assert request_data[key] == value + @mock.patch("crowdin_api.requester.APIRequester.request") def test_get_project(self, m_request, base_absolut_url): m_request.return_value = "response" @@ -699,53 +970,59 @@ def test_delete_project_strings_exporter_settings(self, m_request, base_absolut_ "format": "android", "settings": { "convertPlaceholders": True, + "useCdataForStringsWithTags": True, }, }, - { - "format": "android", - "settings": { - "convertPlaceholders": True, + [ + {"op": "replace", "path": "/format", "value": "android"}, + { + "op": "replace", + "path": "/settings", + "value": { + "convertPlaceholders": True, + "useCdataForStringsWithTags": True, + }, }, - }, + ], ), ( - { - "format": "macosx", - "settings": { - "convertPlaceholders": True, - }, - }, - { - "format": "macosx", - "settings": { - "convertPlaceholders": True, - }, - }, + {"format": "macosx"}, + [{"op": "replace", "path": "/format", "value": "macosx"}], + ), + ( + {"settings": {"copySourceToEmptyTarget": True}}, + [ + { + "op": "replace", + "path": "/settings", + "value": {"copySourceToEmptyTarget": True}, + } + ], ), ( { - "format": "xliff", - "settings": { - "languagePaitMapping": { - "uk": "es", - "de": "en", - }, - }, - }, - { - "format": "xliff", - "settings": { - "languagePaitMapping": { - "uk": "es", - "de": "en", - }, - }, + "data": [ + { + "op": PatchOperation.REPLACE, + "path": StringsExporterSettingsPatchPath.SETTINGS, + "value": {"exportContext": True}, + } + ] }, + [ + { + "op": PatchOperation.REPLACE, + "path": StringsExporterSettingsPatchPath.SETTINGS, + "value": {"exportContext": True}, + } + ], ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") - def test_edit_project_strings_exporter_settings(self, m_request, in_params, request_data, base_absolut_url): + def test_edit_project_strings_exporter_settings( + self, m_request, in_params, request_data, base_absolut_url + ): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) diff --git a/crowdin_api/api_resources/projects/types.py b/crowdin_api/api_resources/projects/types.py index 99319391..8041598a 100644 --- a/crowdin_api/api_resources/projects/types.py +++ b/crowdin_api/api_resources/projects/types.py @@ -6,6 +6,9 @@ EscapeQuotes, EscapeSpecialCharacters, ProjectFilePatchPath, + StringsExporterSettingsPatchPath, + TmPreTranslateAutoApproveOption, + TmPreTranslateMinimumMatchRatio, ) from crowdin_api.typing import TypedDict @@ -22,75 +25,226 @@ class NotificationSettings(TypedDict): managerLanguageCompleted: bool -class QACheckCategories(TypedDict): - EMPTY: bool - SIZE: bool - TAGS: bool - SPACES: bool - VARIABLES: bool - PUNCTUATION: bool - SYMBOLREGISTER: bool - SPECIALSYMBOLS: bool - WRONGTRANSLATION: bool - SPELLCHECK: bool - ICU: bool - TERMS: bool - DUPLICATE: bool - - -class QAChecksIgnorableCategories(TypedDict): - EMPTY: bool - SIZE: bool - TAGS: bool - SPACES: bool - VARIABLES: bool - PUNCTUATION: bool - SYMBOLREGISTER: bool - SPECIALSYMBOLS: bool - WRONGTRANSLATION: bool - SPELLCHECK: bool - ICU: bool - TERMS: bool - DUPLICATE: bool - FTL: bool - ANDROID: bool - - -class PropertyFileFormatSettings(TypedDict): +class QACheckCategories(TypedDict, total=False): + empty: bool + size: bool + tags: bool + spaces: bool + variables: bool + punctuation: bool + symbolRegister: bool + specialSymbols: bool + wrongTranslation: bool + spellcheck: bool + icu: bool + terms: bool + duplicate: bool + ftl: bool + android: bool + numbers: bool + ai: bool + outdated: bool + mdx: bool + unifiedPlaceholders: bool + + +class QAChecksIgnorableCategories(QACheckCategories, total=False): + pass + + +class TmPreTranslate(TypedDict, total=False): + """ + Crowdin only. + """ + + enabled: bool + autoApproveOption: TmPreTranslateAutoApproveOption + minimumMatchRatio: TmPreTranslateMinimumMatchRatio + + +class MtPreTranslateEngineSettings(TypedDict, total=False): + mtId: int + languageIds: Iterable[str] + + +class MtPreTranslate(TypedDict, total=False): + """ + Crowdin only. + """ + + enabled: bool + mts: Iterable[MtPreTranslateEngineSettings] + + +class AiPreTranslatePromptSettings(TypedDict, total=False): + aiPromptId: int + languageIds: Iterable[str] + + +class AiPreTranslate(TypedDict, total=False): + """ + Crowdin only. + """ + + enabled: bool + aiPrompts: Iterable[AiPreTranslatePromptSettings] + + +class WorkflowStepConfig(TypedDict, total=False): + """ + Keys are Language Identifiers, values are User (`assignees`) or Team (`assignedTeams`) Identifiers. + """ + + assignees: Dict[str, Iterable[int]] + assignedTeams: Dict[str, Iterable[int]] + + +class _WorkflowStepSettingsRequired(TypedDict): + id: int + + +class WorkflowStepSettings(_WorkflowStepSettingsRequired, total=False): + """ + Crowdin Enterprise only. Workflow Template Step configuration. + + Use `config` for Translation and Proofreading steps, `vendorId` for vendor steps, + `mtId` for machine translation steps and `promptId` for AI steps. + """ + + languages: Iterable[str] + config: WorkflowStepConfig + vendorId: int + mtId: int + promptId: int + + +class PropertyFileFormatSettings(TypedDict, total=False): escapeQuotes: EscapeQuotes escapeSpecialCharacters: EscapeSpecialCharacters exportPattern: str -class XmlFileFormatSettings(TypedDict): +class XmlFileFormatSettings(TypedDict, total=False): translateContent: bool translateAttributes: bool translatableElements: Iterable[str] contentSegmentation: bool srxStorageId: int + inlineTags: Iterable[str] exportPattern: str -class SpecificFileFormatSettings(TypedDict): +class SpecificFileFormatSettings(TypedDict, total=False): """ Includes kind standard file format settings: - - WebXml file - - Html file - - Adoc file - - Md file + - WebXml file (`inlineTags`) + - Html file (`inlineTags`, `excludedElements`) + - Adoc file (`excludeIncludeDirectives`) + - Android file - FmMd file - - FmHtml file - - MadcapFisnp file - - Idml file + - FmHtml file (`inlineTags`, `excludedElements`, `excludedFrontMatterElements`) + - MadcapFlsnp file + - Idml file (`inlineHyperlinkText`) - Mif file - Dita file + - Arb file + - Fjs file + - MacOSX file + - Chrome file + - CSV file + - XLSX file + - Xliff file + - Xliff 2.0 file + - React intl file + """ + contentSegmentation: bool + srxStorageId: int + exportPattern: str + inlineTags: Iterable[str] + excludedElements: Iterable[str] + excludedFrontMatterElements: Iterable[str] + excludeIncludeDirectives: bool + inlineHyperlinkText: bool + + +class MdFileFormatSettings(TypedDict, total=False): + """ + Markdown file format settings. + + Values available: + - strongMarker, emphasisMarker: "asterisk", "underscore" + - unorderedListBullet: "asterisks", "plus", "dash" + - tableColumnWidth: "consolidate", "evenly_distribute_cells" + - frontMatterQuotes: "auto", "single", "double" + """ + contentSegmentation: bool + srxStorageId: int + exportPattern: str + inlineTags: Iterable[str] + strongMarker: str + emphasisMarker: str + unorderedListBullet: str + tableColumnWidth: str + frontMatterQuotes: str + + +class MdxFileFormatSettings(TypedDict, total=False): + """ + Mdx v1 / Mdx v2 file format settings. + + Values available: + - type (Mdx v1 only): "mdx_v1", "mdx_v2" + - strongMarker, emphasisMarker: "asterisk", "underscore" + - unorderedListBullet: "asterisks", "plus", "dash" + - tableColumnWidth: "consolidate", "evenly_distribute_cells" + """ + contentSegmentation: bool + srxStorageId: int + exportPattern: str + type: str + excludedFrontMatterElements: Iterable[str] + excludeCodeBlocks: bool + strongMarker: str + emphasisMarker: str + unorderedListBullet: str + tableColumnWidth: str + + +class JsonFileFormatSettings(TypedDict, total=False): + """ + Json file format settings. + + Values available for `type`: "i18next_json", "nestjs_i18n". """ contentSegmentation: bool srxStorageId: int exportPattern: str + type: str + + +class JavaScriptFileFormatSettings(TypedDict, total=False): + """ + JavaScript file format settings. + + Values available for `exportQuotes`: "single", "double". + """ + exportPattern: str + exportQuotes: str -class DocxFileFormatSettings(TypedDict): +class StringCatalogFileFormatSettings(TypedDict, total=False): + importKeyAsSource: bool + importTranslations: bool + exportPattern: str + + +class VdfFileFormatSettings(TypedDict, total=False): + convertIcu: bool + addGenderArgument: bool + exportPattern: str + + +class DocxFileFormatSettings(TypedDict, total=False): cleanTagsAggressively: bool translateHiddenText: bool translateHyperlinkUrls: bool @@ -100,35 +254,75 @@ class DocxFileFormatSettings(TypedDict): contentSegmentation: bool srxStorageId: int exportPattern: str + translateDocProperties: bool + translateComments: bool + ignoreWhitespaceStyles: bool + addTabAsCharacter: bool + addLineSeparatorAsCharacter: bool + lineSeparatorReplacement: str + replaceNoBreakHyphenTag: bool + ignoreSoftHyphenTag: bool + complexFieldDefinitionsToExtract: Iterable[str] + translateWordHeadersFooters: bool + translateWordGraphicName: bool + translateWordGraphicDescription: bool + ignoreWordFontColors: bool + wordFontColorsMinIgnoranceThreshold: str + wordFontColorsMaxIgnoranceThreshold: str + excludeWordStyles: Iterable[str] + translateWordInExcludeStyleMode: bool + wordHighlightColors: Iterable[str] + translateWordInExcludeHighlightMode: bool + translateWordExcludeColors: bool + wordExcludedColors: Iterable[str] + translateExcelCellsCopied: bool + translateExcelSheetNames: bool + excelExcludedColors: Iterable[str] + translateExcelDiagramData: bool + translateExcelDrawings: bool + allowWordStyleOptimization: bool + translateExcelExcludeColors: bool -class MediaWikiFileFormatSettings(TypedDict): +class MediaWikiFileFormatSettings(TypedDict, total=False): srxStorageId: int exportPattern: str -class TxtFileFormatSettings(TypedDict): +class TxtFileFormatSettings(TypedDict, total=False): srxStorageId: int exportPattern: str -class OtherFileFormatSettings(TypedDict): +class OtherFileFormatSettings(TypedDict, total=False): exportPattern: str -class AndroidStringsExporterSettings(TypedDict): +class AndroidStringsExporterSettings(TypedDict, total=False): convertPlaceholders: bool + convertLineBreaks: bool + useCdataForStringsWithTags: bool -class MacOSXStringsExporterSettings(TypedDict): +class MacOSXStringsExporterSettings(TypedDict, total=False): convertPlaceholders: bool + convertLineBreaks: bool + exportContext: bool -class XliffStringsExporterSettings(TypedDict): +class XliffStringsExporterSettings(TypedDict, total=False): languagePairMapping: Dict[str, str] + copySourceToEmptyTarget: bool + exportTranslatorsComment: bool + + +class StringsExporterSettingsPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: StringsExporterSettingsPatchPath class ProjectFilePatchRequest(TypedDict): - value: Union[str, List[str]] + value: Union[str, List[str], Dict[str, Any]] op: PatchOperation path: ProjectFilePatchPath diff --git a/crowdin_api/api_resources/reports/enums.py b/crowdin_api/api_resources/reports/enums.py index 8888004e..77c04105 100644 --- a/crowdin_api/api_resources/reports/enums.py +++ b/crowdin_api/api_resources/reports/enums.py @@ -10,17 +10,47 @@ class ExportFormat(Enum): class ScopeType(Enum): ORGANIZATION = "organization" GRPOUP = "group" + GROUP = "group" PROJECT = "project" class ReportName(Enum): PRE_TRANSLATE_ACCURACY = "pre-translate-accuracy" + # Deprecated: no longer supported by the API COSTS_ESTIMATION = "costs-estimation" + # Deprecated: no longer supported by the API TRANSLATION_COSTS = "translation-costs" TOP_MEMBERS = "top-members" CONTRIBUTION_RAW_DATA = "contribution-raw-data" COSTS_ESTIMATION_POST_EDITING = "costs-estimation-pe" TRANSLATION_COSTS_POST_EDITING = "translation-costs-pe" + TRANSLATOR_ACCURACY = "translator-accuracy" + SOURCE_CONTENT_UPDATES = "source-content-updates" + PROJECT_MEMBERS = "project-members" + EDITOR_ISSUES = "editor-issues" + QA_CHECK_ISSUES = "qa-check-issues" + SAVING_ACTIVITY = "saving-activity" + TRANSLATION_ACTIVITY = "translation-activity" + TIME_SPENT = "time-spent" + # Enterprise only + TASK_USAGE = "task-usage" + + +class GroupReportName(Enum): + """ + Report names for Enterprise group and organization reports. + """ + + GROUP_TRANSLATION_COSTS_POST_EDITING = "group-translation-costs-pe" + GROUP_TOP_MEMBERS = "group-top-members" + GROUP_TASK_USAGE = "group-task-usage" + GROUP_QA_CHECK_ISSUES = "group-qa-check-issues" + GROUP_TRANSLATION_ACTIVITY = "group-translation-activity" + GROUP_SOURCE_CONTENT_UPDATES = "group-source-content-updates" + GROUP_TIME_SPENT = "group-time-spent" + GROUP_PRE_TRANSLATE_ACCURACY = "group-pre-translate-accuracy" + GROUP_TRANSLATOR_ACCURACY = "group-translator-accuracy" + GROUP_SAVING_ACTIVITY = "group-saving-activity" class Unit(Enum): @@ -28,6 +58,8 @@ class Unit(Enum): WORDS = "words" CHARS = "chars" CHARS_WITH_SPACES = "chars_with_spaces" + # Only for hourly report settings templates + HOURS = "hours" class Currency(Enum): @@ -53,6 +85,8 @@ class Currency(Enum): ZAR = "ZAR" GEL = "GEL" UAH = "UAH" + DDK = "DDK" + PLN = "PLN" class SchemaMode(Enum): @@ -85,6 +119,12 @@ class FuzzyRateMode(Enum): class GroupBy(Enum): USER = "user" LANGUAGE = "language" + # Only for time spent reports + TASK = "task" + # Only for task usage reports + TYPE = "type" + # Only for group and organization reports + PROJECT = "project" class ContributionMode: @@ -94,11 +134,14 @@ class ContributionMode: class ReportSettingsTemplatesPatchPath(Enum): - NAME = "name" - CURRENCY = "currency" - UNIT = "unit" - MODE = "mode" - CONFIG = "config" + NAME = "/name" + CURRENCY = "/currency" + UNIT = "/unit" + # Crowdin project report settings templates only + MODE = "/mode" + CONFIG = "/config" + # Enterprise organization report settings templates only + IS_PUBLIC = "/isPublic" class ReportLabelIncludeType(Enum): @@ -111,3 +154,44 @@ class MatchType(Enum): OPTION_100 = "100" OPTION_99_82 = "99-82" OPTION_81_60 = "81-60" + + +class SavingActivityMode(Enum): + CURRENCY = "currency" + RELATIVE = "relative" + + +class EditorIssueType(Enum): + GENERAL_QUESTION = "general_question" + TRANSLATION_MISTAKE = "translation_mistake" + CONTEXT_REQUEST = "context_request" + SOURCE_MISTAKE = "source_mistake" + + +class TaskType(Enum): + """ + Task type for `typeTasks` report schema field. + + 0 - translate, 1 - proofread, 2 - translate by vendor, 3 - proofread by vendor. + """ + + TRANSLATE = 0 + PROOFREAD = 1 + TRANSLATE_BY_VENDOR = 2 + PROOFREAD_BY_VENDOR = 3 + + +class TaskUsageReportType(Enum): + WORKLOAD = "workload" + CREATED_VS_RESOLVED = "created-vs-resolved" + PERFORMANCE = "performance" + TIME = "time" + COST = "cost" + + +class TaskUsageStatus(Enum): + TODO = "todo" + IN_PROGRESS = "in_progress" + DONE = "done" + CLOSED = "closed" + REVIEW = "review" diff --git a/crowdin_api/api_resources/reports/resource.py b/crowdin_api/api_resources/reports/resource.py index 3b6757a1..39451a4f 100644 --- a/crowdin_api/api_resources/reports/resource.py +++ b/crowdin_api/api_resources/reports/resource.py @@ -1,6 +1,6 @@ import abc from datetime import datetime -from typing import Dict, Iterable, Optional +from typing import Dict, Iterable, Optional, Union from deprecated import deprecated from crowdin_api.api_resources.abstract.resources import BaseResource @@ -13,6 +13,11 @@ GroupBy, Unit, ReportLabelIncludeType, + SavingActivityMode, + EditorIssueType, + TaskType, + TaskUsageReportType, + TaskUsageStatus, ) from crowdin_api.api_resources.reports.requests.cost_estimation_post_editing import ( IndividualRate as CostEstimationPeIndividualRate, @@ -30,7 +35,11 @@ StepTypes, ReportSettingsTemplatesPatchRequest, Config, - BaseRates + BaseRates, + HourlyBaseRates, + HourlyIndividualRate, + PostEditingConfig, + HourlyConfig, ) @@ -46,10 +55,10 @@ def generate_report(self, request_data: Dict, projectId: Optional[int] = None): Generate Report. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.download.download + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -96,15 +105,16 @@ def generate_top_members_report( format: Optional[Format] = Format.XLSX, dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, + userIds: Optional[Iterable[int]] = None, ): """ Generate Report(Top Members). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -119,6 +129,7 @@ def generate_top_members_report( "format": format, "dateFrom": dateFrom, "dateTo": dateTo, + "userIds": userIds, }, }, ) @@ -132,15 +143,24 @@ def generate_contribution_raw_data_report( userId: Optional[int] = None, dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, + columns: Optional[Iterable[str]] = None, + tmIds: Optional[Iterable[int]] = None, + mtIds: Optional[Iterable[int]] = None, + aiPromptIds: Optional[Iterable[int]] = None, + fileIds: Optional[Iterable[int]] = None, + directoryIds: Optional[Iterable[int]] = None, + branchIds: Optional[Iterable[int]] = None, ): """ Generate Report(Contribution Raw Data). + `fileIds` and `directoryIds` are available for file-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -156,6 +176,56 @@ def generate_contribution_raw_data_report( "userId": userId, "dateFrom": dateFrom, "dateTo": dateTo, + "columns": columns, + "tmIds": tmIds, + "mtIds": mtIds, + "aiPromptIds": aiPromptIds, + "fileIds": fileIds, + "directoryIds": directoryIds, + "branchIds": branchIds, + }, + }, + ) + + def generate_contribution_raw_data_by_task_report( + self, + mode: ContributionMode, + task_id: int, + project_id: Optional[int] = None, + unit: Optional[Unit] = None, + columns: Optional[Iterable[str]] = None, + tm_ids: Optional[Iterable[int]] = None, + mt_ids: Optional[Iterable[int]] = None, + ai_prompt_ids: Optional[Iterable[int]] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + ): + """ + Generate Report(Contribution Raw Data By Task). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post + """ + + project_id = project_id or self.get_project_id() + + return self.generate_report( + projectId=project_id, + request_data={ + "name": "contribution-raw-data", + "schema": { + "mode": mode, + "unit": unit, + "taskId": task_id, + "columns": columns, + "tmIds": tm_ids, + "mtIds": mt_ids, + "aiPromptIds": ai_prompt_ids, + "dateFrom": date_from, + "dateTo": date_to, }, }, ) @@ -169,13 +239,13 @@ def generate_source_content_updates_report( date_to: Optional[datetime] = None, ): """ - Generate Report(Contribution Raw Data). + Generate Report(Source Content Updates). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -204,10 +274,10 @@ def generate_project_members_report( Generate Report(Project Members). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -230,16 +300,16 @@ def generate_editor_issues_report( date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, format: Optional[Format] = Format.XLSX, - issue_type: Optional[str] = None, + issue_type: Optional[Union[EditorIssueType, str]] = None, ): """ Generate Report(Editor Issues). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -263,15 +333,16 @@ def generate_qa_check_issues_report( format: Optional[Format] = Format.XLSX, date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, + language_id: Optional[str] = None, ): """ Generate Report(Qa Check Issues). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -282,6 +353,7 @@ def generate_qa_check_issues_report( "name": "qa-check-issues", "schema": { "format": format, + "languageId": language_id, "dateFrom": date_from, "dateTo": date_to, }, @@ -296,15 +368,22 @@ def generate_saving_activity_report( format: Optional[Format] = Format.XLSX, date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, + mode: Optional[SavingActivityMode] = None, + file_ids: Optional[Iterable[int]] = None, + directory_ids: Optional[Iterable[int]] = None, + branch_ids: Optional[Iterable[int]] = None, + user_ids: Optional[Iterable[int]] = None, ): """ Generate Report(Saving Activity). + `file_ids` and `directory_ids` are available for file-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -319,6 +398,11 @@ def generate_saving_activity_report( "format": format, "dateFrom": date_from, "dateTo": date_to, + "mode": mode, + "fileIds": file_ids, + "directoryIds": directory_ids, + "branchIds": branch_ids, + "userIds": user_ids, }, }, ) @@ -331,15 +415,16 @@ def generate_translation_activity_report( format: Optional[Format] = Format.XLSX, date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, + user_ids: Optional[Iterable[int]] = None, ): """ Generate Report(Translation Activity). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -354,6 +439,7 @@ def generate_translation_activity_report( "format": format, "dateFrom": date_from, "dateTo": date_to, + "userIds": user_ids, }, }, ) @@ -367,15 +453,26 @@ def generate_pre_translate_accuracy_general_report( languageId: Optional[str] = None, dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, + matchScoreCategories: Optional[Iterable[str]] = None, + fileIds: Optional[Iterable[int]] = None, + directoryIds: Optional[Iterable[int]] = None, + branchIds: Optional[Iterable[int]] = None, + labelIds: Optional[Iterable[int]] = None, + labelIncludeType: Optional[ReportLabelIncludeType] = None, + skipArchiving: Optional[bool] = None, ): """ - Generate Report. + Generate Report(Pre-Translate Accuracy General). + + `postEditingCategories` is deprecated by the API, use `matchScoreCategories` instead. + + `fileIds` and `directoryIds` are available for file-based projects only. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -388,9 +485,16 @@ def generate_pre_translate_accuracy_general_report( "unit": unit, "format": format, "postEditingCategories": postEditingCategories, + "matchScoreCategories": matchScoreCategories, "languageId": languageId, "dateFrom": dateFrom, "dateTo": dateTo, + "fileIds": fileIds, + "directoryIds": directoryIds, + "branchIds": branchIds, + "labelIds": labelIds, + "labelIncludeType": labelIncludeType, + "skipArchiving": skipArchiving, }, }, ) @@ -402,15 +506,19 @@ def generate_pre_translate_accuracy_by_task_report( format: Optional[Format] = None, postEditingCategories: Optional[Iterable[str]] = None, taskId: Optional[int] = None, + matchScoreCategories: Optional[Iterable[str]] = None, + skipArchiving: Optional[bool] = None, ): """ - Generate Report. + Generate Report(Pre-Translate Accuracy By Task). + + `postEditingCategories` is deprecated by the API, use `matchScoreCategories` instead. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -423,7 +531,62 @@ def generate_pre_translate_accuracy_by_task_report( "unit": unit, "format": format, "postEditingCategories": postEditingCategories, + "matchScoreCategories": matchScoreCategories, "taskId": taskId, + "skipArchiving": skipArchiving, + }, + }, + ) + + def generate_translator_accuracy_report( + self, + project_id: Optional[int] = None, + unit: Optional[Unit] = None, + format: Optional[Format] = None, + match_score_categories: Optional[Iterable[str]] = None, + language_id: Optional[str] = None, + user_ids: Optional[Iterable[int]] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + file_ids: Optional[Iterable[int]] = None, + directory_ids: Optional[Iterable[int]] = None, + branch_ids: Optional[Iterable[int]] = None, + label_ids: Optional[Iterable[int]] = None, + label_include_type: Optional[ReportLabelIncludeType] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Report(Translator Accuracy). + + `file_ids` and `directory_ids` are available for file-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post + """ + + project_id = project_id or self.get_project_id() + + return self.generate_report( + projectId=project_id, + request_data={ + "name": "translator-accuracy", + "schema": { + "unit": unit, + "format": format, + "matchScoreCategories": match_score_categories, + "languageId": language_id, + "userIds": user_ids, + "dateFrom": date_from, + "dateTo": date_to, + "fileIds": file_ids, + "directoryIds": directory_ids, + "branchIds": branch_ids, + "labelIds": label_ids, + "labelIncludeType": label_include_type, + "skipArchiving": skip_archiving, }, }, ) @@ -446,16 +609,22 @@ def generate_costs_estimation_post_editing_general_report( date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, label_ids: Optional[Iterable[int]] = None, - label_include_type: Optional[ReportLabelIncludeType] = None + label_include_type: Optional[ReportLabelIncludeType] = None, + skip_archiving: Optional[bool] = None, + workflow_step_id: Optional[int] = None, ): """ - Generate Report. + Generate Report(Costs Estimation Post-Editing General). + + `workflow_step_id` is available for Crowdin Enterprise only. + + `file_ids` and `directory_ids` are available for file-based projects only. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -480,7 +649,9 @@ def generate_costs_estimation_post_editing_general_report( "dateFrom": date_from, "dateTo": date_to, "labelIds": label_ids, - "labelIncludeType": label_include_type + "labelIncludeType": label_include_type, + "skipArchiving": skip_archiving, + "workflowStepId": workflow_step_id, } } ) @@ -496,16 +667,20 @@ def generate_costs_estimation_post_editing_by_task_report( net_rate_schemes: Optional[CostEstimationPeNetRateSchemes] = None, calculate_internal_matches: Optional[bool] = None, include_pre_translated_strings: Optional[bool] = None, - task_id: Optional[int] = None + task_id: Optional[int] = None, + task_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, ): """ - Generate Report. + Generate Report(Costs Estimation Post-Editing By Task). + + `task_id` is deprecated by the API, use `task_ids` instead. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -523,7 +698,9 @@ def generate_costs_estimation_post_editing_by_task_report( "netRateSchemes": net_rate_schemes, "calculateInternalMatches": calculate_internal_matches, "includePreTranslatedStrings": include_pre_translated_strings, - "taskId": task_id + "taskId": task_id, + "taskIds": task_ids, + "skipArchiving": skip_archiving, } } ) @@ -544,16 +721,26 @@ def generate_translation_costs_post_editing_general_report( user_ids: Optional[Iterable[int]] = None, file_ids: Optional[Iterable[int]] = None, directory_ids: Optional[Iterable[int]] = None, - branch_ids: Optional[Iterable[int]] = None + branch_ids: Optional[Iterable[int]] = None, + use_category_based_proofread_rates: Optional[bool] = None, + use_tm_edit_distance: Optional[bool] = None, + label_ids: Optional[Iterable[int]] = None, + label_include_type: Optional[ReportLabelIncludeType] = None, + skip_archiving: Optional[bool] = None, + workflow_step_id: Optional[int] = None, ): """ - Generate Report. + Generate Report(Translation Costs Post-Editing General). + + `workflow_step_id` is available for Crowdin Enterprise only. + + `file_ids` and `directory_ids` are available for file-based projects only. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -576,7 +763,13 @@ def generate_translation_costs_post_editing_general_report( "userIds": user_ids, "fileIds": file_ids, "directoryIds": directory_ids, - "branchIds": branch_ids + "branchIds": branch_ids, + "useCategoryBasedProofreadRates": use_category_based_proofread_rates, + "useTmEditDistance": use_tm_edit_distance, + "labelIds": label_ids, + "labelIncludeType": label_include_type, + "skipArchiving": skip_archiving, + "workflowStepId": workflow_step_id, } } ) @@ -590,16 +783,22 @@ def generate_translation_costs_post_editing_by_task_report( unit: Optional[Unit] = None, currency: Optional[Currency] = None, format: Optional[Format] = None, - task_id: Optional[int] = None + task_id: Optional[int] = None, + task_ids: Optional[Iterable[int]] = None, + use_category_based_proofread_rates: Optional[bool] = None, + use_tm_edit_distance: Optional[bool] = None, + skip_archiving: Optional[bool] = None, ): """ - Generate Report. + Generate Report(Translation Costs Post-Editing By Task). + + `task_id` is deprecated by the API, use `task_ids` instead. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ project_id = project_id or self.get_project_id() @@ -615,11 +814,66 @@ def generate_translation_costs_post_editing_by_task_report( "baseRates": base_rates, "individualRates": individual_rates, "netRateSchemes": net_rate_schemes, - "taskId": task_id + "taskId": task_id, + "taskIds": task_ids, + "useCategoryBasedProofreadRates": use_category_based_proofread_rates, + "useTmEditDistance": use_tm_edit_distance, + "skipArchiving": skip_archiving, } } ) + def generate_time_spent_report( + self, + project_id: Optional[int] = None, + format: Optional[Format] = None, + group_by: Optional[GroupBy] = None, + base_rates: Optional[HourlyBaseRates] = None, + individual_rates: Optional[Iterable[HourlyIndividualRate]] = None, + language_id: Optional[str] = None, + user_ids: Optional[Iterable[int]] = None, + type_tasks: Optional[TaskType] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + task_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + workflow_step_id: Optional[int] = None, + ): + """ + Generate Report(Time Spent). + + `workflow_step_id` is available for Crowdin Enterprise only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post + """ + + project_id = project_id or self.get_project_id() + + return self.generate_report( + projectId=project_id, + request_data={ + "name": "time-spent", + "schema": { + "format": format, + "groupBy": group_by, + "baseRates": base_rates, + "individualRates": individual_rates, + "languageId": language_id, + "userIds": user_ids, + "typeTasks": type_tasks, + "dateFrom": date_from, + "dateTo": date_to, + "taskIds": task_ids, + "workflowStepId": workflow_step_id, + "skipArchiving": skip_archiving, + }, + }, + ) + def check_report_generation_status( self, reportId: str, projectId: Optional[int] = None ): @@ -627,10 +881,10 @@ def check_report_generation_status( Check Report Generation Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.get Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.get """ projectId = projectId or self.get_project_id() @@ -645,10 +899,10 @@ def download_report(self, reportId: str, projectId: Optional[int] = None): Download Report. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.download.download + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.download.download Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.download.download + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.download.download """ projectId = projectId or self.get_project_id() @@ -680,10 +934,10 @@ def list_report_settings_template( List Report Settings Templates. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.settings-templates.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.settings-templates.getMany Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.settings-templates.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.settings-templates.getMany """ projectId = projectId or self.get_project_id() @@ -699,18 +953,21 @@ def add_report_settings_template( name: str, currency: Currency, unit: Unit, - config: Config, + config: Union[PostEditingConfig, HourlyConfig, Config], isPublic: Optional[bool] = None, projectId: Optional[int] = None, + isGlobal: Optional[bool] = None, ): """ Add Report Settings Templates. + `isGlobal` is available for Crowdin only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.settings-templates.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.settings-templates.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.settings-templates.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.settings-templates.post """ projectId = projectId or self.get_project_id() @@ -724,9 +981,9 @@ def add_report_settings_template( "name": name, "currency": currency, "unit": unit, - "mode": "simple", "config": config, "isPublic": isPublic, + "isGlobal": isGlobal, } ) @@ -739,10 +996,10 @@ def get_report_settings_template( Get Report Settings Templates. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.settings-templates.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.settings-templates.get Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.settings-templates.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.settings-templates.get """ projectId = projectId or self.get_project_id() @@ -765,10 +1022,10 @@ def edit_report_settings_template( Edit Report Settings Templates. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.settings-templates.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.settings-templates.patch Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.settings-templates.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.settings-templates.patch """ projectId = projectId or self.get_project_id() @@ -791,10 +1048,10 @@ def delete_report_settings_template( Delete Report Settings Templates. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.settings-templates.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.settings-templates.delete Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.settings-templates.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.settings-templates.delete """ projectId = projectId or self.get_project_id() @@ -808,118 +1065,257 @@ def delete_report_settings_template( ) -class ReportsResource(BaseReportsResource, BaseReportSettingsTemplatesResource): +class UserReportSettingsTemplatesResource(BaseReportSettingsTemplatesResource): """ - Resource for Reports. + Resource for User Report Settings Templates API. - Reports help to estimate costs, calculate translation costs, and identify the top members. + Supporting the endpoints for managing user report settings templates. - Use API to generate Cost Estimate, Translation Cost, and Top Members reports. You can then - export reports in .xlsx or .csv file formats. Report generation is an asynchronous operation - and shall be completed with a sequence of API methods. + These methods are also available on `ReportsResource` and `EnterpriseReportsResource` + (both platforms support `/users/{userId}/reports/settings-templates`). Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Reports + https://support.crowdin.com/developer/api/v2/#tag/User-Report-Settings-Templates """ - def get_report_archive_path(self, userId: int, archiveId: Optional[int] = None): - if archiveId is not None: - return f"users/{userId}/reports/archives/{archiveId}" - - return f"users/{userId}/reports/archives" - - def get_report_archive_export_path( + def get_user_report_settings_templates_path( self, userId: int, - archiveId: int, - exportId: Optional[str] = None, + reportSettingsTemplateId: Optional[int] = None ): - if exportId is not None: - return f"users/{userId}/reports/archives/{archiveId}/exports/{exportId}" + if reportSettingsTemplateId is not None: + return f"users/{userId}/reports/settings-templates/{reportSettingsTemplateId}" - return f"users/{userId}/reports/archives/{archiveId}/exports" + return f"users/{userId}/reports/settings-templates" - def list_report_archives( + def list_user_report_settings_template( self, userId: int, - scopeType: Optional[str] = None, - scopeId: Optional[int] = None, - limit: Optional[int] = None, offset: Optional[int] = None, + limit: Optional[int] = None ): """ - List Report Archives + List User Report Settings Templates. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.reports.archives.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.settings-templates.getMany """ - params = {"scopeType": scopeType, "scopeId": scopeId} - params.update(self.get_page_params(limit=limit, offset=offset)) - - return self.requester.request( + return self._get_entire_data( method="get", - path=self.get_report_archive_path(userId=userId), - params=params, + path=self.get_user_report_settings_templates_path(userId=userId), + params=self.get_page_params(offset=offset, limit=limit), ) - def get_report_archive(self, userId: int, archiveId: int): + def add_user_report_settings_template( + self, + userId: int, + name: str, + currency: Currency, + unit: Unit, + config: Union[PostEditingConfig, HourlyConfig, Config], + ): """ - Get Report Archive + Add User Report Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.archives.get + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.settings-templates.post """ return self.requester.request( - method="get", - path=self.get_report_archive_path(userId=userId, archiveId=archiveId), + method="post", + path=self.get_user_report_settings_templates_path( + userId=userId, + ), + request_data={ + "name": name, + "currency": currency, + "unit": unit, + "config": config, + } ) - def delete_report_archive(self, userId: int, archiveId: int): + def get_user_report_settings_template( + self, + userId: int, + reportSettingsTemplateId: int, + ): """ - Delete Report Archive + Get User Report Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.archives.delete + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.settings-templates.get """ return self.requester.request( - method="delete", - path=self.get_report_archive_path(userId=userId, archiveId=archiveId), + method="get", + path=self.get_user_report_settings_templates_path( + userId=userId, + reportSettingsTemplateId=reportSettingsTemplateId + ), ) - def export_report_archive( - self, userId: int, archiveId: int, format: Optional[ExportFormat] = None + def edit_user_report_settings_template( + self, + userId: int, + reportSettingsTemplateId: int, + data: Iterable[ReportSettingsTemplatesPatchRequest], ): """ - Export Report Archive + Edit User Report Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.reports.archives.exports.post + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.settings-templates.patch """ - format = format or ExportFormat.XLSX return self.requester.request( - method="post", - path=self.get_report_archive_export_path( - userId=userId, archiveId=archiveId + method="patch", + path=self.get_user_report_settings_templates_path( + userId=userId, + reportSettingsTemplateId=reportSettingsTemplateId ), - request_data={"format": format}, + request_data=data, ) - def check_report_archive_export_status( - self, userId: int, archiveId: int, exportId: str + def delete_user_report_settings_template( + self, + userId: int, + reportSettingsTemplateId: int, ): """ - Check Report Archive Status + Delete User Report Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.archives.exports.get + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.settings-templates.delete """ return self.requester.request( - method="get", - path=self.get_report_archive_export_path( - userId=userId, archiveId=archiveId, exportId=exportId - ), - ) - + method="delete", + path=self.get_user_report_settings_templates_path( + userId=userId, + reportSettingsTemplateId=reportSettingsTemplateId + ), + ) + + +class ReportsResource(BaseReportsResource, UserReportSettingsTemplatesResource): + """ + Resource for Reports. + + Reports help to estimate costs, calculate translation costs, and identify the top members. + + Use API to generate Cost Estimate, Translation Cost, and Top Members reports. You can then + export reports in .xlsx or .csv file formats. Report generation is an asynchronous operation + and shall be completed with a sequence of API methods. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Reports + """ + + def get_report_archive_path(self, userId: int, archiveId: Optional[int] = None): + if archiveId is not None: + return f"users/{userId}/reports/archives/{archiveId}" + + return f"users/{userId}/reports/archives" + + def get_report_archive_export_path( + self, + userId: int, + archiveId: int, + exportId: Optional[str] = None, + ): + if exportId is not None: + return f"users/{userId}/reports/archives/{archiveId}/exports/{exportId}" + + return f"users/{userId}/reports/archives/{archiveId}/exports" + + def list_report_archives( + self, + userId: int, + scopeType: Optional[str] = None, + scopeId: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + taskId: Optional[int] = None, + name: Optional[str] = None, + dateFrom: Optional[datetime] = None, + dateTo: Optional[datetime] = None, + ): + """ + List Report Archives + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.reports.archives.getMany + """ + params = { + "scopeType": scopeType, + "scopeId": scopeId, + "taskId": taskId, + "name": name, + "dateFrom": dateFrom, + "dateTo": dateTo, + } + params.update(self.get_page_params(limit=limit, offset=offset)) + + return self._get_entire_data( + method="get", + path=self.get_report_archive_path(userId=userId), + params=params, + ) + + def get_report_archive(self, userId: int, archiveId: int): + """ + Get Report Archive + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.archives.get + """ + return self.requester.request( + method="get", + path=self.get_report_archive_path(userId=userId, archiveId=archiveId), + ) + + def delete_report_archive(self, userId: int, archiveId: int): + """ + Delete Report Archive + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.archives.delete + """ + return self.requester.request( + method="delete", + path=self.get_report_archive_path(userId=userId, archiveId=archiveId), + ) + + def export_report_archive( + self, userId: int, archiveId: int, format: Optional[ExportFormat] = None + ): + """ + Export Report Archive + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.reports.archives.exports.post + """ + format = format or ExportFormat.XLSX + return self.requester.request( + method="post", + path=self.get_report_archive_export_path( + userId=userId, archiveId=archiveId + ), + request_data={"format": format}, + ) + + def check_report_archive_export_status( + self, userId: int, archiveId: int, exportId: str + ): + """ + Check Report Archive Status + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.archives.exports.get + """ + return self.requester.request( + method="get", + path=self.get_report_archive_export_path( + userId=userId, archiveId=archiveId, exportId=exportId + ), + ) + def download_report_archive( self, userId: int, archiveId: int, exportId: str ): @@ -927,7 +1323,7 @@ def download_report_archive( Download Report Archive Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.archives.exports.download.get + https://support.crowdin.com/developer/api/v2/#operation/api.users.reports.archives.exports.download.get """ path = str( self.get_report_archive_export_path( @@ -959,7 +1355,7 @@ def generate_simple_cost_estimate_report( Generate Report(Cost Estimate Schema). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -1003,7 +1399,7 @@ def generate_fuzzy_cost_estimate_report( Generate Report(Cost Estimate Fuzzy Mode). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -1045,7 +1441,7 @@ def generate_simple_translation_cost_report( Generate Report(Translation Cost). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -1085,7 +1481,7 @@ def generate_fuzzy_translation_cost_report( Generate Report(Translation Fuzzy Cost). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() @@ -1109,7 +1505,7 @@ def generate_fuzzy_translation_cost_report( ) -class EnterpriseReportsResource(BaseReportsResource, BaseReportSettingsTemplatesResource): +class EnterpriseReportsResource(BaseReportsResource, UserReportSettingsTemplatesResource): """ Resource for Enterprise Reports. @@ -1120,7 +1516,7 @@ class EnterpriseReportsResource(BaseReportsResource, BaseReportSettingsTemplates and shall be completed with a sequence of API methods. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Reports + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Reports """ @staticmethod def _prepare_stepTypes(step_types_const: dict, stepTypes: Optional[Iterable[StepTypes]] = None): @@ -1150,7 +1546,7 @@ def generate_simple_cost_estimate_report( Generate Report(Cost Estimate schema). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() step_types_const = { @@ -1194,7 +1590,7 @@ def generate_fuzzy_cost_estimate_report( Generate Report(Cost Estimate Fuzzy Mode). Links to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() step_types_const = { @@ -1237,7 +1633,7 @@ def generate_simple_translation_cost_report( Generate Report(Translation Cost). Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() step_types_const = { @@ -1278,7 +1674,7 @@ def generate_fuzzy_translation_cost_report( Generate Report(Translation Cost Fuzzy Mode). Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ projectId = projectId or self.get_project_id() step_types_const = { @@ -1325,17 +1721,30 @@ def list_report_archives( scopeId: Optional[int] = None, limit: Optional[int] = None, offset: Optional[int] = None, + userId: Optional[int] = None, + taskId: Optional[int] = None, + name: Optional[str] = None, + dateFrom: Optional[datetime] = None, + dateTo: Optional[datetime] = None, ): """ List Report Archives Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.archives.getMany - """ - params = {"scopeType": scopeType, "scopeId": scopeId} + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.archives.getMany + """ + params = { + "scopeType": scopeType, + "scopeId": scopeId, + "userId": userId, + "taskId": taskId, + "name": name, + "dateFrom": dateFrom, + "dateTo": dateTo, + } params.update(self.get_page_params(limit=limit, offset=offset)) - return self.requester.request( + return self._get_entire_data( method="get", path=self.get_report_archive_path(), params=params, @@ -1346,7 +1755,7 @@ def get_report_archive(self, archiveId: int): Get Report Archive Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.archives.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.archives.get """ return self.requester.request( method="get", @@ -1358,7 +1767,7 @@ def delete_report_archive(self, archiveId: int): Delete Report Archive Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.archives.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.archives.delete """ return self.requester.request( method="delete", @@ -1372,7 +1781,7 @@ def export_report_archive( Export Report Archive Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.archives.exports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.archives.exports.post """ format = format or ExportFormat.XLSX return self.requester.request( @@ -1386,7 +1795,7 @@ def check_report_archive_export_status(self, archiveId: int, exportId: str): Check Report Archive Status Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.archives.exports.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.archives.exports.get """ return self.requester.request( method="get", @@ -1400,7 +1809,7 @@ def download_report_archive(self, archiveId: int, exportId: str): Download Report Archive Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.archives.exports.download.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.archives.exports.download.get """ path = str( self.get_report_archive_export_path(archiveId=archiveId, exportId=exportId) @@ -1411,337 +1820,1221 @@ def download_report_archive(self, archiveId: int, exportId: str): path=path, ) - @staticmethod - def get_group_reports_path(group_id: int, report_id: Optional[str] = None): - if report_id is not None: - return f"groups/{group_id}/reports/{group_id}" - - return f"groups/{group_id}/reports" - - def generate_group_report(self, group_id: int, request_data: Dict): - """ - Generate Group Report. - - Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.reports.post - """ - - return self.requester.request( - method="post", - path=self.get_group_reports_path(group_id=group_id), - request_data=request_data, - ) - - def generate_group_translation_costs_post_editing_general_report( + def generate_task_usage_report( self, - group_id: int, - base_rates: BaseRates, - individual_rates: Iterable[TranslationCostsPeIndividualRate], - net_rate_schemes: TranslationCostsPeNetRateSchemes, - project_ids: Optional[Iterable[int]] = None, - unit: Optional[Unit] = None, - currency: Optional[Currency] = None, + project_id: Optional[int] = None, format: Optional[Format] = None, - group_by: Optional[GroupBy] = None, + type: Optional[TaskUsageReportType] = None, date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, - user_ids: Optional[Iterable[int]] = None + group_by: Optional[GroupBy] = None, + type_tasks: Optional[TaskType] = None, + language_id: Optional[str] = None, + creator_id: Optional[int] = None, + assignee_id: Optional[int] = None, + words_count_from: Optional[int] = None, + words_count_to: Optional[int] = None, + statuses: Optional[Iterable[TaskUsageStatus]] = None, ): """ - Generate Group Report (General). - - Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.reports.post - """ - - return self.generate_group_report( - group_id=group_id, - request_data={ - "name": "group-translation-costs-pe", - "schema": { - "projectIds": project_ids, - "unit": unit, - "currency": currency, - "format": format, - "baseRates": base_rates, - "individualRates": individual_rates, - "netRateSchemes": net_rate_schemes, - "groupBy": group_by, - "dateFrom": date_from, - "dateTo": date_to, - "userIds": user_ids - }, - }, - ) + Generate Report(Task Usage). - def generate_group_task_usage_report( - self, - group_id: int, - format: Optional[Format] = None, - type: Optional[str] = None, - project_ids: Optional[Iterable[int]] = None, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None, - group_by: Optional[GroupBy] = None, - type_task: Optional[int] = None, - language_id: Optional[str] = None, - creator_id: Optional[int] = None, - assignee_id: Optional[int] = None, - ): - """ - Generate Group Report (Task Usage Report). + `words_count_from` and `words_count_to` are used only with `time` type, `statuses` only + with `cost` type. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.reports.post """ - return self.generate_group_report( - group_id=group_id, + project_id = project_id or self.get_project_id() + + return self.generate_report( + projectId=project_id, request_data={ - "name": "group-task-usage", + "name": "task-usage", "schema": { - "projectIds": project_ids, "format": format, "type": type, "dateFrom": date_from, "dateTo": date_to, "groupBy": group_by, - "typeTask": type_task, + "typeTasks": type_tasks, "languageId": language_id, "creatorId": creator_id, "assigneeId": assignee_id, + "wordsCountFrom": words_count_from, + "wordsCountTo": words_count_to, + "statuses": statuses, }, }, ) - def generate_group_qa_check_issues_report( - self, - group_id: int, - project_ids: Optional[Iterable[int]] = None, - format: Optional[Format] = None, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None, + # Project report settings templates are not available in Crowdin Enterprise API anymore + @deprecated("Use `list_organization_report_settings_templates` instead") + def list_report_settings_template( + self, + projectId: Optional[int] = None, + offset: Optional[int] = None, + limit: Optional[int] = None ): """ - Generate Group Report (Group Qa Check Issues Report). + List Report Settings Templates. + + Deprecated: use `list_organization_report_settings_templates` instead. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.getMany """ - - return self.generate_group_report( - group_id=group_id, - request_data={ - "name": "group-qa-check-issues", - "schema": { - "projectIds": project_ids, - "format": format, - "dateFrom": date_from, - "dateTo": date_to, - }, - }, + return super().list_report_settings_template( + projectId=projectId, offset=offset, limit=limit ) - def generate_group_translation_activity_report( - self, - group_id: int, - unit: Optional[Unit] = None, - project_ids: Optional[Iterable[int]] = None, - format: Optional[Format] = None, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None, + @deprecated("Use `add_organization_report_settings_template` instead") + def add_report_settings_template( + self, + name: str, + currency: Currency, + unit: Unit, + config: Union[PostEditingConfig, HourlyConfig, Config], + isPublic: Optional[bool] = None, + projectId: Optional[int] = None, + isGlobal: Optional[bool] = None, ): """ - Generate Group Report (Group translation consumption). + Add Report Settings Templates. - Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.groups.reports.post - """ + Deprecated: use `add_organization_report_settings_template` instead. - return self.generate_group_report( - group_id=group_id, - request_data={ - "name": "group-translation-activity", - "schema": { - "unit": unit, - "projectIds": project_ids, - "format": format, - "dateFrom": date_from, - "dateTo": date_to, - }, - }, + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.post + """ + return super().add_report_settings_template( + name=name, + currency=currency, + unit=unit, + config=config, + isPublic=isPublic, + projectId=projectId, + isGlobal=isGlobal, ) - @staticmethod - def get_organization_reports_path(report_id: Optional[str] = None): - if report_id is not None: - return f"reports/{report_id}" - - return "reports" - - def generate_organization_translation_costs_post_editing_general_report( + @deprecated("Use `get_organization_report_settings_template` instead") + def get_report_settings_template( self, - base_rates: BaseRates, - individual_rates: Iterable[TranslationCostsPeIndividualRate], - net_rate_schemes: TranslationCostsPeNetRateSchemes, - project_ids: Optional[Iterable[int]] = None, - unit: Optional[Unit] = None, - currency: Optional[Currency] = None, - format: Optional[Format] = None, - group_by: Optional[GroupBy] = None, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None, - user_ids: Optional[Iterable[int]] = None + reportSettingsTemplateId: int, + projectId: Optional[int] = None, ): """ - Generate Organization Report (General). + Get Report Settings Templates. + + Deprecated: use `get_organization_report_settings_template` instead. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.reports.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.get """ + return super().get_report_settings_template( + reportSettingsTemplateId=reportSettingsTemplateId, projectId=projectId + ) - return self.requester.request( - method="post", - path=self.get_organization_reports_path(), - request_data={ - "name": "group-translation-costs-pe", - "schema": { - "projectIds": project_ids, - "unit": unit, - "currency": currency, - "format": format, - "baseRates": base_rates, - "individualRates": individual_rates, - "netRateSchemes": net_rate_schemes, - "groupBy": group_by, - "dateFrom": date_from, - "dateTo": date_to, - "userIds": user_ids - }, - }, + @deprecated("Use `edit_organization_report_settings_template` instead") + def edit_report_settings_template( + self, + reportSettingsTemplateId: int, + data: Iterable[ReportSettingsTemplatesPatchRequest], + projectId: Optional[int] = None, + ): + """ + Edit Report Settings Templates. + + Deprecated: use `edit_organization_report_settings_template` instead. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.patch + """ + return super().edit_report_settings_template( + reportSettingsTemplateId=reportSettingsTemplateId, data=data, projectId=projectId ) + @deprecated("Use `delete_organization_report_settings_template` instead") + def delete_report_settings_template( + self, + reportSettingsTemplateId: int, + projectId: Optional[int] = None, + ): + """ + Delete Report Settings Templates. -class UserReportSettingsTemplatesResource(BaseReportSettingsTemplatesResource): - """ - Resource for User Report Settings Templates API. + Deprecated: use `delete_organization_report_settings_template` instead. - Supporting the endpoints for managing user report settings templates. - Link to documentation: - https://developer.crowdin.com/api/v2/#tag/User-Report-Settings-Templates - """ + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.delete + """ + return super().delete_report_settings_template( + reportSettingsTemplateId=reportSettingsTemplateId, projectId=projectId + ) - def get_user_report_settings_templates_path( - self, - userId: int, - reportSettingsTemplateId: Optional[int] = None + @staticmethod + def get_organization_report_settings_templates_path( + reportSettingsTemplateId: Optional[int] = None, ): if reportSettingsTemplateId is not None: - return f"users/{userId}/reports/settings-templates/{reportSettingsTemplateId}" + return f"reports/settings-templates/{reportSettingsTemplateId}" - return f"users/{userId}/reports/settings-templates" + return "reports/settings-templates" - def list_user_report_settings_template( + def list_organization_report_settings_templates( self, - userId: int, + project_id: Optional[int] = None, + group_id: Optional[int] = None, offset: Optional[int] = None, - limit: Optional[int] = None + limit: Optional[int] = None, ): """ - List User Report Settings Templates. + List Organization Report Settings Templates. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.settings-templates.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.getMany """ + params = {"projectId": project_id, "groupId": group_id} + params.update(self.get_page_params(offset=offset, limit=limit)) + return self._get_entire_data( method="get", - path=self.get_user_report_settings_templates_path(userId=userId), - params=self.get_page_params(offset=offset, limit=limit), + path=self.get_organization_report_settings_templates_path(), + params=params, ) - def add_user_report_settings_template( + def add_organization_report_settings_template( self, - userId: int, name: str, currency: Currency, unit: Unit, - config: Config, + config: Union[PostEditingConfig, HourlyConfig], + project_id: Optional[int] = None, + group_id: Optional[int] = None, + is_public: Optional[bool] = None, ): """ - Add User Report Settings Template. + Add Organization Report Settings Template. + + `project_id` and `group_id` can't be used together. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.settings-templates.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.post """ return self.requester.request( method="post", - path=self.get_user_report_settings_templates_path( - userId=userId, - ), + path=self.get_organization_report_settings_templates_path(), request_data={ + "projectId": project_id, + "groupId": group_id, "name": name, "currency": currency, "unit": unit, - "mode": "simple", "config": config, - } + "isPublic": is_public, + }, ) - def get_user_report_settings_template( - self, - userId: int, - reportSettingsTemplateId: int, - ): + def get_organization_report_settings_template(self, reportSettingsTemplateId: int): """ - Get User Report Settings Template. + Get Organization Report Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.settings-templates.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.get """ return self.requester.request( method="get", - path=self.get_user_report_settings_templates_path( - userId=userId, + path=self.get_organization_report_settings_templates_path( reportSettingsTemplateId=reportSettingsTemplateId ), ) - def edit_user_report_settings_template( + def edit_organization_report_settings_template( self, - userId: int, reportSettingsTemplateId: int, data: Iterable[ReportSettingsTemplatesPatchRequest], ): """ - Edit User Report Settings Template. + Edit Organization Report Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.settings-templates.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.patch """ return self.requester.request( method="patch", - path=self.get_user_report_settings_templates_path( - userId=userId, + path=self.get_organization_report_settings_templates_path( reportSettingsTemplateId=reportSettingsTemplateId ), request_data=data, ) - def delete_user_report_settings_template( - self, - userId: int, - reportSettingsTemplateId: int, - ): + def delete_organization_report_settings_template(self, reportSettingsTemplateId: int): """ - Delete User Report Settings Template. + Delete Organization Report Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.reports.settings-templates.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.settings-templates.delete """ return self.requester.request( method="delete", - path=self.get_user_report_settings_templates_path( - userId=userId, + path=self.get_organization_report_settings_templates_path( reportSettingsTemplateId=reportSettingsTemplateId ), ) + + @staticmethod + def get_group_reports_path(group_id: int, report_id: Optional[str] = None): + if report_id is not None: + return f"groups/{group_id}/reports/{report_id}" + + return f"groups/{group_id}/reports" + + def generate_group_report(self, group_id: int, request_data: Dict): + """ + Generate Group Report. + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.requester.request( + method="post", + path=self.get_group_reports_path(group_id=group_id), + request_data=request_data, + ) + + def check_group_report_generation_status(self, group_id: int, report_id: str): + """ + Check Group Report Generation Status. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.get + """ + + return self.requester.request( + method="get", + path=self.get_group_reports_path(group_id=group_id, report_id=report_id), + ) + + def download_group_report(self, group_id: int, report_id: str): + """ + Download Group Report. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.download.download + """ + + return self.requester.request( + method="get", + path=f"{self.get_group_reports_path(group_id=group_id, report_id=report_id)}/download", + ) + + @staticmethod + def get_organization_reports_path(report_id: Optional[str] = None): + if report_id is not None: + return f"reports/{report_id}" + + return "reports" + + def generate_organization_report(self, request_data: Dict): + """ + Generate Organization Report. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.requester.request( + method="post", + path=self.get_organization_reports_path(), + request_data=request_data, + ) + + def check_organization_report_generation_status(self, report_id: str): + """ + Check Organization Report Generation Status. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.get + """ + + return self.requester.request( + method="get", + path=self.get_organization_reports_path(report_id=report_id), + ) + + def download_organization_report(self, report_id: str): + """ + Download Organization Report. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.download.download + """ + + return self.requester.request( + method="get", + path=f"{self.get_organization_reports_path(report_id=report_id)}/download", + ) + + def generate_group_translation_costs_post_editing_general_report( + self, + group_id: int, + base_rates: BaseRates, + individual_rates: Iterable[TranslationCostsPeIndividualRate], + net_rate_schemes: TranslationCostsPeNetRateSchemes, + project_ids: Optional[Iterable[int]] = None, + unit: Optional[Unit] = None, + currency: Optional[Currency] = None, + format: Optional[Format] = None, + group_by: Optional[GroupBy] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + user_ids: Optional[Iterable[int]] = None, + use_category_based_proofread_rates: Optional[bool] = None, + use_tm_edit_distance: Optional[bool] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Group Report (Translation Costs Post-Editing General). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-translation-costs-pe", + "schema": { + "baseRates": base_rates, + "individualRates": individual_rates, + "netRateSchemes": net_rate_schemes, + "projectIds": project_ids, + "unit": unit, + "currency": currency, + "format": format, + "groupBy": group_by, + "dateFrom": date_from, + "dateTo": date_to, + "userIds": user_ids, + "useCategoryBasedProofreadRates": use_category_based_proofread_rates, + "useTmEditDistance": use_tm_edit_distance, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_group_translation_costs_post_editing_by_task_report( + self, + group_id: int, + base_rates: BaseRates, + individual_rates: Iterable[TranslationCostsPeIndividualRate], + net_rate_schemes: TranslationCostsPeNetRateSchemes, + unit: Optional[Unit] = None, + currency: Optional[Currency] = None, + format: Optional[Format] = None, + task_ids: Optional[Iterable[int]] = None, + use_category_based_proofread_rates: Optional[bool] = None, + use_tm_edit_distance: Optional[bool] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Group Report (Translation Costs Post-Editing By Task). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-translation-costs-pe", + "schema": { + "baseRates": base_rates, + "individualRates": individual_rates, + "netRateSchemes": net_rate_schemes, + "unit": unit, + "currency": currency, + "format": format, + "taskIds": task_ids, + "useCategoryBasedProofreadRates": use_category_based_proofread_rates, + "useTmEditDistance": use_tm_edit_distance, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_group_top_members_report( + self, + group_id: int, + project_ids: Optional[Iterable[int]] = None, + unit: Optional[Unit] = None, + language_id: Optional[str] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + user_ids: Optional[Iterable[int]] = None, + ): + """ + Generate Group Report (Top Members). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-top-members", + "schema": { + "projectIds": project_ids, + "unit": unit, + "languageId": language_id, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "userIds": user_ids, + }, + }, + ) + + def generate_group_task_usage_report( + self, + group_id: int, + format: Optional[Format] = None, + type: Optional[TaskUsageReportType] = None, + project_ids: Optional[Iterable[int]] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + group_by: Optional[GroupBy] = None, + type_task: Optional[TaskType] = None, + language_id: Optional[str] = None, + creator_id: Optional[int] = None, + assignee_id: Optional[int] = None, + words_count_from: Optional[int] = None, + words_count_to: Optional[int] = None, + statuses: Optional[Iterable[TaskUsageStatus]] = None, + ): + """ + Generate Group Report (Task Usage). + + `words_count_from` and `words_count_to` are used only with `time` type, `statuses` only + with `cost` type. `type_task` is sent as `typeTasks`. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-task-usage", + "schema": { + "format": format, + "type": type, + "projectIds": project_ids, + "dateFrom": date_from, + "dateTo": date_to, + "groupBy": group_by, + "typeTasks": type_task, + "languageId": language_id, + "creatorId": creator_id, + "assigneeId": assignee_id, + "wordsCountFrom": words_count_from, + "wordsCountTo": words_count_to, + "statuses": statuses, + }, + }, + ) + + def generate_group_qa_check_issues_report( + self, + group_id: int, + project_ids: Optional[Iterable[int]] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + ): + """ + Generate Group Report (Qa Check Issues). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-qa-check-issues", + "schema": { + "projectIds": project_ids, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + }, + }, + ) + + def generate_group_translation_activity_report( + self, + group_id: int, + unit: Optional[Unit] = None, + project_ids: Optional[Iterable[int]] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + user_ids: Optional[Iterable[int]] = None, + ): + """ + Generate Group Report (Translation Activity). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-translation-activity", + "schema": { + "unit": unit, + "projectIds": project_ids, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "userIds": user_ids, + }, + }, + ) + + def generate_group_source_content_updates_report( + self, + group_id: int, + unit: Optional[Unit] = None, + format: Optional[Format] = None, + project_ids: Optional[Iterable[int]] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + ): + """ + Generate Group Report (Source Content Updates). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-source-content-updates", + "schema": { + "unit": unit, + "format": format, + "projectIds": project_ids, + "dateFrom": date_from, + "dateTo": date_to, + }, + }, + ) + + def generate_group_time_spent_report( + self, + group_id: int, + format: Optional[Format] = None, + group_by: Optional[GroupBy] = None, + base_rates: Optional[HourlyBaseRates] = None, + individual_rates: Optional[Iterable[HourlyIndividualRate]] = None, + language_id: Optional[str] = None, + user_ids: Optional[Iterable[int]] = None, + type_tasks: Optional[TaskType] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + project_ids: Optional[Iterable[int]] = None, + task_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Group Report (Time Spent). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-time-spent", + "schema": { + "format": format, + "groupBy": group_by, + "baseRates": base_rates, + "individualRates": individual_rates, + "languageId": language_id, + "userIds": user_ids, + "typeTasks": type_tasks, + "dateFrom": date_from, + "dateTo": date_to, + "projectIds": project_ids, + "taskIds": task_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_group_pre_translate_accuracy_general_report( + self, + group_id: int, + unit: Optional[Unit] = None, + language_id: Optional[str] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + match_score_categories: Optional[Iterable[str]] = None, + project_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Group Report (Pre-Translate Accuracy General). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-pre-translate-accuracy", + "schema": { + "unit": unit, + "languageId": language_id, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "matchScoreCategories": match_score_categories, + "projectIds": project_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_group_pre_translate_accuracy_by_task_report( + self, + group_id: int, + unit: Optional[Unit] = None, + format: Optional[Format] = None, + match_score_categories: Optional[Iterable[str]] = None, + task_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Group Report (Pre-Translate Accuracy By Task). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-pre-translate-accuracy", + "schema": { + "unit": unit, + "format": format, + "matchScoreCategories": match_score_categories, + "taskIds": task_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_group_translator_accuracy_report( + self, + group_id: int, + unit: Optional[Unit] = None, + format: Optional[Format] = None, + language_id: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + match_score_categories: Optional[Iterable[str]] = None, + user_ids: Optional[Iterable[int]] = None, + project_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Group Report (Translator Accuracy). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-translator-accuracy", + "schema": { + "unit": unit, + "format": format, + "languageId": language_id, + "dateFrom": date_from, + "dateTo": date_to, + "matchScoreCategories": match_score_categories, + "userIds": user_ids, + "projectIds": project_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_group_saving_activity_report( + self, + group_id: int, + unit: Optional[Unit] = None, + project_ids: Optional[Iterable[int]] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + language_id: Optional[str] = None, + mode: Optional[SavingActivityMode] = None, + ): + """ + Generate Group Report (Saving Activity). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.groups.reports.post + """ + + return self.generate_group_report( + group_id=group_id, + request_data={ + "name": "group-saving-activity", + "schema": { + "unit": unit, + "projectIds": project_ids, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "languageId": language_id, + "mode": mode, + }, + }, + ) + + def generate_organization_translation_costs_post_editing_general_report( + self, + base_rates: BaseRates, + individual_rates: Iterable[TranslationCostsPeIndividualRate], + net_rate_schemes: TranslationCostsPeNetRateSchemes, + project_ids: Optional[Iterable[int]] = None, + unit: Optional[Unit] = None, + currency: Optional[Currency] = None, + format: Optional[Format] = None, + group_by: Optional[GroupBy] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + user_ids: Optional[Iterable[int]] = None, + use_category_based_proofread_rates: Optional[bool] = None, + use_tm_edit_distance: Optional[bool] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Organization Report (Translation Costs Post-Editing General). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-translation-costs-pe", + "schema": { + "baseRates": base_rates, + "individualRates": individual_rates, + "netRateSchemes": net_rate_schemes, + "projectIds": project_ids, + "unit": unit, + "currency": currency, + "format": format, + "groupBy": group_by, + "dateFrom": date_from, + "dateTo": date_to, + "userIds": user_ids, + "useCategoryBasedProofreadRates": use_category_based_proofread_rates, + "useTmEditDistance": use_tm_edit_distance, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_organization_translation_costs_post_editing_by_task_report( + self, + base_rates: BaseRates, + individual_rates: Iterable[TranslationCostsPeIndividualRate], + net_rate_schemes: TranslationCostsPeNetRateSchemes, + unit: Optional[Unit] = None, + currency: Optional[Currency] = None, + format: Optional[Format] = None, + task_ids: Optional[Iterable[int]] = None, + use_category_based_proofread_rates: Optional[bool] = None, + use_tm_edit_distance: Optional[bool] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Organization Report (Translation Costs Post-Editing By Task). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-translation-costs-pe", + "schema": { + "baseRates": base_rates, + "individualRates": individual_rates, + "netRateSchemes": net_rate_schemes, + "unit": unit, + "currency": currency, + "format": format, + "taskIds": task_ids, + "useCategoryBasedProofreadRates": use_category_based_proofread_rates, + "useTmEditDistance": use_tm_edit_distance, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_organization_top_members_report( + self, + project_ids: Optional[Iterable[int]] = None, + unit: Optional[Unit] = None, + language_id: Optional[str] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + user_ids: Optional[Iterable[int]] = None, + ): + """ + Generate Organization Report (Top Members). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-top-members", + "schema": { + "projectIds": project_ids, + "unit": unit, + "languageId": language_id, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "userIds": user_ids, + }, + }, + ) + + def generate_organization_task_usage_report( + self, + format: Optional[Format] = None, + type: Optional[TaskUsageReportType] = None, + project_ids: Optional[Iterable[int]] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + group_by: Optional[GroupBy] = None, + type_tasks: Optional[TaskType] = None, + language_id: Optional[str] = None, + creator_id: Optional[int] = None, + assignee_id: Optional[int] = None, + words_count_from: Optional[int] = None, + words_count_to: Optional[int] = None, + statuses: Optional[Iterable[TaskUsageStatus]] = None, + ): + """ + Generate Organization Report (Task Usage). + + `words_count_from` and `words_count_to` are used only with `time` type, `statuses` only + with `cost` type. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-task-usage", + "schema": { + "format": format, + "type": type, + "projectIds": project_ids, + "dateFrom": date_from, + "dateTo": date_to, + "groupBy": group_by, + "typeTasks": type_tasks, + "languageId": language_id, + "creatorId": creator_id, + "assigneeId": assignee_id, + "wordsCountFrom": words_count_from, + "wordsCountTo": words_count_to, + "statuses": statuses, + }, + }, + ) + + def generate_organization_qa_check_issues_report( + self, + project_ids: Optional[Iterable[int]] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + ): + """ + Generate Organization Report (Qa Check Issues). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-qa-check-issues", + "schema": { + "projectIds": project_ids, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + }, + }, + ) + + def generate_organization_translation_activity_report( + self, + unit: Optional[Unit] = None, + project_ids: Optional[Iterable[int]] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + user_ids: Optional[Iterable[int]] = None, + ): + """ + Generate Organization Report (Translation Activity). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-translation-activity", + "schema": { + "unit": unit, + "projectIds": project_ids, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "userIds": user_ids, + }, + }, + ) + + def generate_organization_source_content_updates_report( + self, + unit: Optional[Unit] = None, + format: Optional[Format] = None, + project_ids: Optional[Iterable[int]] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + ): + """ + Generate Organization Report (Source Content Updates). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-source-content-updates", + "schema": { + "unit": unit, + "format": format, + "projectIds": project_ids, + "dateFrom": date_from, + "dateTo": date_to, + }, + }, + ) + + def generate_organization_time_spent_report( + self, + format: Optional[Format] = None, + group_by: Optional[GroupBy] = None, + base_rates: Optional[HourlyBaseRates] = None, + individual_rates: Optional[Iterable[HourlyIndividualRate]] = None, + language_id: Optional[str] = None, + user_ids: Optional[Iterable[int]] = None, + type_tasks: Optional[TaskType] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + project_ids: Optional[Iterable[int]] = None, + task_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Organization Report (Time Spent). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-time-spent", + "schema": { + "format": format, + "groupBy": group_by, + "baseRates": base_rates, + "individualRates": individual_rates, + "languageId": language_id, + "userIds": user_ids, + "typeTasks": type_tasks, + "dateFrom": date_from, + "dateTo": date_to, + "projectIds": project_ids, + "taskIds": task_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_organization_pre_translate_accuracy_general_report( + self, + unit: Optional[Unit] = None, + language_id: Optional[str] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + match_score_categories: Optional[Iterable[str]] = None, + project_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Organization Report (Pre-Translate Accuracy General). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-pre-translate-accuracy", + "schema": { + "unit": unit, + "languageId": language_id, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "matchScoreCategories": match_score_categories, + "projectIds": project_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_organization_pre_translate_accuracy_by_task_report( + self, + unit: Optional[Unit] = None, + format: Optional[Format] = None, + match_score_categories: Optional[Iterable[str]] = None, + task_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Organization Report (Pre-Translate Accuracy By Task). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-pre-translate-accuracy", + "schema": { + "unit": unit, + "format": format, + "matchScoreCategories": match_score_categories, + "taskIds": task_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_organization_translator_accuracy_report( + self, + unit: Optional[Unit] = None, + format: Optional[Format] = None, + language_id: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + match_score_categories: Optional[Iterable[str]] = None, + user_ids: Optional[Iterable[int]] = None, + project_ids: Optional[Iterable[int]] = None, + skip_archiving: Optional[bool] = None, + ): + """ + Generate Organization Report (Translator Accuracy). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-translator-accuracy", + "schema": { + "unit": unit, + "format": format, + "languageId": language_id, + "dateFrom": date_from, + "dateTo": date_to, + "matchScoreCategories": match_score_categories, + "userIds": user_ids, + "projectIds": project_ids, + "skipArchiving": skip_archiving, + }, + }, + ) + + def generate_organization_saving_activity_report( + self, + unit: Optional[Unit] = None, + project_ids: Optional[Iterable[int]] = None, + format: Optional[Format] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + language_id: Optional[str] = None, + mode: Optional[SavingActivityMode] = None, + ): + """ + Generate Organization Report (Saving Activity). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.reports.post + """ + + return self.generate_organization_report( + request_data={ + "name": "group-saving-activity", + "schema": { + "unit": unit, + "projectIds": project_ids, + "format": format, + "dateFrom": date_from, + "dateTo": date_to, + "languageId": language_id, + "mode": mode, + }, + }, + ) diff --git a/crowdin_api/api_resources/reports/tests/test_reports_resources.py b/crowdin_api/api_resources/reports/tests/test_reports_resources.py index f36461b1..a2e11991 100644 --- a/crowdin_api/api_resources/reports/tests/test_reports_resources.py +++ b/crowdin_api/api_resources/reports/tests/test_reports_resources.py @@ -16,7 +16,10 @@ Unit, ReportSettingsTemplatesPatchPath, MatchType, - ReportLabelIncludeType + ReportLabelIncludeType, + TaskType, + TaskUsageReportType, + TaskUsageStatus, ) from crowdin_api.api_resources.reports.requests.cost_estimation_post_editing import ( IndividualRate, @@ -85,6 +88,10 @@ def test_get_report_archive_export_path(self, incoming_data, path, base_absolut_ "scopeId": None, "limit": 25, "offset": 0, + "taskId": None, + "name": None, + "dateFrom": None, + "dateTo": None, }, ), ( @@ -99,6 +106,10 @@ def test_get_report_archive_export_path(self, incoming_data, path, base_absolut_ "scopeId": 1, "limit": 10, "offset": 2, + "taskId": None, + "name": None, + "dateFrom": None, + "dateTo": None, }, ), ), @@ -554,6 +565,7 @@ def test_generate_fuzzy_translation_cost_report( "format": Format.XLSX, "dateFrom": None, "dateTo": None, + "userIds": None, }, ), ( @@ -570,6 +582,7 @@ def test_generate_fuzzy_translation_cost_report( "format": Format.JSON, "dateFrom": datetime(year=1988, month=1, day=4), "dateTo": datetime(year=2015, month=10, day=13), + "userIds": None, }, ), ), @@ -598,6 +611,13 @@ def test_generate_top_members_report( "userId": None, "dateFrom": None, "dateTo": None, + "columns": None, + "tmIds": None, + "mtIds": None, + "aiPromptIds": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, }, ), ( @@ -616,6 +636,13 @@ def test_generate_top_members_report( "userId": 1, "dateFrom": datetime(year=1988, month=1, day=4), "dateTo": datetime(year=2015, month=10, day=13), + "columns": None, + "tmIds": None, + "mtIds": None, + "aiPromptIds": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, }, ), ), @@ -773,7 +800,8 @@ def test_generate_editor_issues_report( { "format": Format.XLSX, "dateFrom": None, - "dateTo": None + "dateTo": None, + "languageId": None, }, ), ( @@ -786,6 +814,7 @@ def test_generate_editor_issues_report( "format": Format.XLSX, "dateFrom": datetime(2023, 4, 1), "dateTo": datetime(2023, 4, 30), + "languageId": None, }, ), ] @@ -819,6 +848,11 @@ def test_generate_qa_check_issues_report( "format": Format.XLSX, "dateFrom": None, "dateTo": None, + "mode": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, + "userIds": None, }, ), ( @@ -835,6 +869,11 @@ def test_generate_qa_check_issues_report( "format": Format.XLSX, "dateFrom": datetime(2023, 2, 1), "dateTo": datetime(2023, 2, 28), + "mode": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, + "userIds": None, }, ), ] @@ -875,6 +914,13 @@ def test_generate_saving_activity_report( "languageId": "uk", "dateFrom": datetime(year=1988, month=1, day=4), "dateTo": datetime(year=2015, month=10, day=13), + "matchScoreCategories": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, + "labelIds": None, + "labelIncludeType": None, + "skipArchiving": None, }, ), ( @@ -892,6 +938,13 @@ def test_generate_saving_activity_report( "languageId": "uk", "dateFrom": datetime(year=1988, month=1, day=4), "dateTo": datetime(year=2015, month=10, day=13), + "matchScoreCategories": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, + "labelIds": None, + "labelIncludeType": None, + "skipArchiving": None, }, ) ], @@ -931,7 +984,9 @@ def test_generate_pre_translate_accuracy_general_report( "unit": Unit.WORDS, "format": Format.XLSX, "postEditingCategories": ["0-20", "20-50"], - "taskId": 1 + "taskId": 1, + "matchScoreCategories": None, + "skipArchiving": None, } ), ( @@ -944,7 +999,9 @@ def test_generate_pre_translate_accuracy_general_report( "unit": Unit.WORDS, "format": Format.XLSX, "postEditingCategories": None, - "taskId": 1 + "taskId": 1, + "matchScoreCategories": None, + "skipArchiving": None, } ) ] @@ -1031,7 +1088,9 @@ def test_generate_pre_translate_accuracy_by_task_report( "dateFrom": datetime(year=1988, month=1, day=4), "dateTo": datetime(year=2015, month=10, day=13), "labelIds": [1], - "labelIncludeType": ReportLabelIncludeType.STRINGS_WITH_LABEL + "labelIncludeType": ReportLabelIncludeType.STRINGS_WITH_LABEL, + "skipArchiving": None, + "workflowStepId": None, } ) ] @@ -1102,7 +1161,9 @@ def test_generate_costs_estimation_post_editing_general_report( }, "calculateInternalMatches": True, "includePreTranslatedStrings": True, - "taskId": 1 + "taskId": 1, + "taskIds": None, + "skipArchiving": None, } ) ] @@ -1170,7 +1231,13 @@ def test_generate_costs_estimation_post_editing_by_task_report( "userIds": [1], "fileIds": [1], "directoryIds": [1], - "branchIds": [1] + "branchIds": [1], + "useCategoryBasedProofreadRates": None, + "useTmEditDistance": None, + "labelIds": None, + "labelIncludeType": None, + "skipArchiving": None, + "workflowStepId": None, } ) ] @@ -1224,7 +1291,11 @@ def test_generate_translation_costs_post_editing_general_report( "netRateSchemes": NetRateSchemes(tmMatch=[ Match(matchType=MatchType.OPTION_99_82, price=70) ]), - "taskId": 1 + "taskId": 1, + "taskIds": None, + "useCategoryBasedProofreadRates": None, + "useTmEditDistance": None, + "skipArchiving": None, } ) ] @@ -1341,6 +1412,11 @@ def test_get_report_archive_export_path( "scopeId": None, "limit": 25, "offset": 0, + "userId": None, + "taskId": None, + "name": None, + "dateFrom": None, + "dateTo": None, }, ), ( @@ -1355,6 +1431,11 @@ def test_get_report_archive_export_path( "scopeId": 1, "limit": 10, "offset": 2, + "userId": None, + "taskId": None, + "name": None, + "dateFrom": None, + "dateTo": None, }, ), ), @@ -1834,6 +1915,7 @@ def test_generate_fuzzy_translation_cost_report( "format": Format.XLSX, "dateFrom": None, "dateTo": None, + "userIds": None, }, ), ( @@ -1850,6 +1932,7 @@ def test_generate_fuzzy_translation_cost_report( "format": Format.JSON, "dateFrom": datetime(year=1988, month=1, day=4), "dateTo": datetime(year=2015, month=10, day=13), + "userIds": None, }, ), ), @@ -1878,6 +1961,13 @@ def test_generate_top_members_report( "userId": None, "dateFrom": None, "dateTo": None, + "columns": None, + "tmIds": None, + "mtIds": None, + "aiPromptIds": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, }, ), ( @@ -1896,6 +1986,13 @@ def test_generate_top_members_report( "userId": 1, "dateFrom": datetime(year=1988, month=1, day=4), "dateTo": datetime(year=2015, month=10, day=13), + "columns": None, + "tmIds": None, + "mtIds": None, + "aiPromptIds": None, + "fileIds": None, + "directoryIds": None, + "branchIds": None, }, ), ), @@ -1984,7 +2081,10 @@ def test_generate_contribution_raw_data_report( "groupBy": GroupBy.LANGUAGE, "dateFrom": None, "dateTo": None, - "userIds": [10, 11] + "userIds": [10, 11], + "useCategoryBasedProofreadRates": None, + "useTmEditDistance": None, + "skipArchiving": None, } ) ] @@ -2073,7 +2173,10 @@ def test_generate_group_translation_costs_post_editing_general_report( "groupBy": GroupBy.LANGUAGE, "dateFrom": None, "dateTo": None, - "userIds": [10, 11] + "userIds": [10, 11], + "useCategoryBasedProofreadRates": None, + "useTmEditDistance": None, + "skipArchiving": None, } ) ] @@ -2109,10 +2212,13 @@ def test_generate_organization_translation_costs_post_editing_general_report( "dateFrom": None, "dateTo": None, "groupBy": None, - "typeTask": None, + "typeTasks": None, "languageId": None, "creatorId": None, "assigneeId": None, + "wordsCountFrom": None, + "wordsCountTo": None, + "statuses": None, }, ), ( @@ -2135,10 +2241,13 @@ def test_generate_organization_translation_costs_post_editing_general_report( "dateFrom": datetime(2023, 1, 1), "dateTo": datetime(2023, 12, 31), "groupBy": GroupBy.USER, - "typeTask": 2, + "typeTasks": 2, "languageId": "uk", "creatorId": 10, "assigneeId": 20, + "wordsCountFrom": None, + "wordsCountTo": None, + "statuses": None, }, ), ] @@ -2214,6 +2323,7 @@ def test_generate_group_qa_check_issues_report( "format": None, "dateFrom": None, "dateTo": None, + "userIds": None, }, ), ( @@ -2230,6 +2340,7 @@ def test_generate_group_qa_check_issues_report( "format": Format.XLSX, "dateFrom": datetime(2024, 1, 1), "dateTo": datetime(2024, 1, 31), + "userIds": None, }, ), ] @@ -2371,7 +2482,6 @@ def test_list_report_settings_template( "name": "test_name", "currency": Currency.UAH, "unit": Unit.WORDS, - "mode": "simple", "config": { "regularRates": [ { @@ -2393,6 +2503,7 @@ def test_list_report_settings_template( ] }, "isPublic": None, + "isGlobal": None, }, ), ( @@ -2426,7 +2537,6 @@ def test_list_report_settings_template( "name": "test_name", "currency": Currency.UAH, "unit": Unit.WORDS, - "mode": "simple", "config": { "regularRates": [ { @@ -2448,6 +2558,7 @@ def test_list_report_settings_template( ] }, "isPublic": False, + "isGlobal": None, }, ), ), @@ -2637,7 +2748,6 @@ def test_list_user_report_settings_template( "name": "test_name", "currency": Currency.UAH, "unit": Unit.WORDS, - "mode": "simple", "config": { "regularRates": [ { @@ -2740,3 +2850,1166 @@ def test_delete_user_report_settings_template(self, m_request, base_absolut_url) reportSettingsTemplateId=1 ), ) + + +class TestReportsResourceNewReports: + resource_class = ReportsResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @pytest.mark.parametrize( + "name_method", + [ + "list_user_report_settings_template", + "add_user_report_settings_template", + "get_user_report_settings_template", + "edit_user_report_settings_template", + "delete_user_report_settings_template", + ], + ) + def test_user_report_settings_templates_available(self, name_method): + assert hasattr(ReportsResource, name_method) + assert hasattr(EnterpriseReportsResource, name_method) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_report_archives_filters(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + date_from = datetime(2024, 1, 1) + date_to = datetime(2024, 2, 1) + assert resource.list_report_archives( + userId=1, + taskId=2, + name="archive", + dateFrom=date_from, + dateTo=date_to, + ) == "response" + m_request.assert_called_once_with( + method="get", + path="users/1/reports/archives", + params={ + "scopeType": None, + "scopeId": None, + "taskId": 2, + "name": "archive", + "dateFrom": date_from, + "dateTo": date_to, + "limit": 25, + "offset": 0, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_contribution_raw_data_by_task_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.generate_contribution_raw_data_by_task_report( + mode=ContributionMode.TRANSLATIONS, + task_id=5, + project_id=1, + unit=Unit.WORDS, + columns=["userId", "languageId"], + tm_ids=[1], + mt_ids=[2], + ai_prompt_ids=[3], + date_from=datetime(2024, 1, 1), + date_to=datetime(2024, 2, 1), + ) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/reports", + request_data={ + "name": "contribution-raw-data", + "schema": { + "mode": ContributionMode.TRANSLATIONS, + "unit": Unit.WORDS, + "taskId": 5, + "columns": ["userId", "languageId"], + "tmIds": [1], + "mtIds": [2], + "aiPromptIds": [3], + "dateFrom": datetime(2024, 1, 1), + "dateTo": datetime(2024, 2, 1), + }, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_translator_accuracy_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.generate_translator_accuracy_report( + project_id=1, + unit=Unit.WORDS, + format=Format.JSON, + match_score_categories=["100-90"], + language_id="uk", + user_ids=[1], + date_from=datetime(2024, 1, 1), + date_to=datetime(2024, 2, 1), + file_ids=[2], + directory_ids=[3], + branch_ids=[4], + label_ids=[5], + label_include_type=ReportLabelIncludeType.STRINGS_WITH_LABEL, + skip_archiving=True, + ) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/reports", + request_data={ + "name": "translator-accuracy", + "schema": { + "unit": Unit.WORDS, + "format": Format.JSON, + "matchScoreCategories": ["100-90"], + "languageId": "uk", + "userIds": [1], + "dateFrom": datetime(2024, 1, 1), + "dateTo": datetime(2024, 2, 1), + "fileIds": [2], + "directoryIds": [3], + "branchIds": [4], + "labelIds": [5], + "labelIncludeType": ReportLabelIncludeType.STRINGS_WITH_LABEL, + "skipArchiving": True, + }, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_time_spent_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.generate_time_spent_report( + project_id=1, + format=Format.XLSX, + group_by=GroupBy.TASK, + base_rates={"hourly": 10.0}, + individual_rates=[{"languageIds": ["uk"], "userIds": [1], "hourly": 20.0}], + language_id="uk", + user_ids=[1], + type_tasks=TaskType.PROOFREAD, + date_from=datetime(2024, 1, 1), + date_to=datetime(2024, 2, 1), + task_ids=[7], + skip_archiving=False, + workflow_step_id=9, + ) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/reports", + request_data={ + "name": "time-spent", + "schema": { + "format": Format.XLSX, + "groupBy": GroupBy.TASK, + "baseRates": {"hourly": 10.0}, + "individualRates": [{"languageIds": ["uk"], "userIds": [1], "hourly": 20.0}], + "languageId": "uk", + "userIds": [1], + "typeTasks": TaskType.PROOFREAD, + "dateFrom": datetime(2024, 1, 1), + "dateTo": datetime(2024, 2, 1), + "taskIds": [7], + "workflowStepId": 9, + "skipArchiving": False, + }, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_translation_activity_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.generate_translation_activity_report( + project_id=1, + unit=Unit.WORDS, + language_id="uk", + format=Format.JSON, + date_from=datetime(2024, 1, 1), + date_to=datetime(2024, 2, 1), + user_ids=[1, 2], + ) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/reports", + request_data={ + "name": "translation-activity", + "schema": { + "unit": Unit.WORDS, + "languageId": "uk", + "format": Format.JSON, + "dateFrom": datetime(2024, 1, 1), + "dateTo": datetime(2024, 2, 1), + "userIds": [1, 2], + }, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_report_settings_template_hourly(self, m_request, base_absolut_url): + m_request.return_value = "response" + + config = { + "baseRates": {"hourly": 10.0}, + "individualRates": [{"languageIds": ["uk"], "userIds": [1], "hourly": 20.0}], + } + resource = self.get_resource(base_absolut_url) + assert resource.add_report_settings_template( + name="hourly", + currency=Currency.PLN, + unit=Unit.HOURS, + config=config, + projectId=1, + isGlobal=True, + ) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/reports/settings-templates", + request_data={ + "name": "hourly", + "currency": Currency.PLN, + "unit": Unit.HOURS, + "config": config, + "isPublic": None, + "isGlobal": True, + }, + ) + + +class TestEnterpriseReportsResourceNewReports: + resource_class = EnterpriseReportsResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_report_archives_filters(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_report_archives( + scopeType=ScopeType.GROUP, + scopeId=1, + userId=2, + taskId=3, + name="archive", + dateFrom="2024-01-01", + dateTo="2024-02-01", + ) == "response" + m_request.assert_called_once_with( + method="get", + path="reports/archives", + params={ + "scopeType": ScopeType.GROUP, + "scopeId": 1, + "userId": 2, + "taskId": 3, + "name": "archive", + "dateFrom": "2024-01-01", + "dateTo": "2024-02-01", + "limit": 25, + "offset": 0, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_task_usage_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.generate_task_usage_report( + project_id=1, + format=Format.CSV, + type=TaskUsageReportType.COST, + date_from=datetime(2024, 1, 1), + date_to=datetime(2024, 2, 1), + group_by=GroupBy.TYPE, + type_tasks=TaskType.TRANSLATE, + language_id="uk", + creator_id=1, + assignee_id=2, + words_count_from=10, + words_count_to=20, + statuses=[TaskUsageStatus.DONE], + ) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/reports", + request_data={ + "name": "task-usage", + "schema": { + "format": Format.CSV, + "type": TaskUsageReportType.COST, + "dateFrom": datetime(2024, 1, 1), + "dateTo": datetime(2024, 2, 1), + "groupBy": GroupBy.TYPE, + "typeTasks": TaskType.TRANSLATE, + "languageId": "uk", + "creatorId": 1, + "assigneeId": 2, + "wordsCountFrom": 10, + "wordsCountTo": 20, + "statuses": [TaskUsageStatus.DONE], + }, + }, + ) + + @pytest.mark.parametrize( + "incoming_data, path", + ( + ({"group_id": 1}, "groups/1/reports"), + ({"group_id": 1, "report_id": "hash"}, "groups/1/reports/hash"), + ), + ) + def test_get_group_reports_path(self, incoming_data, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_group_reports_path(**incoming_data) == path + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_group_report_generation_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.check_group_report_generation_status(group_id=1, report_id="hash") == "response" + m_request.assert_called_once_with(method="get", path="groups/1/reports/hash") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_download_group_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.download_group_report(group_id=1, report_id="hash") == "response" + m_request.assert_called_once_with(method="get", path="groups/1/reports/hash/download") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_organization_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + request_data = {"name": "group-top-members", "schema": {}} + assert resource.generate_organization_report(request_data=request_data) == "response" + m_request.assert_called_once_with(method="post", path="reports", request_data=request_data) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_organization_report_generation_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.check_organization_report_generation_status(report_id="hash") == "response" + m_request.assert_called_once_with(method="get", path="reports/hash") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_download_organization_report(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.download_organization_report(report_id="hash") == "response" + m_request.assert_called_once_with(method="get", path="reports/hash/download") + + @pytest.mark.parametrize( + "method_name, in_params, name, schema", + ( + ( + "generate_group_translation_costs_post_editing_general_report", + { + "base_rates": "v_baseRates", + "individual_rates": "v_individualRates", + "net_rate_schemes": "v_netRateSchemes", + "project_ids": "v_projectIds", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "group_by": "v_groupBy", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "user_ids": "v_userIds", + "use_category_based_proofread_rates": "v_useCategoryBasedProofreadRates", + "use_tm_edit_distance": "v_useTmEditDistance", + "skip_archiving": "v_skipArchiving", + }, + "group-translation-costs-pe", + { + "baseRates": "v_baseRates", + "individualRates": "v_individualRates", + "netRateSchemes": "v_netRateSchemes", + "projectIds": "v_projectIds", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "groupBy": "v_groupBy", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "userIds": "v_userIds", + "useCategoryBasedProofreadRates": "v_useCategoryBasedProofreadRates", + "useTmEditDistance": "v_useTmEditDistance", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_group_translation_costs_post_editing_by_task_report", + { + "base_rates": "v_baseRates", + "individual_rates": "v_individualRates", + "net_rate_schemes": "v_netRateSchemes", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "task_ids": "v_taskIds", + "use_category_based_proofread_rates": "v_useCategoryBasedProofreadRates", + "use_tm_edit_distance": "v_useTmEditDistance", + "skip_archiving": "v_skipArchiving", + }, + "group-translation-costs-pe", + { + "baseRates": "v_baseRates", + "individualRates": "v_individualRates", + "netRateSchemes": "v_netRateSchemes", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "taskIds": "v_taskIds", + "useCategoryBasedProofreadRates": "v_useCategoryBasedProofreadRates", + "useTmEditDistance": "v_useTmEditDistance", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_group_top_members_report", + { + "project_ids": "v_projectIds", + "unit": "v_unit", + "language_id": "v_languageId", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "user_ids": "v_userIds", + }, + "group-top-members", + { + "projectIds": "v_projectIds", + "unit": "v_unit", + "languageId": "v_languageId", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "userIds": "v_userIds", + }, + ), + ( + "generate_group_task_usage_report", + { + "format": "v_format", + "type": "v_type", + "project_ids": "v_projectIds", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "group_by": "v_groupBy", + "type_task": "v_typeTasks", + "language_id": "v_languageId", + "creator_id": "v_creatorId", + "assignee_id": "v_assigneeId", + "words_count_from": "v_wordsCountFrom", + "words_count_to": "v_wordsCountTo", + "statuses": "v_statuses", + }, + "group-task-usage", + { + "format": "v_format", + "type": "v_type", + "projectIds": "v_projectIds", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "groupBy": "v_groupBy", + "typeTasks": "v_typeTasks", + "languageId": "v_languageId", + "creatorId": "v_creatorId", + "assigneeId": "v_assigneeId", + "wordsCountFrom": "v_wordsCountFrom", + "wordsCountTo": "v_wordsCountTo", + "statuses": "v_statuses", + }, + ), + ( + "generate_group_qa_check_issues_report", + { + "project_ids": "v_projectIds", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + }, + "group-qa-check-issues", + { + "projectIds": "v_projectIds", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + }, + ), + ( + "generate_group_translation_activity_report", + { + "unit": "v_unit", + "project_ids": "v_projectIds", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "user_ids": "v_userIds", + }, + "group-translation-activity", + { + "unit": "v_unit", + "projectIds": "v_projectIds", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "userIds": "v_userIds", + }, + ), + ( + "generate_group_source_content_updates_report", + { + "unit": "v_unit", + "format": "v_format", + "project_ids": "v_projectIds", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + }, + "group-source-content-updates", + { + "unit": "v_unit", + "format": "v_format", + "projectIds": "v_projectIds", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + }, + ), + ( + "generate_group_time_spent_report", + { + "format": "v_format", + "group_by": "v_groupBy", + "base_rates": "v_baseRates", + "individual_rates": "v_individualRates", + "language_id": "v_languageId", + "user_ids": "v_userIds", + "type_tasks": "v_typeTasks", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "project_ids": "v_projectIds", + "task_ids": "v_taskIds", + "skip_archiving": "v_skipArchiving", + }, + "group-time-spent", + { + "format": "v_format", + "groupBy": "v_groupBy", + "baseRates": "v_baseRates", + "individualRates": "v_individualRates", + "languageId": "v_languageId", + "userIds": "v_userIds", + "typeTasks": "v_typeTasks", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "projectIds": "v_projectIds", + "taskIds": "v_taskIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_group_pre_translate_accuracy_general_report", + { + "unit": "v_unit", + "language_id": "v_languageId", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "match_score_categories": "v_matchScoreCategories", + "project_ids": "v_projectIds", + "skip_archiving": "v_skipArchiving", + }, + "group-pre-translate-accuracy", + { + "unit": "v_unit", + "languageId": "v_languageId", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "matchScoreCategories": "v_matchScoreCategories", + "projectIds": "v_projectIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_group_pre_translate_accuracy_by_task_report", + { + "unit": "v_unit", + "format": "v_format", + "match_score_categories": "v_matchScoreCategories", + "task_ids": "v_taskIds", + "skip_archiving": "v_skipArchiving", + }, + "group-pre-translate-accuracy", + { + "unit": "v_unit", + "format": "v_format", + "matchScoreCategories": "v_matchScoreCategories", + "taskIds": "v_taskIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_group_translator_accuracy_report", + { + "unit": "v_unit", + "format": "v_format", + "language_id": "v_languageId", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "match_score_categories": "v_matchScoreCategories", + "user_ids": "v_userIds", + "project_ids": "v_projectIds", + "skip_archiving": "v_skipArchiving", + }, + "group-translator-accuracy", + { + "unit": "v_unit", + "format": "v_format", + "languageId": "v_languageId", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "matchScoreCategories": "v_matchScoreCategories", + "userIds": "v_userIds", + "projectIds": "v_projectIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_group_saving_activity_report", + { + "unit": "v_unit", + "project_ids": "v_projectIds", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "language_id": "v_languageId", + "mode": "v_mode", + }, + "group-saving-activity", + { + "unit": "v_unit", + "projectIds": "v_projectIds", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "languageId": "v_languageId", + "mode": "v_mode", + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_group_reports( + self, m_request, method_name, in_params, name, schema, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert getattr(resource, method_name)(group_id=1, **in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="groups/1/reports", + request_data={"name": name, "schema": schema}, + ) + + @pytest.mark.parametrize( + "method_name, in_params, name, schema", + ( + ( + "generate_organization_translation_costs_post_editing_general_report", + { + "base_rates": "v_baseRates", + "individual_rates": "v_individualRates", + "net_rate_schemes": "v_netRateSchemes", + "project_ids": "v_projectIds", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "group_by": "v_groupBy", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "user_ids": "v_userIds", + "use_category_based_proofread_rates": "v_useCategoryBasedProofreadRates", + "use_tm_edit_distance": "v_useTmEditDistance", + "skip_archiving": "v_skipArchiving", + }, + "group-translation-costs-pe", + { + "baseRates": "v_baseRates", + "individualRates": "v_individualRates", + "netRateSchemes": "v_netRateSchemes", + "projectIds": "v_projectIds", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "groupBy": "v_groupBy", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "userIds": "v_userIds", + "useCategoryBasedProofreadRates": "v_useCategoryBasedProofreadRates", + "useTmEditDistance": "v_useTmEditDistance", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_organization_translation_costs_post_editing_by_task_report", + { + "base_rates": "v_baseRates", + "individual_rates": "v_individualRates", + "net_rate_schemes": "v_netRateSchemes", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "task_ids": "v_taskIds", + "use_category_based_proofread_rates": "v_useCategoryBasedProofreadRates", + "use_tm_edit_distance": "v_useTmEditDistance", + "skip_archiving": "v_skipArchiving", + }, + "group-translation-costs-pe", + { + "baseRates": "v_baseRates", + "individualRates": "v_individualRates", + "netRateSchemes": "v_netRateSchemes", + "unit": "v_unit", + "currency": "v_currency", + "format": "v_format", + "taskIds": "v_taskIds", + "useCategoryBasedProofreadRates": "v_useCategoryBasedProofreadRates", + "useTmEditDistance": "v_useTmEditDistance", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_organization_top_members_report", + { + "project_ids": "v_projectIds", + "unit": "v_unit", + "language_id": "v_languageId", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "user_ids": "v_userIds", + }, + "group-top-members", + { + "projectIds": "v_projectIds", + "unit": "v_unit", + "languageId": "v_languageId", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "userIds": "v_userIds", + }, + ), + ( + "generate_organization_task_usage_report", + { + "format": "v_format", + "type": "v_type", + "project_ids": "v_projectIds", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "group_by": "v_groupBy", + "type_tasks": "v_typeTasks", + "language_id": "v_languageId", + "creator_id": "v_creatorId", + "assignee_id": "v_assigneeId", + "words_count_from": "v_wordsCountFrom", + "words_count_to": "v_wordsCountTo", + "statuses": "v_statuses", + }, + "group-task-usage", + { + "format": "v_format", + "type": "v_type", + "projectIds": "v_projectIds", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "groupBy": "v_groupBy", + "typeTasks": "v_typeTasks", + "languageId": "v_languageId", + "creatorId": "v_creatorId", + "assigneeId": "v_assigneeId", + "wordsCountFrom": "v_wordsCountFrom", + "wordsCountTo": "v_wordsCountTo", + "statuses": "v_statuses", + }, + ), + ( + "generate_organization_qa_check_issues_report", + { + "project_ids": "v_projectIds", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + }, + "group-qa-check-issues", + { + "projectIds": "v_projectIds", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + }, + ), + ( + "generate_organization_translation_activity_report", + { + "unit": "v_unit", + "project_ids": "v_projectIds", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "user_ids": "v_userIds", + }, + "group-translation-activity", + { + "unit": "v_unit", + "projectIds": "v_projectIds", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "userIds": "v_userIds", + }, + ), + ( + "generate_organization_source_content_updates_report", + { + "unit": "v_unit", + "format": "v_format", + "project_ids": "v_projectIds", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + }, + "group-source-content-updates", + { + "unit": "v_unit", + "format": "v_format", + "projectIds": "v_projectIds", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + }, + ), + ( + "generate_organization_time_spent_report", + { + "format": "v_format", + "group_by": "v_groupBy", + "base_rates": "v_baseRates", + "individual_rates": "v_individualRates", + "language_id": "v_languageId", + "user_ids": "v_userIds", + "type_tasks": "v_typeTasks", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "project_ids": "v_projectIds", + "task_ids": "v_taskIds", + "skip_archiving": "v_skipArchiving", + }, + "group-time-spent", + { + "format": "v_format", + "groupBy": "v_groupBy", + "baseRates": "v_baseRates", + "individualRates": "v_individualRates", + "languageId": "v_languageId", + "userIds": "v_userIds", + "typeTasks": "v_typeTasks", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "projectIds": "v_projectIds", + "taskIds": "v_taskIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_organization_pre_translate_accuracy_general_report", + { + "unit": "v_unit", + "language_id": "v_languageId", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "match_score_categories": "v_matchScoreCategories", + "project_ids": "v_projectIds", + "skip_archiving": "v_skipArchiving", + }, + "group-pre-translate-accuracy", + { + "unit": "v_unit", + "languageId": "v_languageId", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "matchScoreCategories": "v_matchScoreCategories", + "projectIds": "v_projectIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_organization_pre_translate_accuracy_by_task_report", + { + "unit": "v_unit", + "format": "v_format", + "match_score_categories": "v_matchScoreCategories", + "task_ids": "v_taskIds", + "skip_archiving": "v_skipArchiving", + }, + "group-pre-translate-accuracy", + { + "unit": "v_unit", + "format": "v_format", + "matchScoreCategories": "v_matchScoreCategories", + "taskIds": "v_taskIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_organization_translator_accuracy_report", + { + "unit": "v_unit", + "format": "v_format", + "language_id": "v_languageId", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "match_score_categories": "v_matchScoreCategories", + "user_ids": "v_userIds", + "project_ids": "v_projectIds", + "skip_archiving": "v_skipArchiving", + }, + "group-translator-accuracy", + { + "unit": "v_unit", + "format": "v_format", + "languageId": "v_languageId", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "matchScoreCategories": "v_matchScoreCategories", + "userIds": "v_userIds", + "projectIds": "v_projectIds", + "skipArchiving": "v_skipArchiving", + }, + ), + ( + "generate_organization_saving_activity_report", + { + "unit": "v_unit", + "project_ids": "v_projectIds", + "format": "v_format", + "date_from": "v_dateFrom", + "date_to": "v_dateTo", + "language_id": "v_languageId", + "mode": "v_mode", + }, + "group-saving-activity", + { + "unit": "v_unit", + "projectIds": "v_projectIds", + "format": "v_format", + "dateFrom": "v_dateFrom", + "dateTo": "v_dateTo", + "languageId": "v_languageId", + "mode": "v_mode", + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_generate_organization_reports( + self, m_request, method_name, in_params, name, schema, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert getattr(resource, method_name)(**in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="reports", + request_data={"name": name, "schema": schema}, + ) + + @pytest.mark.parametrize( + "incoming_data, path", + ( + ({}, "reports/settings-templates"), + ({"reportSettingsTemplateId": 1}, "reports/settings-templates/1"), + ), + ) + def test_get_organization_report_settings_templates_path( + self, incoming_data, path, base_absolut_url + ): + resource = self.get_resource(base_absolut_url) + assert resource.get_organization_report_settings_templates_path(**incoming_data) == path + + @pytest.mark.parametrize( + "incoming_data, request_params", + ( + ({}, {"projectId": None, "groupId": None, "offset": 0, "limit": 25}), + ( + {"project_id": 1, "group_id": 2, "offset": 5, "limit": 10}, + {"projectId": 1, "groupId": 2, "offset": 5, "limit": 10}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_organization_report_settings_templates( + self, m_request, incoming_data, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_organization_report_settings_templates(**incoming_data) == "response" + m_request.assert_called_once_with( + method="get", + path="reports/settings-templates", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_organization_report_settings_template(self, m_request, base_absolut_url): + m_request.return_value = "response" + + config = { + "baseRates": {"fullTranslation": 0.1, "proofread": 0.05}, + "individualRates": [], + "netRateSchemes": {"tmMatch": [], "mtMatch": [], "suggestionMatch": []}, + } + resource = self.get_resource(base_absolut_url) + assert resource.add_organization_report_settings_template( + name="template", + currency=Currency.USD, + unit=Unit.WORDS, + config=config, + group_id=2, + is_public=True, + ) == "response" + m_request.assert_called_once_with( + method="post", + path="reports/settings-templates", + request_data={ + "projectId": None, + "groupId": 2, + "name": "template", + "currency": Currency.USD, + "unit": Unit.WORDS, + "config": config, + "isPublic": True, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_organization_report_settings_template(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_organization_report_settings_template(1) == "response" + m_request.assert_called_once_with(method="get", path="reports/settings-templates/1") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_organization_report_settings_template(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": ReportSettingsTemplatesPatchPath.IS_PUBLIC, + "value": True, + } + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_organization_report_settings_template(1, data) == "response" + m_request.assert_called_once_with( + method="patch", path="reports/settings-templates/1", request_data=data + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_organization_report_settings_template(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_organization_report_settings_template(1) == "response" + m_request.assert_called_once_with(method="delete", path="reports/settings-templates/1") + + @pytest.mark.parametrize( + "method_name, kwargs, expected", + ( + ( + "list_report_settings_template", + {"projectId": 1}, + { + "method": "get", + "path": "projects/1/reports/settings-templates", + "params": {"offset": 0, "limit": 25}, + }, + ), + ( + "add_report_settings_template", + { + "name": "n", + "currency": Currency.USD, + "unit": Unit.WORDS, + "config": {}, + "projectId": 1, + }, + { + "method": "post", + "path": "projects/1/reports/settings-templates", + "request_data": { + "name": "n", + "currency": Currency.USD, + "unit": Unit.WORDS, + "config": {}, + "isPublic": None, + "isGlobal": None, + }, + }, + ), + ( + "get_report_settings_template", + {"reportSettingsTemplateId": 2, "projectId": 1}, + {"method": "get", "path": "projects/1/reports/settings-templates/2"}, + ), + ( + "edit_report_settings_template", + {"reportSettingsTemplateId": 2, "data": [], "projectId": 1}, + { + "method": "patch", + "path": "projects/1/reports/settings-templates/2", + "request_data": [], + }, + ), + ( + "delete_report_settings_template", + {"reportSettingsTemplateId": 2, "projectId": 1}, + {"method": "delete", "path": "projects/1/reports/settings-templates/2"}, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_deprecated_project_report_settings_templates( + self, m_request, method_name, kwargs, expected, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert getattr(resource, method_name)(**kwargs) == "response" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + m_request.assert_called_once_with(**expected) diff --git a/crowdin_api/api_resources/reports/types.py b/crowdin_api/api_resources/reports/types.py index 9b9e1707..e0c100c4 100644 --- a/crowdin_api/api_resources/reports/types.py +++ b/crowdin_api/api_resources/reports/types.py @@ -62,3 +62,50 @@ class Match(TypedDict): class BaseRates(TypedDict): fullTranslation: float proofread: float + + +class HourlyBaseRates(TypedDict): + hourly: float + + +class HourlyIndividualRate(TypedDict): + languageIds: Iterable[str] + userIds: Iterable[int] + hourly: float + + +class PostEditingIndividualRate(TypedDict): + languageIds: Iterable[str] + userIds: Iterable[int] + fullTranslation: float + proofread: float + + +class PostEditingNetRateSchemes(TypedDict, total=False): + tmMatch: Iterable[Match] + mtMatch: Iterable[Match] + aiMatch: Iterable[Match] + suggestionMatch: Iterable[Match] + + +class PostEditingConfig(TypedDict, total=False): + """ + Report settings template config for post-editing templates. + """ + + baseRates: BaseRates + individualRates: Iterable[PostEditingIndividualRate] + netRateSchemes: PostEditingNetRateSchemes + calculateInternalMatches: bool + includePreTranslatedStrings: bool + useCategoryBasedProofreadRates: bool + useTmEditDistance: bool + + +class HourlyConfig(TypedDict): + """ + Report settings template config for hourly templates (unit `hours`). + """ + + baseRates: HourlyBaseRates + individualRates: Iterable[HourlyIndividualRate] diff --git a/crowdin_api/api_resources/screenshots/enums.py b/crowdin_api/api_resources/screenshots/enums.py index 27a3a227..7b03c317 100644 --- a/crowdin_api/api_resources/screenshots/enums.py +++ b/crowdin_api/api_resources/screenshots/enums.py @@ -3,6 +3,7 @@ class ScreenshotPatchPath(Enum): NAME = "/name" + LABEL_IDS = "/labelIds" class TagPatchPath(Enum): diff --git a/crowdin_api/api_resources/screenshots/resource.py b/crowdin_api/api_resources/screenshots/resource.py index 06853784..d89564e5 100644 --- a/crowdin_api/api_resources/screenshots/resource.py +++ b/crowdin_api/api_resources/screenshots/resource.py @@ -8,6 +8,7 @@ TagPatchRequest, ) from crowdin_api.sorting import Sorting +from crowdin_api.utils import convert_to_query_list class ScreenshotsResource(BaseResource): @@ -20,7 +21,7 @@ class ScreenshotsResource(BaseResource): Use API to manage screenshots and their tags. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Screenshots + https://support.crowdin.com/developer/api/v2/#tag/Screenshots """ # Screenshots @@ -41,12 +42,16 @@ def list_screenshots( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + search: Optional[str] = None, ): """ List Screenshots. + :param search: Search screenshots by name, tagged strings or file names that include + screenshots. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.getMany """ projectId = projectId or self.get_project_id() @@ -55,7 +60,13 @@ def list_screenshots( warnings.warn("`stringId` is deprecated, use `stringIds` instead", category=DeprecationWarning) stringIds = [stringId] - params = {"orderBy": orderBy, "stringIds": stringIds, "labelIds": labelIds, "excludeLabelIds": excludeLabelIds} + params = { + "search": search, + "orderBy": orderBy, + "stringIds": convert_to_query_list(stringIds), + "labelIds": convert_to_query_list(labelIds), + "excludeLabelIds": convert_to_query_list(excludeLabelIds), + } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) return self._get_entire_data( @@ -78,8 +89,11 @@ def add_screenshot( """ Add Screenshot. + :param fileId: File Identifier (file-based projects only). + :param directoryId: Directory Identifier (file-based projects only). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.post """ projectId = projectId or self.get_project_id() @@ -103,7 +117,7 @@ def get_screenshot(self, screenshotId: int, projectId: Optional[int] = None): Get Screenshot. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.get """ projectId = projectId or self.get_project_id() @@ -119,12 +133,16 @@ def update_screenshot( storageId: int, name: str, projectId: Optional[int] = None, + usePreviousTags: Optional[bool] = None, ): """ Update Screenshot. + :param usePreviousTags: Defines whether to keep the tags of the previous screenshot + version (the API default is `True`). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.put + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.put """ projectId = projectId or self.get_project_id() @@ -134,6 +152,7 @@ def update_screenshot( request_data={ "storageId": storageId, "name": name, + "usePreviousTags": usePreviousTags, }, path=self.get_screenshots_path(projectId=projectId, screenshotId=screenshotId), ) @@ -143,7 +162,7 @@ def delete_screenshot(self, screenshotId: int, projectId: Optional[int] = None): Delete Screenshot. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.delete """ projectId = projectId or self.get_project_id() @@ -163,7 +182,7 @@ def edit_screenshot( Edit Screenshot. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.patch """ projectId = projectId or self.get_project_id() @@ -193,7 +212,7 @@ def list_tags( List Tags. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.getMany """ projectId = projectId or self.get_project_id() @@ -214,7 +233,7 @@ def replace_tags( Replace Tags. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.putMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.putMany """ projectId = projectId or self.get_project_id() @@ -226,13 +245,24 @@ def replace_tags( ) def auto_tag( - self, screenshotId: int, autoTag: bool, projectId: Optional[int] = None + self, + screenshotId: int, + autoTag: bool, + projectId: Optional[int] = None, + fileId: Optional[int] = None, + branchId: Optional[int] = None, + directoryId: Optional[int] = None, ): """ Auto Tag. + :param fileId: Limit auto tagging to strings of this file (file-based projects only). + :param branchId: Limit auto tagging to strings of this branch. + :param directoryId: Limit auto tagging to strings of this directory + (file-based projects only). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.putMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.putMany """ projectId = projectId or self.get_project_id() @@ -240,7 +270,12 @@ def auto_tag( return self.requester.request( method="put", path=self.get_tags_path(projectId=projectId, screenshotId=screenshotId), - request_data={"autoTag": autoTag}, + request_data={ + "autoTag": autoTag, + "fileId": fileId, + "branchId": branchId, + "directoryId": directoryId, + }, ) def add_tag( @@ -253,7 +288,7 @@ def add_tag( Add Tag. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.post """ projectId = projectId or self.get_project_id() @@ -269,7 +304,7 @@ def clear_tags(self, screenshotId: int, projectId: Optional[int] = None): Clear Tags. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.deleteMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.deleteMany """ projectId = projectId or self.get_project_id() @@ -284,7 +319,7 @@ def get_tag(self, screenshotId: int, tagId: int, projectId: Optional[int] = None Get Tag. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.get """ projectId = projectId or self.get_project_id() @@ -301,7 +336,7 @@ def delete_tag( Delete Tag. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.delete """ projectId = projectId or self.get_project_id() @@ -322,7 +357,7 @@ def edit_tag( Edit Tag. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.screenshots.tags.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.screenshots.tags.patch """ projectId = projectId or self.get_project_id() diff --git a/crowdin_api/api_resources/screenshots/tests/test_screenshots_resources.py b/crowdin_api/api_resources/screenshots/tests/test_screenshots_resources.py index 962aae61..0bfe898a 100644 --- a/crowdin_api/api_resources/screenshots/tests/test_screenshots_resources.py +++ b/crowdin_api/api_resources/screenshots/tests/test_screenshots_resources.py @@ -39,6 +39,7 @@ def test_get_screenshots_path(self, in_params, path, base_absolut_url): ( {}, { + "search": None, "orderBy": None, "stringIds": None, "labelIds": None, @@ -55,16 +56,18 @@ def test_get_screenshots_path(self, in_params, path, base_absolut_url): "stringIds": [1, 2, 3], "labelIds": [4, 5, 6], "excludeLabelIds": [7, 8, 9], + "search": "home", "limit": 10, "offset": 0, }, { + "search": "home", "orderBy": Sorting( [SortingRule(ListScreenshotsOrderBy.ID, SortingOrder.DESC)] ), - "stringIds": [1, 2, 3], - "labelIds": [4, 5, 6], - "excludeLabelIds": [7, 8, 9], + "stringIds": "1,2,3", + "labelIds": "4,5,6", + "excludeLabelIds": "7,8,9", "limit": 10, "offset": 0, }, @@ -87,6 +90,26 @@ def test_list_screenshots( params=request_params, ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_screenshots_string_id(self, m_request, base_absolut_url): + m_request.return_value = "response" + resource = self.get_resource(base_absolut_url) + with pytest.warns(DeprecationWarning): + assert resource.list_screenshots(projectId=1, stringId=5) == "response" + m_request.assert_called_once_with( + method="get", + path=resource.get_screenshots_path(projectId=1), + params={ + "search": None, + "orderBy": None, + "stringIds": "5", + "labelIds": None, + "excludeLabelIds": None, + "offset": 0, + "limit": 25, + }, + ) + @pytest.mark.parametrize( "in_params, expected_params", [ @@ -150,7 +173,24 @@ def test_update_screenshot(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="put", path=resource.get_screenshots_path(projectId=1, screenshotId=2), - request_data={"storageId": 3, "name": "test"}, + request_data={"storageId": 3, "name": "test", "usePreviousTags": None}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_update_screenshot_use_previous_tags(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.update_screenshot( + projectId=1, screenshotId=2, storageId=3, name="test", usePreviousTags=False + ) + == "response" + ) + m_request.assert_called_once_with( + method="put", + path=resource.get_screenshots_path(projectId=1, screenshotId=2), + request_data={"storageId": 3, "name": "test", "usePreviousTags": False}, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -174,7 +214,12 @@ def test_edit_screenshot(self, m_request, base_absolut_url): "value": "test", "op": PatchOperation.REPLACE, "path": ScreenshotPatchPath.NAME, - } + }, + { + "value": [1, 2], + "op": PatchOperation.REPLACE, + "path": ScreenshotPatchPath.LABEL_IDS, + }, ] resource = self.get_resource(base_absolut_url) @@ -243,7 +288,29 @@ def test_auto_tag(self, m_request, base_absolut_url): projectId=1, screenshotId=2, ), - request_data={"autoTag": False}, + request_data={ + "autoTag": False, + "fileId": None, + "branchId": None, + "directoryId": None, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_auto_tag_with_scope(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.auto_tag( + projectId=1, screenshotId=2, autoTag=True, fileId=3, branchId=4, directoryId=5 + ) + == "response" + ) + m_request.assert_called_once_with( + method="put", + path=resource.get_tags_path(projectId=1, screenshotId=2), + request_data={"autoTag": True, "fileId": 3, "branchId": 4, "directoryId": 5}, ) @mock.patch("crowdin_api.requester.APIRequester.request") diff --git a/crowdin_api/api_resources/screenshots/types.py b/crowdin_api/api_resources/screenshots/types.py index dd08a1f0..3bdf2320 100644 --- a/crowdin_api/api_resources/screenshots/types.py +++ b/crowdin_api/api_resources/screenshots/types.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Union +from typing import Any, Iterable, Optional, Union from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.api_resources.screenshots.enums import ScreenshotPatchPath, TagPatchPath @@ -6,7 +6,7 @@ class ScreenshotPatchRequest(TypedDict): - value: str + value: Union[str, Iterable[int]] op: Union[PatchOperation, str] path: ScreenshotPatchPath diff --git a/crowdin_api/api_resources/security_logs/resource.py b/crowdin_api/api_resources/security_logs/resource.py index ffb65bc1..438d43f0 100644 --- a/crowdin_api/api_resources/security_logs/resource.py +++ b/crowdin_api/api_resources/security_logs/resource.py @@ -9,19 +9,19 @@ class SecurityLogsResource(BaseResource): """ Resource for Security Logs - Link to documentaion: - https://developer.crowdin.com/api/v2/#tag/Security-Logs + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Security-Logs Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#tag/Security-Logs + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Security-Logs """ def get_user_security_logs_path( self, userId: int, securityLogId: Optional[int] = None ): if securityLogId is not None: - return f"/users/{userId}/security-logs/{securityLogId}" - return f"/users/{userId}/security-logs" + return f"users/{userId}/security-logs/{securityLogId}" + return f"users/{userId}/security-logs" def list_user_security_logs( self, @@ -37,8 +37,8 @@ def list_user_security_logs( """ List User Security Logs - Link to documentaion: - https://developer.crowdin.com/api/v2/#operation/api.users.security-logs.getMany + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.users.security-logs.getMany """ params = { @@ -60,7 +60,7 @@ def get_user_security_log(self, userId: int, securityLogId: int): Get User Security Log Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.users.security-logs.get + https://support.crowdin.com/developer/api/v2/#operation/api.users.security-logs.get """ return self.requester.request( @@ -72,8 +72,8 @@ def get_user_security_log(self, userId: int, securityLogId: int): def get_organization_security_logs_path(self, securityLogId: Optional[int] = None): if securityLogId is not None: - return f"/security-logs/{securityLogId}" - return "/security-logs" + return f"security-logs/{securityLogId}" + return "security-logs" def list_organization_security_logs( self, @@ -90,7 +90,7 @@ def list_organization_security_logs( List Organization Security Logs Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.security-logs.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.security-logs.getMany """ params = { @@ -112,8 +112,8 @@ def get_organization_security_log(self, securityLogId: int): """ Get Organization Security Log - Link to documentaion: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.security-logs.get + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.security-logs.get """ return self.requester.request( diff --git a/crowdin_api/api_resources/security_logs/tests/test_security_logs_resource.py b/crowdin_api/api_resources/security_logs/tests/test_security_logs_resource.py index 23a2d4cc..698a07a7 100644 --- a/crowdin_api/api_resources/security_logs/tests/test_security_logs_resource.py +++ b/crowdin_api/api_resources/security_logs/tests/test_security_logs_resource.py @@ -16,8 +16,8 @@ def get_resource(self, base_absolut_url): @pytest.mark.parametrize( "incoming_data, path", ( - ({"userId": 1}, "/users/1/security-logs"), - ({"userId": 1, "securityLogId": 2}, "/users/1/security-logs/2"), + ({"userId": 1}, "users/1/security-logs"), + ({"userId": 1, "securityLogId": 2}, "users/1/security-logs/2"), ), ) def test_get_user_security_logs_path(self, incoming_data, path, base_absolut_url): @@ -89,8 +89,8 @@ def test_get_user_security_log(self, m_request, base_absolut_url): @pytest.mark.parametrize( "incoming_data, path", ( - ({}, "/security-logs"), - ({"securityLogId": 1}, "/security-logs/1"), + ({}, "security-logs"), + ({"securityLogId": 1}, "security-logs/1"), ), ) def test_get_organization_security_logs_path( diff --git a/crowdin_api/api_resources/source_files/enums.py b/crowdin_api/api_resources/source_files/enums.py index 7dcfa2d9..f0e45a67 100644 --- a/crowdin_api/api_resources/source_files/enums.py +++ b/crowdin_api/api_resources/source_files/enums.py @@ -10,8 +10,9 @@ class Priority(Enum): class BranchPatchPath(Enum): NAME = "/name" TITLE = "/title" - EXPORT_PATTERN = "/exportPattern" + EXPORT_PATTERN = "/exportPattern" # file-based projects only PRIORITY = "/priority" + IS_PROTECTED = "/isProtected" # string-based projects only class DirectoryPatchPath(Enum): @@ -60,6 +61,36 @@ class FileType(Enum): SBV = "sbv" VTT = "vtt" SRT = "srt" + XLIFF_TWO = "xliff_two" + MDX_V1 = "mdx_v1" + MDX_V2 = "mdx_v2" + VSDX = "vsdx" + XLSX = "xlsx" + PROPERTIES_PLAY = "properties_play" + PROPERTIES_XML = "properties_xml" + MAXTHON = "maxthon" + GO_JSON = "go_json" + DITA = "dita" + IDML = "idml" + MIF = "mif" + STRINGSDICT = "stringsdict" + PLIST = "plist" + VDF = "vdf" + STF = "stf" + TOML = "toml" + CONTENTFUL_RT = "contentful_rt" + SVG = "svg" + JS = "js" + COFFEE = "coffee" + TS = "ts" + I18NEXT_JSON = "i18next_json" + XAML = "xaml" + ARB = "arb" + ADOC = "adoc" + FBT = "fbt" + WEBXML = "webxml" + NESTJS_I18N = "nestjs_i18n" + LOC = "loc" class EscapeQuotes(Enum): @@ -116,6 +147,59 @@ class FilePatchPath(Enum): EXCLUDED_TARGET_LANGUAGES = "/excludedTargetLanguages" ATTACH_LABEL_IDS = "/attachLabelIds" DETACH_LABEL_IDS = "/detachLabelIds" + IMPORT_OPTIONS_IMPORT_KEY_AS_SOURCE = "/importOptions/importKeyAsSource" + IMPORT_OPTIONS_IMPORT_HIDDEN_SHEETS = "/importOptions/importHiddenSheets" + IMPORT_OPTIONS_HIDE_ATTRIBUTE_VALUES = "/importOptions/hideAttributeValues" + IMPORT_OPTIONS_EXCLUDED_ELEMENTS = "/importOptions/excludedElements" + IMPORT_OPTIONS_EXCLUDE_INCLUDE_DIRECTIVES = "/importOptions/excludeIncludeDirectives" + IMPORT_OPTIONS_EXCLUDED_FRONT_MATTER_ELEMENTS = "/importOptions/excludedFrontMatterElements" + IMPORT_OPTIONS_EXCLUDE_CODE_BLOCKS = "/importOptions/excludeCodeBlocks" + IMPORT_OPTIONS_INLINE_TAGS = "/importOptions/inlineTags" + IMPORT_OPTIONS_INLINE_HYPERLINK_TEXT = "/importOptions/inlineHyperlinkText" + IMPORT_OPTIONS_TRANSLATE_DOC_PROPERTIES = "/importOptions/translateDocProperties" + IMPORT_OPTIONS_TRANSLATE_COMMENTS = "/importOptions/translateComments" + IMPORT_OPTIONS_IGNORE_WHITESPACE_STYLES = "/importOptions/ignoreWhitespaceStyles" + IMPORT_OPTIONS_ADD_TAB_AS_CHARACTER = "/importOptions/addTabAsCharacter" + IMPORT_OPTIONS_ADD_LINE_SEPARATOR_AS_CHARACTER = "/importOptions/addLineSeparatorAsCharacter" + IMPORT_OPTIONS_LINE_SEPARATOR_REPLACEMENT = "/importOptions/lineSeparatorReplacement" + IMPORT_OPTIONS_REPLACE_NO_BREAK_HYPHEN_TAG = "/importOptions/replaceNoBreakHyphenTag" + IMPORT_OPTIONS_IGNORE_SOFT_HYPHEN_TAG = "/importOptions/ignoreSoftHyphenTag" + IMPORT_OPTIONS_COMPLEX_FIELD_DEFINITIONS_TO_EXTRACT = ( + "/importOptions/complexFieldDefinitionsToExtract" + ) + IMPORT_OPTIONS_TRANSLATE_WORD_HEADERS_FOOTERS = "/importOptions/translateWordHeadersFooters" + IMPORT_OPTIONS_TRANSLATE_WORD_GRAPHIC_NAME = "/importOptions/translateWordGraphicName" + IMPORT_OPTIONS_TRANSLATE_WORD_GRAPHIC_DESCRIPTION = ( + "/importOptions/translateWordGraphicDescription" + ) + IMPORT_OPTIONS_IGNORE_WORD_FONT_COLORS = "/importOptions/ignoreWordFontColors" + IMPORT_OPTIONS_WORD_FONT_COLORS_MIN_IGNORANCE_THRESHOLD = ( + "/importOptions/wordFontColorsMinIgnoranceThreshold" + ) + IMPORT_OPTIONS_WORD_FONT_COLORS_MAX_IGNORANCE_THRESHOLD = ( + "/importOptions/wordFontColorsMaxIgnoranceThreshold" + ) + IMPORT_OPTIONS_EXCLUDE_WORD_STYLES = "/importOptions/excludeWordStyles" + IMPORT_OPTIONS_TRANSLATE_WORD_IN_EXCLUDE_STYLE_MODE = ( + "/importOptions/translateWordInExcludeStyleMode" + ) + IMPORT_OPTIONS_WORD_HIGHLIGHT_COLORS = "/importOptions/wordHighlightColors" + IMPORT_OPTIONS_TRANSLATE_WORD_IN_EXCLUDE_HIGHLIGHT_MODE = ( + "/importOptions/translateWordInExcludeHighlightMode" + ) + IMPORT_OPTIONS_TRANSLATE_WORD_EXCLUDE_COLORS = "/importOptions/translateWordExcludeColors" + IMPORT_OPTIONS_WORD_EXCLUDED_COLORS = "/importOptions/wordExcludedColors" + IMPORT_OPTIONS_TRANSLATE_EXCEL_CELLS_COPIED = "/importOptions/translateExcelCellsCopied" + IMPORT_OPTIONS_TRANSLATE_EXCEL_SHEET_NAMES = "/importOptions/translateExcelSheetNames" + IMPORT_OPTIONS_EXCEL_EXCLUDED_COLORS = "/importOptions/excelExcludedColors" + IMPORT_OPTIONS_TRANSLATE_EXCEL_DIAGRAM_DATA = "/importOptions/translateExcelDiagramData" + IMPORT_OPTIONS_TRANSLATE_EXCEL_DRAWINGS = "/importOptions/translateExcelDrawings" + EXPORT_OPTIONS_EXPORT_QUOTES = "/exportOptions/exportQuotes" + EXPORT_OPTIONS_ESCAPE_SPECIAL_CHARACTERS = "/exportOptions/escapeSpecialCharacters" + EXPORT_OPTIONS_ALLOW_WORD_STYLE_OPTIMIZATION = "/exportOptions/allowWordStyleOptimization" + EXPORT_OPTIONS_TRANSLATE_EXCEL_EXCLUDE_COLORS = "/exportOptions/translateExcelExcludeColors" + FIELDS = "/fields" # Enterprise only + FIELD = "/fields/{fieldSlug}" # Enterprise only class ListProjectBranchesOrderBy(Enum): @@ -147,3 +231,35 @@ class ListFilesOrderBy(Enum): UPDATED_AT = "updatedAt" EXPORT_PATTERN = "exportPattern" PRIORITY = "priority" + + +class EscapeSpecialCharacters(Enum): + """ + Values available: + 0 - Do not escape special characters + 1 - Escape special characters by a backslash + """ + ZERO = 0 + ONE = 1 + + +class MarkdownMarker(Enum): + ASTERISK = "asterisk" + UNDERSCORE = "underscore" + + +class UnorderedListBullet(Enum): + ASTERISKS = "asterisks" + PLUS = "plus" + DASH = "dash" + + +class TableColumnWidth(Enum): + CONSOLIDATE = "consolidate" + EVENLY_DISTRIBUTE_CELLS = "evenly_distribute_cells" + + +class FrontMatterQuotes(Enum): + AUTO = "auto" + SINGLE = "single" + DOUBLE = "double" diff --git a/crowdin_api/api_resources/source_files/resource.py b/crowdin_api/api_resources/source_files/resource.py index d566a514..18dcebf3 100644 --- a/crowdin_api/api_resources/source_files/resource.py +++ b/crowdin_api/api_resources/source_files/resource.py @@ -1,4 +1,4 @@ -from typing import Any, Iterable, Optional, Union +from typing import Any, Iterable, Optional from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.source_files.enums import ( @@ -9,18 +9,10 @@ from crowdin_api.api_resources.source_files.types import ( BranchPatchRequest, DirectoryPatchRequest, + FileExportOptions, + FileFields, + FileImportOptions, FilePatchRequest, - GeneralExportOptions, - JavascriptExportOptions, - HtmlFileImportOptions, - HtmlWithFrontMatterFileImportOptions, - MdxV1FileImportOptions, - MdxV2FileImportOptions, - OtherImportOptions, - PropertyExportOptions, - SpreadsheetImportOptions, - XmlImportOptions, - DocxFileImportOptions, ) from crowdin_api.sorting import Sorting @@ -39,7 +31,7 @@ class SourceFilesResource(BaseResource): Crowdin. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Source-Files + https://support.crowdin.com/developer/api/v2/#tag/Source-Files """ # Organization Search @@ -56,8 +48,8 @@ def search_branches( Search Branches. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.branches.getMany - https://developer.crowdin.com/enterprise/api/v2/#operation/api.branches.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.branches.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.branches.getMany """ params = { @@ -84,8 +76,8 @@ def search_directories( Search Directories. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.directories.getMany - https://developer.crowdin.com/enterprise/api/v2/#operation/api.directories.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.directories.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.directories.getMany """ params = { @@ -112,8 +104,8 @@ def search_files( Search Files. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.files.getMany - https://developer.crowdin.com/enterprise/api/v2/#operation/api.files.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.files.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.files.getMany """ params = { @@ -147,7 +139,7 @@ def list_project_branches( List Branches. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.branches.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.branches.getMany """ projectId = projectId or self.get_project_id() @@ -165,12 +157,16 @@ def add_branch( title: Optional[str] = None, exportPattern: Optional[str] = None, priority: Optional[Priority] = None, + isProtected: Optional[bool] = None, ): """ Add Branch. + `exportPattern` and `priority` are for file-based projects only, + `isProtected` is for string-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.branches.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.branches.post """ projectId = projectId or self.get_project_id() @@ -183,6 +179,7 @@ def add_branch( "title": title, "exportPattern": exportPattern, "priority": priority, + "isProtected": isProtected, }, ) @@ -191,7 +188,7 @@ def get_branch(self, branchId: int, projectId: Optional[int] = None): Get Branch. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.branches.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.branches.get """ projectId = projectId or self.get_project_id() @@ -210,8 +207,11 @@ def delete_branch( """ Delete Branch. + Pass `prefer="respond-async"` to delete the branch asynchronously (recommended); the API + then returns a job to poll via `check_branch_deletion_status`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.branches.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.branches.delete """ projectId = projectId or self.get_project_id() @@ -233,7 +233,7 @@ def check_branch_deletion_status( Check Branch Deletion Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.branches.jobs.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.branches.jobs.get """ projectId = projectId or self.get_project_id() @@ -252,8 +252,11 @@ def edit_branch( """ Edit Branch. + `/exportPattern` is for file-based projects only, `/isProtected` is for string-based + projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.branches.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.branches.patch """ projectId = projectId or self.get_project_id() @@ -287,7 +290,7 @@ def list_directories( List Directories. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.directories.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.directories.getMany """ projectId = projectId or self.get_project_id() @@ -312,7 +315,7 @@ def add_directory( projectId: Optional[int] = None, branchId: Optional[int] = None, directoryId: Optional[int] = None, - title: Optional[int] = None, + title: Optional[str] = None, exportPattern: Optional[str] = None, priority: Optional[Priority] = None, ): @@ -320,7 +323,7 @@ def add_directory( Add Directory. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.directories.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.directories.post """ projectId = projectId or self.get_project_id() @@ -343,7 +346,7 @@ def get_directory(self, directoryId: int, projectId: Optional[int] = None): Get Directory. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.directories.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.directories.get """ projectId = projectId or self.get_project_id() @@ -362,8 +365,11 @@ def delete_directory( """ Delete Directory. + Pass `prefer="respond-async"` to delete the directory asynchronously (recommended); the API + then returns a job to poll via `check_directory_deletion_status`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.directories.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.directories.delete """ projectId = projectId or self.get_project_id() @@ -385,7 +391,7 @@ def check_directory_deletion_status( Check Directory Deletion Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.directories.jobs.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.directories.jobs.get """ projectId = projectId or self.get_project_id() @@ -405,7 +411,7 @@ def edit_directory( Edit Directory. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.directories.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.directories.patch """ projectId = projectId or self.get_project_id() @@ -439,7 +445,7 @@ def list_files( List Files. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.getMany """ projectId = projectId or self.get_project_id() @@ -463,32 +469,23 @@ def add_file( projectId: Optional[int] = None, branchId: Optional[int] = None, directoryId: Optional[int] = None, - title: Optional[int] = None, + title: Optional[str] = None, context: Optional[str] = None, type: Optional[FileType] = FileType.AUTO, - importOptions: Optional[ - Union[ - SpreadsheetImportOptions, - XmlImportOptions, - DocxFileImportOptions, - OtherImportOptions, - HtmlFileImportOptions, - HtmlWithFrontMatterFileImportOptions, - MdxV1FileImportOptions, - MdxV2FileImportOptions, - ] - ] = None, - exportOptions: Optional[ - Union[PropertyExportOptions, GeneralExportOptions, JavascriptExportOptions] - ] = None, + importOptions: Optional[FileImportOptions] = None, + exportOptions: Optional[FileExportOptions] = None, excludedTargetLanguages: Optional[Iterable[str]] = None, attachLabelIds: Optional[Iterable[int]] = None, + parserVersion: Optional[int] = None, + fields: Optional[FileFields] = None, ): """ Add File. + `parserVersion` must be used together with `type`. `fields` is Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.post """ projectId = projectId or self.get_project_id() @@ -508,6 +505,8 @@ def add_file( "exportOptions": exportOptions, "excludedTargetLanguages": excludedTargetLanguages, "attachLabelIds": attachLabelIds, + "parserVersion": parserVersion, + "fields": fields, }, ) @@ -516,7 +515,7 @@ def get_file(self, fileId: int, projectId: Optional[int] = None): Get File. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.get """ projectId = projectId or self.get_project_id() @@ -532,8 +531,10 @@ def restore_file( """ Restore File. + Restore the file to one of the previous revisions. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.put + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.put """ projectId = projectId or self.get_project_id() @@ -550,29 +551,23 @@ def update_file( storageId: int, projectId: Optional[int] = None, updateOption: Optional[FileUpdateOption] = None, - importOptions: Optional[ - Union[ - SpreadsheetImportOptions, - XmlImportOptions, - DocxFileImportOptions, - OtherImportOptions, - HtmlFileImportOptions, - HtmlWithFrontMatterFileImportOptions, - MdxV1FileImportOptions, - MdxV2FileImportOptions, - ] - ] = None, - exportOptions: Optional[ - Union[GeneralExportOptions, PropertyExportOptions, JavascriptExportOptions] - ] = None, + importOptions: Optional[FileImportOptions] = None, + exportOptions: Optional[FileExportOptions] = None, attachLabelIds: Optional[Iterable[int]] = None, detachLabelIds: Optional[Iterable[int]] = None, + name: Optional[str] = None, + replaceModifiedContext: Optional[bool] = None, ): """ Update File. + Replace the file with a new one from the Storage. `importOptions` is sent to the API + as-is (e.g. `{"importTranslations": True}` for spreadsheet files). Note that + `importOptions.importTranslations` in the API response for spreadsheet files is + marked as deprecated by the API and may not reflect the value sent in the request. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.put + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.put """ projectId = projectId or self.get_project_id() @@ -587,6 +582,8 @@ def update_file( "exportOptions": exportOptions, "attachLabelIds": attachLabelIds, "detachLabelIds": detachLabelIds, + "name": name, + "replaceModifiedContext": replaceModifiedContext, }, ) @@ -599,8 +596,11 @@ def delete_file( """ Delete File. + Pass `prefer="respond-async"` to delete the file asynchronously (recommended); the API + then returns a job to poll via `check_file_deletion_status`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.delete """ projectId = projectId or self.get_project_id() @@ -622,7 +622,7 @@ def check_file_deletion_status( Check File Deletion Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.jobs.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.jobs.get """ projectId = projectId or self.get_project_id() @@ -641,8 +641,11 @@ def edit_file( """ Edit File. + `/fields` and `/fields/{fieldSlug}` paths are Enterprise only. For `/fields/{fieldSlug}` + pass the path as a string, e.g. `"/fields/some-field"`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.patch """ projectId = projectId or self.get_project_id() @@ -658,7 +661,7 @@ def download_file_preview(self, fileId: int, projectId: Optional[int] = None): Download File Preview. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.preview.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.preview.get """ projectId = projectId or self.get_project_id() @@ -672,7 +675,7 @@ def download_file(self, fileId: int, projectId: Optional[int] = None): Download File. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.download.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.download.get """ projectId = projectId or self.get_project_id() @@ -705,7 +708,7 @@ def list_file_revisions( List File Revisions. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.revisions.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.revisions.getMany """ projectId = projectId or self.get_project_id() @@ -723,7 +726,7 @@ def get_file_revision( Get File Revision. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.revisions.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.revisions.get """ projectId = projectId or self.get_project_id() @@ -765,10 +768,10 @@ def list_asset_references( "offset": offset, } - return self.requester.request( + return self._get_entire_data( method="get", path=self.get_asset_references_path(project_id, file_id), - params=params + params=params, ) def add_asset_reference( diff --git a/crowdin_api/api_resources/source_files/tests/test_source_files_resources.py b/crowdin_api/api_resources/source_files/tests/test_source_files_resources.py index 9452f37f..79434790 100644 --- a/crowdin_api/api_resources/source_files/tests/test_source_files_resources.py +++ b/crowdin_api/api_resources/source_files/tests/test_source_files_resources.py @@ -1,3 +1,4 @@ +import json from unittest import mock import pytest @@ -8,6 +9,7 @@ DirectoryPatchPath, FilePatchPath, FileType, + FileUpdateOption, ListDirectoriesOrderBy, ListFilesOrderBy, ListProjectBranchesOrderBy, @@ -174,7 +176,25 @@ def test_add_branch(self, m_request, in_params, request_data, base_absolut_url): m_request.assert_called_once_with( method="post", path=resource.get_branch_path(projectId=1), - request_data=request_data, + request_data={"isProtected": None, **request_data}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_branch_string_based(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.add_branch(projectId=1, name="name", isProtected=True) == "response" + m_request.assert_called_once_with( + method="post", + path=resource.get_branch_path(projectId=1), + request_data={ + "name": "name", + "title": None, + "exportPattern": None, + "priority": None, + "isProtected": True, + }, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -649,7 +669,45 @@ def test_add_file(self, m_request, in_params, request_data, base_absolut_url): m_request.assert_called_once_with( method="post", path=resource.get_file_path(projectId=1), - request_data=request_data, + request_data={"parserVersion": None, "fields": None, **request_data}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_file_with_parser_version_and_fields(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_file( + projectId=1, + storageId=1, + name="name", + type=FileType.DOCX, + importOptions={"translateDocProperties": True, "translateComments": False}, + exportOptions={"allowWordStyleOptimization": False}, + parserVersion=2, + fields={"some-field": "value"}, + ) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path=resource.get_file_path(projectId=1), + request_data={ + "name": "name", + "storageId": 1, + "branchId": None, + "directoryId": None, + "title": None, + "type": FileType.DOCX, + "context": None, + "importOptions": {"translateDocProperties": True, "translateComments": False}, + "exportOptions": {"allowWordStyleOptimization": False}, + "excludedTargetLanguages": None, + "attachLabelIds": None, + "parserVersion": 2, + "fields": {"some-field": "value"}, + }, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -719,6 +777,28 @@ def test_edit_file(self, m_request, base_absolut_url): path=resource.get_file_path(projectId=1, fileId=2), ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_file_fields(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + {"value": {"a": 1}, "op": PatchOperation.REPLACE, "path": FilePatchPath.FIELDS}, + { + "value": True, + "op": PatchOperation.REPLACE, + "path": FilePatchPath.IMPORT_OPTIONS_TRANSLATE_DOC_PROPERTIES, + }, + {"value": "x", "op": PatchOperation.REPLACE, "path": "/fields/some-field"}, + ] + + resource = self.get_resource(base_absolut_url) + assert resource.edit_file(projectId=1, fileId=2, data=data) == "response" + m_request.assert_called_once_with( + method="patch", + request_data=data, + path=resource.get_file_path(projectId=1, fileId=2), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") def test_update_file(self, m_request, base_absolut_url): m_request.return_value = "response" @@ -734,10 +814,76 @@ def test_update_file(self, m_request, base_absolut_url): "exportOptions": None, "attachLabelIds": None, "detachLabelIds": None, + "name": None, + "replaceModifiedContext": None, + }, + path=resource.get_file_path(projectId=1, fileId=2), + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_update_file_with_all_params(self, m_request, base_absolut_url): + m_request.return_value = "response" + + import_options = { + "firstLineContainsHeader": True, + "importTranslations": True, + "scheme": {"identifier": 0, "sourcePhrase": 1, "translation": 2}, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.update_file( + projectId=1, + fileId=2, + storageId=1, + updateOption=FileUpdateOption.KEEP_TRANSLATIONS, + importOptions=import_options, + exportOptions={"exportPattern": "pattern"}, + attachLabelIds=[1], + detachLabelIds=[2], + name="name.csv", + replaceModifiedContext=True, + ) + == "response" + ) + m_request.assert_called_once_with( + method="put", + request_data={ + "storageId": 1, + "updateOption": FileUpdateOption.KEEP_TRANSLATIONS, + "importOptions": { + "firstLineContainsHeader": True, + "importTranslations": True, + "scheme": {"identifier": 0, "sourcePhrase": 1, "translation": 2}, + }, + "exportOptions": {"exportPattern": "pattern"}, + "attachLabelIds": [1], + "detachLabelIds": [2], + "name": "name.csv", + "replaceModifiedContext": True, }, path=resource.get_file_path(projectId=1, fileId=2), ) + @mock.patch("requests.Session.request") + def test_update_file_sends_import_translations(self, m_session_request, base_absolut_url): + """Regression test for #246: importTranslations must reach the HTTP body unchanged.""" + m_session_request.return_value = mock.Mock(status_code=200, content=b"{}") + + resource = self.get_resource(base_absolut_url) + resource.update_file( + projectId=1, + fileId=2, + storageId=1, + updateOption=FileUpdateOption.KEEP_TRANSLATIONS, + importOptions={"importTranslations": True, "firstLineContainsHeader": False}, + ) + assert json.loads(m_session_request.call_args.kwargs["data"]) == { + "storageId": 1, + "updateOption": "keep_translations", + "importOptions": {"importTranslations": True, "firstLineContainsHeader": False}, + } + @mock.patch("crowdin_api.requester.APIRequester.request") def test_restore_file(self, m_request, base_absolut_url): m_request.return_value = "response" diff --git a/crowdin_api/api_resources/source_files/types.py b/crowdin_api/api_resources/source_files/types.py index 6c0cd942..cb95f5a8 100644 --- a/crowdin_api/api_resources/source_files/types.py +++ b/crowdin_api/api_resources/source_files/types.py @@ -1,12 +1,17 @@ -from typing import Any, Iterable +from typing import Any, Dict, Iterable, Union from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.api_resources.source_files.enums import ( BranchPatchPath, DirectoryPatchPath, EscapeQuotes, + EscapeSpecialCharacters, ExportQuotes, FilePatchPath, + FrontMatterQuotes, + MarkdownMarker, + TableColumnWidth, + UnorderedListBullet, ) from crowdin_api.typing import TypedDict @@ -24,25 +29,48 @@ class DirectoryPatchRequest(TypedDict): class Scheme(TypedDict, total=False): + none: int identifier: int sourcePhrase: int + sourceOrTranslation: int + translation: int + context: int + maxLength: int + translatable: int + labels: int -class SpreadsheetImportOptions(TypedDict): +class SpreadsheetImportOptions(TypedDict, total=False): firstLineContainsHeader: bool + importHiddenSheets: bool + importHiddenRows: bool + importEqSuggestions: bool + autoApproveImported: bool + translateHidden: bool + addToTm: bool + contentSegmentation: bool + srxStorageId: int importTranslations: bool scheme: Scheme - srxStorageId: int -class XmlImportOptions(TypedDict): +class XmlImportOptions(TypedDict, total=False): translateContent: bool translateAttributes: bool + inlineTags: Iterable[str] contentSegmentation: bool translatableElements: Iterable[str] + srxStorageId: int -class DocxFileImportOptions(TypedDict): +class WebXmlFileImportOptions(TypedDict, total=False): + inlineTags: Iterable[str] + hideAttributeValues: bool + contentSegmentation: bool + srxStorageId: int + + +class DocxFileImportOptions(TypedDict, total=False): cleanTagsAggressively: bool translateHiddenText: bool translateHyperlinkUrls: bool @@ -51,52 +79,161 @@ class DocxFileImportOptions(TypedDict): importHiddenSlides: bool contentSegmentation: bool srxStorageId: int - - -class OtherImportOptions(TypedDict): + translateDocProperties: bool + translateComments: bool + ignoreWhitespaceStyles: bool + addTabAsCharacter: bool + addLineSeparatorAsCharacter: bool + lineSeparatorReplacement: str + replaceNoBreakHyphenTag: bool + ignoreSoftHyphenTag: bool + complexFieldDefinitionsToExtract: Iterable[str] + translateWordHeadersFooters: bool + translateWordGraphicName: bool + translateWordGraphicDescription: bool + ignoreWordFontColors: bool + wordFontColorsMinIgnoranceThreshold: str + wordFontColorsMaxIgnoranceThreshold: str + excludeWordStyles: Iterable[str] + translateWordInExcludeStyleMode: bool + wordHighlightColors: Iterable[str] + translateWordInExcludeHighlightMode: bool + translateWordExcludeColors: bool + wordExcludedColors: Iterable[str] + translateExcelCellsCopied: bool + translateExcelSheetNames: bool + excelExcludedColors: Iterable[str] + translateExcelDiagramData: bool + translateExcelDrawings: bool + + +class VsdxFileImportOptions(TypedDict, total=False): + cleanTagsAggressively: bool + translateHyperlinkUrls: bool contentSegmentation: bool srxStorageId: int -class GeneralExportOptions(TypedDict): - exportPattern: str - - -class PropertyExportOptions: - escapeQuotes: EscapeQuotes - exportPattern: str - - -class JavascriptExportOptions: - exportQuotes: ExportQuotes - exportPattern: str +class IdmlFileImportOptions(TypedDict, total=False): + inlineHyperlinkText: bool + contentSegmentation: bool + srxStorageId: int -class FilePatchRequest(TypedDict): - value: Any - op: PatchOperation - path: FilePatchPath +class OtherImportOptions(TypedDict, total=False): + contentSegmentation: bool + srxStorageId: int -class HtmlFileImportOptions(TypedDict): +class HtmlFileImportOptions(TypedDict, total=False): excludedElements: Iterable[str] contentSegmentation: bool + inlineTags: Iterable[str] srxStorageId: int -class HtmlWithFrontMatterFileImportOptions(HtmlFileImportOptions): +class HtmlWithFrontMatterFileImportOptions(HtmlFileImportOptions, total=False): + excludedFrontMatterElements: Iterable[str] + + +class MdFileImportOptions(TypedDict, total=False): excludedFrontMatterElements: Iterable[str] + excludeCodeBlocks: bool + inlineTags: Iterable[str] + contentSegmentation: bool + srxStorageId: int -class MdxV1FileImportOptions(TypedDict): +class MdxV1FileImportOptions(TypedDict, total=False): excludedFrontMatterElements: Iterable[str] excludeCodeBlocks: bool contentSegmentation: bool srxStorageId: int -class MdxV2FileImportOptions(TypedDict): +class MdxV2FileImportOptions(TypedDict, total=False): excludedFrontMatterElements: Iterable[str] excludeCodeBlocks: bool contentSegmentation: bool srxStorageId: int + + +class StringCatalogFileImportOptions(TypedDict, total=False): + importKeyAsSource: bool + importTranslations: bool + + +class AdocFileImportOptions(TypedDict, total=False): + excludeIncludeDirectives: bool + + +class VdfFileImportOptions(TypedDict, total=False): + convertIcu: bool + addGenderArgument: bool + + +FileImportOptions = Union[ + SpreadsheetImportOptions, + XmlImportOptions, + WebXmlFileImportOptions, + DocxFileImportOptions, + VsdxFileImportOptions, + IdmlFileImportOptions, + HtmlFileImportOptions, + HtmlWithFrontMatterFileImportOptions, + MdFileImportOptions, + MdxV1FileImportOptions, + MdxV2FileImportOptions, + StringCatalogFileImportOptions, + AdocFileImportOptions, + VdfFileImportOptions, + OtherImportOptions, +] + + +class GeneralExportOptions(TypedDict, total=False): + exportPattern: str + + +class PropertyExportOptions(GeneralExportOptions, total=False): + escapeQuotes: EscapeQuotes + escapeSpecialCharacters: EscapeSpecialCharacters + + +class JavascriptExportOptions(GeneralExportOptions, total=False): + exportQuotes: ExportQuotes + + +class MdxFileExportOptions(GeneralExportOptions, total=False): + strongMarker: MarkdownMarker + emphasisMarker: MarkdownMarker + unorderedListBullet: UnorderedListBullet + tableColumnWidth: TableColumnWidth + + +class MdFileExportOptions(MdxFileExportOptions, total=False): + frontMatterQuotes: FrontMatterQuotes + + +class DocxFileExportOptions(GeneralExportOptions, total=False): + allowWordStyleOptimization: bool + translateExcelExcludeColors: bool + + +FileExportOptions = Union[ + GeneralExportOptions, + PropertyExportOptions, + JavascriptExportOptions, + MdFileExportOptions, + MdxFileExportOptions, + DocxFileExportOptions, +] + +# Enterprise only. Keys get via List Fields. +FileFields = Dict[str, Union[str, int, bool, Iterable[str]]] + + +class FilePatchRequest(TypedDict): + value: Any + op: PatchOperation + path: Union[FilePatchPath, str] diff --git a/crowdin_api/api_resources/source_strings/enums.py b/crowdin_api/api_resources/source_strings/enums.py index 56ad66d0..d42d395f 100644 --- a/crowdin_api/api_resources/source_strings/enums.py +++ b/crowdin_api/api_resources/source_strings/enums.py @@ -10,11 +10,15 @@ class ScopeFilter(Enum): class SourceStringsPatchPath(Enum): + IDENTIFIER = "/identifier" TEXT = "/text" CONTEXT = "/context" IS_HIDDEN = "/isHidden" MAXLENGTH = "/maxLength" LABEL_IDS = "/labelIds" + # Crowdin Enterprise only + FIELDS = "/fields" + FIELD = "/fields/{fieldSlug}" class StringBatchOperations(Enum): @@ -30,6 +34,10 @@ class StringBatchOperationsPath(Enum): IS_HIDDEN = "/{stringId}/isHidden" MAX_LENGTH = "/{stringId}/maxLength" LABEL_IDS = "/{stringId}/labelIds" + # Use with the "remove" operation + STRING = "/{stringId}" + # Use with the "add" operation + NEW_STRING = "/-" class ListStringsOrderBy(Enum): @@ -40,3 +48,35 @@ class ListStringsOrderBy(Enum): CREATED_AT = "createdAt" UPDATED_AT = "updatedAt" TYPE = "type" + + +class StringUpdateOption(Enum): + """ + Defines whether to keep existing translations and approvals for updated strings. + """ + + KEEP_TRANSLATIONS_AND_APPROVALS = "keep_translations_and_approvals" + KEEP_TRANSLATIONS = "keep_translations" + CLEAR_TRANSLATIONS_AND_APPROVALS = "clear_translations_and_approvals" + + +class UploadStringsType(Enum): + AUTO = "auto" + ANDROID = "android" + MACOSX = "macosx" + ARB = "arb" + CSV = "csv" + JSON = "json" + XLSX = "xlsx" + XLIFF = "xliff" + XLIFF_TWO = "xliff_two" + RESX = "resx" + GETTEXT = "gettext" + I18NEXT_JSON = "i18next_json" + PROPERTIES = "properties" + PROPERTIES_XML = "properties_xml" + PROPERTIES_PLAY = "properties_play" + YAML = "yaml" + STRING_CATALOG = "string_catalog" + NESTJS_I18N = "nestjs_i18n" + STRINGSDICT = "stringsdict" diff --git a/crowdin_api/api_resources/source_strings/resource.py b/crowdin_api/api_resources/source_strings/resource.py index 6e6bfb88..7de7885e 100644 --- a/crowdin_api/api_resources/source_strings/resource.py +++ b/crowdin_api/api_resources/source_strings/resource.py @@ -1,11 +1,16 @@ -from typing import Iterable, Optional +from typing import Any, Dict, Iterable, Optional, Union from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.enums import DenormalizePlaceholders -from crowdin_api.api_resources.source_strings.enums import ScopeFilter +from crowdin_api.api_resources.source_strings.enums import ( + ScopeFilter, + StringUpdateOption, + UploadStringsType, +) from crowdin_api.api_resources.source_strings.types import ( SourceStringsPatchRequest, StringBatchOperationPatchRequest, + UploadStringsImportOptions, ) from crowdin_api.sorting import Sorting @@ -22,7 +27,7 @@ class SourceStringsResource(BaseResource): iOS strings, PROPERTIES, XLIFF). Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Source-Strings + https://support.crowdin.com/developer/api/v2/#tag/Source-Strings """ def search_strings( @@ -40,8 +45,8 @@ def search_strings( Search Strings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.strings.getMany - https://developer.crowdin.com/enterprise/api/v2/#operation/api.strings.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.strings.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.strings.getMany """ params = { @@ -78,12 +83,16 @@ def list_strings( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + directoryId: Optional[int] = None, ): """ List Strings. + :param directoryId: Directory Identifier (file-based projects only). + Can't be used with `taskId`, `fileId` or `branchId` in the same request. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings.getMany """ projectId = projectId or self.get_project_id() @@ -91,6 +100,7 @@ def list_strings( "orderBy": orderBy, "branchId": branchId, "fileId": fileId, + "directoryId": directoryId, "denormalizePlaceholders": denormalizePlaceholders, "labelIds": None if labelIds is None else ",".join(str(item) for item in labelIds), "taskId": taskId, @@ -108,7 +118,7 @@ def list_strings( def add_string( self, - text: str, + text: Union[str, Dict[str, str]], projectId: Optional[int] = None, identifier: Optional[str] = None, fileId: Optional[int] = None, @@ -116,13 +126,20 @@ def add_string( isHidden: Optional[bool] = None, maxLength: Optional[int] = None, labelIds: Optional[Iterable[int]] = None, - branchId: Optional[int] = None + branchId: Optional[int] = None, + fields: Optional[Dict[str, Any]] = None, ): """ Add String. + :param text: Text for translation. Use a dict (e.g. {"one": "...", "other": "..."}) + for plural strings. + :param fileId: File Identifier (file-based projects only). + :param branchId: Branch Identifier (string-based projects only). + :param fields: Fields values, keys get via List Fields (Crowdin Enterprise only). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings.post """ projectId = projectId or self.get_project_id() @@ -138,16 +155,22 @@ def add_string( "isHidden": isHidden, "maxLength": maxLength, "labelIds": labelIds, - "branchId": branchId + "branchId": branchId, + "fields": fields, }, ) - def get_string(self, stringId: int, projectId: Optional[int] = None): + def get_string( + self, + stringId: int, + projectId: Optional[int] = None, + denormalizePlaceholders: Optional[DenormalizePlaceholders] = None, + ): """ Get String. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings.get """ projectId = projectId or self.get_project_id() @@ -155,6 +178,7 @@ def get_string(self, stringId: int, projectId: Optional[int] = None): return self.requester.request( method="get", path=self.get_source_strings_path(projectId=projectId, stringId=stringId), + params={"denormalizePlaceholders": denormalizePlaceholders}, ) def delete_string(self, stringId: int, projectId: Optional[int] = None): @@ -162,7 +186,7 @@ def delete_string(self, stringId: int, projectId: Optional[int] = None): Delete String. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings.delete """ projectId = projectId or self.get_project_id() @@ -177,12 +201,16 @@ def edit_string( stringId: int, data: Iterable[SourceStringsPatchRequest], projectId: Optional[int] = None, + updateOption: Optional[StringUpdateOption] = None, ): """ Edit String. + :param updateOption: Defines whether to keep existing translations and approvals for + the updated string. Applied only when `text` or `identifier` is changed. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings.patch """ projectId = projectId or self.get_project_id() @@ -190,6 +218,7 @@ def edit_string( return self.requester.request( method="patch", path=self.get_source_strings_path(projectId=projectId, stringId=stringId), + params={"updateOption": updateOption}, request_data=data, ) @@ -197,12 +226,16 @@ def string_batch_operation( self, data: Iterable[StringBatchOperationPatchRequest], projectId: Optional[int] = None, + updateOption: Optional[StringUpdateOption] = None, ): """ String Batch Operations. + :param updateOption: Defines whether to keep existing translations and approvals for + updated strings. Applied only when `text` or `identifier` is changed. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.strings.batchPatch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.strings.batchPatch """ projectId = projectId or self.get_project_id() @@ -210,5 +243,180 @@ def string_batch_operation( return self.requester.request( method="patch", path=self.get_source_strings_path(projectId=projectId), + params={"updateOption": updateOption}, request_data=data, ) + + # Upload Strings + def get_strings_uploads_path(self, projectId: int, uploadId: Optional[str] = None): + if uploadId is not None: + return f"projects/{projectId}/strings/uploads/{uploadId}" + + return f"projects/{projectId}/strings/uploads" + + def upload_strings( + self, + storageId: int, + branchId: int, + projectId: Optional[int] = None, + type: Optional[UploadStringsType] = None, + parserVersion: Optional[int] = None, + labelIds: Optional[Iterable[int]] = None, + updateStrings: Optional[bool] = None, + cleanupMode: Optional[bool] = None, + importOptions: Optional[UploadStringsImportOptions] = None, + updateOption: Optional[StringUpdateOption] = None, + ): + """ + Upload Strings. + + String-based projects only. + + :param updateOption: Must be used together with `updateStrings = True`. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#operation/api.projects.strings.uploads.post + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#operation/api.projects.strings.uploads.post + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_strings_uploads_path(projectId=projectId), + request_data={ + "storageId": storageId, + "branchId": branchId, + "type": type, + "parserVersion": parserVersion, + "labelIds": labelIds, + "updateStrings": updateStrings, + "cleanupMode": cleanupMode, + "importOptions": importOptions, + "updateOption": updateOption, + }, + ) + + def upload_strings_status(self, uploadId: str, projectId: Optional[int] = None): + """ + Upload Strings Status. + + String-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#operation/api.projects.strings.uploads.get + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#operation/api.projects.strings.uploads.get + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_strings_uploads_path(projectId=projectId, uploadId=uploadId), + ) + + +class EnterpriseSourceStringsResource(SourceStringsResource): + """ + Resource for Source Strings (Crowdin Enterprise). + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Source-Strings + """ + + def get_reviewed_builds_path(self, projectId: int, buildId: Optional[int] = None): + if buildId is not None: + return f"projects/{projectId}/strings/reviewed-builds/{buildId}" + + return f"projects/{projectId}/strings/reviewed-builds" + + def list_reviewed_source_files_builds( + self, + projectId: Optional[int] = None, + branchId: Optional[int] = None, + page: Optional[int] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List Reviewed Source Files Builds. + + File-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.strings.reviewed-builds.getMany + """ + + projectId = projectId or self.get_project_id() + params = {"branchId": branchId} + params.update(self.get_page_params(page=page, offset=offset, limit=limit)) + + return self._get_entire_data( + method="get", + path=self.get_reviewed_builds_path(projectId=projectId), + params=params, + ) + + def build_reviewed_source_files( + self, + projectId: Optional[int] = None, + branchId: Optional[int] = None, + ): + """ + Build Reviewed Source Files. + + File-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.strings.reviewed-builds.post + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_reviewed_builds_path(projectId=projectId), + request_data={"branchId": branchId}, + ) + + def check_reviewed_source_files_build_status( + self, + buildId: int, + projectId: Optional[int] = None, + ): + """ + Check Reviewed Source Files Build Status. + + File-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.strings.reviewed-builds.get + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_reviewed_builds_path(projectId=projectId, buildId=buildId), + ) + + def download_reviewed_source_files( + self, + buildId: int, + projectId: Optional[int] = None, + ): + """ + Download Reviewed Source Files. + + File-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.strings.reviewed-builds.download.download + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_reviewed_builds_path(projectId=projectId, buildId=buildId) + "/download", + ) diff --git a/crowdin_api/api_resources/source_strings/tests/test_source_strings_resources.py b/crowdin_api/api_resources/source_strings/tests/test_source_strings_resources.py index 8deafaf7..f8f43ca3 100644 --- a/crowdin_api/api_resources/source_strings/tests/test_source_strings_resources.py +++ b/crowdin_api/api_resources/source_strings/tests/test_source_strings_resources.py @@ -8,8 +8,13 @@ SourceStringsPatchPath, StringBatchOperationsPath, StringBatchOperations, + StringUpdateOption, + UploadStringsType, +) +from crowdin_api.api_resources.source_strings.resource import ( + EnterpriseSourceStringsResource, + SourceStringsResource, ) -from crowdin_api.api_resources.source_strings.resource import SourceStringsResource from crowdin_api.requester import APIRequester from crowdin_api.sorting import Sorting, SortingOrder, SortingRule @@ -96,6 +101,7 @@ def test_get_source_strings_path(self, in_params, path, base_absolut_url): "offset": 0, "limit": 10, "fileId": None, + "directoryId": None, "croql": None, "denormalizePlaceholders": None, "labelIds": None, @@ -120,6 +126,7 @@ def test_get_source_strings_path(self, in_params, path, base_absolut_url): "scope": ScopeFilter.CONTEXT, "branchId": 2, "taskId": 5, + "directoryId": 7, }, { "orderBy": Sorting( @@ -135,6 +142,7 @@ def test_get_source_strings_path(self, in_params, path, base_absolut_url): "scope": ScopeFilter.CONTEXT, "branchId": 2, "taskId": 5, + "directoryId": 7, }, ), ), @@ -166,7 +174,8 @@ def test_list_strings(self, m_request, in_params, request_params, base_absolut_u "isHidden": None, "maxLength": None, "labelIds": None, - "branchId": None + "branchId": None, + "fields": None, }, ), ( @@ -178,7 +187,8 @@ def test_list_strings(self, m_request, in_params, request_params, base_absolut_u "isHidden": True, "maxLength": 2, "labelIds": [1, 2, 3], - "branchId": None + "branchId": None, + "fields": {"some-field": "value"}, }, { "text": "text", @@ -188,7 +198,26 @@ def test_list_strings(self, m_request, in_params, request_params, base_absolut_u "isHidden": True, "maxLength": 2, "labelIds": [1, 2, 3], - "branchId": None + "branchId": None, + "fields": {"some-field": "value"}, + }, + ), + ( + { + "text": {"one": "string", "other": "strings"}, + "identifier": "identifier", + "branchId": 3, + }, + { + "text": {"one": "string", "other": "strings"}, + "identifier": "identifier", + "fileId": None, + "context": None, + "isHidden": None, + "maxLength": None, + "labelIds": None, + "branchId": 3, + "fields": None, }, ), ), @@ -212,7 +241,28 @@ def test_get_string(self, m_request, base_absolut_url): resource = self.get_resource(base_absolut_url) assert resource.get_string(projectId=1, stringId=2) == "response" m_request.assert_called_once_with( - method="get", path=resource.get_source_strings_path(projectId=1, stringId=2) + method="get", + path=resource.get_source_strings_path(projectId=1, stringId=2), + params={"denormalizePlaceholders": None}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_string_denormalize_placeholders(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.get_string( + projectId=1, + stringId=2, + denormalizePlaceholders=DenormalizePlaceholders.ENABLE, + ) + == "response" + ) + m_request.assert_called_once_with( + method="get", + path=resource.get_source_strings_path(projectId=1, stringId=2), + params={"denormalizePlaceholders": DenormalizePlaceholders.ENABLE}, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -243,6 +293,41 @@ def test_edit_string(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="patch", request_data=data, + params={"updateOption": None}, + path=resource.get_source_strings_path(projectId=1, stringId=2), + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_string_with_update_option(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "value": "new.identifier", + "op": PatchOperation.REPLACE, + "path": SourceStringsPatchPath.IDENTIFIER, + }, + { + "value": {"some-field": "value"}, + "op": PatchOperation.REPLACE, + "path": SourceStringsPatchPath.FIELDS, + }, + ] + + resource = self.get_resource(base_absolut_url) + assert ( + resource.edit_string( + projectId=1, + stringId=2, + data=data, + updateOption=StringUpdateOption.KEEP_TRANSLATIONS, + ) + == "response" + ) + m_request.assert_called_once_with( + method="patch", + request_data=data, + params={"updateOption": StringUpdateOption.KEEP_TRANSLATIONS}, path=resource.get_source_strings_path(projectId=1, stringId=2), ) @@ -267,5 +352,202 @@ def test_string_batch_operation(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="patch", path=resource.get_source_strings_path(1), + params={"updateOption": None}, request_data=data, ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_string_batch_operation_with_update_option(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": StringBatchOperations.ADD, + "path": StringBatchOperationsPath.NEW_STRING, + "value": {"text": "new", "identifier": "a.b.c", "branchId": 5}, + }, + { + "op": StringBatchOperations.REMOVE, + "path": StringBatchOperationsPath.STRING, + }, + ] + resource = self.get_resource(base_absolut_url) + assert ( + resource.string_batch_operation( + projectId=1, + data=data, + updateOption=StringUpdateOption.CLEAR_TRANSLATIONS_AND_APPROVALS, + ) + == "response" + ) + m_request.assert_called_once_with( + method="patch", + path=resource.get_source_strings_path(1), + params={"updateOption": StringUpdateOption.CLEAR_TRANSLATIONS_AND_APPROVALS}, + request_data=data, + ) + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"projectId": 1}, "projects/1/strings/uploads"), + ({"projectId": 1, "uploadId": "abc"}, "projects/1/strings/uploads/abc"), + ), + ) + def test_get_strings_uploads_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_strings_uploads_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {"storageId": 1, "branchId": 2}, + { + "storageId": 1, + "branchId": 2, + "type": None, + "parserVersion": None, + "labelIds": None, + "updateStrings": None, + "cleanupMode": None, + "importOptions": None, + "updateOption": None, + }, + ), + ( + { + "storageId": 1, + "branchId": 2, + "type": UploadStringsType.XLSX, + "parserVersion": 3, + "labelIds": [4, 5], + "updateStrings": True, + "cleanupMode": False, + "importOptions": { + "firstLineContainsHeader": True, + "scheme": {"identifier": 0, "sourcePhrase": 1}, + }, + "updateOption": StringUpdateOption.KEEP_TRANSLATIONS, + }, + { + "storageId": 1, + "branchId": 2, + "type": UploadStringsType.XLSX, + "parserVersion": 3, + "labelIds": [4, 5], + "updateStrings": True, + "cleanupMode": False, + "importOptions": { + "firstLineContainsHeader": True, + "scheme": {"identifier": 0, "sourcePhrase": 1}, + }, + "updateOption": StringUpdateOption.KEEP_TRANSLATIONS, + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_upload_strings(self, m_request, in_params, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.upload_strings(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/strings/uploads", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_upload_strings_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.upload_strings_status(projectId=1, uploadId="abc") == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/strings/uploads/abc", + ) + + +class TestEnterpriseSourceStringsResource(TestSourceFilesResource): + resource_class = EnterpriseSourceStringsResource + + @pytest.mark.parametrize( + "in_params, path", + ( + ({"projectId": 1}, "projects/1/strings/reviewed-builds"), + ({"projectId": 1, "buildId": 2}, "projects/1/strings/reviewed-builds/2"), + ), + ) + def test_get_reviewed_builds_path(self, in_params, path, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_reviewed_builds_path(**in_params) == path + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"branchId": None, "offset": 0, "limit": 25}), + ({"branchId": 2, "offset": 5, "limit": 10}, {"branchId": 2, "offset": 5, "limit": 10}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_reviewed_source_files_builds( + self, m_request, in_params, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_reviewed_source_files_builds(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/strings/reviewed-builds", + params=request_params, + ) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ({}, {"branchId": None}), + ({"branchId": 2}, {"branchId": 2}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_build_reviewed_source_files( + self, m_request, in_params, request_data, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.build_reviewed_source_files(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/strings/reviewed-builds", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_reviewed_source_files_build_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.check_reviewed_source_files_build_status(projectId=1, buildId=2) + == "response" + ) + m_request.assert_called_once_with( + method="get", + path="projects/1/strings/reviewed-builds/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_download_reviewed_source_files(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.download_reviewed_source_files(projectId=1, buildId=2) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/strings/reviewed-builds/2/download", + ) diff --git a/crowdin_api/api_resources/source_strings/types.py b/crowdin_api/api_resources/source_strings/types.py index 64a9d0f2..31805cee 100644 --- a/crowdin_api/api_resources/source_strings/types.py +++ b/crowdin_api/api_resources/source_strings/types.py @@ -19,3 +19,43 @@ class StringBatchOperationPatchRequest(TypedDict): op: StringBatchOperations path: StringBatchOperationsPath value: Union[str, dict, int, bool] + + +class UploadStringsSpreadsheetScheme(TypedDict, total=False): + """ + Column mapping (numbering starts at 0). Language identifiers (e.g. "en", "de") may also be + used as keys with a column number as a value. + """ + + none: int + identifier: int + sourcePhrase: int + sourceOrTranslation: int + translation: int + context: int + translatable: int + + +class UploadStringsSpreadsheetImportOptions(TypedDict, total=False): + firstLineContainsHeader: bool + importHiddenSheets: bool + contentSegmentation: bool + srxStorageId: int + scheme: UploadStringsSpreadsheetScheme + + +class UploadStringsStringCatalogImportOptions(TypedDict, total=False): + importKeyAsSource: bool + importTranslations: bool + + +class UploadStringsOtherImportOptions(TypedDict, total=False): + contentSegmentation: bool + srxStorageId: int + + +UploadStringsImportOptions = Union[ + UploadStringsSpreadsheetImportOptions, + UploadStringsStringCatalogImportOptions, + UploadStringsOtherImportOptions, +] diff --git a/crowdin_api/api_resources/storages/resource.py b/crowdin_api/api_resources/storages/resource.py index 363eec7e..2fc0a763 100644 --- a/crowdin_api/api_resources/storages/resource.py +++ b/crowdin_api/api_resources/storages/resource.py @@ -17,7 +17,7 @@ class StoragesResource(BaseResource): be removed from storage and will remain in your account. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Storage + https://support.crowdin.com/developer/api/v2/#tag/Storage """ def get_storages_path(self, storageId: Optional[int] = None): @@ -35,7 +35,7 @@ def list_storages( """List Storages. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.storages.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.storages.getMany """ return self._get_entire_data( @@ -48,7 +48,7 @@ def add_storage(self, file: IO): """Add Storage. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.storages.post + https://support.crowdin.com/developer/api/v2/#operation/api.storages.post """ return self.requester.request(method="post", path=self.get_storages_path(), file=file) @@ -57,7 +57,7 @@ def get_storage(self, storageId: int): """Get Storage. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.storages.get + https://support.crowdin.com/developer/api/v2/#operation/api.storages.get """ return self.requester.request( @@ -68,7 +68,7 @@ def delete_storage(self, storageId: int): """Delete Storage. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.storages.delete + https://support.crowdin.com/developer/api/v2/#operation/api.storages.delete """ return self.requester.request( diff --git a/crowdin_api/api_resources/string_comments/resource.py b/crowdin_api/api_resources/string_comments/resource.py index f5a702e1..7cdf4812 100644 --- a/crowdin_api/api_resources/string_comments/resource.py +++ b/crowdin_api/api_resources/string_comments/resource.py @@ -1,4 +1,4 @@ -from typing import Iterable, Optional +from typing import Dict, Iterable, Optional, Union from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.string_comments.enums import ( @@ -17,7 +17,7 @@ class StringCommentsResource(BaseResource): Use API to add or remove strings translations, approvals, and votes. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/String-Comments + https://support.crowdin.com/developer/api/v2/#tag/String-Comments """ def get_string_comments_path(self, projectId: int, stringCommentId: Optional[int] = None): @@ -37,18 +37,24 @@ def list_string_comments( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + fileId: Optional[int] = None, ): """ List String Comments. + :param stringId: Filter comments by string. Can't be used together with `fileId`. + :param fileId: Filter comments by asset file (file-based projects only). + Can't be used together with `stringId`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.comments.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.comments.getMany """ projectId = projectId or self.get_project_id() params = { "orderBy": orderBy, "stringId": stringId, + "fileId": fileId, "type": type, "issueType": None if issueType is None else ",".join(item.value for item in issueType), "issueStatus": issueStatus, @@ -64,18 +70,27 @@ def list_string_comments( def add_string_comment( self, text: str, - stringId: int, - targetLanguageId: str, - type: StringCommentType, + stringId: Optional[int] = None, + targetLanguageId: Optional[str] = None, + type: Optional[StringCommentType] = None, projectId: Optional[int] = None, issueType: Optional[StringCommentIssueType] = None, - attachments: Optional[Iterable[int]] = None, + attachments: Optional[Iterable[Union[int, Dict[str, int]]]] = None, + fileId: Optional[int] = None, + isShared: Optional[bool] = None, ): """ Add String Comment. + :param stringId: String Identifier. Required unless `fileId` is used. + :param type: Comment type (comment or issue). Required by the API. + :param attachments: Storage Identifiers (or {"id": storageId} objects) of attachments. + :param fileId: Asset File Identifier, used instead of `stringId` to comment + an asset (file-based projects only). + :param isShared: Defines shared comment or issue (Crowdin Enterprise only). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.comments.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.comments.post """ projectId = projectId or self.get_project_id() @@ -86,10 +101,16 @@ def add_string_comment( request_data={ "text": text, "stringId": stringId, + "fileId": fileId, "targetLanguageId": targetLanguageId, "type": type, + "isShared": isShared, "issueType": issueType, - "attachments": attachments, + "attachments": None + if attachments is None + else [ + item if isinstance(item, dict) else {"id": item} for item in attachments + ], }, ) @@ -98,7 +119,7 @@ def get_string_comment(self, stringCommentId: int, projectId: Optional[int] = No Get String Comment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.comments.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.comments.get """ projectId = projectId or self.get_project_id() @@ -117,7 +138,7 @@ def delete_string_comment( Delete String Comment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.comments.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.comments.delete """ projectId = projectId or self.get_project_id() @@ -136,7 +157,7 @@ def delete_string_comment_attachment( Delete String Comment Attachment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.comments.attachments.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.comments.attachments.delete """ projectId = projectId or self.get_project_id() @@ -159,7 +180,7 @@ def edit_string_comment( Edit String Comment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.comments.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.comments.patch """ projectId = projectId or self.get_project_id() diff --git a/crowdin_api/api_resources/string_comments/tests/test_string_comments_resources.py b/crowdin_api/api_resources/string_comments/tests/test_string_comments_resources.py index 20f3e7a1..e751f180 100644 --- a/crowdin_api/api_resources/string_comments/tests/test_string_comments_resources.py +++ b/crowdin_api/api_resources/string_comments/tests/test_string_comments_resources.py @@ -48,6 +48,20 @@ def test_get_string_comments_path(self, in_params, path, base_absolut_url): "offset": 0, "limit": 10, "stringId": None, + "fileId": None, + "type": None, + "issueType": None, + "issueStatus": None, + }, + ), + ( + {"fileId": 5}, + { + "orderBy": None, + "offset": 0, + "limit": 25, + "stringId": None, + "fileId": 5, "type": None, "issueType": None, "issueStatus": None, @@ -75,6 +89,7 @@ def test_get_string_comments_path(self, in_params, path, base_absolut_url): "offset": 0, "limit": 10, "stringId": 1, + "fileId": None, "type": StringCommentType.COMMENT, "issueType": "context_request,source_mistake", "issueStatus": StringCommentIssueStatus.UNRESOLVED, @@ -107,8 +122,10 @@ def test_list_string_comments(self, m_request, in_params, request_params, base_a { "text": "text", "stringId": 1, + "fileId": None, "targetLanguageId": "ua", "type": StringCommentType.COMMENT, + "isShared": None, "issueType": None, "attachments": None, }, @@ -125,10 +142,31 @@ def test_list_string_comments(self, m_request, in_params, request_params, base_a { "text": "text", "stringId": 1, + "fileId": None, "targetLanguageId": "ua", "type": StringCommentType.COMMENT, + "isShared": None, "issueType": StringCommentIssueType.CONTEXT_REQUEST, - "attachments": [1, 2, 3], + "attachments": [{"id": 1}, {"id": 2}, {"id": 3}], + }, + ), + ( + { + "text": "text", + "fileId": 7, + "type": StringCommentType.ISSUE, + "isShared": True, + "attachments": [{"id": 1}], + }, + { + "text": "text", + "stringId": None, + "fileId": 7, + "targetLanguageId": None, + "type": StringCommentType.ISSUE, + "isShared": True, + "issueType": None, + "attachments": [{"id": 1}], }, ), ), diff --git a/crowdin_api/api_resources/string_corrections/resource.py b/crowdin_api/api_resources/string_corrections/resource.py index 1e095b4f..63a32d9e 100644 --- a/crowdin_api/api_resources/string_corrections/resource.py +++ b/crowdin_api/api_resources/string_corrections/resource.py @@ -8,9 +8,10 @@ class StringCorrectionsResource(BaseResource): """ - Resource for String Corrections. + Resource for String Corrections (Crowdin Enterprise only). - Use API to add or remove strings translations, approvals, and votes. + Use API to list, add, get, restore, and delete source string corrections + proposed during proofreading. Link to documentation: https://support.crowdin.com/developer/enterprise/api/v2/#tag/String-Corrections @@ -46,10 +47,10 @@ def list_corrections( "denormalizePlaceholders": convert_enum_to_string_if_exists(denormalize_placeholders) } - return self.requester.request( + return self._get_entire_data( method="get", path=self.get_string_corrections_path(project_id), - params=params + params=params, ) def add_correction( diff --git a/crowdin_api/api_resources/string_corrections/tests/test_string_corrections_resource.py b/crowdin_api/api_resources/string_corrections/tests/test_string_corrections_resource.py index 1b90c651..e0e4423f 100644 --- a/crowdin_api/api_resources/string_corrections/tests/test_string_corrections_resource.py +++ b/crowdin_api/api_resources/string_corrections/tests/test_string_corrections_resource.py @@ -74,6 +74,26 @@ def test_list_corrections(self, m_request, in_params, request_params, base_absol params=request_params ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_corrections_fetch_all(self, m_request, base_absolut_url): + m_request.return_value = {"data": [{"data": {"id": 1}}]} + + resource = self.get_resource(base_absolut_url) + assert resource.with_fetch_all().list_corrections(1, string_id=2) == { + "data": [{"data": {"id": 1}}] + } + m_request.assert_called_once_with( + method="get", + path="projects/1/corrections", + params={ + "stringId": 2, + "limit": 500, + "offset": 0, + "orderBy": None, + "denormalizePlaceholders": None, + }, + ) + @pytest.mark.parametrize( "in_params, request_params", ( diff --git a/crowdin_api/api_resources/string_translations/enums.py b/crowdin_api/api_resources/string_translations/enums.py index 2e63e0cd..d52f45f5 100644 --- a/crowdin_api/api_resources/string_translations/enums.py +++ b/crowdin_api/api_resources/string_translations/enums.py @@ -28,3 +28,17 @@ class ListStringTranslationsOrderBy(Enum): TEXT = "text" RATING = "rating" CREATED_AT = "createdAt" + + +class TranslationProvider(Enum): + TM = "tm" + GLOBAL_TM = "global_tm" + GOOGLE = "google" + MICROSOFT = "microsoft" + CROWDIN = "crowdin" + DEEPL = "deepl" + AMAZON = "amazon" + GOOGLE_AUTOML = "google_automl" + MODERNMT = "modernmt" + CUSTOM_MT = "custom_mt" + AI = "ai" diff --git a/crowdin_api/api_resources/string_translations/resource.py b/crowdin_api/api_resources/string_translations/resource.py index fbeef0c1..a97cc164 100644 --- a/crowdin_api/api_resources/string_translations/resource.py +++ b/crowdin_api/api_resources/string_translations/resource.py @@ -1,8 +1,8 @@ -from typing import Iterable, Optional +from typing import Iterable, Optional, Union from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.enums import DenormalizePlaceholders, PluralCategoryName -from crowdin_api.api_resources.string_translations.enums import VoteMark +from crowdin_api.api_resources.string_translations.enums import TranslationProvider, VoteMark from crowdin_api.api_resources.string_translations.types import ( ApprovalBatchOpPatchRequest, TranslationBatchOpPatchRequest @@ -17,7 +17,7 @@ class StringTranslationsResource(BaseResource): Use API to add or remove strings translations, approvals, and votes. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/String-Translations + https://support.crowdin.com/developer/api/v2/#tag/String-Translations """ def search_translations( @@ -35,8 +35,8 @@ def search_translations( Search Translations. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.translations.getMany - https://developer.crowdin.com/enterprise/api/v2/#operation/api.translations.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.translations.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.translations.getMany """ params = { @@ -74,12 +74,18 @@ def list_translation_approvals( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + correctionId: Optional[int] = None, ): """ List Translation Approvals + `fileId` is available in file-based projects only, `correctionId` in Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.approvals.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.approvals.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.approvals.getMany """ projectId = projectId or self.get_project_id() @@ -91,6 +97,7 @@ def list_translation_approvals( "stringId": stringId, "languageId": languageId, "translationId": translationId, + "correctionId": correctionId, } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) @@ -102,14 +109,20 @@ def list_translation_approvals( def add_approval( self, - translationId: int, + translationId: Optional[int] = None, projectId: Optional[int] = None, + correctionId: Optional[int] = None, ): """ Add Approval. + Pass either `translationId` or `correctionId` (Crowdin Enterprise only). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.approvals.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.approvals.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.approvals.post """ projectId = projectId or self.get_project_id() @@ -117,20 +130,28 @@ def add_approval( return self.requester.request( method="post", path=self.get_approvals_path(projectId=projectId), - request_data={"translationId": translationId}, + request_data={"translationId": translationId, "correctionId": correctionId}, ) - def remove_string_approvals(self, stringId: int, projectId: Optional[int] = None): + def remove_string_approvals( + self, + stringId: Optional[int] = None, + projectId: Optional[int] = None, + fileId: Optional[int] = None, + ): """ Remove String Approvals - Link to documentaion: + Remove approvals of a string (`stringId`) or of all strings in a file + (`fileId`, file-based projects only). `stringId` is required in string-based projects. + + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/String-Translations/operation/api.projects.approvals.deleteMany """ projectId = projectId or self.get_project_id() - params = {"stringId": stringId} + params = {"stringId": stringId, "fileId": fileId} return self.requester.request( method="delete", path=self.get_approvals_path(projectId=projectId), params=params @@ -141,7 +162,7 @@ def get_approval(self, approvalId: int, projectId: Optional[int] = None): Get Approval. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.approvals.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.approvals.get """ projectId = projectId or self.get_project_id() @@ -153,10 +174,10 @@ def get_approval(self, approvalId: int, projectId: Optional[int] = None): def remove_approval(self, approvalId: int, projectId: Optional[int] = None): """ - Remove Approvall. + Remove Approval. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.approvals.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.approvals.delete """ projectId = projectId or self.get_project_id() @@ -182,12 +203,22 @@ def list_language_translations( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + approvedOnly: Optional[Union[bool, int]] = None, + passedWorkflow: Optional[Union[bool, int]] = None, + minApprovalCount: Optional[int] = None, ): """ List Language Translations + `fileId` and `directoryId` are available in file-based projects only. + `orderBy` and `approvedOnly` are available in Crowdin only; + `passedWorkflow` and `minApprovalCount` in Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.languages.translations.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.languages.translations.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.languages.translations.getMany """ projectId = projectId or self.get_project_id() @@ -204,6 +235,9 @@ def list_language_translations( "directoryId": directoryId, "croql": croql, "denormalizePlaceholders": denormalizePlaceholders, + "approvedOnly": None if approvedOnly is None else int(approvedOnly), + "passedWorkflow": None if passedWorkflow is None else int(passedWorkflow), + "minApprovalCount": minApprovalCount, } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) @@ -224,7 +258,7 @@ def translation_alignment( Translation Alignment Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.alignment.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.alignment.post """ projectId = projectId or self.get_project_id() @@ -257,12 +291,15 @@ def list_string_translations( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + fileId: Optional[int] = None, ): """ List String Translations + `languageId` is required by the API. `fileId` is available in file-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.getMany """ projectId = projectId or self.get_project_id() @@ -271,6 +308,7 @@ def list_string_translations( "stringId": stringId, "languageId": languageId, "denormalizePlaceholders": denormalizePlaceholders, + "fileId": fileId, } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) @@ -288,12 +326,17 @@ def add_translation( projectId: Optional[int] = None, pluralCategoryName: Optional[PluralCategoryName] = None, addToTm: Optional[bool] = None, + provider: Optional[TranslationProvider] = None, + providerId: Optional[int] = None, + isPreTranslated: Optional[bool] = None, ): """ Add Translation. + `provider` is required when `providerId` or `isPreTranslated` is specified. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.post """ projectId = projectId or self.get_project_id() @@ -306,36 +349,78 @@ def add_translation( "text": text, "pluralCategoryName": pluralCategoryName, "addToTm": addToTm, + "provider": provider, + "providerId": providerId, + "isPreTranslated": isPreTranslated, + }, + ) + + def add_file_translations( + self, + fileId: int, + languageId: str, + storageId: int, + projectId: Optional[int] = None, + ): + """ + Add Translation (upload translations of a file from storage). + + File-based projects only. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.translations.post + """ + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=self.get_translations_path(projectId=projectId), + request_data={ + "fileId": fileId, + "languageId": languageId, + "storageId": storageId, }, ) def delete_string_translations( self, - stringId: int, + stringId: Optional[int] = None, languageId: Optional[str] = None, projectId: Optional[int] = None, + fileId: Optional[int] = None, ): """ Delete String Translations. + Delete translations of a string (`stringId`) or of all strings in a file + (`fileId`, file-based projects only). `stringId` is required in string-based projects. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.deleteMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.deleteMany """ projectId = projectId or self.get_project_id() return self.requester.request( method="delete", - params={"stringId": stringId, "languageId": languageId}, + params={"stringId": stringId, "languageId": languageId, "fileId": fileId}, path=self.get_translations_path(projectId=projectId), ) - def get_translation(self, translationId: int, projectId: Optional[int] = None): + def get_translation( + self, + translationId: int, + projectId: Optional[int] = None, + denormalizePlaceholders: Optional[DenormalizePlaceholders] = None, + ): """ Get Translation. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.get """ projectId = projectId or self.get_project_id() @@ -343,6 +428,7 @@ def get_translation(self, translationId: int, projectId: Optional[int] = None): return self.requester.request( method="get", path=self.get_translations_path(projectId=projectId, translationId=translationId), + params={"denormalizePlaceholders": denormalizePlaceholders}, ) def restore_translation(self, translationId: int, projectId: Optional[int] = None): @@ -350,7 +436,7 @@ def restore_translation(self, translationId: int, projectId: Optional[int] = Non Restore Translation. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.put + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.put """ projectId = projectId or self.get_project_id() @@ -365,7 +451,7 @@ def delete_translation(self, translationId: int, projectId: Optional[int] = None Delete Translation. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.delete """ projectId = projectId or self.get_project_id() @@ -398,8 +484,10 @@ def list_translation_votes( """ List Translation Votes + `fileId` is available in file-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.votes.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.votes.getMany """ projectId = projectId or self.get_project_id() @@ -427,7 +515,7 @@ def add_vote( Add Vote. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.votes.pos + https://support.crowdin.com/developer/api/v2/#operation/api.projects.votes.post """ projectId = projectId or self.get_project_id() @@ -443,7 +531,7 @@ def get_vote(self, voteId: int, projectId: Optional[int] = None): Get Vote. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.votes.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.votes.get """ projectId = projectId or self.get_project_id() @@ -458,7 +546,7 @@ def cancel_vote(self, voteId: int, projectId: Optional[int] = None): Cancel Vote. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.votes.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.votes.delete """ projectId = projectId or self.get_project_id() diff --git a/crowdin_api/api_resources/string_translations/tests/test_string_translations_resources.py b/crowdin_api/api_resources/string_translations/tests/test_string_translations_resources.py index d4cd36ed..76405b2f 100644 --- a/crowdin_api/api_resources/string_translations/tests/test_string_translations_resources.py +++ b/crowdin_api/api_resources/string_translations/tests/test_string_translations_resources.py @@ -6,6 +6,7 @@ ListLanguageTranslationsOrderBy, ListStringTranslationsOrderBy, ListTranslationApprovalsOrderBy, + TranslationProvider, VoteMark, ) from crowdin_api.api_resources.string_translations.resource import StringTranslationsResource @@ -103,6 +104,7 @@ def test_get_approvals_path(self, in_params, path, base_absolut_url): "stringId": None, "languageId": None, "translationId": None, + "correctionId": None, }, ), ( @@ -139,6 +141,22 @@ def test_get_approvals_path(self, in_params, path, base_absolut_url): "stringId": 2, "languageId": "ua", "translationId": 3, + "correctionId": None, + }, + ), + ( + {"offset": 0, "limit": 10, "correctionId": 4}, + { + "orderBy": None, + "offset": 0, + "limit": 10, + "fileId": None, + "labelIds": None, + "excludeLabelIds": None, + "stringId": None, + "languageId": None, + "translationId": None, + "correctionId": 4, }, ), ), @@ -166,7 +184,19 @@ def test_add_approval(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="post", path=resource.get_approvals_path(projectId=1), - request_data={"translationId": 2}, + request_data={"translationId": 2, "correctionId": None}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_approval_by_correction(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.add_approval(projectId=1, correctionId=3) == "response" + m_request.assert_called_once_with( + method="post", + path=resource.get_approvals_path(projectId=1), + request_data={"translationId": None, "correctionId": 3}, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -183,7 +213,19 @@ def test_remove_string_approvals(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="delete", path=resource.get_approvals_path(projectId=projectId), - params={"stringId": stringId}, + params={"stringId": stringId, "fileId": None}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_remove_string_approvals_by_file(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.remove_string_approvals(projectId=2, fileId=5) == "response" + m_request.assert_called_once_with( + method="delete", + path=resource.get_approvals_path(projectId=2), + params={"stringId": None, "fileId": 5}, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -223,6 +265,9 @@ def test_remove_approval(self, m_request, base_absolut_url): "directoryId": None, "croql": None, "denormalizePlaceholders": None, + "approvedOnly": None, + "passedWorkflow": None, + "minApprovalCount": None, }, ), ( @@ -263,6 +308,45 @@ def test_remove_approval(self, m_request, base_absolut_url): "directoryId": 7, "croql": "croql", "denormalizePlaceholders": DenormalizePlaceholders.ENABLE, + "approvedOnly": None, + "passedWorkflow": None, + "minApprovalCount": None, + }, + ), + ( + {"offset": 0, "limit": 10, "approvedOnly": True}, + { + "orderBy": None, + "offset": 0, + "limit": 10, + "stringIds": None, + "labelIds": None, + "fileId": None, + "branchId": None, + "directoryId": None, + "croql": None, + "denormalizePlaceholders": None, + "approvedOnly": 1, + "passedWorkflow": None, + "minApprovalCount": None, + }, + ), + ( + {"offset": 0, "limit": 10, "passedWorkflow": False, "minApprovalCount": 2}, + { + "orderBy": None, + "offset": 0, + "limit": 10, + "stringIds": None, + "labelIds": None, + "fileId": None, + "branchId": None, + "directoryId": None, + "croql": None, + "denormalizePlaceholders": None, + "approvedOnly": None, + "passedWorkflow": 0, + "minApprovalCount": 2, }, ), ), @@ -327,6 +411,7 @@ def test_get_translations_path(self, in_params, path, base_absolut_url): "stringId": None, "languageId": None, "denormalizePlaceholders": None, + "fileId": None, }, ), ( @@ -359,6 +444,19 @@ def test_get_translations_path(self, in_params, path, base_absolut_url): "stringId": 1, "languageId": 2, "denormalizePlaceholders": DenormalizePlaceholders.ENABLE, + "fileId": None, + }, + ), + ( + {"offset": 0, "limit": 10, "languageId": "uk", "fileId": 3}, + { + "orderBy": None, + "offset": 0, + "limit": 10, + "stringId": None, + "languageId": "uk", + "denormalizePlaceholders": None, + "fileId": 3, }, ), ), @@ -390,6 +488,9 @@ def test_list_string_translations(self, m_request, in_params, request_params, ba "text": "text", "pluralCategoryName": None, "addToTm": None, + "provider": None, + "providerId": None, + "isPreTranslated": None, }, ), ( @@ -405,6 +506,30 @@ def test_list_string_translations(self, m_request, in_params, request_params, ba "text": "text", "pluralCategoryName": "some name", "addToTm": None, + "provider": None, + "providerId": None, + "isPreTranslated": None, + }, + ), + ( + { + "stringId": 1, + "languageId": "ua", + "text": "text", + "addToTm": False, + "provider": TranslationProvider.AI, + "providerId": 5, + "isPreTranslated": True, + }, + { + "stringId": 1, + "languageId": "ua", + "text": "text", + "pluralCategoryName": None, + "addToTm": False, + "provider": TranslationProvider.AI, + "providerId": 5, + "isPreTranslated": True, }, ), ), @@ -432,10 +557,37 @@ def test_delete_string_translations(self, m_request, base_absolut_url): ) m_request.assert_called_once_with( method="delete", - params={"stringId": 2, "languageId": "ua"}, + params={"stringId": 2, "languageId": "ua", "fileId": None}, path=resource.get_translations_path(projectId=1), ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_string_translations_by_file(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_string_translations(projectId=1, fileId=3) == "response" + m_request.assert_called_once_with( + method="delete", + params={"stringId": None, "languageId": None, "fileId": 3}, + path=resource.get_translations_path(projectId=1), + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_file_translations(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_file_translations(projectId=1, fileId=2, languageId="uk", storageId=3) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path=resource.get_translations_path(projectId=1), + request_data={"fileId": 2, "languageId": "uk", "storageId": 3}, + ) + @mock.patch("crowdin_api.requester.APIRequester.request") def test_get_translation(self, m_request, base_absolut_url): m_request.return_value = "response" @@ -445,6 +597,26 @@ def test_get_translation(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="get", path=resource.get_translations_path(projectId=1, translationId=2), + params={"denormalizePlaceholders": None}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_translation_denormalize_placeholders(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.get_translation( + projectId=1, + translationId=2, + denormalizePlaceholders=DenormalizePlaceholders.ENABLE, + ) + == "response" + ) + m_request.assert_called_once_with( + method="get", + path=resource.get_translations_path(projectId=1, translationId=2), + params={"denormalizePlaceholders": DenormalizePlaceholders.ENABLE}, ) @mock.patch("crowdin_api.requester.APIRequester.request") diff --git a/crowdin_api/api_resources/string_translations/types.py b/crowdin_api/api_resources/string_translations/types.py index 52bc384f..e55033f9 100644 --- a/crowdin_api/api_resources/string_translations/types.py +++ b/crowdin_api/api_resources/string_translations/types.py @@ -1,4 +1,6 @@ -from typing import TypedDict, Any +from typing import Any + +from crowdin_api.typing import TypedDict from crowdin_api.api_resources.enums import PatchOperation diff --git a/crowdin_api/api_resources/style_guides/enums.py b/crowdin_api/api_resources/style_guides/enums.py index a789c7bf..609304c6 100644 --- a/crowdin_api/api_resources/style_guides/enums.py +++ b/crowdin_api/api_resources/style_guides/enums.py @@ -15,3 +15,4 @@ class StyleGuidePatchPath(Enum): PROJECT_IDS = "/projectIds" IS_SHARED = "/isShared" STORAGE_ID = "/storageId" + GROUP_ID = "/groupId" diff --git a/crowdin_api/api_resources/style_guides/resource.py b/crowdin_api/api_resources/style_guides/resource.py index 5ff7ba01..c7da719f 100644 --- a/crowdin_api/api_resources/style_guides/resource.py +++ b/crowdin_api/api_resources/style_guides/resource.py @@ -50,6 +50,8 @@ def add_style_guide(self, request_data: AddStyleGuideRequest): """ Create Style Guide + Note: `groupId` in request data is supported by Crowdin Enterprise only. + Link to documentation: https://support.crowdin.com/developer/api/v2/#operation/api.style-guides.post """ @@ -91,6 +93,8 @@ def edit_style_guide( """ Edit Style Guide + Note: the `/groupId` patch path is supported by Crowdin Enterprise only. + Link to documentation: https://support.crowdin.com/developer/api/v2/#operation/api.style-guides.patch """ diff --git a/crowdin_api/api_resources/style_guides/tests/test_style_guides_resources.py b/crowdin_api/api_resources/style_guides/tests/test_style_guides_resources.py index d88ac603..26d6db0b 100644 --- a/crowdin_api/api_resources/style_guides/tests/test_style_guides_resources.py +++ b/crowdin_api/api_resources/style_guides/tests/test_style_guides_resources.py @@ -95,6 +95,7 @@ def test_list_style_guides(self, m_request, incoming_data, request_params, base_ languageIds=["uk", "fr", "de"], projectIds=[1, 2, 3], isShared=False, + groupId=2, ), { "name": "Be My Eyes iOS's Style Guide", @@ -103,6 +104,7 @@ def test_list_style_guides(self, m_request, incoming_data, request_params, base_ "languageIds": ["uk", "fr", "de"], "projectIds": [1, 2, 3], "isShared": False, + "groupId": 2, }, ), ), @@ -153,7 +155,12 @@ def test_edit_style_guide(self, m_request, base_absolut_url): "op": PatchOperation.REPLACE, "path": StyleGuidePatchPath.NAME, "value": "Be My Eyes iOS's Style Guide", - } + }, + { + "op": PatchOperation.REPLACE, + "path": StyleGuidePatchPath.GROUP_ID, + "value": 2, + }, ] resource = self.get_resource(base_absolut_url) diff --git a/crowdin_api/api_resources/style_guides/types.py b/crowdin_api/api_resources/style_guides/types.py index a3078ef6..036038d0 100644 --- a/crowdin_api/api_resources/style_guides/types.py +++ b/crowdin_api/api_resources/style_guides/types.py @@ -12,6 +12,7 @@ class AddStyleGuideRequest(TypedDict): languageIds: Optional[Iterable[str]] projectIds: Optional[Iterable[int]] isShared: Optional[bool] + groupId: Optional[int] # Enterprise only class StyleGuidePatchRequest(TypedDict): diff --git a/crowdin_api/api_resources/system_placeholders/__init__.py b/crowdin_api/api_resources/system_placeholders/__init__.py new file mode 100644 index 00000000..f5406199 --- /dev/null +++ b/crowdin_api/api_resources/system_placeholders/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {"tests": False} diff --git a/crowdin_api/api_resources/system_placeholders/enums.py b/crowdin_api/api_resources/system_placeholders/enums.py new file mode 100644 index 00000000..3d06efa7 --- /dev/null +++ b/crowdin_api/api_resources/system_placeholders/enums.py @@ -0,0 +1,37 @@ +from enum import Enum + + +class SystemPlaceholderPatchPath(Enum): + """ + The first segment is the key of the system placeholder, and `/isEnabled` is the only property + it accepts. + """ + + WRAPPED_AMPERSAND = "/wrappedAmpersand/isEnabled" + APPLE_STRINGSDICT_PLURAL = "/appleStringsdictPlural/isEnabled" + DOLLAR_INSIDE_BRACES = "/dollarInsideBraces/isEnabled" + DOLLAR_OUTSIDE_BRACES = "/dollarOutsideBraces/isEnabled" + WRAPPED_COLON = "/wrappedColon/isEnabled" + WRAPPED_DOLLAR = "/wrappedDollar/isEnabled" + DOLLAR_PARENTHESES = "/dollarParentheses/isEnabled" + I18NEXT_NESTING = "/i18nextNesting/isEnabled" + I18NEXT_LEGACY = "/i18nextLegacy/isEnabled" + RUBY_INTERPOLATION = "/rubyInterpolation/isEnabled" + MAILCHIMP_MERGE_TAG = "/mailchimpMergeTag/isEnabled" + SWIFT_INTERPOLATION = "/swiftInterpolation/isEnabled" + BRACES_TRIPLE = "/bracesTriple/isEnabled" + BRACES_DOUBLE = "/bracesDouble/isEnabled" + BRACES_SINGLE = "/bracesSingle/isEnabled" + BRACES_DOUBLE_FORMATTED = "/bracesDoubleFormatted/isEnabled" + DATE_TIME_PATTERN = "/dateTimePattern/isEnabled" + APPLE_STRING_CATALOG_NAMED = "/appleStringCatalogNamed/isEnabled" + PRINTF_SPECIFIER = "/printfSpecifier/isEnabled" + PYTHON_PERCENT_FORMAT = "/pythonPercentFormat/isEnabled" + RAILS_I18N = "/railsI18n/isEnabled" + JAVA_MESSAGE_FORMAT = "/javaMessageFormat/isEnabled" + DOT_NET_COMPOSITE_FORMAT = "/dotNetCompositeFormat/isEnabled" + TWIG = "/twig/isEnabled" + PHP_INTERPOLATION = "/phpInterpolation/isEnabled" + FREEMARKER_DIRECTIVE = "/freemarkerDirective/isEnabled" + WRAPPED_PERCENT = "/wrappedPercent/isEnabled" + DATE_TIME_SPECIFIER = "/dateTimeSpecifier/isEnabled" diff --git a/crowdin_api/api_resources/system_placeholders/resource.py b/crowdin_api/api_resources/system_placeholders/resource.py new file mode 100644 index 00000000..7827fb2a --- /dev/null +++ b/crowdin_api/api_resources/system_placeholders/resource.py @@ -0,0 +1,59 @@ +from typing import Iterable, Optional + +from crowdin_api.api_resources.abstract.resources import BaseResource +from crowdin_api.api_resources.system_placeholders.types import SystemPlaceholderPatchRequest + + +class SystemPlaceholdersResource(BaseResource): + """ + Resource for Project System Placeholders. + + System placeholders are the placeholders Crowdin ships. Each project can turn them on and off. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#tag/Placeholders + """ + + def get_system_placeholders_path(self, projectId: int): + return f"projects/{projectId}/system-placeholders" + + def list_system_placeholders( + self, + projectId: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List Project System Placeholders. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.system-placeholders.getMany + """ + + projectId = projectId or self.get_project_id() + + return self._get_entire_data( + method="get", + path=self.get_system_placeholders_path(projectId=projectId), + params=self.get_page_params(limit=limit, offset=offset), + ) + + def system_placeholders_batch_operations( + self, + data: Iterable[SystemPlaceholderPatchRequest], + projectId: Optional[int] = None, + ): + """ + Batch Edit Project System Placeholders. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.system-placeholders.batchPatch + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="patch", + path=self.get_system_placeholders_path(projectId=projectId), + request_data=data, + ) diff --git a/crowdin_api/api_resources/system_placeholders/tests/test_system_placeholders_resources.py b/crowdin_api/api_resources/system_placeholders/tests/test_system_placeholders_resources.py new file mode 100644 index 00000000..3da0ea44 --- /dev/null +++ b/crowdin_api/api_resources/system_placeholders/tests/test_system_placeholders_resources.py @@ -0,0 +1,78 @@ +from unittest import mock + +import pytest +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.system_placeholders.enums import SystemPlaceholderPatchPath +from crowdin_api.api_resources.system_placeholders.resource import SystemPlaceholdersResource +from crowdin_api.requester import APIRequester + + +class TestSystemPlaceholdersResource: + resource_class = SystemPlaceholdersResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + def test_get_system_placeholders_path(self, base_absolut_url): + resource = self.get_resource(base_absolut_url) + assert resource.get_system_placeholders_path(projectId=1) == "projects/1/system-placeholders" + + @pytest.mark.parametrize( + "in_params, request_params", + ( + ({}, {"limit": 25, "offset": 0}), + ({"limit": 10, "offset": 2}, {"limit": 10, "offset": 2}), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_system_placeholders( + self, m_request, in_params, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_system_placeholders(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/system-placeholders", + params=request_params, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_system_placeholders_with_project_id(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.resource_class( + requester=APIRequester(base_url=base_absolut_url), project_id=3 + ) + assert resource.list_system_placeholders() == "response" + m_request.assert_called_once_with( + method="get", + path="projects/3/system-placeholders", + params={"limit": 25, "offset": 0}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_system_placeholders_batch_operations(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": SystemPlaceholderPatchPath.WRAPPED_AMPERSAND, + "value": False, + }, + { + "op": PatchOperation.TEST, + "path": SystemPlaceholderPatchPath.PRINTF_SPECIFIER, + "value": True, + }, + ] + + resource = self.get_resource(base_absolut_url) + assert resource.system_placeholders_batch_operations(projectId=1, data=data) == "response" + m_request.assert_called_once_with( + method="patch", + path="projects/1/system-placeholders", + request_data=data, + ) diff --git a/crowdin_api/api_resources/system_placeholders/types.py b/crowdin_api/api_resources/system_placeholders/types.py new file mode 100644 index 00000000..c86488ec --- /dev/null +++ b/crowdin_api/api_resources/system_placeholders/types.py @@ -0,0 +1,13 @@ +from crowdin_api.api_resources.enums import PatchOperation +from crowdin_api.api_resources.system_placeholders.enums import SystemPlaceholderPatchPath +from crowdin_api.typing import TypedDict + + +class SystemPlaceholderPatchRequest(TypedDict): + """ + `op` accepts `replace` or `test` only. + """ + + op: PatchOperation + path: SystemPlaceholderPatchPath + value: bool diff --git a/crowdin_api/api_resources/tasks/enums.py b/crowdin_api/api_resources/tasks/enums.py index dbf8f6e2..71cacb31 100644 --- a/crowdin_api/api_resources/tasks/enums.py +++ b/crowdin_api/api_resources/tasks/enums.py @@ -2,18 +2,40 @@ class TaskOperationPatchPath(Enum): + """ + Patch paths for Crowdin tasks. + + `FILE_IDS`, `DIRECTORY_IDS` and `SPLIT_FILES` are file-based projects only. + `SPLIT_FILES` is deprecated, use `SPLIT_CONTENT` instead. + """ + STATUS = "/status" TITLE = "/title" DESCRIPTION = "/description" DEADLINE = "/deadline" + STARTED_AT = "/startedAt" + RESOLVED_AT = "/resolvedAt" SPLIT_FILES = "/splitFiles" + SPLIT_CONTENT = "/splitContent" FILE_IDS = "/fileIds" + DIRECTORY_IDS = "/directoryIds" + BRANCH_IDS = "/branchIds" + STRING_IDS = "/stringIds" ASSIGNEES = "/assignees" DATE_FROM = "/dateFrom" DATE_TO = "/dateTo" + TRANSLATIONS_UPDATED_DATE_FROM = "/translationsUpdatedDateFrom" + TRANSLATIONS_UPDATED_DATE_TO = "/translationsUpdatedDateTo" LABEL_IDS = "/labelIds" - BATCH_ID = "/batchId" + LABEL_MATCH_RULE = "/labelMatchRule" + EXCLUDE_LABEL_IDS = "/excludeLabelIds" + EXCLUDE_LABEL_MATCH_RULE = "/excludeLabelMatchRule" + SKIP_ASSIGNED_STRINGS = "/skipAssignedStrings" RESET_SCOPE = "/resetScope" + GENERATE_COST_ESTIMATE = "/generateCostEstimate" + GENERATE_TRANSLATION_COST = "/generateTranslationCost" + REPORT_SETTINGS_TEMPLATE_ID = "/reportSettingsTemplateId" + BATCH_ID = "/batchId" class VendorTaskOperationPatchPath(Enum): @@ -22,6 +44,126 @@ class VendorTaskOperationPatchPath(Enum): status = "/status" +class PendingTaskOperationPatchPath(Enum): + TITLE = "/title" + DESCRIPTION = "/description" + ASSIGNEES = "/assignees" + DEADLINE = "/deadline" + + +class VendorPendingTaskOperationPatchPath(Enum): + TITLE = "/title" + DESCRIPTION = "/description" + + +class EnterpriseTaskOperationPatchPath(Enum): + """ + Patch paths for Crowdin Enterprise tasks. + + `FILE_IDS`, `DIRECTORY_IDS` and `SPLIT_FILES` are file-based projects only. + `SPLIT_FILES` is deprecated, use `SPLIT_CONTENT` instead. + To edit a single field value use the `/fields/{fieldSlug}` path as a plain string. + """ + + STATUS = "/status" + TITLE = "/title" + DESCRIPTION = "/description" + DEADLINE = "/deadline" + STARTED_AT = "/startedAt" + RESOLVED_AT = "/resolvedAt" + SPLIT_FILES = "/splitFiles" + SPLIT_CONTENT = "/splitContent" + FILE_IDS = "/fileIds" + DIRECTORY_IDS = "/directoryIds" + BRANCH_IDS = "/branchIds" + STRING_IDS = "/stringIds" + ASSIGNEES = "/assignees" + ASSIGNED_TEAMS = "/assignedTeams" + DATE_FROM = "/dateFrom" + DATE_TO = "/dateTo" + TRANSLATIONS_UPDATED_DATE_FROM = "/translationsUpdatedDateFrom" + TRANSLATIONS_UPDATED_DATE_TO = "/translationsUpdatedDateTo" + LABEL_IDS = "/labelIds" + LABEL_MATCH_RULE = "/labelMatchRule" + EXCLUDE_LABEL_IDS = "/excludeLabelIds" + EXCLUDE_LABEL_MATCH_RULE = "/excludeLabelMatchRule" + SKIP_ASSIGNED_STRINGS = "/skipAssignedStrings" + SKIP_ASSIGNED_STRINGS_SCOPE = "/skipAssignedStringsScope" + FIELDS = "/fields" + RESET_SCOPE = "/resetScope" + GENERATE_COST_ESTIMATE = "/generateCostEstimate" + GENERATE_TRANSLATION_COST = "/generateTranslationCost" + REPORT_SETTINGS_TEMPLATE_ID = "/reportSettingsTemplateId" + BATCH_ID = "/batchId" + + +class EnterpriseVendorTaskOperationPatchPath(Enum): + """ + Patch paths for Crowdin Enterprise vendor tasks. + + `FILE_IDS` is file-based projects only. + """ + + TITLE = "/title" + DESCRIPTION = "/description" + FILE_IDS = "/fileIds" + STRING_IDS = "/stringIds" + DATE_FROM = "/dateFrom" + DATE_TO = "/dateTo" + TRANSLATIONS_UPDATED_DATE_FROM = "/translationsUpdatedDateFrom" + TRANSLATIONS_UPDATED_DATE_TO = "/translationsUpdatedDateTo" + DEADLINE = "/deadline" + STARTED_AT = "/startedAt" + RESOLVED_AT = "/resolvedAt" + LABEL_IDS = "/labelIds" + LABEL_MATCH_RULE = "/labelMatchRule" + EXCLUDE_LABEL_IDS = "/excludeLabelIds" + EXCLUDE_LABEL_MATCH_RULE = "/excludeLabelMatchRule" + GENERATE_COST_ESTIMATE = "/generateCostEstimate" + GENERATE_TRANSLATION_COST = "/generateTranslationCost" + REPORT_SETTINGS_TEMPLATE_ID = "/reportSettingsTemplateId" + + +class EnterpriseInterOrganizationalTaskOperationPatchPath(Enum): + """ + Patch paths for Crowdin Enterprise inter-organizational tasks. + + `SPLIT_FILES` is file-based projects only and deprecated, use `SPLIT_CONTENT` instead. + """ + + ASSIGNEE = "/assignee" + ASSIGNED_TEAMS = "/assignedTeams" + SPLIT_FILES = "/splitFiles" + SPLIT_CONTENT = "/splitContent" + STATUS = "/status" + GENERATE_COST_ESTIMATE = "/generateCostEstimate" + GENERATE_TRANSLATION_COST = "/generateTranslationCost" + REPORT_SETTINGS_TEMPLATE_ID = "/reportSettingsTemplateId" + + +class EnterprisePendingTaskOperationPatchPath(Enum): + TITLE = "/title" + DESCRIPTION = "/description" + ASSIGNEES = "/assignees" + ASSIGNED_TEAMS = "/assignedTeams" + DEADLINE = "/deadline" + + +class TaskCommentPatchPath(Enum): + TEXT = "/text" + TIME_SPENT = "/timeSpent" + + +class TaskLabelMatchRule(Enum): + ALL = "all" + ANY = "any" + + +class TaskSkipAssignedStringsScope(Enum): + ALL = "all" + SAME_WORKFLOW_STEP = "sameWorkflowStep" + + class ConfigTaskOperationPatchPath(Enum): NAME = "/name" CONFIG = "/config" diff --git a/crowdin_api/api_resources/tasks/resource.py b/crowdin_api/api_resources/tasks/resource.py index 49c2c3ce..60cb117e 100644 --- a/crowdin_api/api_resources/tasks/resource.py +++ b/crowdin_api/api_resources/tasks/resource.py @@ -1,22 +1,31 @@ from datetime import datetime -from typing import Dict, Iterable, Optional, Union +from typing import Any, Dict, Iterable, Optional, Union from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.tasks.enums import ( CrowdinGeneralTaskType, CrowdinTaskStatus, CrowdinTaskType, + TaskLabelMatchRule, + TaskSkipAssignedStringsScope, ) from crowdin_api.api_resources.tasks.types import ( CrowdinTaskAssignee, EnterpriseTaskAssignedTeams, TaskPatchRequest, VendorPatchRequest, + PendingTaskPatchRequest, + VendorPendingTaskPatchRequest, + EnterpriseTaskPatchRequest, + EnterpriseVendorTaskPatchRequest, + EnterpriseInterOrganizationalTaskPatchRequest, + EnterprisePendingTaskPatchRequest, ConfigPatchRequest, EnterpriseTaskSettingsTemplateLanguages, TaskSettingsTemplateLanguages, ) from crowdin_api.sorting import Sorting +from crowdin_api.utils import convert_to_query_list from crowdin_api.api_resources.tasks.types import TaskCommentPatchRequest @@ -31,7 +40,7 @@ class TasksResource(BaseResource): Use API to create, modify, and delete specific tasks. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Tasks + https://support.crowdin.com/developer/api/v2/#tag/Tasks """ def get_task_settings_templates_path( self, projectId: int, taskSettingsTemplateId: Optional[int] = None @@ -52,10 +61,10 @@ def list_task_settings_templates( List Task Settings Templates. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.settings-templates.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.settings-templates.getMany Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.getMany """ projectId = projectId or self.get_project_id() @@ -77,7 +86,7 @@ def add_task_settings_template( Add Task Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.settings-templates.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.settings-templates.post """ projectId = projectId or self.get_project_id() @@ -95,10 +104,10 @@ def get_task_settings_template( Get Task Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.settings-templates.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.settings-templates.get Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.get """ projectId = projectId or self.get_project_id() @@ -117,10 +126,10 @@ def delete_task_settings_template( Delete Task Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.settings-templates.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.settings-templates.delete Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.delete """ projectId = projectId or self.get_project_id() @@ -143,10 +152,10 @@ def edit_task_settings_template( Edit Task Settings Template. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.settings-templates.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.settings-templates.patch Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.patch """ projectId = projectId or self.get_project_id() @@ -171,7 +180,7 @@ def list_tasks( projectId: Optional[int] = None, orderBy: Optional[Sorting] = None, assigneeId: Optional[int] = None, - status: Optional[CrowdinTaskStatus] = None, + status: Optional[Union[CrowdinTaskStatus, Iterable[CrowdinTaskStatus]]] = None, batchId: Optional[int] = None, page: Optional[int] = None, offset: Optional[int] = None, @@ -180,18 +189,21 @@ def list_tasks( """ List Tasks. + `status` can be a single status or a list of statuses. + `assigneeId` is Crowdin only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.getMany Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.getMany """ projectId = projectId or self.get_project_id() params = { "orderBy": orderBy, "assigneeId": assigneeId, - "status": status, + "status": convert_to_query_list(status), "batchId": batchId, } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) @@ -207,7 +219,7 @@ def add_task(self, request_data: Dict, projectId: Optional[int] = None): Add Task. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -222,7 +234,7 @@ def add_general_task( self, title: str, languageId: str, - fileIds: Iterable[int], + fileIds: Optional[Iterable[int]], type: CrowdinGeneralTaskType, projectId: Optional[int] = None, status: Optional[CrowdinTaskStatus] = None, @@ -238,12 +250,25 @@ def add_general_task( dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, batchId: Optional[int] = None, + directoryIds: Optional[Iterable[int]] = None, + branchIds: Optional[Iterable[int]] = None, + labelMatchRule: Optional[TaskLabelMatchRule] = None, + excludeLabelMatchRule: Optional[TaskLabelMatchRule] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, ): """ - Add Task(Crowdin Task Create Form). + Add Task(Crowdin Task Create Form, Create By Source Ids Form). + + One of `fileIds`, `directoryIds` or `branchIds` is required (pass `fileIds=None` when + using `directoryIds` or `branchIds`). `fileIds` and `directoryIds` are file-based + projects only. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -268,6 +293,15 @@ def add_general_task( "dateFrom": dateFrom, "dateTo": dateTo, "batchId": batchId, + "directoryIds": directoryIds, + "branchIds": branchIds, + "labelMatchRule": labelMatchRule, + "excludeLabelMatchRule": excludeLabelMatchRule, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, }, ) @@ -289,12 +323,17 @@ def add_general_by_string_ids_task( dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, batchId: Optional[int] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, ): """ - Add Task(Crowdin Task Create Form). + Add Task(Crowdin Task Create Form, Create By String Ids Form). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -317,6 +356,11 @@ def add_general_by_string_ids_task( "dateFrom": dateFrom, "dateTo": dateTo, "batchId": batchId, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, }, ) @@ -324,7 +368,7 @@ def add_vendor_task( self, title: str, languageId: str, - fileIds: Iterable[int], + fileIds: Optional[Iterable[int]], type: CrowdinTaskType, vendor: str, projectId: Optional[int] = None, @@ -337,12 +381,25 @@ def add_vendor_task( deadline: Optional[datetime] = None, dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, + directoryIds: Optional[Iterable[int]] = None, + branchIds: Optional[Iterable[int]] = None, + labelMatchRule: Optional[TaskLabelMatchRule] = None, + excludeLabelMatchRule: Optional[TaskLabelMatchRule] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, ): """ - Add Task(Crowdin Vendor Task Create Form). + Add Task(Crowdin Vendor Task Create Form, Create By Source Ids Form). + + One of `fileIds`, `directoryIds` or `branchIds` is required (pass `fileIds=None` when + using `directoryIds` or `branchIds`). `fileIds` and `directoryIds` are file-based + projects only. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -364,6 +421,15 @@ def add_vendor_task( "deadline": deadline, "dateFrom": dateFrom, "dateTo": dateTo, + "directoryIds": directoryIds, + "branchIds": branchIds, + "labelMatchRule": labelMatchRule, + "excludeLabelMatchRule": excludeLabelMatchRule, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, }, ) @@ -382,12 +448,21 @@ def add_vendor_by_string_ids_task( deadline: Optional[datetime] = None, dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, + labelIds: Optional[Iterable[int]] = None, + labelMatchRule: Optional[TaskLabelMatchRule] = None, + excludeLabelIds: Optional[Iterable[int]] = None, + excludeLabelMatchRule: Optional[TaskLabelMatchRule] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, ): """ - Add Task(Crowdin Vendor Task Create Form). + Add Task(Crowdin Vendor Task Create Form, Create By String Ids Form). Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -407,6 +482,15 @@ def add_vendor_by_string_ids_task( "deadline": deadline, "dateFrom": dateFrom, "dateTo": dateTo, + "labelIds": labelIds, + "labelMatchRule": labelMatchRule, + "excludeLabelIds": excludeLabelIds, + "excludeLabelMatchRule": excludeLabelMatchRule, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, }, ) @@ -421,6 +505,9 @@ def add_pending_task( ): """ Add Task(Crowdin Pending Task Create Form). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -449,6 +536,9 @@ def add_vendor_pending_task( ): """ Add Task(Crowdin Vendor Pending Task Create Form). + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -471,7 +561,7 @@ def export_task_strings(self, taskId: int, projectId: Optional[int] = None): Export Task Strings. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.exports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.exports.post """ projectId = projectId or self.get_project_id() @@ -486,7 +576,7 @@ def get_task(self, taskId: int, projectId: Optional[int] = None): Get Task. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.get """ projectId = projectId or self.get_project_id() @@ -500,7 +590,7 @@ def delete_task(self, taskId: int, projectId: Optional[int] = None): Delete Task. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.delete """ projectId = projectId or self.get_project_id() @@ -513,14 +603,21 @@ def delete_task(self, taskId: int, projectId: Optional[int] = None): def edit_task( self, taskId: int, - data: Union[Iterable[VendorPatchRequest], Iterable[TaskPatchRequest]], + data: Union[ + Iterable[VendorPatchRequest], + Iterable[TaskPatchRequest], + Iterable[PendingTaskPatchRequest], + Iterable[VendorPendingTaskPatchRequest], + ], projectId: Optional[int] = None, ): """ Edit Task. + The `/splitFiles` patch path is deprecated, use `/splitContent` instead. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.patch """ projectId = projectId or self.get_project_id() @@ -534,20 +631,25 @@ def edit_task( def list_user_tasks( self, orderBy: Optional[Sorting] = None, - status: Optional[CrowdinTaskStatus] = None, + status: Optional[Union[CrowdinTaskStatus, Iterable[CrowdinTaskStatus]]] = None, isArchived: Optional[bool] = None, page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, ): """ - List Tasks. + List User Tasks (tasks of the authorized user). + + `status` can be a single status or a list of statuses. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.user.tasks.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.user.tasks.getMany """ - params = {"orderBy": orderBy, "status": status} + params = {"orderBy": orderBy, "status": convert_to_query_list(status)} if isArchived is not None: params["isArchived"] = 1 if isArchived else 0 @@ -556,6 +658,82 @@ def list_user_tasks( return self._get_entire_data(method="get", path="user/tasks", params=params) + def _get_tasks_filter_params( + self, + orderBy: Optional[Sorting] = None, + status: Optional[Union[CrowdinTaskStatus, Iterable[CrowdinTaskStatus]]] = None, + type: Optional[Union[CrowdinTaskType, Iterable[CrowdinTaskType]]] = None, + projectIds: Optional[Iterable[int]] = None, + assigneeIds: Optional[Iterable[int]] = None, + creatorIds: Optional[Iterable[int]] = None, + targetLanguageIds: Optional[Iterable[str]] = None, + sourceLanguageIds: Optional[Iterable[str]] = None, + createdAtFrom: Optional[datetime] = None, + createdAtTo: Optional[datetime] = None, + deadlineFrom: Optional[datetime] = None, + deadlineTo: Optional[datetime] = None, + ) -> Dict: + return { + "orderBy": orderBy, + "status": convert_to_query_list(status), + "type": convert_to_query_list(type), + "projectIds": convert_to_query_list(projectIds), + "assigneeIds": convert_to_query_list(assigneeIds), + "creatorIds": convert_to_query_list(creatorIds), + "targetLanguageIds": convert_to_query_list(targetLanguageIds), + "sourceLanguageIds": convert_to_query_list(sourceLanguageIds), + "createdAtFrom": createdAtFrom, + "createdAtTo": createdAtTo, + "deadlineFrom": deadlineFrom, + "deadlineTo": deadlineTo, + } + + def list_specific_user_tasks( + self, + userId: int, + orderBy: Optional[Sorting] = None, + status: Optional[Union[CrowdinTaskStatus, Iterable[CrowdinTaskStatus]]] = None, + type: Optional[Union[CrowdinTaskType, Iterable[CrowdinTaskType]]] = None, + projectIds: Optional[Iterable[int]] = None, + assigneeIds: Optional[Iterable[int]] = None, + creatorIds: Optional[Iterable[int]] = None, + targetLanguageIds: Optional[Iterable[str]] = None, + sourceLanguageIds: Optional[Iterable[str]] = None, + createdAtFrom: Optional[datetime] = None, + createdAtTo: Optional[datetime] = None, + deadlineFrom: Optional[datetime] = None, + deadlineTo: Optional[datetime] = None, + page: Optional[int] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List User Tasks (all of the specified user's project tasks). + + Crowdin only. `status` and `type` can be a single value or a list of values. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.users.tasks.getMany + """ + + params = self._get_tasks_filter_params( + orderBy=orderBy, + status=status, + type=type, + projectIds=projectIds, + assigneeIds=assigneeIds, + creatorIds=creatorIds, + targetLanguageIds=targetLanguageIds, + sourceLanguageIds=sourceLanguageIds, + createdAtFrom=createdAtFrom, + createdAtTo=createdAtTo, + deadlineFrom=deadlineFrom, + deadlineTo=deadlineTo, + ) + params.update(self.get_page_params(page=page, offset=offset, limit=limit)) + + return self._get_entire_data(method="get", path=f"users/{userId}/tasks", params=params) + # Task Comments def get_task_comments_path( self, @@ -581,7 +759,7 @@ def list_task_comments( List Task Comments. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.comments.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.comments.getMany """ projectId = projectId or self.get_project_id() @@ -599,12 +777,15 @@ def add_task_comment( text: str, taskId: int, projectId: Optional[int] = None, + timeSpent: Optional[int] = None, ): """ Add Task Comment. + `timeSpent` is the time spent on the task, in seconds. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.comments.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.comments.post """ projectId = projectId or self.get_project_id() @@ -612,7 +793,7 @@ def add_task_comment( return self.requester.request( method="post", path=self.get_task_comments_path(projectId=projectId, taskId=taskId), - request_data={"text": text}, + request_data={"text": text, "timeSpent": timeSpent}, ) def get_task_comment( @@ -625,7 +806,7 @@ def get_task_comment( Get Task Comment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.comments.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.comments.get """ projectId = projectId or self.get_project_id() @@ -647,7 +828,7 @@ def delete_task_comment( Delete Task Comment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.comments.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.comments.delete """ projectId = projectId or self.get_project_id() @@ -669,8 +850,10 @@ def edit_task_comment( """ Edit Task Comment. + Supported patch paths: `/text` and `/timeSpent` (see `TaskCommentPatchPath`). + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tasks.comments.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tasks.comments.patch """ projectId = projectId or self.get_project_id() @@ -690,7 +873,7 @@ def edit_task_archived_status( Edit Task Archived Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.user.tasks.patch + https://support.crowdin.com/developer/api/v2/#operation/api.user.tasks.patch """ projectId = projectId or self.get_project_id() @@ -714,7 +897,7 @@ class EnterpriseTasksResource(TasksResource): Use API to create, modify, and delete specific tasks. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Tasks + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Tasks """ def add_task_settings_template( @@ -727,7 +910,7 @@ def add_task_settings_template( Add Task Settings Template. Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.settings-templates.post """ projectId = projectId or self.get_project_id() @@ -742,8 +925,8 @@ def add_general_task( self, title: str, languageId: str, - fileIds: Iterable[int], - type: CrowdinGeneralTaskType, + fileIds: Optional[Iterable[int]], + type: Optional[CrowdinGeneralTaskType], workflowStepId: Optional[int] = None, projectId: Optional[int] = None, status: Optional[CrowdinTaskStatus] = None, @@ -760,12 +943,28 @@ def add_general_task( dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, batchId: Optional[int] = None, + directoryIds: Optional[Iterable[int]] = None, + branchIds: Optional[Iterable[int]] = None, + labelMatchRule: Optional[TaskLabelMatchRule] = None, + excludeLabelMatchRule: Optional[TaskLabelMatchRule] = None, + skipAssignedStringsScope: Optional[TaskSkipAssignedStringsScope] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, + fields: Optional[Dict[str, Any]] = None, ): """ - Add Task(Enterprise Task Create Form). + Add Task(Enterprise Task Create Form, Create By Source Ids Form). + + One of `fileIds`, `directoryIds` or `branchIds` is required (pass `fileIds=None` when + using `directoryIds` or `branchIds`). `fileIds` and `directoryIds` are file-based + projects only. One of `type` or `workflowStepId` is required (pass `type=None` when + using `workflowStepId`). `status` accepts `todo` or `in_progress`. Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -792,6 +991,17 @@ def add_general_task( "dateFrom": dateFrom, "dateTo": dateTo, "batchId": batchId, + "directoryIds": directoryIds, + "branchIds": branchIds, + "labelMatchRule": labelMatchRule, + "excludeLabelMatchRule": excludeLabelMatchRule, + "skipAssignedStringsScope": skipAssignedStringsScope, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, + "fields": fields, }, ) @@ -815,12 +1025,21 @@ def add_general_by_string_ids_task( dateFrom: Optional[datetime] = None, dateTo: Optional[datetime] = None, batchId: Optional[int] = None, + skipAssignedStringsScope: Optional[TaskSkipAssignedStringsScope] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, + fields: Optional[Dict[str, Any]] = None, ): """ - Add Task(Enterprise Task Create Form). + Add Task(Enterprise Task Create Form, Create By String Ids Form). + + One of `type` or `workflowStepId` is required. `status` accepts `todo` or `in_progress`. Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -845,6 +1064,13 @@ def add_general_by_string_ids_task( "dateFrom": dateFrom, "dateTo": dateTo, "batchId": batchId, + "skipAssignedStringsScope": skipAssignedStringsScope, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, + "fields": fields, }, ) @@ -853,7 +1079,7 @@ def add_vendor_task( title: str, languageId: str, workflowStepId: int, - fileIds: Iterable[int], + fileIds: Optional[Iterable[int]], projectId: Optional[int] = None, description: Optional[str] = None, skipAssignedStrings: Optional[bool] = None, @@ -863,12 +1089,27 @@ def add_vendor_task( deadline: Optional[datetime] = None, startedAt: Optional[datetime] = None, dateTo: Optional[datetime] = None, + directoryIds: Optional[Iterable[int]] = None, + branchIds: Optional[Iterable[int]] = None, + labelMatchRule: Optional[TaskLabelMatchRule] = None, + excludeLabelMatchRule: Optional[TaskLabelMatchRule] = None, + skipAssignedStringsScope: Optional[TaskSkipAssignedStringsScope] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, + fields: Optional[Dict[str, Any]] = None, ): """ - Add Task(Enterprise Vendor Task Create Form). + Add Task(Enterprise Vendor Task Create Form, Create By Source Ids Form). + + One of `fileIds`, `directoryIds` or `branchIds` is required (pass `fileIds=None` when + using `directoryIds` or `branchIds`). `fileIds` and `directoryIds` are file-based + projects only. Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -888,6 +1129,17 @@ def add_vendor_task( "deadline": deadline, "startedAt": startedAt, "dateTo": dateTo, + "directoryIds": directoryIds, + "branchIds": branchIds, + "labelMatchRule": labelMatchRule, + "excludeLabelMatchRule": excludeLabelMatchRule, + "skipAssignedStringsScope": skipAssignedStringsScope, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, + "fields": fields, }, ) @@ -904,12 +1156,21 @@ def add_vendor_by_string_ids_task( deadline: Optional[datetime] = None, startedAt: Optional[datetime] = None, dateTo: Optional[datetime] = None, + labelIds: Optional[Iterable[int]] = None, + labelMatchRule: Optional[TaskLabelMatchRule] = None, + skipAssignedStringsScope: Optional[TaskSkipAssignedStringsScope] = None, + translationsUpdatedDateFrom: Optional[datetime] = None, + translationsUpdatedDateTo: Optional[datetime] = None, + generateCostEstimate: Optional[bool] = None, + generateTranslationCost: Optional[bool] = None, + reportSettingsTemplateId: Optional[int] = None, + fields: Optional[Dict[str, Any]] = None, ): """ - Add Task(Enterprise Vendor Task Create Form). + Add Task(Enterprise Vendor Task Create Form, Create By String Ids Form). Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() @@ -927,6 +1188,15 @@ def add_vendor_by_string_ids_task( "deadline": deadline, "startedAt": startedAt, "dateTo": dateTo, + "labelIds": labelIds, + "labelMatchRule": labelMatchRule, + "skipAssignedStringsScope": skipAssignedStringsScope, + "translationsUpdatedDateFrom": translationsUpdatedDateFrom, + "translationsUpdatedDateTo": translationsUpdatedDateTo, + "generateCostEstimate": generateCostEstimate, + "generateTranslationCost": generateTranslationCost, + "reportSettingsTemplateId": reportSettingsTemplateId, + "fields": fields, }, ) @@ -939,21 +1209,33 @@ def add_pending_task( assignees: Optional[Iterable[CrowdinTaskAssignee]] = None, assignedTeams: Optional[Iterable[EnterpriseTaskAssignedTeams]] = None, deadline: Optional[datetime] = None, + type: Optional[CrowdinTaskType] = None, + workflowStepId: Optional[int] = None, + vendor: Optional[str] = None, ): """ Add Task(Enterprise Pending Task Create Form). + One of `type` or `workflowStepId` can be provided, not both. When neither is given, + `type` defaults to `CrowdinTaskType.PROOFREAD`. `vendor` is required for + `CrowdinTaskType.PROOFREAD_BY_VENDOR`. + Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.tasks.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.post """ projectId = projectId or self.get_project_id() + if type is None and workflowStepId is None: + type = CrowdinTaskType.PROOFREAD + return self.add_task( projectId=projectId, request_data={ "precedingTaskId": precedingTaskId, - "type": CrowdinTaskType.PROOFREAD, + "type": type, + "workflowStepId": workflowStepId, + "vendor": vendor, "title": title, "description": description, "assignees": assignees, @@ -961,3 +1243,73 @@ def add_pending_task( "deadline": deadline, }, ) + + def edit_task( + self, + taskId: int, + data: Union[ + Iterable[EnterpriseTaskPatchRequest], + Iterable[EnterpriseVendorTaskPatchRequest], + Iterable[EnterpriseInterOrganizationalTaskPatchRequest], + Iterable[EnterprisePendingTaskPatchRequest], + ], + projectId: Optional[int] = None, + ): + """ + Edit Task. + + The `/splitFiles` patch path is deprecated, use `/splitContent` instead. + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.tasks.patch + """ + + return super().edit_task(taskId=taskId, data=data, projectId=projectId) + + def list_organization_tasks( + self, + orderBy: Optional[Sorting] = None, + status: Optional[Union[CrowdinTaskStatus, Iterable[CrowdinTaskStatus]]] = None, + type: Optional[Union[CrowdinTaskType, Iterable[CrowdinTaskType]]] = None, + projectIds: Optional[Iterable[int]] = None, + groupIds: Optional[Iterable[int]] = None, + assigneeIds: Optional[Iterable[int]] = None, + creatorIds: Optional[Iterable[int]] = None, + targetLanguageIds: Optional[Iterable[str]] = None, + sourceLanguageIds: Optional[Iterable[str]] = None, + createdAtFrom: Optional[datetime] = None, + createdAtTo: Optional[datetime] = None, + deadlineFrom: Optional[datetime] = None, + deadlineTo: Optional[datetime] = None, + page: Optional[int] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List Tasks (organization level). + + `status` and `type` can be a single value or a list of values. + `projectIds` cannot be used together with `groupIds`. + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.tasks.getMany + """ + + params = self._get_tasks_filter_params( + orderBy=orderBy, + status=status, + type=type, + projectIds=projectIds, + assigneeIds=assigneeIds, + creatorIds=creatorIds, + targetLanguageIds=targetLanguageIds, + sourceLanguageIds=sourceLanguageIds, + createdAtFrom=createdAtFrom, + createdAtTo=createdAtTo, + deadlineFrom=deadlineFrom, + deadlineTo=deadlineTo, + ) + params["groupIds"] = convert_to_query_list(groupIds) + params.update(self.get_page_params(page=page, offset=offset, limit=limit)) + + return self._get_entire_data(method="get", path="tasks", params=params) diff --git a/crowdin_api/api_resources/tasks/tests/test_tasks_resources.py b/crowdin_api/api_resources/tasks/tests/test_tasks_resources.py index 15cef7a3..b55a36c2 100644 --- a/crowdin_api/api_resources/tasks/tests/test_tasks_resources.py +++ b/crowdin_api/api_resources/tasks/tests/test_tasks_resources.py @@ -11,12 +11,57 @@ ListUserTasksOrderBy, TaskOperationPatchPath, ConfigTaskOperationPatchPath, + PendingTaskOperationPatchPath, + VendorPendingTaskOperationPatchPath, + EnterpriseTaskOperationPatchPath, + EnterpriseVendorTaskOperationPatchPath, + EnterpriseInterOrganizationalTaskOperationPatchPath, + EnterprisePendingTaskOperationPatchPath, + TaskCommentPatchPath, + TaskLabelMatchRule, + TaskSkipAssignedStringsScope, ) from crowdin_api.api_resources.tasks.resource import TasksResource, EnterpriseTasksResource from crowdin_api.requester import APIRequester from crowdin_api.sorting import Sorting, SortingOrder, SortingRule +COST_FIELDS_NEW = { + "translationsUpdatedDateFrom": None, + "translationsUpdatedDateTo": None, + "generateCostEstimate": None, + "generateTranslationCost": None, + "reportSettingsTemplateId": None, +} +C_GENERAL_NEW = { + "directoryIds": None, + "branchIds": None, + "labelMatchRule": None, + "excludeLabelMatchRule": None, + **COST_FIELDS_NEW, +} +C_GENERAL_STRING_NEW = {**COST_FIELDS_NEW} +C_VENDOR_NEW = {**C_GENERAL_NEW} +C_VENDOR_STRING_NEW = { + "labelIds": None, + "labelMatchRule": None, + "excludeLabelIds": None, + "excludeLabelMatchRule": None, + **COST_FIELDS_NEW, +} +E_GENERAL_NEW = {**C_GENERAL_NEW, "skipAssignedStringsScope": None, "fields": None} +E_GENERAL_STRING_NEW = {**C_GENERAL_STRING_NEW, "skipAssignedStringsScope": None, "fields": None} +E_VENDOR_NEW = {**E_GENERAL_NEW} +E_VENDOR_STRING_NEW = { + "labelIds": None, + "labelMatchRule": None, + "skipAssignedStringsScope": None, + **COST_FIELDS_NEW, + "fields": None, +} +E_PENDING_NEW = {"workflowStepId": None, "vendor": None} + + class TestTasksResource: resource_class = TasksResource @@ -315,7 +360,7 @@ def test_add_general_task(self, m_add_task, incoming_data, request_data, base_ab resource = self.get_resource(base_absolut_url) assert resource.add_general_task(projectId=1, **incoming_data) == "response" - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**C_GENERAL_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -389,7 +434,7 @@ def test_add_general_by_string_ids_task(self, m_add_task, incoming_data, request resource = self.get_resource(base_absolut_url) assert resource.add_general_by_string_ids_task(projectId=1, **incoming_data) == "response" - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**C_GENERAL_STRING_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -461,7 +506,7 @@ def test_add_vendor_task(self, m_add_task, incoming_data, request_data, base_abs resource = self.get_resource(base_absolut_url) assert resource.add_vendor_task(projectId=1, **incoming_data) == "response" - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**C_VENDOR_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -529,7 +574,7 @@ def test_add_vendor_by_string_ids_task( resource = self.get_resource(base_absolut_url) assert resource.add_vendor_by_string_ids_task(projectId=1, **incoming_data) == "response" - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**C_VENDOR_STRING_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -803,7 +848,361 @@ def test_add_task_comment(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="post", path=resource.get_task_comments_path(projectId=1, taskId=2), - request_data={"text": "hello"}, + request_data={"text": "hello", "timeSpent": None}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_task_comment_with_time_spent(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_task_comment(projectId=1, taskId=2, text="hello", timeSpent=3600) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path=resource.get_task_comments_path(projectId=1, taskId=2), + request_data={"text": "hello", "timeSpent": 3600}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_task_comment_time_spent(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "value": 120, + "op": PatchOperation.REPLACE, + "path": TaskCommentPatchPath.TIME_SPENT, + } + ] + + resource = self.get_resource(base_absolut_url) + assert ( + resource.edit_task_comment(projectId=1, taskId=2, taskCommentId=3, data=data) + == "response" + ) + m_request.assert_called_once_with( + method="patch", + path=resource.get_task_comments_path(projectId=1, taskId=2, taskCommentId=3), + request_data=data, + ) + + COST_FIELDS_VALUES = { + "translationsUpdatedDateFrom": datetime(year=2024, month=1, day=1), + "translationsUpdatedDateTo": datetime(year=2024, month=2, day=1), + "generateCostEstimate": True, + "generateTranslationCost": False, + "reportSettingsTemplateId": 7, + } + + @mock.patch("crowdin_api.api_resources.tasks.resource.TasksResource.add_task") + def test_add_general_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + new_fields = { + "directoryIds": [10], + "branchIds": [11], + "labelMatchRule": TaskLabelMatchRule.ALL, + "excludeLabelMatchRule": TaskLabelMatchRule.ANY, + **self.COST_FIELDS_VALUES, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_general_task( + projectId=1, + title="title", + languageId="uk", + fileIds=None, + type=CrowdinGeneralTaskType.PROOFREAD, + **new_fields, + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "fileIds": None, + "type": CrowdinGeneralTaskType.PROOFREAD, + "status": None, + "description": None, + "splitContent": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "labelIds": None, + "excludeLabelIds": None, + "assignees": None, + "deadline": None, + "startedAt": None, + "dateFrom": None, + "dateTo": None, + "batchId": None, + **new_fields, + }, + ) + + @mock.patch("crowdin_api.api_resources.tasks.resource.TasksResource.add_task") + def test_add_general_by_string_ids_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_general_by_string_ids_task( + projectId=1, + title="title", + languageId="uk", + stringIds=[1], + type=CrowdinGeneralTaskType.TRANSLATE, + **self.COST_FIELDS_VALUES, + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "stringIds": [1], + "type": CrowdinGeneralTaskType.TRANSLATE, + "status": None, + "description": None, + "splitContent": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "assignees": None, + "deadline": None, + "startedAt": None, + "dateFrom": None, + "dateTo": None, + "batchId": None, + **self.COST_FIELDS_VALUES, + }, + ) + + @mock.patch("crowdin_api.api_resources.tasks.resource.TasksResource.add_task") + def test_add_vendor_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + new_fields = { + "directoryIds": None, + "branchIds": [11], + "labelMatchRule": TaskLabelMatchRule.ANY, + "excludeLabelMatchRule": TaskLabelMatchRule.ALL, + **self.COST_FIELDS_VALUES, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_vendor_task( + projectId=1, + title="title", + languageId="uk", + fileIds=None, + type=CrowdinTaskType.TRANSLATE_BY_VENDOR, + vendor="gengo", + **new_fields, + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "fileIds": None, + "type": CrowdinTaskType.TRANSLATE_BY_VENDOR, + "vendor": "gengo", + "status": None, + "description": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "labelIds": None, + "excludeLabelIds": None, + "deadline": None, + "dateFrom": None, + "dateTo": None, + **new_fields, + }, + ) + + @mock.patch("crowdin_api.api_resources.tasks.resource.TasksResource.add_task") + def test_add_vendor_by_string_ids_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + new_fields = { + "labelIds": [1], + "labelMatchRule": TaskLabelMatchRule.ALL, + "excludeLabelIds": [2], + "excludeLabelMatchRule": TaskLabelMatchRule.ANY, + **self.COST_FIELDS_VALUES, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_vendor_by_string_ids_task( + projectId=1, + title="title", + languageId="uk", + stringIds=[1], + type=CrowdinTaskType.PROOFREAD_BY_VENDOR, + vendor="oht", + **new_fields, + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "stringIds": [1], + "type": CrowdinTaskType.PROOFREAD_BY_VENDOR, + "vendor": "oht", + "status": None, + "description": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "deadline": None, + "dateFrom": None, + "dateTo": None, + **new_fields, + }, + ) + + @pytest.mark.parametrize( + "path", + ( + TaskOperationPatchPath.STARTED_AT, + TaskOperationPatchPath.SPLIT_CONTENT, + TaskOperationPatchPath.GENERATE_COST_ESTIMATE, + TaskOperationPatchPath.GENERATE_TRANSLATION_COST, + TaskOperationPatchPath.REPORT_SETTINGS_TEMPLATE_ID, + PendingTaskOperationPatchPath.ASSIGNEES, + VendorPendingTaskOperationPatchPath.DESCRIPTION, + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_task_new_paths(self, m_request, path, base_absolut_url): + m_request.return_value = "response" + data = [{"value": 1, "op": PatchOperation.REPLACE, "path": path}] + + resource = self.get_resource(base_absolut_url) + assert resource.edit_task(projectId=1, taskId=2, data=data) == "response" + m_request.assert_called_once_with( + method="patch", + request_data=data, + path="projects/1/tasks/2", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_tasks_multiple_statuses(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.list_tasks( + projectId=1, status=[CrowdinTaskStatus.TODO, CrowdinTaskStatus.IN_PROGRESS] + ) + == "response" + ) + m_request.assert_called_once_with( + method="get", + path="projects/1/tasks", + params={ + "orderBy": None, + "assigneeId": None, + "status": "todo,in_progress", + "batchId": None, + "offset": 0, + "limit": 25, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_user_tasks_multiple_statuses(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.list_user_tasks(status=[CrowdinTaskStatus.DONE, CrowdinTaskStatus.CLOSED]) + == "response" + ) + m_request.assert_called_once_with( + method="get", + path="user/tasks", + params={"orderBy": None, "status": "done,closed", "offset": 0, "limit": 25}, + ) + + @pytest.mark.parametrize( + "incoming_data, request_params", + ( + ( + {}, + { + "orderBy": None, + "status": None, + "type": None, + "projectIds": None, + "assigneeIds": None, + "creatorIds": None, + "targetLanguageIds": None, + "sourceLanguageIds": None, + "createdAtFrom": None, + "createdAtTo": None, + "deadlineFrom": None, + "deadlineTo": None, + "offset": 0, + "limit": 25, + }, + ), + ( + { + "orderBy": Sorting([SortingRule(ListTasksOrderBy.ID, SortingOrder.DESC)]), + "status": CrowdinTaskStatus.TODO, + "type": [CrowdinTaskType.TRANSLATE, CrowdinTaskType.PROOFREAD], + "projectIds": [1, 2], + "assigneeIds": [3], + "creatorIds": [4, 5], + "targetLanguageIds": ["uk", "de"], + "sourceLanguageIds": ["en"], + "createdAtFrom": datetime(year=2024, month=1, day=1), + "createdAtTo": datetime(year=2024, month=2, day=1), + "deadlineFrom": datetime(year=2024, month=3, day=1), + "deadlineTo": datetime(year=2024, month=4, day=1), + "limit": 10, + }, + { + "orderBy": Sorting([SortingRule(ListTasksOrderBy.ID, SortingOrder.DESC)]), + "status": CrowdinTaskStatus.TODO, + "type": "0,1", + "projectIds": "1,2", + "assigneeIds": "3", + "creatorIds": "4,5", + "targetLanguageIds": "uk,de", + "sourceLanguageIds": "en", + "createdAtFrom": datetime(year=2024, month=1, day=1), + "createdAtTo": datetime(year=2024, month=2, day=1), + "deadlineFrom": datetime(year=2024, month=3, day=1), + "deadlineTo": datetime(year=2024, month=4, day=1), + "offset": 0, + "limit": 10, + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_specific_user_tasks( + self, m_request, incoming_data, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_specific_user_tasks(userId=12, **incoming_data) == "response" + m_request.assert_called_once_with( + method="get", + path="users/12/tasks", + params=request_params, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -970,7 +1369,7 @@ def test_add_general_task( resource = self.get_resource(base_absolut_url) assert resource.add_general_task(projectId=1, **incoming_data) == "response" - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**E_GENERAL_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -1056,7 +1455,7 @@ def test_add_general_by_string_ids_task( resource.add_general_by_string_ids_task(projectId=1, **incoming_data) == "response" ) - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**E_GENERAL_STRING_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -1125,7 +1524,7 @@ def test_add_vendor_task( resource = self.get_resource(base_absolut_url) assert resource.add_vendor_task(projectId=1, **incoming_data) == "response" - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**E_VENDOR_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -1191,7 +1590,7 @@ def test_add_vendor_by_string_ids_task( resource.add_vendor_by_string_ids_task(projectId=1, **incoming_data) == "response" ) - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**E_VENDOR_STRING_NEW, **request_data}) @pytest.mark.parametrize( "incoming_data, request_data", @@ -1242,4 +1641,325 @@ def test_add_pending_task( resource = self.get_resource(base_absolut_url) assert resource.add_pending_task(projectId=1, **incoming_data) == "response" - m_add_task.assert_called_once_with(projectId=1, request_data=request_data) + m_add_task.assert_called_once_with(projectId=1, request_data={**E_PENDING_NEW, **request_data}) + + @mock.patch( + "crowdin_api.api_resources.tasks.resource.EnterpriseTasksResource.add_task" + ) + def test_add_pending_task_by_workflow_step(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_pending_task( + projectId=1, title="title", precedingTaskId=1, workflowStepId=5, vendor="oht" + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "precedingTaskId": 1, + "type": None, + "workflowStepId": 5, + "vendor": "oht", + "title": "title", + "description": None, + "assignees": None, + "assignedTeams": None, + "deadline": None, + }, + ) + + COST_FIELDS_VALUES = { + "translationsUpdatedDateFrom": datetime(year=2024, month=1, day=1), + "translationsUpdatedDateTo": datetime(year=2024, month=2, day=1), + "generateCostEstimate": True, + "generateTranslationCost": False, + "reportSettingsTemplateId": 7, + } + + @mock.patch( + "crowdin_api.api_resources.tasks.resource.EnterpriseTasksResource.add_task" + ) + def test_add_general_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + new_fields = { + "directoryIds": [10], + "branchIds": None, + "labelMatchRule": TaskLabelMatchRule.ALL, + "excludeLabelMatchRule": TaskLabelMatchRule.ANY, + "skipAssignedStringsScope": TaskSkipAssignedStringsScope.SAME_WORKFLOW_STEP, + **self.COST_FIELDS_VALUES, + "fields": {"some-field": "value"}, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_general_task( + projectId=1, + title="title", + languageId="uk", + fileIds=None, + type=None, + workflowStepId=3, + status=CrowdinTaskStatus.IN_PROGRESS, + **new_fields, + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "fileIds": None, + "type": None, + "workflowStepId": 3, + "status": CrowdinTaskStatus.IN_PROGRESS, + "description": None, + "splitContent": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "labelIds": None, + "excludeLabelIds": None, + "assignees": None, + "assignedTeams": None, + "deadline": None, + "startedAt": None, + "dateFrom": None, + "dateTo": None, + "batchId": None, + **new_fields, + }, + ) + + @mock.patch( + "crowdin_api.api_resources.tasks.resource.EnterpriseTasksResource.add_task" + ) + def test_add_general_by_string_ids_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + new_fields = { + "skipAssignedStringsScope": TaskSkipAssignedStringsScope.ALL, + **self.COST_FIELDS_VALUES, + "fields": {"some-field": 1}, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_general_by_string_ids_task( + projectId=1, title="title", languageId="uk", stringIds=[1], **new_fields + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "stringIds": [1], + "type": None, + "workflowStepId": None, + "status": None, + "description": None, + "splitContent": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "assignees": None, + "assignedTeams": None, + "deadline": None, + "startedAt": None, + "dateFrom": None, + "dateTo": None, + "batchId": None, + **new_fields, + }, + ) + + @mock.patch( + "crowdin_api.api_resources.tasks.resource.EnterpriseTasksResource.add_task" + ) + def test_add_vendor_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + new_fields = { + "directoryIds": None, + "branchIds": [2], + "labelMatchRule": TaskLabelMatchRule.ANY, + "excludeLabelMatchRule": TaskLabelMatchRule.ALL, + "skipAssignedStringsScope": TaskSkipAssignedStringsScope.ALL, + **self.COST_FIELDS_VALUES, + "fields": {"some-field": True}, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_vendor_task( + projectId=1, + title="title", + languageId="uk", + workflowStepId=3, + fileIds=None, + **new_fields, + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "fileIds": None, + "workflowStepId": 3, + "description": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "labelIds": None, + "excludeLabelIds": None, + "deadline": None, + "startedAt": None, + "dateTo": None, + **new_fields, + }, + ) + + @mock.patch( + "crowdin_api.api_resources.tasks.resource.EnterpriseTasksResource.add_task" + ) + def test_add_vendor_by_string_ids_task_new_fields(self, m_add_task, base_absolut_url): + m_add_task.return_value = "response" + new_fields = { + "labelIds": [1], + "labelMatchRule": TaskLabelMatchRule.ALL, + "skipAssignedStringsScope": TaskSkipAssignedStringsScope.SAME_WORKFLOW_STEP, + **self.COST_FIELDS_VALUES, + "fields": {"some-field": "value"}, + } + + resource = self.get_resource(base_absolut_url) + assert ( + resource.add_vendor_by_string_ids_task( + projectId=1, + title="title", + languageId="uk", + workflowStepId=3, + stringIds=[1, 2], + **new_fields, + ) + == "response" + ) + m_add_task.assert_called_once_with( + projectId=1, + request_data={ + "title": "title", + "languageId": "uk", + "stringIds": [1, 2], + "workflowStepId": 3, + "description": None, + "skipAssignedStrings": None, + "includePreTranslatedStringsOnly": None, + "deadline": None, + "startedAt": None, + "dateTo": None, + **new_fields, + }, + ) + + @pytest.mark.parametrize( + "path", + ( + EnterpriseTaskOperationPatchPath.ASSIGNED_TEAMS, + EnterpriseTaskOperationPatchPath.SKIP_ASSIGNED_STRINGS_SCOPE, + EnterpriseTaskOperationPatchPath.FIELDS, + "/fields/some-field", + EnterpriseVendorTaskOperationPatchPath.REPORT_SETTINGS_TEMPLATE_ID, + EnterpriseInterOrganizationalTaskOperationPatchPath.GENERATE_COST_ESTIMATE, + EnterpriseInterOrganizationalTaskOperationPatchPath.GENERATE_TRANSLATION_COST, + EnterpriseInterOrganizationalTaskOperationPatchPath.REPORT_SETTINGS_TEMPLATE_ID, + EnterprisePendingTaskOperationPatchPath.ASSIGNED_TEAMS, + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_task(self, m_request, path, base_absolut_url): + m_request.return_value = "response" + data = [{"value": 1, "op": PatchOperation.REPLACE, "path": path}] + + resource = self.get_resource(base_absolut_url) + assert resource.edit_task(projectId=1, taskId=2, data=data) == "response" + m_request.assert_called_once_with( + method="patch", + request_data=data, + path="projects/1/tasks/2", + ) + + @pytest.mark.parametrize( + "incoming_data, request_params", + ( + ( + {}, + { + "orderBy": None, + "status": None, + "type": None, + "projectIds": None, + "groupIds": None, + "assigneeIds": None, + "creatorIds": None, + "targetLanguageIds": None, + "sourceLanguageIds": None, + "createdAtFrom": None, + "createdAtTo": None, + "deadlineFrom": None, + "deadlineTo": None, + "offset": 0, + "limit": 25, + }, + ), + ( + { + "orderBy": Sorting([SortingRule(ListTasksOrderBy.ID, SortingOrder.ASC)]), + "status": [CrowdinTaskStatus.TODO, CrowdinTaskStatus.DONE], + "type": CrowdinTaskType.TRANSLATE_BY_VENDOR, + "groupIds": [1, 2], + "assigneeIds": [3, 4], + "creatorIds": [5], + "targetLanguageIds": ["uk"], + "sourceLanguageIds": ["en", "fr"], + "createdAtFrom": datetime(year=2024, month=1, day=1), + "createdAtTo": datetime(year=2024, month=2, day=1), + "deadlineFrom": datetime(year=2024, month=3, day=1), + "deadlineTo": datetime(year=2024, month=4, day=1), + "offset": 5, + }, + { + "orderBy": Sorting([SortingRule(ListTasksOrderBy.ID, SortingOrder.ASC)]), + "status": "todo,done", + "type": CrowdinTaskType.TRANSLATE_BY_VENDOR, + "projectIds": None, + "groupIds": "1,2", + "assigneeIds": "3,4", + "creatorIds": "5", + "targetLanguageIds": "uk", + "sourceLanguageIds": "en,fr", + "createdAtFrom": datetime(year=2024, month=1, day=1), + "createdAtTo": datetime(year=2024, month=2, day=1), + "deadlineFrom": datetime(year=2024, month=3, day=1), + "deadlineTo": datetime(year=2024, month=4, day=1), + "offset": 5, + "limit": 25, + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_organization_tasks( + self, m_request, incoming_data, request_params, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_organization_tasks(**incoming_data) == "response" + m_request.assert_called_once_with( + method="get", + path="tasks", + params=request_params, + ) diff --git a/crowdin_api/api_resources/tasks/types.py b/crowdin_api/api_resources/tasks/types.py index c94e4cc8..e46740c2 100644 --- a/crowdin_api/api_resources/tasks/types.py +++ b/crowdin_api/api_resources/tasks/types.py @@ -5,6 +5,13 @@ TaskOperationPatchPath, VendorTaskOperationPatchPath, ConfigTaskOperationPatchPath, + PendingTaskOperationPatchPath, + VendorPendingTaskOperationPatchPath, + EnterpriseTaskOperationPatchPath, + EnterpriseVendorTaskOperationPatchPath, + EnterpriseInterOrganizationalTaskOperationPatchPath, + EnterprisePendingTaskOperationPatchPath, + TaskCommentPatchPath, ) from crowdin_api.typing import TypedDict @@ -26,6 +33,42 @@ class VendorPatchRequest(TypedDict): path: VendorTaskOperationPatchPath +class PendingTaskPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: PendingTaskOperationPatchPath + + +class VendorPendingTaskPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: VendorPendingTaskOperationPatchPath + + +class EnterpriseTaskPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: Union[EnterpriseTaskOperationPatchPath, str] + + +class EnterpriseVendorTaskPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: EnterpriseVendorTaskOperationPatchPath + + +class EnterpriseInterOrganizationalTaskPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: EnterpriseInterOrganizationalTaskOperationPatchPath + + +class EnterprisePendingTaskPatchRequest(TypedDict): + value: Any + op: PatchOperation + path: EnterprisePendingTaskOperationPatchPath + + class ConfigPatchRequest(TypedDict): value: Union[str, int] op: PatchOperation @@ -60,5 +103,4 @@ class EnterpriseTaskSettingsTemplateLanguages(TypedDict): class TaskCommentPatchRequest(TypedDict): value: Any op: PatchOperation - # For now only /text is supported in edit operations - path: str + path: Union[TaskCommentPatchPath, str] diff --git a/crowdin_api/api_resources/teams/resource.py b/crowdin_api/api_resources/teams/resource.py index acd0fd87..e96ff981 100644 --- a/crowdin_api/api_resources/teams/resource.py +++ b/crowdin_api/api_resources/teams/resource.py @@ -2,7 +2,13 @@ from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.teams.types \ - import Permissions, TeamPatchRequest, TeamByProjectRole, GroupTeamPatchRequest + import ( + Permissions, + TeamPatchRequest, + TeamByProjectRole, + GroupTeamPatchRequest, + TeamProjectPermissionsPatchRequest, + ) from crowdin_api.api_resources.users.enums import ProjectRole from crowdin_api.sorting import Sorting from crowdin_api.utils import convert_to_query_string, convert_enum_to_string_if_exists @@ -17,7 +23,7 @@ class TeamsResource(BaseResource): Use API to create, modify, and delete specific teams and members. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Teams + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Teams """ def get_teams_path(self, teamId: Optional[int] = None): @@ -41,7 +47,9 @@ def get_group_teams_path(self, group_id: int, team_id: Optional[int] = None): def list_group_teams( self, group_id: int, - order_by: Optional[Sorting] = None + order_by: Optional[Sorting] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, ): """ List Group Teams @@ -53,8 +61,9 @@ def list_group_teams( params = { "orderBy": order_by } + params.update(self.get_page_params(offset=offset, limit=limit)) - return self.requester.request( + return self._get_entire_data( method="get", path=self.get_group_teams_path(group_id), params=params @@ -99,16 +108,22 @@ def add_team_to_project( self, teamId: int, projectId: Optional[int] = None, - accessToAllWorkflowSteps: bool = True, - managerAccess: bool = False, + accessToAllWorkflowSteps: Optional[bool] = None, + managerAccess: Optional[bool] = None, permissions: Optional[Permissions] = None, - roles: Optional[Iterable[TeamByProjectRole]] = None + roles: Optional[Iterable[TeamByProjectRole]] = None, + developerAccess: Optional[bool] = None, ): """ Add Team To Project. + `managerAccess`, `developerAccess`, `accessToAllWorkflowSteps`, `permissions` and `roles` + are mutually exclusive. `accessToAllWorkflowSteps` and `permissions` are deprecated by + the API, use `roles` instead. Omitted values fall back to the API defaults + (`accessToAllWorkflowSteps` - `true`, `managerAccess` - `false`). + Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.teams.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.teams.post """ projectId = projectId or self.get_project_id() @@ -120,6 +135,7 @@ def add_team_to_project( "teamId": teamId, "accessToAllWorkflowSteps": accessToAllWorkflowSteps, "managerAccess": managerAccess, + "developerAccess": developerAccess, "permissions": permissions, "roles": roles }, @@ -140,7 +156,7 @@ def list_teams( List Teams. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.getMany """ params = { @@ -164,7 +180,7 @@ def add_team(self, name: str): Add Team. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.post """ return self.requester.request( @@ -178,7 +194,7 @@ def get_team(self, teamId: int): Get Team. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.get """ return self.requester.request(method="get", path=self.get_teams_path(teamId=teamId)) @@ -188,7 +204,7 @@ def delete_team(self, teamId: int): Delete Team. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.delete """ return self.requester.request(method="delete", path=self.get_teams_path(teamId=teamId)) @@ -198,7 +214,7 @@ def edit_team(self, teamId: int, data: Iterable[TeamPatchRequest]): Edit Team. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.patch """ return self.requester.request( @@ -207,6 +223,43 @@ def edit_team(self, teamId: int, data: Iterable[TeamPatchRequest]): request_data=data ) + def list_team_project_permissions( + self, + teamId: int, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List Team Project Permissions. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.projects.permissions.getMany + """ + + return self._get_entire_data( + method="get", + path=f"{self.get_teams_path(teamId=teamId)}/projects/permissions", + params=self.get_page_params(offset=offset, limit=limit), + ) + + def edit_team_project_permissions( + self, + teamId: int, + data: Iterable[TeamProjectPermissionsPatchRequest], + ): + """ + Edit Team Project Permissions. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.projects.permissions.patch + """ + + return self.requester.request( + method="patch", + path=f"{self.get_teams_path(teamId=teamId)}/projects/permissions", + request_data=data, + ) + def teams_member_list( self, teamId: int, @@ -217,7 +270,7 @@ def teams_member_list( Team Members List. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.members.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.members.getMany """ return self._get_entire_data( @@ -231,7 +284,7 @@ def add_team_members(self, teamId: int, userIds: Iterable[int]): Add Team Members. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.members.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.members.post """ return self.requester.request( @@ -245,7 +298,7 @@ def delete_all_team_members(self, teamId: int): Delete All Team Members. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.members.deleteMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.members.deleteMany """ return self.requester.request(method="delete", path=self.get_members_path(teamId=teamId)) @@ -255,7 +308,7 @@ def delete_team_member(self, teamId: int, memberId: int): Delete Team Member. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.members.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.teams.members.delete """ return self.requester.request( diff --git a/crowdin_api/api_resources/teams/tests/test_teams_resources.py b/crowdin_api/api_resources/teams/tests/test_teams_resources.py index eed42362..57d72633 100644 --- a/crowdin_api/api_resources/teams/tests/test_teams_resources.py +++ b/crowdin_api/api_resources/teams/tests/test_teams_resources.py @@ -64,7 +64,9 @@ def test_get_group_teams_path(self, group_id, team_id, path, base_absolut_url): ( {}, { - "orderBy": None + "orderBy": None, + "offset": 0, + "limit": 25, } ), ( @@ -87,6 +89,8 @@ def test_get_group_teams_path(self, group_id, team_id, path, base_absolut_url): ) ] ), + "offset": 0, + "limit": 25, } ) ) @@ -166,10 +170,11 @@ def test_get_group_team(self, m_request, base_absolut_url): {"teamId": 1}, { "teamId": 1, - "accessToAllWorkflowSteps": True, - "managerAccess": False, + "accessToAllWorkflowSteps": None, + "managerAccess": None, "permissions": None, "roles": None, + "developerAccess": None, } ), ( @@ -194,7 +199,8 @@ def test_get_group_team(self, m_request, base_absolut_url): }, "de": {"workflowStepIds": "all"} }, - "roles": None + "roles": None, + "developerAccess": None, }, ), ( @@ -278,7 +284,8 @@ def test_get_group_team(self, m_request, base_absolut_url): "languagesAccess": [] } } - ] + ], + "developerAccess": None, }, ), ), @@ -477,3 +484,60 @@ def test_delete_team_member(self, m_request, base_absolut_url): method="delete", path=resource.get_members_path(teamId=1, memberId=2) ) + + +class TestTeamsProjectPermissions: + resource_class = TeamsResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_team_to_project_developer_access(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.add_team_to_project( + teamId=1, projectId=2, developerAccess=True + ) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/2/teams", + request_data={ + "teamId": 1, + "accessToAllWorkflowSteps": None, + "managerAccess": None, + "developerAccess": True, + "permissions": None, + "roles": None, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_team_project_permissions(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.list_team_project_permissions(teamId=1, offset=5, limit=10) == "response" + m_request.assert_called_once_with( + method="get", + path="teams/1/projects/permissions", + params={"offset": 5, "limit": 10}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_team_project_permissions(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": "/45/roles", + "value": [{"name": "proofreader", "permissions": {"allLanguages": True}}], + } + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_team_project_permissions(teamId=1, data=data) == "response" + m_request.assert_called_once_with( + method="patch", path="teams/1/projects/permissions", request_data=data + ) diff --git a/crowdin_api/api_resources/teams/types.py b/crowdin_api/api_resources/teams/types.py index 050962fa..be7700f1 100644 --- a/crowdin_api/api_resources/teams/types.py +++ b/crowdin_api/api_resources/teams/types.py @@ -44,3 +44,15 @@ class GroupTeamPatchRequest(TypedDict): op: PatchOperation path: str value: Any + + +class TeamProjectPermissionsPatchRequest(TypedDict): + """ + JSON Patch operation for team project permissions. + + `path` is `/{projectId}/roles` (replace), `/{projectId}/roles/-` (add) or `/{projectId}` (remove). + """ + + op: PatchOperation + path: str + value: Any diff --git a/crowdin_api/api_resources/translation_memory/enums.py b/crowdin_api/api_resources/translation_memory/enums.py index 7507fe88..9f24dd9c 100644 --- a/crowdin_api/api_resources/translation_memory/enums.py +++ b/crowdin_api/api_resources/translation_memory/enums.py @@ -3,6 +3,15 @@ class TranslationMemoryPatchPath(Enum): NAME = "/name" + LANGUAGE_ID = "/languageId" + IS_SHARED = "/isShared" + GROUP_ID = "/groupId" # Enterprise only + + +class TranslationMemoryExportFormat(Enum): + TMX = "tmx" + CSV = "csv" + XLSX = "xlsx" class TranslationMemorySegmentRecordOperation(Enum): diff --git a/crowdin_api/api_resources/translation_memory/resource.py b/crowdin_api/api_resources/translation_memory/resource.py index 3880c0c4..c50e8474 100644 --- a/crowdin_api/api_resources/translation_memory/resource.py +++ b/crowdin_api/api_resources/translation_memory/resource.py @@ -2,6 +2,7 @@ from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.enums import ExportFormat +from crowdin_api.api_resources.translation_memory.enums import TranslationMemoryExportFormat from crowdin_api.api_resources.translation_memory.types import ( OrganizationConcordanceSearchRequest, TranslationMemoryPatchRequest, @@ -28,7 +29,7 @@ class TranslationMemoryResource(BaseResource): are asynchronous operations and shall be completed with sequence of API methods. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Translation-Memory + https://support.crowdin.com/developer/api/v2/#tag/Translation-Memory """ def get_tms_path(self, tmId: Optional[int] = None): @@ -43,15 +44,23 @@ def list_tms( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + filter: Optional[str] = None, + userId: Optional[int] = None, + groupId: Optional[int] = None, ): """ List TMs. + :param filter: Filter TMs by `name`. + :param userId: Project Member Identifier. Crowdin only. + :param groupId: Group Identifier. Set 0 to see TMs of root group. + Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.tms.getMany """ - params = {"orderBy": orderBy} + params = {"orderBy": orderBy, "filter": filter, "userId": userId, "groupId": groupId} params.update(self.get_page_params(page=page, offset=offset, limit=limit)) return self._get_entire_data( @@ -60,18 +69,34 @@ def list_tms( params=params, ) - def add_tm(self, name: str, languageId: str): + def add_tm( + self, + name: str, + languageId: str, + isShared: Optional[bool] = None, + groupId: Optional[int] = None, + ): """ - Add Glossary. + Add TM. + + :param isShared: Whether the TM should be shared to all projects within the account + (Crowdin) or within the group (Crowdin Enterprise). + :param groupId: Group Identifier. If 0 – the TM will be available for all projects + and groups in the workspace. Crowdin Enterprise only. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.post + https://support.crowdin.com/developer/api/v2/#operation/api.tms.post """ return self.requester.request( method="post", path=self.get_tms_path(), - request_data={"name": name, "languageId": languageId}, + request_data={ + "name": name, + "languageId": languageId, + "isShared": isShared, + "groupId": groupId, + }, ) def get_tm(self, tmId: int): @@ -79,7 +104,7 @@ def get_tm(self, tmId: int): Get TM. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.get + https://support.crowdin.com/developer/api/v2/#operation/api.tms.get """ return self.requester.request(method="get", path=self.get_tms_path(tmId=tmId)) @@ -89,7 +114,7 @@ def delete_tm(self, tmId: int): Delete TM. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.delete + https://support.crowdin.com/developer/api/v2/#operation/api.tms.delete """ return self.requester.request(method="delete", path=self.get_tms_path(tmId=tmId)) @@ -98,8 +123,10 @@ def edit_tm(self, tmId: int, data: Iterable[TranslationMemoryPatchRequest]): """ Edit TM. + `TranslationMemoryPatchPath.GROUP_ID` (`/groupId`) is Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.patch + https://support.crowdin.com/developer/api/v2/#operation/api.tms.patch """ return self.requester.request( @@ -111,7 +138,7 @@ def clear_tm(self, tmId: int): Clear TM. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.segments.clear + https://support.crowdin.com/developer/api/v2/#operation/api.tms.segments.clear """ return self.requester.request( @@ -131,14 +158,17 @@ def list_tm_segments( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + croql: Optional[str] = None, ): """ List TM Segments. + :param croql: Filter segments by CroQL. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.segments.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.tms.segments.getMany """ - params = {"orderBy": orderBy} + params = {"orderBy": orderBy, "croql": croql} params.update(self.get_page_params(page=page, offset=offset, limit=limit)) return self._get_entire_data( @@ -154,7 +184,7 @@ def create_tm_segment( Create TM Segment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.segments.post + https://support.crowdin.com/developer/api/v2/#operation/api.tms.segments.post """ data = {"records": records} @@ -169,7 +199,7 @@ def get_tm_segment(self, tmId: int, segmentId: int): Get TM Segment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.segments.get + https://support.crowdin.com/developer/api/v2/#operation/api.tms.segments.get """ return self.requester.request( method="get", path=self.get_tm_segments_path(tmId=tmId, segmentId=segmentId) @@ -180,7 +210,7 @@ def delete_tm_segment(self, tmId: int, segmentId: int): Delete TM Segment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.segments.delete + https://support.crowdin.com/developer/api/v2/#operation/api.tms.segments.delete """ return self.requester.request( method="delete", @@ -203,7 +233,7 @@ def edit_tm_segment( Edit TM Segment. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.segments.patch + https://support.crowdin.com/developer/api/v2/#operation/api.tms.segments.patch """ return self.requester.request( method="patch", @@ -226,7 +256,7 @@ def tm_segment_batch_operations( TM Segment Batch Operations. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.segments.patchBatch + https://support.crowdin.com/developer/api/v2/#operation/api.tms.segments.patchBatch """ return self.requester.request( method="patch", @@ -246,13 +276,16 @@ def export_tm( tmId: int, sourceLanguageId: Optional[str] = None, targetLanguageId: Optional[str] = None, - format: Optional[ExportFormat] = None, + format: Optional[Union[TranslationMemoryExportFormat, ExportFormat]] = None, ): """ Export TM. + :param format: TM file format: `tmx` (default), `csv` or `xlsx`. + Use `TranslationMemoryExportFormat`. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.exports.post + https://support.crowdin.com/developer/api/v2/#operation/api.tms.exports.post """ return self.requester.request( @@ -270,7 +303,7 @@ def check_tm_export_status(self, tmId: int, exportId: str): Check TM Export Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.exports.get + https://support.crowdin.com/developer/api/v2/#operation/api.tms.exports.get """ return self.requester.request( @@ -282,7 +315,7 @@ def download_tm(self, tmId: int, exportId: str): Download TM. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.exports.download.download + https://support.crowdin.com/developer/api/v2/#operation/api.tms.exports.download.download """ return self.requester.request( @@ -303,7 +336,7 @@ def concordance_search_in_tms( Concordance search in TMs Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.tms.concordance.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.tms.concordance.post """ projectId = projectId or self.get_project_id() @@ -326,10 +359,10 @@ def organization_concordance_search(self, request_data: OrganizationConcordanceS Concordance search in organization TMs. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.concordance.post + https://support.crowdin.com/developer/api/v2/#operation/api.tms.concordance.post Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.tms.concordance.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.tms.concordance.post """ return self.requester.request( @@ -350,7 +383,7 @@ def import_tm( Import TM. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.imports.post + https://support.crowdin.com/developer/api/v2/#operation/api.tms.imports.post """ return self.requester.request( @@ -368,7 +401,7 @@ def check_tm_import_status(self, tmId: int, importId: str): Check TM Import Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.tms.imports.get + https://support.crowdin.com/developer/api/v2/#operation/api.tms.imports.get """ return self.requester.request( diff --git a/crowdin_api/api_resources/translation_memory/tests/test_translation_memory_resources.py b/crowdin_api/api_resources/translation_memory/tests/test_translation_memory_resources.py index eeb7992a..7b5b732b 100644 --- a/crowdin_api/api_resources/translation_memory/tests/test_translation_memory_resources.py +++ b/crowdin_api/api_resources/translation_memory/tests/test_translation_memory_resources.py @@ -5,6 +5,7 @@ from crowdin_api.api_resources.translation_memory.enums import ( ListTmSegmentsOrderBy, ListTmsOrderBy, + TranslationMemoryExportFormat, TranslationMemoryPatchPath, TranslationMemorySegmentBatchOperation, TranslationMemorySegmentBatchOperationPath, @@ -48,6 +49,9 @@ def test_get_screenshots_path(self, in_params, path, base_absolut_url): {}, { "orderBy": None, + "filter": None, + "userId": None, + "groupId": None, "limit": 25, "offset": 0, }, @@ -59,11 +63,17 @@ def test_get_screenshots_path(self, in_params, path, base_absolut_url): ), "limit": 25, "offset": 0, + "filter": "name", + "userId": 1, + "groupId": 2, }, { "orderBy": Sorting( [SortingRule(ListTmsOrderBy.ID, SortingOrder.DESC)] ), + "filter": "name", + "userId": 1, + "groupId": 2, "limit": 25, "offset": 0, }, @@ -84,16 +94,29 @@ def test_list_tms( path=resource.get_tms_path(), ) + @pytest.mark.parametrize( + "incoming_data, request_data", + ( + ( + {"name": "test", "languageId": "fr"}, + {"name": "test", "languageId": "fr", "isShared": None, "groupId": None}, + ), + ( + {"name": "test", "languageId": "fr", "isShared": True, "groupId": 2}, + {"name": "test", "languageId": "fr", "isShared": True, "groupId": 2}, + ), + ), + ) @mock.patch("crowdin_api.requester.APIRequester.request") - def test_add_tm(self, m_request, base_absolut_url): + def test_add_tm(self, m_request, incoming_data, request_data, base_absolut_url): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - assert resource.add_tm(name="test", languageId="fr") == "response" + assert resource.add_tm(**incoming_data) == "response" m_request.assert_called_once_with( method="post", path=resource.get_tms_path(), - request_data={"name": "test", "languageId": "fr"}, + request_data=request_data, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -127,7 +150,22 @@ def test_edit_tm(self, m_request, base_absolut_url): "value": "value", "op": PatchOperation.REPLACE, "path": TranslationMemoryPatchPath.NAME, - } + }, + { + "value": "uk", + "op": PatchOperation.REPLACE, + "path": TranslationMemoryPatchPath.LANGUAGE_ID, + }, + { + "value": True, + "op": PatchOperation.REPLACE, + "path": TranslationMemoryPatchPath.IS_SHARED, + }, + { + "value": 1, + "op": PatchOperation.REPLACE, + "path": TranslationMemoryPatchPath.GROUP_ID, + }, ] resource = self.get_resource(base_absolut_url) @@ -166,6 +204,7 @@ def test_get_tm_segments_path(self, in_params, path, base_absolut_url): {}, { "orderBy": None, + "croql": None, "limit": 25, "offset": 0, }, @@ -175,6 +214,7 @@ def test_get_tm_segments_path(self, in_params, path, base_absolut_url): "orderBy": Sorting( [SortingRule(ListTmSegmentsOrderBy.ID, SortingOrder.DESC)] ), + "croql": "count of records > 1", "limit": 25, "offset": 0, }, @@ -182,6 +222,7 @@ def test_get_tm_segments_path(self, in_params, path, base_absolut_url): "orderBy": Sorting( [SortingRule(ListTmSegmentsOrderBy.ID, SortingOrder.DESC)] ), + "croql": "count of records > 1", "limit": 25, "offset": 0, }, @@ -358,6 +399,14 @@ def test_get_tm_export_path(self, in_params, path, base_absolut_url): "format": ExportFormat.CSV, }, ), + ( + {"format": TranslationMemoryExportFormat.TMX}, + { + "sourceLanguageId": None, + "targetLanguageId": None, + "format": TranslationMemoryExportFormat.TMX, + }, + ), ), ) @mock.patch("crowdin_api.requester.APIRequester.request") diff --git a/crowdin_api/api_resources/translation_memory/types.py b/crowdin_api/api_resources/translation_memory/types.py index 44ac2072..0e3c4afd 100644 --- a/crowdin_api/api_resources/translation_memory/types.py +++ b/crowdin_api/api_resources/translation_memory/types.py @@ -23,7 +23,7 @@ class OrganizationConcordanceSearchRequest(TypedDict): autoSubstitution: bool minRelevant: int expressions: Iterable[str] - userId: Optional[int] + userId: Optional[int] # Crowdin only class TranslationMemorySegmentRecord(TypedDict): diff --git a/crowdin_api/api_resources/translation_status/enums.py b/crowdin_api/api_resources/translation_status/enums.py index 237134ad..911fd663 100644 --- a/crowdin_api/api_resources/translation_status/enums.py +++ b/crowdin_api/api_resources/translation_status/enums.py @@ -53,3 +53,8 @@ class Validation(Enum): WRONG_TRANSLATION_ISSUE_CHECK = "wrong_translation_issue_check" SPELLCHECK = "spellcheck" ICU_CHECK = "icu_check" + + +class QaChecksRevalidationCategory(Enum): + TERMS = "terms" + AI = "ai" diff --git a/crowdin_api/api_resources/translation_status/resource.py b/crowdin_api/api_resources/translation_status/resource.py index 7a150d03..b06be2a9 100644 --- a/crowdin_api/api_resources/translation_status/resource.py +++ b/crowdin_api/api_resources/translation_status/resource.py @@ -1,7 +1,13 @@ +import warnings from typing import Iterable, Optional from crowdin_api.api_resources.abstract.resources import BaseResource -from crowdin_api.api_resources.translation_status.enums import Category, Validation +from crowdin_api.api_resources.translation_status.enums import ( + Category, + QaChecksRevalidationCategory, + Validation, +) +from crowdin_api.api_resources.translation_status.types import ValidateQaChecksRequest class TranslationStatusResource(BaseResource): @@ -14,7 +20,7 @@ class TranslationStatusResource(BaseResource): file, language, branch, directory. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Translation-Status + https://support.crowdin.com/developer/api/v2/#tag/Translation-Status """ def get_branch_progress( @@ -29,7 +35,7 @@ def get_branch_progress( Get Branch Progress. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.branches.languages.progress.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.branches.languages.progress.getMany """ projectId = projectId or self.get_project_id() @@ -52,7 +58,7 @@ def get_directory_progress( Get Directory Progress. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.directories.languages.progress.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.directories.languages.progress.getMany """ projectId = projectId or self.get_project_id() @@ -75,7 +81,7 @@ def get_file_progress( Get File Progress. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.files.languages.progress.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.files.languages.progress.getMany """ projectId = projectId or self.get_project_id() @@ -98,7 +104,7 @@ def get_language_progress( Get Language Progress. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.languages.files.progress.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.languages.files.progress.getMany """ projectId = projectId or self.get_project_id() @@ -121,7 +127,7 @@ def get_project_progress( Get Project Progress. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.languages.progress.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.languages.progress.getMany """ projectId = projectId or self.get_project_id() @@ -134,17 +140,38 @@ def get_project_progress( params=params, ) + def get_qa_checks_revalidation_path(self, projectId: int, revalidationId: Optional[str] = None): + if revalidationId is not None: + return f"projects/{projectId}/qa-checks/revalidate/{revalidationId}" + + warnings.warn( + "Calling QA checks revalidation status/cancel without `revalidationId` is deprecated, " + "pass the `revalidationId` returned by `start_qa_checks_revalidation`", + DeprecationWarning, + stacklevel=3, + ) + return f"projects/{projectId}/qa-checks/revalidate" + def start_qa_checks_revalidation( self, projectId: Optional[int] = None, + qaCheckCategories: Optional[Iterable[QaChecksRevalidationCategory]] = None, + languageIds: Optional[Iterable[str]] = None, + failedOnly: Optional[bool] = None, + externalQaCheckIds: Optional[Iterable[int]] = None, ): """ Start QA Checks Revalidation. Triggers a new QA checks revalidation job for the project. + `externalQaCheckIds` is available in Crowdin Enterprise only. + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/Translation-Status/operation/api.projects.qa-checks.revalidate.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Translation-Status/operation/api.projects.qa-checks.revalidate.post """ projectId = projectId or self.get_project_id() @@ -152,17 +179,25 @@ def start_qa_checks_revalidation( return self.requester.request( method="post", path=f"projects/{projectId}/qa-checks/revalidate", + request_data={ + "qaCheckCategories": qaCheckCategories, + "languageIds": languageIds, + "failedOnly": failedOnly, + "externalQaCheckIds": externalQaCheckIds, + }, ) def get_qa_checks_revalidation_status( self, projectId: Optional[int] = None, + revalidationId: Optional[str] = None, ): """ Get QA Checks Revalidation Status. - Returns the status of the currently running or last completed QA checks - revalidation job. + Returns the status of the QA checks revalidation job identified by `revalidationId` + (the identifier returned by `start_qa_checks_revalidation`). Omitting `revalidationId` + is deprecated. Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/Translation-Status/operation/api.projects.qa-checks.revalidate.get @@ -172,17 +207,22 @@ def get_qa_checks_revalidation_status( return self.requester.request( method="get", - path=f"projects/{projectId}/qa-checks/revalidate", + path=self.get_qa_checks_revalidation_path( + projectId=projectId, revalidationId=revalidationId + ), ) def cancel_qa_checks_revalidation( self, projectId: Optional[int] = None, + revalidationId: Optional[str] = None, ): """ Cancel QA Checks Revalidation. - Cancels the currently running QA checks revalidation job. + Cancels the QA checks revalidation job identified by `revalidationId` + (the identifier returned by `start_qa_checks_revalidation`). Omitting `revalidationId` + is deprecated. Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/Translation-Status/operation/api.projects.qa-checks.revalidate.delete @@ -192,7 +232,34 @@ def cancel_qa_checks_revalidation( return self.requester.request( method="delete", - path=f"projects/{projectId}/qa-checks/revalidate", + path=self.get_qa_checks_revalidation_path( + projectId=projectId, revalidationId=revalidationId + ), + ) + + def validate_text_by_qa_checks( + self, + data: Iterable[ValidateQaChecksRequest], + projectId: Optional[int] = None, + ): + """ + Validate Text by QA Checks. + + Runs QA checks against the passed texts without saving them as translations. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.validate-qa-checks.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.translations.validate-qa-checks.post + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="post", + path=f"projects/{projectId}/translations/validate-qa-checks", + request_data=data, ) def list_qa_check_issues( @@ -204,12 +271,17 @@ def list_qa_check_issues( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + taskId: Optional[int] = None, + fileId: Optional[int] = None, + branchId: Optional[int] = None, ): """ List QA Check Issues. + `fileId` is available in file-based projects only, `branchId` in string-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.qa-checks.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.qa-checks.getMany """ projectId = projectId or self.get_project_id() @@ -217,6 +289,9 @@ def list_qa_check_issues( "languageIds": None if languageIds is None else ",".join(languageIds), "category": ",".join((item.value for item in category)) if category else None, "validation": ",".join((item.value for item in validation)) if validation else None, + "taskId": taskId, + "fileId": fileId, + "branchId": branchId, } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) diff --git a/crowdin_api/api_resources/translation_status/tests/test_translation_status_resources.py b/crowdin_api/api_resources/translation_status/tests/test_translation_status_resources.py index d5f56b1c..a90d3afa 100644 --- a/crowdin_api/api_resources/translation_status/tests/test_translation_status_resources.py +++ b/crowdin_api/api_resources/translation_status/tests/test_translation_status_resources.py @@ -1,7 +1,12 @@ from unittest import mock import pytest -from crowdin_api.api_resources.translation_status.enums import Category, Validation +from crowdin_api.api_resources.enums import PluralCategoryName +from crowdin_api.api_resources.translation_status.enums import ( + Category, + QaChecksRevalidationCategory, + Validation, +) from crowdin_api.api_resources.translation_status.resource import TranslationStatusResource from crowdin_api.requester import APIRequester @@ -85,15 +90,44 @@ def test_get_project_progress(self, m_request, base_absolut_url): path="projects/1/languages/progress", ) + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {}, + { + "qaCheckCategories": None, + "languageIds": None, + "failedOnly": None, + "externalQaCheckIds": None, + }, + ), + ( + { + "qaCheckCategories": [QaChecksRevalidationCategory.AI], + "languageIds": ["uk", "fr"], + "failedOnly": True, + "externalQaCheckIds": [1, 2], + }, + { + "qaCheckCategories": [QaChecksRevalidationCategory.AI], + "languageIds": ["uk", "fr"], + "failedOnly": True, + "externalQaCheckIds": [1, 2], + }, + ), + ), + ) @mock.patch("crowdin_api.requester.APIRequester.request") - def test_start_qa_checks_revalidation(self, m_request, base_absolut_url): + def test_start_qa_checks_revalidation(self, m_request, in_params, request_data, base_absolut_url): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - assert resource.start_qa_checks_revalidation(projectId=1) == "response" + assert resource.start_qa_checks_revalidation(projectId=1, **in_params) == "response" m_request.assert_called_once_with( method="post", path="projects/1/qa-checks/revalidate", + request_data=request_data, ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -101,7 +135,24 @@ def test_get_qa_checks_revalidation_status(self, m_request, base_absolut_url): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - assert resource.get_qa_checks_revalidation_status(projectId=1) == "response" + assert ( + resource.get_qa_checks_revalidation_status(projectId=1, revalidationId="rev-id") + == "response" + ) + m_request.assert_called_once_with( + method="get", + path="projects/1/qa-checks/revalidate/rev-id", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_qa_checks_revalidation_status_without_id_is_deprecated( + self, m_request, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + with pytest.warns(DeprecationWarning): + assert resource.get_qa_checks_revalidation_status(projectId=1) == "response" m_request.assert_called_once_with( method="get", path="projects/1/qa-checks/revalidate", @@ -112,12 +163,51 @@ def test_cancel_qa_checks_revalidation(self, m_request, base_absolut_url): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - assert resource.cancel_qa_checks_revalidation(projectId=1) == "response" + assert ( + resource.cancel_qa_checks_revalidation(projectId=1, revalidationId="rev-id") + == "response" + ) + m_request.assert_called_once_with( + method="delete", + path="projects/1/qa-checks/revalidate/rev-id", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_cancel_qa_checks_revalidation_without_id_is_deprecated( + self, m_request, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + with pytest.warns(DeprecationWarning): + assert resource.cancel_qa_checks_revalidation(projectId=1) == "response" m_request.assert_called_once_with( method="delete", path="projects/1/qa-checks/revalidate", ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_validate_text_by_qa_checks(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + {"stringId": 1, "languageId": "uk", "text": "text"}, + { + "stringId": 2, + "languageId": "uk", + "text": "texts", + "pluralCategoryName": PluralCategoryName.FEW, + }, + ] + + resource = self.get_resource(base_absolut_url) + assert resource.validate_text_by_qa_checks(projectId=1, data=data) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/translations/validate-qa-checks", + request_data=data, + ) + @pytest.mark.parametrize( "in_params,request_params", ( @@ -127,6 +217,9 @@ def test_cancel_qa_checks_revalidation(self, m_request, base_absolut_url): "languageIds": None, "category": None, "validation": None, + "taskId": None, + "fileId": None, + "branchId": None, "offset": 0, "limit": 25, }, @@ -141,6 +234,22 @@ def test_cancel_qa_checks_revalidation(self, m_request, base_absolut_url): "languageIds": "some,string", "category": "icu,empty", "validation": "icu_check,tags_check", + "taskId": None, + "fileId": None, + "branchId": None, + "offset": 0, + "limit": 25, + }, + ), + ( + {"taskId": 1, "fileId": 2, "branchId": 3}, + { + "languageIds": None, + "category": None, + "validation": None, + "taskId": 1, + "fileId": 2, + "branchId": 3, "offset": 0, "limit": 25, }, diff --git a/crowdin_api/api_resources/translation_status/types.py b/crowdin_api/api_resources/translation_status/types.py new file mode 100644 index 00000000..75e4a789 --- /dev/null +++ b/crowdin_api/api_resources/translation_status/types.py @@ -0,0 +1,14 @@ +from typing import Optional + +from crowdin_api.api_resources.enums import PluralCategoryName +from crowdin_api.typing import TypedDict + + +class _ValidateQaChecksRequestRequired(TypedDict): + stringId: int + languageId: str + text: str + + +class ValidateQaChecksRequest(_ValidateQaChecksRequestRequired, total=False): + pluralCategoryName: Optional[PluralCategoryName] diff --git a/crowdin_api/api_resources/translations/enums.py b/crowdin_api/api_resources/translations/enums.py index 06d1eae7..547e1ba2 100644 --- a/crowdin_api/api_resources/translations/enums.py +++ b/crowdin_api/api_resources/translations/enums.py @@ -11,6 +11,7 @@ class PreTranslationAutoApproveOption(Enum): ALL = "all" EXCEPT_AUTO_SUBSTITUTED = "exceptAutoSubstituted" PERFECT_MATCH_ONLY = "perfectMatchOnly" + PERFECT_MATCH_APPROVED_ONLY = "perfectMatchApprovedOnly" NONE = "none" @@ -36,3 +37,14 @@ class CharTransformation(Enum): class PreTranslationEditOperation(Enum): REPLACE = "replace" TEST = "test" + + +class PreTranslationPriority(Enum): + LOW = "low" + NORMAL = "normal" + HIGH = "high" + + +class PreTranslationPatchPath(Enum): + STATUS = "/status" + PRIORITY = "/priority" diff --git a/crowdin_api/api_resources/translations/resource.py b/crowdin_api/api_resources/translations/resource.py index 2c5b29cd..257244ac 100644 --- a/crowdin_api/api_resources/translations/resource.py +++ b/crowdin_api/api_resources/translations/resource.py @@ -1,19 +1,25 @@ +import warnings from typing import Dict, Iterable, Optional +from deprecated import deprecated + from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.enums import ExportProjectTranslationFormat from crowdin_api.api_resources.translations.types import ( FallbackLanguages, EditPreTranslationScheme, + ImportTranslationsOptions, UploadTranslationRequest, ) from crowdin_api.api_resources.translations.enums import ( CharTransformation, PreTranslationApplyMethod, PreTranslationAutoApproveOption, + PreTranslationPriority, PreTranslationReplaceTranslationsOption, PreTranslationScope, ) +from crowdin_api.sorting import Sorting class TranslationsResource(BaseResource): @@ -28,7 +34,7 @@ class TranslationsResource(BaseResource): are asynchronous operations and shall be completed with sequence of API methods. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Translations + https://support.crowdin.com/developer/api/v2/#tag/Translations """ def get_builds_path(self, projectId: int, buildId: Optional[int] = None): @@ -43,8 +49,10 @@ def pre_translation_status( """ Pre-Translation Status. + `preTranslationId` is the auto-translation job identifier (`jobIdentifier` in the API reference). + Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Translations/paths/~1projects~1{projectId}~1pre-translations~1{preTranslationId}/get + https://support.crowdin.com/developer/api/v2/#tag/Translations/paths/~1projects~1{projectId}~1pre-translations~1{preTranslationId}/get """ projectId = projectId or self.get_project_id() @@ -60,6 +68,7 @@ def list_pre_translations( page: Optional[int] = None, offset: Optional[int] = None, limit: Optional[int] = None, + orderBy: Optional[Sorting] = None, ): """ List Pre-Translations @@ -69,9 +78,10 @@ def list_pre_translations( """ projectId = projectId or self.get_project_id() - params = self.get_page_params(page=page, offset=offset, limit=limit) + params = {"orderBy": orderBy} + params.update(self.get_page_params(page=page, offset=offset, limit=limit)) - return self.requester.request( + return self._get_entire_data( method="get", path=f"projects/{projectId}/pre-translations", params=params, @@ -79,8 +89,8 @@ def list_pre_translations( def apply_pre_translation( self, - languageIds: Iterable[str], - fileIds: Iterable[int], + languageIds: Optional[Iterable[str]] = None, + fileIds: Optional[Iterable[int]] = None, projectId: Optional[int] = None, method: Optional[PreTranslationApplyMethod] = None, engineId: Optional[int] = None, @@ -98,15 +108,36 @@ def apply_pre_translation( labelIds: Optional[Iterable[int]] = None, excludeLabelIds: Optional[Iterable[int]] = None, branchIds: Optional[Iterable[int]] = None, + directoryIds: Optional[Iterable[int]] = None, + taskId: Optional[int] = None, + priority: Optional[PreTranslationPriority] = None, + translationModifiedAfter: Optional[str] = None, + notifyOnCompletion: Optional[bool] = None, + sourceLanguageId: Optional[str] = None, + customInstruction: Optional[str] = None, + minimumMatchRatio: Optional[int] = None, ): """ Apply Pre-Translation. + Pre-translate by files: pass `languageIds` together with `fileIds`, `directoryIds` + (file-based projects only) or `branchIds`. `fileIds` is required only when neither + `directoryIds` nor `branchIds` is set. + + Pre-translate by task: pass `taskId` (the target language is taken from the task), + `languageIds` and files are not required in this case. + `translateUntranslatedOnly` is deprecated in favor of `scope` and cannot be combined with it in the same request. + `sourceLanguageId` is available in Crowdin only, `minimumMatchRatio` in + Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.pre-translations.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.pre-translations.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.pre-translations.post """ if translateUntranslatedOnly is not None and scope is not None: raise ValueError( @@ -114,6 +145,12 @@ def apply_pre_translation( "cannot be combined with it in the same request." ) + if translateUntranslatedOnly is not None: + warnings.warn( + "`translateUntranslatedOnly` is deprecated, use `scope` instead", + DeprecationWarning, + ) + if fallbackLanguages is None: fallbackLanguages = [] @@ -150,6 +187,14 @@ def apply_pre_translation( "labelIds": labelIds, "excludeLabelIds": excludeLabelIds, "branchIds": branchIds, + "directoryIds": directoryIds, + "taskId": taskId, + "priority": priority, + "translationModifiedAfter": translationModifiedAfter, + "notifyOnCompletion": notifyOnCompletion, + "sourceLanguageId": sourceLanguageId, + "customInstruction": customInstruction, + "minimumMatchRatio": minimumMatchRatio, }, ) @@ -180,6 +225,8 @@ def edit_pre_translation( """ Edit Pre-Translation + Supported patch paths: `PreTranslationPatchPath` (`/status`, `/priority`). + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/Translations/operation/api.projects.pre-translations.patch """ @@ -218,12 +265,21 @@ def build_project_directory_translation( skipUntranslatedStrings: Optional[bool] = None, skipUntranslatedFiles: Optional[bool] = None, exportApprovedOnly: Optional[bool] = None, + exportWithMinApprovalsCount: Optional[int] = None, + exportStringsThatPassedWorkflow: Optional[bool] = None, + preserveFolderHierarchy: Optional[bool] = None, ): """ Build Project Directory Translation. + `exportApprovedOnly` is available in Crowdin only; `exportWithMinApprovalsCount` and + `exportStringsThatPassedWorkflow` in Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.directories.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.directories.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.translations.builds.directories.post """ projectId = projectId or self.get_project_id() @@ -236,6 +292,9 @@ def build_project_directory_translation( "skipUntranslatedStrings": skipUntranslatedStrings, "skipUntranslatedFiles": skipUntranslatedFiles, "exportApprovedOnly": exportApprovedOnly, + "exportWithMinApprovalsCount": exportWithMinApprovalsCount, + "exportStringsThatPassedWorkflow": exportStringsThatPassedWorkflow, + "preserveFolderHierarchy": preserveFolderHierarchy, }, ) @@ -248,12 +307,20 @@ def build_project_file_translation( skipUntranslatedFiles: Optional[bool] = None, exportApprovedOnly: Optional[bool] = None, eTag: Optional[str] = None, + exportWithMinApprovalsCount: Optional[int] = None, + exportStringsThatPassedWorkflow: Optional[bool] = None, ): """ Build Project File Translation. + `exportApprovedOnly` is available in Crowdin only; `exportWithMinApprovalsCount` and + `exportStringsThatPassedWorkflow` in Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.files.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.files.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.translations.builds.files.post """ if eTag is not None: @@ -272,6 +339,8 @@ def build_project_file_translation( "skipUntranslatedStrings": skipUntranslatedStrings, "skipUntranslatedFiles": skipUntranslatedFiles, "exportApprovedOnly": exportApprovedOnly, + "exportWithMinApprovalsCount": exportWithMinApprovalsCount, + "exportStringsThatPassedWorkflow": exportStringsThatPassedWorkflow, }, ) @@ -287,7 +356,7 @@ def list_project_builds( List Project Builds. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.getMany """ projectId = projectId or self.get_project_id() @@ -307,7 +376,7 @@ def build_project_translation( Build Project Translation. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.post """ projectId = projectId or self.get_project_id() @@ -327,12 +396,19 @@ def build_crowdin_project_translation( skipUntranslatedFiles: Optional[bool] = None, exportApprovedOnly: Optional[bool] = None, exportWithMinApprovalsCount: Optional[int] = None, + exportStringsThatPassedWorkflow: Optional[bool] = None, ): """ Build Project Translation(Crowdin Translation Create Project Build Form). + File-based projects only. `exportApprovedOnly` is available in Crowdin only; + `exportWithMinApprovalsCount` and `exportStringsThatPassedWorkflow` in Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.translations.builds.post """ projectId = projectId or self.get_project_id() @@ -346,6 +422,7 @@ def build_crowdin_project_translation( "skipUntranslatedFiles": skipUntranslatedFiles, "exportApprovedOnly": exportApprovedOnly, "exportWithMinApprovalsCount": exportWithMinApprovalsCount, + "exportStringsThatPassedWorkflow": exportStringsThatPassedWorkflow, }, ) @@ -357,12 +434,15 @@ def build_pseudo_project_translation( suffix: Optional[str] = None, lengthTransformation: Optional[int] = None, charTransformation: Optional[CharTransformation] = None, + branchId: Optional[int] = None, ): """ Build Project Translation(Translation Create Project Pseudo Build Form). + File-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.post """ projectId = projectId or self.get_project_id() @@ -375,9 +455,11 @@ def build_pseudo_project_translation( "suffix": suffix, "lengthTransformation": lengthTransformation, "charTransformation": charTransformation, + "branchId": branchId, }, ) + @deprecated("Use `import_translations` instead") def upload_translation( self, languageId: str, @@ -392,8 +474,10 @@ def upload_translation( """ Upload Translations. + Deprecated by the API: use `import_translations` instead. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.postOnLanguage + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.postOnLanguage """ projectId = projectId or self.get_project_id() @@ -419,7 +503,7 @@ def download_project_translations( Download Project Translations. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.download.download + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.download.download """ projectId = projectId or self.get_project_id() @@ -434,7 +518,7 @@ def check_project_build_status(self, buildId: int, projectId: Optional[int] = No Check Project Build Status. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.get """ projectId = projectId or self.get_project_id() @@ -449,7 +533,7 @@ def cancel_build(self, buildId: int, projectId: Optional[int] = None): Cancel Build. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.builds.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.builds.delete """ projectId = projectId or self.get_project_id() @@ -471,12 +555,21 @@ def export_project_translation( skipUntranslatedStrings: Optional[bool] = None, skipUntranslatedFiles: Optional[bool] = None, exportApprovedOnly: Optional[bool] = None, + exportWithMinApprovalsCount: Optional[int] = None, + exportStringsThatPassedWorkflow: Optional[bool] = None, ): """ Export Project Translation. + `directoryIds`, `fileIds` and `skipUntranslatedFiles` are available in file-based projects only. + `exportApprovedOnly` is available in Crowdin only; `exportWithMinApprovalsCount` and + `exportStringsThatPassedWorkflow` in Crowdin Enterprise only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.translations.exports.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.translations.exports.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.translations.exports.post """ projectId = projectId or self.get_project_id() @@ -494,6 +587,8 @@ def export_project_translation( "skipUntranslatedStrings": skipUntranslatedStrings, "skipUntranslatedFiles": skipUntranslatedFiles, "exportApprovedOnly": exportApprovedOnly, + "exportWithMinApprovalsCount": exportWithMinApprovalsCount, + "exportStringsThatPassedWorkflow": exportStringsThatPassedWorkflow, }, ) @@ -501,16 +596,21 @@ def import_translations( self, project_id: int, storage_id: int, - language_ids: Optional[Iterable[int]] = None, + language_ids: Optional[Iterable[str]] = None, file_id: Optional[int] = None, import_eq_suggestions: Optional[bool] = None, auto_approve_imported: Optional[bool] = None, translate_hidden: Optional[bool] = None, add_to_tm: Optional[bool] = None, + branch_id: Optional[int] = None, + import_options: Optional[ImportTranslationsOptions] = None, ): """ Import Translations + `file_id` is used in file-based projects; `branch_id` and `import_options` + (spreadsheet columns mapping) in string-based projects. + Link to documentation: https://support.crowdin.com/developer/api/v2/#tag/Translations/operation/api.projects.translations.imports """ @@ -526,6 +626,8 @@ def import_translations( "autoApproveImported": auto_approve_imported, "translateHidden": translate_hidden, "addToTm": add_to_tm, + "branchId": branch_id, + "importOptions": import_options, } ) diff --git a/crowdin_api/api_resources/translations/tests/test_translations_resources.py b/crowdin_api/api_resources/translations/tests/test_translations_resources.py index 176fcf17..d26d9094 100644 --- a/crowdin_api/api_resources/translations/tests/test_translations_resources.py +++ b/crowdin_api/api_resources/translations/tests/test_translations_resources.py @@ -1,3 +1,4 @@ +from enum import Enum from unittest import mock import pytest @@ -7,11 +8,18 @@ PreTranslationApplyMethod, PreTranslationAutoApproveOption, PreTranslationEditOperation, + PreTranslationPatchPath, + PreTranslationPriority, PreTranslationReplaceTranslationsOption, PreTranslationScope, ) from crowdin_api.api_resources.translations.resource import TranslationsResource from crowdin_api.requester import APIRequester +from crowdin_api.sorting import Sorting, SortingOrder, SortingRule + + +class ListPreTranslationsTestOrderBy(Enum): + ID = "id" class TestTranslationsResource: @@ -43,7 +51,7 @@ def test_list_pre_translations(self, m_request, base_absolut_url): m_request.return_value = "response" resource = self.get_resource(base_absolut_url) - params = resource.get_page_params() + params = {"orderBy": None, **resource.get_page_params()} assert resource.list_pre_translations(projectId=1) == "response" m_request.assert_called_once_with( method="get", @@ -51,6 +59,126 @@ def test_list_pre_translations(self, m_request, base_absolut_url): params=params, ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_pre_translations_with_order_by(self, m_request, base_absolut_url): + m_request.return_value = "response" + + order_by = Sorting([SortingRule(ListPreTranslationsTestOrderBy.ID, SortingOrder.DESC)]) + + resource = self.get_resource(base_absolut_url) + assert resource.list_pre_translations(projectId=1, orderBy=order_by, limit=10) == "response" + m_request.assert_called_once_with( + method="get", + path="projects/1/pre-translations", + params={"orderBy": order_by, "offset": 0, "limit": 10}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_apply_pre_translation_by_directories_and_new_fields(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.apply_pre_translation( + projectId=1, + languageIds=["uk"], + directoryIds=[3, 4], + method=PreTranslationApplyMethod.AI, + aiPromptId=5, + autoApproveOption=PreTranslationAutoApproveOption.PERFECT_MATCH_APPROVED_ONLY, + scope=PreTranslationScope.ALL, + priority=PreTranslationPriority.HIGH, + translationModifiedAfter="2025-01-01T00:00:00+00:00", + notifyOnCompletion=True, + sourceLanguageId="en", + customInstruction="Be concise", + minimumMatchRatio=80, + ) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path="projects/1/pre-translations", + request_data={ + "languageIds": ["uk"], + "fileIds": None, + "method": PreTranslationApplyMethod.AI, + "engineId": None, + "aiPromptId": 5, + "autoApproveOption": PreTranslationAutoApproveOption.PERFECT_MATCH_APPROVED_ONLY, + "duplicateTranslations": None, + "skipApprovedTranslations": None, + "translateUntranslatedOnly": None, + "scope": PreTranslationScope.ALL, + "translationModifiedBefore": None, + "replaceTranslationsOption": None, + "resetApprovalStatus": None, + "translateWithPerfectMatchOnly": None, + "fallbackLanguages": [], + "labelIds": [], + "excludeLabelIds": [], + "branchIds": [], + "directoryIds": [3, 4], + "taskId": None, + "priority": PreTranslationPriority.HIGH, + "translationModifiedAfter": "2025-01-01T00:00:00+00:00", + "notifyOnCompletion": True, + "sourceLanguageId": "en", + "customInstruction": "Be concise", + "minimumMatchRatio": 80, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_apply_pre_translation_by_task(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.apply_pre_translation(projectId=1, taskId=7) == "response" + m_request.assert_called_once_with( + method="post", + path="projects/1/pre-translations", + request_data={ + "languageIds": None, + "fileIds": None, + "method": None, + "engineId": None, + "aiPromptId": None, + "autoApproveOption": None, + "duplicateTranslations": None, + "skipApprovedTranslations": None, + "translateUntranslatedOnly": None, + "scope": None, + "translationModifiedBefore": None, + "replaceTranslationsOption": None, + "resetApprovalStatus": None, + "translateWithPerfectMatchOnly": None, + "fallbackLanguages": [], + "labelIds": [], + "excludeLabelIds": [], + "branchIds": [], + "directoryIds": None, + "taskId": 7, + "priority": None, + "translationModifiedAfter": None, + "notifyOnCompletion": None, + "sourceLanguageId": None, + "customInstruction": None, + "minimumMatchRatio": None, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_apply_pre_translation_translate_untranslated_only_warns(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + with pytest.warns(DeprecationWarning): + resource.apply_pre_translation( + projectId=1, languageIds=["uk"], fileIds=[1], translateUntranslatedOnly=True + ) + m_request.assert_called_once() + @pytest.mark.parametrize( "in_params, request_data", ( @@ -78,6 +206,14 @@ def test_list_pre_translations(self, m_request, base_absolut_url): "labelIds": [], "excludeLabelIds": [], "branchIds": [], + "directoryIds": None, + "taskId": None, + "priority": None, + "translationModifiedAfter": None, + "notifyOnCompletion": None, + "sourceLanguageId": None, + "customInstruction": None, + "minimumMatchRatio": None, }, ), ( @@ -118,6 +254,14 @@ def test_list_pre_translations(self, m_request, base_absolut_url): "labelIds": [1], "excludeLabelIds": [1], "branchIds": [], + "directoryIds": None, + "taskId": None, + "priority": None, + "translationModifiedAfter": None, + "notifyOnCompletion": None, + "sourceLanguageId": None, + "customInstruction": None, + "minimumMatchRatio": None, }, ), ), @@ -169,6 +313,14 @@ def test_apply_pre_translation_with_branchids(self, m_request, base_absolut_url) "labelIds": [], "excludeLabelIds": [], "branchIds": [10, 20, 30], + "directoryIds": None, + "taskId": None, + "priority": None, + "translationModifiedAfter": None, + "notifyOnCompletion": None, + "sourceLanguageId": None, + "customInstruction": None, + "minimumMatchRatio": None, }, path="projects/1/pre-translations", ) @@ -212,6 +364,14 @@ def test_apply_pre_translation_edge_cases(self, m_request, base_absolut_url): "labelIds": [], "excludeLabelIds": [], "branchIds": [], + "directoryIds": None, + "taskId": None, + "priority": None, + "translationModifiedAfter": None, + "notifyOnCompletion": None, + "sourceLanguageId": None, + "customInstruction": None, + "minimumMatchRatio": None, }, path="projects/1/pre-translations", ) @@ -267,6 +427,14 @@ def test_apply_pre_translation_with_all_optional_params(self, m_request, base_ab "labelIds": [1, 2, 3], "excludeLabelIds": [4, 5], "branchIds": [10, 20], + "directoryIds": None, + "taskId": None, + "priority": None, + "translationModifiedAfter": None, + "notifyOnCompletion": None, + "sourceLanguageId": None, + "customInstruction": None, + "minimumMatchRatio": None, }, path="projects/1/pre-translations", ) @@ -312,6 +480,14 @@ def test_apply_pre_translation_with_scope_and_retranslation_options( "labelIds": [], "excludeLabelIds": [], "branchIds": [], + "directoryIds": None, + "taskId": None, + "priority": None, + "translationModifiedAfter": None, + "notifyOnCompletion": None, + "sourceLanguageId": None, + "customInstruction": None, + "minimumMatchRatio": None, }, path="projects/1/pre-translations", ) @@ -354,7 +530,7 @@ def test_edit_bundle(self, m_request, base_absolut_url): { "value": "value", "op": PreTranslationEditOperation.REPLACE, - "path": "/status", + "path": PreTranslationPatchPath.STATUS, } ] @@ -401,6 +577,8 @@ def test_pre_translation_batch_operations(self, m_request, base_absolut_url): "skipUntranslatedStrings": None, "skipUntranslatedFiles": None, "exportApprovedOnly": None, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, }, None, ), @@ -417,6 +595,8 @@ def test_pre_translation_batch_operations(self, m_request, base_absolut_url): "skipUntranslatedStrings": False, "skipUntranslatedFiles": False, "exportApprovedOnly": False, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, }, {"If-None-Match": "eTag"}, ), @@ -481,6 +661,7 @@ def test_build_project_translation(self, m_request, base_absolut_url): "skipUntranslatedFiles": None, "exportApprovedOnly": None, "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, }, ), ( @@ -499,6 +680,7 @@ def test_build_project_translation(self, m_request, base_absolut_url): "skipUntranslatedFiles": True, "exportApprovedOnly": False, "exportWithMinApprovalsCount": True, + "exportStringsThatPassedWorkflow": None, }, ), ), @@ -527,6 +709,7 @@ def test_build_crowdin_project_translation( "suffix": None, "lengthTransformation": None, "charTransformation": None, + "branchId": None, }, ), ( @@ -543,6 +726,7 @@ def test_build_crowdin_project_translation( "suffix": "nohtyp", "lengthTransformation": 2, "charTransformation": CharTransformation.ARABIC, + "branchId": None, }, ), ), @@ -707,6 +891,8 @@ def test_cancel_build(self, m_request, base_absolut_url): "skipUntranslatedStrings": None, "skipUntranslatedFiles": None, "exportApprovedOnly": None, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, }, ), ( @@ -731,6 +917,8 @@ def test_cancel_build(self, m_request, base_absolut_url): "skipUntranslatedStrings": False, "skipUntranslatedFiles": True, "exportApprovedOnly": False, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, }, ), ), @@ -767,7 +955,9 @@ def test_export_project_translation(self, m_request, in_params, request_data, ba "importEqSuggestions": True, "autoApproveImported": True, "translateHidden": True, - "addToTm": True + "addToTm": True, + "branchId": None, + "importOptions": None, }, ), ) @@ -816,3 +1006,207 @@ def test_import_translations_report(self, m_request, base_absolut_url): method="get", path=f"projects/{project_id}/translations/imports/{import_translation_id}/report", ) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {}, + { + "targetLanguageIds": None, + "skipUntranslatedStrings": None, + "skipUntranslatedFiles": None, + "exportApprovedOnly": None, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": None, + "preserveFolderHierarchy": None, + }, + ), + ( + { + "targetLanguageIds": ["uk"], + "skipUntranslatedStrings": True, + "skipUntranslatedFiles": False, + "exportApprovedOnly": True, + "exportWithMinApprovalsCount": 2, + "exportStringsThatPassedWorkflow": False, + "preserveFolderHierarchy": True, + }, + { + "targetLanguageIds": ["uk"], + "skipUntranslatedStrings": True, + "skipUntranslatedFiles": False, + "exportApprovedOnly": True, + "exportWithMinApprovalsCount": 2, + "exportStringsThatPassedWorkflow": False, + "preserveFolderHierarchy": True, + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_build_project_directory_translation( + self, m_request, in_params, request_data, base_absolut_url + ): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.build_project_directory_translation(projectId=1, directoryId=3, **in_params) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path="projects/1/translations/builds/directories/3", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_build_project_file_translation_enterprise_fields(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.build_project_file_translation( + projectId=1, + fileId=2, + targetLanguageId="uk", + exportWithMinApprovalsCount=1, + exportStringsThatPassedWorkflow=True, + ) + == "response" + ) + m_request.assert_called_once_with( + method="post", + headers=None, + path="projects/1/translations/builds/files/2", + request_data={ + "targetLanguageId": "uk", + "skipUntranslatedStrings": None, + "skipUntranslatedFiles": None, + "exportApprovedOnly": None, + "exportWithMinApprovalsCount": 1, + "exportStringsThatPassedWorkflow": True, + }, + ) + + @mock.patch( + "crowdin_api.api_resources.translations.resource." + "TranslationsResource.build_project_translation" + ) + def test_build_crowdin_project_translation_passed_workflow( + self, m_build_project_translation, base_absolut_url + ): + m_build_project_translation.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.build_crowdin_project_translation( + projectId=1, exportStringsThatPassedWorkflow=True + ) + == "response" + ) + m_build_project_translation.assert_called_once_with( + projectId=1, + request_data={ + "branchId": None, + "targetLanguageIds": None, + "skipUntranslatedStrings": None, + "skipUntranslatedFiles": None, + "exportApprovedOnly": None, + "exportWithMinApprovalsCount": None, + "exportStringsThatPassedWorkflow": True, + }, + ) + + @mock.patch( + "crowdin_api.api_resources.translations.resource." + "TranslationsResource.build_project_translation" + ) + def test_build_pseudo_project_translation_with_branch( + self, m_build_project_translation, base_absolut_url + ): + m_build_project_translation.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.build_pseudo_project_translation(projectId=1, pseudo=True, branchId=4) == "response" + m_build_project_translation.assert_called_once_with( + projectId=1, + request_data={ + "pseudo": True, + "prefix": None, + "suffix": None, + "lengthTransformation": None, + "charTransformation": None, + "branchId": 4, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_export_project_translation_enterprise_fields(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert ( + resource.export_project_translation( + projectId=1, + targetLanguageId="uk", + exportWithMinApprovalsCount=0, + exportStringsThatPassedWorkflow=True, + ) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path="projects/1/translations/exports", + request_data={ + "targetLanguageId": "uk", + "format": None, + "labelIds": None, + "branchIds": None, + "directoryIds": None, + "fileIds": None, + "skipUntranslatedStrings": None, + "skipUntranslatedFiles": None, + "exportApprovedOnly": None, + "exportWithMinApprovalsCount": 0, + "exportStringsThatPassedWorkflow": True, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_import_translations_string_based(self, m_request, base_absolut_url): + m_request.return_value = "response" + + import_options = {"scheme": {"identifier": 0, "en": 1, "uk": 2}} + + resource = self.get_resource(base_absolut_url) + assert ( + resource.import_translations( + 1, 2, language_ids=["uk"], branch_id=3, import_options=import_options + ) + == "response" + ) + m_request.assert_called_once_with( + method="post", + path="projects/1/translations/imports", + request_data={ + "storageId": 2, + "languageIds": ["uk"], + "fileId": None, + "importEqSuggestions": None, + "autoApproveImported": None, + "translateHidden": None, + "addToTm": None, + "branchId": 3, + "importOptions": import_options, + }, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_upload_translation_is_deprecated(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + with pytest.warns(DeprecationWarning): + resource.upload_translation(projectId=1, languageId="uk", storageId=1, fileId=2) diff --git a/crowdin_api/api_resources/translations/types.py b/crowdin_api/api_resources/translations/types.py index d42b55a6..4ac5d147 100644 --- a/crowdin_api/api_resources/translations/types.py +++ b/crowdin_api/api_resources/translations/types.py @@ -1,6 +1,9 @@ -from typing import Iterable, Optional +from typing import Dict, Iterable, Optional, Union from crowdin_api.typing import TypedDict -from crowdin_api.api_resources.translations.enums import PreTranslationEditOperation +from crowdin_api.api_resources.translations.enums import ( + PreTranslationEditOperation, + PreTranslationPatchPath, +) class FallbackLanguages(TypedDict): @@ -9,7 +12,7 @@ class FallbackLanguages(TypedDict): class EditPreTranslationScheme(TypedDict): op: PreTranslationEditOperation - path: str + path: Union[PreTranslationPatchPath, str] value: str @@ -20,3 +23,14 @@ class UploadTranslationRequest(TypedDict): autoApproveImported: Optional[bool] translateHidden: Optional[bool] addToTm: Optional[bool] + + +class ImportTranslationsOptions(TypedDict, total=False): + """ + Import options for spreadsheet files in string-based projects. + + `scheme` maps column names (`none`, `identifier`, `sourceOrTranslation`, `translation` + or a language identifier such as `en`) to column numbers, starting at 0. + """ + + scheme: Dict[str, int] diff --git a/crowdin_api/api_resources/users/enums.py b/crowdin_api/api_resources/users/enums.py index 559873ee..519f4daa 100644 --- a/crowdin_api/api_resources/users/enums.py +++ b/crowdin_api/api_resources/users/enums.py @@ -14,6 +14,15 @@ class UserPatchPath(Enum): LAST_NAME = "/lastName" TIMEZONE = "/timezone" STATUS = "/status" + ADMIN_ACCESS = "/adminAccess" + AVATAR_STORAGE_ID = "/avatarStorageId" + + +class AuthenticatedUserPatchPath(Enum): + USERNAME = "/username" + FULL_NAME = "/fullName" + TIMEZONE = "/timezone" + AVATAR_STORAGE_ID = "/avatarStorageId" class ProjectRole(Enum): diff --git a/crowdin_api/api_resources/users/resource.py b/crowdin_api/api_resources/users/resource.py index 8cd21286..4292ef10 100644 --- a/crowdin_api/api_resources/users/resource.py +++ b/crowdin_api/api_resources/users/resource.py @@ -3,14 +3,32 @@ from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.users.enums import UserRole, OrganizationRole, UserStatus -from crowdin_api.api_resources.users.types import UserPatchRequest, ProjectMemberRole, GroupManagerPatchRequest +from crowdin_api.api_resources.users.types import ( + UserPatchRequest, + ProjectMemberRole, + GroupManagerPatchRequest, + AuthenticatedUserPatchRequest, + UserProjectPermissionsPatchRequest, +) from crowdin_api.sorting import Sorting from crowdin_api.utils import convert_to_query_string, convert_enum_to_string_if_exists class BaseUsersResource(BaseResource): + """ + Base class with methods shared by `UsersResource` and `EnterpriseUsersResource`. + """ def get_authenticated_user(self): + """ + Get Authenticated User. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.user.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.user.get + """ return self.requester.request(method="get", path="user") def get_members_path(self, projectId: int, memberId: Optional[int] = None): @@ -34,7 +52,7 @@ def _list_project_members( List Project Members. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.members.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.members.getMany """ projectId = projectId or self.get_project_id() @@ -50,6 +68,45 @@ def _list_project_members( params=params, ) + def get_member_info(self, memberId: int, projectId: Optional[int] = None): + """ + Get Member Info. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.members.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.members.get + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="get", + path=self.get_members_path(projectId=projectId, memberId=memberId) + ) + + def delete_member_from_project( + self, + memberId: int, + projectId: Optional[int] = None, + ): + """ + Delete Member From Project. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.members.delete + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.members.delete + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="delete", path=self.get_members_path(projectId=projectId, memberId=memberId) + ) + class UsersResource(BaseUsersResource): """ @@ -59,7 +116,7 @@ class UsersResource(BaseUsersResource): authenticated user. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Users + https://support.crowdin.com/developer/api/v2/#tag/Users """ def list_project_members( @@ -77,7 +134,7 @@ def list_project_members( List Project Members. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.members.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.projects.members.getMany """ return self._list_project_members( projectId=projectId, @@ -90,19 +147,80 @@ def list_project_members( extraParams={"role": role} ) - def get_member_info(self, memberId: int, projectId: Optional[int] = None): + def edit_authenticated_user(self, data: Iterable[AuthenticatedUserPatchRequest]): """ - Get Member Info. + Edit Authenticated User. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.user.patch + """ + + return self.requester.request(method="patch", path="user", request_data=data) + + def add_project_member( + self, + userIds: Optional[Iterable[int]] = None, + projectId: Optional[int] = None, + usernames: Optional[Iterable[str]] = None, + emails: Optional[Iterable[str]] = None, + managerAccess: Optional[bool] = None, + developerAccess: Optional[bool] = None, + roles: Optional[Iterable[ProjectMemberRole]] = None, + message: Optional[str] = None, + ): + """ + Add Project Member. + + One of `userIds`, `usernames` or `emails` is required. + `managerAccess`, `developerAccess` and `roles` are mutually exclusive. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.members.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.members.post """ projectId = projectId or self.get_project_id() return self.requester.request( - method="get", - path=self.get_members_path(projectId=projectId, memberId=memberId) + method="post", + path=self.get_members_path(projectId=projectId), + request_data={ + "userIds": userIds, + "usernames": usernames, + "emails": emails, + "managerAccess": managerAccess, + "developerAccess": developerAccess, + "roles": roles, + "message": message, + }, + ) + + def replace_project_member_permissions( + self, + memberId: int, + projectId: Optional[int] = None, + managerAccess: Optional[bool] = None, + developerAccess: Optional[bool] = None, + roles: Optional[Iterable[ProjectMemberRole]] = None, + ): + """ + Replace Project Member Permissions. + + `managerAccess`, `developerAccess` and `roles` are mutually exclusive. + + Link to documentation: + https://support.crowdin.com/developer/api/v2/#operation/api.projects.members.put + """ + + projectId = projectId or self.get_project_id() + + return self.requester.request( + method="put", + path=self.get_members_path(projectId=projectId, memberId=memberId), + request_data={ + "managerAccess": managerAccess, + "developerAccess": developerAccess, + "roles": roles, + }, ) @@ -114,7 +232,7 @@ class EnterpriseUsersResource(BaseUsersResource): authenticated user. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Users + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Users """ def get_users_path(self, userId: Optional[int] = None): if userId is not None: @@ -132,7 +250,9 @@ def list_group_managers( self, group_id: int, team_ids: Optional[Iterable[int]] = None, - order_by: Optional[Sorting] = None + order_by: Optional[Sorting] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, ): """ List Group Managers @@ -142,11 +262,12 @@ def list_group_managers( """ params = { - "team_ids": ",".join(str(teamId) for teamId in team_ids) if team_ids is not None else None, - "order_by": order_by + "teamIds": ",".join(str(teamId) for teamId in team_ids) if team_ids is not None else None, + "orderBy": order_by } + params.update(self.get_page_params(offset=offset, limit=limit)) - return self.requester.request( + return self._get_entire_data( method="get", path=self.get_group_managers_path(group_id), params=params @@ -202,7 +323,7 @@ def list_project_members( List Project Members. Link to documentation for enterprise: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.members.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.members.getMany """ return self._list_project_members( @@ -218,18 +339,25 @@ def list_project_members( def add_project_member( self, - userIds: Iterable[int], + userIds: Optional[Iterable[int]] = None, projectId: Optional[int] = None, accessToAllWorkflowSteps: Optional[bool] = None, managerAccess: Optional[bool] = None, permissions: Optional[Dict] = None, - roles: Optional[Iterable[ProjectMemberRole]] = None + roles: Optional[Iterable[ProjectMemberRole]] = None, + usernames: Optional[Iterable[str]] = None, + emails: Optional[Iterable[str]] = None, + developerAccess: Optional[bool] = None, ): """ Add Project Member. + One of `userIds`, `usernames` or `emails` is required. + + `accessToAllWorkflowSteps` and `permissions` are deprecated by the API, use `roles` instead. + Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.members.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.members.post """ projectId = projectId or self.get_project_id() @@ -239,8 +367,11 @@ def add_project_member( path=self.get_members_path(projectId=projectId), request_data={ "userIds": userIds, + "usernames": usernames, + "emails": emails, "accessToAllWorkflowSteps": accessToAllWorkflowSteps, "managerAccess": managerAccess, + "developerAccess": developerAccess, "permissions": permissions, "roles": roles }, @@ -253,13 +384,16 @@ def replace_project_member_permissions( accessToAllWorkflowSteps: Optional[bool] = None, managerAccess: Optional[bool] = None, permissions: Optional[Dict] = None, - roles: Optional[Iterable[ProjectMemberRole]] = None + roles: Optional[Iterable[ProjectMemberRole]] = None, + developerAccess: Optional[bool] = None, ): """ Replace Project Member Permissions. + `accessToAllWorkflowSteps` and `permissions` are deprecated by the API, use `roles` instead. + Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.members.put + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.members.put """ projectId = projectId or self.get_project_id() @@ -270,41 +404,25 @@ def replace_project_member_permissions( request_data={ "accessToAllWorkflowSteps": accessToAllWorkflowSteps, "managerAccess": managerAccess, + "developerAccess": developerAccess, "permissions": permissions, "roles": roles }, ) - def delete_member_from_project( - self, - memberId: int, - projectId: Optional[int] = None, - ): - """ - Delete Member From Project. - - Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.members.delete - """ - - projectId = projectId or self.get_project_id() - - return self.requester.request( - method="delete", path=self.get_members_path(projectId=projectId, memberId=memberId) - ) - def invite_user( self, email: str, firstName: Optional[str] = None, lastName: Optional[str] = None, - timezone: Optional[str] = None + timezone: Optional[str] = None, + adminAccess: Optional[bool] = None, ): """ Invite User. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.users.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.users.post """ return self.requester.request( method="post", @@ -313,16 +431,83 @@ def invite_user( "email": email, "firstName": firstName, "lastName": lastName, - "timezone": timezone + "timezone": timezone, + "adminAccess": adminAccess, } ) + def get_user(self, userId: int): + """ + Get User. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.users.getById + """ + + return self.requester.request(method="get", path=self.get_users_path(userId=userId)) + + def list_user_project_contributions( + self, + userId: int, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List User Project Contributions. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.users.projects.contributions.getMany + """ + + return self._get_entire_data( + method="get", + path=f"{self.get_users_path(userId=userId)}/projects/contributions", + params=self.get_page_params(offset=offset, limit=limit), + ) + + def list_user_project_permissions( + self, + userId: int, + offset: Optional[int] = None, + limit: Optional[int] = None, + ): + """ + List User Project Permissions. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.users.projects.permissions.getMany + """ + + return self._get_entire_data( + method="get", + path=f"{self.get_users_path(userId=userId)}/projects/permissions", + params=self.get_page_params(offset=offset, limit=limit), + ) + + def edit_user_project_permissions( + self, + userId: int, + data: Iterable[UserProjectPermissionsPatchRequest], + ): + """ + Edit User Project Permissions. + + Link to documentation: + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.users.projects.permissions.patch + """ + + return self.requester.request( + method="patch", + path=f"{self.get_users_path(userId=userId)}/projects/permissions", + request_data=data, + ) + def edit_user(self, userId: int, data: Iterable[UserPatchRequest]): """ Edit User. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.users.patch + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.users.patch """ return self.requester.request( @@ -336,7 +521,7 @@ def delete_user(self, userId: int): Delete User. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.users.delete + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.users.delete """ return self.requester.request( @@ -392,7 +577,7 @@ def list_users( } params.update(self.get_page_params(offset=offset, limit=limit)) - return self.requester.request( + return self._get_entire_data( method="get", path=self.get_users_path(), params=params diff --git a/crowdin_api/api_resources/users/tests/test_users_resources.py b/crowdin_api/api_resources/users/tests/test_users_resources.py index ce0587d4..f3bc47aa 100644 --- a/crowdin_api/api_resources/users/tests/test_users_resources.py +++ b/crowdin_api/api_resources/users/tests/test_users_resources.py @@ -14,7 +14,8 @@ UserStatus, UserTwoFactorAuthStatus, OrganizationRole, - ProjectRole + ProjectRole, + AuthenticatedUserPatchPath, ) from crowdin_api.api_resources.users.resource import ( UsersResource, @@ -198,8 +199,10 @@ def test_get_group_managers_path(self, group_id, user_id, path, base_absolut_url ( {}, { - "team_ids": None, - "order_by": None + "teamIds": None, + "orderBy": None, + "offset": 0, + "limit": 25, }, ), ( @@ -215,8 +218,8 @@ def test_get_group_managers_path(self, group_id, user_id, path, base_absolut_url ), }, { - "team_ids": "1,2,3", - "order_by": Sorting( + "teamIds": "1,2,3", + "orderBy": Sorting( [ SortingRule( ListGroupManagersOrderBy.ID, @@ -224,6 +227,8 @@ def test_get_group_managers_path(self, group_id, user_id, path, base_absolut_url ) ] ), + "offset": 0, + "limit": 25, } ) ) @@ -363,7 +368,8 @@ def test_list_project_members(self, m_request, in_params, request_params, base_a "email": "", "firstName": None, "lastName": None, - "timezone": None + "timezone": None, + "adminAccess": None, }, ), ( @@ -377,7 +383,8 @@ def test_list_project_members(self, m_request, in_params, request_params, base_a "email": "john@example.com", "firstName": "Jon", "lastName": "Doe", - "timezone": "America/New_York" + "timezone": "America/New_York", + "adminAccess": None, }, ), ), @@ -404,7 +411,10 @@ def test_invite_user(self, m_request, in_params, request_params, base_absolut_ur "accessToAllWorkflowSteps": None, "managerAccess": None, "permissions": None, - "roles": None + "roles": None, + "usernames": None, + "emails": None, + "developerAccess": None, }, ), ( @@ -474,7 +484,10 @@ def test_invite_user(self, m_request, in_params, request_params, base_absolut_ur "languagesAccess": [] } } - ] + ], + "usernames": None, + "emails": None, + "developerAccess": None, }, ), ), @@ -498,7 +511,8 @@ def test_add_project_member(self, m_request, in_params, request_params, base_abs "accessToAllWorkflowSteps": None, "managerAccess": None, "permissions": None, - "roles": None + "roles": None, + "developerAccess": None, }, ), ( @@ -564,7 +578,8 @@ def test_add_project_member(self, m_request, in_params, request_params, base_abs "languagesAccess": [] } } - ] + ], + "developerAccess": None, }, ), ), @@ -687,3 +702,155 @@ def test_list_users(self, m_request, in_params, request_params, base_absolut_url path="users", params=request_params ) + + +class TestUsersResourceNewMethods: + resource_class = UsersResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_authenticated_user(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + { + "op": PatchOperation.REPLACE, + "path": AuthenticatedUserPatchPath.FULL_NAME, + "value": "John Doe", + } + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_authenticated_user(data) == "response" + m_request.assert_called_once_with(method="patch", path="user", request_data=data) + + @pytest.mark.parametrize( + "in_params, request_data", + ( + ( + {"userIds": [1]}, + { + "userIds": [1], + "usernames": None, + "emails": None, + "managerAccess": None, + "developerAccess": None, + "roles": None, + "message": None, + }, + ), + ( + { + "usernames": ["john"], + "emails": ["john@example.com"], + "developerAccess": True, + "message": "Welcome", + }, + { + "userIds": None, + "usernames": ["john"], + "emails": ["john@example.com"], + "managerAccess": None, + "developerAccess": True, + "roles": None, + "message": "Welcome", + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_project_member(self, m_request, in_params, request_data, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.add_project_member(projectId=1, **in_params) == "response" + m_request.assert_called_once_with( + method="post", path="projects/1/members", request_data=request_data + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_replace_project_member_permissions(self, m_request, base_absolut_url): + m_request.return_value = "response" + + roles = [{"name": ProjectRole.TRANSLATOR, "permissions": {"allLanguages": True}}] + resource = self.get_resource(base_absolut_url) + assert resource.replace_project_member_permissions( + memberId=2, projectId=1, roles=roles + ) == "response" + m_request.assert_called_once_with( + method="put", + path="projects/1/members/2", + request_data={"managerAccess": None, "developerAccess": None, "roles": roles}, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_member_from_project(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.delete_member_from_project(memberId=2, projectId=1) == "response" + m_request.assert_called_once_with(method="delete", path="projects/1/members/2") + + +class TestEnterpriseUsersResourceNewMethods: + resource_class = EnterpriseUsersResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_member_info(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_member_info(memberId=2, projectId=1) == "response" + m_request.assert_called_once_with(method="get", path="projects/1/members/2") + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_user(self, m_request, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert resource.get_user(1) == "response" + m_request.assert_called_once_with(method="get", path="users/1") + + @pytest.mark.parametrize( + "method_name, path", + ( + ("list_user_project_contributions", "users/1/projects/contributions"), + ("list_user_project_permissions", "users/1/projects/permissions"), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_user_projects(self, m_request, method_name, path, base_absolut_url): + m_request.return_value = "response" + + resource = self.get_resource(base_absolut_url) + assert getattr(resource, method_name)(userId=1, offset=10, limit=5) == "response" + m_request.assert_called_once_with( + method="get", path=path, params={"offset": 10, "limit": 5} + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_user_project_permissions(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [{"op": PatchOperation.REMOVE, "path": "/2815"}] + resource = self.get_resource(base_absolut_url) + assert resource.edit_user_project_permissions(userId=1, data=data) == "response" + m_request.assert_called_once_with( + method="patch", path="users/1/projects/permissions", request_data=data + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_user_admin_access(self, m_request, base_absolut_url): + m_request.return_value = "response" + + data = [ + {"op": PatchOperation.REPLACE, "path": UserPatchPath.ADMIN_ACCESS, "value": True}, + {"op": PatchOperation.REPLACE, "path": UserPatchPath.AVATAR_STORAGE_ID, "value": 5}, + ] + resource = self.get_resource(base_absolut_url) + assert resource.edit_user(userId=1, data=data) == "response" + m_request.assert_called_once_with(method="patch", path="users/1", request_data=data) diff --git a/crowdin_api/api_resources/users/types.py b/crowdin_api/api_resources/users/types.py index d64e70b4..79f8e296 100644 --- a/crowdin_api/api_resources/users/types.py +++ b/crowdin_api/api_resources/users/types.py @@ -1,7 +1,11 @@ from typing import Any, Optional, Iterable from crowdin_api.api_resources.enums import PatchOperation -from crowdin_api.api_resources.users.enums import UserPatchPath, ProjectRole +from crowdin_api.api_resources.users.enums import ( + UserPatchPath, + ProjectRole, + AuthenticatedUserPatchPath, +) from crowdin_api.typing import TypedDict @@ -11,6 +15,12 @@ class UserPatchRequest(TypedDict): path: UserPatchPath +class AuthenticatedUserPatchRequest(TypedDict): + value: str + op: PatchOperation + path: AuthenticatedUserPatchPath + + class LanguageData(TypedDict): allContent: bool workflowStepIds: Optional[Iterable[Any]] @@ -35,3 +45,15 @@ class GroupManagerPatchRequest(TypedDict): op: PatchOperation path: str value: Any + + +class UserProjectPermissionsPatchRequest(TypedDict): + """ + JSON Patch operation for project permissions. + + `path` is `/{projectId}/roles` (replace), `/{projectId}/roles/-` (add) or `/{projectId}` (remove). + """ + + op: PatchOperation + path: str + value: Any diff --git a/crowdin_api/api_resources/vendors/resource.py b/crowdin_api/api_resources/vendors/resource.py index 7e07cf32..b5a2032b 100644 --- a/crowdin_api/api_resources/vendors/resource.py +++ b/crowdin_api/api_resources/vendors/resource.py @@ -14,15 +14,15 @@ class VendorsResource(BaseResource): Use API to get the list of the Vendors you already invited to your organization. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Vendors + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Vendors """ def list_vendors(self, offset: Optional[int] = None, limit: Optional[int] = None): """ - List Teams. + List Vendors. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.teams.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.vendors.getMany """ return self._get_entire_data( diff --git a/crowdin_api/api_resources/webhooks/enums.py b/crowdin_api/api_resources/webhooks/enums.py index e588564a..7cb1cc9a 100644 --- a/crowdin_api/api_resources/webhooks/enums.py +++ b/crowdin_api/api_resources/webhooks/enums.py @@ -2,19 +2,35 @@ class WebhookEvents(Enum): + FILE_ADDED = "file.added" + FILE_UPDATED = "file.updated" + FILE_REVERTED = "file.reverted" + FILE_DELETED = "file.deleted" FILE_TRANSLATED = "file.translated" FILE_APPROVED = "file.approved" + FILE_QA_FINISHED = "file.qa.finished" PROJECT_TRANSLATED = "project.translated" PROJECT_APPROVED = "project.approved" + PROJECT_QA_FINISHED = "project.qa.finished" + PROJECT_BUILT = "project.built" + PRE_TRANSLATION_COMPLETED = "preTranslation.completed" TRANSLATION_UPDATED = "translation.updated" STRING_ADDED = "string.added" STRING_UPDATED = "string.updated" STRING_DELETED = "string.deleted" + STRING_COMMENT_CREATED = "stringComment.created" + STRING_COMMENT_UPDATED = "stringComment.updated" + STRING_COMMENT_DELETED = "stringComment.deleted" + STRING_COMMENT_RESTORED = "stringComment.restored" SUGGESTION_ADDED = "suggestion.added" SUGGESTION_UPDATED = "suggestion.updated" SUGGESTION_DELETED = "suggestion.deleted" SUGGESTION_APPROVED = "suggestion.approved" SUGGESTION_DISAPPROVED = "suggestion.disapproved" + TASK_ADDED = "task.added" + TASK_STATUS_CHANGED = "task.statusChanged" + TASK_UPDATED = "task.updated" + TASK_DELETED = "task.deleted" class WebhookRequestType(Enum): diff --git a/crowdin_api/api_resources/webhooks/organization/resource.py b/crowdin_api/api_resources/webhooks/organization/resource.py index 14fd33d6..cd3f13c6 100644 --- a/crowdin_api/api_resources/webhooks/organization/resource.py +++ b/crowdin_api/api_resources/webhooks/organization/resource.py @@ -3,34 +3,45 @@ from crowdin_api.api_resources.abstract.resources import BaseResource from crowdin_api.api_resources.webhooks.enums import WebhookRequestType, WebhookContentType -from crowdin_api.api_resources.webhooks.organization.enums import OrganizationWebhookEvent, EnterpriseOrgWebhookEvent +from crowdin_api.api_resources.webhooks.organization.enums import ( + OrganizationWebhookEvent, + EnterpriseOrgWebhookEvent, +) from crowdin_api.api_resources.webhooks.organization.types import OrganizationWebhookPatchRequest class OrganizationWebhooksResource(BaseResource): - BASE_URL = "/webhooks" - - def get_webhooks_path( - self, - organization_webhook_id: int - ): - return f"{self.BASE_URL}/{organization_webhook_id}" + """ + Resource for Organization Webhooks. - """Webhooks allow you to collect information about events that happen in your Crowdin account. You can select the - request type, content type, and add a custom payload, which allows you to create integrations with other systems - on your own. + Webhooks allow you to collect information about events that happen in your Crowdin account. + You can select the request type, content type, and add a custom payload, which allows you to + create integrations with other systems on your own. You can configure webhooks for the following events: - project is created - project is deleted + - group is created (Crowdin Enterprise only) + - group is deleted (Crowdin Enterprise only) Use API to create, modify, and delete specific webhooks. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Organization-Webhooks + https://support.crowdin.com/developer/api/v2/#tag/Organization-Webhooks + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Organization-Webhooks """ + BASE_URL = "webhooks" + + def get_webhooks_path( + self, + organization_webhook_id: int + ): + return f"{self.BASE_URL}/{organization_webhook_id}" + def list_webhooks( self, page: Optional[int] = None, @@ -41,7 +52,7 @@ def list_webhooks( List Webhooks Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.webhooks.getMany + https://support.crowdin.com/developer/api/v2/#operation/api.webhooks.getMany """ return self._get_entire_data( method="get", @@ -70,7 +81,7 @@ def add_webhook( For Enterprise please use method "add_webhook_enterprise" Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.webhooks.post + https://support.crowdin.com/developer/api/v2/#operation/api.webhooks.post """ return self.requester.request( @@ -106,7 +117,7 @@ def add_webhook_enterprise( Events list is different Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.webhooks.post + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.webhooks.post """ return self.requester.request( @@ -133,7 +144,7 @@ def get_webhook( Get webhook Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.webhooks.get + https://support.crowdin.com/developer/api/v2/#operation/api.webhooks.get """ return self.requester.request( method="get", @@ -148,7 +159,7 @@ def delete_webhook( Delete webhook Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.webhooks.delete + https://support.crowdin.com/developer/api/v2/#operation/api.webhooks.delete """ return self.requester.request( @@ -165,7 +176,7 @@ def edit_webhook( Edit webhook Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.webhooks.patch + https://support.crowdin.com/developer/api/v2/#operation/api.webhooks.patch """ return self.requester.request( diff --git a/crowdin_api/api_resources/webhooks/organization/tests/test_organization_webhooks_resources.py b/crowdin_api/api_resources/webhooks/organization/tests/test_organization_webhooks_resources.py index 0707414b..2fef13f7 100644 --- a/crowdin_api/api_resources/webhooks/organization/tests/test_organization_webhooks_resources.py +++ b/crowdin_api/api_resources/webhooks/organization/tests/test_organization_webhooks_resources.py @@ -31,7 +31,7 @@ def test_list_webhooks(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="get", params=resource.get_page_params(), - path=resource.BASE_URL + path="webhooks" ) @pytest.mark.parametrize( @@ -102,7 +102,7 @@ def test_add_webhook(self, m_request, in_params, request_data, base_absolut_url) assert resource.add_webhook(**in_params) == "response" m_request.assert_called_once_with( method="post", - path=resource.BASE_URL, + path="webhooks", request_data=request_data ) @@ -174,7 +174,7 @@ def test_add_webhook_enterprise(self, m_request, in_params, request_data, base_a assert resource.add_webhook(**in_params) == "response" m_request.assert_called_once_with( method="post", - path=resource.BASE_URL, + path="webhooks", request_data=request_data ) @@ -186,7 +186,7 @@ def test_get_webhook(self, m_request, base_absolut_url): assert resource.get_webhook(organization_webhook_id=1) == "response" m_request.assert_called_once_with( method="get", - path=resource.get_webhooks_path(organization_webhook_id=1) + path="webhooks/1" ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -197,7 +197,7 @@ def test_delete_webhook(self, m_request, base_absolut_url): assert resource.delete_webhook(organization_webhook_id=1) == "response" m_request.assert_called_once_with( method="delete", - path=resource.get_webhooks_path(organization_webhook_id=1) + path="webhooks/1" ) @mock.patch("crowdin_api.requester.APIRequester.request") @@ -229,5 +229,5 @@ def test_edit_webhook(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="patch", request_data=data, - path=resource.get_webhooks_path(organization_webhook_id=1) + path="webhooks/1" ) diff --git a/crowdin_api/api_resources/webhooks/organization/types.py b/crowdin_api/api_resources/webhooks/organization/types.py index d4c39cff..0b16114b 100644 --- a/crowdin_api/api_resources/webhooks/organization/types.py +++ b/crowdin_api/api_resources/webhooks/organization/types.py @@ -1,8 +1,9 @@ -from typing import TypedDict, Any +from typing import Any from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.api_resources.webhooks.organization.enums import OrganizationWebhookPatchPath +from crowdin_api.typing import TypedDict class OrganizationWebhookPatchRequest(TypedDict): diff --git a/crowdin_api/api_resources/webhooks/resource.py b/crowdin_api/api_resources/webhooks/resource.py index d344d2fa..2fd754dc 100644 --- a/crowdin_api/api_resources/webhooks/resource.py +++ b/crowdin_api/api_resources/webhooks/resource.py @@ -35,7 +35,7 @@ class WebhooksResource(BaseResource): Use API to create, modify, and delete specific webhooks. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Webhooks + https://support.crowdin.com/developer/api/v2/#tag/Webhooks """ def get_webhooks_path(self, projectId: int, webhookId: Optional[int] = None): @@ -55,7 +55,7 @@ def list_webhooks( List Webhooks. Link to documentation: - https://developer.crowdin.com/api/v2/#tag/Webhooks + https://support.crowdin.com/developer/api/v2/#operation/api.projects.webhooks.getMany """ projectId = projectId or self.get_project_id() @@ -82,8 +82,10 @@ def add_webhook( """ Add Webhook. + Note: `file.*` events are available for file-based projects only. + Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.webhooks.post + https://support.crowdin.com/developer/api/v2/#operation/api.projects.webhooks.post """ projectId = projectId or self.get_project_id() @@ -109,7 +111,7 @@ def get_webhook(self, webhookId: int, projectId: Optional[int] = None): Get Webhook. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.webhooks.get + https://support.crowdin.com/developer/api/v2/#operation/api.projects.webhooks.get """ projectId = projectId or self.get_project_id() @@ -124,7 +126,7 @@ def delete_webhook(self, webhookId: int, projectId: Optional[int] = None): Delete Webhook. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.webhooks.delete + https://support.crowdin.com/developer/api/v2/#operation/api.projects.webhooks.delete """ projectId = projectId or self.get_project_id() @@ -141,10 +143,10 @@ def edit_webhook( projectId: Optional[int] = None, ): """ - Edit Custom Language. + Edit Webhook. Link to documentation: - https://developer.crowdin.com/api/v2/#operation/api.projects.webhooks.patch + https://support.crowdin.com/developer/api/v2/#operation/api.projects.webhooks.patch """ projectId = projectId or self.get_project_id() diff --git a/crowdin_api/api_resources/workflows/resource.py b/crowdin_api/api_resources/workflows/resource.py index 227737fe..ec92e44e 100644 --- a/crowdin_api/api_resources/workflows/resource.py +++ b/crowdin_api/api_resources/workflows/resource.py @@ -16,7 +16,7 @@ class WorkflowsResource(BaseResource): the details of a specific template. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#tag/Workflows + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Workflows """ def get_workflow_steps_path(self, projectId: int, stepId: Optional[int] = None): @@ -31,18 +31,24 @@ def get_workflow_templates_path(self, templateId: Optional[int] = None): return "workflow-templates" - def list_workflow_steps(self, projectId: Optional[int] = None): + def list_workflow_steps( + self, + projectId: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): """ List Workflow Steps. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.workflow-steps.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.workflow-steps.getMany """ projectId = projectId or self.get_project_id() return self._get_entire_data( method="get", path=self.get_workflow_steps_path(projectId=projectId), + params=self.get_page_params(offset=offset, limit=limit), ) def get_workflow_step(self, stepId: int, projectId: Optional[int] = None): @@ -50,7 +56,7 @@ def get_workflow_step(self, stepId: int, projectId: Optional[int] = None): Get Workflow Step. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.workflow-steps.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.workflow-steps.get """ projectId = projectId or self.get_project_id() @@ -69,7 +75,7 @@ def list_workflow_templates( List Workflow Templates. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.workflow-templates.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.workflow-templates.getMany """ params = {"groupId": groupId} params.update(self.get_page_params(offset=offset, limit=limit)) @@ -85,7 +91,7 @@ def get_workflow_template(self, templateId: int): Get Workflow Template. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.workflow-templates.get + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.workflow-templates.get """ return self.requester.request( method="get", @@ -109,7 +115,7 @@ def list_workflow_step_strings( List Strings on the Workflow Step. Link to documentation: - https://developer.crowdin.com/enterprise/api/v2/#operation/api.projects.workflow-steps.strings.getMany + https://support.crowdin.com/developer/enterprise/api/v2/#operation/api.projects.workflow-steps.strings.getMany """ projectId = projectId or self.get_project_id() diff --git a/crowdin_api/api_resources/workflows/tests/test_workflows_resources.py b/crowdin_api/api_resources/workflows/tests/test_workflows_resources.py index 5ff8c33d..7ff94402 100644 --- a/crowdin_api/api_resources/workflows/tests/test_workflows_resources.py +++ b/crowdin_api/api_resources/workflows/tests/test_workflows_resources.py @@ -52,7 +52,7 @@ def test_list_workflow_steps(self, m_request, base_absolut_url): m_request.assert_called_once_with( method="get", path=resource.get_workflow_steps_path(projectId=1), - params=None + params={"offset": 0, "limit": 25}, ) @mock.patch("crowdin_api.requester.APIRequester.request") diff --git a/crowdin_api/client.py b/crowdin_api/client.py index 1ad27856..f8cf3934 100644 --- a/crowdin_api/client.py +++ b/crowdin_api/client.py @@ -104,6 +104,19 @@ def graphql(self, query: str, variables: Optional[Dict] = None) -> Dict: request_data=data ) + @property + def advisors(self) -> api_resources.AdvisorsResource: + if self.PROJECT_ID: + return api_resources.AdvisorsResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.AdvisorsResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + @property def ai(self) -> Union[api_resources.AIResource, api_resources.EnterpriseAIResource]: if self._is_enterprise_platform: @@ -125,6 +138,19 @@ def applications(self) -> api_resources.ApplicationResource: requester=self.get_api_requestor(), page_size=self.PAGE_SIZE ) + @property + def branches(self) -> api_resources.BranchesResource: + if self.PROJECT_ID: + return api_resources.BranchesResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.BranchesResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + @property def bundles(self) -> api_resources.BundlesResource: if self.PROJECT_ID: @@ -138,6 +164,54 @@ def bundles(self) -> api_resources.BundlesResource: requester=self.get_api_requestor(), page_size=self.PAGE_SIZE ) + @property + def clients(self) -> api_resources.ClientsResource: + if not self._is_enterprise_platform: + raise CrowdinException(detail="Not implemented for the base API") + + if self.PROJECT_ID: + return api_resources.ClientsResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.ClientsResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + + @property + def custom_placeholders(self) -> api_resources.CustomPlaceholdersResource: + if not self._is_enterprise_platform: + raise CrowdinException(detail="Not implemented for the base API") + + if self.PROJECT_ID: + return api_resources.CustomPlaceholdersResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.CustomPlaceholdersResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + + @property + def custom_spellcheckers(self) -> api_resources.CustomSpellcheckersResource: + if not self._is_enterprise_platform: + raise CrowdinException(detail="Not implemented for the base API") + + if self.PROJECT_ID: + return api_resources.CustomSpellcheckersResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.CustomSpellcheckersResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + @property def dictionaries(self) -> api_resources.DictionariesResource: if self.PROJECT_ID: @@ -164,6 +238,22 @@ def distributions(self) -> api_resources.DistributionsResource: requester=self.get_api_requestor(), page_size=self.PAGE_SIZE ) + @property + def external_qa_checks(self) -> api_resources.ExternalQaChecksResource: + if not self._is_enterprise_platform: + raise CrowdinException(detail="Not implemented for the base API") + + if self.PROJECT_ID: + return api_resources.ExternalQaChecksResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.ExternalQaChecksResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + @property def fields(self) -> api_resources.FieldsResource: if not self._is_enterprise_platform: @@ -235,6 +325,64 @@ def languages(self) -> api_resources.LanguagesResource: requester=self.get_api_requestor(), page_size=self.PAGE_SIZE ) + @property + def notifications(self) -> api_resources.NotificationResource: + if self.PROJECT_ID: + return api_resources.NotificationResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.NotificationResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + + @property + def organization(self) -> api_resources.OrganizationResource: + if not self._is_enterprise_platform: + raise CrowdinException(detail="Not implemented for the base API") + + if self.PROJECT_ID: + return api_resources.OrganizationResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.OrganizationResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + + @property + def organization_webhooks(self) -> api_resources.OrganizationWebhooksResource: + if self.PROJECT_ID: + return api_resources.OrganizationWebhooksResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.OrganizationWebhooksResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + + @property + def project_placeholders(self) -> api_resources.ProjectPlaceholdersResource: + if not self._is_enterprise_platform: + raise CrowdinException(detail="Not implemented for the base API") + + if self.PROJECT_ID: + return api_resources.ProjectPlaceholdersResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.ProjectPlaceholdersResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + @property def projects(self) -> api_resources.ProjectsResource: if self.PROJECT_ID: @@ -309,17 +457,22 @@ def source_files(self) -> api_resources.SourceFilesResource: ) @property - def source_strings(self) -> api_resources.SourceStringsResource: + def source_strings( + self, + ) -> Union[api_resources.SourceStringsResource, api_resources.EnterpriseSourceStringsResource]: + if self._is_enterprise_platform: + source_strings_class = api_resources.EnterpriseSourceStringsResource + else: + source_strings_class = api_resources.SourceStringsResource + if self.PROJECT_ID: - return api_resources.SourceStringsResource( + return source_strings_class( requester=self.get_api_requestor(), project_id=self.PROJECT_ID, page_size=self.PAGE_SIZE, ) - return api_resources.SourceStringsResource( - requester=self.get_api_requestor(), page_size=self.PAGE_SIZE - ) + return source_strings_class(requester=self.get_api_requestor(), page_size=self.PAGE_SIZE) @property def storages(self) -> api_resources.StoragesResource: @@ -347,6 +500,22 @@ def string_comments(self) -> api_resources.StringCommentsResource: requester=self.get_api_requestor(), page_size=self.PAGE_SIZE ) + @property + def string_corrections(self) -> api_resources.StringCorrectionsResource: + if not self._is_enterprise_platform: + raise CrowdinException(detail="Not implemented for the base API") + + if self.PROJECT_ID: + return api_resources.StringCorrectionsResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.StringCorrectionsResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + @property def string_translations(self) -> api_resources.StringTranslationsResource: if self.PROJECT_ID: @@ -373,6 +542,19 @@ def style_guides(self) -> api_resources.StyleGuidesResource: requester=self.get_api_requestor(), page_size=self.PAGE_SIZE ) + @property + def system_placeholders(self) -> api_resources.SystemPlaceholdersResource: + if self.PROJECT_ID: + return api_resources.SystemPlaceholdersResource( + requester=self.get_api_requestor(), + project_id=self.PROJECT_ID, + page_size=self.PAGE_SIZE, + ) + + return api_resources.SystemPlaceholdersResource( + requester=self.get_api_requestor(), page_size=self.PAGE_SIZE + ) + @property def tasks(self) -> Union[api_resources.TasksResource, api_resources.EnterpriseTasksResource]: if self._is_enterprise_platform: @@ -448,17 +630,25 @@ def translations(self) -> api_resources.TranslationsResource: ) @property - def machine_translations(self) -> api_resources.MachineTranslationEnginesResource: + def machine_translations( + self, + ) -> Union[ + api_resources.MachineTranslationEnginesResource, + api_resources.EnterpriseMachineTranslationEnginesResource, + ]: + if self._is_enterprise_platform: + mt_class = api_resources.EnterpriseMachineTranslationEnginesResource + else: + mt_class = api_resources.MachineTranslationEnginesResource + if self.PROJECT_ID: - return api_resources.MachineTranslationEnginesResource( + return mt_class( requester=self.get_api_requestor(), project_id=self.PROJECT_ID, page_size=self.PAGE_SIZE, ) - return api_resources.MachineTranslationEnginesResource( - requester=self.get_api_requestor(), page_size=self.PAGE_SIZE - ) + return mt_class(requester=self.get_api_requestor(), page_size=self.PAGE_SIZE) @property def users(self) -> Union[api_resources.UsersResource, api_resources.EnterpriseUsersResource]: diff --git a/crowdin_api/tests/test_client.py b/crowdin_api/tests/test_client.py index 9ee4cc87..1b3b2f8e 100644 --- a/crowdin_api/tests/test_client.py +++ b/crowdin_api/tests/test_client.py @@ -2,6 +2,7 @@ import pytest from crowdin_api import CrowdinClient +from crowdin_api.exceptions import CrowdinException class MockCrowdinClientEnterprise(CrowdinClient): @@ -160,7 +161,9 @@ def test_api_requestor_custom_values(self, m_APIRequester): @pytest.mark.parametrize( "property_name, class_name", ( + ("advisors", "AdvisorsResource"), ("ai", "AIResource"), + ("branches", "BranchesResource"), ("bundles", "BundlesResource"), ("dictionaries", "DictionariesResource"), ("distributions", "DistributionsResource"), @@ -168,6 +171,8 @@ def test_api_requestor_custom_values(self, m_APIRequester): ("labels", "LabelsResource"), ("languages", "LanguagesResource"), ("machine_translations", "MachineTranslationEnginesResource"), + ("notifications", "NotificationResource"), + ("organization_webhooks", "OrganizationWebhooksResource"), ("projects", "ProjectsResource"), ("reports", "ReportsResource"), ("screenshots", "ScreenshotsResource"), @@ -178,6 +183,7 @@ def test_api_requestor_custom_values(self, m_APIRequester): ("string_comments", "StringCommentsResource"), ("string_translations", "StringTranslationsResource"), ("style_guides", "StyleGuidesResource"), + ("system_placeholders", "SystemPlaceholdersResource"), ("tasks", "TasksResource"), ("translation_memory", "TranslationMemoryResource"), ("translation_status", "TranslationStatusResource"), @@ -211,6 +217,22 @@ def test_storages(self, _m_api_requestor, property_name, class_name): requester="api_requestor", project_id=1, page_size=25 ) + @pytest.mark.parametrize( + "property_name", + ( + "clients", + "custom_placeholders", + "custom_spellcheckers", + "external_qa_checks", + "organization", + "project_placeholders", + "string_corrections", + ), + ) + def test_enterprise_only_resources(self, property_name): + with pytest.raises(CrowdinException): + getattr(CrowdinClient(), property_name) + @mock.patch("crowdin_api.client.CrowdinClient.get_api_requestor") def test_graphql(self, mock_get_requestor): """Test GraphQL functionality with basic request validation.""" @@ -235,26 +257,38 @@ class TestCrowdinClientEnterprise: @pytest.mark.parametrize( "property_name, class_name", ( + ("advisors", "AdvisorsResource"), ("ai", "EnterpriseAIResource"), + ("branches", "BranchesResource"), ("bundles", "BundlesResource"), + ("clients", "ClientsResource"), + ("custom_placeholders", "CustomPlaceholdersResource"), + ("custom_spellcheckers", "CustomSpellcheckersResource"), ("dictionaries", "DictionariesResource"), ("distributions", "DistributionsResource"), + ("external_qa_checks", "ExternalQaChecksResource"), ("fields", "FieldsResource"), ("glossaries", "GlossariesResource"), ("groups", "GroupsResource"), ("labels", "LabelsResource"), ("languages", "LanguagesResource"), - ("machine_translations", "MachineTranslationEnginesResource"), + ("machine_translations", "EnterpriseMachineTranslationEnginesResource"), + ("notifications", "NotificationResource"), + ("organization", "OrganizationResource"), + ("organization_webhooks", "OrganizationWebhooksResource"), + ("project_placeholders", "ProjectPlaceholdersResource"), ("projects", "ProjectsResource"), ("reports", "EnterpriseReportsResource"), ("screenshots", "ScreenshotsResource"), ("security_logs", "SecurityLogsResource"), ("source_files", "SourceFilesResource"), - ("source_strings", "SourceStringsResource"), + ("source_strings", "EnterpriseSourceStringsResource"), ("storages", "StoragesResource"), ("string_comments", "StringCommentsResource"), + ("string_corrections", "StringCorrectionsResource"), ("string_translations", "StringTranslationsResource"), ("style_guides", "StyleGuidesResource"), + ("system_placeholders", "SystemPlaceholdersResource"), ("tasks", "EnterpriseTasksResource"), ("teams", "TeamsResource"), ("translation_memory", "TranslationMemoryResource"), diff --git a/crowdin_api/utils.py b/crowdin_api/utils.py index 7f78bf3c..7dec18d4 100644 --- a/crowdin_api/utils.py +++ b/crowdin_api/utils.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Optional, Iterable, Callable +from typing import Any, Callable, Iterable, Optional def convert_to_query_string( @@ -23,3 +23,20 @@ def convert_enum_collection_to_string_if_exists(value: Optional[Iterable[Enum]]) if value is None: return None return ','.join([item.value for item in value if isinstance(item, Enum)]) + + +def convert_to_query_list(value: Any) -> Any: + """ + Serialize a query value that the API expects as a comma-separated list. + + Iterables (except strings) are joined with commas, using `.value` for enum members; an empty + iterable becomes `None` so the filter is omitted. Single values are returned as they are. + """ + if value is None or isinstance(value, (str, bytes, Enum)) or not isinstance(value, Iterable): + return value + + items = list(value) + if not items: + return None + + return ",".join(str(item.value if isinstance(item, Enum) else item) for item in items)