From 25e3883aa6ea502cbab47bfd504949b058d711f4 Mon Sep 17 00:00:00 2001 From: Franco Pellegrini Date: Sat, 30 May 2026 12:27:08 +0200 Subject: [PATCH] Move plone.app.dexterity views to plone.app.layout. refs plone/Products.CMFPlone#3953 --- news/3953.breaking | 2 + setup.py | 7 + src/plone/app/layout/configure.zcml | 1 + src/plone/app/layout/dexterity/__init__.py | 0 src/plone/app/layout/dexterity/add_type.py | 58 ++++ src/plone/app/layout/dexterity/behaviors.pt | 97 ++++++ src/plone/app/layout/dexterity/behaviors.py | 138 ++++++++ src/plone/app/layout/dexterity/clone_type.py | 38 +++ src/plone/app/layout/dexterity/configure.zcml | 144 +++++++++ src/plone/app/layout/dexterity/container.pt | 48 +++ src/plone/app/layout/dexterity/export.py | 134 ++++++++ src/plone/app/layout/dexterity/fields.py | 40 +++ .../app/layout/dexterity/folder_listing.pt | 147 +++++++++ .../app/layout/dexterity/folder_listing.py | 93 ++++++ .../app/layout/dexterity/import_types.py | 191 +++++++++++ src/plone/app/layout/dexterity/item.pt | 36 +++ src/plone/app/layout/dexterity/layout.py | 15 + src/plone/app/layout/dexterity/modeleditor.pt | 79 +++++ src/plone/app/layout/dexterity/modeleditor.py | 149 +++++++++ src/plone/app/layout/dexterity/overview.pt | 54 ++++ src/plone/app/layout/dexterity/overview.py | 34 ++ .../app/layout/dexterity/tabbed_forms.pt | 40 +++ src/plone/app/layout/dexterity/types.py | 302 ++++++++++++++++++ .../app/layout/dexterity/types_listing.pt | 48 +++ .../app/layout/dexterity/types_listing_row.pt | 50 +++ .../app/layout/dexterity/typesformwrapper.pt | 21 ++ src/plone/app/layout/dexterity/utils.py | 9 + .../layout/tests/import/dexterity_export.zip | Bin 0 -> 5804 bytes .../app/layout/tests/test_dexterity_export.py | 55 ++++ .../app/layout/tests/test_dexterity_import.py | 88 +++++ 30 files changed, 2118 insertions(+) create mode 100644 news/3953.breaking create mode 100644 src/plone/app/layout/dexterity/__init__.py create mode 100644 src/plone/app/layout/dexterity/add_type.py create mode 100644 src/plone/app/layout/dexterity/behaviors.pt create mode 100644 src/plone/app/layout/dexterity/behaviors.py create mode 100644 src/plone/app/layout/dexterity/clone_type.py create mode 100644 src/plone/app/layout/dexterity/configure.zcml create mode 100644 src/plone/app/layout/dexterity/container.pt create mode 100644 src/plone/app/layout/dexterity/export.py create mode 100644 src/plone/app/layout/dexterity/fields.py create mode 100644 src/plone/app/layout/dexterity/folder_listing.pt create mode 100644 src/plone/app/layout/dexterity/folder_listing.py create mode 100644 src/plone/app/layout/dexterity/import_types.py create mode 100644 src/plone/app/layout/dexterity/item.pt create mode 100644 src/plone/app/layout/dexterity/layout.py create mode 100644 src/plone/app/layout/dexterity/modeleditor.pt create mode 100644 src/plone/app/layout/dexterity/modeleditor.py create mode 100644 src/plone/app/layout/dexterity/overview.pt create mode 100644 src/plone/app/layout/dexterity/overview.py create mode 100644 src/plone/app/layout/dexterity/tabbed_forms.pt create mode 100644 src/plone/app/layout/dexterity/types.py create mode 100644 src/plone/app/layout/dexterity/types_listing.pt create mode 100644 src/plone/app/layout/dexterity/types_listing_row.pt create mode 100644 src/plone/app/layout/dexterity/typesformwrapper.pt create mode 100644 src/plone/app/layout/dexterity/utils.py create mode 100644 src/plone/app/layout/tests/import/dexterity_export.zip create mode 100644 src/plone/app/layout/tests/test_dexterity_export.py create mode 100644 src/plone/app/layout/tests/test_dexterity_import.py diff --git a/news/3953.breaking b/news/3953.breaking new file mode 100644 index 000000000..a43917801 --- /dev/null +++ b/news/3953.breaking @@ -0,0 +1,2 @@ +Move plone.app.dexterity views to plone.app.layout +[frapell] diff --git a/setup.py b/setup.py index 9bc78815b..937509124 100644 --- a/setup.py +++ b/setup.py @@ -46,16 +46,23 @@ "plone.app.uuid", "plone.app.viewletmanager >=1.2", "plone.base >=4.0.0a1", + "plone.behavior", "plone.dexterity", "plone.formwidget.namedfile", "plone.i18n", "plone.memoize", + "plone.namedfile", "plone.portlets", "plone.protect", + "plone.registry", + "plone.schemaeditor", + "plone.supermodel", + "plone.z3cform", "Products.CMFEditions >=1.2.2", "Products.GenericSetup", "Products.statusmessages", "Products.ZCatalog", + "z3c.form", "Zope", ], extras_require=dict( diff --git a/src/plone/app/layout/configure.zcml b/src/plone/app/layout/configure.zcml index f1cefa5d7..8cae46fc5 100644 --- a/src/plone/app/layout/configure.zcml +++ b/src/plone/app/layout/configure.zcml @@ -21,6 +21,7 @@ directory="profiles/uninstall" /> + diff --git a/src/plone/app/layout/dexterity/__init__.py b/src/plone/app/layout/dexterity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/plone/app/layout/dexterity/add_type.py b/src/plone/app/layout/dexterity/add_type.py new file mode 100644 index 000000000..133480a18 --- /dev/null +++ b/src/plone/app/layout/dexterity/add_type.py @@ -0,0 +1,58 @@ +from plone.app.dexterity import _ +from plone.app.dexterity.interfaces import ITypeSettings +from plone.base.utils import safe_text +from plone.dexterity.fti import DexterityFTI +from plone.z3cform.layout import wrap_form +from Products.CMFCore.utils import getToolByName +from z3c.form import field +from z3c.form import form + + +class TypeAddForm(form.AddForm): + label = _("Add Content Type") + fields = field.Fields(ITypeSettings).select("title", "id", "description") + id = "add-type-form" + fti_id = None + + def create(self, data): + id = data.pop("id") + + fti = DexterityFTI(id) + fti.id = id + data["title"] = safe_text(data["title"]) + if data["description"]: + data["description"] = safe_text(data["description"]) + data["i18n_domain"] = "plone" + data["behaviors"] = "\n".join( + [ + "plone.dublincore", + "plone.namefromtitle", + ] + ) + data["model_source"] = """ + + + + +""" + + data["klass"] = "plone.dexterity.content.Container" + data["filter_content_types"] = True + data["icon_expr"] = "string:file-earmark-text" + fti.manage_changeProperties(**data) + return fti + + def add(self, fti): + ttool = getToolByName(self.context, "portal_types") + ttool._setObject(fti.id, fti) + self.fti_id = fti.id + self.status = _("Type added successfully.") + + def nextURL(self): + url = self.context.absolute_url() + if self.fti_id is not None: + url += f"/{self.fti_id}/@@fields" + return url + + +TypeAddFormPage = wrap_form(TypeAddForm) diff --git a/src/plone/app/layout/dexterity/behaviors.pt b/src/plone/app/layout/dexterity/behaviors.pt new file mode 100644 index 000000000..48081a7f2 --- /dev/null +++ b/src/plone/app/layout/dexterity/behaviors.pt @@ -0,0 +1,97 @@ + +
+ + + + + + + + + +
+ + Name of behavior + + Interface of behavior +
+ +
+ + +
+ + + (Required) + + +
+ Error +
+ +
+ + + + + + + + + + + +
+ + name of behaviorinterface of behavior
+ field description + +
+
+
+ +
+ + +
+ + +
diff --git a/src/plone/app/layout/dexterity/behaviors.py b/src/plone/app/layout/dexterity/behaviors.py new file mode 100644 index 000000000..c02570161 --- /dev/null +++ b/src/plone/app/layout/dexterity/behaviors.py @@ -0,0 +1,138 @@ +from collections import Counter +from copy import deepcopy +from operator import attrgetter +from plone.app.dexterity import _ +from plone.app.layout.dexterity.layout import TypeFormLayout +from plone.app.dexterity.interfaces import ITypeSchemaContext +from plone.base.utils import safe_text +from plone.behavior.interfaces import IBehavior +from plone.behavior.registration import BehaviorRegistrationNotFound +from plone.behavior.registration import lookup_behavior_registration +from plone.dexterity.fti import DexterityFTIModificationDescription +from z3c.form import field +from z3c.form import form +from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget +from zope import schema +from zope.browserpage.viewpagetemplatefile import ViewPageTemplateFile +from zope.component import adapter +from zope.component import getUtilitiesFor +from zope.lifecycleevent import modified + +TTW_BEHAVIOR_BLACKLIST = [ + # skip deprecated behavior + "plone.app.dexterity.behaviors.related.IRelatedItems", +] + + +def behaviorConfigurationModified(object, event): + description = DexterityFTIModificationDescription("behaviors", "") + modified(object.fti, description) + + +@adapter(ITypeSchemaContext) +class BehaviorConfigurationAdapter: + def __init__(self, context): + self.__dict__["context"] = context + self.__dict__["fti"] = self.context.fti + + def __getattr__(self, name): + # be sure to get a valid value + reg = lookup_behavior_registration(name=name) + iid = reg.interface.__identifier__ + return iid in self.fti.behaviors or safe_text(reg.name) in self.fti.behaviors + + def __setattr__(self, name, value): + # add or remove the behavior based on the value from the form + behaviors = list(self.fti.behaviors) + reg = lookup_behavior_registration(name=name) + iid = reg.interface.__identifier__ + if reg.name: + # behavior has a name -> use it + # but first remove the dotted behavior if present + if iid in self.fti.behaviors: + behaviors.remove(iid) + # prepare named behavior for add/remove + bname = safe_text(reg.name) + else: + # no name found -> prepare dotted behavior for add/remove instead + bname = iid + + # add/remove bname if based on value True false + if value and bname not in behaviors: + behaviors.append(bname) + elif not value and bname in behaviors: + behaviors.remove(bname) + + self.fti.behaviors = behaviors + + def __iter__(self): + # iterate through the present behaviors + for name in self.fti.behaviors: + try: + reg = lookup_behavior_registration(name=name) + except BehaviorRegistrationNotFound: + # ignore wrong names + continue + if reg.name: + yield safe_text(reg.name) + else: + yield name + + +class TypeBehaviorsForm(form.EditForm): + template = ViewPageTemplateFile("behaviors.pt") + label = _("Behaviors") + description = _("Select the behaviors to enable for this content type.") + successMessage = _("Behaviors successfully updated.") + noChangesMessage = _("No changes were made.") + buttons = deepcopy(form.EditForm.buttons) + buttons["apply"].title = _("Save") + + def getContent(self): + return BehaviorConfigurationAdapter(self.context) + + @property + def behaviors(self): + """Return dict of (behavior name, reg)""" + counts = Counter([id(reg) for name, reg in getUtilitiesFor(IBehavior)]) + behavs = {} + for name, reg in getUtilitiesFor(IBehavior): + if name in TTW_BEHAVIOR_BLACKLIST: + # skip blacklisted + continue + with_name = counts[id(reg)] > 1 + if with_name and reg.name != name: + continue + fname = safe_text(reg.name if reg.name else name) + behavs[fname] = reg + return behavs + + @property + def fields(self): + counts = Counter([id(reg) for name, reg in getUtilitiesFor(IBehavior)]) + fields = [] + for name, reg in getUtilitiesFor(IBehavior): + if name in TTW_BEHAVIOR_BLACKLIST: + # skip blacklisted + continue + with_name = counts[id(reg)] > 1 + if with_name and reg.name != name: + continue + fname = safe_text(reg.name if reg.name else name) + fields.append( + schema.Bool( + __name__=fname, + title=reg.title, + description=reg.description, + required=False, + ) + ) + form_fields = field.Fields(*sorted(fields, key=attrgetter("title"))) + for ff in form_fields.values(): + ff.widgetFactory = SingleCheckBoxFieldWidget + return form_fields + + +class TypeBehaviorsPage(TypeFormLayout): + form = TypeBehaviorsForm + label = _("Behaviors") diff --git a/src/plone/app/layout/dexterity/clone_type.py b/src/plone/app/layout/dexterity/clone_type.py new file mode 100644 index 000000000..b2710b4a3 --- /dev/null +++ b/src/plone/app/layout/dexterity/clone_type.py @@ -0,0 +1,38 @@ +from plone.app.dexterity import _ +from plone.app.dexterity.interfaces import ITypeSettings +from plone.dexterity.fti import DexterityFTI +from plone.z3cform.layout import wrap_form +from Products.CMFCore.utils import getToolByName +from z3c.form import field +from z3c.form import form + + +class TypeCloneForm(form.AddForm): + label = _("Clone Content Type") + fields = field.Fields(ITypeSettings).select("title", "id") + id = "clone-type-form" + + def create(self, data): + type_id = data.pop("id") + props = dict(self.context.fti.propertyItems()) + # make sure we don't share the factory + if props["factory"] == self.context.fti.getId(): + del props["factory"] + + props["title"] = data["title"] + props["add_view_expr"] = props["add_view_expr"].replace( + self.context.fti.getId(), type_id + ) + fti = DexterityFTI(type_id, **props) + return fti + + def add(self, fti): + ttool = getToolByName(self.context, "portal_types") + ttool._setObject(fti.id, fti) + self.status = _("Type cloned successfully.") + + def nextURL(self): + return self.context.aq_parent.absolute_url() + + +TypeCloneFormPage = wrap_form(TypeCloneForm) diff --git a/src/plone/app/layout/dexterity/configure.zcml b/src/plone/app/layout/dexterity/configure.zcml new file mode 100644 index 000000000..bd455ac2c --- /dev/null +++ b/src/plone/app/layout/dexterity/configure.zcml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plone/app/layout/dexterity/container.pt b/src/plone/app/layout/dexterity/container.pt new file mode 100644 index 000000000..442acc89a --- /dev/null +++ b/src/plone/app/layout/dexterity/container.pt @@ -0,0 +1,48 @@ + + + + + + + + + + + + +
+ + + + +
+ +
+ Contents + + + +
+ +
+
+ + + diff --git a/src/plone/app/layout/dexterity/export.py b/src/plone/app/layout/dexterity/export.py new file mode 100644 index 000000000..285a84636 --- /dev/null +++ b/src/plone/app/layout/dexterity/export.py @@ -0,0 +1,134 @@ +# @@types-export view for dexterity types configlet. View support for the +# "Export" button. This is done by repurposing the GS typeinfo export and +# removing unselected type information from its output. +from io import BytesIO +from lxml import etree +from plone.base.utils import safe_bytes +from plone.base.utils import safe_text +from plone.supermodel import serializeModel +from Products.CMFCore.utils import getToolByName +from Products.Five.browser import BrowserView +from Products.GenericSetup.context import BaseContext +from Products.GenericSetup.context import TarballExportContext +from zipfile import ZipFile + +import time + + +class SelectiveZipExportContext(TarballExportContext): + def __init__(self, tool, typelist, encoding=None, base_name="setup_tool"): + BaseContext.__init__(self, tool, encoding) + + self.typelist = typelist + self.filenames = ["types.xml"] + for tn in typelist: + self.filenames.append(f"types/{tn}.xml") + + timestamp = time.gmtime() + self._archive_filename = ( + base_name + "-%4d%02d%02d%02d%02d%02d.zip" % timestamp[:6] + ) + + self._archive_stream = BytesIO() + self._archive = ZipFile(self._archive_stream, "w") + + def writeDataFile(self, filename, text, content_type, subdir=None): + if filename not in self.filenames: + return + + if filename == "types.xml": + # Remove all the types except our targets. + # Strategy: suck into ElementTree element, remove nodes, + # convert back to text, prettify. + root = etree.fromstring(text) + todelete = [] + for element in root.getchildren(): + name = element.attrib["name"] + if name != "title" and name not in self.typelist: + todelete.append(element) + for element in todelete: + root.remove(element) + # Add a marker for ZopeSkel additions + root.append(etree.Comment(" -*- extra stuff goes here -*- ")) + # minor prettifying + root_str = safe_text(etree.tostring(root)) + text = f'\n{root_str}' + text = text.replace("", "-->\n") + + self._archive.writestr(filename, safe_bytes(text)) + + +class TypesExport(BrowserView): + """Generate a types export archive for download""" + + def __call__(self): + RESPONSE = self.request.RESPONSE + ps = getToolByName(self.context, "portal_setup") + + items = self.request.selected.split(",") + context = SelectiveZipExportContext(ps, items, base_name="dexterity_export") + handler = ps.getExportStep("typeinfo") + handler(context) + + filename = context.getArchiveFilename() + + RESPONSE.setHeader("Content-type", "application/zip") + RESPONSE.setHeader("Content-disposition", f"attachment; filename={filename}") + + return context.getArchive() + + +class ModelsExport(BrowserView): + """Generate an archive for download of model xml files for selected + types. + """ + + def __call__(self): + RESPONSE = self.request.RESPONSE + pt = getToolByName(self.context, "portal_types") + + items = self.request.selected.split(",") + + if len(items) == 1: + # return a single XML file + + item = items[0] + filename = f"{item}.xml" + text = serializeModel(pt[item].lookupModel()) + + RESPONSE.setHeader("Content-type", "application/xml") + RESPONSE.setHeader( + "Content-disposition", f"attachment; filename={filename}" + ) + + return text + + elif len(items) > 1: + # pack multiple items into a zip file + + timestamp = time.gmtime() + archive_filename = ( + "dexterity_models-%4d%02d%02d%02d%02d%02d.zip" % timestamp[:6] + ) + + archive_stream = BytesIO() + archive = ZipFile(archive_stream, "w") + + for item in items: + filename = f"models/{item}.xml" + text = serializeModel(pt[item].lookupModel()) + archive.writestr(filename, text) + + archive.close() + + RESPONSE.setHeader("Content-type", "application/zip") + RESPONSE.setHeader( + "Content-disposition", + f"attachment; filename={archive_filename}", + ) + + return archive_stream.getvalue() + + else: + return "" diff --git a/src/plone/app/layout/dexterity/fields.py b/src/plone/app/layout/dexterity/fields.py new file mode 100644 index 000000000..59b7f4627 --- /dev/null +++ b/src/plone/app/layout/dexterity/fields.py @@ -0,0 +1,40 @@ +from importlib.metadata import distribution +from importlib.metadata import PackageNotFoundError +from plone.app.dexterity import _ +from plone.app.layout.dexterity.layout import TypeFormLayout +from plone.schemaeditor.browser.schema.listing import ReadOnlySchemaListing +from plone.schemaeditor.browser.schema.listing import SchemaListing +from z3c.form import button + +try: + distribution("plone.resourceeditor") + HAS_RESOURCEEDITOR = True +except PackageNotFoundError: + HAS_RESOURCEEDITOR = False + +# We want to add a Plone-specific feature to the SchemaListing +# form from plone.schemaeditor. We'll do this by subclassing, then +# adding the plone-specific button for the ace model editor. + + +class EnhancedSchemaListing(SchemaListing): + def handleModelEdit(self, action): + self.request.response.redirect("@@modeleditor") + + +if HAS_RESOURCEEDITOR: + but = button.Button("modeleditor", title="Edit XML Field Model") + EnhancedSchemaListing.buttons += button.Buttons(but) + handler = button.Handler(but, EnhancedSchemaListing.handleModelEdit) + EnhancedSchemaListing.handlers.addHandler(but, handler) + + +class TypeFieldsPage(TypeFormLayout): + label = _("Fields") + + @property + def form(self): + if self.context.fti.hasDynamicSchema: + return EnhancedSchemaListing + else: + return ReadOnlySchemaListing diff --git a/src/plone/app/layout/dexterity/folder_listing.pt b/src/plone/app/layout/dexterity/folder_listing.pt new file mode 100644 index 000000000..09ff82b71 --- /dev/null +++ b/src/plone/app/layout/dexterity/folder_listing.pt @@ -0,0 +1,147 @@ + + + + + + + + + +
+ + + +
+
+ + + + + + + Item Title + + + +
+ + — + + + by + + Bob Dobalina + + + + + + — + last modified + + August 16, 2001 at 23:35:59 + + + + + + Place custom listing info for custom types here + + + +
+
+
+

+ description +

+
+
+
+
+
+ +
+ +
+ + +

+ There are currently no items in this folder. +

+
+ +
+
+ +
+
+ + + diff --git a/src/plone/app/layout/dexterity/folder_listing.py b/src/plone/app/layout/dexterity/folder_listing.py new file mode 100644 index 000000000..00e30e70f --- /dev/null +++ b/src/plone/app/layout/dexterity/folder_listing.py @@ -0,0 +1,93 @@ +from Acquisition import aq_inner +from plone.app.dexterity import _ +from plone.base.batch import Batch +from plone.base.interfaces import ISecuritySchema +from plone.registry.interfaces import IRegistry +from Products.Five import BrowserView +from zope.component import getMultiAdapter +from zope.component import getUtility + + +class FolderView(BrowserView): + def __init__(self, context, request): + super().__init__(context, request) + + self.plone_view = getMultiAdapter((context, request), name="plone") + self.portal_state = getMultiAdapter( + (context, request), name="plone_portal_state" + ) + self.pas_member = getMultiAdapter((context, request), name="pas_member") + + limit_display = getattr(self.request, "limit_display", None) + limit_display = int(limit_display) if limit_display is not None else 20 + b_size = getattr(self.request, "b_size", None) + self.b_size = int(b_size) if b_size is not None else limit_display + b_start = getattr(self.request, "b_start", None) + self.b_start = int(b_start) if b_start is not None else 0 + + def results(self, **kwargs): + """Return a content listing based result set with contents of the + folder. + + :param **kwargs: Any keyword argument, which can be used for catalog + queries. + :type **kwargs: keyword argument + + :returns: plone.app.contentlisting based result set. + :rtype: ``plone.app.contentlisting.interfaces.IContentListing`` based + sequence. + """ + # Extra filter + kwargs.update(self.request.get("contentFilter", {})) + if "object_provides" not in kwargs: # object_provides is more specific + kwargs.setdefault("portal_type", self.friendly_types) + kwargs.setdefault("batch", True) + kwargs.setdefault("b_size", self.b_size) + kwargs.setdefault("b_start", self.b_start) + + listing = aq_inner(self.context).restrictedTraverse("@@folderListing", None) + if listing is None: + return [] + results = listing(**kwargs) + return results + + def batch(self): + batch = Batch(self.results(), size=self.b_size, start=self.b_start, orphan=1) + return batch + + def normalizeString(self, text): + return self.plone_view.normalizeString(text) + + def toLocalizedTime(self, time, long_format=None, time_only=None): + return self.plone_view.toLocalizedTime(time, long_format, time_only) + + @property + def friendly_types(self): + return self.portal_state.friendly_types() + + @property + def isAnon(self): + return self.portal_state.anonymous() + + @property + def navigation_root_url(self): + return self.portal_state.navigation_root_url() + + @property + def use_view_action(self): + registry = getUtility(IRegistry) + return registry.get("plone.types_use_view_action_in_listings", []) + + @property + def show_about(self): + registry = getUtility(IRegistry) + settings = registry.forInterface(ISecuritySchema, prefix="plone") + show_about = getattr(settings, "allow_anon_views_about", False) + return show_about or not self.isAnon + + @property + def no_items_message(self): + return _( + "description_no_items_in_folder", + default="There are currently no items in this folder.", + ) diff --git a/src/plone/app/layout/dexterity/import_types.py b/src/plone/app/layout/dexterity/import_types.py new file mode 100644 index 000000000..a228dff27 --- /dev/null +++ b/src/plone/app/layout/dexterity/import_types.py @@ -0,0 +1,191 @@ +"""Support for importing Dexterity types from GS zip file.""" + +# XXX: need to make exceptions more specific, shorten messages +from DateTime.DateTime import DateTime +from io import BytesIO +from lxml import etree +from plone.app.dexterity import _ +from plone.namedfile.field import NamedFile +from plone.z3cform.layout import wrap_form +from Products.CMFCore.utils import getToolByName +from Products.GenericSetup.context import BaseContext +from Products.GenericSetup.interfaces import IImportContext +from z3c.form import field +from z3c.form import form +from zipfile import BadZipfile +from zipfile import ZipFile +from zope.component.hooks import getSite +from zope.interface import implementer +from zope.interface import Interface +from zope.interface import Invalid +from zope.interface import invariant + +import os.path +import zope.schema + + +class ITypeProfileImport(Interface): + """Fields for a zip import form""" + + profile_file = NamedFile( + title=_("Type profiles archive file"), + required=True, + ) + + @invariant + def isGoodImportFile(data): + nfile = getattr(data, "profile_file", None) + if nfile is None: + # let required validator handle this + return None + try: + archive = ZipFile(BytesIO(data.profile_file.data), "r") + except BadZipfile: + raise Invalid( + _("Error: The file submitted must be a zip archive."), + ) + name_list = archive.namelist() + for fname in name_list: + if fname == "types.xml": + continue + if ( + os.path.dirname(fname) != "types" + or os.path.splitext(fname)[1] != ".xml" + ): + raise Invalid( + _( + "Error: The file submitted must be a zip archive " + "containing only type profile information." + ) + ) + + # check XML for basic integrity + with archive.open("types.xml", "r") as f: + source = f.read() + root = etree.fromstring(source) + if root.tag != "object": + raise Invalid(_("types.xml in archive is invalid.")) + + # check against existing types; don't allow overwrites + site = getSite() + existing_types = getToolByName(site, "portal_types").listContentTypes() + for element in root.getchildren(): + if element.tag == "object": + attribs = element.attrib + if not attribs["meta_type"] == "Dexterity FTI": + raise Invalid( + _("Types in archive must be only Dexterity types."), + ) + if attribs["name"] in existing_types: + msg = ( + "One or more types in the import archive is an " + 'existing type. Delete "%s" if you ' + "really wish to replace it." + ) + raise Invalid( + _(msg, attribs["name"]), + ) + + +@implementer(ITypeProfileImport) +class TypeProfileImport: + form_fields = field.Fields(ITypeProfileImport) + profile_file = zope.schema.fieldproperty.FieldProperty( + ITypeProfileImport["profile_file"] + ) + + def __init__(self, profile_file): + self.profile_file = profile_file + + +class TypeProfileImportForm(form.AddForm): + label = _("Import Content Types") + description = _( + "You may import types by uploading a zip archive containing type " + "profiles. The import archive should contain a types.xml file and a " + "types directory containing one or more Dexterity type information " + "files. For a sample, create a content type and export it from the " + "Dexterity Content Types page." + ) + fields = field.Fields(ITypeProfileImport) + id = "import-types-form" + + def create(self, data): + return TypeProfileImport(**data) + + def add(self, profile_import): + # initialize import context + types_tool = getToolByName(self.context, "portal_types") + import_context = ZipFileImportContext( + types_tool, BytesIO(profile_import.profile_file.data) + ) + # run the profile + setup_tool = getToolByName(self.context, "portal_setup") + handler = setup_tool.getImportStep("typeinfo") + handler(import_context) + self.status = _("Imported successfully.") + + def nextURL(self): + url = self.context.absolute_url() + return url + + +TypeProfileImportFormPage = wrap_form(TypeProfileImportForm) + + +@implementer(IImportContext) +class ZipFileImportContext(BaseContext): + """GS Import context for a ZipFile""" + + def __init__(self, tool, archive_bits, encoding=None, should_purge=False): + super().__init__(tool, encoding) + self._archive = ZipFile(archive_bits, "r") + self._should_purge = bool(should_purge) + self.name_list = self._archive.namelist() + + def readDataFile(self, filename, subdir=None): + if subdir is not None: + filename = "/".join((subdir, filename)) + + try: + file = self._archive.open(filename, "r") + except KeyError: + return None + + return file.read() + + def getLastModified(self, path): + try: + zip_info = self._archive.getinfo(path) + except KeyError: + return None + return DateTime(*zip_info.date_time) + + def isDirectory(self, path): + """See IImportContext""" + + # namelist only includes full filenames, not directories + return path not in self.name_list + + def listDirectory(self, path, skip=[]): + """See IImportContext""" + + # namelist contains only full path/filenames, not + # directories. But we need to include directories. + + if path is None: + path = "" + path_parts = path.split("/") + res = set() + for pn in self.name_list: + dn, bn = os.path.split(pn) + dn_parts = dn.split("/") + if dn == path: + if bn not in skip: + res.add(bn) + continue + if dn.startswith(path) and ( + path == "" or len(dn_parts) == len(path_parts) + 1 + ): + res.add(dn_parts[-1]) + return list(res) diff --git a/src/plone/app/layout/dexterity/item.pt b/src/plone/app/layout/dexterity/item.pt new file mode 100644 index 000000000..1069ef268 --- /dev/null +++ b/src/plone/app/layout/dexterity/item.pt @@ -0,0 +1,36 @@ + + + + + + + + + + + + +
+ + + + +
+ +
+
+ + + diff --git a/src/plone/app/layout/dexterity/layout.py b/src/plone/app/layout/dexterity/layout.py new file mode 100644 index 000000000..4f776a83c --- /dev/null +++ b/src/plone/app/layout/dexterity/layout.py @@ -0,0 +1,15 @@ +from plone.app.dexterity import _ +from plone.z3cform.layout import FormWrapper +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile + + +class TypeFormLayout(FormWrapper): + index = ViewPageTemplateFile("tabbed_forms.pt") + + @property + def tabs(self): + return ( + (_("Overview"), "@@overview"), + (_("Fields"), "@@fields"), + (_("Behaviors"), "@@behaviors"), + ) diff --git a/src/plone/app/layout/dexterity/modeleditor.pt b/src/plone/app/layout/dexterity/modeleditor.pt new file mode 100644 index 000000000..afd95b60b --- /dev/null +++ b/src/plone/app/layout/dexterity/modeleditor.pt @@ -0,0 +1,79 @@ + + + +
+ + +
+ + Back to the schema editor + + +

Edit field model for + name +

+ +

+ This form allows you to directly edit the XML representation + of the field list. This makes it possible to add annotations for fieldsets, + validation and widgets. See the + Dexterity XML + section of the + Dexterity Developer Manual + for details. +

+
+ +
+ +
+ + + + + +
+ + + +
+ +
+ +
+
+ + diff --git a/src/plone/app/layout/dexterity/modeleditor.py b/src/plone/app/layout/dexterity/modeleditor.py new file mode 100644 index 000000000..a7fb5f7c4 --- /dev/null +++ b/src/plone/app/layout/dexterity/modeleditor.py @@ -0,0 +1,149 @@ +from AccessControl import Unauthorized +from lxml import etree +from plone.app.dexterity import _ +from plone.base.utils import safe_bytes +from plone.base.utils import safe_text +from plone.supermodel import loadString +from plone.supermodel import serializeModel +from plone.supermodel.parser import SupermodelParseError +from Products.Five import BrowserView +from Products.statusmessages.interfaces import IStatusMessage +from zope.component import queryMultiAdapter +from zope.deprecation import deprecate + +import html + +NAMESPACE = "{http://namespaces.plone.org/supermodel/schema}" + + +class ModelEditorView(BrowserView): + """Editor view.""" + + @property + @deprecate("""`ModelEditorView.escaped_model_source` is deprecated will be removed +in Plone 7. Use `model_source` without the `structure` TALES expression +instead.""") + def escaped_model_source(self): + # Return the HTML escaped model source. + return html.escape(self.model_source, False) + + @deprecate("""`ModelEditorView.modelSource` is deprecated and will be removed in +Plone 7. Use `model_source` instead. +""") + def modelSource(self): + # BBB + return self.model_source + + @property + def model_source(self): + # Return modified source from textarea or the original FTI source. + source = self._unescaped_source_from_request() or self.context.fti.model_source + if source: + return source + + # or serialize the model file + model = self.context.fti.lookupModel() + return serializeModel(model) + + def _unescaped_source_from_request(self): + """Unescape the source from the request. + + We expect that the source we get from the request is escaped html. + If we pass this directly to the lxml parser, we get: + Error: XMLSyntaxError: Start tag expected + See https://github.com/plone/Products.CMFPlone/issues/3695 + So we need to unescape it. + + There is a danger that we unescape too much. If we somehow get already + unescaped xml, this may contain escaped html. If we then call html.unescape, + this html gets unescaped, which is not what we want. + The source likely starts with one of these strings: + + <?xml + <model + + We check if it starts with '<' and we only unescape then. + """ + source = self.request.form.get("source") + if not source: + return + # If you let the source start with spaces, it actually becomes invisible + # in the code editor. So strip it to be safe. + source = source.strip() + if source.startswith("<"): + source = html.unescape(source) + return source + + def authorized(self, context, request): + authenticator = queryMultiAdapter((context, request), name="authenticator") + return authenticator and authenticator.verify() + + def __call__(self): + """View and eventually save the form.""" + + save = "form.button.save" in self.request.form + source = self._unescaped_source_from_request() + if save and source: + # First, check for authenticator + if not self.authorized(self.context, self.request): + raise Unauthorized + + # Is it valid XML? + # Some safety measures. + # We do not want to load entities, especially file:/// entities. + # Also discard processing instructions. + # + source = safe_bytes(source) + parser = etree.XMLParser(resolve_entities=False, remove_pis=True) + try: + root = etree.fromstring(source, parser=parser) + except etree.XMLSyntaxError as e: + IStatusMessage(self.request).addStatusMessage( + f"XMLSyntaxError: {html.escape(safe_text(e.args[0]))}", + "error", + ) + return super().__call__() + + # a little more sanity checking, look at first two element levels + if root.tag != NAMESPACE + "model": + IStatusMessage(self.request).addStatusMessage( + _("Error: root tag must be 'model'"), + "error", + ) + return super().__call__() + + for element in root.getchildren(): + if element.tag != NAMESPACE + "schema": + IStatusMessage(self.request).addStatusMessage( + _("Error: all model elements must be 'schema'"), + "error", + ) + return super().__call__() + + # can supermodel parse it? + # This is mainly good for catching bad dotted names. + try: + loadString(source, policy="dexterity") + except SupermodelParseError as e: + message = e.args[0].replace('\n File ""', "") + IStatusMessage(self.request).addStatusMessage( + f"SuperModelParseError: {html.escape(message)}", + "error", + ) + return super().__call__() + + # clean up formatting sins + source = etree.tostring( + root, pretty_print=True, xml_declaration=True, encoding="utf8" + ) + + # Save to FTI and also allow to clear the source + fti = self.context.fti + fti.manage_changeProperties(model_source=source) + + IStatusMessage(self.request).addStatusMessage( + _("Changes saved."), + "info", + ) + + return super().__call__() diff --git a/src/plone/app/layout/dexterity/overview.pt b/src/plone/app/layout/dexterity/overview.pt new file mode 100644 index 000000000..27c9b8f9f --- /dev/null +++ b/src/plone/app/layout/dexterity/overview.pt @@ -0,0 +1,54 @@ + + + + + +
+ Contained items + + + +
+
+ +
+
+
+
+ +
+
diff --git a/src/plone/app/layout/dexterity/overview.py b/src/plone/app/layout/dexterity/overview.py new file mode 100644 index 000000000..806b04fe3 --- /dev/null +++ b/src/plone/app/layout/dexterity/overview.py @@ -0,0 +1,34 @@ +from plone.app.dexterity import _ +from plone.app.layout.dexterity.layout import TypeFormLayout +from plone.app.dexterity.interfaces import ITypeSettings +from Products.CMFCore.interfaces import IFolderish +from z3c.form import field +from z3c.form import form +from zope.browserpage.viewpagetemplatefile import ViewPageTemplateFile +from zope.dottedname.resolve import resolve as resolveDottedName + + +class TypeOverviewForm(form.EditForm): + enableCSRFProtection = True + template = ViewPageTemplateFile("overview.pt") + + @property + def fields(self): + # if this type's class is not a container, + # remove the field for filtering contained content types + klass = resolveDottedName(self.context.fti.klass) + fields = field.Fields(ITypeSettings) + filtered = fields.select( + "title", "description", "allowed_content_types", "filter_content_types" + ) + if not IFolderish.implementedBy(klass): + del filtered["filter_content_types"] + return filtered + + def getContent(self): + return self.context.fti + + +class TypeOverviewPage(TypeFormLayout): + form = TypeOverviewForm + label = _("Overview") diff --git a/src/plone/app/layout/dexterity/tabbed_forms.pt b/src/plone/app/layout/dexterity/tabbed_forms.pt new file mode 100644 index 000000000..d631c3b04 --- /dev/null +++ b/src/plone/app/layout/dexterity/tabbed_forms.pt @@ -0,0 +1,40 @@ + + + + +
+ +
+ Portal status message +
+ +

+ ()

+ + +
+
+ +
+ +
+ + diff --git a/src/plone/app/layout/dexterity/types.py b/src/plone/app/layout/dexterity/types.py new file mode 100644 index 000000000..77d19a15c --- /dev/null +++ b/src/plone/app/layout/dexterity/types.py @@ -0,0 +1,302 @@ +from OFS.SimpleItem import SimpleItem +from plone.app.dexterity import _ +from plone.app.layout.dexterity.utils import UTF8Property +from plone.app.dexterity.interfaces import ITypeSchemaContext +from plone.app.dexterity.interfaces import ITypesContext +from plone.app.dexterity.interfaces import ITypeSettings +from plone.app.dexterity.interfaces import ITypeStats +from plone.dexterity.interfaces import IDexterityFTI +from plone.dexterity.utils import getAdditionalSchemata +from plone.schemaeditor.browser.schema.traversal import SchemaContext +from plone.z3cform import layout +from plone.z3cform.crud import crud +from plone.z3cform.layout import FormWrapper +from Products.CMFCore.utils import getToolByName +from Products.Five.browser.pagetemplatefile import ( + ViewPageTemplateFile as FiveViewPageTemplateFile, +) +from z3c.form import button +from z3c.form import field +from zope.browserpage.viewpagetemplatefile import ViewPageTemplateFile +from zope.cachedescriptors.property import Lazy as lazy_property +from zope.component import adapter +from zope.component import ComponentLookupError +from zope.component import getAllUtilitiesRegisteredFor +from zope.component import getUtility +from zope.interface import implementer +from zope.publisher.interfaces.browser import IBrowserPublisher +from ZPublisher.BaseRequest import DefaultPublishTraverse + +import urllib + +ALLOWED_FIELDS = [ + "plone.app.textfield.RichText", + "plone.namedfile.field.NamedBlobImage", + "plone.namedfile.field.NamedBlobFile", + "plone.schema.email.Email", + "z3c.relationfield.schema.RelationChoice", + "z3c.relationfield.schema.RelationList", + "zope.schema._bootstrapfields.Bool", + "zope.schema._bootstrapfields.Int", + "plone.schema.jsonfield.JSONField", + "zope.schema._bootstrapfields.Password", + "zope.schema._bootstrapfields.Text", + "zope.schema._bootstrapfields.TextLine", + "zope.schema._field.Choice", + "zope.schema._field.Date", + "zope.schema._field.Datetime", + "zope.schema._field.Float", + "zope.schema._field.Set", + "zope.schema._field.URI", +] + + +class TypeEditSubForm(crud.EditSubForm): + """Content type edit subform. Just here to use a custom template.""" + + template = ViewPageTemplateFile("types_listing_row.pt") + + +class TypeEditForm(crud.EditForm): + """Content type edit form. + + Just a normal CRUD form without the form title or edit button. + """ + + label = None + editsubform_factory = TypeEditSubForm + + buttons = crud.EditForm.buttons.copy().omit("edit") + handlers = crud.EditForm.handlers.copy() + + @button.buttonAndHandler(_("Clone")) + def handleClone(self, action): + selected = self.selected_items() + + if len(selected) > 1: + self.status = _("Please select a single type to clone.") + elif len(selected) == 1: + id = selected[0][0] + url = f"{self.context.context.absolute_url()}/{id}/@@clone" + self.request.response.redirect(url) + else: + self.status = _("Please select a type to clone.") + + @button.buttonAndHandler(_("Export Type Profiles")) + def handleExport(self, action): + selected = ",".join([items[0] for items in self.selected_items()]) + + if len(selected) == 0: + self.status = _("Please select types to export.") + elif len(selected) > 0: + url = "{}/@@types-export?selected={}".format( + self.context.context.absolute_url(), + urllib.parse.quote(selected), + ) + self.request.response.redirect(url) + + @button.buttonAndHandler(_("Export Schema Models")) + def handleExportModels(self, action): + selected = ",".join([items[0] for items in self.selected_items()]) + + if len(selected) == 0: + self.status = _("Please select types to export.") + elif len(selected) > 0: + url = "{}/@@models-export?selected={}".format( + self.context.context.absolute_url(), urllib.parse.quote(selected) + ) + self.request.response.redirect(url) + + +class TypesEditFormWrapper(FormWrapper): + """Render Plone frame around our form with little modifications""" + + form = TypeEditForm + index = FiveViewPageTemplateFile("typesformwrapper.pt") + + +@adapter(IDexterityFTI) +@implementer(ITypeSettings) +class TypeSettingsAdapter: + def __init__(self, context): + self.context = context + + @property + def id(self): + return self.context.getId() + + title = UTF8Property("title") + description = UTF8Property("description") + + @property + def container(self): + return self.context.container + + def _get_allowed_content_types(self): + return set(self.context.allowed_content_types) + + def _set_allowed_content_types(self, value): + if not value: + value = () + self.context.allowed_content_types = tuple(value) + if value: + self.context.filter_content_types = True + + allowed_content_types = property( + _get_allowed_content_types, _set_allowed_content_types + ) + + def _get_filter_content_types(self): + value = self.context.filter_content_types + if not value: + return "all" + elif value and not self.allowed_content_types: + return "none" + else: + return "some" + + def _set_filter_content_types(self, value): + if value == "none": + self.context.filter_content_types = True + self.context.allowed_content_types = () + elif value == "all": + self.context.filter_content_types = False + elif value == "some": + self.context.filter_content_types = True + + filter_content_types = property( + _get_filter_content_types, _set_filter_content_types + ) + + +@adapter(IDexterityFTI) +@implementer(ITypeStats) +class TypeStatsAdapter: + def __init__(self, context): + self.context = context + + @property + def item_count(self): + catalog = getToolByName(self.context, "portal_catalog") + lengths = dict(catalog.Indexes["portal_type"].uniqueValues(withLengths=True)) + return lengths.get(self.context.getId(), 0) + + +class TypesListing(crud.CrudForm): + """The combined content type edit + add forms.""" + + @lazy_property + def description(self): + if self.get_items(): + return _( + "The following custom content types are available for your " "site." + ) + return _( + "help_addcontenttype_button", + default='Content types show up on Plone\'s "Add Item" menu and ' + "allow you to store custom data in your site. Click the " + '"Add Content Type" button to begin creating a new ' + "content type with its own fields.", + ) + + template = ViewPageTemplateFile("types_listing.pt") + view_schema = field.Fields(ITypeSettings).select("title", "description") + view_schema += field.Fields(ITypeStats) + addform_factory = crud.NullForm + editform_factory = TypeEditForm + + def get_items(self): + """Look up all Dexterity FTIs via the component registry. + + (These utilities are created via an IObjectCreated handler for the + DexterityFTI class, configured in plone.dexterity.) + """ + ftis = getAllUtilitiesRegisteredFor(IDexterityFTI) + return [(fti.__name__, fti) for fti in ftis] + + def remove(self, id_and_item): + """Remove a content type.""" + ttool = getToolByName(self.context, "portal_types") + ttool.manage_delObjects([id_and_item[0]]) + + def link(self, item, field): + """Generate links to the edit page for each type. + + (But only for types with schemata that can be edited through the web.) + """ + if field == "title": + return "{}/{}".format( + self.context.absolute_url(), urllib.parse.quote(item.__name__) + ) + + +# Create a form wrapper so the form gets layout. +TypesListingPage = layout.wrap_form( + TypesListing, __wrapper_class=TypesEditFormWrapper, label=_("Content Types") +) + + +@implementer(ITypeSchemaContext) +class TypeSchemaContext(SchemaContext): + fti = None + schemaName = "" + schemaEditorView = "fields" + allowedFields = ALLOWED_FIELDS + + def browserDefault(self, request): + return self, ("@@overview",) + + @property + def additionalSchemata(self): + return getAdditionalSchemata(portal_type=self.fti.getId()) + + +# IBrowserPublisher tells the Zope 2 traverser to pay attention to the +# publishTraverse and browserDefault methods. +@implementer(ITypesContext, IBrowserPublisher) +class TypesContext(SimpleItem): + """This class represents the types configlet. + + It allows us to traverse through it to (a wrapper of) the schema + of a particular type. + """ + + def __init__(self, context, request): + super().__init__(context, request) + + # make sure that breadcrumbs will be correct + self.id = None + self.Title = lambda: _("Content Types") + + # turn off green edit border for anything in the type control panel + request.set("disable_border", 1) + + def publishTraverse(self, request, name): + """Traverse to a schema context. + + 1. Try to find a content type whose name matches the next URL path + element. + 2. Look up its schema. + 3. Return a schema context (an acquisition-aware wrapper of the + schema). + """ + try: + fti = getUtility(IDexterityFTI, name=name) + except ComponentLookupError: + return DefaultPublishTraverse(self, request).publishTraverse(request, name) + + schema = fti.lookupSchema() + schema_context = TypeSchemaContext( + schema, request, name=name, title=fti.title + ).__of__(self) + schema_context.fti = fti + schema_context.schemaName = "" + return schema_context + + def browserDefault(self, request): + """Show the 'edit' view by default. + + If we aren't traversing to a schema beneath the types configlet, + we actually want to see the TypesListingPage. + """ + return self, ("@@edit",) diff --git a/src/plone/app/layout/dexterity/types_listing.pt b/src/plone/app/layout/dexterity/types_listing.pt new file mode 100644 index 000000000..2cfa36757 --- /dev/null +++ b/src/plone/app/layout/dexterity/types_listing.pt @@ -0,0 +1,48 @@ + + + + +

+

+
+ + + + + + + +
+ +
+
+ +
+ +
+
diff --git a/src/plone/app/layout/dexterity/types_listing_row.pt b/src/plone/app/layout/dexterity/types_listing_row.pt new file mode 100644 index 000000000..fb27ffe7f --- /dev/null +++ b/src/plone/app/layout/dexterity/types_listing_row.pt @@ -0,0 +1,50 @@ + + + + + + + + + + +
+ +
+ +
+
+ + +
diff --git a/src/plone/app/layout/dexterity/typesformwrapper.pt b/src/plone/app/layout/dexterity/typesformwrapper.pt new file mode 100644 index 000000000..b65ca0c64 --- /dev/null +++ b/src/plone/app/layout/dexterity/typesformwrapper.pt @@ -0,0 +1,21 @@ + + + + +

Title

+
+ +
+
+
+ diff --git a/src/plone/app/layout/dexterity/utils.py b/src/plone/app/layout/dexterity/utils.py new file mode 100644 index 000000000..17f47a434 --- /dev/null +++ b/src/plone/app/layout/dexterity/utils.py @@ -0,0 +1,9 @@ +class UTF8Property: + def __init__(self, name): + self.name = name + + def __get__(self, inst, type=None): + return getattr(inst.context, self.name) + + def __set__(self, inst, value): + setattr(inst.context, self.name, value) diff --git a/src/plone/app/layout/tests/import/dexterity_export.zip b/src/plone/app/layout/tests/import/dexterity_export.zip new file mode 100644 index 0000000000000000000000000000000000000000..34563be9152532a3c40aba4de796c344b5aa6e92 GIT binary patch literal 5804 zcmeHLQE%f!5N;`l)be@=As*OUp%X_Hn-edn*W`L?sZ_N<9wJ`N#$G3TU3;zFb=rUs z_dj^zhw#9U;K%R;GrM+bC#f$W5J<#TQhPl!J3BM`&6gcs?!EbN2d|&!SEoPz@XZHT zyE{Ah*@rPZFSrSAGkN?RzQ~MgBUIj}PlHeB^TENf8o%VRB{|D@pB746CL^aqNyaU6 z_kB8)D(7T`w#Y~+Ne2hyxX`NL+RiIwE3D*naH?{vl{CaoIbkyD11*=%_^sty!1U*%3mSH4+8gyc%}2c+>3!TIU{f<< zY?-EHqF}L8uDN^D>p`$W@7rM53i+qy{{C+eYy7-hhdgX~zG(tlN*zuU@$|F&C{Y;` zkjnzILk|cckmB4N;mbaqT3Z~2Aq)lILZSktCn2WU6fEW@M4|iB=qG?+R~;DXY_0RYJl|tVLm=(C8XNF9)nL$lOC>Rv^q|;i;VypC=4q7o@uY#eh*20n`nhAch>`1C)!gb^vIXncZ!@~^> zS4O4CuOrL!gj+gT??NRhvWzDJGT@w`19YWRVItKSwlI@Y-9XNC*`2CXNZ3VDwL7bw z|9UlNo+nFsZWAumf|HCAVdByt^3cIKlcw9Lgr}^OcGD%h^Z-~*RbsHB$uXB43xmv< zETMK$z#~KpXqG%}&KjlQ_n5`ko4AW50zjCFYJqW<1}~Ci=)m;U<6dXZt|c?zx!cA; zQsY`sB_%);u+O>fG&1H>HWNy(4}rJMilSkEp%Ea`C(N?o;;bA)ah@v8UBFgCd1t5g zP5`cJ*j&(MF1`eKIoB#1xmMuHp)2Ahp7M;{w*wlRFjEPak%5j8Lx*`R?K3x0!JXl8 ziH%?Zd3Pumcz+T4Oo=%{Lt(8zD+3wRX3FHo`jvdGo+_Pftc${x;>J2voE}f?GZz#L zfFD~75rTuM;4-O@!`=I?i9NSZT@&zX!3c`%3QCGL40sa_><(96x)zV4=WZhC12}8A z75O3hvE@2LCARGF{3FCSZuxI$%(snHQtnfLc&&63)PP+at@RHoCe@3wmcz{ z0V%Tev@!Ni;$qvUUn7JOXkjoGiKN0b5<_R0Kv(Ni&rB%tEbtMfuyaD+b~$Ne6^6*7 zv*JUe8Q~*zv`Tf+biHlY>%6&{Fak(jpJIRUkb4J!HR?K-JqCEck)rv#dR3pha5h%u z9T4U693tHd}n~-lO+_2d0Rhb!-vX>Yrze z-4yrVWs9R5)!u(AeYcw}<_dmE=>H05gh;PnjVm$iJbn6?IODyBx14bk64qpI8_;Bg z-)PGjx16#5nY`tU09>9kx@PiEaYpC{Th53?s6j$&lIZB*$w~dV<%}x-|8mCNHy-ZY zf2@ngy?DKXW8TIg@9?s%wtO^+!g#&fHK6FuVpk6;myIklR&N50{Pgy!5kL>*ufxj+ RZ=v8G{QUy|e|-m^{s32