Index: 0.11/announcerplugin/api.py =================================================================== --- 0.11/announcerplugin/api.py (revision 10515) +++ 0.11/announcerplugin/api.py (working copy) @@ -51,6 +51,17 @@ entirely. """ +class IAnnouncementPolicer(Interface): + """An IAnnouncementPolicer can remove subscribers, depending on + whatever criteria there might be for suppressing announcements. + + IAnnouncementPolicer instances are given a concrete list of subscriptions, + as determined by the active IAnnouncementSubscribers, along with the event + causing the announcement.""" + + def police_subscribers(self, event, subscriptions): + """Remove any unwanted subscribers from the subscriptions list dict.""" + class IAnnouncementFormatter(Interface): """Formatters are responsible for converting an event into a message appropriate for a given transport. @@ -174,15 +185,20 @@ identical to implementing entire panels. """ - def get_announcement_preference_boxes(req): + def get_announcement_preference_boxes(self, req): """Accepts a request object, and returns an iterable of (name, label) pairs; one for each box that the implementation can generate. + + If an empty label is returned (or label is set to None), + a template name prefs_announcer_boxlabel_XXX.html will be + formed by replacing XXX with the box name. The template is + then rendered, and the result used as the box label. If a single item is returned, be sure to 'yield' it instead of returning it.""" - def render_announcement_preference_box(req, box): + def render_announcement_preference_box(self, req, box): """Accepts a request object, and the name (as from the previous method) of the box that should be rendered. @@ -192,6 +208,43 @@ with the data member. """ +class IAnnouncementPreferenceSettingProvider(Interface): + """An IAnnouncementPreferenceSettingProvider returns one or more + settings to be put into the same box. The settings can be grouped + together in a shared box with settings from different implementators. + + Any component can always implement IAnnouncementPreferenceProvider to get + preferences from users in its own, separate 'box', of course. However, + considering there may be several components providing settings on similar + 'topics', it is overkill for the user interface to force each one to + display a separate 'box'. Boxes with single line content look silly. + """ + + def get_announcement_preference_setting_boxes(self, req): + """Accepts a request object, and returns an iterable of + box names; one for each box that the implementation + wants to contribute to. + + Box labels will be determined by rendering a separate template, + named prefs_announcer_boxlabel_XXX.html, with XXX replaced by + the box name. + + Also, a single box template with name prefs_announcer_box_XXX.html + will be used to "frame" the individual items in the box. + + If a single item is returned, be sure to 'yield' it instead of + returning it.""" + + def render_announcement_preference_setting_list(self, req, box): + """Accepts a request object, and the name (as from the previous + method) of the box that should be rendered. + + Returns a list of tuples of (name, template, data). The name will + be used to determine the order of entries in the box. + template is a template filename which will be rendered using + data to provide a snipped within the box representing some settings. + """ + class IAnnouncementAddressResolver(Interface): """Handles mapping Trac usernames to addresses for distributors to use.""" @@ -268,6 +321,7 @@ implements(IEnvironmentSetupParticipant) subscribers = ExtensionPoint(IAnnouncementSubscriber) + policers = ExtensionPoint(IAnnouncementPolicer) distributors = ExtensionPoint(IAnnouncementDistributor) # IEnvironmentSetupParticipant implementation @@ -358,6 +412,16 @@ ) ) ) + for pol in self.policers: + pol.police_subscribers(evt, subscriptions) + if self.policers: + self.log.debug( + "AnnouncementSystem will deliver to these subscriptions: " \ + "%s"%(', '.join(['[%s(%s) via %s]' % ((s[1] or s[3]),\ + s[2] and 'authenticated' or 'not authenticated',s[0])\ + for s in subscriptions]) + ) + ) packages = {} for transport, sid, authenticated, address in subscriptions: if transport not in packages: Index: 0.11/announcerplugin/userpref.py =================================================================== --- 0.11/announcerplugin/userpref.py (revision 0) +++ 0.11/announcerplugin/userpref.py (revision 0) @@ -0,0 +1,157 @@ +""" +Helper functions for user settings handling. + +Each setting is described by a dict, here called spec, with two mandatory keys: + + 'name': 'setting_name_for_use_as_form_field_and_db_key' + 'type': 'bool' or 'list' or 'string' + +A further key can be given to define a default value: + + 'default': whatever + +The default value, if given, should be a real bool, list, or string, +corresponding to the given type. + +Calling code can then use these three functions: + + user_setting() to retrieve individual settings + user_setting_inputs() to get a list of form input fields to use in + templates where the user can change settings + user_setting_update() to call upon POST of the form, to modify + settings according to user choice. +""" + +from genshi.builder import tag + +__all__ = [ 'user_setting', 'user_setting_inputs', 'user_setting_update' ] + +_false_values = (None, False, 'False', 'false', 0, '0', '') + +def user_setting(spec, env=None, sid=None, req=None): + """Retrieve one user setting, either based on given username (env, sid), + or by using session data from req. If the user has no preference set, + return a suitable default.""" + assert req or (env and sid) + assert not (req and sid) + if req: + value = req.session.get(spec['name']) + else: + db = env.get_db_cnx() + cursor = db.cursor() + cursor.execute(""" + SELECT value + FROM session_attribute + WHERE sid=%s + AND authenticated=1 + AND name=%s + """, (sid, spec['name'])) + value = cursor.fetchone() + if value: + value = value[0] + if value == None: + return _default(spec) + return _parse(spec, value) + +def user_setting_inputs(req, *specs): + """Return a list of elements, representing form fields + for setting a user preference, and for resetting it to its system + default value. + + The first element of the list renders as a checkbox if the user has + modified any of the settings, and is no longer tracking system defaults. + When the user then checks that box and submits, the settings are returned + to their system defaults. In the case that the settings are already + tracking system defaults, only a hidden field will be there. + + One additional element will be returned for each of the given specs, + each representing an input element for a single setting. + + The returned list elements are all genshi tags, suitable for direct + use in templates. + + The naming of the input fields will be such that user_settings_update(), + when given the same specs, will do the right thing.""" + + assert req + assert len(specs) > 0 + + nondefault = False + input = [] + for spec in specs: + name = spec['name'] + value = req.session.get(name) + if value != None: + value = _parse(spec, value) + nondefault = True + else: + value = _default(spec) + if spec['type'] == 'bool': + if value: + input.append(tag.input(type="checkbox", name=name, + checked='checked')) + else: + input.append(tag.input(type="checkbox", name=name)) + else: + if spec['type'] == 'list': + value = ','.join(value) + input.append(tag.input(type="text", name=name, value=value)) + if nondefault: + input.insert(0, tag.span( + tag.input(type="hidden", name='ND_%s' % specs[0]['name'], + value=nondefault), + tag.input(type="checkbox", name='RST_%s' % specs[0]['name']) + )) + else: + input.insert(0, tag.span( + tag.input(type="hidden", name='ND_%s' % specs[0]['name'], + value=nondefault), + )) + return input + +def user_setting_update(req, *specs): + """The partner to user_setting_inputs, this function looks at POSTed + form input in req.args, and acts accordingly, either modifying + user settings, or returning them to their system defaults.""" + + assert req + assert len(specs) > 0 + + if req.args.get('RST_%s' % specs[0]['name']): + for spec in specs: + try: + del req.session[spec['name']] + except: + pass + return + if 'False' == req.args.get('ND_%s' % specs[0]['name']): + for spec in specs: + value = _parse(spec, req.args.get(spec['name'])) + if value != _default(spec): + break + else: + return + for spec in specs: + value = _parse(spec, req.args.get(spec['name'])) + if spec['type'] == 'bool': + value = str(value not in _false_values) + elif spec['type'] == 'list': + value = ','.join(value) + req.session[spec['name']] = value + +def _parse(spec, value): + """convert a value from string to internal format, depending on type.""" + if spec['type'] == 'bool': + value = value not in _false_values + elif spec['type'] == 'list': + value = [ x.strip() for x in value.split(',') ] + return value + +def _default(spec): + if 'default' in spec: + return spec['default'] + if spec['type'] == 'bool': + return False + if spec['type'] == 'list': + return [] + return '' Index: 0.11/announcerplugin/pref.py =================================================================== --- 0.11/announcerplugin/pref.py (revision 10515) +++ 0.11/announcerplugin/pref.py (working copy) @@ -4,18 +4,15 @@ from trac.web import IRequestHandler from pkg_resources import resource_filename from announcerplugin.api import IAnnouncementPreferenceProvider +from announcerplugin.api import IAnnouncementPreferenceSettingProvider from trac.web.chrome import Chrome -def truth(v): - if v in (False, 'False', 'false', 0, '0', ''): - return None - return True - class AnnouncerPreferences(Component): - implements(IPreferencePanelProvider, ITemplateProvider) + implements(IPreferencePanelProvider, ITemplateProvider, IAnnouncementPreferenceProvider) preference_boxes = ExtensionPoint(IAnnouncementPreferenceProvider) - + preference_settings = ExtensionPoint(IAnnouncementPreferenceSettingProvider) + def get_htdocs_dirs(self): return [('announcer', resource_filename(__name__, 'htdocs'))] @@ -26,23 +23,95 @@ def get_preference_panels(self, req): yield ('announcer', 'Announcements') - def _get_boxes(self, req): + def _get_boxes(self, chrome, req): for pr in self.preference_boxes: boxes = pr.get_announcement_preference_boxes(req) - boxdata = {} - if boxes: - for boxname, boxlabel in boxes: - yield ((boxname, boxlabel) + - pr.render_announcement_preference_box(req, boxname)) + if not boxes: + continue + for boxname, boxlabel in boxes: + if not boxlabel: + boxlabel = chrome.render_template( + req, 'prefs_announcer_boxlabel_%s.html' % boxname, + {}, content_type='text/html', fragment=True + ) + template, data = \ + pr.render_announcement_preference_box(req, boxname) + if template: + yield (boxname, boxlabel, template, data) def render_preference_panel(self, req, panel, path_info=None): + chrome = Chrome(self.env) + parts = {} + for name, label, template, data in self._get_boxes(chrome, req): + if name not in parts: + parts[name] = [] + parts[name].append((name, label, template, data)) streams = [] - chrome = Chrome(self.env) - for name, label, template, data in self._get_boxes(req): - streams.append((label, chrome.render_template( - req, template, data, content_type='text/html', fragment=True - ))) + if parts: + sorted = self._sort_parts(parts, 'pref.order') + for part in sorted: + for name, label, template, data in part: + streams.append((label, chrome.render_template( + req, template, data, + content_type='text/html', fragment=True + ))) add_stylesheet(req, 'announcer/css/announcer_prefs.css') return 'prefs_announcer.html', {"boxes": streams} - + # IAnnouncementPreferenceProvider implementation + + def get_announcement_preference_boxes(self, req): + allboxes = set() + for setter in self.preference_settings: + for box in setter.get_announcement_preference_setting_boxes(req): + allboxes.add(box) + for box in allboxes: + yield box, None + + def render_announcement_preference_box(self, req, box): + chrome = Chrome(self.env) + parts = {} + for setter in self.preference_settings: + for part, template, data in \ + setter.render_announcement_preference_setting_list(req, box): + if part not in parts: + parts[part] = [] + parts[part].append((template, data)) + if not parts: + return None, None + sorted = self._sort_parts(parts, 'pref.order.%s' % box) + streams = [] + for part in sorted: + for template, data in part: + streams.append(chrome.render_template( + req, template, data, content_type='text/html', fragment=True + )) + return 'prefs_announcer_box_%s.html' % box, { "settings": streams } + + def _sort_parts(self, parts, configitem): + """Given a dict parts, sort by dict keys, returning the values in + the preferred order. Sorting is alphabetic by default. + + When configitem is found in trac.ini, it is interpreted a list + of keys specifiying sort order. keys of parts not on that list, + are sorted behind those specified. + + NOTE: the incoming dict is completely depopulated by this function.""" + sorted = [] + order = self.config.get('announcer', configitem, None) + if order: + order = [ x.strip() for x in order.split(',') ] + else: + order = parts.keys() + order.sort() + for part in order: + if part in parts: + sorted.append(parts[part]) + del parts[part] + if parts: + order = parts.keys() + order.sort() + for part in order: + sorted.append(parts[part]) + del parts[part] + return sorted Index: 0.11/announcerplugin/producers/ticket.py =================================================================== --- 0.11/announcerplugin/producers/ticket.py (revision 10515) +++ 0.11/announcerplugin/producers/ticket.py (working copy) @@ -1,5 +1,4 @@ from trac.core import * -from trac.config import BoolOption from trac.ticket.api import ITicketChangeListener from announcerplugin.api import AnnouncementSystem, AnnouncementEvent @@ -32,12 +31,6 @@ class TicketChangeProducer(Component): implements(ITicketChangeListener) - ignore_cc_changes = BoolOption('announcer', 'ignore_cc_changes', 'false', - doc="""When true, the system will not send out announcement events if - the only field that was changed was CC. A change to the CC field that - happens at the same as another field will still result in an event - being created.""") - def __init__(self, *args, **kwargs): pass @@ -50,9 +43,6 @@ ) def ticket_changed(self, ticket, comment, author, old_values): - if old_values.keys() == ['cc'] and not comment and \ - self.ignore_cc_changes: - return announcer = AnnouncementSystem(ticket.env) announcer.send( TicketChangeEvent("ticket", "changed", ticket, Index: 0.11/announcerplugin/policer/nevernotifyupdater.py =================================================================== --- 0.11/announcerplugin/policer/nevernotifyupdater.py (revision 0) +++ 0.11/announcerplugin/policer/nevernotifyupdater.py (revision 0) @@ -0,0 +1,44 @@ +from trac.core import Component, implements +from trac.config import BoolOption + +from announcerplugin.api import IAnnouncementPolicer +from announcerplugin.api import IAnnouncementPreferenceSettingProvider +from announcerplugin.userpref import * + +class AnnouncementPolicerNeverNotifyUpdater(Component): + """This policer removes wiki or ticket change authors from + the list of subscriptions. It can be used as a replacement for + the standard notification system NeverNotifyUpdaterPlugin.""" + + implements(IAnnouncementPolicer, IAnnouncementPreferenceSettingProvider) + + ini_never_notify_updater = BoolOption( + 'announcer', 'policer.never_notify_updater', 'true', + doc="""Suppress all announcements to the change author, by default.""") + + def __init__(self): + self.never_notify_updater = { + 'name': 'announcer_policer_never_notify_updater', + 'type': 'bool', + 'default': self.ini_never_notify_updater, + } + + def police_subscribers(self, event, subscriptions): + if user_setting(self.never_notify_updater, + sid=event.author, env=self.env): + for s in subscriptions.copy(): + if (s[1] == event.author): + subscriptions.discard(s) + + def get_announcement_preference_setting_boxes(self, req): + yield 'policer_suppressions' + + def render_announcement_preference_setting_list(self, req, box): + if box != 'policer_suppressions': + return + if req.method == 'POST': + user_setting_update(req, self.never_notify_updater) + yield ('never_notify_updater', + 'prefs_announcer_policer_never_notify_updater.html', { + 'input': user_setting_inputs(req, self.never_notify_updater), + }) Index: 0.11/announcerplugin/policer/has_view_permission.py =================================================================== --- 0.11/announcerplugin/policer/has_view_permission.py (revision 0) +++ 0.11/announcerplugin/policer/has_view_permission.py (revision 0) @@ -0,0 +1,27 @@ +from trac.core import Component, implements +from trac.perm import PermissionSystem + +from announcerplugin.api import IAnnouncementPolicer + +class AnnouncementPolicerHasViewPermission(Component): + """This policer removes subscribers from the list of subscriptions + when they do not have the requisite Trac permission for the realm. + + Realm ticket requires TICKET_VIEW + Realm wiki requires WIKI_VIEW + """ + + implements(IAnnouncementPolicer) + + def police_subscribers(self, event, subscriptions): + required = None + if event.realm == 'ticket': + required = 'TICKET_VIEW' + elif event.realm == 'wiki': + required = 'WIKI_VIEW' + if not required: + return + perm = PermissionSystem(self.env) + for s in subscriptions.copy(): + if not perm.check_permission(required, username=s[1]): + subscriptions.discard(s) Index: 0.11/announcerplugin/policer/__init__.py =================================================================== Index: 0.11/announcerplugin/policer/ignore_cc_changes.py =================================================================== --- 0.11/announcerplugin/policer/ignore_cc_changes.py (revision 0) +++ 0.11/announcerplugin/policer/ignore_cc_changes.py (revision 0) @@ -0,0 +1,47 @@ +from trac.core import Component, implements +from trac.config import BoolOption + +from announcerplugin.api import IAnnouncementPolicer +from announcerplugin.api import IAnnouncementPreferenceSettingProvider +from announcerplugin.userpref import * + +class AnnouncementPolicerIgnoreCcChanges(Component): + """This policer removes subscribers from the list of subscriptions + when only the CC list of the ticket changed, and the subscriber + prefers it this way.""" + + implements(IAnnouncementPolicer, IAnnouncementPreferenceSettingProvider) + + ini_ignore_cc_changes = BoolOption( + 'announcer', 'ignore_cc_changes', 'false', + doc="""When true, the system will not send out announcement events if + the only field that was changed was CC. A change to the CC field that + happens at the same as another field will still result in an event + being created.""") + + def __init__(self): + self.ignore_cc_changes = { + 'name': 'announcer_policer_ignore_cc_changes', + 'type': 'bool', + 'default': self.ini_ignore_cc_changes, + } + + def police_subscribers(self, event, subscriptions): + if event.realm == 'ticket' and event.category == 'changed' and \ + event.changes.keys() == [ 'cc' ] and not event.comment: + for s in subscriptions.copy(): + if user_setting(self.ignore_cc_changes, sid=s[1], env=self.env): + subscriptions.discard(s) + + def get_announcement_preference_setting_boxes(self, req): + yield 'policer_suppressions' + + def render_announcement_preference_setting_list(self, req, box): + if box != 'policer_suppressions': + return + if req.method == 'POST': + user_setting_update(req, self.ignore_cc_changes) + yield ('ignore_cc_changes', + 'prefs_announcer_policer_ignore_cc_changes.html', { + 'input': user_setting_inputs(req, self.ignore_cc_changes), + }) Index: 0.11/announcerplugin/policer/nevernotifyatall.py =================================================================== --- 0.11/announcerplugin/policer/nevernotifyatall.py (revision 0) +++ 0.11/announcerplugin/policer/nevernotifyatall.py (revision 0) @@ -0,0 +1,36 @@ +from trac.core import Component, implements + +from announcerplugin.api import IAnnouncementPolicer +from announcerplugin.api import IAnnouncementPreferenceSettingProvider +from announcerplugin.userpref import * + +class AnnouncementPolicerNeverNotifyAtAll(Component): + """This policer removes a subscriber from all announcements, + if they have set the corresponding user preference.""" + + implements(IAnnouncementPolicer, IAnnouncementPreferenceSettingProvider) + + def __init__(self): + self.never_notify_at_all = { + 'name': 'announcer_policer_never_notify_at_all', + 'type': 'bool', + 'default': False, + } + + def police_subscribers(self, event, subscriptions): + for s in subscriptions.copy(): + if user_setting(self.never_notify_at_all, sid=s[1], env=self.env): + subscriptions.discard(s) + + def get_announcement_preference_setting_boxes(self, req): + yield 'policer_suppressions' + + def render_announcement_preference_setting_list(self, req, box): + if box != 'policer_suppressions': + return + if req.method == 'POST': + user_setting_update(req, self.never_notify_at_all) + yield ('never_notify_at_all', + 'prefs_announcer_policer_never_notify_at_all.html', { + 'input': user_setting_inputs(req, self.never_notify_at_all), + }) Index: 0.11/announcerplugin/policer/ignore_status_only.py =================================================================== --- 0.11/announcerplugin/policer/ignore_status_only.py (revision 0) +++ 0.11/announcerplugin/policer/ignore_status_only.py (revision 0) @@ -0,0 +1,64 @@ +from trac.core import Component, implements +from trac.config import BoolOption, ListOption + +from announcerplugin.api import IAnnouncementPolicer +from announcerplugin.api import IAnnouncementPreferenceSettingProvider +from announcerplugin.userpref import * + +class AnnouncementPolicerIgnoreStatusOnly(Component): + """This policer removes subscribers from the list of subscriptions + when only the status of the ticket changed, and the subscriber + prefers it this way.""" + + implements(IAnnouncementPolicer, IAnnouncementPreferenceSettingProvider) + + ini_ignore_status_only = BoolOption( + 'announcer', 'ignore_status_only', 'false', + doc="""When set, the system will not send out announcement events if + the only field that was changed was status. A change to the status + field that happens at the same as another field will still result + in an event being created.""") + + ini_no_ignore_status = ListOption( + 'announcer', 'no_ignore_status', None, + doc="""When set in addition to setting ignore_status_only to true, + this list option lets you configure status values for which + announcements should still be sent.""") + + def __init__(self): + self.ignore_status_only = { + 'name': 'announcer_policer_ignore_status_only', + 'type': 'bool', + 'default': self.ini_ignore_status_only, + } + self.no_ignore_status = { + 'name': 'announcer_policer_no_ignore_status', + 'type': 'list', + 'default': self.ini_no_ignore_status, + } + + def police_subscribers(self, event, subscriptions): + if event.realm == 'ticket' and event.category == 'changed' and \ + event.changes.keys() == [ 'status' ] and not event.comment: + status = event.target['status'] + for s in subscriptions.copy(): + if user_setting(self.ignore_status_only, + sid=s[1], env=self.env) and \ + status not in user_setting(self.no_ignore_status, + sid=s[1], env=self.env): + subscriptions.discard(s) + + def get_announcement_preference_setting_boxes(self, req): + yield 'policer_suppressions' + + def render_announcement_preference_setting_list(self, req, box): + if box != 'policer_suppressions': + return + if req.method == 'POST': + user_setting_update(req, + self.ignore_status_only, self.no_ignore_status) + yield ('ignore_status_only', + 'prefs_announcer_policer_ignore_status_only.html', { + 'input': user_setting_inputs(req, + self.ignore_status_only, self.no_ignore_status), + }) Index: 0.11/announcerplugin/templates/prefs_announcer_policer_never_notify_updater.html =================================================================== --- 0.11/announcerplugin/templates/prefs_announcer_policer_never_notify_updater.html (revision 0) +++ 0.11/announcerplugin/templates/prefs_announcer_policer_never_notify_updater.html (revision 0) @@ -0,0 +1,7 @@ +
+ The following options suppress announcements to you, + when certain conditions are met, even when other announcement + options determine that you should receive an announcement. +
+ Do not send announcements... +
+ For options you changed, a checkbox appears at the right margin. + Check that box and submit the form, to return the option to tracking + its system defaults. +
+ Index: 0.11/announcerplugin/templates/prefs_announcer_boxlabel_policer_suppressions.html =================================================================== --- 0.11/announcerplugin/templates/prefs_announcer_boxlabel_policer_suppressions.html (revision 0) +++ 0.11/announcerplugin/templates/prefs_announcer_boxlabel_policer_suppressions.html (revision 0) @@ -0,0 +1,3 @@ +