Skip to content

Commit

Permalink
added upload and download aliases in csv format. (#1813)
Browse files Browse the repository at this point in the history
* Added text/CSV content-type support to upload/download aliases in bulk.
* Added aliases unit/integration tests for aliases service.
* Added documentation
  • Loading branch information
Faakhir30 authored Sep 30, 2024
1 parent 682567a commit a653349
Show file tree
Hide file tree
Showing 11 changed files with 370 additions and 16 deletions.
40 changes: 36 additions & 4 deletions docs/source/endpoints/aliases.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ 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 a JSON payload.
**datetime** parameter is optional:

```{eval-rst}
.. http:example:: curl httpie python-requests
Expand All @@ -85,10 +86,26 @@ 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 a 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
Expand All @@ -101,6 +118,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:
Expand Down
1 change: 1 addition & 0 deletions news/1812.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added create and fetch aliases in CSV format. @Faakhir30
39 changes: 35 additions & 4 deletions src/plone/restapi/services/aliases/add.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
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
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)
Expand Down Expand Up @@ -83,15 +90,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")
target = alias.get("redirect-to")
Expand All @@ -113,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)

Expand Down
9 changes: 9 additions & 0 deletions src/plone/restapi/services/aliases/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
name="@aliases"
/>

<plone:service
method="GET"
accept="text/csv"
factory=".get.AliasesGet"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
permission="zope2.View"
name="@aliases"
/>

<plone:service
method="GET"
accept="application/json,application/schema+json"
Expand Down
58 changes: 50 additions & 8 deletions src/plone/restapi/services/aliases/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from zope.component.hooks import getSite
from zope.interface import implementer
from zope.interface import Interface
import json


@implementer(IExpandableElement)
Expand All @@ -26,7 +27,8 @@ def reply_item(self):
redirects = storage.redirects(context_path)
aliases = [deroot_path(alias) for alias in redirects]
self.request.response.setStatus(201)
return [{"path": alias} for alias in aliases]
self.request.response.setHeader("Content-Type", "application/json")
return [{"path": alias} for alias in aliases], len(aliases)

def reply_root(self):
"""
Expand All @@ -48,31 +50,71 @@ def reply_root(self):

newbatch = RedirectsControlPanel(self.context, self.request).redirects()
items_total = len([item for item in newbatch])
self.request.response.setHeader("Content-Type", "application/json")

return redirects, items_total

def reply_root_csv(self):
batch = RedirectsControlPanel(self.context, self.request).redirects()
redirects = [entry for entry in batch]

for redirect in redirects:
del redirect["redirect"]
redirect["datetime"] = datetimelike_to_iso(redirect["datetime"])
self.request.response.setStatus(201)

self.request.form["b_start"] = "0"
self.request.form["b_size"] = "1000000"
self.request.__annotations__.pop("plone.memoize")

filestream = RedirectsControlPanel(self.context, self.request).download()
content = filestream.read()
filestream.close()

self.request.response.setHeader("Content-Type", "text/csv")
self.request.response.setHeader(
"Content-Disposition", "attachment; filename=redirects.csv"
)
self.request.response.setHeader("Content-Length", str(len(content)))
return content

def __call__(self, expand=False):
result = {"aliases": {"@id": f"{self.context.absolute_url()}/@aliases"}}
if not expand:
return result

if IPloneSiteRoot.providedBy(self.context):
items, items_total = self.reply_root()
result["aliases"]["items"] = items
result["aliases"]["items_total"] = items_total
if self.request.getHeader("Accept") == "text/csv":
result["aliases"]["items"] = self.reply_root_csv()
return result
else:
items, items_total = self.reply_root()
else:
result["aliases"]["items"] = self.reply_item()
result["aliases"]["items_total"] = len(result["aliases"]["items"])

items, items_total = self.reply_item()
result["aliases"]["items"] = items
result["aliases"]["items_total"] = items_total
return result


_no_content_marker = object()


class AliasesGet(Service):
"""Get aliases"""

def reply(self):
aliases = Aliases(self.context, self.request)
return aliases(expand=True)["aliases"]

def render(self):
self.check_permission()
content = self.reply()
if self.request.getHeader("Accept") == "text/csv":
return content["items"]
if content is not _no_content_marker:
return json.dumps(
content, indent=2, sort_keys=True, separators=(", ", ": ")
)


def deroot_path(path):
"""Remove the portal root from alias"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
POST /plone/@aliases HTTP/1.1
Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="test_file.csv"
Content-Type: text/csv

old path,new path,datetime,manual
/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True

------WebKitFormBoundary7MA4YWxkTrZu0gW--
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
HTTP/1.1 204 No Content

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GET /plone/@aliases HTTP/1.1
Accept: text/csv
Authorization: Basic YWRtaW46c2VjcmV0
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
HTTP/1.1 201 Created
Content-Type: text/csv; charset=utf-8

old path,new path,datetime,manual
/fizzbuzz,/front-page,2022/05/05 00:00:00 GMT+0,True
/old-page,/front-page,2022/05/05 00:00:00 GMT+0,True
53 changes: 53 additions & 0 deletions src/plone/restapi/tests/test_documentation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from base64 import b64encode
from datetime import datetime
from datetime import timezone
import io
from pkg_resources import resource_filename
from plone import api
from plone.app.discussion.interfaces import ICommentAddedEvent
Expand Down Expand Up @@ -2097,6 +2098,58 @@ def test_aliases_root_get(self):
response = self.api_session.get(url + query)
save_request_and_response_for_docs("aliases_root_get", response)

def test_aliases_root_get_csv_format(self):
url = f"{self.portal.absolute_url()}/@aliases"
query = ""

payload = {
"items": [
{
"path": "/old-page",
"redirect-to": "/front-page",
"datetime": "2022-05-05",
},
{
"path": "/fizzbuzz",
"redirect-to": "/front-page",
"datetime": "2022-05-05",
},
]
}
response = self.api_session.post(url, json=payload)
self.api_session.headers.update({"Content-Type": "application/json"})
self.api_session.headers.update({"Accept": "text/csv"})
response = self.api_session.get(url + query)
save_request_and_response_for_docs("aliases_root_get_csv_format", response)

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"
csv_file = io.BytesIO(content)
csv_file.name = "test_file.csv"

# Setting a fixed boundary intentionally to make the producing .req and .resp files deterministic
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"

# Manually construct the multipart body
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{csv_file.name}"\r\n'
"Content-Type: text/csv\r\n\r\n"
f"{content.decode()}\r\n"
f"--{boundary}--\r\n"
)

headers = {
"Accept": "application/json",
"Authorization": "Basic YWRtaW46c2VjcmV0",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}

response = self.api_session.post(url, headers=headers, data=body)
save_request_and_response_for_docs("aliases_root_add_csv_format", response)

def test_aliases_root_filter(self):
# Get aliases
url = f"{self.portal.absolute_url()}/@aliases"
Expand Down
Loading

0 comments on commit a653349

Please sign in to comment.