Products.CMFPlone/plip-1486-redirection: Add redirection control panel
Stephan Klinger <jenkins-z4DKO/[email protected]>
| Newsgroups | gmane.comp.web.zope.plone.cvs |
|---|---|
| Message-ID | <[email protected]> |
Repository: Products.CMFPlone Branch: refs/heads/plip-1486-redirection Date: 2017-07-10T09:31:40+03:00 Author: Stephan Klinger (staeff) <[email protected]> Commit: https://github.com/plone/Products.CMFPlone/commit/5d84e82287ede7d71397a7b86e16fa3ce7e42a61 Add redirection control panel Files changed: A Products/CMFPlone/controlpanel/browser/redirects-controlpanel.pt A Products/CMFPlone/controlpanel/browser/redirects-manage.pt A Products/CMFPlone/controlpanel/browser/redirects.py M Products/CMFPlone/controlpanel/browser/configure.zcml diff --git a/Products/CMFPlone/controlpanel/browser/configure.zcml b/Products/CMFPlone/controlpanel/browser/configure.zcml index 5115a526a..1c1c008e6 100644 --- a/Products/CMFPlone/controlpanel/browser/configure.zcml +++ b/Products/CMFPlone/controlpanel/browser/configure.zcml @@ -291,4 +291,13 @@ permission="cmf.ManagePortal" /> + <browser:page + name="redirection-controlpanel" + for="*" + class=".redirects.RedirectsControlPanel" + permission="cmf.ManagePortal" + /> + + <adapter factory=".redirects.RedirectsControlPanelAdapter" /> + </configure> diff --git a/Products/CMFPlone/controlpanel/browser/redirects-controlpanel.pt b/Products/CMFPlone/controlpanel/browser/redirects-controlpanel.pt new file mode 100644 index 000000000..e0da95330 --- /dev/null +++ b/Products/CMFPlone/controlpanel/browser/redirects-controlpanel.pt @@ -0,0 +1,149 @@ +<html xmlns="http://www.w3.org/1999/xhtml" + xml:lang="en-US" lang="en-US" + xmlns:tal="http://xml.zope.org/namespaces/tal" + xmlns:metal="http://xml.zope.org/namespaces/metal" + xmlns:i18n="http://xml.zope.org/namespaces/i18n" + metal:use-macro="context/prefs_main_template/macros/master" + i18n:domain="RedirectionTool"> + +<metal:block metal:fill-slot="top_slot" + tal:define="dummy python:request.set('disable_border', 1)" /> + + <body> + <div metal:fill-slot="prefs_configlet_main"> + <!--<dl tal:condition="view/errors"--> + <!--class="portalMessage error">--> + <!--<dt>Error</dt>--> + <!--<dd>--> + <!--<p i18n:translate="error_bulk_upload">--> + <!--No aliases were added. Please correct these errors in your CSV file and try again:--> + <!--</p>--> + <!--<table style="text-align: left; vertical-align: top" border="0" cellpadding="2" cellspacing="0">--> + <!--<tr tal:repeat="error view/errors"--> + <!--style="vertical-align: top">--> + <!--<td nowrap="nowrap"><tal:block i18n:translate="label_bulk_upload_line">Line</tal:block> <span tal:replace="error/line_number" /><tal:block i18n:translate="label_bulk_upload_line_suffix">:</tal:block></td>--> + <!--<td>--> + <!--<code tal:content="error/line" />--> + <!--</td>--> + <!--<td tal:content="error/message" />--> + <!--</tr>--> + <!--</table>--> + <!--</dd>--> + <!--</dl>--> + + <h1 i18n:translate="heading_add_aliases">Manage aliases</h1> + + <p i18n:translate="description_manage_aliases"> + Using this form, you can select for which types you want to manage + aliases and manage all existing aliases. + </p> + + + <!--<form tal:attributes="action view/view_url" method="post">--> + <!--<tal:block tal:repeat="widget python: view.form.widgets.values()">--> + + <!--<div class="field"--> + <!--tal:define="description widget/field/description;--> + <!--error widget/error"--> + <!--tal:attributes="class python:'field'+(error and ' error' or '')">--> + + <!--<label i18n:translate=""--> + <!--tal:attributes="for widget/name"--> + <!--tal:content="widget/label">--> + <!--label--> + <!--</label>--> + + <!--<span class="fieldRequired"--> + <!--title="Required"--> + <!--i18n:attributes="title title_required;"--> + <!--i18n:translate="label_required"--> + <!--tal:condition="widget/required">--> + <!--(Required)--> + <!--</span>--> + + <!--<div class="formHelp"--> + <!--i18n:translate=""--> + <!--tal:content="description"--> + <!--tal:condition="description">--> + <!--field description--> + <!--</div>--> + + <!--<div tal:condition="error"--> + <!--tal:content="structure error">--> + <!--The Error--> + <!--</div>--> + + <!--<div class="widget" tal:content="structure widget">--> + <!--<input type="text" />--> + <!--</div>--> + <!--</div>--> + + <!--</tal:block>--> + + <!--<div class="formControls"--> + <!--i18n:domain="plone">--> + <!--<input class="context"--> + <!--type="submit"--> + <!--value="Save"--> + <!--name="form.button.Save"--> + <!--i18n:attributes="value label_save;"--> + <!--/>--> + <!--</div>--> + <!--</form>--> + + <!--<form tal:attributes="action view/view_url" method="post">--> + <!--<fieldset>--> + <!--<legend i18n:translate="legend_all_existing_aliases">All existing aliases for this site</legend>--> + + <!--<tal:redirects repeat="redirect view/redirects">--> + <!--<div>--> + <!--<input type="checkbox"--> + <!--class="noborder"--> + <!--name="redirects:tuple"--> + <!--tal:attributes="value redirect/redirect" />--> + + <!--<span tal:replace="redirect/path" /> → <span tal:replace="redirect/redirect-to" />--> + <!--</div>--> + <!--</tal:redirects>--> + + <!--<div class="formControls">--> + <!--<input class="context"--> + <!--type="submit"--> + <!--value="Remove"--> + <!--name="form.button.Remove"--> + <!--i18n:attributes="value"--> + <!--/>--> + <!--</div>--> + + <!--</fieldset>--> + <!--</form>--> + + <!--<form tal:attributes="action view/view_url" method="post" enctype="multipart/form-data">--> + <!--<fieldset>--> + <!--<legend i18n:translate="legend_bulk_upload">Bulk-upload aliases</legend>--> + + <!--<p i18n:translate="description_bulk_upload">--> + <!--Add many aliases at once by uploading a CSV file. The first column should be the path to redirect from; the second, the path to redirect to. Both paths must be Plone-site-relative, starting with a slash (/).--> + <!--</p>--> + <!--<p>--> + <!--<tal:block i18n:translate="example_caption_bulk_upload">Example:</tal:block><br />--> + <!--<code i18n:translate="example_bulk_upload">/old-home-page.asp,/front-page<br />--> + <!--/people/JoeT,/Users/joe-thurston</code>--> + <!--</p>--> + + <!--<input type="file" name="file" />--> + + <!--<div class="formControls">--> + <!--<input class="context"--> + <!--type="submit"--> + <!--value="Upload"--> + <!--name="form.button.Upload"--> + <!--i18n:attributes="value"--> + <!--/>--> + <!--</div>--> + + <!--</fieldset>--> + <!--</form>--> + </div> + </body> +</html> diff --git a/Products/CMFPlone/controlpanel/browser/redirects-manage.pt b/Products/CMFPlone/controlpanel/browser/redirects-manage.pt new file mode 100644 index 000000000..51d8f9ebf --- /dev/null +++ b/Products/CMFPlone/controlpanel/browser/redirects-manage.pt @@ -0,0 +1,100 @@ +<html xmlns="http://www.w3.org/1999/xhtml" + xml:lang="en-US" lang="en-US" + xmlns:tal="http://xml.zope.org/namespaces/tal" + xmlns:metal="http://xml.zope.org/namespaces/metal" + xmlns:i18n="http://xml.zope.org/namespaces/i18n" + metal:use-macro="here/main_template/macros/master" + i18n:domain="RedirectionTool"> + + <body> + <div metal:fill-slot="main" + tal:define="context_state context/@@plone_context_state"> + <h1 class="documentFirstHeading" i18n:translate="heading_add_aliases">Manage aliases</h1> + + <dl class="portalMessage info" tal:condition="context_state/is_default_page"> + <dt i18n:translate=""> + Info + </dt> + <dd i18n:translate="help_sharing_page_default_page"> + You are managing the aliases of a default view in a container. + To manage them for the entire container, + <a i18n:name="go_here" + i18n:translate="help_aliases_go_here" + tal:define="folder context_state/folder" + tal:attributes="href string:${folder/absolute_url}/@@manage-aliases">go here</a>. + </dd> + </dl> + + <p i18n:translate="description_adding_aliases"> + Using this form, you can manage aliases for an item. This + is an easy way to make an item available under two different URLs. + </p> + + <form tal:attributes="action view/view_url" method="post"> + <fieldset> + <legend i18n:translate="legend_existing_aliases">Existing aliases for this item</legend> + + <div class="field" + tal:define="error options/errors/redirection|nothing; + redirection request/title | nothing;" + tal:attributes="class python:error and 'field error' or 'field'"> + <label for="title" i18n:translate="label_alias">Alias</label> + + <span class="fieldRequired" title="Required" + i18n:attributes="title" + i18n:translate="label_required">(Required)</span> + + <div tal:content="error">Validation error output</div> + + <div class="formHelp" i18n:translate="help_alias"> + Enter the alternative Short Name or an absolute path where an alias should exist. + </div> + + <input type="text" + name="redirection" + value="#" + size="40" + tal:attributes="value request/redirection | nothing;" + /> + </div> + <div class="formControls"> + <input class="context" + type="submit" + value="Add" + name="form.button.Add" + i18n:attributes="value" + /> + </div> + + </fieldset> + </form> + + <form tal:attributes="action view/view_url" method="post"> + <fieldset> + <legend i18n:translate="legend_existing_aliases">Existing aliases for this item</legend> + + <tal:redirects repeat="redirect view/redirects"> + <div> + <input type="checkbox" + class="noborder" + name="redirects:tuple" + tal:attributes="value redirect/redirect" /> + + <span tal:replace="redirect/path" /> + </div> + </tal:redirects> + + <div class="formControls"> + <input class="context" + type="submit" + value="Remove" + name="form.button.Remove" + i18n:attributes="value" + /> + </div> + + </fieldset> + </form> + </div> + </body> +</html> diff --git a/Products/CMFPlone/controlpanel/browser/redirects.py b/Products/CMFPlone/controlpanel/browser/redirects.py new file mode 100644 index 000000000..ecfdf7893 --- /dev/null +++ b/Products/CMFPlone/controlpanel/browser/redirects.py @@ -0,0 +1,261 @@ +import csv +from cStringIO import StringIO + +from zope.interface import implements, Interface +from zope.component import adapts, getUtility +from zope.schema import Choice, Tuple + +from AccessControl import getSecurityManager +#from Products.RedirectionTool.permissions import ModifyAliases + +from Products.Five.browser import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile + +from Products.statusmessages.interfaces import IStatusMessage +from Products.CMFCore.interfaces import ISiteRoot +from Products.CMFCore.utils import getToolByName +from Products.CMFPlone.interfaces import IPloneSiteRoot +from plone.app.redirector.interfaces import IRedirectionStorage + +from z3c.form import form, field + +from plone.memoize.instance import memoize + +from zope.i18nmessageid import MessageFactory +_ = MessageFactory('plone') + + +def absolutize_path(path, context=None, is_alias=True): + """Check whether `path` is a well-formed path from the portal root, and + make it Zope-root-relative. If `is_alias` (as opposed to "is_target"), + also make sure the user has the requisite ModifyAliases permissions to + make an alias from that path. Return a 2-tuple: (absolute redirection path, + an error message iff something goes wrong and otherwise ''). + + Assume relative paths are relative to `context`; reject relative paths if + `context` is None. + + """ + portal = getUtility(ISiteRoot) + err = None + if path is None or path == '': + err = (is_alias and _(u"You have to enter an alias.") + or _(u"You have to enter a target.")) + elif '://' in path: + err = (is_alias and _(u"An alias is a path from the portal root and doesn't include http:// or alike.") # noqa + or _(u"Target path must be relative to the portal root and not include http:// or the like.")) # noqa + else: + if path.startswith('/'): + context_path = "/".join(portal.getPhysicalPath()) + path = "%s%s" % (context_path, path) + else: + if context is None: + err = (is_alias and _(u"Alias path must start with a slash.") + or _(u"Target path must start with a slash.")) + else: + context_path = "/".join(context.getPhysicalPath()[:-1]) + path = "%s/%s" % (context_path, path) + if not err and is_alias: # noqa XXX should we require Modify Alias permission on the target as well? + source = path.split('/') + while len(source): + obj = portal.unrestrictedTraverse(source, None) + if obj is None: + source = source[:-1] + else: + if not getSecurityManager().checkPermission(ModifyAliases, obj): + obj = None + break + if obj is None: + err = _(u"You don't have the permission to set an alias from the location you provided.") # noqa + else: + pass + # XXX check if there is an existing alias + # XXX check whether there is an object + return path, err + + +class RedirectsView(BrowserView): + template = ViewPageTemplateFile('redirects-manage.pt') + + def redirects(self): + storage = getUtility(IRedirectionStorage) + portal = getUtility(ISiteRoot) + context_path = "/".join(self.context.getPhysicalPath()) + portal_path = "/".join(portal.getPhysicalPath()) + redirects = storage.redirects(context_path) + for redirect in redirects: + path = redirect[len(portal_path):] + yield { + 'redirect': redirect, + 'path': path, + } + + def __call__(self): + storage = getUtility(IRedirectionStorage) + request = self.request + form = request.form + status = IStatusMessage(self.request) + errors = {} + + if 'form.button.Add' in form: + redirection, err = absolutize_path(form.get('redirection'), is_alias=True) + if err: + errors['redirection'] = err + status.addStatusMessage(err, type='error') + else: + # XXX check if there is an existing alias + # XXX check whether there is an object + del form['redirection'] + storage.add(redirection, "/".join(self.context.getPhysicalPath())) + status.addStatusMessage(_(u"Alias added."), type='info') + elif 'form.button.Remove' in form: + redirects = form.get('redirects', ()) + for redirect in redirects: + storage.remove(redirect) + if len(redirects) > 1: + status.addStatusMessage(_(u"Aliases removed."), type='info') + else: + status.addStatusMessage(_(u"Alias removed."), type='info') + + return self.template(errors=errors) + + @memoize + def view_url(self): + return self.context.absolute_url() + '/@@manage-aliases' + + +class IAliasesSchema(Interface): + + managed_types = Tuple(title=_(u"Managed types"), + description=_(u"Select the types for which the " + "aliases can be managed"), + required=True, + missing_value=tuple(), + value_type=Choice( + vocabulary="plone.app.vocabularies.ReallyUserFriendlyTypes")) + + +class RedirectsControlPanelAdapter(object): + + adapts(IPloneSiteRoot) + implements(IAliasesSchema) + + def __init__(self, context): + self.context = context + self.rt = getToolByName(context, 'portal_redirection') + + def get_managed_types(self): + return self.rt.getRedirectionAllowedForTypes() + + def set_managed_types(self, value): + if type(value) in (str, unicode): + value = [value] + self.rt.setRedirectionAllowedForTypes(value) + + managed_types = property(get_managed_types, set_managed_types) + + +class RedirectsControlPanelForm(form.EditForm): + fields = field.Fields(IAliasesSchema) + + +class RedirectsControlPanel(BrowserView): + template = ViewPageTemplateFile('redirects-controlpanel.pt') + + def __init__(self, context, request): + super(RedirectsControlPanel, self).__init__(context, request) + self.errors = [] + # list of tuples: (line_number, absolute_redirection_path, err_msg, target) + + def redirects(self): + storage = getUtility(IRedirectionStorage) + portal = getUtility(ISiteRoot) + portal_path = "/".join(portal.getPhysicalPath()) + portal_path_len = len(portal_path) + for redirect in storage: + if redirect.startswith(portal_path): + path = redirect[portal_path_len:] + else: + path = redirect + redirectto = storage.get(redirect) + if redirectto.startswith(portal_path): + redirectto = redirectto[portal_path_len:] + yield { + 'redirect': redirect, + 'path': path, + 'redirect-to': redirectto, + } + + def __call__(self): + storage = getUtility(IRedirectionStorage) + portal = getUtility(ISiteRoot) + request = self.request + form = request.form + status = IStatusMessage(self.request) + + if 'form.button.Remove' in form: + redirects = form.get('redirects', ()) + for redirect in redirects: + storage.remove(redirect) + if len(redirects) == 0: + status.addStatusMessage(_(u"No aliases selected for removal."), type='info') + elif len(redirects) > 1: + status.addStatusMessage(_(u"Aliases removed."), type='info') + else: + status.addStatusMessage(_(u"Alias removed."), type='info') + elif 'form.button.Save' in form: + dst = IAliasesSchema(self.context) + dst.managed_types = self.request.form['form.widgets.managed_types'] + elif 'form.button.Upload' in form: + self.upload(form['file'], portal, storage, status) + + self.form = RedirectsControlPanelForm(self.context, self.request) + self.form.update() + return self.template() + + def upload(self, file, portal, storage, status): + """Add the redirections from the CSV file `file`. If anything goes wrong, do nothing.""" + # Turn all kinds of newlines into LF ones. The csv module doesn't do + # its own newline sniffing and requires either \n or \r. + file = StringIO('\n'.join(file.read().splitlines())) + + # Use first two lines as a representative sample for guessing format, + # in case one is a bunch of headers. + dialect = csv.Sniffer().sniff(file.readline() + file.readline()) + file.seek(0) + + successes = [] # list of tuples: (abs_redirection, target) + had_errors = False + for i, fields in enumerate(csv.reader(file, dialect)): + if len(fields) == 2: + redirection, target = fields + abs_redirection, err = absolutize_path(redirection, is_alias=True) + abs_target, target_err = absolutize_path(target, is_alias=False) + if err and target_err: + err = "%s %s" % (err, target_err) # sloppy w.r.t. i18n + elif target_err: + err = target_err + else: + if abs_redirection == abs_target: + # TODO: detect indirect recursion + err = _(u"Aliases that point to themselves will cause" + u"an endless cycle of redirects.") + else: + err = _(u"Each line must have 2 columns.") + + if not err: + if not had_errors: # else don't bother + successes.append((abs_redirection, abs_target)) + else: + had_errors = True + self.errors.append(dict(line_number=i+1, line=dialect.delimiter.join(fields), + message=err)) + + if not had_errors: + for abs_redirection, abs_target in successes: + storage.add(abs_redirection, abs_target) + status.addStatusMessage(_(u"%i aliases added.") % len(successes), type='info') + + @memoize + def view_url(self): + return self.context.absolute_url() + '/@@aliases-controlpanel' ------------------------------------------------------------------------------ Check out the vibrant tech community on one of the world's most engaging tech sites, Slashdot.org! http://sdm.link/slashdot