diff --git a/CMFCollector/CHANGELOG.txt b/CMFCollector/CHANGELOG.txt new file mode 100644 index 0000000..d20168c --- /dev/null +++ b/CMFCollector/CHANGELOG.txt @@ -0,0 +1,39 @@ +CMFCollector Changelog + +version: 0.93 + + * issue description gets a bogus default value: help_prefix_lines_with_whitespace (thanks Lucas Hofman) + + + +version: 0.92 + + * 3/1/2004 MSL + + - fixed a bug in Install.py + +version: 0.91 + + * 2/25/2004 MSL (http://www.zope.org/Members/bowerymarc). Bug fixes to maintain the product's compatibility with up to zope 2.6.2, CMF 1.4.2, Plone 2.0RC5. I have a bunch of old collectors I don't want to lose! + + * MSL changed the following skins: + + - collector_contents.pt - ability to set batch size. date range search. + + - collector_macros.pt - changed issue_header to show structured description (i.e. renders html correctly) + + - collector_search.py - added date range search. TODO: check date pairs, fill in min/max date if one date is missing. + + * you may have to give Manager proxy roles to these scripts, depending on your specific security settings (if you have problems try this): + + - collector_add_issue.py + + - collector_issue_edit.py + + - collector_issue_followup.py + + * added limi's collector_plone skins and updated Install.py to install those too. + + * new collector_issue_workflow.zexp fixes some bugs and (importantly) adds a TESTED state. Yes, once you resolve, then you must test! + + * updated the defaults for a new collector config diff --git a/CMFCollector/Collector.py b/CMFCollector/Collector.py new file mode 100644 index 0000000..3264577 --- /dev/null +++ b/CMFCollector/Collector.py @@ -0,0 +1,571 @@ +############################################################################## +# +# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + +"""Implement the Collector issue-container content type.""" + +import os, urllib +from DateTime import DateTime +from Globals import InitializeClass, DTMLFile, package_home + +from AccessControl import ClassSecurityInfo, ModuleSecurityInfo, Permission +from AccessControl import getSecurityManager + +from Products.CMFDefault.DublinCore import DefaultDublinCoreImpl +from Products.CMFCore.PortalContent import PortalContent +from Products.CMFCore.WorkflowCore import WorkflowAction +from Products.CMFCore.CatalogTool import CatalogTool + +from Products.CMFDefault.SkinnedFolder import SkinnedFolder + +import util + +# Import permission names +from Products.CMFCore import CMFCorePermissions +from CollectorPermissions import * + +from CollectorIssue import addCollectorIssue, CollectorIssue + +INTERNAL_CATALOG_ID = 'collector_catalog' + +# Factory type information -- makes Events objects play nicely +# with the Types Tool (portal_types) +factory_type_information = ( + {'id': 'Collector', + 'content_icon': 'collector_icon.gif', + 'meta_type': 'CMF Collector', + 'description': ('A Collector is a facility for tracking bug reports and' + ' other issues.'), + 'product': 'CMFCollector', + 'factory': 'addCollector', + 'allowed_content_types': ('CollectorIssue', + 'CollectorCatalog', + 'Collector Subset', + ), + 'immediate_view': 'collector_edit_form', + 'actions': ({'id': 'view', + 'name': 'Browse', + 'action': 'string:${object_url}/collector_contents', + 'permissions': (ViewCollector,)}, + {'id': 'addissue', + 'name': 'New Issue', + 'action': 'string:${object_url}/collector_add_issue_form', + 'permissions': (AddCollectorIssue,)}, + {'id': 'edit', + 'name': 'Configure', + 'action': 'string:${object_url}/collector_edit_form', + 'permissions': (ManageCollector,)}, + ), + }, + ) + +_dtmldir = os.path.join(package_home(globals()), 'dtml') +addCollectorForm = DTMLFile('addCollectorForm', _dtmldir, Kind='CMF Collector') + +class Collector(SkinnedFolder): + """Collection of IssueBundles.""" + + meta_type = 'CMF Collector' + effective_date = expiration_date = None + + DEFAULT_IMPORTANCES = ['*unassigned*', 'critical','high', 'medium','low'] + DEFAULT_CLASSIFICATIONS = ['*unassigned*','problem','potential_problem','bug', 'bug+solution', + 'feature_request', + 'doc', 'test'] + DEFAULT_VERSION_INFO_SPIEL = ( + "Version details; also include related info like browser," + " webserver, database, python, OS, etc.") + + DEFAULT_DESCRIPTION = ''' +Bug/Issue Collector. Submit bug reports, feature requests, problem reports, error reports, etc. here. +
Issue states are: +
Pending - issue has been requested to be looked at. +
Accepted - issue has been accepted by someone (please don't accept issues for someone else!) +
Deferred - decision to defer dealing with issue +
Rejected - issue will not be dealt with +
Resolved - issue has been dealt with +
Tested - resolved issue has been tested and verified
+''' + + version_info_spiel = DEFAULT_VERSION_INFO_SPIEL + + security = ClassSecurityInfo() + + abbrev = 'CLTR' + + managers = () + dispatching = 1 + # participation modes: 'staff', 'authenticated', 'anyone' + participation = 'staff' + + # state_email - a dictionary pairing state names with email destinations + # for all notifications occuring within that state. + state_email = {} + + batch_size = 10 + + _properties=({'id':'title', 'type': 'string', 'mode':'w'}, + {'id':'last_issue_id', 'type': 'int', 'mode':'w'}, + {'id':'batch_size', 'type': 'int', 'mode':'w'}, + ) + + def __init__(self, id, title='', description=DEFAULT_DESCRIPTION, abbrev='', + email=None, + topics=None, classifications=None, importances=None, + managers=None, supporters=None, dispatching=None, + version_info_spiel=None): + + SkinnedFolder.__init__(self, id, title) + + self._setup_internal_catalog() + + self.last_issue_id = 0 + + self.description = description + self.abbrev = abbrev + + username = str(getSecurityManager().getUser()) + util.add_local_role(self, username, 'Manager') + util.add_local_role(self, username, 'Owner') + if managers is None: + if username: managers = [username] + else: managers = [] + elif username and username not in managers: + managers.append(username) + self.managers = managers + if supporters is None: + supporters = [] + self.supporters = supporters + self._adjust_staff_roles(no_reindex=1) + + # XXX We need to ensure *some* collector email addr... + self.email = email + + if topics is None: + self.topics = ['*unassigned*', 'Zope', 'Collector', 'Database', + 'Catalog', 'ZServer'] + else: self.topics = topics + + if classifications is None: + self.classifications = self.DEFAULT_CLASSIFICATIONS + else: self.classifications = classifications + + if importances is None: + self.importances = self.DEFAULT_IMPORTANCES + else: self.importances = importances + + if version_info_spiel is None: + self.version_info_spiel = self.DEFAULT_VERSION_INFO_SPIEL + else: self.version_info_spiel = version_info_spiel + + return self + + def _setup_internal_catalog(self): + """Create and situate properly configured collector catalog.""" + catalog = CollectorCatalog() + self._setObject(catalog.id, catalog) + + security.declareProtected(CMFCorePermissions.View, 'get_internal_catalog') + def get_internal_catalog(self): + """ """ + return self._getOb(INTERNAL_CATALOG_ID) + + + security.declareProtected(AddCollectorIssue, 'new_issue_id') + def new_issue_id(self): + """Return a new issue id, incrementing the internal counter.""" + lastid = self.last_issue_id = self.last_issue_id + 1 + return str(lastid) + + security.declareProtected(AddCollectorIssue, 'add_issue') + def add_issue(self, + title=None, + description=None, + security_related=None, + submitter_name=None, + submitter_email=None, + kibitzers=None, + topic=None, + importance=None, + classification=None, + version_info=None, + assignees=None, + file=None, fileid=None, filetype=None): + """Create a new collector issue.""" + id = self.new_issue_id() + submitter_id = str(getSecurityManager().getUser()) + + err = addCollectorIssue(self, + id, + title=title, + description=description, + submitter_id=submitter_id, + submitter_name=submitter_name, + submitter_email=submitter_email, + kibitzers=kibitzers, + topic=topic, + classification=classification, + security_related=security_related, + importance=importance, + version_info=version_info, + assignees=assignees, + file=file, fileid=fileid, filetype=filetype) + return id, err + + + security.declareProtected(ManageCollector, 'edit') + def edit(self, title=None, description=None, + abbrev=None, email=None, + managers=None, supporters=None, dispatching=None, + participation=None, + state_email=None, + topics=None, classifications=None, + importances=None, + version_info_spiel=None): + + changes = [] + staff_changed = 0 + userid = str(getSecurityManager().getUser()) + + if title is not None and title != self.title: + self.title = title + changes.append("Title") + + if description is not None and self.description != description: + self.description = description + changes.append("Description") + + if abbrev is not None and self.abbrev != abbrev: + self.abbrev = abbrev + changes.append("Abbrev") + + if email is not None and self.email != email: + self.email = email + changes.append("Email") + + if not self.email: + raise ValueError, ('' + '' + 'The collector must' + ' have an email address' + '' + '') + + if managers is not None or not self.managers: + # XXX Vette managers - they must exist, etc. + x = filter(None, managers) + if not self.managers: + changes.append("Managers can't be empty - including initial" + " owner") + # Somehow we arrived here with self.managers empty - reinstate + # at least the owner, if any found, else the current manager. + owners = self.users_with_local_role('Owner') + if owners: + x.extend(owners) + else: + x.append(userid) + elif ((userid in self.managers) + and (userid not in x)): + changes.append("Managers cannot de-enlist themselves") + x.append(userid) + if self.managers != x: + changes.append("Managers") + self.managers = x + staff_changed = 1 + + if supporters is not None: + # XXX Vette supporters - they must exist, etc. + x = filter(None, supporters) + if self.supporters != x: + changes.append("Supporters") + self.supporters = x + staff_changed = 1 + + if staff_changed: + changes.extend(self._adjust_staff_roles()) + + if dispatching is not None and self.dispatching != dispatching: + self.dispatching = dispatching + changes.append("Dispatching %s" + % ((dispatching and "on") or "off")) + + if participation is not None and self.participation != participation: + self._adjust_participation_mode(participation) + changes.append("Participation => '%s'" % participation) + + if state_email is not None: + changed = 0 + # Use a new dict, to ensure it's divorced from shared class + # variable hood. + se = {} + if type(self.state_email) != type({}): + # Backwards-compat hack. Convert back to dictionary... + d = {} + for k, v in self.state_email.items(): d[k] = v + self.state_email = d + se.update(self.state_email) + for k, v in state_email.items(): + current_setting = se.get(k, None) + if ( ((not current_setting) and v) + or (current_setting and (current_setting != v)) ): + changed = 1 + if not v: + del se[k] + else: + se[k] = v + if changed: + self.state_email = se + changes.append("State email") + + if topics is not None: + x = filter(None, topics) + if self.topics != x: + self.topics = x + changes.append("Topics") + + if classifications is not None: + x = filter(None, classifications) + if self.classifications != x: + self.classifications = x + changes.append("Classifications") + + if importances is not None: + x = filter(None, importances) + if self.importances != x: + self.importances = x + changes.append("Importance") + + if version_info_spiel is not None: + if self.version_info_spiel != version_info_spiel: + self.version_info_spiel = version_info_spiel + changes.append("Version Info spiel") + + return ", ".join(changes) + + def _adjust_staff_roles(self, no_reindex=0): + """Adjust local-role assignments to track staff roster settings. + Ie, ensure: only designated supporters and managers have 'Reviewer' + local role, only designated managers have 'Manager' local role. + + We reindex the issues if any local role changes occur, so + allowedRolesAndUsers catalog index tracks. + + We return a list of changes (or non-changes).""" + + managers = self.managers + supporters = self.supporters + change_notes = [] + changed = 0 + + if not managers: + # Something is awry. Managers are not allowed to remove + # themselves from the managers roster, and only managers should be + # able to adjust the roles, so: + change_notes.append("Populated empty managers roster") + changed = 1 + self.managers = managers = [str(getSecurityManager().getUser())] + if util.users_for_local_role(self, managers, 'Manager'): + changed = 1 + if util.users_for_local_role(self, managers + supporters, 'Reviewer'): + changed = 1 + if changed and not no_reindex: + self._reindex_issues() + + return change_notes + + def _adjust_participation_mode(self, mode): + """Set role privileges according to participation mode.""" + + target_roles = ['Reviewer', 'Manager', 'Owner'] + + if mode == 'authenticated': + target_roles = target_roles + ['Authenticated'] + elif mode == 'anyone': + target_roles = target_roles + ['Authenticated', 'Anonymous'] + + # Adjust who can add followups: + self.manage_permission(AddCollectorIssueFollowup, + roles=target_roles, + acquire=1) + # Adjust who can add "attachments": + self.manage_permission(CMFCorePermissions.AddPortalContent, + roles=target_roles, + acquire=1) + + self.participation = mode + + security.declareProtected(ManageCollector, 'reinstate_catalog') + def reinstate_catalog(self, internal_only=1): + """Recreate and reload internal catalog, to accommodate drastic + changes.""" + try: + self._delObject(INTERNAL_CATALOG_ID) + except AttributeError: + pass + self._setup_internal_catalog() + self._reindex_issues(internal_only=internal_only) + + def _reindex_issues(self, internal_only=1): + """For, eg, allowedRolesAndUsers recompute after local_role changes. + + We also make sure that the AddCollectorIssueFollowup permission + acquires (old workflows controlled this). This isn't exactly the + right place, but it is an expedient one.""" + + for i in self.objectValues(spec='CMF Collector Issue'): + + # Ensure the issue acquires AddCollectorIssueFollowup + # and AddPortalContent permissions. + for m in i.ac_inherited_permissions(1): + if m[0] in [AddCollectorIssueFollowup, + CMFCorePermissions.AddPortalContent]: + perm = Permission.Permission(m[0], m[1], i) + roles = perm.getRoles() + if type(roles) == type(()): + perm.setRoles(list(roles)) + + i.reindexObject(internal_only=internal_only) + + security.declareProtected(ManageCollector, 'issue_states') + def issue_states(self): + """Return a sorted list of potential states for issues.""" + # We use a stub issue (which we create, the first time) in order to + # get the workflow states. Kinda yucky - would be nice if the + # workflow provided more direct introspection. + got = [] + if hasattr(self, 'stub'): + sample = self['stub'] + else: + sample = CollectorIssue('stub', self, invisible=1) + for wf in self.portal_workflow.getWorkflowsFor(sample): + got.extend([x.id for x in wf.states.values()]) + got.sort() + return got + + security.declareProtected(CMFCorePermissions.View, 'Subject') + def Subject(self): + return self.topics + + security.declareProtected(CMFCorePermissions.View, 'length') + def length(self): + """Use length protocol.""" + return self.__len__() + + def __len__(self): + """length() protocol method.""" + return len(self.objectIds()) - 1 + + def __repr__(self): + return ("<%s %s (%d issues) at 0x%s>" + % (self.__class__.__name__, `self.id`, len(self), + hex(id(self))[2:])) + +InitializeClass(Collector) + +catalog_factory_type_information = ( + {'id': 'Collector Catalog', + 'content_icon': 'collector_icon.gif', + 'meta_type': 'CMF Collector Catalog', + 'description': ('Internal catalog.'), + 'product': 'CMFCollector', + 'factory': None, + 'immediate_view': None},) + +class CollectorCatalog(CatalogTool): + + id = INTERNAL_CATALOG_ID + meta_type = 'CMF Collector Catalog' + portal_type = 'Collector Catalog' + + def enumerateIndexes(self): + standard = CatalogTool.enumerateIndexes(self) + custom = (('status', 'FieldIndex'), + ('topic', 'FieldIndex'), + ('classification', 'FieldIndex'), + ('importance', 'FieldIndex'), + ('security_related', 'FieldIndex'), + ('confidential', 'FieldIndex'), + ('resolution', 'TextIndex'), + ('submitter_id', 'FieldIndex'), + ('submitter_email', 'FieldIndex'), + ('version_info', 'TextIndex'), + ('assigned_to', 'KeywordIndex'), + ('upload_number', 'KeywordIndex') + ) + return standard + custom + + def enumerateColumns( self ): + """Return field names of data to be cached on query results.""" + standard = CatalogTool.enumerateColumns(self) + custom = ('id', + 'status', + 'submitter_id', + 'topic', + 'classification', + 'importance', + 'security_related', + 'confidential', + 'version_info', + 'assigned_to', + 'uploads', + 'action_number', + 'upload_number', + ) + custom = tuple([col for col in custom if col not in standard]) + return standard + custom + + +InitializeClass(CollectorCatalog) + + +# XXX Enable use of pdb.set_trace() in python scripts +#ModuleSecurityInfo('pdb').declarePublic('set_trace') + +def addCollector(self, id, title='', description='', abbrev='', + topics=None, classifications=None, importances=None, + managers=None, supporters=None, dispatching=None, + version_info_spiel=None, + REQUEST=None): + """ + Create a collector. + """ + it = Collector(id, title=title, description=description, abbrev=abbrev, + topics=topics, classifications=classifications, + managers=managers, supporters=supporters, + dispatching=dispatching, + version_info_spiel=version_info_spiel) + self._setObject(id, it) + it = self._getOb(id) + it._setPortalTypeName('Collector') + + it.manage_permission(ManageCollector, roles=['Manager', 'Owner'], + acquire=1) + it.manage_permission(EditCollectorIssue, + roles=['Reviewer'], + acquire=1) + it.manage_permission(AddCollectorIssueFollowup, + roles=['Reviewer', 'Manager', 'Owner'], + acquire=1) + it.manage_permission(CMFCorePermissions.AddPortalContent, + roles=['Reviewer', 'Manager', 'Owner'], + acquire=1) + it.manage_permission(CMFCorePermissions.AccessInactivePortalContent, + roles=['Anonymous', 'Reviewer', 'Manager', 'Owner'], + acquire=1) + it.manage_permission(CMFCorePermissions.AccessFuturePortalContent, + roles=['Anonymous', 'Reviewer', 'Manager', 'Owner'], + acquire=1) + if REQUEST is not None: + try: url=self.DestinationURL() + except: url=REQUEST['URL1'] + REQUEST.RESPONSE.redirect('%s/manage_main' % url) + return id diff --git a/CMFCollector/CollectorIssue.py b/CMFCollector/CollectorIssue.py new file mode 100644 index 0000000..ade6365 --- /dev/null +++ b/CMFCollector/CollectorIssue.py @@ -0,0 +1,807 @@ +############################################################################## +# +# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + +"""Implement the Collector Issue content type - a bundle containing the +collector transcript and various parts.""" + +import os, urllib, string, re +import smtplib + +from DateTime import DateTime +from Globals import InitializeClass +from AccessControl import ClassSecurityInfo, getSecurityManager +from Acquisition import aq_base + +import util # Collector utilities. + +from Products.CMFDefault.DublinCore import DefaultDublinCoreImpl +from Products.CMFCore.WorkflowCore import WorkflowAction +from Products.CMFCore.utils import getToolByName + +from Products.CMFDefault.SkinnedFolder import SkinnedFolder +from WebTextDocument import addWebTextDocument + +# Import permission names +from Products.CMFCore.CMFCorePermissions import View +from Products.CMFCore.CMFCorePermissions import ModifyPortalContent +from CollectorPermissions import * + +RULE = '_' * 40 + +UPLOAD_PREFIX = "Uploaded: " +uploadexp = re.compile('(%s)([^<,\n]*)([<,\n])' + % UPLOAD_PREFIX, re.MULTILINE) + +factory_type_information = ( + {'id': 'Collector Issue', + 'icon': 'collector_issue_icon.gif', + 'meta_type': 'CMF Collector Issue', + 'description': ('A Collector Issue represents a bug report or' + ' other support request.'), + 'product': 'CMFCollector', + 'factory': None, # So not included in 'New' add form + 'allowed_content_types': ('Collector Issue Transcript', + 'File', 'Image'), + 'immediate_view': 'collector_edit_form', + 'actions': ({'id': 'view', + 'name': 'View', + 'action': 'string:${object_url}/collector_issue_contents', + 'permissions': (ViewCollector,)}, + {'id': 'followup', + 'name': 'Followup', + 'action': + 'string:${object_url}/collector_issue_followup_form', + 'permissions': (AddCollectorIssueFollowup,)}, + {'id': 'edit', + 'name': 'Edit', + 'action': 'string:${object_url}/collector_issue_edit_form', + 'permissions': (EditCollectorIssue,)}, + {'id': 'browse', + 'name': 'Browse', + 'action': 'string:${object_url}/collector_issue_up', + 'permissions': (ViewCollector,)}, + {'id': 'addIssue', + 'name': 'New', + 'action': 'string:${object_url}/collector_issue_add_issue', + 'permissions': (ViewCollector,)}, + ), + }, + ) + +TRANSCRIPT_NAME = "ISSUE_TRANSCRIPT" + +class CollectorIssue(SkinnedFolder, DefaultDublinCoreImpl): + """An individual support request in the CMF Collector.""" + + meta_type = 'CMF Collector Issue' + effective_date = expiration_date = None + TRANSCRIPT_FORMAT = 'webtext' + + security = ClassSecurityInfo() + + action_number = 0 + + ACTIONS_ORDER = ['Accept', 'Assign', + 'Resolve', 'Reject', 'Defer', + 'Resign'] + + # Accumulated instance-data backwards-compatability values: + _collector_path = None + # XXX This security declaration doesn't seem to have an effect? + security.declareProtected(EditCollectorIssue, 'submitter_email') + submitter_email = None + submitter_name = None + invisible = 0 + version_info = '' + + def __init__(self, + id, container, + title='', description='', + submitter_id=None, submitter_name=None, + submitter_email=None, + kibitzers=None, + security_related=0, + topic=None, classification=None, importance=None, + resolution=None, + version_info=None, + creation_date=None, modification_date=None, + effective_date=None, expiration_date=None, + assignees=None, + file=None, fileid=None, filetype=None, + invisible=0): + """ """ + + self.invisible = invisible + SkinnedFolder.__init__(self, id, title) + self._set_collector_path(container) + + mbtool = getToolByName(container, 'portal_membership') + user = mbtool.getAuthenticatedMember() + if submitter_id is None: + submitter_id = str(user) + self.submitter_id = submitter_id + self.__of__(container)._set_submitter_specs(submitter_id, + submitter_name, + submitter_email) + + if kibitzers is None: + kibitzers = () + self.kibitzers = kibitzers + + self.topic = topic + self.classification = classification + self.security_related = (security_related and 1) or 0 + self.importance = importance + self.resolution = resolution + self.version_info = version_info + + self.portal_type = 'Collector Issue' + # 'contained' is for stuff that needs collector acquisition wrapping. + container._setObject(id, self) + contained = container._getOb(id) + contained._setPortalTypeName('Collector Issue') + DefaultDublinCoreImpl.__init__(contained, + title=title, description=description, + effective_date=effective_date, + expiration_date=expiration_date) + + if modification_date: + self._setModificationDate(DateTime(modification_date)) + + def _set_submitter_specs(self, submitter_id, + submitter_name, submitter_email): + """Given an id, set the name and email as warranted.""" + + mbrtool = getToolByName(self, 'portal_membership') + user = mbrtool.getMemberById(submitter_id) + changes = [] + if self.submitter_id != submitter_id: + if user is None: + if ((string.lower(submitter_id[:len('anonymous')]) + == 'anonymous') + or not submitter_id): + user = self.acl_users._nobody + submitter_id = str(user) + else: + raise ValueError, "User '%s' not found" % submitter_id + changes.append("Submitter id: '%s' => '%s'" % (self.submitter_id, + submitter_id)) + self.submitter_id = submitter_id + + if not submitter_name: + name = util.safeGetProperty(user, 'full_name', '') + if name: submitter_name = name + else: submitter_name = submitter_id + if self.submitter_name != submitter_name: + changes.append('submitter name') + self.submitter_name = submitter_name + email_pref = util.safeGetProperty(user, 'email', '') + if submitter_email and submitter_email == email_pref: + # A bit different than you'd expect: only stash the specified + # email if it's different than the member-preference. Otherwise, + # stash None, so the preference is tracked at send time. + submitter_email = None + if self.submitter_email != submitter_email: + changes.append("submitter email") + self.submitter_email = submitter_email + return changes + + def _set_collector_path(self, collector): + """Stash path to containing collector.""" + # For getting the internal catalog when being indexed - at which + # time we may not have an acquisition context... + self._collector_path = "/".join(collector.getPhysicalPath()) + + security.declareProtected(View, 'no_submitter_email') + def no_submitter_email(self): + """True if there's no way to get email address for the submitter.""" + if self.submitter_email: + return 0 + if self.submitter_id != str(self.acl_users._nobody): + member = self.portal_membership.getMemberById(self.submitter_id) + if member: + email_pref = util.safeGetProperty(member, 'email', '') + return not email_pref + return 1 + + security.declareProtected(View, 'CookedBody') + def CookedBody(self): + """Rendered content.""" + body = self.get_transcript().CookedBody() + return uploadexp.sub(r'\1 \2\3' + % self.absolute_url(), + body) + + + security.declareProtected(EditCollectorIssue, 'edit') + def edit(self, + title=None, + submitter_id=None, submitter_name=None, submitter_email=None, + security_related=None, + description=None, + topic=None, + classification=None, + importance=None, + version_info=None, + stealthy=None, + comment=None, + text=None): + """Update the explicitly passed fields.""" + + changes = [] + changed = self._changed + + transcript = self.get_transcript() + text = text.replace('\r', '') + changes += self._set_submitter_specs(submitter_id, + submitter_name, submitter_email) + if text is not None and text != transcript.text: + changes.append('edited transcript') + transcript.edit(text_format=self.TRANSCRIPT_FORMAT, text=text) + if changed('title', title): + changes.append('revised title') + self.title = title + if ((security_related is not None) + and ((not security_related) != (not self.security_related))): + changes.append('security_related %s' + % (security_related and 'set' or 'unset')) + self.security_related = (security_related and 1) or 0 + if changed('description', description): + changes.append('revised description') + self.description = description + if changed('topic', topic): + changes.append('topic (%s => %s)' % (self.topic, topic)) + self.topic = topic + if changed('importance', importance): + changes.append('importance (%s => %s)' + % (self.importance, importance)) + self.importance = importance + if changed('classification', classification): + changes.append('classification (%s => %s)' + % (self.classification, classification)) + self.classification = classification + if changed('version_info', version_info): + changes.append('revised version_info') + self.version_info = version_info + + if comment: + changes.append('new comment') + + if not changes: + return 'No changes.' + + self.action_number += 1 + + username = str(getSecurityManager().getUser()) + + if comment: + comment = "\n\n" + comment + else: + comment = '' + + if not stealthy: + transcript.edit(self.TRANSCRIPT_FORMAT, + self._entry_header('Edit', username) + + "\n\n" + + " Changes: " + ", ".join(changes) + + comment + + ((self.action_number > 1) and "\n" + RULE + "\n") + + transcript.EditableBody()) + else: + transcript.edit(self.TRANSCRIPT_FORMAT, + transcript.EditableBody()) + + self._notifyModified() + self.reindexObject() + self._send_update_notice('Edit', username) + + return ", ".join(changes) + + def _changed(self, field_name, value): + """True if value is not None and different than self.field_name.""" + return ((value is not None) and + (getattr(self, field_name, None) != value)) + + security.declareProtected(View, 'get_transcript') + def get_transcript(self): + return self._getOb(TRANSCRIPT_NAME) + + security.declareProtected(AddCollectorIssueFollowup, 'do_action') + def do_action(self, action, comment, + assignees=None, file=None, fileid=None, filetype=None): + """Execute an action, adding comment to the transcript.""" + + action_number = self.action_number = self.action_number + 1 + username = str(getSecurityManager().getUser()) + + orig_supporters = self.assigned_to() + # Strip off '_confidential' from status, if any. + orig_status = string.split(self.status(), '_')[0] + + if string.lower(action) != 'comment': + # Confirm against portal actions tool: + if action != 'request' and action not in self._valid_actions(): + raise 'Unauthorized', "Invalid action '%s'" % action + + self.portal_workflow.doActionFor(self, + action, + comment=comment, + username=username, + assignees=assignees) + + new_status = string.split(self.status(), '_')[0] + + if string.lower(action) == 'request': + self._create_transcript(comment) + transcript = self.get_transcript() + + comment_header = [self._entry_header(action, username)] + + if (orig_status + and (orig_status != 'New') + and (new_status != orig_status)): + comment_header.append(" Status: %s => %s" + % (orig_status, new_status)) + + additions, removals = self._supporters_diff(orig_supporters) + if additions or removals: + if additions: + reroster = " Supporters added: %s" % ", ".join(additions) + if removals: + reroster += "; removed: %s" % ", ".join(removals) + elif removals: + reroster = " Supporters removed: %s" % ", ".join(removals) + comment_header.append(reroster) + + (uploadmsg, fileid) = self._process_file(file, fileid, + filetype, comment) + if uploadmsg: + comment_header.append("\n" + uploadmsg) + + comment_header_str = "\n\n".join(comment_header) + "\n\n" + + transcript.edit(self.TRANSCRIPT_FORMAT, + comment_header_str + + comment + + ((action_number > 1) + and ("\n" + RULE + "\n") + or '') + + transcript.EditableBody()) + + self._notifyModified() + self.reindexObject() + got = self._send_update_notice(action, username, + orig_status, additions, removals, + file=file, fileid=fileid) + + return got + + def _supporters_diff(self, orig_supporters): + """Indicate supporter roster changes, relative to orig_supporters. + + Return (list-of-added-supporters, list-of-removed-supporters).""" + plus, minus = list(self.assigned_to()), [] + for supporter in orig_supporters: + if supporter in plus: plus.remove(supporter) + else: minus.append(supporter) + return (plus, minus) + + def _send_update_notice(self, action, actor, + orig_status=None, additions=None, removals=None, + file=None, fileid=None, lower=string.lower): + """Send email notification about issue event to relevant parties.""" + + action = string.capitalize(string.split(action, '_')[0]) + new_status = self.status() + + recipients = [] + + # Who to notify: + # + # We want to noodge only assigned supporters while it's being worked + # on, ie assigned supporters are corresponding about it. Otherwise, + # either the dispatchers (managers) and assigned supporters get + # notices, or everyone gets notices, depending on the collector's + # .dispatching setting: + # + # - Requester always + # - Person taking action always + # - Supporters assigned to the issue always + # - Managers or managers + all supporters (according to dispatching): + # - When in any state besides accepted (or accepted_confidential) + # - When being accepted (or accepted_confidential) + # - When is accepted (or accepted_confidential) and moving to + # another state + # - Any supporters being removed from the issue by the current action + # - In addition, any destinations for the resulting state registered + # in state_email are included. + # + # We're liberal about allowing duplicates in the collection phase - + # all duplicate addresses will be filtered out before actual send. + + candidates = [self.submitter_id, actor] + list(self.assigned_to()) + continuing_accepted = (orig_status + and + lower(orig_status) in ['accepted', + 'accepted_confidential'] + and + lower(new_status) in ['accepted', + 'accepted_confidential']) + if orig_status and not continuing_accepted: + candidates.extend(self.aq_parent.managers) + if not self.aq_parent.dispatching: + candidates.extend(self.aq_parent.supporters) + else: + candidates.extend(self.assigned_to()) + + if removals: + # Notify supporters being removed from the issue (confirms + # their action, if they're resigning, and informs them if + # manager is deassigning them). + candidates.extend(removals) + + didids = []; gotemails = [] + for userid in candidates: + if userid in didids: + # Cull duplicates. + continue + didids.append(userid) + name, email = util.get_email_fullname(self, userid) + if (userid == self.submitter_id) and self.submitter_email: + if self.submitter_email == email: + # Explicit one same as user preference - clear the + # explicit, so issue notification destination will track + # changes to the preference. + self.submitter_email = None + else: + # Explicitly specified email overrides user pref email. + email = self.submitter_email + if self.submitter_name: + name = self.submitter_name + if email: + if email in gotemails: + continue + gotemails.append(email) + recipients.append((name, email)) + + if (self.state_email.has_key(new_status) + and self.state_email[new_status]): + for addr in re.split(", *| +", self.state_email[new_status]): + se = ("_%s_ recipient" % new_status, addr) + candidates.append(se) # For recipients-debug + if addr not in recipients: + recipients.append(se) + + if recipients: + to = ", ".join(['"%s" <%s>' % (name, email) + for name, email in recipients]) + title = self.aq_parent.title[:50] + short_title = " ".join(title[:40].split(" ")[:-1]) or title + if short_title != title[:40]: + short_title = short_title + " ..." + sender = ('"Collector: %s" <%s>' + % (short_title, self.aq_parent.email)) + + if '.' in title or ',' in title: + title = '"%s"' % title + + if self.abbrev: + subject = "[%s]" % self.abbrev + else: subject = "[Collector]" + subject = ('%s %s/%2d %s "%s"' + % (subject, self.id, self.action_number, + string.capitalize(action), self.title)) + + body = self.get_transcript().text + body = uploadexp.sub(r'\1 "\2"\n - %s/\2/view' + % self.absolute_url(), + body) + cin = self.collector_issue_notice + message = cin(sender=sender, + recipients=to, + subject=subject, + issue_id=self.id, + action=action, + actor=actor, + number=self.action_number, + security_related=self.security_related, + confidential=self.confidential(), + title=self.title, + submitter_name=self.submitter_name, + status=new_status, + klass=self.classification, + topic=self.topic, + importance=self.importance, + issue_url=self.absolute_url(), + body=body, + candidates=candidates) + mh = self.MailHost + try: + mh.send(message) + except: + import sys + return "Email notice error: '%s'" % str(sys.exc_info()[1]) + + def _process_file(self, file, fileid, filetype, comment): + """Upload file to issue if it is substantial (has a name). + + Return a message describing the file, for transcript inclusion.""" + if file and file.filename: + if not fileid: + fileid = string.split(string.split(file.filename, '/')[-1], + '\\')[-1] + upload = self._add_artifact(fileid, filetype, comment, file) + uploadmsg = "%s%s" % (UPLOAD_PREFIX, fileid) + return (uploadmsg, fileid) + else: + return ('', '') + + def _add_artifact(self, id, type, description, file): + """Add new artifact, and return object.""" + self.invokeFactory(type, id) + it = self._getOb(id) + # Acquire view and access permissions from container + it.manage_permission('View', acquire=1) + it.manage_permission('Access contents information', acquire=1) + it.description = description + it.manage_upload(file) + return it + + def upload_number(self): + """ """ + return len(self) + + security.declareProtected(View, 'assigned_to') + def assigned_to(self): + """Return the current supporters list, according to workflow.""" + wftool = getToolByName(self, 'portal_workflow') + return wftool.getInfoFor(self, 'assigned_to', ()) or () + + security.declareProtected(View, 'is_assigned') + def is_assigned(self): + """True iff the current user is among .assigned_to().""" + username = str(getSecurityManager().getUser()) + return username in (self.assigned_to() or ()) + + security.declareProtected(View, 'status') + def status(self): + """Return the current status according to workflow.""" + wftool = getToolByName(self, 'portal_workflow') + return wftool.getInfoFor(self, 'state', 'Pending') + + security.declareProtected(View, 'review_state') + review_state = status + + security.declareProtected(View, 'confidential') + def confidential(self): + """True if workflow has the issue marked confidential. + + (Security_related issues start confidential, and are made + unconfidential on any completion.)""" + wftool = getToolByName(self, 'portal_workflow') + return wftool.getInfoFor(self, 'confidential', 0) + + def _create_transcript(self, description): + """Create events and comments transcript, with initial entry.""" + + user = getSecurityManager().getUser() + addWebTextDocument(self, TRANSCRIPT_NAME, description=description) + it = self.get_transcript() + it._setPortalTypeName('Collector Issue Transcript') + it.title = self.title + + def _entry_header(self, type, user, date=None, prefix="= ", suffix=""): + """Return text for the header of a new transcript entry.""" + # Ideally this would be a skin method (probly python script), but i + # don't know how to call it from the product, sigh. + t = string.capitalize(type) + if self.action_number: + lead = t + " - Entry #" + str(self.action_number) + else: + lead = t + if date is None: + date = DateTime() + if not isinstance(date, DateTime): + date = DateTime(date) + + return ("%s%s by %s on %s%s" % + (prefix, lead, str(user), date.aCommon(), suffix)) + + security.declareProtected(View, 'cited_text') + def cited_text(self): + """Quote text for use in literal citations.""" + return util.cited_text(self.get_transcript().text) + + def _valid_actions(self): + """Return actions valid according to workflow/application logic.""" + + pa = getToolByName(self, 'portal_actions', None) + allactions = pa.listFilteredActionsFor(self) + return [entry['name'] + for entry in allactions.get('issue_workflow', [])] + + security.declareProtected(View, 'valid_actions_pairs') + def valid_actions_pairs(self): + """Return ordered (prettyname, rawname) valid workflow actions.""" + # XXX I would do this with a python script method, but i'm hitting + # inability to assign to indexes (eg, 'list[x] = 1' or + # 'dict[x] = 1'), so having to resort to python code, sigh. + + order=self.ACTIONS_ORDER + got = [()] * len(order) + remainder = [] + + for raw in self._valid_actions(): + pretty = raw.split('_')[0].capitalize() + if pretty in order: + got[order.index(pretty)] = (raw, pretty) + else: + remainder.append((raw, pretty)) + return filter(None, got) + remainder + + + ################################################# + # Dublin Core and search provisions + + # The transcript indexes itself, we just need to index the salient + # attribute-style issue data/metadata... + + security.declareProtected(ModifyPortalContent, 'indexObject') + def indexObject(self): + if self.invisible: + return + for i in (self._get_internal_catalog(), + getToolByName(self, 'portal_catalog', None)): + if i is not None: + i.indexObject(self) + + security.declareProtected(ModifyPortalContent, 'unindexObject') + def unindexObject(self): + for i in (self._get_internal_catalog(), + getToolByName(self, 'portal_catalog', None)): + if i is not None: + i.unindexObject(self) + + security.declareProtected(ModifyPortalContent, 'reindexObject') + def reindexObject(self, idxs=[], internal_only=0): + if self.invisible: + return + catalogs = [self._get_internal_catalog()] + if not internal_only: + catalogs.append(getToolByName(self, 'portal_catalog', None)) + for i in catalogs: + if i is not None: + i.reindexObject(self) + + def _get_internal_catalog(self): + if self._collector_path is None: + # Last ditch effort - this will only work when we're being called + # from the collector's .reindex_issues() method. + self._set_collector_path(self.aq_parent) + container = self.restrictedTraverse(self._collector_path) + return container.get_internal_catalog() + + def manage_afterAdd(self, item, container): + """Add self to the workflow and catalog.""" + # Are we being added (or moved)? + if aq_base(container) is not aq_base(self): + self._set_collector_path(self.aq_parent) + wf = getToolByName(self, 'portal_workflow', None) + if wf is not None: + wf.notifyCreated(self) + self.indexObject() + + def manage_beforeDelete(self, item, container): + """Remove self from the catalog.""" + # Are we going away? + if aq_base(container) is not aq_base(self): + self.unindexObject() + # Now let our "aspects" know we are going away. + for it, subitem in self.objectItems(): + si_m_bD = getattr(subitem, 'manage_beforeDelete', None) + if si_m_bD is not None: + si_m_bD(item, container) + + def SearchableText(self): + """Consolidate all text and structured fields for catalog search.""" + # Make this a composite of the text and structured fields. + return (self.title + ' ' + + self.description + ' ' + + self.topic + ' ' + + self.classification + ' ' + + self.importance + ' ' + + self.status() + ' ' + + (self.resolution or '') + ' ' + + self.version_info + ' ' + + ((self.security_related and 'security_related') or '')) + + def Subject(self): + """The structured attributes.""" + return (self.topic, + self.classification, + self.importance, + ) + + def _setModificationDate(self, date): + # Recent versions of CMF (1.3, maybe earlier) DefaultDublinCoreImpl + # have .setModificationDate(), older use bobobase_modification_time, + # which i don't know how to set, if can should be done - so trying to + # set an initial collector issue mod time is a noop when using with + # pre-1.3 CMF. + if not isinstance(date, DateTime): + date = DateTime(date) + if hasattr(self, 'setModificationDate'): + self.setModificationDate(date) + else: + pass # XXX Sigh - not with + + def _notifyModified(self): + # Recent versions of CMF (1.3, maybe earlier) DefaultDublinCoreImpl + # have .notifyCreated(), older use bobobase_modification_time, + # which automatically tracks. + if hasattr(self, 'notifyModified'): + self.notifyModified() + + def __len__(self): + """Number of uploaded artifacts (ie, excluding transcript).""" + return len(self.objectIds()) - 1 + + def __repr__(self): + return ("<%s %s \"%s\" at 0x%s>" + % (self.__class__.__name__, + self.id, self.title, + hex(id(self))[2:])) + +InitializeClass(CollectorIssue) + + +def addCollectorIssue(self, + id, + title='', + description='', + submitter_id=None, + submitter_name=None, + submitter_email=None, + kibitzers=None, + topic=None, + classification=None, + security_related=0, + importance=None, + version_info=None, + assignees=None, + file=None, fileid=None, filetype=None, + REQUEST=None): + """Create a new issue in the collector. + + We return a string indicating any errors, or None if there weren't any.""" + + it = CollectorIssue(id=id, + container=self, + title=title, + description=description, + submitter_id=submitter_id, + submitter_name=submitter_name, + submitter_email=submitter_email, + kibitzers=kibitzers, + topic=topic, + classification=classification, + security_related=security_related, + importance=importance, + version_info=version_info, + assignees=assignees, + file=file, fileid=fileid, filetype=filetype) + it = self._getOb(it.id) + got = it.do_action('request', description, assignees, + file, fileid, filetype) + + return got diff --git a/CMFCollector/CollectorPermissions.py b/CMFCollector/CollectorPermissions.py new file mode 100644 index 0000000..307316e --- /dev/null +++ b/CMFCollector/CollectorPermissions.py @@ -0,0 +1,25 @@ +from Products.CMFCore import CMFCorePermissions +from Products.CMFCore.CMFCorePermissions import setDefaultRoles + +# Gathering Event Related Permissions into one place +ViewCollector = CMFCorePermissions.View +AddCollector = 'Add portal collector' +ManageCollector = 'Add portal collector' +AddCollectorIssue = 'Add collector issue' +AddCollectorIssueFollowup = 'Add collector issue comment' +EditCollectorIssue = 'Edit collector issue' +SupportIssue = 'Support collector issue' + +# Set up default roles for permissions +setDefaultRoles(AddCollector, + ('Manager', 'Owner')) +setDefaultRoles(ManageCollector, + ('Manager', 'Owner')) +setDefaultRoles(AddCollectorIssue, + ('Anonymous', 'Manager', 'Reviewer', 'Owner')) +setDefaultRoles(AddCollectorIssueFollowup, + ('Manager', 'Reviewer', 'Owner')) +setDefaultRoles(EditCollectorIssue, + ('Manager', 'Reviewer')) +setDefaultRoles(SupportIssue, + ('Manager', 'Reviewer')) diff --git a/CMFCollector/CollectorSubset.py b/CMFCollector/CollectorSubset.py new file mode 100644 index 0000000..9bc601b --- /dev/null +++ b/CMFCollector/CollectorSubset.py @@ -0,0 +1,161 @@ +from Products.CMFCore.PortalContent import PortalContent +from Products.CMFDefault.DublinCore import DefaultDublinCoreImpl +from Products.CMFCore.CMFCorePermissions import View, ModifyPortalContent + +from Acquisition import aq_parent +from AccessControl import ClassSecurityInfo +from Globals import InitializeClass + +from urllib import urlencode + +factory_type_information = \ +( { 'id' : 'Collector Subset' + , 'icon' : 'collector_icon.gif' + , 'meta_type' : 'CMF Collector Issue' + , 'description' : ( 'A Collector Subset represents a view on a subset ' + + 'of the issues in a collector.' + ) + , 'product' : 'CMFCollector' + , 'factory' : 'addCollectorSubset' + , 'immediate_view': 'subset_edit_form' + , 'actions' : + ( { 'id' : 'view' + , 'name' : 'View' + , 'action' : 'string:${object_url}/subset_view' + , 'permissions' : ( View, ) + } + , { 'id' : 'edit' + , 'name' : 'Edit' + , 'action' : 'string:${object_url}/subset_edit_form' + , 'permissions' : ( ModifyPortalContent, ) + } + , { 'id' : 'metadata' + , 'name' : 'Metadata' + , 'action' : 'string:${object_url}/metadata_edit_form' + , 'permissions' : ( ModifyPortalContent, ) + } + ) + } +, +) + +PARAMETER_TYPES = ( 'review_state' + , 'submitter_id' + , 'supporters:list' + , 'topics:list' + , 'classifications:list' + , 'importances:list' + ) + +class CollectorSubset( PortalContent, DefaultDublinCoreImpl ): + """ + Represent a "persistent" query against a collector's issues. + """ + meta_type = 'Collector Subset' + + _parameters = None + + security = ClassSecurityInfo() + + security.declareObjectProtected( View ) + + def __init__( self, id ): + self._setId( id ) + + index_html = None # Self-publishing + + security.declarePrivate( '_buildQueryString' ) + def _buildQueryString( self ): + + parameters = {} + + for k, v in self.listParameters(): + if v not in ( None, '' ): + parameters[ k ] = v + + return urlencode( parameters ) + + security.declarePrivate( '_getParameterDict' ) + def _getParameterDict( self ): + + return self._parameters or {} + + security.declareProtected( View, 'listParameterTypes' ) + def listParameterTypes( self ): + """ + Return a list of the allowed query parameter types for defining + the subset. + """ + return PARAMETER_TYPES + + def __call__( self, *args, **kw ): + """ + Redirect to the parent collector's main view, but with + query parameters set. + """ + self.REQUEST['RESPONSE'].redirect( self.getCollectorURL() ) + + security.declareProtected( View, 'getCollectorURL' ) + def getCollectorURL( self ): + """ + Return the URL into our collector, with qualifying query string + matching our parameters. + """ + parent = aq_parent( self ) + return ( '%s/collector_contents?%s' + % ( parent.absolute_url(), self._buildQueryString() ) + ) + + security.declareProtected( View, 'getParameterValue' ) + def getParameterValue( self, key ): + """ + Return the value for the given key. + """ + if key not in PARAMETER_TYPES: + raise ValueError, 'Invalid key: %s' % key + + return self._getParameterDict().get( key, '' ) + + security.declareProtected( View, 'listParameters' ) + def listParameters( self ): + """ + Return a list of the query parameters which define this subset, + as a sequence of (key,value) tuples. + """ + return self._getParameterDict().items() + + security.declareProtected( ModifyPortalContent, 'setParameter' ) + def setParameter( self, key, value ): + """ + Add / update a single parameter used to define this subset. + + o 'key' must be in PARAMETER_TYPES + + o 'value' should be a string + """ + if key not in PARAMETER_TYPES: + raise ValueError, 'Invalid key: %s' % key + + parms = self._getParameterDict() + parms[ key ] = value + self._parameters = parms + + security.declareProtected( ModifyPortalContent, 'clearParameters' ) + def clearParameters( self ): + """ + Erase all parameters. + """ + try: + del self._parameters + except KeyError: + pass + + +def addCollectorSubset( self, id, REQUEST=None ): + """ + Add one. + """ + self._setObject( id, CollectorSubset( id ) ) + + if REQUEST is not None: + REQUEST[ 'RESPONSE' ].redirect( '%s/manage_main?Subset+added.' ) diff --git a/CMFCollector/Extensions/Install.py b/CMFCollector/Extensions/Install.py new file mode 100644 index 0000000..18b312c --- /dev/null +++ b/CMFCollector/Extensions/Install.py @@ -0,0 +1,101 @@ +############################################################################## +# +# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + +"""This file is an installation script (External method) for the CMF Collector. + +To use, add an external method to the root of the CMF Site that you want CMF +Event registered in with the configuration: + + id: install_collector + title (optional): Install Collector + module name: CMFCollector.InstallCollector + function name: install_collector + +Then go to the management screen for the newly added external method +and click the 'Try it' tab. The install function will execute and give +information about the steps it took to register and install the +CMF Events into the CMF Site instance. +""" +from Products.CMFCore.TypesTool import ContentFactoryMetadata +from Products.CMFCore.DirectoryView import addDirectoryViews +from Products.CMFCore.utils import getToolByName +from Products import CMFCollector +from cStringIO import StringIO +from Acquisition import aq_base +import string + +INVISIBLE_TYPES = ('Collector Issue', 'Collector Issue Transcript') + +def install(self): + " Register the Collector with portal_types and friends " + out = StringIO() + types_tool = getToolByName(self, 'portal_types') + skins_tool = getToolByName(self, 'portal_skins') + metadata_tool = getToolByName(self, 'portal_metadata') + catalog = getToolByName(self, 'portal_catalog') + + # Borrowed from CMFDefault.Portal.PortalGenerator.setupTypes() + # We loop through anything defined in the factory type information + # and configure it in the types tool if it doesn't already exist + for t in CMFCollector.factory_type_information: + if t['id'] not in types_tool.objectIds(): + cfm = apply(ContentFactoryMetadata, (), t) + types_tool._setObject(t['id'], cfm) + out.write('Registered %s with the types tool\n' % t['id']) + else: + out.write('Skipping "%s" - already in types tool\n' % t['id']) + + # Setup the skins + # This is borrowed from CMFDefault/scripts/addImagesToSkinPaths.pys + if 'collector' not in skins_tool.objectIds(): + # We need to add Filesystem Directory Views for any directories + # in our skins/ directory. These directories should already be + # configured. + addDirectoryViews(skins_tool, 'skins', CMFCollector.collector_globals) + out.write("Added collector skin directory view to portal_skins\n") + + # Now we need to go through the skin configurations and insert + # 'collector' into the configurations. Preferably, this should be + # right before where 'content' is placed. Otherwise, we append + # it to the end. + skins = skins_tool.getSkinSelections() + for skin in skins: + path = skins_tool.getSkinPath(skin) + path = map(string.strip, string.split(path,',')) + if 'collector_plone' not in path: + try: path.insert(path.index('content'), 'collector_plone') + except ValueError: + path.append('collector_plone') + +# path = string.join(path, ', ') + # addSkinSelection will replace exissting skins as well. + skins_tool.addSkinSelection(skin, string.join(path, ', ')) + out.write("Added 'collector_plone' to %s skin\n" % skin) + else: + out.write("Skipping %s skin, 'collector_plone' is already set up\n" % ( + skin)) + if 'collector' not in path: + try: path.insert(path.index('content'), 'collector') + except ValueError: + path.append('collector') + + path = string.join(path, ', ') + # addSkinSelection will replace exissting skins as well. + skins_tool.addSkinSelection(skin, path) + out.write("Added 'collector' to %s skin\n" % skin) + else: + out.write("Skipping %s skin, 'collector' is already set up\n" % ( + skin)) + + return out.getvalue() + diff --git a/CMFCollector/Extensions/__init__.py b/CMFCollector/Extensions/__init__.py new file mode 100644 index 0000000..1ac008a --- /dev/null +++ b/CMFCollector/Extensions/__init__.py @@ -0,0 +1,2 @@ +# this file is here to make Install.py importable. +# we need to make it non-zero size to make winzip cooperate diff --git a/CMFCollector/Extensions/webtext_migration.py b/CMFCollector/Extensions/webtext_migration.py new file mode 100644 index 0000000..fb86704 --- /dev/null +++ b/CMFCollector/Extensions/webtext_migration.py @@ -0,0 +1,81 @@ +"""Convert collector issue instances transcripts to WebTextDocument. + +This is only necessary if you used a pre-1.0 version of the collector. If you +did, create an external method in your portal root: + + o id: collector_webtext_migration + + o title (optional): Upgrade collector issues (temporary) + + o module name: CMFCollector.webtext_migration.py + + o function name: collector_webtext_migration + +For each collector, visit the visit the URL constructed of the URL for the +collector plus '/collector_webtext_migration'. This will run the method on +the collector, producing a (sparse) page reporting the changes, or that no +changes were necessary. + +The process may take a while, if your site catalogs a lot of objects - the +converted issues are (necessarily) reindexed, internally and in the site +catalog. + +You can delete the external method once you've upgraded your preexisting +issues - it won't be needed after that.""" + +MIGRATE_ATTRIBUTES = ['effective_date', + 'expiration_date', + '_isDiscussable', + '_stx_level', # even though we don't use it + '_last_safety_belt_editor', + '_last_safety_belt', + '_safety_belt', + ] + +from Products.CMFCollector.WebTextDocument import WebTextDocument +from Products.CMFCollector.CollectorIssue import RULE +import re + +tidypre = re.compile("\n\n").sub +tidyleadspace = re.compile("\n ([^ ])").sub + +def collector_webtext_migration(self): + """Migrate old CMF "Document" based transcripts to "WebTextDocument".""" + total_changed = 0 + issues = self.objectValues(spec="CMF Collector Issue") + for issue in issues: + transcript = issue.get_transcript() + was_p_mtime = transcript._p_mtime + was_creation_date = transcript.creation_date + changed = 0 + if transcript.meta_type != "WebText Document": + changed = 1 + webtext = WebTextDocument(transcript.id, + title=transcript.title, + description=transcript.description, + text=transcript.text) + for attr in MIGRATE_ATTRIBUTES: + if hasattr(transcript, attr): + setattr(webtext, attr, getattr(transcript, attr)) + issue._delObject(transcript.id) + issue._setObject(webtext.id, webtext) + transcript = getattr(issue, webtext.id) + if changed or transcript.text_format != 'webtext': + total_changed += 1 + transcript.text_format = 'webtext' + transcript.cooked_text = '' + text = tidypre('\n', transcript.text) + text = tidyleadspace('\n\\1', transcript.text) + text = text.replace('\n
\n', '\n' + RULE + '\n') + + transcript.text = text # Ditch garbage + transcript.edit('webtext', text) # Cook the text. + transcript._p_mtime = was_p_mtime + transcript.creation_date = was_creation_date + transcript.meta_type = "Collector Issue Transcript" + if total_changed: + self.reinstate_catalog() + return ("Converted %d of %d issues, and reinstated catalog" + % (total_changed, len(issues))) + else: + return ("No changes, all issues are current.") diff --git a/CMFCollector/INSTALL.txt b/CMFCollector/INSTALL.txt new file mode 100644 index 0000000..f51ae14 --- /dev/null +++ b/CMFCollector/INSTALL.txt @@ -0,0 +1,75 @@ +Installing CMFCollector + + The CMFCollector is an issue collector for Zope. + + Prerequisites: + + - Zope, 2.4 or better (Python 2.x) and the following addons. + + - The CMF + + Version 1.2 or better. + + - Zope Page templates + + "ZPT", http://www.zope.org/Wikis/DevSite/Projects/ZPT + ZPT is part of the Zope distribution as of version 2.5 + + - the Skins tool in the CMF must be set up to use ZPT views: + you must have at least the 'collector' and 'zpt_generic' + layers included on the skin you're using. + + The installation script will add the 'collector' layer + to any existing skins for you. + + To install CMFCollector: + + - Uncompress the CMFCollector product into your zope/Products + directory or link it there, e.g.:: + + ln -s /path/to/installation /path/to/zope/Products + + - In the root of your CMFSite installation (within the ZMI), add an + external method to the root of the CMF Site, with the following + configuration values: + + o id: install_collector + + o title (optional): Install Collector Content Types + + o module name: CMFCollector.Install + + o function name: install + + Go to the management screen for the newly added external method and + click the 'Test' tab. + + The install function will execute and give information about the + steps it took to register and install the CMF Events into the CMF + Site instance. + + - Install the workflow: + + 1. Put collector_issue_workflow.zexp in your site's import/ + directory. + + 2. Within the portal's portal_workflow 'Contents' tab: + + o hit the 'Import/Export' button + + o fill in Import File Name: collector_issue_workflow.zexp + + o hit 'import' + + 3. Within the portal_workflow 'Workflows' tab, fill in: + + Collector Issue: collector_issue_workflow + + ... and hit the "Change" button. + + Then the workflow will be associated with any new issues you create. + + - Add a Collector to your site: + + Go to your site's interface, and add a Collector as you would any + other piece of content. \ No newline at end of file diff --git a/CMFCollector/KNOWN_PROBLEMS.txt b/CMFCollector/KNOWN_PROBLEMS.txt new file mode 100644 index 0000000..21ea423 --- /dev/null +++ b/CMFCollector/KNOWN_PROBLEMS.txt @@ -0,0 +1,50 @@ +CMFCollector Known Problems + + Nov 7, 2001 - Special actions when importing a collector -- + You can move a collector across sites, and within a site, using + export/import - but you have to take a couple of special actions + to make this work. + + 1. You must _disable_ the issue workflow connection during import of + a collector, in order to avoid spurious workflow transitions. + + Probably the simplest way to disable the workflow connection is + to go to the portal_workflow tool's "Workflows" tab in the Zope + Management Interface and change the setting for "Collector + Issue" to be empty. Then do the import, and make sure to + reestablish the Collector Issue Workflows setting to + "collector_issue_workflow". + + 2. Now you have to reinstate the catalog settings for the imported + collector. + + You do this in the collector's "configure" page, which is + available in the collector's "browse" view actions box (if you + own the collector or otherwise have manage privilege). At the + "Reinstate catalog" activity at the bottom of the page, check + "Internal and Site-wide" and submit. + + The state of all the imported collector issues should now be + properly resurrected and indexed in the site and the collector's + internal catalog. + + Oct 27, 2001 - Catalog search "active" content culling disabled -- + The CMF catalog search is supposed to automatically cull out items + with expired expiration_date and/or unreached effective_date, + unless the visitor has AccessFuturePortalContent and/or + AccessInactivePortalContent permissions. This culling is not + working correctly for the collector's internal catalog, at least + in some versions, culling out *all* results. + + This looks like a CMF and/or ZCatalog bug, and i haven't been able + to track where the proper behavior is supposed to be implemented. + Hence, i've punted on this, and am setting collectors to grant + Anonymous AccessInactivePortalContent and + AccessFuturePortalContent (in Collector.py addCollectorIssue()), + meaning that the active-status culling will not happen - even when + you want it to - within the collector. I'll be revisiting this + workaround when i know more about how to solve the actual problem. + + Oct, 2001 - Must add collectors via the CMF (folder-view "New") interface -- + You cannot add collector instances via the Zope management + interface. This may or may not be a difficult thing to solve. diff --git a/CMFCollector/README.txt b/CMFCollector/README.txt new file mode 100644 index 0000000..47c2403 --- /dev/null +++ b/CMFCollector/README.txt @@ -0,0 +1,8 @@ +CMFCollector README + + The CMFCollector is starting out as a rudimentary issue tracker, to + replace the equally rudimentary, ancient collector we've been using + on zope.org for a long time. It is being implemented as CMF content + to enable evolution to a more comprehensive solution with time. + + See INSTALL.txt for instructions about installing in your CMF site. diff --git a/CMFCollector/RELEASE_NOTES.txt b/CMFCollector/RELEASE_NOTES.txt new file mode 100644 index 0000000..411cb77 --- /dev/null +++ b/CMFCollector/RELEASE_NOTES.txt @@ -0,0 +1,121 @@ +CMF Collector Update Notes + + This file is for notes about crucial considerations for updating + preexisting collectors to the current release. You should always + check here when doing updates for instructions about accommodating + changes, when necessary. + + The file is ordered most recent items first, and the items refer to + the collector version as indicated by the VERSION.text file in the + same directory. + + See KNOWN_PROBLEMS.txt for pending bugs and problems to beware. + + Version 0.9b, Nov 7, 2002 + + Fixed bug where non-manager (supporters and non-staff) could not + upload files ("attachments") to issues unless they owned the + issues. You can get the new, proper permission settings on + existing collectors by changing your collector's "participation + mode" on the configuration page. Toggle it away and then back to + the current setting to preserve the current setting... + + The collector now works with CMF 1.3 and prior versions (tested to + my knowledge on CMF 1.3 and 1.2, at least). + + Tres implemented CollectorSubset, which encapsulates persistent + queries you can add to a collector and refer to by URL traversal. + + There's now a batch_size parameter, and other handy niggles. + + Version 0.9b, Feb 21, 2002 + + Enabled alternate collector policies about who can followup with + comments on existing issues. Now the collector configurer can + enable comments by only staff and issue participants (the prior + and default situation), by any authenticated user, or by anyone + including authenticated users. + + To get this new functionality, you'll have to do two things + + - Remove the old collector_issue_workflow from the site's workflow + contents and import the new collector_issue_workflow.zexp + + - Run the configuration "Reinstate catalog" action - the + "Internal only" option will be sufficient - so preexisting + issues will be properly adjusted. + + Version 0.9b, Nov 7, 2001 + + - Packaged transcript webtext in a Document derivative + ("WebTextDocument"), to take advantage of the CookedBody + caching. + + Pre-existing issues using the old Document transcript will lose + proper formatting. To fix that, migrate them to the new document + type using an external method, Extensions/webtext_migration.py. + See the notes at the top of that file for migration instructions. + + Version 0.9b, Nov 1, 2001 + + - Accommodation for recent (two days ago) DCWorkflow changes - + backwards compatable with prior DCWorkflow version, but + necessary for operation with new (DCWorkflow.py 1.12 and later). + + See the checkin notice for details: + http://cvs.zope.org/CMF/CMFCollector/collector_issue_workflow.zexp#rev1.10 + + Version 0.9b, Oct 28, 2001 + + I believe we're now feature-complete for v 0.9, hence the beta + designation and only bug fixes 'til 0.9 full release. + + - Minor changes to the workflow - may help with issues preexisting + 0.9a, if you encounter problems. + + Version 0.9a, Oct 25, 2001 + + - This version requires two installation changes. + + - The first is simple - replace (or adjust) the workflow. You can + either: + + - install the new collector issue workflow, by first + deleting the existing one (in the portal_workflow tool + 'contents' tab) and installing the new one, according to the + instructions in INSTALL.txt, or + + - adjust the existing one, by adding the 'request' transition to + the 'Pending' and 'Pending_confidential' states. (Visit the + collector_issue_workflow within the portal_workflow tool, go + to states, enabling the 'request' transition in each.) + + - Until the collector owner does the second, noone will be able to + browse your collector. The owners have to go directly to the + configure form for their collectors and pick an option that + instates the new, collector-internal catalog and recatalogs all + the issues. + + To do so, after putting the new Product code in place (and + restarting your site, if you don't have auto-refresh going) + collector owners should go to the URL produced by appending + "/collector_edit_form" to the URL of the collectors - eg:: + + http://new.zope.org/Members/klm/ColDev/collector_edit_form + + for the example collector at http://new.zope.org/Members/klm/ColDev. + + Check the box by "Reinstate internal catalog?", to the right of + the submit and reset buttons at the bottom of the form, and then + submit them. This will establish a new, internal catalog as + well as indexing all the items against it and the site catalog + (using changed schemas). + + These changes solve the issue discussed at + http://new.zope.org/Members/klm/ColDev/25 - see there for details. + + This version also entails a change to the collector issue + workflow. You can either delete the old workflow and import the + new one (see the INSTALL.txt file for instructions), or just go + to the portal workflow tool and enable the 'request' transition + on the diff --git a/CMFCollector/TODO.txt b/CMFCollector/TODO.txt new file mode 100644 index 0000000..ef10286 --- /dev/null +++ b/CMFCollector/TODO.txt @@ -0,0 +1,20 @@ +To-do: + + o 2/28/2004 MSL: Fix install to look for plone and install collector_plone skin only on plone site, and also do an uninstall to take skins out. Fix collector_search.py to gracefully deal with a missing date from the date range search. + + o 10/14/2001 klm: Email implemented. Have to incorporate andrews + work on search (and make it a return-to-self, single form + activity), and implement issue edit. Still some workflow bugs to + resolve! + + o 10/12/2001 klm: Workflow done. Andrew helped with a start on + searching (not yet checked in). Still todo: complete search, email + notification, issue edit, maybe issue subscription + + o 10/10/2001 klm: Basic content types implemented. Working on + workflow (prototyping with through-the-web DCWorkflow, but not yet + packaging that), searching, email, and assignment, but checking in + the base pieces. + + o 09/24/2001 klm: Implementation. (Designs at + http://dev.zope.org/Wikis/DevSite/Projects/CollectorReplacement/FrontPage .) diff --git a/CMFCollector/WebTextDocument.py b/CMFCollector/WebTextDocument.py new file mode 100644 index 0000000..0a9b1f5 --- /dev/null +++ b/CMFCollector/WebTextDocument.py @@ -0,0 +1,134 @@ +############################################################################## +# +# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + +"""A Document derivative for presenting plain text on the web. + + - Paragraphs of contiguous lines at the left margin are flowed until hard + newlines. + - Indented and '>' cited lines are presented exactly, preserving whitespace. + - URLs (outside indented and cited literal regions) are turned into links. + - Character entities outside of linkified URLs are html-quoted. + +This makes it easy to present both flowed paragraphs and source code (and +other literal text), without having to know and navigate the nuances of HTML +and/or structured text.""" + +import os, urllib, string, re +from Globals import InitializeClass +from AccessControl import ClassSecurityInfo, getSecurityManager +from Acquisition import aq_base + +import util # Collector utilities. + +from Products.CMFDefault.Document import Document +from Products.CMFDefault.utils import SimpleHTMLParser, bodyfinder + +from Products.CMFCore import CMFCorePermissions +from CollectorPermissions import * + +factory_type_information = ( + {'id': 'WebText Document', + 'meta_type': 'WebText Document', + 'icon': 'document_icon.gif', + 'description': ('A document for simple text, with blank-line delimited' + ' paragraphs and special (indented, cited text)' + ' preformatting.'), + 'product': 'CMFCollector', + 'factory': None, # XXX Register add method when blessed. + 'immediate_view': 'metadata_edit_form', + # XXX May need its own forms, in order to inhibit formatting option. + 'actions': ({'name': 'View', + 'action': 'string:${object_url}/document_view', + 'permissions': (CMFCorePermissions.View,)}, + {'name': 'Edit', + 'action': 'string:${object_url}/document_edit_form', + 'permissions': (CMFCorePermissions.ModifyPortalContent,)}, + {'name': 'Metadata', + 'action': 'string:${object_url}/metadata_edit_form', + 'permissions': (CMFCorePermissions.ModifyPortalContent,)}, + ), + }, + ) + +def addWebTextDocument(self, id, title='', description='', text_format='', + text=''): + """ Add a WebText Document """ + o = WebTextDocument(id, title=title, description=description, + text_format=text_format, text=text) + self._setObject(id,o) + +class WebTextDocument(Document): + __doc__ # Use the module documentation. + + meta_type = 'WebText Document' + TEXT_FORMAT = 'webtext' + text_format = TEXT_FORMAT + + _stx_level = 0 + + security = ClassSecurityInfo() + + def __init__(self, id, title='', description='', text_format='', + text=''): + Document.__init__(self, id, title=title, description=description, + text_format=text_format or self.text_format, + text=text) + self.text_format = text_format or self.TEXT_FORMAT + + security.declarePrivate('guessFormat') + def guessFormat(self, text): + """Infer inner document content type.""" + # Respect the registered text_format, if we can, else sniff. + if string.lower(self.text_format) == self.TEXT_FORMAT: + return self.TEXT_FORMAT + elif string.lower(self.text_format) == 'html': + return 'text/html' + elif string.lower(self.text_format) in ['stx', 'structuredtext', + 'structured-text', + 'structured_text']: + return 'structured-text' + else: + return Document.guessFormat(self, text) + + def _edit(self, text, text_format='', safety_belt=''): + """ Edit the Document - Parses headers and cooks the body""" + headers = {} + if not text_format: + text_format = self.text_format + if not safety_belt: + safety_belt = headers.get('SafetyBelt', '') + if not self._safety_belt_update(safety_belt=safety_belt): + msg = ("Intervening changes from elsewhere detected." + " Please refetch the document and reapply your changes." + " (You may be able to recover your version using the" + " browser 'back' button, but will have to apply them" + " to a freshly fetched copy.)") + raise 'EditingConflict', msg + self.cooked_text = self.cookText(text) + self.text = text + + def cookText(self, text): + return util.format_webtext(text) + + # XXX This is obsolete for CMF 1.2 Document. It may be sufficient for + # compatability with pre-CMF-1.2 Document architecture, but that's + # untested. + security.declarePrivate('handleText') + def handleText(self, text, format=None, stx_level=None): + """Handle the raw text, returning headers, body, cooked, format""" + body = text + cooked = self.cookText(text) + headers = {} + return headers, body, cooked, self.TEXT_FORMAT + +InitializeClass(WebTextDocument) diff --git a/CMFCollector/__init__.py b/CMFCollector/__init__.py new file mode 100644 index 0000000..2268fbf --- /dev/null +++ b/CMFCollector/__init__.py @@ -0,0 +1,91 @@ +############################################################################## +# +# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + +from Products.CMFDefault import Portal +import Collector, CollectorIssue, WebTextDocument, CollectorSubset +import Products.CMFCore + +from Products.CMFCore import utils, CMFCorePermissions +from Products.CMFCore.DirectoryView import registerDirectory +import CollectorPermissions + +import sys +this_module = sys.modules[ __name__ ] + +factory_type_information = ( + (Collector.factory_type_information + + CollectorIssue.factory_type_information + + Collector.catalog_factory_type_information + + CollectorSubset.factory_type_information + + + ({'id': 'Collector Issue Transcript', + # 'content_icon': 'event_icon.gif', + 'meta_type': 'WebText Document', + 'description': ('A transcript of issue activity, including comments,' + ' state changes, and so forth.'), + 'product': 'CMFCollector', + 'factory': None, # So not included in 'New' add form + 'allowed_content_types': None, + 'immediate_view': 'collector_transcript_view', + 'actions': ( + { 'id': 'view', + 'name': 'View', + 'action': 'string:${object_url}/../', + 'permissions': (CMFCorePermissions.View,) }, + { 'id': 'addcomment', + 'name': 'Add Comment', + 'action': + 'string:${object_url}/collector_transcript_comment_form', + 'permissions': + (CollectorPermissions.AddCollectorIssueFollowup,) }, + { 'id': 'edittranscript', + 'name': 'Edit Transcript', + 'action': + 'string:${object_url}/collector_transcript_edit_form', + 'permissions': (CollectorPermissions.EditCollectorIssue,) }, + ), + }, + ) + ) + ) + +contentClasses = (Collector.Collector, CollectorIssue.CollectorIssue, + Collector.CollectorCatalog, CollectorSubset.CollectorSubset) +contentConstructors = (Collector.addCollector, + CollectorIssue.addCollectorIssue, + CollectorSubset.addCollectorSubset) +z_bases = utils.initializeBasesPhase1(contentClasses, this_module) +# This is used by a script (external method) that can be run +# to set up collector in an existing CMF Site instance. +collector_globals = globals() + +# Make the skins available as DirectoryViews +registerDirectory('skins', globals()) +registerDirectory('skins/collector', globals()) + +def initialize(context): + utils.initializeBasesPhase2(z_bases, context) + context.registerHelp(directory='help') + context.registerHelpTitle('CMF Collector Help') + + context.registerClass(Collector.Collector, + constructors = (Collector.addCollector,), + permission = CMFCorePermissions.AddPortalContent) + + context.registerClass(CollectorIssue.CollectorIssue, + constructors = (CollectorIssue.addCollectorIssue,), + permission = CollectorPermissions.AddCollectorIssue) + + context.registerClass(CollectorSubset.CollectorSubset, + constructors = (CollectorSubset.addCollectorSubset,), + permission = CMFCorePermissions.AddPortalContent) diff --git a/CMFCollector/collector_issue_workflow.zexp b/CMFCollector/collector_issue_workflow.zexp new file mode 100644 index 0000000..cd80539 --- /dev/null +++ b/CMFCollector/collector_issue_workflow.zexp Binary files differ diff --git a/CMFCollector/collector_issue_workflow.zexp.orig b/CMFCollector/collector_issue_workflow.zexp.orig new file mode 100644 index 0000000..d0e852f --- /dev/null +++ b/CMFCollector/collector_issue_workflow.zexp.orig Binary files differ diff --git a/CMFCollector/dtml/addCollectorForm.dtml b/CMFCollector/dtml/addCollectorForm.dtml new file mode 100644 index 0000000..fd94fdb --- /dev/null +++ b/CMFCollector/dtml/addCollectorForm.dtml @@ -0,0 +1,37 @@ + + + + +

+Start the collector creation process. +

+ +
+ + + + + + + + + +
+
+ Id +
+
+ +
+ +
+ +
+
+
+ + diff --git a/CMFCollector/help/placeholder.txt b/CMFCollector/help/placeholder.txt new file mode 100644 index 0000000..039f01a --- /dev/null +++ b/CMFCollector/help/placeholder.txt @@ -0,0 +1 @@ +So that 'cvs up -d' doesn't blow us away. diff --git a/CMFCollector/skins/collector/aCompact.py b/CMFCollector/skins/collector/aCompact.py new file mode 100644 index 0000000..02eb5fe --- /dev/null +++ b/CMFCollector/skins/collector/aCompact.py @@ -0,0 +1,12 @@ +## Script (Python) "aCustom.py" +##parameters=thetime +##title=Given a DateTime object, return a slightly more compact aCommon repr. + +if same_type(thetime, ""): + import DateTime + dt = DateTime.DateTime(thetime) +else: + dt = thetime + +return "%s %s, %s %s" % (dt.aMonth(), dt.day(), dt.yy(), dt.TimeMinutes()) + diff --git a/CMFCollector/skins/collector/collector_add_issue.py b/CMFCollector/skins/collector/collector_add_issue.py new file mode 100644 index 0000000..d64793c --- /dev/null +++ b/CMFCollector/skins/collector/collector_add_issue.py @@ -0,0 +1,28 @@ +## Script (Python) "collector_add_issue.py" +##parameters=title, security_related, submitter_email, topic, importance, classification, description, version_info +##title=Submit a Request + +from Products.PythonScripts.standard import url_quote_plus + +REQGET = context.REQUEST.get + +id, err = context.add_issue(title=title, + security_related=security_related, + submitter_name=REQGET('submitter_name'), + submitter_email=submitter_email, + description=description, + topic=topic, + classification=classification, + importance=importance, + version_info=version_info, + assignees=REQGET('assignees', []), + file=REQGET('file'), + fileid=REQGET('fileid', ''), + filetype=REQGET('filetype', 'file')) + +dest = "%s/%s" % (context.absolute_url(), id) +if err: + dest += '?portal_status_message=' + url_quote_plus(err) + +context.REQUEST.RESPONSE.redirect(dest) + diff --git a/CMFCollector/skins/collector/collector_add_issue_form.pt b/CMFCollector/skins/collector/collector_add_issue_form.pt new file mode 100644 index 0000000..5a2cbff --- /dev/null +++ b/CMFCollector/skins/collector/collector_add_issue_form.pt @@ -0,0 +1,199 @@ + + This span ensures that the visitor has edit privilege, by fetching - but not + displaying - the protected collector.add_issue method. + + + + + + Template description: Form for submitting new collector issues. + + + + +
+ +
+ COLLECTOR HEADER +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Submit A New Issue +
 
Issue Title + + + + + Security Related? +
+ + If checked, issue will not be publicly visible until + completed. + +
Submitter + + + Email + +
  + + NOTE that anonymous-submitted issues do not allow submitter + to followup. + +
Topic + + Importance + +
Classification + +
Version Info + + + + + +
+ Provide details that will help supporters understand the problem. +
+ + Prefix lines with whitespace or '>' to preserve their + format. + +
Description + + + Assign to:
+ +
Upload + +
+ add_artifacts_table_row +
+ +

+ +
+ +
+ +
+ +
+ + diff --git a/CMFCollector/skins/collector/collector_contents.pt b/CMFCollector/skins/collector/collector_contents.pt new file mode 100644 index 0000000..78f1354 --- /dev/null +++ b/CMFCollector/skins/collector/collector_contents.pt @@ -0,0 +1,381 @@ + + + + Template description: Batching view of the collector issues. + + + + +
+
+ COLLECTOR HEADER +
+
+ +
+ +
+ +
+ COLLECTOR HEADER +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ISSUE-BATCH NAVIGATION +
+
+ + Issue + + + + + ID + + + + + + TITLE + + + +
    + + From + SUBMITTER ID. + + ??? + + CREATEDATE + ... + + MODDATE + +
    + + + + + + STATUS + + + + + TOPIC/CLASSIFICATION + + + Importance + + + + NUM COMMENTS + followupS? + + + + , + Assigned: + + SUPPORTERS + + +
    + + DESCRIPTION +
+
+ ISSUE-BATCH NAVIGATION +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Any text: + + + Show + + issues per screen +
Status Requester Assigned Security Related
+ + + + + + + +
Topic Classification Importance Date Range
+ + + + + + + + +
+ + + +
+ +
+ +
+
+ + + diff --git a/CMFCollector/skins/collector/collector_edit.py b/CMFCollector/skins/collector/collector_edit.py new file mode 100644 index 0000000..9c716fb --- /dev/null +++ b/CMFCollector/skins/collector/collector_edit.py @@ -0,0 +1,41 @@ +## Script (Python) "collector_edit.py" +##parameters=title, description, email, abbrev, managers, supporters, dispatching, participation, state_email, topics, classifications, importances, version_info_spiel +##title=Configure Collector + +from Products.PythonScripts.standard import url_quote_plus + +changes = context.edit(title=title, + description=description, + abbrev=abbrev, + email=email, + managers=managers, + supporters=supporters, + dispatching=dispatching, + participation=participation, + state_email=state_email, + topics=topics, + classifications=classifications, + importances=importances, + version_info_spiel=version_info_spiel) + +if not changes: + changes = "No changes" +else: + changes = "Changed: " + changes + +recatalog = context.REQUEST.get('recatalog', None) +if recatalog: + if recatalog == 1: + context.reinstate_catalog(internal_only=1) + changes += ", reinstated catalog, reindexed internally" + else: + context.reinstate_catalog(internal_only=0) + changes += ", reinstated catalog, reindexed internally and site wide" + +msg = '?portal_status_message=%s.' % url_quote_plus(changes) + +context.REQUEST.RESPONSE.redirect("%s/%s%s" + % (context.absolute_url(), + "collector_edit_form", + msg)) + diff --git a/CMFCollector/skins/collector/collector_edit_form.pt b/CMFCollector/skins/collector/collector_edit_form.pt new file mode 100644 index 0000000..9c5f2a5 --- /dev/null +++ b/CMFCollector/skins/collector/collector_edit_form.pt @@ -0,0 +1,347 @@ + + This span ensures that the visitor has edit privilege, by fetching - but not + displaying - the protected collector.edit method. + + + + + + Template description: Form for configuring the collector. + + + + +
+ +
+ COLLECTOR HEADER +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Configure Collector +
 
Title + + +
Abbreviation + + + Collector email subject-line prefix + +
Description + + Will be rendered as HTML: +
+ +
Collector Email + + + + + + +
+ + + + Collector email "From" address + +
+
Managers + + + + + Managers configure the collector, and receive request notices. + Managers can accept issues, but must be on Supporters roster to be + assigned. (Managers cannot remove themselves from the + management roster.) + +
Supporters + + + + + Supporters can be assigned to requests. They may or may not + receive initial request notifications, depending on the + "dispatching" setting below. + +
Dispatcher Mode + + + + + + + + + + +
+ + Off + + + Managers and supporters receive pending-issue notices + +
+ + On + + + Only managers receive pending-issue notices + +
+
Participation Mode + + + + + + + + + + + + + + +
+ + Staff + + + Only collector staff and issue requester participate in an + issue + +
+ + Authenticated + + + Any non-anonymous visitors can participate + +
+ + Anyone + + + Anyone, including anonymous visitors, can chime in + +
+
Notifications + + Fill in addresses next to the state names to have notifications for + issues in that state sent to those addresses (this is in addition to + normal issue-participant notifications): + + +
+ State: + + + + State: + + +
Selections + + Fill in the alternatives for issue categorization: + + +
Topics + + + Classifications + + +
Importance + + +
Version Info Spiel + + Something to prompt requesters for useful version info... + +
+ +
Reinstate catalog + + Occasionally, product updates require internal catalog reinit and + reindex and other existing-issue adjustment. Collector product + RELEASE_NOTES.txt will indicate when necessary. + +
+ No + Internal only + Internal & Site-wide + (slow) +
 

+ + +
+ +
+
+ + diff --git a/CMFCollector/skins/collector/collector_icon.gif b/CMFCollector/skins/collector/collector_icon.gif new file mode 100644 index 0000000..e36caba --- /dev/null +++ b/CMFCollector/skins/collector/collector_icon.gif Binary files differ diff --git a/CMFCollector/skins/collector/collector_issue_add_issue.py b/CMFCollector/skins/collector/collector_issue_add_issue.py new file mode 100644 index 0000000..762633e --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_add_issue.py @@ -0,0 +1,10 @@ +## Script (Python) "collector_issue_add_issue.py" +##title=Submit a Request + +typeinfo = context.portal_types.getTypeInfo('Collector') +addissue = typeinfo.getActionById('addissue') + +context.REQUEST.RESPONSE.redirect("%s/%s" + % (context.aq_parent.absolute_url(), + addissue)) + diff --git a/CMFCollector/skins/collector/collector_issue_cite_comment.py b/CMFCollector/skins/collector/collector_issue_cite_comment.py new file mode 100644 index 0000000..e873962 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_cite_comment.py @@ -0,0 +1,7 @@ +## Script (Python) "collector_issue_cite_comment.py" +##parameters= +##title=Redirect to issue contents with cite parameter + +context.REQUEST.RESPONSE.redirect(context.absolute_url() + + '?do_cite=1#comment') + diff --git a/CMFCollector/skins/collector/collector_issue_comment_header.py b/CMFCollector/skins/collector/collector_issue_comment_header.py new file mode 100644 index 0000000..7b616d6 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_comment_header.py @@ -0,0 +1,21 @@ +## Script (Python) "collector_issue_comment_header.py" +##title=Form the header for a new comment entry in an issue +##bind container=container +##bind context=context +##parameters=type + +"""Return text for the header of a new comment entry in an issue.""" + +from DateTime import DateTime +import string +user = context.REQUEST.AUTHENTICATED_USER + +if string.lower(type) == "comment": + # We number the comments (sequence_number is incremented by add_comment) + lead = "
" + type + " #" + str(context.sequence_number) +else: + # ... but don't number the other entries. + lead = type + +return "%s by %s on %s ==>" % (lead, str(user), DateTime().aCommon()) + diff --git a/CMFCollector/skins/collector/collector_issue_contents.pt b/CMFCollector/skins/collector/collector_issue_contents.pt new file mode 100644 index 0000000..c920edb --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_contents.pt @@ -0,0 +1,64 @@ + + + + + Template description: Basic view of issue characteristics and transcript. + + +
+
+ ISSUE HEADER +
+
+ +
+ +
+ +
+ ISSUE HEADER +
+
+ +
+ + + + + + + + + + + + + +
+ + + Issue ID Transcript + + +
+ +
+ + +
+ + TRANSCRIPT + +
+
+ +
+ +
+ + + diff --git a/CMFCollector/skins/collector/collector_issue_edit.py b/CMFCollector/skins/collector/collector_issue_edit.py new file mode 100644 index 0000000..84fd183 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_edit.py @@ -0,0 +1,46 @@ +## Script (Python) "collector_issue_edit.py" +##title=Submit a Request + +from Products.PythonScripts.standard import url_quote_plus + +REQGET = context.REQUEST.get + +was_security_related = context.security_related + +changed = context.edit(title=REQGET('title'), + submitter_id=REQGET('submitter_id', None), + submitter_name=REQGET('submitter_name', None), + submitter_email=REQGET('submitter_email', None), + security_related=REQGET('security_related', 0), + description=REQGET('description'), + topic=REQGET('topic'), + classification=REQGET('classification'), + importance=REQGET('importance'), + version_info=REQGET('version_info'), + stealthy=REQGET('stealthy'), + comment=REQGET('comment'), + text=REQGET('text')) + +if context.security_related != was_security_related: + # We're toggling security_related - we have to do the corresponding + # restrict/unrestrict if available in the current state: + if context.security_related: + seeking_pretty = 'Restrict' + else: + seeking_pretty = 'Unrestrict' + for action, pretty in context.valid_actions_pairs(): + if pretty == seeking_pretty: + context.do_action(action, ' Triggered by security_related toggle.') + changed = changed + ", " + pretty.lower() + 'ed' + break + +whence = context.absolute_url() + +if changed: + msg = url_quote_plus("Changed: " + changed) + context.REQUEST.RESPONSE.redirect("%s?portal_status_message=%s" + % (whence, msg)) + +else: + context.REQUEST.RESPONSE.redirect(whence) + diff --git a/CMFCollector/skins/collector/collector_issue_edit_form.pt b/CMFCollector/skins/collector/collector_issue_edit_form.pt new file mode 100644 index 0000000..36c03c2 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_edit_form.pt @@ -0,0 +1,248 @@ + + This span ensures that the visitor has edit privilege, by fetching - but + not displaying - the protected issue.edit method. + + + + + + + Template description: Basic view of issue characteristics and transcript. + + +
+ +
+ ISSUE HEADER +
+ +
+ +
+ +
+ +
+ ISSUE HEADER +
+
+ + + + + + + + + + +
+ + + Edit Collector Issue + + +
+

+ Adjust various issue data - the current settings are indicated + above. Use the regular issue followup, instead, to change the issue + workflow state or assigned supporters, and use the folder contents + view to change the uploads. +

+
+ +
+ +
+ + + This table will be replaced by the issue header structure, with the + various value slots being replaced by the input fields below. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ISSUE TITLE + +
Security Related: related? + +
ISSUE DESCRIPTION + +
FROM + + + +
FROM + + Id: +
+ Alt Email: + +
+
FROM + + + +
Topic + + + +
Classification + + + +
Importance + +
Version info + +
Upload + + Use the issue folder_contents view, if accessible, to change the + uploads. + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Comment
+ +
+ + + + + +
Stealthy? + + (Comment will be disregarded) +
+
Edit Transcript
+ +
+ + +
+ +
+ +
+ + + diff --git a/CMFCollector/skins/collector/collector_issue_followup.py b/CMFCollector/skins/collector/collector_issue_followup.py new file mode 100644 index 0000000..001cc42 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_followup.py @@ -0,0 +1,24 @@ +## Script (Python) "collector_issue_followup.py" +##parameters=comment, action="comment" +##title=Submit a new comment. + +from Products.PythonScripts.standard import url_quote_plus + +REQUEST = context.REQUEST + +got = context.do_action(action, + comment, + assignees=REQUEST.get('assignees', []), + file=REQUEST.get('file'), + fileid=REQUEST.get('fileid', ''), + filetype=(REQUEST.get('filetype', 'file'))) + +if context.status() in ['Resolved', 'Rejected', 'Deferred']: + destination = context.aq_parent.absolute_url() +else: + destination = context.absolute_url() + +if got: + destination += '?portal_status_message=' + url_quote_plus(got) + +context.REQUEST.RESPONSE.redirect(destination) diff --git a/CMFCollector/skins/collector/collector_issue_followup_form.pt b/CMFCollector/skins/collector/collector_issue_followup_form.pt new file mode 100644 index 0000000..be76fc3 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_followup_form.pt @@ -0,0 +1,160 @@ + + This span ensures that the visitor has edit privilege, by fetching - but + not displaying - the protected collector.do_action method. + + + + + + + Template description: Form for entering new issue actions. + + +
+
+ ISSUE HEADER +
+
+ +
+ + + +
+ +
+ ISSUE HEADER +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + Issue ID + + + + + Note: We have no submitter address, so they won't get followups. + +
+ Entry + + Click + here to cite the existing transcript. +
+ Enter your followup. +
Action: + Comment  + + + ACTION  + + + + Assign: +
+ + +
+ Prefix lines with whitespace or '>' to preserve their format. +
+
+ +
+ +
+ ADD-ARTIFACTS TABLE +
+ +
+ +
+ +
+ +
+ + + diff --git a/CMFCollector/skins/collector/collector_issue_icon.gif b/CMFCollector/skins/collector/collector_issue_icon.gif new file mode 100644 index 0000000..131ad73 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_icon.gif Binary files differ diff --git a/CMFCollector/skins/collector/collector_issue_notice.dtml b/CMFCollector/skins/collector/collector_issue_notice.dtml new file mode 100644 index 0000000..3e32a05 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_notice.dtml @@ -0,0 +1,18 @@ + + Form an email message for issue events. + +From: +To: +Subject: +X-Recipients-debug: + +Issue # Update () "" + ** Security Related ** (ConfidentialPublic) + Status , / +To followup, visit: + + +============================================================== + +============================================================== + diff --git a/CMFCollector/skins/collector/collector_issue_reject.py b/CMFCollector/skins/collector/collector_issue_reject.py new file mode 100644 index 0000000..81bbecd --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_reject.py @@ -0,0 +1,5 @@ +## Script (Python) "collector_issue_reject.py" +##title=Reject a collector issue + +context.REQUEST.RESPONSE.redirect(context.absolute_url()) + diff --git a/CMFCollector/skins/collector/collector_issue_trim_states.py b/CMFCollector/skins/collector/collector_issue_trim_states.py new file mode 100644 index 0000000..b179753 --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_trim_states.py @@ -0,0 +1,18 @@ +## Script (Python) "collector_issue_trim_states.py" +##title=Return massaged list of states of issues in catalog. + +# Pare out irrelevant states and trim '_confidential' from the rest. + +import string + +states = context.portal_catalog.uniqueValuesFor('review_state') + +got = [] +for i in states: + if i in ['private', 'published', 'pending']: + continue + trim = string.split(i, '_')[0] + if trim not in got: + got.append(trim) + +return got diff --git a/CMFCollector/skins/collector/collector_issue_up.py b/CMFCollector/skins/collector/collector_issue_up.py new file mode 100644 index 0000000..390310e --- /dev/null +++ b/CMFCollector/skins/collector/collector_issue_up.py @@ -0,0 +1,5 @@ +## Script (Python) "collector_issue_up.py" +##title=Submit a Request + +context.REQUEST.RESPONSE.redirect(context.aq_parent.absolute_url()) + diff --git a/CMFCollector/skins/collector/collector_localrole_edit.py b/CMFCollector/skins/collector/collector_localrole_edit.py new file mode 100644 index 0000000..70b0e9e --- /dev/null +++ b/CMFCollector/skins/collector/collector_localrole_edit.py @@ -0,0 +1,24 @@ +## Script (Python) "folder_localrole_edit" +##bind container=container +##bind context=context +##bind namespace= +##bind script=script +##bind subpath=traverse_subpath +##parameters=change_type +##title=Set local roles +## +pm = context.portal_membership + +if change_type == 'add': + pm.setLocalRoles( obj=context + , member_ids=context.REQUEST.get('member_ids', ()) + , member_role=context.REQUEST.get('member_role', '') + ) +else: + pm.deleteLocalRoles( obj=context + , member_ids=context.REQUEST.get('member_ids', ()) + ) + +qst='?portal_status_message=Local+Roles+changed.' + +context.REQUEST.RESPONSE.redirect( context.absolute_url() + '/folder_localrole_form' + qst ) diff --git a/CMFCollector/skins/collector/collector_macros.pt b/CMFCollector/skins/collector/collector_macros.pt new file mode 100644 index 0000000..bda6e6d --- /dev/null +++ b/CMFCollector/skins/collector/collector_macros.pt @@ -0,0 +1,344 @@ + + + + + Collector issue macros: + + - collector_header + + - issue_header + + - issue_batch_nav + + - add_artifacts_table + + +
+ + + + + + + + +
+
+ + TITLE + + [] + + Issue Collector + +
+
+
+ DESCRIPTION +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Issue ID of + + COLLECTOR TITLE + [] + + +
Title: + + TITLE + + +
Status: + + STATUS + + Security + related: + + No + + + Yes + CONFIDENTIALITY + + +
Description: + + + DESCRIPTION + + +
From: + NAME + on: + + + CREATIONDATE + + +
+ ID + + Last update: + + + ~ + + MODDATE + + +
Topic/class: + + TOPIC/CLASSIFICATION + + Importance: + + + IMPORTANCE + +
Version info: + VERSION_INFO +
Assigned: + + SUPPORTERS +
Uploads: + +
+
+ + +
+ + + + + ITEM ID + + + DESCRIPTION + +
+
+ +
+ +
+ + + This macros depends on a number of batch parameters being defined in + the invoking template: + - BATCHSIZE + - batch + - prev (may be undefined) + - next (may be undefined) + - last_batch + + + + + + + + + + + + + + + +
+ + + + << + + + + + + + Previous + + + + + X to X of + X of + FOUND/TOTAL + TOTAL + + + + + + Next + + + + + + + >> + + +
+
+ +
+ + + + + + + + + + +
+ File or +
+ Image +
(Optional) Id:
+ +
+ + + diff --git a/CMFCollector/skins/collector/collector_ordered_traits.py b/CMFCollector/skins/collector/collector_ordered_traits.py new file mode 100644 index 0000000..bbb2cb9 --- /dev/null +++ b/CMFCollector/skins/collector/collector_ordered_traits.py @@ -0,0 +1,14 @@ +## Script (Python) "collector_ordered_traits.py" +##parameters=traits, order +##title=Return traits list ordered according to second arg, then remainder. + +remainder = filter(None, traits[:]) +got = [] +for i in order: + if not remainder: + break + if i in remainder: + got.append(i) + remainder.remove(i) + +return got + remainder diff --git a/CMFCollector/skins/collector/collector_search.py b/CMFCollector/skins/collector/collector_search.py new file mode 100644 index 0000000..1a9a6cf --- /dev/null +++ b/CMFCollector/skins/collector/collector_search.py @@ -0,0 +1,63 @@ +## Script (Python) "collector_search.py" +##title=Build Collector Search + +query = {} +query['sort_on'] = 'created' +query['Type'] = "Collector Issue" + +reqget = context.REQUEST.get +subj_items = [] + +def supplement_query(field, index_name=None, reqget=reqget, query=query): + if not index_name: index_name = field + val = reqget(field, None) + if val: + query[index_name] = val + +def supplement_query_date(field, index_name=None, reqget=reqget, query=query): + if not index_name: index_name = field + val = reqget(field, None) + if val: +# query[index_name] = map(DateTime(), val) +#this is a total hack, assumes always a pair of dates + query[index_name] = [DateTime(val[0]), DateTime(val[1])] + +supplement_query("SearchableText") +supplement_query("Creator") +supplement_query("classifications", "classification") +supplement_query("topics", "topic") +supplement_query("supporters", "assigned_to") +supplement_query("resolution") +supplement_query("version_info") +supplement_query("importances", "importance") +supplement_query_date("Date") +supplement_query("Date_usage") +supplement_query_date("created") +supplement_query("created_usage") +supplement_query_date("modified") +supplement_query("modified_usage") + +sr = reqget("security_related", []) +if sr: + if 'Yes' in sr and 'No' in sr: + # Both means we don't care - don't include in query. + pass + elif 'Yes' in sr: + query['security_related'] = [1] + else: + query['security_related'] = [0] + +rs = [] + +for i in reqget("status", []): + rs.append(i) + # Include confidential alternatives to selected states. + # XXX To account for changes, we should obtain all the possible states, + # and just do token processing according to their names. + if i in ['Pending', 'Accepted']: + rs.append("%s_confidential" % i) + if rs: + query['status'] = rs + +got = context.get_internal_catalog()(REQUEST=query) +return got diff --git a/CMFCollector/skins/collector/collector_transcript_view.py b/CMFCollector/skins/collector/collector_transcript_view.py new file mode 100644 index 0000000..a6d0f80 --- /dev/null +++ b/CMFCollector/skins/collector/collector_transcript_view.py @@ -0,0 +1,5 @@ +## Script (Python) "collector_transcript_view.py" +##title=Redirect to the issue containing the transcript + +context.REQUEST.RESPONSE.redirect(context.aq_parent.absolute_url()) + diff --git a/CMFCollector/skins/collector/subset_edit.py b/CMFCollector/skins/collector/subset_edit.py new file mode 100644 index 0000000..cdbc79b --- /dev/null +++ b/CMFCollector/skins/collector/subset_edit.py @@ -0,0 +1,9 @@ +##title=Update a Collector Subset +##parameters=parameters, REQUEST +context.clearParameters() +for parm in parameters: + context.setParameter( parm.key, parm.value ) +info = context.getTypeInfo() +action = info.getActionById( 'edit' ) +REQUEST['RESPONSE'].redirect( '%s/%s?portal_status_message=Updated.' + % ( context.absolute_url(), action ) ) diff --git a/CMFCollector/skins/collector/subset_edit_form.pt b/CMFCollector/skins/collector/subset_edit_form.pt new file mode 100644 index 0000000..36d35b2 --- /dev/null +++ b/CMFCollector/skins/collector/subset_edit_form.pt @@ -0,0 +1,50 @@ + + + +
+ +

Document Title

+ +
+ Document Description goes here. +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + +
Parameter Value
+ + + +
+
+
+ +
+ + + + + diff --git a/CMFCollector/skins/collector/subset_view.pt b/CMFCollector/skins/collector/subset_view.pt new file mode 100644 index 0000000..f280d46 --- /dev/null +++ b/CMFCollector/skins/collector/subset_view.pt @@ -0,0 +1,42 @@ + + + +
+ +

Document Title

+ +
+ Document Description goes here. +
+ +
+ +
+ + + + + + + + + + + +
Parameter Value
+ +
+ +

Subset Page: /index_html

+ +
+ + + + diff --git a/CMFCollector/skins/collector_plone/README.collector_contents.txt b/CMFCollector/skins/collector_plone/README.collector_contents.txt new file mode 100644 index 0000000..d60df16 --- /dev/null +++ b/CMFCollector/skins/collector_plone/README.collector_contents.txt @@ -0,0 +1,3 @@ +I like the original collector contents view better. this one looks more plone-ish but lacks information and is hard to read + +-- MSL 2/28/2004 \ No newline at end of file diff --git a/CMFCollector/skins/collector_plone/README.txt b/CMFCollector/skins/collector_plone/README.txt new file mode 100644 index 0000000..d8736ef --- /dev/null +++ b/CMFCollector/skins/collector_plone/README.txt @@ -0,0 +1,11 @@ +This is just the original Plone skin for CMFCollector, it works well, +but is not going to be part of Plone Core anymore. I'm donating it to +whoever wants to maintain it. I hope I'm not being rude when I check +this into the CMF repository. It's not hooked up to anything yet, but +can easily be in the Install.py - check if the site is a Plone site, +and install this skin if it is. + +Feel free to contact me if you have any questions. + +-- Alexander Limi, 25. Oct 2003 + http://www.plonesolutions.com diff --git a/CMFCollector/skins/collector_plone/collector_add_issue_form.pt b/CMFCollector/skins/collector_plone/collector_add_issue_form.pt new file mode 100644 index 0000000..435f8f0 --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_add_issue_form.pt @@ -0,0 +1,220 @@ + + + + This span ensures that the visitor has edit privilege, by fetching - but not + displaying - the protected collector.add_issue method. + + + Template description: Form for submitting new collector issues. + + + + +
+ +

+Add new issue to TITLE +

+ +
+Use this form to add your issue to the Collector. Please provide as many details +as possible. +
+ +
+ +
+ +
+ +
+ + Issue Details + +
+
Title
+ +
+ +
+
+ +
+
Security
+
+ + + + + Yes, this issue is security related, and + should not be publicly visible until + completed. + + +
+
+ +
+
Submitter
+ +
+
+ +
+ +
+
+ +
+
Email
+ +
+ + + NOTE that anonymous-submitted issues do not allow submitter + to followup. + + +
+
+ +
+
Topic
+ +
+ +
+
+ +
+
Importance
+ +
+ +
+ +
+ +
+
Classification
+ +
+ +
+ +
+ +
+
Version Info
+
+ +
+
+ + + + +
+
Description
+
+ +
+ +
+ +
+
Assign to
+
+ +
+
+ +
+ +
+
Attachment (optional)
+
+ +
+
+ +
+
Attachment type (optional)
+
+ + + + +
+
+ +
+
Name of upload (optional)
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+ +
+ +
+ + + + diff --git a/CMFCollector/skins/collector_plone/collector_edit_form.pt b/CMFCollector/skins/collector_plone/collector_edit_form.pt new file mode 100644 index 0000000..e7f9e06 --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_edit_form.pt @@ -0,0 +1,403 @@ + + + + This span ensures that the visitor has edit privilege, by fetching - but not + displaying - the protected collector.edit method. + +
+ +

Collector Configuration

+ +
+ +
+ +
+ + Collector Details + +
+
+ + Name + + +
+ +
+ +
+ + +
+ +
+ + +
+
Title
+
+ +
+
+ +
+
+ Abbreviation + +
+
+ + +
+
+ +
+
Description
+
+ +
+
+ +
+
Collector email
+
+ + + + +
+
+ +
+
Managers
+
+ + +
+
+ +
+
Supporters
+
+ + +
+
+ +
+
Dispatcher Mode
+
+
+ + + Off (Managers and supporters receive pending-issue notices) + + + +
+ + + + On (Only managers receive pending-issue notices) + +
+
+
+ +
+
Participation Mode
+
+
+ + + Staff + + (Only collector staff and issue requester participate in an + issue) + + +
+ + + + Authenticated + + (Any non-anonymous visitors can participate) + + +
+ + + + Anyone + + (Anyone, including anonymous visitors, can chime in) + +
+
+
+ +
+
Notifications
+
+
+ + + State: + +
+ + +
+ +
+
+ +
+
+ +
+
Selections
+
+
+ + Topics + + + Classifications + + + Importance + +
+ +
+
+ +
+
Version boilerplate
+
+ + + +
+
+ +
+
Reinstate catalog
+
+
+ + +
+ + + +
+ + + +
+ +
+ +
+
+ +
+
 
+
+ + +
+
+ + + +
+ + diff --git a/CMFCollector/skins/collector_plone/collector_icon.gif b/CMFCollector/skins/collector_plone/collector_icon.gif new file mode 100644 index 0000000..af32930 --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_icon.gif Binary files differ diff --git a/CMFCollector/skins/collector_plone/collector_issue_contents.pt b/CMFCollector/skins/collector_plone/collector_issue_contents.pt new file mode 100644 index 0000000..56f1c68 --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_issue_contents.pt @@ -0,0 +1,68 @@ + + + + + + + + + + Template description: Basic view of issue characteristics and transcript. + + +
+
+ ISSUE HEADER +
+
+ +
+ +
+ +
+ ISSUE HEADER +
+
+ +
+ +
+ +
+ +
+ Transcript for Issue + ID + (7 + + + entry + + + + + + entries + ) +
+ +
+
+ TRANSCRIPT +
+
+ +
+
+ +
+ +
+ + + diff --git a/CMFCollector/skins/collector_plone/collector_issue_edit_form.pt b/CMFCollector/skins/collector_plone/collector_issue_edit_form.pt new file mode 100644 index 0000000..e0ea77e --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_issue_edit_form.pt @@ -0,0 +1,200 @@ + + + + + + + + + + This span ensures that the visitor has edit privilege, by fetching - but + not displaying - the protected issue.edit method. + + + + Template description: Basic view of issue characteristics and transcript. + + +
+ +

+ Edit Collector Issue +

+ +
+ Use this form to edit the data of an existing issue. Do not use this form + to follow up or change the workflow status of an issue - use the issue follow + up form for that. +
+ +
+ +
+ +
+ +
+ ISSUE HEADER +
+
+ +
+ + Issue details + + +
+
Title
+
+ +
+
+ +
+
Security
+
+ + +
+
+ +
+
Description
+
+ +
+
+ +
+
From (name)
+
+ +
+
+ +
+
From (login name)
+
+ +
+
+ +
+
From (alt. email)
+
+ +
+
+ +
+
Topic
+
+ +
+
+ +
+
Classification
+
+ +
+
+ + +
+
Importance
+
+ +
+
+ + +
+
Version info
+
+ +
+
+ + +
+
Upload
+
+ Use the issue folder_contents view (if accessible) to change the + uploads. +
+
+ + +
+
Comment
+
+ +
+
+ +
+
Transcript
+
+ +
+
+ + +
+
 
+
+ + +
+
+ +
+ +
+ + + diff --git a/CMFCollector/skins/collector_plone/collector_issue_icon.gif b/CMFCollector/skins/collector_plone/collector_issue_icon.gif new file mode 100644 index 0000000..c2d217b --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_issue_icon.gif Binary files differ diff --git a/CMFCollector/skins/collector_plone/collector_issue_notice.dtml b/CMFCollector/skins/collector_plone/collector_issue_notice.dtml new file mode 100644 index 0000000..0ef9a92 --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_issue_notice.dtml @@ -0,0 +1,18 @@ + + Form an email message for issue events. + +From: +To: +Subject: +X-Recipients-debug: + +Issue # Update () "" + ** Security Related ** (ConfidentialPublic) + Status , / +To followup, visit: + +______________________________________________________________ + + +______________________________________________________________ + diff --git "a/CMFCollector/skins/collector_plone/collector_issue\342\200\246ollowup_form.pt" "b/CMFCollector/skins/collector_plone/collector_issue\342\200\246ollowup_form.pt" new file mode 100644 index 0000000..9f458d6 --- /dev/null +++ "b/CMFCollector/skins/collector_plone/collector_issue\342\200\246ollowup_form.pt" @@ -0,0 +1,172 @@ + + + + This span ensures that the visitor has edit privilege, by fetching - but + not displaying - the protected collector.do_action method. + + + + + + + + + Template description: Form for entering new issue actions. + + +
+
+ ISSUE HEADER +
+
+ +
+ + + +
+ +
+ ISSUE HEADER +
+
+ +
+ + + Note: We have no mail address for the submitters, so they won't + get followups. + +
+ + +
+ + Follow-up details + + +
+
Action
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
Follow-up
+
+ +
+
+ + + + Click + here to cite the existing transcript. + + +
+
Attachment (optional)
+
+ +
+
+ +
+
Attachment type (optional)
+
+ + + + +
+
+ +
+
Name of upload (optional)
+
+ +
+
+ +
+
 
+
+ +
+
+ +
+ +
+ + diff --git a/CMFCollector/skins/collector_plone/collector_macros.pt b/CMFCollector/skins/collector_plone/collector_macros.pt new file mode 100644 index 0000000..3c863b8 --- /dev/null +++ b/CMFCollector/skins/collector_plone/collector_macros.pt @@ -0,0 +1,325 @@ + + + + + Collector issue macros: + + - collector_header + + - issue_header + + - issue_batch_nav + + - add_artifacts_table + + +
+ +

+ TITLE + + + [] + +

+ +
+ DESCRIPTION +
+ +
+ + +
+ +

+ Issue ID of + + + COLLECTOR TITLE + [] + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Title: + + + TITLE + + +
Status: + + STATUS + + Security related: + + No + + + Yes + CONFIDENTIALITY + + +
Description: + + + DESCRIPTION + + +
From: + NAME + on + + + CREATIONDATE + + +
+ ID + + Last update: + + + ~ + + MODDATE + + +
Topic/class: + + TOPIC/CLASSIFICATION + + Importance: + + IMPORTANCE + +
Version info: + VERSION_INFO +
Assigned: + + SUPPORTERS +
Uploads: + + + + + +
+ + + + + ITEM ID + + DESCRIPTION +
+
 
+ +
+ +
+ + + This macros depends on a number of batch parameters being defined in + the invoking template: + - BATCHSIZE + - batch + - prev (may be undefined) + - next (may be undefined) + - last_batch + + + + + + + + + + + + + + + +
+ + + + << + + + + + + + Previous + + + + + X to X of + X of + FOUND/TOTAL + TOTAL + + + + + + Next + + + + + + + >> + + +
+
+ +
+ + +
+ File + Image +
+ + (Optional) Id: + +
+ +
+ + + diff --git a/CMFCollector/skins/collector_plone/original-collector_contents.pt b/CMFCollector/skins/collector_plone/original-collector_contents.pt new file mode 100644 index 0000000..e567fd8 --- /dev/null +++ b/CMFCollector/skins/collector_plone/original-collector_contents.pt @@ -0,0 +1,384 @@ + + + + Template description: Batching view of the collector issues. + + + + +
+ +
+ COLLECTOR HEADER +
+ +
+ +
+ +
+ +
+ COLLECTOR HEADER +
+
+ +
+ +
+ Found + found + issues. In total there are + total + issues in this collector. +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 Issue  Title  Submitter  Date  Status 
+ + ID + + + + Issue + + + + TITLE + + + + + SUBMITTER ID + ? + + CREATEDATE + ... + + MODDATE + + + + + + + STATUS + + +
+ + + TOPIC/CLASSIFICATION + + +
+ + + Importance + + + , + Assigned to + + + SUPPORTERS + + +
+ + +
+ + +
+ + + Search for issues + +
+
Contains
+ +
+ +
+
+ +
+
Show
+ +
+ Show + + issues per screen +
+
+ + +
+
 
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusRequesterAssignedSecurity Related
+ + + + + + + +
TopicClassificationImportance  
+ + + + + + + + +
+ + + +
+
+
+ +
+ +
 
+ +
+ + +
+
+ +
+ +
+
+ + diff --git a/CMFCollector/tests/__init__.py b/CMFCollector/tests/__init__.py new file mode 100644 index 0000000..29f81bd --- /dev/null +++ b/CMFCollector/tests/__init__.py @@ -0,0 +1,5 @@ +"""Unit test package for CMFCollector. + +As test suites are added, they should be added to the +mega-test-suite in Products.CMFCollector.tests.test_all.py +""" diff --git a/CMFCollector/tests/test_CollectorSubset.py b/CMFCollector/tests/test_CollectorSubset.py new file mode 100644 index 0000000..6797c15 --- /dev/null +++ b/CMFCollector/tests/test_CollectorSubset.py @@ -0,0 +1,133 @@ +import unittest + +class DummyRecord: + + def __init__( self, key, value ): + self.key = key + self.value = value + +class CollectorSubsetTests( unittest.TestCase ): + + def _makeOne( self, id='subset', *args, **kw ): + + from Products.CMFCollector.CollectorSubset import CollectorSubset + return CollectorSubset( id=id, *args, **kw ) + + def test_listParameterTypes( self ): + + subset = self._makeOne() + + parm_types = subset.listParameterTypes() + + self.failUnless( 'review_state' in parm_types ) + self.failUnless( 'submitter_id' in parm_types ) + self.failUnless( 'supporters:list' in parm_types ) + + self.failUnless( 'topics:list' in parm_types ) + self.failUnless( 'classifications:list' in parm_types ) + self.failUnless( 'importances:list' in parm_types ) + + def test_empty( self ): + + subset = self._makeOne() + + self.assertEqual( len( subset.listParameters() ), 0 ) + self.assertEqual( subset._buildQueryString(), '' ) + + self.assertEqual( subset.getParameterValue( 'review_state' ), '' ) + self.assertEqual( subset.getParameterValue( 'submitter_id' ), '' ) + self.assertEqual( subset.getParameterValue( 'supporters:list' ), '' ) + self.assertEqual( subset.getParameterValue( 'topics:list' ), '' ) + self.assertEqual( subset.getParameterValue( 'classifications:list' ) + , '' ) + self.assertEqual( subset.getParameterValue( 'importances:list' ), '' ) + + def test_getParameterValue_badParm( self ): + + subset = self._makeOne() + self.assertRaises( ValueError, subset.getParameterValue, 'importaince' ) + + def test_setParameter_badParm( self ): + + subset = self._makeOne() + + self.assertRaises( ValueError, subset.setParameter + , 'wonders_about', 'fred' ) + + def test_setParameter_oneParm( self ): + + subset = self._makeOne() + + subset.setParameter( 'supporters:list', 'fred' ) + + self.assertEqual( len( subset.listParameters() ), 1 ) + self.assertEqual( subset._buildQueryString(), 'supporters%3Alist=fred' ) + + self.assertEqual( subset.getParameterValue( 'review_state' ), '' ) + self.assertEqual( subset.getParameterValue( 'submitter_id' ), '' ) + self.assertEqual( subset.getParameterValue( 'supporters:list' ) + , 'fred' ) + self.assertEqual( subset.getParameterValue( 'topics:list' ), '' ) + self.assertEqual( subset.getParameterValue( 'classifications:list' ) + , '' ) + self.assertEqual( subset.getParameterValue( 'importances:list' ), '' ) + + def test_clearParameters( self ): + + subset = self._makeOne() + + subset.setParameter( 'supporters:list', 'fred' ) + subset.clearParameters() + + self.assertEqual( len( subset.listParameters() ), 0 ) + self.assertEqual( subset._buildQueryString(), '' ) + + parm_types = subset.listParameterTypes() + + self.failUnless( 'review_state' in parm_types ) + self.failUnless( 'submitter_id' in parm_types ) + self.failUnless( 'supporters:list' in parm_types ) + + self.failUnless( 'topics:list' in parm_types ) + self.failUnless( 'classifications:list' in parm_types ) + self.failUnless( 'importances:list' in parm_types ) + + self.assertEqual( subset.getParameterValue( 'review_state' ), '' ) + self.assertEqual( subset.getParameterValue( 'submitter_id' ), '' ) + self.assertEqual( subset.getParameterValue( 'supporters:list' ), '' ) + self.assertEqual( subset.getParameterValue( 'topics:list' ), '' ) + self.assertEqual( subset.getParameterValue( 'classifications:list' ) + , '' ) + self.assertEqual( subset.getParameterValue( 'importances:list' ), '' ) + + def test_setParameters_twoParms( self ): + + subset = self._makeOne() + + subset.setParameter( 'supporters:list', 'fred' ) + subset.setParameter( 'topics:list', 'bug' ) + + self.assertEqual( len( subset.listParameters() ), 2 ) + qs = subset._buildQueryString() + terms = qs.split( '&' ) + terms.sort() + self.assertEqual( terms[0], 'supporters%3Alist=fred' ) + self.assertEqual( terms[1], 'topics%3Alist=bug' ) + + self.assertEqual( subset.getParameterValue( 'review_state' ), '' ) + self.assertEqual( subset.getParameterValue( 'submitter_id' ), '' ) + self.assertEqual( subset.getParameterValue( 'supporters:list' ) + , 'fred' ) + self.assertEqual( subset.getParameterValue( 'topics:list' ), 'bug' ) + self.assertEqual( subset.getParameterValue( 'classifications:list' ) + , '' ) + self.assertEqual( subset.getParameterValue( 'importances:list' ), '' ) + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest( unittest.makeSuite( CollectorSubsetTests ) ) + return suite + +if __name__ == '__main__': + unittest.main( defaultTest='test_suite' ) + diff --git a/CMFCollector/tests/test_all.py b/CMFCollector/tests/test_all.py new file mode 100644 index 0000000..13d1db8 --- /dev/null +++ b/CMFCollector/tests/test_all.py @@ -0,0 +1,10 @@ +"""Currently all stub, no substance.""" +import Zope +from unittest import TestSuite,main +from Products.CMFCore.tests.base.utils import build_test_suite + +def test_suite(): + return TestSuite() + +if __name__ == '__main__': + main(defaultTest='test_suite') diff --git a/CMFCollector/util.py b/CMFCollector/util.py new file mode 100644 index 0000000..32c6a5d --- /dev/null +++ b/CMFCollector/util.py @@ -0,0 +1,296 @@ +############################################################################## +# +# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## + +"""Sundry collector utilities.""" + +import string, re +from Products.CMFCore.utils import getToolByName +from DocumentTemplate.DT_Var import html_quote + +def users_for_local_role(object, userids, role): + """Give only designated userids specified local role. + + Return 1 iff any role changes happened.""" + already = [] + changed = 0 + for u in object.users_with_local_role(role): + if u in userids: + already.append(u) + else: + changed = 1 + remove_local_role(object, u, role) + for u in userids: + if u not in already: + changed = 1 + add_local_role(object, u, role) + return changed + +def add_local_role(object, userid, role): + """Add object role for userid if not already there.""" + roles = list(object.get_local_roles_for_userid(userid)) + if role not in roles: + roles.append(role) + object.manage_setLocalRoles(userid, roles) + +def remove_local_role(object, userid, role): + """Add object role for userid if not already there.""" + roles = list(object.get_local_roles_for_userid(userid)) + roles.remove(role) + if roles: + object.manage_setLocalRoles(userid, roles) + else: + object.manage_delLocalRoles([userid]) + +def get_email_fullname(self, userid): + """Get full_name or userid, and email, from membership tool.""" + mbrtool = getToolByName(self, 'portal_membership') + user = mbrtool.getMemberById(userid) + if user is not None: + email = safeGetProperty(user, 'email', None) + name = safeGetProperty(user, 'full_name', str(user)) + if '.' in name or ',' in name: + name = '"%s"' % name + return (name, email) + return (None, None) + +def safeGetProperty(userobj, property, default=None): + """Defaulting user.getProperty(), allowing for variant user folders.""" + try: + if not hasattr(userobj, 'getProperty'): + return getattr(userobj, property, default) + else: + return userobj.getProperty(property, default) + except: + # We can't just itemize the possible candidate exceptions because one + # is a string with spaces, which isn't interned and hence object id + # won't match. Sigh. + import sys + exc = sys.exc_info()[0] + if (exc == 'Property not found' + or isinstance(exc, TypeError) + or isinstance(exc, AttributeError) + or isinstance(exc, LookupError)): + try: + # Some (eg, our old LDAP user folder) support getProperty but + # not defaulting: + return userobj.getProperty(property) + except: + return default + else: + raise + +############################## +# WebText processing utilities +preexp = re.compile(r'<pre>') +unpreexp = re.compile(r'</pre>') +urlchars = (r'[A-Za-z0-9/:@_%~#=&\.\-\?]+') +nonpuncurlchars = (r'[A-Za-z0-9/@_%~#=&\-]') +url = (r'["=]?((http|https|ftp|mailto|file|about):%s%s)' + % (urlchars, nonpuncurlchars)) +urlexp=re.compile(url) + +def format_webtext(text, + presearch=preexp.search, presplit=preexp.split, + unpresearch=unpreexp.search, unpresplit=unpreexp.split, + urlexpsub=urlexp.sub): + """Transform web text for browser presentation. + + - HTML quote everything except URLs (which can't contain '<', so are safe) + - Terminate all lines with
s. + - Whitespace-quote indented and '>' cited lines + - Whitespace-quote lines within
/
pairs + - Turn URLs recognized outside of literal regions into links.""" + + # Definitions: + # + # - "in_literal" exemptions: Lines starting with whitespace or '>' + # - "in_pre" exemptions: Lines residing within (non-exempted)
 tag
+    #
+    # Nuances:
+    #
+    # - Neither exemption can toggle while the other applies - each renders
+    #   the cues for the other mostly ineffective, except...
+    # - in_pre cannot deactivate on a literal-exemption qualifying line, so
+    #   pre tags can be used to contain cited text with (ineffective) 
s. + # - We mostly don't handle pre tag nesting, except balanced within a line + + in_pre = at_literal = 0 + got = [] + + for l in text.split('\n'): + + if not l: + got.append(l) + continue + + at_literal = (l.startswith(" ") or l.startswith(">")) + + if at_literal: + # In a cited or leading-whitespace literal. + got.append(_webtext_format_line(l, do_whitespace=1)) + + elif in_pre: + # In a pre. + x = unpresplit(l) + if len(x) > 1 and not presearch(x[-1]): + in_pre = 0 + got.append(_webtext_format_line(l, do_whitespace=1)) + + else: + # Non-literal case. + x = presplit(l) + if len(x) > 1 and not unpresearch(x[-1]): + # The line has a prevailing
.
+                in_pre = 1
+            got.append(_webtext_format_line(l))
+            
+    return "
\n".join(got) + +def _webtext_format_line(line, + do_html_quote=1, do_links=1, do_whitespace=0): + """ + Turn URLs into links, and html_quote everything else.""" + + if do_links: + urlmatches = list_search_hits(line, urlexp) + else: + urlmatches = [] + + got = [] + cursor = 0 + lenline = len(line) + while cursor < lenline: + + if urlmatches: + urlmatch = urlmatches.pop(0) + curstart, curend = urlmatch.start(), urlmatch.end() + else: + urlmatch = None + curstart = curend = lenline + + nonurl = line[cursor:curstart] + if do_html_quote: + nonurl = html_quote(nonurl) + if do_whitespace: + nonurl = nonurl.replace(" ", " ") + got.append(nonurl) + + if urlmatch: + url = line[curstart:curend] + got.append('%s' % (url, url)) + + cursor = curend + + return "".join(got) + +def list_search_hits(text, exprobj): + """Return a list of match objects for non-overlapping text hits.""" + cursor = 0 + got = [] + while 1: + hit = exprobj.search(text, cursor) + if hit: + cursor = hit.end() + got.append(hit) + else: + break + return got + +def test_webtext_format_line(): + wfl = _webtext_format_line + assert wfl("") == "" + assert wfl("x") == "x" + assert wfl(" ") == " " + assert wfl("& < >") == "& < >" + assert wfl(" ", do_whitespace=1) == " " + subj = "http://www.zope.org and so on" + assert wfl(subj) == ('' + 'http://www.zope.org' + ' and so on') + assert wfl(subj, do_whitespace=1) == ('' + 'http://www.zope.org' + ' and so on') + subj = " and so on" + assert wfl(subj) == ('<' + 'http://www.zope.org&value=1>' + ' and so on') + subj = "... http://www.zope.org&value=1" + assert wfl(subj) == ('... ' + 'http://www.zope.org&value=1') + + +# Match group 1 is citation prefix, group 2 is leading whitespace: +cite_prefixexp = re.compile('([\s>]*>)?([\s]*)') + +def cited_text(text, cite_prefixexp=cite_prefixexp): + """Quote text for use in literal citations. + + We prepend '>' to each line, splitting long lines (propagating + existing citation and leading whitespace) when necessary.""" + # Over elaborate stuff snarfed from my wiki commenting provisions. + + got = [] + for line in text.split('\n'): + pref = '> ' + if len(line) < 79: + got.append(pref + line) + continue + m = cite_prefixexp.match(line) + if m is None: + pref = '> %s' + else: + if m.group(1): + pref = pref + m.group(1) + line = line[m.end(1)+1:] + if m.end(1) > 60: + # Too deep quoting - collapse it: + pref = '> >> ' + lencut = 0 + pref = pref + '%s' + leading_space = m.group(2) + if leading_space: + pref = pref + leading_space + line = line[len(leading_space):] + lenpref = len(pref) + continuation_padding = '' + lastcurlen = 0 + while 1: + curlen = len(line) + lenpref + if curlen < 79 or (lastcurlen and lastcurlen <= curlen): + # Small enough - we're done - or not shrinking - bail out + if line: got.append((pref % continuation_padding) + line) + break + else: + lastcurlen = curlen + splitpoint = max(line[:78-lenpref].rfind(' '), + line[:78-lenpref].rfind('\t')) + if not splitpoint or splitpoint == -1: + if line.strip(): + got.append((pref % continuation_padding) + + line) + line = '' + else: + if line[:splitpoint].strip(): + got.append((pref % continuation_padding) + + line[:splitpoint]) + line = line[splitpoint+1:] + if not continuation_padding: + # Continuation lines are indented more than intial - just + # enough to line up past, eg, simple bullets. + continuation_padding = ' ' + return string.join(got, '\n') + +def sorted(l): + x = list(l[:]) + x.sort() + return x diff --git a/CMFCollector/version.txt b/CMFCollector/version.txt new file mode 100644 index 0000000..341ad3c --- /dev/null +++ b/CMFCollector/version.txt @@ -0,0 +1 @@ +0.93