Skip to content

Commit a653349

Browse files
authored
added upload and download aliases in csv format. (#1813)
* Added text/CSV content-type support to upload/download aliases in bulk. * Added aliases unit/integration tests for aliases service. * Added documentation
1 parent 682567a commit a653349

File tree

11 files changed

+370
-16
lines changed

11 files changed

+370
-16
lines changed

docs/source/endpoints/aliases.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ Response:
7070
:language: http
7171
```
7272

73-
## Adding URL aliases in bulk
73+
## Adding URL aliases in bulk via JSON
7474

75-
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:
75+
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.
76+
**datetime** parameter is optional:
7677

7778
```{eval-rst}
7879
.. http:example:: curl httpie python-requests
@@ -85,10 +86,26 @@ Response:
8586
:language: http
8687
```
8788

89+
## Adding URL aliases in bulk via CSV
8890

89-
## Listing all available aliases
91+
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.
92+
**datetime** parameter is optional:
9093

91-
To list all aliases, send a `GET` request to the `/@aliases` endpoint on site `root`:
94+
```{eval-rst}
95+
.. http:example:: curl httpie python-requests
96+
:request: ../../../src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.req
97+
```
98+
99+
Response:
100+
101+
```{literalinclude} ../../../src/plone/restapi/tests/http-examples/aliases_root_add_csv_format.resp
102+
:language: http
103+
```
104+
105+
106+
## Listing all available aliases via JSON
107+
108+
To list all aliases in JSON format, send a `GET` request to the `/@aliases` endpoint on site `root`:
92109

93110
```{eval-rst}
94111
.. http:example:: curl httpie python-requests
@@ -101,6 +118,21 @@ Response:
101118
:language: http
102119
```
103120

121+
## Listing all available aliases via CSV
122+
123+
To download all aliases as a CSV file, send a `GET` request to the `/@aliases` endpoint on site `root`:
124+
125+
```{eval-rst}
126+
.. http:example:: curl httpie python-requests
127+
:request: ../../../src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.req
128+
```
129+
130+
Response:
131+
132+
```{literalinclude} ../../../src/plone/restapi/tests/http-examples/aliases_root_get_csv_format.resp
133+
:language: http
134+
```
135+
104136
## Filter aliases
105137

106138
To search for specific aliases, send a `GET` request to the `/@aliases` endpoint on site `root` with a `q` parameter:

news/1812.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added create and fetch aliases in CSV format. @Faakhir30

src/plone/restapi/services/aliases/add.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
from DateTime import DateTime
2+
from DateTime.interfaces import DateTimeError
23
from plone.app.redirector.interfaces import IRedirectionStorage
34
from plone.restapi import _
45
from plone.restapi.deserializer import json_body
56
from plone.restapi.services import Service
67
from Products.CMFPlone.controlpanel.browser.redirects import absolutize_path
8+
from Products.CMFPlone.controlpanel.browser.redirects import RedirectsControlPanel
9+
from Products.statusmessages.interfaces import IStatusMessage
710
from zExceptions import BadRequest
811
from zope.component import getMultiAdapter
12+
from zope.component.hooks import getSite
913
from zope.component import getUtility
1014
from zope.interface import alsoProvides
1115
from zope.interface import implementer
1216
from zope.publisher.interfaces import IPublishTraverse
1317

1418
import plone.protect.interfaces
19+
import logging
20+
21+
logger = logging.getLogger("Plone")
1522

1623

1724
@implementer(IPublishTraverse)
@@ -83,15 +90,35 @@ def edit_for_navigation_root(self, alias):
8390
class AliasesRootPost(Service):
8491
"""Creates new aliases via controlpanel"""
8592

86-
def reply(self):
87-
data = json_body(self.request)
93+
def _reply_csv(self):
94+
form = self.request.form
95+
if not form.get("file"):
96+
raise BadRequest("No file uploaded")
97+
controlpanel = RedirectsControlPanel(self.context, self.request)
8898
storage = getUtility(IRedirectionStorage)
89-
aliases = data.get("items", [])
99+
status = IStatusMessage(self.request)
100+
portal = getSite()
101+
file = form["file"]
102+
controlpanel.upload(file, portal, storage, status)
103+
file.close()
104+
105+
if err := status.show():
106+
if err[0].type == "error":
107+
raise BadRequest(err[0].message)
108+
elif err[0].type == "info":
109+
logger.info(err[0].message)
110+
return self.reply_no_content()
90111

112+
def reply(self):
91113
# Disable CSRF protection
92114
if "IDisableCSRFProtection" in dir(plone.protect.interfaces):
93115
alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection)
116+
if "multipart/form-data" in self.request.getHeader("Content-Type"):
117+
return self._reply_csv()
94118

119+
storage = getUtility(IRedirectionStorage)
120+
data = json_body(self.request)
121+
aliases = data.get("items", [])
95122
for alias in aliases:
96123
redirection = alias.get("path")
97124
target = alias.get("redirect-to")
@@ -113,7 +140,11 @@ def reply(self):
113140

114141
date = alias.get("datetime", None)
115142
if date:
116-
date = DateTime(date)
143+
try:
144+
date = DateTime(date)
145+
except DateTimeError:
146+
logger.warning("Failed to parse as DateTime: %s", date)
147+
date = None
117148

118149
storage.add(abs_redirection, abs_target, now=date, manual=True)
119150

src/plone/restapi/services/aliases/configure.zcml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
name="@aliases"
1313
/>
1414

15+
<plone:service
16+
method="GET"
17+
accept="text/csv"
18+
factory=".get.AliasesGet"
19+
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
20+
permission="zope2.View"
21+
name="@aliases"
22+
/>
23+
1524
<plone:service
1625
method="GET"
1726
accept="application/json,application/schema+json"

src/plone/restapi/services/aliases/get.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from zope.component.hooks import getSite
1010
from zope.interface import implementer
1111
from zope.interface import Interface
12+
import json
1213

1314

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

3133
def reply_root(self):
3234
"""
@@ -48,31 +50,71 @@ def reply_root(self):
4850

4951
newbatch = RedirectsControlPanel(self.context, self.request).redirects()
5052
items_total = len([item for item in newbatch])
53+
self.request.response.setHeader("Content-Type", "application/json")
54+
5155
return redirects, items_total
5256

57+
def reply_root_csv(self):
58+
batch = RedirectsControlPanel(self.context, self.request).redirects()
59+
redirects = [entry for entry in batch]
60+
61+
for redirect in redirects:
62+
del redirect["redirect"]
63+
redirect["datetime"] = datetimelike_to_iso(redirect["datetime"])
64+
self.request.response.setStatus(201)
65+
66+
self.request.form["b_start"] = "0"
67+
self.request.form["b_size"] = "1000000"
68+
self.request.__annotations__.pop("plone.memoize")
69+
70+
filestream = RedirectsControlPanel(self.context, self.request).download()
71+
content = filestream.read()
72+
filestream.close()
73+
74+
self.request.response.setHeader("Content-Type", "text/csv")
75+
self.request.response.setHeader(
76+
"Content-Disposition", "attachment; filename=redirects.csv"
77+
)
78+
self.request.response.setHeader("Content-Length", str(len(content)))
79+
return content
80+
5381
def __call__(self, expand=False):
5482
result = {"aliases": {"@id": f"{self.context.absolute_url()}/@aliases"}}
5583
if not expand:
5684
return result
57-
5885
if IPloneSiteRoot.providedBy(self.context):
59-
items, items_total = self.reply_root()
60-
result["aliases"]["items"] = items
61-
result["aliases"]["items_total"] = items_total
86+
if self.request.getHeader("Accept") == "text/csv":
87+
result["aliases"]["items"] = self.reply_root_csv()
88+
return result
89+
else:
90+
items, items_total = self.reply_root()
6291
else:
63-
result["aliases"]["items"] = self.reply_item()
64-
result["aliases"]["items_total"] = len(result["aliases"]["items"])
65-
92+
items, items_total = self.reply_item()
93+
result["aliases"]["items"] = items
94+
result["aliases"]["items_total"] = items_total
6695
return result
6796

6897

98+
_no_content_marker = object()
99+
100+
69101
class AliasesGet(Service):
70102
"""Get aliases"""
71103

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

108+
def render(self):
109+
self.check_permission()
110+
content = self.reply()
111+
if self.request.getHeader("Accept") == "text/csv":
112+
return content["items"]
113+
if content is not _no_content_marker:
114+
return json.dumps(
115+
content, indent=2, sort_keys=True, separators=(", ", ": ")
116+
)
117+
76118

77119
def deroot_path(path):
78120
"""Remove the portal root from alias"""
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
POST /plone/@aliases HTTP/1.1
2+
Accept: application/json
3+
Authorization: Basic YWRtaW46c2VjcmV0
4+
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
5+
6+
------WebKitFormBoundary7MA4YWxkTrZu0gW
7+
Content-Disposition: form-data; name="file"; filename="test_file.csv"
8+
Content-Type: text/csv
9+
10+
old path,new path,datetime,manual
11+
/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True
12+
13+
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
HTTP/1.1 204 No Content
2+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
GET /plone/@aliases HTTP/1.1
2+
Accept: text/csv
3+
Authorization: Basic YWRtaW46c2VjcmV0
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
HTTP/1.1 201 Created
2+
Content-Type: text/csv; charset=utf-8
3+
4+
old path,new path,datetime,manual
5+
/fizzbuzz,/front-page,2022/05/05 00:00:00 GMT+0,True
6+
/old-page,/front-page,2022/05/05 00:00:00 GMT+0,True

src/plone/restapi/tests/test_documentation.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from base64 import b64encode
22
from datetime import datetime
33
from datetime import timezone
4+
import io
45
from pkg_resources import resource_filename
56
from plone import api
67
from plone.app.discussion.interfaces import ICommentAddedEvent
@@ -2097,6 +2098,58 @@ def test_aliases_root_get(self):
20972098
response = self.api_session.get(url + query)
20982099
save_request_and_response_for_docs("aliases_root_get", response)
20992100

2101+
def test_aliases_root_get_csv_format(self):
2102+
url = f"{self.portal.absolute_url()}/@aliases"
2103+
query = ""
2104+
2105+
payload = {
2106+
"items": [
2107+
{
2108+
"path": "/old-page",
2109+
"redirect-to": "/front-page",
2110+
"datetime": "2022-05-05",
2111+
},
2112+
{
2113+
"path": "/fizzbuzz",
2114+
"redirect-to": "/front-page",
2115+
"datetime": "2022-05-05",
2116+
},
2117+
]
2118+
}
2119+
response = self.api_session.post(url, json=payload)
2120+
self.api_session.headers.update({"Content-Type": "application/json"})
2121+
self.api_session.headers.update({"Accept": "text/csv"})
2122+
response = self.api_session.get(url + query)
2123+
save_request_and_response_for_docs("aliases_root_get_csv_format", response)
2124+
2125+
def test_aliases_root_add_csv_format(self):
2126+
url = f"{self.portal.absolute_url()}/@aliases"
2127+
2128+
content = b"old path,new path,datetime,manual\n/old-page,/front-page,2022/01/01 00:00:00 GMT+0,True\n"
2129+
csv_file = io.BytesIO(content)
2130+
csv_file.name = "test_file.csv"
2131+
2132+
# Setting a fixed boundary intentionally to make the producing .req and .resp files deterministic
2133+
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
2134+
2135+
# Manually construct the multipart body
2136+
body = (
2137+
f"--{boundary}\r\n"
2138+
f'Content-Disposition: form-data; name="file"; filename="{csv_file.name}"\r\n'
2139+
"Content-Type: text/csv\r\n\r\n"
2140+
f"{content.decode()}\r\n"
2141+
f"--{boundary}--\r\n"
2142+
)
2143+
2144+
headers = {
2145+
"Accept": "application/json",
2146+
"Authorization": "Basic YWRtaW46c2VjcmV0",
2147+
"Content-Type": f"multipart/form-data; boundary={boundary}",
2148+
}
2149+
2150+
response = self.api_session.post(url, headers=headers, data=body)
2151+
save_request_and_response_for_docs("aliases_root_add_csv_format", response)
2152+
21002153
def test_aliases_root_filter(self):
21012154
# Get aliases
21022155
url = f"{self.portal.absolute_url()}/@aliases"

0 commit comments

Comments
 (0)