From 910f2cbf2541157082771f300d1dbfcf523ae80c Mon Sep 17 00:00:00 2001 From: Faakhir30 Date: Mon, 16 Sep 2024 00:19:40 +0500 Subject: [PATCH 1/3] Added create and fetch aliases using csv. --- news/1812.feature | 1 + src/plone/restapi/services/aliases/add.py | 33 ++++++++++- .../restapi/services/aliases/configure.zcml | 9 +++ src/plone/restapi/services/aliases/get.py | 56 ++++++++++++++++--- .../aliases_root_get_csv_format.req | 3 + .../aliases_root_get_csv_format.resp | 6 ++ .../aliases_root_post_csv_format.req | 13 +++++ .../aliases_root_post_csv_format.resp | 2 + src/plone/restapi/tests/test_documentation.py | 53 ++++++++++++++++++ 9 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 news/1812.feature create mode 100644 src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.req create mode 100644 src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.resp create mode 100644 src/plone/restapi/tests/http-examples/aliases_root_post_csv_format.req create mode 100644 src/plone/restapi/tests/http-examples/aliases_root_post_csv_format.resp diff --git a/news/1812.feature b/news/1812.feature new file mode 100644 index 0000000000..85727b674b --- /dev/null +++ b/news/1812.feature @@ -0,0 +1 @@ +Added create and fetch aliases in CSV format. @Faakhir30 diff --git a/src/plone/restapi/services/aliases/add.py b/src/plone/restapi/services/aliases/add.py index 555fbdbe0f..20ea3466b8 100644 --- a/src/plone/restapi/services/aliases/add.py +++ b/src/plone/restapi/services/aliases/add.py @@ -4,14 +4,20 @@ from plone.restapi.deserializer import json_body from plone.restapi.services import Service from Products.CMFPlone.controlpanel.browser.redirects import absolutize_path +from Products.CMFPlone.controlpanel.browser.redirects import RedirectsControlPanel +from Products.statusmessages.interfaces import IStatusMessage from zExceptions import BadRequest from zope.component import getMultiAdapter +from zope.component.hooks import getSite from zope.component import getUtility from zope.interface import alsoProvides from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse import plone.protect.interfaces +import logging + +logger = logging.getLogger("Plone") @implementer(IPublishTraverse) @@ -83,14 +89,35 @@ def edit_for_navigation_root(self, alias): class AliasesRootPost(Service): """Creates new aliases via controlpanel""" - def reply(self): - data = json_body(self.request) + def _reply_csv(self): + form = self.request.form + if not form.get("file"): + raise BadRequest("No file uploaded") + controlpanel = RedirectsControlPanel(self.context, self.request) storage = getUtility(IRedirectionStorage) - aliases = data.get("items", []) + status = IStatusMessage(self.request) + portal = getSite() + file = form["file"] + controlpanel.upload(file, portal, storage, status) + file.close() + + if err := status.show(): + if err[0].type == "error": + raise BadRequest(err[0].message) + elif err[0].type == "info": + logger.info(err[0].message) + return self.reply_no_content() + def reply(self): # Disable CSRF protection if "IDisableCSRFProtection" in dir(plone.protect.interfaces): alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection) + if "multipart/form-data" in self.request.getHeader("Content-Type"): + return self._reply_csv() + + storage = getUtility(IRedirectionStorage) + data = json_body(self.request) + aliases = data.get("items", []) for alias in aliases: redirection = alias.get("path") diff --git a/src/plone/restapi/services/aliases/configure.zcml b/src/plone/restapi/services/aliases/configure.zcml index e3291cf161..c6499d117a 100644 --- a/src/plone/restapi/services/aliases/configure.zcml +++ b/src/plone/restapi/services/aliases/configure.zcml @@ -12,6 +12,15 @@ name="@aliases" /> + + Date: Tue, 17 Sep 2024 13:02:54 +0500 Subject: [PATCH 2/3] Added tests for alias service. --- src/plone/restapi/services/aliases/add.py | 8 +- .../restapi/tests/test_services_aliases.py | 162 ++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/plone/restapi/tests/test_services_aliases.py diff --git a/src/plone/restapi/services/aliases/add.py b/src/plone/restapi/services/aliases/add.py index 20ea3466b8..ab8af1ed2a 100644 --- a/src/plone/restapi/services/aliases/add.py +++ b/src/plone/restapi/services/aliases/add.py @@ -1,4 +1,5 @@ from DateTime import DateTime +from DateTime.interfaces import DateTimeError from plone.app.redirector.interfaces import IRedirectionStorage from plone.restapi import _ from plone.restapi.deserializer import json_body @@ -118,7 +119,6 @@ def reply(self): storage = getUtility(IRedirectionStorage) data = json_body(self.request) aliases = data.get("items", []) - for alias in aliases: redirection = alias.get("path") target = alias.get("redirect-to") @@ -140,7 +140,11 @@ def reply(self): date = alias.get("datetime", None) if date: - date = DateTime(date) + try: + date = DateTime(date) + except DateTimeError: + logger.warning("Failed to parse as DateTime: %s", date) + date = None storage.add(abs_redirection, abs_target, now=date, manual=True) diff --git a/src/plone/restapi/tests/test_services_aliases.py b/src/plone/restapi/tests/test_services_aliases.py new file mode 100644 index 0000000000..27e4ca693e --- /dev/null +++ b/src/plone/restapi/tests/test_services_aliases.py @@ -0,0 +1,162 @@ +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import TEST_USER_ID +from plone.app.testing import setRoles +from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING +from plone.restapi.testing import RelativeSession + +import transaction +import unittest + + +class TestAliases(unittest.TestCase): + + layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url, test=self) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + self.portal.invokeFactory("Document", id="front-page") + transaction.commit() + + def tearDown(self): + self.api_session.close() + + def test_alias_non_root(self): + data = { + "items": [ + { + "path": "/alias-with-date", + "redirect-to": "/front-page", + "datetime": "2024-09-17T12:00:00", + } + ] + } + response = self.api_session.post("/front-page/@aliases", json=data) + self.assertEqual(response.status_code, 204) + + # Verify alias exists + response = self.api_session.get("/front-page/@aliases") + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.json()["items"]), 1) + + def test_alias_add_invalid_datetime(self): + """Test POST /@aliases with an invalid datetime, makes invalid date=None""" + data = { + "items": [ + { + "path": "/alias-with-valid-date", + "redirect-to": "/front-page", + "datetime": "2024-09-17T12:00:00", + }, + { + "path": "/alias-with-invalid-date", + "redirect-to": "/front-page", + "datetime": "invalid-date", + }, + ] + } + response = self.api_session.post("/@aliases", json=data) + self.assertEqual(response.status_code, 204) + response = self.api_session.get("/@aliases") + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.json()["items"]), 2) + + def test_alias_add_invalid_path(self): + """Test POST /@aliases with an invalid path""" + + data = {"items": [{"path": "/valid-path", "redirect-to": "invalid-redirect"}]} + response = self.api_session.post("/@aliases", json=data) + self.assertEqual(response.status_code, 400) + response = self.api_session.get("/@aliases") + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.json()["items"]), 0) + + def test_duplicate_alias(self): + data = { + "items": [ + {"path": "/duplicate-alias", "redirect-to": "/front-page"}, + {"path": "/duplicate-alias", "redirect-to": "/front-page"}, + ] + } + self.api_session.post("/@aliases", json=data) + response = self.api_session.post("/@aliases", json=data) + self.assertEqual(response.status_code, 400) + + def test_alias_without_redirect(self): + data = {"items": [{"path": "/alias-without-redirect"}]} + response = self.api_session.post("/@aliases", json=data) + self.assertEqual(response.status_code, 400) + + def test_alias_csv_upload(self): + """Test POST /@aliases for CSV upload""" + + content = b"old path,new path,datetime,manual\n/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True\n" + + response = self.api_session.post( + "/@aliases", + files={"file": ("aliases.csv", content, "text/csv")}, + ) + + self.assertEqual(response.status_code, 204) + self.assertEqual(response.content, b"") + response = self.api_session.get("/@aliases") + self.assertEqual(response.status_code, 201) + self.assertEqual( + response.json().get("items"), + [ + { + "datetime": "2022-01-01T00:00:00+00:00", + "manual": True, + "path": "/old-page", + "redirect-to": "/front-page", + } + ], + ) + + def test_alias_csv_download(self): + """Test GET /@aliases with CSV output""" + + data = { + "items": [ + { + "path": "/alias-page", + "redirect-to": "/front-page", + "datetime": "2022/01/01 00:00:00 GMT+0", + }, + ] + } + self.api_session.post("/@aliases", json=data) + headers = {"Accept": "text/csv"} + response = self.api_session.get("/@aliases", headers=headers) + self.assertEqual(response.status_code, 201) + self.assertIn("Content-Disposition", response.headers) + self.assertEqual(response.headers["Content-Type"], "text/csv; charset=utf-8") + content = b"old path,new path,datetime,manual\r\n/alias-page,/front-page,2022/01/01 00:00:00 GMT+0,True\r\n" + self.assertEqual(content, response.content) + + def test_alias_delete(self): + data = {"items": [{"path": "/alias-to-delete", "redirect-to": "/front-page"}]} + self.api_session.post("/@aliases", json=data) + response = self.api_session.delete( + "/@aliases", + json={ + "items": [ + { + "path": "/alias-to-delete", + } + ] + }, + ) + self.assertEqual(response.status_code, 204) + + response = self.api_session.get("/@aliases") + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.json()["items"]), 0) From 266175fd767296b2a10e8855b82612a2c867e881 Mon Sep 17 00:00:00 2001 From: Faakhir30 Date: Tue, 17 Sep 2024 13:53:09 +0500 Subject: [PATCH 3/3] updated documentation for aliases service --- docs/source/endpoints/aliases.md | 38 +++++++++++++++++-- ...at.req => aliases_root_add_csv_format.req} | 0 ....resp => aliases_root_add_csv_format.resp} | 0 src/plone/restapi/tests/test_documentation.py | 4 +- 4 files changed, 36 insertions(+), 6 deletions(-) rename src/plone/restapi/tests/http-examples/{aliases_root_post_csv_format.req => aliases_root_add_csv_format.req} (100%) rename src/plone/restapi/tests/http-examples/{aliases_root_post_csv_format.resp => aliases_root_add_csv_format.resp} (100%) diff --git a/docs/source/endpoints/aliases.md b/docs/source/endpoints/aliases.md index 46f68342e1..b2007a761b 100644 --- a/docs/source/endpoints/aliases.md +++ b/docs/source/endpoints/aliases.md @@ -70,9 +70,9 @@ Response: :language: http ``` -## Adding URL aliases in bulk +## Adding URL aliases in bulk via JSON -You can add multiple URL aliases for multiple pages by sending a `POST` request to the `/@aliases` endpoint on site `root`. **datetime** parameter is optional: +You can add multiple URL aliases for multiple pages by sending a `POST` request to the `/@aliases` endpoint on site `root` using JSON payload. **datetime** parameter is optional: ```{eval-rst} .. http:example:: curl httpie python-requests @@ -85,10 +85,25 @@ Response: :language: http ``` +## Adding URL aliases in bulk via CSV -## Listing all available aliases +You can add multiple URL aliases for multiple pages by sending a `POST` request to the `/@aliases` endpoint on site `root` using CSV file. **datetime** parameter is optional: -To list all aliases, send a `GET` request to the `/@aliases` endpoint on site `root`: +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req +``` + +Response: + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp +:language: http +``` + + +## Listing all available aliases via JSON + +To list all aliases in JSON format, send a `GET` request to the `/@aliases` endpoint on site `root`: ```{eval-rst} .. http:example:: curl httpie python-requests @@ -101,6 +116,21 @@ Response: :language: http ``` +## Listing all available aliases via CSV + +To download all aliases as a csv file, send a `GET` request to the `/@aliases` endpoint on site `root`: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.req +``` + +Response: + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.resp +:language: http +``` + ## Filter aliases To search for specific aliases, send a `GET` request to the `/@aliases` endpoint on site `root` with a `q` parameter: diff --git a/src/plone/restapi/tests/http-examples/aliases_root_post_csv_format.req b/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req similarity index 100% rename from src/plone/restapi/tests/http-examples/aliases_root_post_csv_format.req rename to src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req diff --git a/src/plone/restapi/tests/http-examples/aliases_root_post_csv_format.resp b/src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp similarity index 100% rename from src/plone/restapi/tests/http-examples/aliases_root_post_csv_format.resp rename to src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py index 43bbd3c619..774bbd94b5 100644 --- a/src/plone/restapi/tests/test_documentation.py +++ b/src/plone/restapi/tests/test_documentation.py @@ -2122,7 +2122,7 @@ def test_aliases_root_get_csv_format(self): response = self.api_session.get(url + query) save_request_and_response_for_docs("aliases_root_get_csv_format", response) - def test_aliases_root_post_csv_format(self): + def test_aliases_root_add_csv_format(self): url = f"{self.portal.absolute_url()}/@aliases" content = b"old path,new path,datetime,manual\n/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True\n" @@ -2148,7 +2148,7 @@ def test_aliases_root_post_csv_format(self): } response = self.api_session.post(url, headers=headers, data=body) - save_request_and_response_for_docs("aliases_root_post_csv_format", response) + save_request_and_response_for_docs("aliases_root_add_csv_format", response) def test_aliases_root_filter(self): # Get aliases