# -*- coding: utf-8 -*-
import logging
from plone import api as ploneapi
from plone.jsonapi.core import router
from AccessControl import Unauthorized
from Products.CMFCore.interfaces import ISiteRoot
from Products.CMFCore.interfaces import IFolderish
from Products.ZCatalog.interfaces import ICatalogBrain
from Products.CMFPlone.PloneBatch import Batch
# search helpers
from query import search
from query import make_query
# request helpers
from plone.jsonapi.routes import request as req
from plone.jsonapi.routes.exceptions import APIError
from plone.jsonapi.routes.interfaces import IInfo
from plone.jsonapi.routes.interfaces import IBatch
from plone.jsonapi.routes.interfaces import IDataManager
from plone.jsonapi.routes import underscore as _
__author__ = 'Ramon Bartl <ramon.bartl@googlemail.com>'
__docformat__ = 'plaintext'
logger = logging.getLogger("plone.jsonapi.routes")
_marker = object()
PORTAL_IDS = ["0", "portal", "site", "plone", "root"]
# -----------------------------------------------------------------------------
# Json API (CRUD) Functions
# -----------------------------------------------------------------------------
# GET RECORD
[docs]def get_record(uid=None):
""" returns a single record
"""
obj = None
if uid is not None:
obj = get_object_by_uid(uid)
else:
form = req.get_form()
obj = get_object_by_record(form)
if obj is None:
raise APIError(404, "No object found")
complete = req.get_complete(default=_marker)
if complete is _marker:
complete = True
items = make_items_for([obj], complete=complete)
return _.first(items)
# GET
[docs]def get_items(portal_type=None, request=None, uid=None, endpoint=None):
""" returns a list of items
1. If the UID is given, fetch the object directly => should return 1 item
2. If no UID is given, search for all items of the given portal_type
"""
# fetch the catalog results
results = get_search_results(portal_type=portal_type, uid=uid)
# check for existing complete flag
complete = req.get_complete(default=_marker)
if complete is _marker:
# if the uid is given, get the complete information set
complete = uid and True or False
return make_items_for(results, endpoint, complete=complete)
# GET BATCHED
[docs]def get_batched(portal_type=None, request=None, uid=None, endpoint=None):
""" returns a batched result record (dictionary)
"""
# fetch the catalog results
results = get_search_results(portal_type=portal_type, uid=uid)
# fetch the batch params from the request
size = req.get_batch_size()
start = req.get_batch_start()
# check for existing complete flag
complete = req.get_complete(default=_marker)
if complete is _marker:
# if the uid is given, get the complete information set
complete = uid and True or False
# return a batched record
return get_batch(results, size, start, endpoint=endpoint,
complete=complete)
# CREATE
[docs]def create_items(portal_type=None, request=None, uid=None, endpoint=None):
""" create items
1. If the uid is given, get the object and create the content in there
(assumed that it is folderish)
2. If the uid is 0, the target folder is assumed the portal.
3. If there is no uid given, the payload is checked for either a key
- `parent_uid` specifies the *uid* of the target folder
- `parent_path` specifies the *physical path* of the target folder
"""
# destination where to create the content
dest = uid and get_object_by_uid(uid) or None
# extract the data from the request
records = req.get_request_data()
results = []
for record in records:
if dest is None:
# find the container for content creation
dest = find_target_container(record)
if portal_type is None:
portal_type = record.get("portal_type", None)
id = record.get("id", None)
title = record.get("title", None)
obj = create_object_in_container(dest, portal_type, id=id, title=title)
# update the object
update_object_with_data(obj, record)
results.append(obj)
if not results:
raise APIError(400, "No Objects could be created")
return make_items_for(results, endpoint=endpoint)
# UPDATE
[docs]def update_items(portal_type=None, request=None, uid=None, endpoint=None):
""" update items
1. If the uid is given, the user wants to update the object with the data
given in request body
2. If no uid is given, the user wants to update a bunch of objects.
-> each record contains either an UID, path or parent_path + id
"""
# the data to update
records = req.get_request_data()
# we have an uid -> try to get an object for it
obj = get_object_by_uid(uid)
if obj:
record = records[0] # ignore other records if we got an uid
obj = update_object_with_data(obj, record)
return make_items_for([obj], endpoint=endpoint)
# no uid -> go through the record items
results = []
for record in records:
obj = get_object_by_record(record)
# no object found for this record
if obj is None:
continue
# update the object with the given record data
obj = update_object_with_data(obj, record)
results.append(obj)
if not results:
raise APIError(400, "No Objects could be updated")
return make_items_for(results, endpoint=endpoint)
# DELETE
[docs]def delete_items(portal_type=None, request=None, uid=None, endpoint=None):
""" delete items
1. If the uid is given, we can ignore the request body and delete the
object with the given uid (if the uid was valid).
2. If no uid is given, the user wants to delete more than one item.
=> go through each item and extract the uid. Delete it afterwards.
// we should do this kind of transaction base. So if we can not get an
// object for an uid, no item will be deleted.
3. we could check if the portal_type matches, just to be sure the user
wants to delete the right content.
"""
# try to find the requested objects
objects = find_objects(uid=uid)
# We don't want to delete the portal object
if filter(lambda o: is_root(o), objects):
raise APIError(400, "Can not delete the portal object")
results = []
for obj in objects:
info = IInfo(obj)()
info["deleted"] = delete_object(obj)
results.append(info)
if not results:
raise APIError(404, "No Objects could be found")
return results
# CUT
[docs]def cut_items(portal_type=None, request=None, uid=None, endpoint=None):
""" cut items
"""
# try to find the requested objects
objects = find_objects(uid=uid)
# No objects could be found, bail out
if not objects:
raise APIError(404, "No Objects could be found")
# We support only to cut a single object
if len(objects) > 1:
raise APIError(400, "Can only cut one object at a time")
# We don't want to cut the portal object
if filter(lambda o: is_root(o), objects):
raise APIError(400, "Can not cut the portal object")
# cut the object
obj = objects[0]
obj.aq_parent.manage_cutObjects(obj.getId(), REQUEST=request)
request.response.setHeader("Content-Type", "application/json")
info = IInfo(obj)()
return [info]
# COPY
[docs]def copy_items(portal_type=None, request=None, uid=None, endpoint=None):
""" copy items
"""
# try to find the requested objects
objects = find_objects(uid=uid)
# No objects could be found, bail out
if not objects:
raise APIError(404, "No Objects could be found")
# We support only to copy a single object
if len(objects) > 1:
raise APIError(400, "Can only copy one object at a time")
# We don't want to copy the portal object
if filter(lambda o: is_root(o), objects):
raise APIError(400, "Can not copy the portal object")
# cut the object
obj = objects[0]
obj.aq_parent.manage_copyObjects(obj.getId(), REQUEST=request)
request.response.setHeader("Content-Type", "application/json")
info = IInfo(obj)()
return [info]
# PASTE
[docs]def paste_items(portal_type=None, request=None, uid=None, endpoint=None):
""" paste items
"""
# try to find the requested objects
objects = find_objects(uid=uid)
# No objects could be found, bail out
if not objects:
raise APIError(404, "No Objects could be found")
# check if the cookie is there
cookie = req.get_cookie("__cp")
if cookie is None:
raise APIError(400, "No data found to paste")
# We support only to copy a single object
if len(objects) > 1:
raise APIError(400, "Can only paste to one location")
# cut the object
obj = objects[0]
# paste the object
results = obj.manage_pasteObjects(cookie)
out = []
for result in results:
new_id = result.get("new_id")
pasted = obj.get(new_id)
if pasted:
out.append(IInfo(pasted)())
return out
# -----------------------------------------------------------------------------
# Data Functions
# -----------------------------------------------------------------------------
[docs]def get_search_results(**kw):
"""Search the catalog and return the results
:returns: Catalog search results
:rtype: list/Products.ZCatalog.Lazy.LazyMap
"""
# allow to search for the Plone site
if kw.get("portal_type") == "Plone Site":
return [get_portal()]
elif kw.get("id") in PORTAL_IDS:
return [get_portal()]
elif kw.get("uid") in PORTAL_IDS:
return [get_portal()]
# build and execute a catalog query
query = make_query(**kw)
return search(query)
[docs]def make_items_for(brains_or_objects, endpoint=None, complete=False):
"""Generate API compatible data items for the given list of brains/objects
:param brains_or_objects: List of objects or brains
:type brains_or_objects: list/Products.ZCatalog.Lazy.LazyMap
:param endpoint: The named URL endpoint for the root of the items
:type endpoint: str/unicode
:param complete: Flag to wake up the object and fetch all data
:type complete: bool
:returns: A list of extracted data items
:rtype: list
"""
# check if the user wants to include children
include_children = req.get_children(False)
def extract_data(brain_or_object):
info = get_info(brain_or_object, endpoint=endpoint, complete=complete)
if include_children and is_folderish(brain_or_object):
info.update(get_children_info(brain_or_object, complete=complete))
return info
return map(extract_data, brains_or_objects)
[docs]def get_info(brain_or_object, endpoint=None, complete=False):
"""Extract the data from the catalog brain or object
:param brain_or_object: A single catalog brain or content object
:type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
:param endpoint: The named URL endpoint for the root of the items
:type endpoint: str/unicode
:param complete: Flag to wake up the object and fetch all data
:type complete: bool
:returns: Data mapping for the object/catalog brain
:rtype: dict
"""
# extract the data from the initial object with the proper adapter
info = IInfo(brain_or_object).to_dict()
# update with url info (always included)
url_info = get_url_info(brain_or_object, endpoint)
info.update(url_info)
# include the parent url info
parent = get_parent_info(brain_or_object)
info.update(parent)
# add the complete data of the object if requested
# -> requires to wake up the object if it is a catalog brain
if complete:
# ensure we have a full content object
obj = get_object(brain_or_object)
# get the compatible adapter
adapter = IInfo(obj)
# update the data set with the complete information
info.update(adapter.to_dict())
# add workflow data if the user requested it
# -> only possible if `?complete=yes`
if req.get_workflow(False):
workflow = get_workflow_info(obj)
info.update({"workflow": workflow})
return info
[docs]def get_url_info(brain_or_object, endpoint=None):
"""Generate url information for the content object/catalog brain
:param brain_or_object: A single catalog brain or content object
:type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
:param endpoint: The named URL endpoint for the root of the items
:type endpoint: str/unicode
:returns: URL information mapping
:rtype: dict
"""
# If no endpoint was given, guess the endpoint by portal type
if endpoint is None:
endpoint = get_endpoint(brain_or_object)
uid = get_uid(brain_or_object)
return {
"uid": uid,
"url": get_url(brain_or_object),
"api_url": url_for(endpoint, uid=uid),
}
[docs]def get_workflow_info(brain_or_object, endpoint=None):
"""Generate workflow information of the (first) assigned workflow
:param brain_or_object: A single catalog brain or content object
:type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
:param endpoint: The named URL endpoint for the root of the items
:type endpoint: str/unicode
:returns: Workflow information mapping
:rtype: dict
"""
# ensure we have a full content object
obj = get_object(brain_or_object)
# get the portal workflow tool
wf_tool = get_tool("portal_workflow")
# the assigned workflows of this object
wfs = wf_tool.getWorkflowsFor(obj)
# no worfkflows assigned -> return
if not wfs:
return {}
# get the first one
workflow = wfs[0]
# get the status info of the current state (dictionary)
status = wf_tool.getStatusOf(workflow.getId(), obj)
# https://github.com/collective/plone.jsonapi.routes/issues/33
if not status:
return {}
# get the current review_status
current_state_id = status.get("review_state", None)
# get the wf status object
current_status = workflow.states[current_state_id]
# get the title of the current status
current_state_title = current_status.title
def to_transition_info(transition):
""" return the transition information
"""
return {
"value": transition["id"],
"display": transition["description"],
"url": transition["url"],
}
# get the transition informations
transitions = map(to_transition_info, wf_tool.getTransitionsFor(obj))
return {
"workflow": workflow.getId(),
"status": current_state_title,
"review_state": current_state_id,
"transitions": transitions
}
[docs]def get_parent_info(brain_or_object, endpoint=None):
"""Generate url information for the parent object
:param brain_or_object: A single catalog brain or content object
:type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
:param endpoint: The named URL endpoint for the root of the items
:type endpoint: str/unicode
:returns: URL information mapping
:rtype: dict
"""
# special case for the portal object
if is_root(brain_or_object):
return {}
# get the parent object
parent = get_parent(brain_or_object)
# fall back if no endpoint specified
if endpoint is None:
endpoint = get_endpoint(parent)
# return portal information
if is_root(parent):
return {
"parent_id": get_id(parent),
"parent_uid": 0,
"parent_url": url_for("plonesites", uid=0),
}
return {
"parent_id": get_id(parent),
"parent_uid": get_uid(parent),
"parent_url": url_for(endpoint, uid=get_uid(parent))
}
[docs]def get_children_info(brain_or_object, complete=False):
"""Generate data items of the contained contents
:param brain_or_object: A single catalog brain or content object
:type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
:param complete: Flag to wake up the object and fetch all data
:type complete: bool
:returns: info mapping of contained content items
:rtype: list
"""
# fetch the contents (if folderish)
children = get_contents(brain_or_object)
def extract_data(brain_or_object):
return get_info(brain_or_object, complete=complete)
items = map(extract_data, children)
return {
"children_count": len(items),
"children": items
}
# -----------------------------------------------------------------------------
# Batching Helpers
# -----------------------------------------------------------------------------
[docs]def get_batch(sequence, size, start=0, endpoint=None, complete=False):
""" create a batched result record out of a sequence (catalog brains)
"""
# we call an adapter here to allow backwards compatibility hooks
batch = IBatch(Batch(sequence, size, start))
return {
"pagesize": batch.get_pagesize(),
"next": batch.make_next_url(),
"previous": batch.make_prev_url(),
"page": batch.get_pagenumber(),
"pages": batch.get_numpages(),
"count": batch.get_sequence_length(),
"items": make_items_for([b for b in batch.get_batch()],
endpoint, complete=complete),
}
# -----------------------------------------------------------------------------
# Functional Helpers
# -----------------------------------------------------------------------------
[docs]def get_portal():
""" get the Plone site
"""
return ploneapi.portal.getSite()
[docs]def get_portal_catalog():
""" get the portal_catalog tool
"""
return get_tool("portal_catalog")
[docs]def get_portal_reference_catalog():
""" return reference_catalog tool
"""
return get_tool("reference_catalog")
[docs]def get_portal_workflow():
""" return portal_workflow tool
"""
return get_tool("portal_workflow")
[docs]def is_brain(brain_or_object):
""" checks if the object is a catalog brain
"""
return ICatalogBrain.providedBy(brain_or_object)
[docs]def is_root(brain_or_object):
""" checks if the object is the site root
"""
return ISiteRoot.providedBy(brain_or_object)
[docs]def is_folderish(brain_or_object):
""" checks if the object is folderish
"""
if is_brain(brain_or_object):
return brain_or_object.is_folderish
return IFolderish.providedBy(brain_or_object)
[docs]def get_locally_allowed_types(obj):
""" get the locally allowed types of this object
"""
if not is_folderish(obj):
return []
method = getattr(obj, "getLocallyAllowedTypes", None)
if not callable(method):
return []
return method()
[docs]def url_for(endpoint, **values):
""" returns the api url
"""
try:
return router.url_for(endpoint, force_external=True, values=values)
except Exception:
# XXX plone.jsonapi.core should catch the BuildError of Werkzeug and
# throw another error which can be handled here.
logger.debug("Could not build API URL for endpoint '%s'. "
"No route provider registered?" % endpoint)
return None
[docs]def get_url(obj):
""" get the absolute url for this object
"""
if is_brain(obj):
return obj.getURL()
return obj.absolute_url()
[docs]def get_id(brain_or_object):
""" get the ID of the brain/object
"""
if is_brain(brain_or_object):
return brain_or_object.getId
return brain_or_object.getId()
[docs]def get_uid(obj):
""" get the UID of the brain/object
"""
if is_brain(obj):
return obj.UID
if is_root(obj):
return 0
return obj.UID()
[docs]def get_portal_type(brain_or_object):
""" return the portal type of this object
"""
return brain_or_object.portal_type
[docs]def get_object(brain_or_object):
""" return the referenced object
"""
if is_brain(brain_or_object):
return brain_or_object.getObject()
return brain_or_object
[docs]def get_parent(brain_or_object):
"""Locate the parent object of the content/catalog brain
:param brain_or_object: A single catalog brain or content object
:type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
:returns: parent object
:rtype: ATContentType/DexterityContentType/PloneSite/CatalogBrain
"""
if is_brain(brain_or_object):
parent_path = get_parent_path(brain_or_object)
# parent is the portal object
if parent_path == get_path(get_portal()):
return get_portal()
# query for the parent path
pc = get_portal_catalog()
results = pc(path={
"query": parent_path,
"depth": 0})
# fallback to the object
if not results:
return get_object(brain_or_object).aq_parent
# return the brain
return results[0]
return brain_or_object.aq_parent
[docs]def get_parent_path(brain_or_object):
"""Calculate the parent path
"""
if is_brain(brain_or_object):
path = get_path(brain_or_object)
return path.rpartition("/")[0]
return get_path(brain_or_object.aq_parent)
[docs]def get_path(brain_or_object):
""" return the physical path
"""
if is_brain(brain_or_object):
return brain_or_object.getPath()
return "/".join(brain_or_object.getPhysicalPath())
[docs]def get_contents(brain_or_object, depth=1):
""" return the folder contents
"""
pc = get_portal_catalog()
contents = pc(path={
"query": get_path(brain_or_object),
"depth": depth})
return contents
[docs]def get_endpoint(brain_or_object):
""" get the endpoint for this object
The endpoint is used to generate the api url for this content.
"""
portal_type = get_portal_type(brain_or_object)
# handle portal types with dots
portal_type = portal_type.split(".").pop()
# remove whitespaces
portal_type = portal_type.replace(" ", "")
# lower and pluralize
portal_type = portal_type.lower() + "s"
return portal_type
[docs]def find_objects(uid=None):
""" locate objects
1. get the object from the given uid
2. fetch objects specified in the request parameters
3. fetch objects located in the request body
"""
# The objects to cut
objects = []
# get the object by the given uid or try to find it by the request
# parameters
obj = get_object_by_uid(uid) or get_object_by_request()
if obj:
objects.append(obj)
else:
# no uid -> go through the record items
records = req.get_request_data()
for record in records:
# try to get the object by the given record
obj = get_object_by_record(record)
# no object found for this record
if obj is None:
continue
objects.append(obj)
return objects
[docs]def get_object_by_request():
""" locate the object by the request parameters
"""
form = req.get_form()
return get_object_by_record(form)
[docs]def get_object_by_record(record):
""" locate the object by the given record (dictionary).
The record is usually contained in the request.body or in the request.form
"""
# nothing to do here
if not record:
return None
if record.get("uid"):
return get_object_by_uid(record["uid"])
if record.get("path"):
return get_object_by_path(record["path"])
if record.get("parent_path") and record.get("id"):
path = "/".join([record["parent_path"], record["id"]])
return get_object_by_path(path)
logger.warn("get_object_by_record::No object found! record='%r'" % record)
return None
[docs]def get_object_by_uid(uid):
""" Fetches an object by uid
"""
# nothing to do here
if uid is None:
return None
# define uid 0 as the portal object
if str(uid).lower() in PORTAL_IDS:
return get_portal()
# we try to find the object with both catalogs
pc = get_portal_catalog()
rc = get_portal_reference_catalog()
# try to find the object with the reference catalog first
obj = rc.lookupObject(uid)
if obj:
return obj
# try to find the object with the portal catalog
res = pc(dict(UID=uid))
if len(res) > 1:
raise APIError(400, "More than one object found for UID %s" % uid)
if not res:
return None
return get_object(res[0])
[docs]def get_object_by_path(path):
""" fetch the object by physical path
"""
# nothing to do here
if not path:
return None
pc = get_portal_catalog()
portal = get_portal()
portal_path = get_path(portal)
if not path.startswith(portal_path):
raise APIError(404, "Not a physical path inside the portal")
if path == portal_path:
return portal
res = pc(path=dict(query=path, depth=0))
if not res:
return None
return get_object(res[0])
[docs]def mkdir(path):
""" creates a folder structure by a given path
"""
container = get_portal()
segments = path.split("/")
curpath = None
for n, segment in enumerate(segments):
# skip the first element
if not segment:
continue
curpath = "/".join(segments[:n + 1])
obj = get_object_by_path(curpath)
if obj:
container = obj
continue
if not is_folderish(container):
raise APIError(400, "Object at %s is not a folder" % curpath)
# create the folder on the go
container = ploneapi.content.create(
container, type="Folder", id=segment)
return container
[docs]def find_target_container(record):
""" find the target container for this record
"""
parent_uid = record.get("parent_uid")
parent_path = record.get("parent_path")
target = None
# Try to find the target object
if parent_uid:
target = get_object_by_uid(parent_uid)
elif parent_path:
target = get_object_by_path(parent_path)
# Issue 18
if target is None:
target = mkdir(parent_path)
else:
raise APIError(404, "No target UID/PATH information found")
if not target:
raise APIError(404, "No target container found")
return target
[docs]def do_action_for(brain_or_object, transition):
""" perform wf transition """
obj = get_object(brain_or_object)
return ploneapi.content.transition(obj, transition)
[docs]def delete_object(brain_or_object):
""" delete the object """
obj = get_object(brain_or_object)
# we do not want to delete the site root!
if is_root(obj):
raise APIError(401, "Removing the Portal is not allowed")
try:
return ploneapi.content.delete(obj) is None and True or False
except Unauthorized:
raise APIError(401, "Not allowed to delete object '%s'" % obj.getId())
[docs]def get_current_user():
""" return the current logged in user """
return ploneapi.user.get_current()
[docs]def create_object_in_container(container, portal_type, id=None, title=None):
""" creates an object with the given data in the container
"""
allowed_types = get_locally_allowed_types(container)
if not is_root(container) and portal_type not in allowed_types:
raise APIError(500, "Creation of this portal type"
"is not allowed in this context.")
return create_object(container=container, id=id, title=title,
type=portal_type)
[docs]def create_object(**kw):
defaults = {"save_id": True}
defaults.update(kw)
try:
return ploneapi.content.create(**defaults)
except Unauthorized:
raise APIError(401, "You are not allowed to create this content")
[docs]def update_object_with_data(content, record):
""" update the content with the values from records
"""
dm = IDataManager(content)
if dm is None:
raise APIError(400, "Update on this object is not allowed")
# Iterate through record items
for k, v in record.items():
try:
success = dm.set(k, v, **record)
except Unauthorized:
raise APIError(401, "Not allowed to set the field '%s'" % k)
if not success:
logger.warn("update_object_with_data::skipping key=%r", k)
continue
logger.debug("update_object_with_data::field %r updated", k)
# do a wf transition
if record.get("transition", None):
t = record.get("transition")
logger.info(">>> Do Transition '%s' for Object %s", t, content.getId())
do_action_for(content, t)
# reindex the object
content.reindexObject()
return content