trac-announcer/0000755000175500017550000000000012627703146013537 5ustar debacledebacletrac-announcer/trunk/0000755000175500017550000000000012627703105014675 5ustar debacledebacletrac-announcer/trunk/changelog0000644000175500017550000000617012051274703016551 0ustar debacledebacleAuthors: Steven Hanson, Robert Corsaro (since 17-Jul-2008) |r4028| Maintainer: Steffen Hoffmann announcer-1.0 (not yet released) - branch 0.11 again resolved issues * #5774: No such table: subscriptions * #6452: No notification sent to previous owner when a ticket is reassigned * #7759: Update Account Manager Announcer for new subscription system * #7791: charset="us-ascii" in text part of html announcement * #7834: Emails are attempted to be sent even when there are no recipients * #7974: UnboundLocalError: local variable 'tid' referenced before assignment with announcements from AccountManager plugin * #7975: Subscription incorrectly uses CURRENT_TIMESTAMP on BIGINT column * #7976: filter_exception_realms is ignored in DefaultPermissionFilter * #7977: AccountManager plugin does not work * #8062: Database upgrade silently fails when no i18n is available * #8458: PrivateTicketsPlugin is incompatible with TracAnnouncer plugin * #8577: NameError: when trying to delete a ticket * #8620: Small grammatical fix in the ticket_email_mimic.html email * #8677: "prefix" not defined when sending email * #9206: AttributeError: 'XmppPreferencePanel' has no 'xmpp_format_setting' * #9522: Announcer Plugin doesn't work * #9742: AttributeError on db.rollback() in environment_needs_upgrade * #10083: HTML notifications for attachments do not include the author name * #10384: Properly cast `authenticated` field for subscription_attribute table * #10584: Attachment description field not formatted to HTML * #10620: UndefinedError when navigating to the Announcements preference panel * fix argument assignment issues in `SubscriptionAttribute.add` new features * #7763: Separate subscribers * #10483: Move license to COPYING file announcer-0.12.1 (14-Sep-2010) |r8853| by Robert Corsaro announcer-0.12-p2 (05-Jun-2010) |r8087| by Robert Corsaro on behalf of Steffen Hoffmann announcer-0.12 from '0.11' (13-Feb-10) |r7569| by Robert Corsaro announcer-0.11.1.9 from '1.0-a1' (26-Sep-2010) |r9147| - branch 0.11.2dev resolved issues * #8310: ticket notifications do not thread properly in mail clients * #8620: Small grammatical fix in the ticket_email_mimic.html email * #9021: show correct default in prefs for ticket email format announcer-0.11.1 from '1.0-a1' (26-Sep-2010) |r9146| - branch 0.11.1 resolved issues * #8310: ticket notifications do not thread properly in mail clients * #8620: Small grammatical fix in the ticket_email_mimic.html email * #9021: show correct default in prefs for ticket email format * #9616: AnnouncementSystem fail on ticket change with set_message_id = false announcer-0.11 from '0.2' (26-Sep-2010) |r9145| by Robert Corsaro announcer-0.11 (13-Jan-2010) from |r7548| to |r7569| by Robert Corsaro announcer-1.0-a1 (26-Nov-2009) |r7188| by Robert Corsaro updated from '0.2' (23-Aug-2010) |r8410| by Steffen Hoffmann with declaration to shift from 0.11 development to 0.12 announcer-0.2 (20-Jan-2008) by Robert Corsaro - branch 0.11 due to completed "basic functionality for tickets and wiki notifications" announcer-0.1 (10-Jan-2008) - initial release by Stephen Hansen trac-announcer/trunk/.gitignore0000644000175500017550000000014012057772533016670 0ustar debacledebacle*.egg-info *.patch *.pyc *.mo .stgit-* build/ dist/ patches-*/ .settings .project .pydevproject trac-announcer/trunk/COPYING0000644000175500017550000000323512037604531015731 0ustar debacledebacleCopyright (c) 2008, Steven Hanson Copyright (c) 2009, Robert Corsaro All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the main authors nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. This software now contains voluntary contributions made by many individuals. For the exact contribution history, see the revision history and logs, available at https://trac-hacks.org/log/announcerplugin/. trac-announcer/trunk/README0000644000175500017550000000241012037604531015550 0ustar debacledebacleTracAnnouncer is a flexible trac notifications drop in replacement that is very flexible and customizable. There is a focus on users being able to configure what notifications they would like and relieving the sysadmin from having to manage notifications. HACKING NOTES: The most confusing part of announce is that subscriptions have three related fields, that are not intuitive. (sid, authenticated, address). There is a very good reason for this. First, Trac users are identified throughout the system with the sid, authenticated pair. Anonymous user are allowed to set their sid to anything that they would like via the advanced preferences in the preferences section of the site. The can set their sid to the same sid as some authenticated user. The way we tell the difference between the two identical sids is the authenticated flag. There is a third type of user when we are talking about announcements. Users can enter any email address in some ticket fields, like CC. These subscriptions are not associated with any sid. So the sid and authenticated in the subscription would be None, None. These users should be treated with all default configuration and permissions checked against anonymous. I hope this helps, because it took me a while to wrap my head around :P trac-announcer/trunk/announcer/0000755000175500017550000000000012607775473016704 5ustar debacledebacletrac-announcer/trunk/announcer/model.py0000644000175500017550000003764712557162602020363 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # # NOTE: users are uniquely identified by (sid, authenticated). An anonymous # user is allowed to use an sid that they desire, even one that is already # used by an authenticated user. When a user enters an sid into a field, like # ticket owner, they are refering to an authenticated user. All permission # checking for unauthenticated users should be done against the 'anonymous' # user. from datetime import datetime from trac.util.datefmt import utc from announcer.compat import to_utimestamp __all__ = ['Subscription', 'SubscriptionAttribute'] class Subscription(object): fields = ('id', 'sid', 'authenticated', 'distributor', 'format', 'priority', 'adverb', 'class') def __init__(self, env): self.env = env self.values = {} def __getitem__(self, name): if name not in self.fields: raise KeyError(name) return self.values.get(name) def __setitem__(self, name, value): if name not in self.fields: raise KeyError(name) self.values[name] = value @classmethod def add(cls, env, subscription, db=None): """ID and priority get overwritten.""" @env.with_transaction(db) def do_insert(db): cursor = db.cursor() priority = len(cls.find_by_sid_and_distributor(env, subscription['sid'], subscription['authenticated'], subscription['distributor'], db)) + 1 now = to_utimestamp(datetime.now(utc)) cursor.execute(""" INSERT INTO subscription (time,changetime,sid,authenticated, distributor,format,priority,adverb,class) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) """, (now, now, subscription['sid'], subscription['authenticated'], subscription['distributor'], subscription['format'], int(priority), subscription['adverb'], subscription['class']) ) @classmethod def delete(cls, env, rule_id, db=None): @env.with_transaction(db) def do_delete(db): cursor = db.cursor() cursor.execute(""" SELECT sid,authenticated,distributor FROM subscription WHERE id=%s """, (rule_id,)) sid, authenticated, distributor = cursor.fetchone() cursor.execute(""" DELETE FROM subscription WHERE id=%s """, (rule_id,)) i = 1 for s in cls.find_by_sid_and_distributor(env, sid, authenticated, distributor, db): s['priority'] = i s._update_priority(db) i += 1 @classmethod def move(cls, env, rule_id, priority, db=None): @env.with_transaction(db) def do_delete(db): cursor = db.cursor() cursor.execute(""" SELECT sid,authenticated,distributor FROM subscription WHERE id=%s """, (rule_id,)) sid, authenticated, distributor = cursor.fetchone() if priority > len(cls.find_by_sid_and_distributor(env, sid, authenticated, distributor, db)): return i = 1 for s in cls.find_by_sid_and_distributor(env, sid, authenticated, distributor, db): if int(s['id']) == int(rule_id): s['priority'] = priority s._update_priority(db) i -= 1 elif i == priority: i += 1 s['priority'] = i s._update_priority(db) else: s['priority'] = i s._update_priority(db) i += 1 @classmethod def update_format_by_distributor_and_sid(cls, env, distributor, sid, authenticated, format, db=None): @env.with_transaction(db) def do_update(db): cursor = db.cursor() cursor.execute(""" UPDATE subscription SET format=%s WHERE distributor=%s AND sid=%s AND authenticated=%s """, (format, distributor, sid, int(authenticated))) @classmethod def find_by_sid_and_distributor(cls, env, sid, authenticated, distributor, db=None): subs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() cursor.execute(""" SELECT id,sid,authenticated,distributor, format,priority,adverb,class FROM subscription WHERE sid=%s AND authenticated=%s AND distributor=%s ORDER BY priority """, (sid, int(authenticated), distributor)) for i in cursor.fetchall(): sub = Subscription(env) sub['id'] = i[0] sub['sid'] = i[1] sub['authenticated'] = i[2] sub['distributor'] = i[3] sub['format'] = i[4] sub['priority'] = int(i[5]) sub['adverb'] = i[6] sub['class'] = i[7] subs.append(sub) return subs @classmethod def find_by_sids_and_class(cls, env, uids, klass, db=None): """uids should be a collection to tuples (sid, auth)""" if not uids: return [] subs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() for sid, authenticated in uids: cursor.execute(""" SELECT id,sid,authenticated,distributor, format,priority,adverb,class FROM subscription WHERE class=%s AND sid=%s AND authenticated=%s """, (klass, sid, int(authenticated))) for i in cursor.fetchall(): sub = Subscription(env) sub['id'] = i[0] sub['sid'] = i[1] sub['authenticated'] = i[2] sub['distributor'] = i[3] sub['format'] = i[4] sub['priority'] = int(i[5]) sub['adverb'] = i[6] sub['class'] = i[7] subs.append(sub) return subs @classmethod def find_by_class(cls, env, klass, db=None): subs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() cursor.execute(""" SELECT id,sid,authenticated,distributor, format,priority,adverb,class FROM subscription WHERE class=%s """, (klass,)) for i in cursor.fetchall(): sub = Subscription(env) sub['id'] = i[0] sub['sid'] = i[1] sub['authenticated'] = i[2] sub['distributor'] = i[3] sub['format'] = i[4] sub['priority'] = int(i[5]) sub['adverb'] = i[6] sub['class'] = i[7] subs.append(sub) return subs def subscription_tuple(self): return ( self.values['class'], self.values['distributor'], self.values['sid'], self.values['authenticated'], None, self.values['format'], int(self.values['priority']), self.values['adverb'] ) def _update_priority(self, db=None): @self.env.with_transaction(db) def do_update(db): cursor = db.cursor() now = to_utimestamp(datetime.now(utc)) cursor.execute(""" UPDATE subscription SET changetime=%s, priority=%s WHERE id=%s """, (now, int(self.values['priority']), self.values['id'])) class SubscriptionAttribute(object): fields = ('id', 'sid', 'authenticated', 'class', 'realm', 'target') def __init__(self, env): self.env = env self.values = {} def __getitem__(self, name): if name not in self.fields: raise KeyError(name) return self.values.get(name) def __setitem__(self, name, value): if name not in self.fields: raise KeyError(name) self.values[name] = value @classmethod def add(cls, env, sid, authenticated, klass, realm, attributes, db=None): """id and priority overwritten.""" @env.with_transaction(db) def do_insert(db): cursor = db.cursor() for a in attributes: cursor.execute(""" INSERT INTO subscription_attribute (sid,authenticated,class,realm,target) VALUES (%s,%s,%s,%s,%s) """, (sid, int(authenticated), klass, realm, a)) @classmethod def delete(cls, env, attribute_id, db=None): @env.with_transaction(db) def do_delete(db): cursor = db.cursor() cursor.execute(""" DELETE FROM subscription_attribute WHERE id=%s """, (attribute_id,)) @classmethod def delete_by_sid_and_class(cls, env, sid, authenticated, klass, db=None): @env.with_transaction(db) def do_delete(db): cursor = db.cursor() cursor.execute(""" DELETE FROM subscription_attribute WHERE sid=%s AND authenticated=%s AND class=%s """, (sid, int(authenticated), klass)) @classmethod def delete_by_sid_class_and_target(cls, env, sid, authenticated, klass, target, db=None): @env.with_transaction(db) def do_delete(db): cursor = db.cursor() cursor.execute(""" DELETE FROM subscription_attribute WHERE sid=%s AND authenticated=%s AND class=%s AND target=%s """, (sid, int(authenticated), klass, target)) @classmethod def delete_by_class_realm_and_target(cls, env, klass, realm, target, db=None): @env.with_transaction(db) def do_delete(db): cursor = db.cursor() cursor.execute(""" DELETE FROM subscription_attribute WHERE realm=%s AND class=%s AND target=%s """, (realm, klass, target)) @classmethod def find_by_sid_and_class(cls, env, sid, authenticated, klass, db=None): attrs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() cursor.execute(""" SELECT id,sid,authenticated,class,realm,target FROM subscription_attribute WHERE sid=%s AND authenticated=%s AND class=%s ORDER BY target """, (sid, int(authenticated), klass)) for i in cursor.fetchall(): attr = SubscriptionAttribute(env) attr['id'] = i[0] attr['sid'] = i[1] attr['authenticated'] = i[2] attr['class'] = i[3] attr['realm'] = i[4] attr['target'] = i[5] attrs.append(attr) return attrs @classmethod def find_by_sid_class_and_target(cls, env, sid, authenticated, klass, target, db=None): attrs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() cursor.execute(""" SELECT id,sid,authenticated,class,realm,target FROM subscription_attribute WHERE sid=%s AND authenticated=%s AND class=%s AND target=%s ORDER BY target """, (sid, int(authenticated), klass, target)) for i in cursor.fetchall(): attr = SubscriptionAttribute(env) attr['id'] = i[0] attr['sid'] = i[1] attr['authenticated'] = i[2] attr['class'] = i[3] attr['realm'] = i[4] attr['target'] = i[5] attrs.append(attr) return attrs @classmethod def find_by_sid_class_realm_and_target(cls, env, sid, authenticated, klass, realm, target, db=None): attrs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() cursor.execute(""" SELECT id,sid,authenticated,class,realm,target FROM subscription_attribute WHERE sid=%s AND authenticated=%s AND class=%s AND realm=%s AND target=%s ORDER BY target """, (sid, int(authenticated), klass, realm, target)) for i in cursor.fetchall(): attr = SubscriptionAttribute(env) attr['id'] = i[0] attr['sid'] = i[1] attr['authenticated'] = i[2] attr['class'] = i[3] attr['realm'] = i[4] attr['target'] = i[5] attrs.append(attr) return attrs @classmethod def find_by_class_realm_and_target(cls, env, klass, realm, target, db=None): attrs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() cursor.execute(""" SELECT id,sid,authenticated,class,realm,target FROM subscription_attribute WHERE class=%s AND realm=%s AND target=%s """, (klass, realm, target)) for i in cursor.fetchall(): attr = SubscriptionAttribute(env) attr['id'] = i[0] attr['sid'] = i[1] attr['authenticated'] = i[2] attr['class'] = i[3] attr['realm'] = i[4] attr['target'] = i[5] attrs.append(attr) return attrs @classmethod def find_by_class_and_realm(cls, env, klass, realm, db=None): attrs = [] @env.with_transaction(db) def do_select(db): cursor = db.cursor() cursor.execute(""" SELECT id,sid,authenticated,class,realm,target FROM subscription_attribute WHERE class=%s AND realm=%s """, (klass, realm)) for i in cursor.fetchall(): attr = SubscriptionAttribute(env) attr['id'] = i[0] attr['sid'] = i[1] attr['authenticated'] = i[2] attr['class'] = i[3] attr['realm'] = i[4] attr['target'] = i[5] attrs.append(attr) return attrs @classmethod def change_target(cls, env, klass, realm, target, new_target): env.db_transaction(""" UPDATE subscription_attribute SET target=%s WHERE class=%s AND realm=%s AND target=%s """, (new_target, klass, realm, target)) trac-announcer/trunk/announcer/opt/0000755000175500017550000000000012607775473017506 5ustar debacledebacletrac-announcer/trunk/announcer/opt/acct_mgr/0000755000175500017550000000000012350245165021246 5ustar debacledebacletrac-announcer/trunk/announcer/opt/acct_mgr/__init__.py0000644000175500017550000000000011337147243023347 0ustar debacledebacletrac-announcer/trunk/announcer/opt/acct_mgr/announce.py0000644000175500017550000001742012350245165023432 0ustar debacledebacle#-*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2010,2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # from genshi.template import NewTextTemplate, TemplateLoader from trac.config import BoolOption, ListOption from trac.core import Component, implements from trac.perm import PermissionCache from trac.web.chrome import Chrome from announcer.api import AnnouncementEvent, AnnouncementSystem from announcer.api import IAnnouncementDefaultSubscriber from announcer.api import IAnnouncementFormatter, IAnnouncementSubscriber from announcer.api import IAnnouncementSubscriptionFilter from announcer.api import _ from announcer.distributors.mail import IAnnouncementEmailDecorator from announcer.model import Subscription from announcer.util.mail import set_header, next_decorator from acct_mgr.api import IAccountChangeListener class AccountChangeEvent(AnnouncementEvent): def __init__(self, category, username, password=None, token=None): AnnouncementEvent.__init__(self, 'acct_mgr', category, None) self.username = username self.password = password self.token = token class AccountManagerAnnouncement(Component): """Send announcements on account changes.""" implements( IAccountChangeListener, # from AccountManagerPlugin IAnnouncementEmailDecorator, IAnnouncementFormatter, IAnnouncementDefaultSubscriber, IAnnouncementSubscriber, IAnnouncementSubscriptionFilter ) categories = ('created', 'change', 'delete', 'reset', 'verify', 'approve') default_on = BoolOption("announcer", "always_notify_user_admins", True, """Sent user account notification to admin users per default, so they may opt-out individually instead of requiring everyone to opt-in. """) default_distributor = ListOption("announcer", "always_notify_user_admins_distributor", "email", doc="""Comma-separated list of distributors to send the message to by default. ex. email, xmpp """) # IAccountChangeListener methods def user_created(self, username, password): self._notify('created', username, password) def user_password_changed(self, username, password): self._notify('change', username, password) def user_deleted(self, username): self._notify('delete', username) def user_password_reset(self, username, email, password): """User password has been reset. Note, that this is no longer final, and the old password could still be recovered before first successful login with the new password. """ self._notify('reset', username, password) def user_email_verification_requested(self, username, token): self._notify('verify', username, token=token) def user_registration_approval_required(self, username): self._notify('approve', username) # IAnnouncementDefaultSubscriber method def default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') # IAnnouncementSubscriber methods def subscriptions(self, event): if event.realm == 'acct_mgr': for subscriber in self._get_membership(event): self.log.debug("AccountManagerAnnouncement added '%s " \ "(%s)'", subscriber[1], subscriber[2]) yield subscriber def matches(self, event): if event.realm != 'acct_mgr': return # DEVEL: Need a better plan, because the real issue is a missing # user_* method on AccountManager changes. if not event.category in self.categories: return klass = self.__class__.__name__ for i in Subscription.find_by_class(self.env, klass): yield i.subscription_tuple() def description(self): return _( """notify me on user account changes (`ACCTMGR_USER_ADMIN` required)""") def requires_authentication(self): # Unauthenticated users must never see that. return True # IAnnouncementSubscriptionFilter method def filter_subscriptions(self, event, subscriptions): action = 'ACCTMGR_USER_ADMIN' for subscription in subscriptions: if event.realm != 'acct_mgr': yield subscription continue # Make acct_mgr subscriptions available only for admins. sid, auth = subscription[1:3] # PermissionCache already takes care of sid = None if not auth: sid = 'anonymous' perm = PermissionCache(self.env, sid) if perm.has_permission(action): yield subscription else: self.log.debug( "Filtering %s because of %s rule" % (sid, self.__class__.__name__) ) # IAnnouncementFormatter methods def styles(self, transport, realm): if realm == 'acct_mgr': yield 'text/plain' def alternative_style_for(self, transport, realm, style): if realm == 'acct_mgr' and style != 'text/plain': return 'text/plain' def format(self, transport, realm, style, event): if realm == 'acct_mgr' and style == 'text/plain': return self._format_plaintext(event) # IAnnouncementEmailDecorator method def decorate_message(self, event, message, decorates=None): if event.realm == "acct_mgr": prjname = self.env.project_name subject = '[%s] %s: %s' % (prjname, event.category, event.username) set_header(message, 'Subject', subject) return next_decorator(event, message, decorates) # Private methods def _notify(self, category, username, password=None, token=None): try: announcer = AnnouncementSystem(self.env) announcer.send( AccountChangeEvent(category, username, password, token) ) except Exception, e: self.log.exception("Failure creating announcement for account " "event %s: %s", username, category) def _format_plaintext(self, event): acct_templates = { 'created': 'acct_mgr_user_change_plaintext.txt', 'change': 'acct_mgr_user_change_plaintext.txt', 'delete': 'acct_mgr_user_change_plaintext.txt', 'reset': 'acct_mgr_reset_password_plaintext.txt', 'verify': 'acct_mgr_verify_plaintext.txt', 'approve': 'acct_mgr_approve_plaintext.txt' } data = { 'account': { 'action': event.category, 'username': event.username, 'password': event.password, 'token': event.token }, 'project': { 'name': self.env.project_name, 'url': self.env.abs_href(), 'descr': self.env.project_description }, 'login': { 'link': self.env.abs_href.login() } } if event.category == 'verify': data['verify'] = { 'link': self.env.abs_href.verify_email(token=event.token) } chrome = Chrome(self.env) dirs = [] for provider in chrome.template_providers: dirs += provider.get_templates_dirs() templates = TemplateLoader(dirs, variable_lookup='lenient') template = templates.load(acct_templates[event.category], cls=NewTextTemplate) if template: stream = template.generate(**data) output = stream.render('text') return output trac-announcer/trunk/announcer/opt/__init__.py0000644000175500017550000000000011337147215021567 0ustar debacledebacletrac-announcer/trunk/announcer/opt/subscribers.py0000644000175500017550000005746012607775473022422 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010,2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import re import urllib from fnmatch import fnmatch from genshi.builder import tag from trac.config import BoolOption, ListOption from trac.core import Component, implements from trac.ticket import model from trac.ticket.api import ITicketChangeListener from trac.web.api import IRequestFilter, IRequestHandler from trac.web.chrome import ITemplateProvider, add_ctxtnav, add_notice from trac.wiki.api import IWikiChangeListener from announcer.api import IAnnouncementDefaultSubscriber from announcer.api import IAnnouncementPreferenceProvider from announcer.api import IAnnouncementSubscriber from announcer.api import _, istrue from announcer.model import Subscription, SubscriptionAttribute from announcer.util import get_target_id __all__ = ['AllTicketSubscriber', 'GeneralWikiSubscriber', 'JoinableGroupSubscriber', 'TicketComponentOwnerSubscriber', 'TicketComponentSubscriber', 'TicketCustomFieldSubscriber', 'UserChangeSubscriber', 'WatchSubscriber'] class AllTicketSubscriber(Component): """Subscriber for all ticket changes.""" implements(IAnnouncementSubscriber) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != 'ticket': return if event.category not in ('changed', 'created', 'attachment added'): return klass = self.__class__.__name__ for i in Subscription.find_by_class(self.env, klass): yield i.subscription_tuple() def description(self): return _("notify me when any ticket changes") def requires_authentication(self): return False class GeneralWikiSubscriber(Component): """Allows users to subscribe to wiki announcements based on a pattern that they define. Any wiki announcements, whose page name matches the pattern, will be recieved by the user. """ implements(IAnnouncementPreferenceProvider, IAnnouncementSubscriber) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != 'wiki': return if event.category not in ('changed', 'created', 'attachment added', 'deleted', 'version deleted'): return klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_class_and_realm( self.env, klass, 'wiki') def match(pattern): for raw in pattern['target'].split(' '): if raw != '': pat = urllib.unquote(raw).replace('*', '.*') if re.match(pat, event.target.name): return True sids = set(map(lambda x: (x['sid'],x['authenticated']), filter(match, attrs))) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return _("notify me when a wiki that matches my wiki watch pattern " "is created, or updated") def requires_authentication(self): return False # IAnnouncementPreferenceProvider methods def get_announcement_preference_boxes(self, req): if req.perm.has_permission('WIKI_VIEW'): yield "general_wiki", _("General Wiki Announcements") def render_announcement_preference_box(self, req, panel): klass = self.__class__.__name__ sess = req.session if req.method == "POST": @self.env.with_transaction() def do_update(db): SubscriptionAttribute.delete_by_sid_and_class(self.env, sess.sid, sess.authenticated, klass, db) SubscriptionAttribute.add(self.env, sess.sid, sess.authenticated, klass, 'wiki', (req.args.get('wiki_interests'),), db) (interests,) = SubscriptionAttribute.find_by_sid_and_class( self.env, sess.sid, sess.authenticated, klass) or ({'target':''},) return "prefs_announcer_wiki.html", dict( wiki_interests = '\n'.join( urllib.unquote(x) for x in interests['target'].split(' ') ) ) class JoinableGroupSubscriber(Component): """Allows users to subscribe to groups as defined by the system administrator. Any ticket with the said group listed in the cc field will trigger announcements to users in the group. """ implements(IAnnouncementPreferenceProvider, IAnnouncementSubscriber) joinable_groups = ListOption('announcer', 'joinable_groups', [], doc="""Joinable groups represent 'opt-in' groups that users may freely join. Enter a list of groups (without @) seperated by commas. The name of the groups should be a simple alphanumeric string. By adding the group name preceeded by @ (such as @sec for the sec group) to the CC field of a ticket, everyone in that group will receive an announcement when that ticket is changed. """) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != 'ticket': return if event.category not in ('changed', 'created', 'attachment added'): return klass = self.__class__.__name__ ticket = event.target sids = set() cc = event.target['cc'] or '' for chunk in re.split('\s|,', cc): chunk = chunk.strip() if chunk and chunk.startswith('@'): member = None grp = chunk[1:] attrs = SubscriptionAttribute.find_by_class_realm_and_target( self.env, klass, 'ticket', grp) sids.update(set(map( lambda x: (x['sid'],x['authenticated']), attrs))) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return _("notify me on ticket changes in one of my subscribed groups") def requires_authentication(self): return False # IAnnouncementPreferenceProvider methods def get_announcement_preference_boxes(self, req): if req.authname == "anonymous" and 'email' not in req.session: return if self.joinable_groups: yield "joinable_groups", _("Group Subscriptions") def render_announcement_preference_box(self, req, panel): klass = self.__class__.__name__ if req.method == "POST": @self.env.with_transaction() def do_update(db): SubscriptionAttribute.delete_by_sid_and_class(self.env, req.session.sid, req.session.authenticated, klass, db) def _map(value): g = re.match('^joinable_group_(.*)', value) if g: if istrue(req.args.get(value)): return g.groups()[0] groups = set(filter(None, map(_map,req.args.keys()))) SubscriptionAttribute.add(self.env, req.session.sid, req.session.authenticated, klass, 'ticket', groups, db) attrs = filter(None, map( lambda x: x['target'], SubscriptionAttribute.find_by_sid_and_class( self.env, req.session.sid, req.session.authenticated, klass ) )) data = dict(joinable_groups = {}) for group in self.joinable_groups: data['joinable_groups'][group] = (group in attrs) and True or None return "prefs_announcer_joinable_groups.html", data class TicketComponentOwnerSubscriber(Component): """Allows component owners to subscribe to tickets assigned to their components. """ implements(IAnnouncementDefaultSubscriber, IAnnouncementSubscriber) default_on = BoolOption("announcer", "always_notify_component_owner", 'true', """Whether or not to notify the owner of the ticket's component. The user can override this setting in their preferences. """) default_distributor = ListOption("announcer", "always_notify_component_owner_distributor", "email", doc="""Comma-separated list of distributors to send the message to by default. ex. email, xmpp """) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != "ticket": return if event.category not in ('created', 'changed', 'attachment added'): return ticket = event.target try: component = model.Component(self.env, ticket['component']) if not component.owner: return if re.match(r'^[^@]+@.+', component.owner): sid, auth, addr = None, 0, component.owner else: sid, auth, addr = component.owner, 1, None # Default subscription for s in self.default_subscriptions(): yield (s[0], s[1], sid, auth, addr, None, s[2], s[3]) if sid: klass = self.__class__.__name__ for s in Subscription.find_by_sids_and_class(self.env, ((sid,auth),), klass): yield s.subscription_tuple() except: self.log.debug( "Component for ticket (%s) not found" % ticket['id'] ) def description(self): return _("notify me when a ticket that belongs to a component " "that I own is created or modified") def requires_authentication(self): return True # IAnnouncementDefaultSubscriber method def default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') class TicketComponentSubscriber(Component): """Allows users to subscribe to ticket assigned to the components of their choice. """ implements(IAnnouncementPreferenceProvider, IAnnouncementSubscriber) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != 'ticket': return if event.category not in ('changed', 'created', 'attachment added'): return component = event.target['component'] if not component: return klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_class_realm_and_target( self.env, klass, 'ticket', component) sids = set(map(lambda x: (x['sid'], x['authenticated']), attrs)) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return _("notify me when a ticket associated with " \ "a component I'm watching is modified") def requires_authentication(self): return False # IAnnouncementPreferenceProvider methods def get_announcement_preference_boxes(self, req): if req.authname == "anonymous" and 'email' not in req.session: return yield "joinable_components", _("Ticket Component Subscriptions") def render_announcement_preference_box(self, req, panel): klass = self.__class__.__name__ if req.method == "POST": @self.env.with_transaction() def do_update(db): SubscriptionAttribute.delete_by_sid_and_class(self.env, req.session.sid, req.session.authenticated, klass, db) def _map(value): g = re.match('^component_(.*)', value) if g: if istrue(req.args.get(value)): return g.groups()[0] components = set(filter(None, map(_map,req.args.keys()))) SubscriptionAttribute.add(self.env, req.session.sid, req.session.authenticated, klass, 'ticket', components, db) d = {} attrs = filter(None, map( lambda x: x['target'], SubscriptionAttribute.find_by_sid_and_class( self.env, req.session.sid, req.session.authenticated, klass ) )) for c in model.Component.select(self.env): if c.name in attrs: d[c.name] = True else: d[c.name] = None return "prefs_announcer_joinable_components.html", dict(components=d) class TicketCustomFieldSubscriber(Component): """Allows users to subscribe to tickets that have their sid listed in any field that has a name in the custom_cc_fields list. The custom_cc_fields list must be configured by the system administrator. """ implements(IAnnouncementDefaultSubscriber, IAnnouncementSubscriber) custom_cc_fields = ListOption('announcer', 'custom_cc_fields', doc="Field names that contain users that should be notified on " "ticket changes") default_on = BoolOption("announcer", "always_notify_custom_cc", 'true', """The always_notify_custom_cc will notify the users in the custom cc field by default when a ticket is modified. """) default_distributor = ListOption("announcer", "always_notify_custom_cc_distributor", "email", doc="""Comma-separated list of distributors to send the message to by default. ex. email, xmpp """) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != 'ticket': return if event.category not in ('changed', 'created', 'attachment added'): return klass = self.__class__.__name__ ticket = event.target sids = set() for field in self.custom_cc_fields: subs = ticket[field] or '' for chunk in re.split('\s|,', subs): chunk = chunk.strip() if not chunk or chunk.startswith('@'): continue if re.match(r'^[^@]+@.+', chunk): sid, auth, addr = None, None, chunk else: sid, auth, addr = chunk, True, None # Default subscription for s in self.default_subscriptions(): yield (s[0], s[1], sid, auth, addr, None, s[2], s[3]) if sid: sids.add((sid,auth)) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): if self.custom_cc_fields: return _("notify me when I'm listed in any of the (%s) " "fields"%(','.join(self.custom_cc_fields),)) def requires_authentication(self): return True # IAnnouncementDefaultSubscriber method def default_subscriptions(self): if self.custom_cc_fields: if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') class UserChangeSubscriber(Component): """Allows users to get notified anytime a particular user change triggers an event. """ implements(IAnnouncementPreferenceProvider, IAnnouncementSubscriber) # IAnnouncementSubscriber methods def matches(self, event): klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_class_realm_and_target( self.env, klass, 'user', event.author) sids = set(map(lambda x: (x['sid'],x['authenticated']), attrs)) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return _("notify me when one of my watched users changes something") def requires_authentication(self): return False # IAnnouncementPreferenceProvider methods def get_announcement_preference_boxes(self, req): if req.authname == "anonymous" and 'email' not in req.session: return yield "watch_users", _("Watch Users") def render_announcement_preference_box(self, req, panel): klass = self.__class__.__name__ if req.method == "POST": @self.env.with_transaction() def do_update(db): sess = req.session SubscriptionAttribute.delete_by_sid_and_class(self.env, sess.sid, sess.authenticated, klass, db) users = map(lambda x: x.strip(), req.args.get("announcer_watch_users").split(',')) SubscriptionAttribute.add(self.env, sess.sid, sess.authenticated, klass, 'user', users, db) attrs = filter(None, map( lambda x: x['target'], SubscriptionAttribute.find_by_sid_and_class( self.env, req.session.sid, req.session.authenticated, klass ) )) data = dict(announcer_watch_users=','.join(attrs)) return "prefs_announcer_watch_users.html", data class WatchSubscriber(Component): """Allows user to subscribe to ticket or wiki notification on a per resource basis. Watch, Unwatch links are added to wiki pages and tickets that the user can select to start watching a resource. """ implements(IRequestFilter, IRequestHandler, IAnnouncementSubscriber, ITicketChangeListener, IWikiChangeListener) watchable_paths = ListOption('announcer', 'watchable_paths', 'wiki/*,ticket/*', doc='List of URL paths to allow watching. Globs are supported.') ctxtnav_names = ListOption('announcer', 'ctxtnav_names', "Watch This, Unwatch This", doc="Text of context navigation entries. " "An empty list removes them from the context navigation bar.") path_match = re.compile(r'/watch(/.*)') # IRequestHandler methods def match_request(self, req): m = self.path_match.match(req.path_info) if m: (path_info,) = m.groups() realm, _ = self.path_info_to_realm_target(path_info) return "%s_VIEW" % realm.upper() in req.perm return False def process_request(self, req): match = self.path_match.match(req.path_info) (path_info,) = match.groups() realm, target = self.path_info_to_realm_target(path_info) req.perm.require('%s_VIEW' % realm.upper()) self.toggle_watched(req.session.sid, req.session.authenticated, realm, target, req) req.redirect(req.href(realm, target)) def toggle_watched(self, sid, authenticated, realm, target, req=None): if self.is_watching(sid, authenticated, realm, target): self.set_unwatch(sid, authenticated, realm, target) self._schedule_notice(req, _('You are no longer receiving ' \ 'change notifications about this resource.')) else: self.set_watch(sid, authenticated, realm, target) self._schedule_notice(req, _('You are now receiving ' \ 'change notifications about this resource.')) def _schedule_notice(self, req, message): req.session['_announcer_watch_message_'] = message def _add_notice(self, req): if '_announcer_watch_message_' in req.session: add_notice(req, req.session['_announcer_watch_message_']) del req.session['_announcer_watch_message_'] def is_watching(self, sid, authenticated, realm, target): klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_sid_class_realm_and_target( self.env, sid, authenticated, klass, realm, target) if attrs: return True else: return False def set_watch(self, sid, authenticated, realm, target): klass = self.__class__.__name__ SubscriptionAttribute.add(self.env, sid, authenticated, klass, realm, (target,)) def set_unwatch(self, sid, authenticated, realm, target): klass = self.__class__.__name__ (attr,) = SubscriptionAttribute.find_by_sid_class_realm_and_target( self.env, sid, authenticated, klass, realm, target) if attr: SubscriptionAttribute.delete(self.env, attr['id']) # IRequestFilter methods def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): self._add_notice(req) if req.authname != "anonymous" or 'email' in req.session: for pattern in self.watchable_paths: realm, target = self.path_info_to_realm_target(req.path_info) if fnmatch('%s/%s' % (realm, target), pattern): if '%s_VIEW' % realm.upper() not in req.perm: return (template, data, content_type) self.render_watcher(req) break return (template, data, content_type) # Internal methods def render_watcher(self, req): if not self.ctxtnav_names: return realm, target = self.path_info_to_realm_target(req.path_info) sess = req.session if self.is_watching(sess.sid, sess.authenticated, realm, target): action_name = len(self.ctxtnav_names) >= 2 and \ self.ctxtnav_names[1] or 'Unwatch This' else: action_name = len(self.ctxtnav_names) and \ self.ctxtnav_names[0] or 'Watch This' add_ctxtnav(req, tag.a( _(action_name), href=req.href.watch(realm, target) ) ) def path_info_to_realm_target(self, path_info): realm = target = None g = re.match(r'^/([^/]+)(.*)', path_info) if g: realm, target = g.groups() target = target.strip('/') return self.normalize_realm_target(realm, target) def normalize_realm_target(self, realm, target): if not realm: realm = 'wiki' if not target and realm == 'wiki': target = 'WikiStart' return realm, target # ITicketChangeListener methods def ticket_created(*args): pass def ticket_changed(*args): pass def ticket_deleted(self, ticket): klass = self.__class__.__name__ SubscriptionAttribute.delete_by_class_realm_and_target( self.env, klass, 'ticket', get_target_id(ticket)) # IWikiChangeListener methods def wiki_page_added(*args): pass def wiki_page_changed(*args): pass def wiki_page_deleted(self, page): klass = self.__class__.__name__ SubscriptionAttribute.delete_by_class_realm_and_target( self.env, klass, 'wiki', get_target_id(page)) def wiki_page_version_deleted(*args): pass def wiki_page_renamed(self, page, old_name): class_ = self.__class__.__name__ target = get_target_id(page) SubscriptionAttribute.change_target(self.env, class_, 'wiki', old_name, target) # IAnnouncementSubscriber methods def matches(self, event): klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_class_realm_and_target(self.env, klass, event.realm, get_target_id(event.target)) sids = set(map(lambda x: (x['sid'],x['authenticated']), attrs)) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return _("notify me when one of my watched wiki or tickets is updated") def requires_authentication(self): return False trac-announcer/trunk/announcer/opt/fullblog/0000755000175500017550000000000012350245165021275 5ustar debacledebacletrac-announcer/trunk/announcer/opt/fullblog/__init__.py0000644000175500017550000000000011337147215023375 0ustar debacledebacletrac-announcer/trunk/announcer/opt/fullblog/announce.py0000644000175500017550000003526112350245165023464 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import re from trac.config import BoolOption, Option from trac.core import * from trac.web.api import IRequestFilter, IRequestHandler from trac.web.chrome import Chrome, add_notice, add_ctxtnav from genshi.builder import tag from genshi.template import NewTextTemplate, TemplateLoader from announcer.api import AnnouncementSystem, AnnouncementEvent from announcer.api import IAnnouncementFormatter, IAnnouncementSubscriber from announcer.api import IAnnouncementPreferenceProvider from announcer.api import _ from announcer.distributors.mail import IAnnouncementEmailDecorator from announcer.model import Subscription, SubscriptionAttribute from announcer.util.mail import set_header, next_decorator from tracfullblog.api import IBlogChangeListener from tracfullblog.model import BlogPost, BlogComment class BlogChangeEvent(AnnouncementEvent): def __init__(self, blog_post, category, url, blog_comment=None): AnnouncementEvent.__init__(self, 'blog', category, blog_post) if blog_comment: if 'comment deleted' == category: self.comment = blog_comment['comment'] self.author = blog_comment['author'] self.timestamp = blog_comment['time'] else: self.comment = blog_comment.comment self.author = blog_comment.author self.timestamp = blog_comment.time else: self.comment = blog_post.version_comment self.author = blog_post.version_author self.timestamp = blog_post.version_time self.remote_addr = url self.version = blog_post.version self.blog_post = blog_post self.blog_comment = blog_comment class FullBlogAllSubscriber(Component): """Subscriber for any blog changes.""" implements(IAnnouncementSubscriber) def matches(self, event): if event.realm != 'blog': return if not event.category in ('post created', 'post changed', 'post deleted', 'comment created', 'comment changed', 'comment deleted'): return klass = self.__class__.__name__ for i in Subscription.find_by_class(self.env, klass): yield i.subscription_tuple() def description(self): return _("notify me when any blog is modified, " "changed, deleted or commented on.") class FullBlogNewSubscriber(Component): """Subscriber for any blog post creation.""" implements(IAnnouncementSubscriber) def matches(self, event): if event.realm != 'blog': return if event.category != 'post created': return klass = self.__class__.__name__ for i in Subscription.find_by_class(self.env, klass): yield i.subscription_tuple() def description(self): return "notify me when any blog post is created." class FullBlogMyPostSubscriber(Component): """Subscriber for any blog changes to my posts.""" implements(IAnnouncementSubscriber) always_notify_author = BoolOption('fullblog-announcement', 'always_notify_author', 'true', """Notify the blog author of any changes to her blogs, including changes to comments. """) def matches(self, event): if event.realm != 'blog': return if not event.category in ('post changed', 'post deleted', 'comment created', 'comment changed', 'comment deleted'): return sids = ((event.blog_post.author,1),) klass = self.__class__.__name__ for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return _("notify me when any blog that I posted " "is modified or commented on.") class FullBlogWatchSubscriber(Component): """Subscriber to watch individual blogs.""" implements(IAnnouncementSubscriber) implements(IRequestFilter) implements(IRequestHandler) # IAnnouncementSubscriber def matches(self, event): if event.realm != 'blog': return if not event.category in ('post created', 'post changed', 'post deleted', 'comment created', 'comment changed', 'comment deleted'): return klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_class_realm_and_target(self.env, klass, 'blog', event.blog_post.name) sids = set(map(lambda x: (x['sid'],x['authenticated']), attrs)) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return "notify me when a blog that I'm watching changes." # IRequestFilter def pre_process_request(self, req, handler): return handler def post_process_request(self, req, template, data, content_type): if 'BLOG_VIEW' not in req.perm: return (template, data, content_type) if '_blog_watch_message_' in req.session: add_notice(req, req.session['_blog_watch_message_']) del req.session['_blog_watch_message_'] if req.authname == "anonymous": return (template, data, content_type) # FullBlogPlugin sets the blog_path arg in pre_process_request name = req.args.get('blog_path') if not name: return (template, data, content_type) klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_sid_class_and_target( self.env, req.session.sid, req.session.authenticated, klass, name) if attrs: add_ctxtnav(req, tag.a(_('Unwatch This'), href=req.href.blog_watch(name))) else: add_ctxtnav(req, tag.a(_('Watch This'), href=req.href.blog_watch(name))) return (template, data, content_type) # IRequestHandler def match_request(self, req): return re.match(r'^/blog_watch/(.*)', req.path_info) def process_request(self, req): klass = self.__class__.__name__ m = re.match(r'^/blog_watch/(.*)', req.path_info) (name,) = m.groups() @self.env.with_transaction() def do_update(db): attrs = SubscriptionAttribute.find_by_sid_class_and_target( self.env, req.session.sid, req.session.authenticated, klass, name) if attrs: SubscriptionAttribute.delete_by_sid_class_and_target( self.env, req.session.sid, req.session.authenticated, klass, name) req.session['_blog_watch_message_'] = \ _('You are no longer watching this blog post.') else: SubscriptionAttribute.add( self.env, req.session.sid, req.session.authenticated, klass, 'blog', (name,)) req.session['_blog_watch_message_'] = \ _('You are now watching this blog post.') req.redirect(req.href.blog(name)) class FullBlogBloggerSubscriber(Component): """Subscriber for any blog changes to bloggers that I follow.""" implements(IAnnouncementSubscriber) implements(IAnnouncementPreferenceProvider) def matches(self, event): if event.realm != 'blog': return if not event.category in ('post created', 'post changed', 'post deleted', 'comment created', 'comment changed', 'comment deleted'): return klass = self.__class__.__name__ sids = set(map(lambda x: (x['sid'], x['authenticated']), SubscriptionAttribute.find_by_class_realm_and_target( self.env, klass, 'blog', event.blog_post.author))) for i in Subscription.find_by_sids_and_class(self.env, sids, klass): yield i.subscription_tuple() def description(self): return "notify me when any blogger that I follow has a blog update." # IAnnouncementPreferenceProvider interface def get_announcement_preference_boxes(self, req): if req.authname == "anonymous" and 'email' not in req.session: return yield "bloggers", _("Followed Bloggers") def render_announcement_preference_box(self, req, panel): klass = self.__class__.__name__ if req.method == "POST": @self.env.with_transaction() def do_update(db): SubscriptionAttribute.delete_by_sid_and_class( self.env, req.session.sid, req.session.authenticated, klass) blogs = set(map(lambda x: x.strip(), req.args.get('announcer_watch_bloggers').split(','))) SubscriptionAttribute.add(self.env, req.session.sid, req.session.authenticated, klass, 'blog', blogs) attrs = SubscriptionAttribute.find_by_sid_and_class(self.env, req.session.sid, req.session.authenticated, klass) data = {'sids': ','.join(set(map(lambda x: x['target'], attrs)))} return "prefs_announcer_watch_bloggers.html", dict(data=data) class FullBlogAnnouncement(Component): """Send announcements on blog events.""" implements(IBlogChangeListener) implements(IAnnouncementFormatter) implements(IAnnouncementEmailDecorator) blog_email_subject = Option('fullblog-announcement', 'blog_email_subject', _("Blog: ${blog.name} ${action}"), """Format string for the blog email subject. This is a mini genshi template and it is passed the blog_post and action objects. """) # IBlogChangeListener interface def blog_post_changed(self, postname, version): """Called when a new blog post 'postname' with 'version' is added. version==1 denotes a new post, version>1 is a new version on existing post. """ blog_post = BlogPost(self.env, postname, version) action = 'post created' if version > 1: action = 'post changed' announcer = AnnouncementSystem(self.env) announcer.send( BlogChangeEvent( blog_post, action, self.env.abs_href.blog(blog_post.name) ) ) def blog_post_deleted(self, postname, version, fields): """Called when a blog post is deleted: version==0 means all versions (or last remaining) version is deleted. Any version>0 denotes a specific version only. Fields is a dict with the pre-existing values of the blog post. If all (or last) the dict will contain the 'current' version contents. """ blog_post = BlogPost(self.env, postname, version) announcer = AnnouncementSystem(self.env) announcer.send( BlogChangeEvent( blog_post, 'post deleted', self.env.abs_href.blog(blog_post.name) ) ) def blog_comment_added(self, postname, number): """Called when Blog comment number N on post 'postname' is added.""" blog_post = BlogPost(self.env, postname, 0) blog_comment = BlogComment(self.env, postname, number) announcer = AnnouncementSystem(self.env) announcer.send( BlogChangeEvent( blog_post, 'comment created', self.env.abs_href.blog(blog_post.name), blog_comment ) ) def blog_comment_deleted(self, postname, number, fields): """Called when blog post comment 'number' is deleted. number==0 denotes all comments is deleted and fields will be empty. (usually follows a delete of the blog post). number>0 denotes a specific comment is deleted, and fields will contain the values of the fields as they existed pre-delete. """ blog_post = BlogPost(self.env, postname, 0) announcer = AnnouncementSystem(self.env) announcer.send( BlogChangeEvent( blog_post, 'comment deleted', self.env.abs_href.blog(blog_post.name), fields ) ) # IAnnouncementEmailDecorator def decorate_message(self, event, message, decorates=None): if event.realm == "blog": template = NewTextTemplate(self.blog_email_subject.encode('utf8')) subject = template.generate( blog=event.blog_post, action=event.category ).render('text', encoding=None) set_header(message, 'Subject', subject) return next_decorator(event, message, decorates) # IAnnouncementFormatter interface def styles(self, transport, realm): if realm == 'blog': yield 'text/plain' def alternative_style_for(self, transport, realm, style): if realm == 'blog' and style != 'text/plain': return 'text/plain' def format(self, transport, realm, style, event): if realm == 'blog' and style == 'text/plain': return self._format_plaintext(event) def _format_plaintext(self, event): blog_post = event.blog_post blog_comment = event.blog_comment data = dict( name = blog_post.name, author = event.author, time = event.timestamp, category = event.category, version = event.version, link = event.remote_addr, title = blog_post.title, body = blog_post.body, comment = event.comment, ) chrome = Chrome(self.env) dirs = [] for provider in chrome.template_providers: dirs += provider.get_templates_dirs() templates = TemplateLoader(dirs, variable_lookup='lenient') template = templates.load( 'fullblog_plaintext.txt', cls=NewTextTemplate ) if template: stream = template.generate(**data) output = stream.render('text') return output trac-announcer/trunk/announcer/opt/bitten/0000755000175500017550000000000012350245165020754 5ustar debacledebacletrac-announcer/trunk/announcer/opt/bitten/__init__.py0000644000175500017550000000000011337147230023051 0ustar debacledebacletrac-announcer/trunk/announcer/opt/bitten/announce.py0000644000175500017550000001626512350245165023146 0ustar debacledebacle#-*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # from trac.core import * from trac.web.chrome import Chrome from genshi.template import NewTextTemplate, TemplateLoader from announcer.api import AnnouncementSystem, AnnouncementEvent from announcer.api import IAnnouncementFormatter, IAnnouncementSubscriber from announcer.api import IAnnouncementPreferenceProvider from announcer.api import _ from announcer.distributors.mail import IAnnouncementEmailDecorator from announcer.util.mail import set_header, next_decorator from announcer.util.settings import BoolSubscriptionSetting from bitten.api import IBuildListener from bitten.model import Build, BuildStep, BuildLog class BittenAnnouncedEvent(AnnouncementEvent): def __init__(self, build, category): AnnouncementEvent.__init__(self, 'bitten', category, build) class BittenAnnouncement(Component): """Send announcements on build status.""" implements( IBuildListener, IAnnouncementSubscriber, IAnnouncementFormatter, IAnnouncementEmailDecorator, IAnnouncementPreferenceProvider ) readable_states = { Build.SUCCESS: _('Successful'), Build.FAILURE: _('Failed'), } # IBuildListener interface def build_started(self, build): """build started""" self._notify(build, 'started') def build_aborted(self, build): """build aborted""" self._notify(build, 'aborted') def build_completed(self, build): """build completed""" self._notify(build, 'completed') # IAnnouncementSubscriber interface def subscriptions(self, event): if event.realm == 'bitten': settings = self._settings() if event.category in settings.keys(): for subscriber in settings[event.category].get_subscriptions(): self.log.debug("BittenAnnouncementSubscriber added '%s " \ "(%s)'", subscriber[1], subscriber[2]) yield subscriber def matches(self, event): yield None def description(self): return 'notify me bitten changes NOT IMPLEMENTED' # IAnnouncementFormatter interface def styles(self, transport, realm): if realm == 'bitten': yield 'text/plain' def alternative_style_for(self, transport, realm, style): if realm == 'bitten' and style != 'text/plain': return 'text/plain' def format(self, transport, realm, style, event): if realm == 'bitten' and style == 'text/plain': return self._format_plaintext(event) # IAnnouncementEmailDecorator def decorate_message(self, event, message, decorates=None): if event.realm == "bitten": build_id = str(event.target.id) build_link = self._build_link(event.target) subject = '[%s Build] %s [%s] %s' % ( self.readable_states.get( event.target.status, event.target.status ), self.env.project_name, event.target.rev, event.target.config ) set_header(message, 'X-Trac-Build-ID', build_id) set_header(message, 'X-Trac-Build-URL', build_link) set_header(message, 'Subject', subject) return next_decorator(event, message, decorates) # IAnnouncementPreferenceProvider interface def get_announcement_preference_boxes(self, req): if req.authname == "anonymous" and 'email' not in req.session: return yield "bitten_subscription", _("Bitten Subscription") def render_announcement_preference_box(self, req, panel): settings = self._settings() if req.method == "POST": for k, setting in settings.items(): setting.set_user_setting(req.session, value=req.args.get('bitten_%s_subscription'%k), save=False) req.session.save() data = {} for k, setting in settings.items(): data[k] = setting.get_user_setting(req.session.sid)[1] return "prefs_announcer_bitten.html", data # private methods def _notify(self, build, category): self.log.info('BittenAnnouncedEventProducer invoked for build %r', build) self.log.debug('build status: %s', build.status) self.log.info('Creating announcement for build %r', build) try: announcer = AnnouncementSystem(self.env) announcer.send(BittenAnnouncedEvent(build, category)) except Exception, e: self.log.exception("Failure creating announcement for build " "%s: %s", build.id, e) def _settings(self): ret = {} for p in ('started', 'aborted', 'completed'): ret[p] = BoolSubscriptionSetting(self.env, 'bitten_%s'%p) return ret def _format_plaintext(self, event): failed_steps = BuildStep.select(self.env, build=event.target.id, status=BuildStep.FAILURE) change = self._get_changeset(event.target) data = { 'build': { 'id': event.target.id, 'status': self.readable_states.get( event.target.status, event.target.status ), 'link': self._build_link(event.target), 'config': event.target.config, 'slave': event.target.slave, 'failed_steps': [{ 'name': step.name, 'description': step.description, 'errors': step.errors, 'log_messages': self._get_all_log_messages_for_step(event.target, step), } for step in failed_steps], }, 'change': { 'rev': change.rev, 'link': self.env.abs_href.changeset(change.rev), 'author': change.author, }, 'project': { 'name': self.env.project_name, 'url': self.env.project_url or self.env.abs_href(), 'descr': self.env.project_description } } chrome = Chrome(self.env) dirs = [] for provider in chrome.template_providers: dirs += provider.get_templates_dirs() templates = TemplateLoader(dirs, variable_lookup='lenient') template = templates.load('bitten_plaintext.txt', cls=NewTextTemplate) if template: stream = template.generate(**data) output = stream.render('text') return output def _build_link(self, build): return self.env.abs_href.build(build.config, build.id) def _get_all_log_messages_for_step(self, build, step): messages = [] for log in BuildLog.select(self.env, build=build.id, step=step.name): messages.extend(log.messages) return messages def _get_changeset(self, build): return self.env.get_repository().get_changeset(build.rev) trac-announcer/trunk/announcer/opt/tests/0000755000175500017550000000000012557162602020634 5ustar debacledebacletrac-announcer/trunk/announcer/opt/tests/__init__.py0000644000175500017550000000111612350245165022741 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Ryan J Ollos # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import unittest from announcer.opt.tests import subscribers def test_suite(): suite = unittest.TestSuite() suite.addTest(subscribers.suite()) return suite # Start test suite directly from command line like so: # $> PYTHONPATH=$PWD python announcer/opt/tests/__init__.py if __name__ == '__main__': unittest.main(defaultTest="test_suite") trac-announcer/trunk/announcer/opt/tests/subscribers.py0000644000175500017550000001136412557162602023541 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import shutil import tempfile import unittest from trac.db.api import DatabaseManager from trac.test import EnvironmentStub from trac.wiki.model import WikiPage from announcer.api import AnnouncementSystem from announcer.opt.subscribers import AllTicketSubscriber from announcer.opt.subscribers import GeneralWikiSubscriber from announcer.opt.subscribers import JoinableGroupSubscriber from announcer.opt.subscribers import TicketComponentOwnerSubscriber from announcer.opt.subscribers import TicketComponentSubscriber from announcer.opt.subscribers import TicketCustomFieldSubscriber from announcer.opt.subscribers import UserChangeSubscriber from announcer.opt.subscribers import WatchSubscriber class SubscriberTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub( enable=['trac.*', 'announcer.opt.subscribers.*']) self.env.path = tempfile.mkdtemp() self.db_mgr = DatabaseManager(self.env) self.db = self.env.get_db_cnx() AnnouncementSystem(self.env).upgrade_environment(self.db) def tearDown(self): self.env.db_transaction("DROP table 'subscription'") self.env.db_transaction("DROP table 'subscription_attribute'") self.db.close() # Really close db connections. self.env.shutdown() shutil.rmtree(self.env.path) class AllTicketSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that AllTicketSubscriber initializes cleanly. AllTicketSubscriber(self.env) pass class GeneralWikiSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that GeneralWikiSubscriber initializes cleanly. GeneralWikiSubscriber(self.env) pass class JoinableGroupSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that JoinableGroupSubscriber initializes # cleanly. JoinableGroupSubscriber(self.env) pass class TicketComponentOwnerSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that TicketComponentOwnerSubscriber initializes # cleanly. TicketComponentOwnerSubscriber(self.env) pass class TicketComponentSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that TicketComponentSubscriber initializes # cleanly. TicketComponentSubscriber(self.env) pass class TicketCustomFieldSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that TicketCustomFieldSubscriber initializes # cleanly. TicketCustomFieldSubscriber(self.env) pass class UserChangeSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that UserChangeSubscriber initializes cleanly. UserChangeSubscriber(self.env) pass class WatchSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that WatchSubscriber initializes cleanly. WatchSubscriber(self.env) pass class WikiWatchSubscriberTestCase(SubscriberTestCase): def test_rename_wiki_page(self): sid = 'subscriber' page = WikiPage(self.env) page.name = name = 'PageInitial' page.text = 'Page content' page.save('actor', 'page created') ws = WatchSubscriber(self.env) ws.set_watch(sid, 1, 'wiki', name) new_name = 'PageRenamed' page.rename(new_name) self.assertFalse(ws.is_watching(sid, 1, 'wiki', name)) self.assertTrue(ws.is_watching(sid, 1, 'wiki', new_name)) def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(AllTicketSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(GeneralWikiSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(JoinableGroupSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(TicketComponentOwnerSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(TicketComponentSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(TicketCustomFieldSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(UserChangeSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(WatchSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(WikiWatchSubscriberTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer/trunk/announcer/email_decorators/0000755000175500017550000000000011447713115022202 5ustar debacledebacletrac-announcer/trunk/announcer/templates/0000755000175500017550000000000012071115211020647 5ustar debacledebacletrac-announcer/trunk/announcer/templates/prefs_announcer_author_filter.html0000644000175500017550000000064011434334743027672 0ustar debacledebacle Opt-out of announcements about my own changes.

Never notify me when I make a change.

trac-announcer/trunk/announcer/templates/prefs_announcer_ticket_all.html0000644000175500017550000000061111444454570027136 0ustar debacledebacle trac-announcer/trunk/announcer/templates/prefs_announcer_acct_mgr_subscription.html0000644000175500017550000000141711434334743031411 0ustar debacledebacle Subscribe to user account announcements. trac-announcer/trunk/announcer/templates/bitten_plaintext.txt0000644000175500017550000000134111337147230024776 0ustar debacledebacle${build.status} build of ${project.name} [${change.rev}] --------------------------------------------------------------------- Changeset: ${change.rev} - <${change.link}> Committed by: ${change.author} Build Configuration: ${build.config} Build Slave: ${build.slave} Build Number: ${build.id} - <${build.link}> {% if build.failed_steps %}\ Failures: {% for step in build.failed_steps %}\ Step: $step.name Errors: ${', '.join(step.errors)} Log: #for lvl, msg in step.log_messages [${lvl.upper().ljust(8)}] $msg #end {% end %}\ {% end %}\ -- Build URL: <${build.link}> ${project.name} <${project.url}> ${project.descr} trac-announcer/trunk/announcer/templates/prefs_announcer_emailaddress.html0000644000175500017550000000101011434334743027450 0ustar debacledebacle If you would like to have announcement notices sent to a different address then the main one provided in Trac, you may specify the address here: trac-announcer/trunk/announcer/templates/ticket_email_plaintext.txt0000644000175500017550000000252412044074724026152 0ustar debacledebacle#${ticket.id}: ${ticket['summary']} ${ticket['status']} ${ticket['type']} --------------------------------------------------------------------- {% for field in fields %}\ {% choose %}\ {% when ticket[field['name']] %}\ ${field['label']}: ${ticket[field['name']]} {% end %}\ {% otherwise %}\ ${field['label']}: (None) {% end %}\ {% end %}\ {% end %}\ {% if category == 'created' %}\ --------------------------------------------------------------------- ${ticket['description']} {% end %}\ {% if has_changes or attachment %}\ --------------------------------------------------------------------- Changes (by ${author}): {% for change in short_changes %} * ${change} from '${short_changes[change][0]}' to \ {% choose %}\ {% when short_changes[change][1] %}\ '${short_changes[change][1]}'{% end %}\ {% otherwise %}\ (deleted){% end %}\ {% end %}\ {% end %}\ {% for change in long_changes %}\ * ${change}: ${long_changes[change]} {% end %}\ {% end %}\ {% if attachment %}\ Attachment: * File '${attachment.filename}' added{% if attachment.description %}: ${attachment.description} {% end %} {% end %}\ {% if comment %}\ --------------------------------------------------------------------- Comment{% if not has_changes %} (by ${author}){% end %}: ${comment}\ {% end %}\ -- Ticket URL: ${project_name} ${project_desc} trac-announcer/trunk/announcer/templates/prefs_announcer_joinable_groups.html0000644000175500017550000000112011434334743030177 0ustar debacledebacle The following groups have been defined by the Trac administrators. They are general topics that may be added onto the CC list of tickets (by prepending their name with @). Case does matter.
  • @${grp}
trac-announcer/trunk/announcer/templates/prefs_announcer_bitten.html0000644000175500017550000000136411434334743026314 0ustar debacledebacle Subscribe to build announcements.
  • Subscribe me to build started announcements.
  • Subscribe me to build aborted announcements.
  • Subscribe me to build completed announcements.
trac-announcer/trunk/announcer/templates/prefs_announcer_watch_users.html0000644000175500017550000000113312051274703027343 0ustar debacledebacle

A comma-separated list of users you would like to watch. A watched user will create an announcement each time he/she creates or changes a wiki page or ticket.

trac-announcer/trunk/announcer/templates/prefs_announcer_email.html0000644000175500017550000000127211434334743026114 0ustar debacledebacle By default, the Announcer will deliver all notices to you in a plaintext format. You may override this for each realm that may generate announcements.
  • ${realm.capitalize()} announcements:
trac-announcer/trunk/announcer/templates/prefs_announcer.html0000644000175500017550000000172611434334743024751 0ustar debacledebacle Announcements

Announcements serve as a method for Trac to communicate events to you; the creation of a ticket, the change of a Wiki page, and so on. Under the Announcement system, you will only receive notifications to those topics that you subscribe to.

${title}
${box}
trac-announcer/trunk/announcer/templates/acct_mgr_user_change_plaintext.txt0000644000175500017550000000014611337147243027641 0ustar debacledebacle${account.action} for user ${account.username} -- ${project.name} <${project.url}> ${project.descr} trac-announcer/trunk/announcer/templates/prefs_announcer_distributor.html0000644000175500017550000000076511450260670027400 0ustar debacledebacle I prefer to recieve announcements via trac-announcer/trunk/announcer/templates/prefs_announcer_manage_subscriptions.html0000644000175500017550000000745511451362245031252 0ustar debacledebacle Announcements

Announcements serve as a method for Trac to communicate events to you; the creation of a ticket, the change of a Wiki page, and so on. Under the Announcement system, you will only receive notifications to those topics that you subscribe to.

${distributor} rules
Custom Rules:
Format:
  • ${rule['adverb']} ${rule['description']}

Default Rules:

The following rules have been configured by the system admistrator as the default rules. Any rules defined by you will take higher priority then these rules. This can be confusing if you don't understand how the system works. Only the first matching rule is applied when system events occur. For example, if you have a rule like "always notify me of any ticket changes" in your custom rules, and there is a default rule "never notify me when I update a ticket", then the always rule will take precedent and you will still recieve announcements on ticket changes, even when you are the updater. In the preceding case, you would need to add your own "never notify me.." rule above the "always notify me.." to get the proper behavior.

  • ${rule['adverb']} ${rule['description']}
trac-announcer/trunk/announcer/templates/ticket_email_mimic.html0000644000175500017550000001235412051425100025351 0ustar debacledebacle #${ticket.id}: ${ticket['summary']} Ticket #${ticket.id} (${ticket['status']} ${ticket['type']})
${ticket['summary']}
${field['label']}: ${ticket[field['name']] or 'None'}
Description
${description}
Changes (by ${author}):
  • ${change} changed from ${short_changes[change][0]} to ${short_changes[change][1]}.
  • ${change}:
    ${content}
Attachment:
  • File ${attachment.filename} added: ${comment}
Comments: (by ${author})
${comment}



trac-announcer/trunk/announcer/templates/acct_mgr_verify_plaintext.txt0000644000175500017550000000033411337147243026661 0ustar debacledebaclePlease visit the following URL to confirm your email address. Verification URL: <${verify.link}> Username: ${account.username} Verification Token: ${account.token} -- ${project.name} <${project.url}> ${project.descr} trac-announcer/trunk/announcer/templates/fullblog_plaintext.txt0000644000175500017550000000143211337147215025323 0ustar debacledebacle#${name}: ${title} {% if category == 'post created' or category == 'post updated' %} {% if category == 'post created' %} Added post "${name}" by ${author} at ${time} {% end %}\ {% if category == 'post updated' %}\ Changed post "${name}" by ${author} at ${time}. Revision: ${version} {% end %}\ Page URL: ${link} Content: Title: ${title} ${body} {% if comment %}\ Comment: ${comment} {% end %}\ {% end %}\ {% if category == 'post deleted' %}\ Deleted post "${name}" by ${author} at ${time} {% end %}\ {% if category == 'post deleted' %}\ Page URL: ${link} Deleted version "${version}" of post "${name}" by ${author} at ${time} {% end %}\ {% if category == 'comment created' %}\ Comment added to post "${name}" by ${author} at ${time} Page URL: ${link} Content: ${comment} {% end %} trac-announcer/trunk/announcer/templates/prefs_announcer_legacy.html0000644000175500017550000000167611434334743026301 0ustar debacledebacle
  • Notify me of changes to tickets that belong to components that I own.
  • Notify me of changes to tickets that I own.
  • Notify me of changes to tickets that I reported.
  • Notify me when I update a ticket.
trac-announcer/trunk/announcer/templates/prefs_announcer_xmpp.html0000644000175500017550000000115111450260670026000 0ustar debacledebacle By default, the Announcer will deliver all notices to you in a plaintext format. You may override this for each realm that may generate announcements.
  • ${realm.capitalize()} announcements:
trac-announcer/trunk/announcer/templates/prefs_announcer_xmppaddress.html0000644000175500017550000000057311450260670027355 0ustar debacledebacle Specify your XMPP(jabber) address where you would like jabber announcements delivered.
  • XMPP address:
trac-announcer/trunk/announcer/templates/prefs_announcer_wiki.html0000644000175500017550000000150411434334743025766 0ustar debacledebacle

In addition to other methods that may notify you of changes to Wiki pages, you may list here pages that are of interest to you. Each page should be on a separate line.

You may use wild cards, so that if you want to hear about any page that starts with the name 'Trac' you would enter on it's own line: Trac*

To receive a notice about all wiki changes, simply include a * by itself.

trac-announcer/trunk/announcer/templates/prefs_announcer_unsubscribe_all.html0000644000175500017550000000061511434334743030201 0ustar debacledebacle Opt-out of all announcements.

Never notify me of any changes.

trac-announcer/trunk/announcer/templates/wiki_email_plaintext.txt0000644000175500017550000000127711444016466025640 0ustar debacledebacle{% choose %}\ {% when action == "created" %} * The user '${author}' has created the page: ${page.name}. {% end %}\ {% when action == "changed" %} * The user '${author}' has changed the page: ${page.name}. * Diff link: ${diff} {% end %}\ {% when action == "attachment added" %} * The user '${author}' has added the attachment '${attachment.filename}' to the page: ${page.name}. {% end %}\ {% when action == "version deleted" %} * The page '${page.name}' has been reverted to its previous version. {% end %}\ {% when action == "deleted" %} * The '${page.name}' has been deleted. {% end %}\ {% end %}\ -- Page URL: <${page_link}> ${project_name} URL: <${project_link}> ${project_desc} trac-announcer/trunk/announcer/templates/prefs_announcer_joinable_components.html0000644000175500017550000000121711434334743031054 0ustar debacledebacle Components are a way to classify trac tickets. The following components have been defined by the Trac administrators. If you subscribe to any of these components, you will receive an notification anytime a ticket related to that component is changed or created.
  • ${name}
trac-announcer/trunk/announcer/templates/prefs_announcer_rules.html0000644000175500017550000000246411434334743026163 0ustar debacledebacle

The rule-based subscription module is for advanced users, and allows you to use filters to specify which events you are interested in hearing about.

Every rule is in the form of:

realm, category: query rule

trac-announcer/trunk/announcer/templates/acct_mgr_approve_plaintext.txt0000644000175500017550000000023612071115211027014 0ustar debacledebaclePlease visit the user admin page to confirm a new account registration. Username: ${account.username} -- ${project.name} <${project.url}> ${project.descr} trac-announcer/trunk/announcer/templates/prefs_announcer_watch_bloggers.html0000644000175500017550000000071711450706240030013 0ustar debacledebacle
trac-announcer/trunk/announcer/templates/acct_mgr_reset_password_plaintext.txt0000644000175500017550000000032611337147243030422 0ustar debacledebacleYour Trac password has been reset. Here is your account information: Login URL: <${login.link}> Username: ${account.username} Password: ${account.password} -- ${project.name} <${project.url}> ${project.descr} trac-announcer/trunk/announcer/producers.py0000644000175500017550000001235112037604531021245 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # from trac.attachment import IAttachmentChangeListener from trac.config import BoolOption from trac.core import * from trac.ticket.api import ITicketChangeListener from trac.ticket.model import Ticket from trac.wiki.api import IWikiChangeListener from trac.wiki.model import WikiPage from announcer.api import AnnouncementSystem, AnnouncementEvent, IAnnouncementProducer class AttachmentChangeProducer(Component): implements(IAttachmentChangeListener, IAnnouncementProducer) def __init__(self, *args, **kwargs): pass def realms(self): yield 'ticket' yield 'wiki' def attachment_added(self, attachment): parent = attachment.resource.parent if parent.realm == "ticket": ticket = Ticket(self.env, parent.id) announcer = AnnouncementSystem(ticket.env) announcer.send( TicketChangeEvent("ticket", "attachment added", ticket, attachment=attachment, author=attachment.author, ) ) elif parent.realm == "wiki": page = WikiPage(self.env, parent.id) announcer = AnnouncementSystem(page.env) announcer.send( WikiChangeEvent("wiki", "attachment added", page, attachment=attachment, author=attachment.author, ) ) def attachment_deleted(self, attachment): pass class TicketChangeEvent(AnnouncementEvent): def __init__(self, realm, category, target, comment=None, author=None, changes={}, attachment=None): AnnouncementEvent.__init__(self, realm, category, target) self.author = author self.comment = comment self.changes = changes self.attachment = attachment def get_basic_terms(self): for term in AnnouncementEvent.get_basic_terms(self): yield term ticket = self.target yield ticket['component'] def get_session_terms(self, session_id): ticket = self.target if session_id == self.author: yield "updater" if session_id == ticket['owner']: yield "owner" if session_id == ticket['reporter']: yield "reporter" class TicketChangeProducer(Component): implements(ITicketChangeListener, IAnnouncementProducer) 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 def realms(self): yield 'ticket' def ticket_created(self, ticket): announcer = AnnouncementSystem(ticket.env) announcer.send( TicketChangeEvent("ticket", "created", ticket, author=ticket['reporter'] ) ) 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, comment, author, old_values ) ) def ticket_deleted(self, ticket): pass class WikiChangeEvent(AnnouncementEvent): def __init__(self, realm, category, target, comment=None, author=None, version=None, timestamp=None, remote_addr=None, attachment=None): AnnouncementEvent.__init__(self, realm, category, target) self.author = author self.comment = comment self.version = version self.timestamp = timestamp self.remote_addr = remote_addr self.attachment = attachment class WikiChangeProducer(Component): implements(IWikiChangeListener, IAnnouncementProducer) def realms(self): yield 'wiki' def wiki_page_added(self, page): history = list(page.get_history())[0] announcer = AnnouncementSystem(page.env) announcer.send( WikiChangeEvent("wiki", "created", page, author=history[2], version=history[0] ) ) def wiki_page_changed(self, page, version, t, comment, author, ipnr): announcer = AnnouncementSystem(page.env) announcer.send( WikiChangeEvent("wiki", "changed", page, comment=comment, author=author, version=version, timestamp=t, remote_addr=ipnr ) ) def wiki_page_deleted(self, page): announcer = AnnouncementSystem(page.env) announcer.send( WikiChangeEvent("wiki", "deleted", page) ) def wiki_page_version_deleted(self, page): announcer = AnnouncementSystem(page.env) announcer.send( WikiChangeEvent("wiki", "version deleted", page) ) trac-announcer/trunk/announcer/__init__.py0000644000175500017550000000056512556767271021023 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import pkg_resources pkg_resources.require('Trac >= 1.0') __version__ = __import__('pkg_resources').get_distribution('TracAnnouncer').version trac-announcer/trunk/announcer/pref.py0000644000175500017550000001746012046602033020174 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009,2010 Robert Corsaro # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import re from genshi.filters.transform import Transformer from operator import itemgetter from pkg_resources import resource_filename from trac.core import Component, ExtensionPoint, implements from trac.prefs.api import IPreferencePanelProvider from trac.web.api import ITemplateStreamFilter from trac.web.chrome import Chrome, ITemplateProvider, add_stylesheet from announcer.api import _, tag_, N_ from announcer.api import IAnnouncementDefaultSubscriber from announcer.api import IAnnouncementDistributor from announcer.api import IAnnouncementFormatter from announcer.api import IAnnouncementPreferenceProvider from announcer.api import IAnnouncementSubscriber from announcer.model import Subscription from announcer.util.settings import encode, decode def truth(v): if v in (False, 'False', 'false', 0, '0', ''): return None return True class AnnouncerTemplateProvider(Component): """Provides templates and static resources for the announcer plugin.""" implements(ITemplateProvider) abstract = True # ITemplateProvider methods def get_htdocs_dirs(self): """Return the absolute path of a directory containing additional static resources (such as images, style sheets, etc). """ return [('announcer', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): """Return the absolute path of the directory containing the provided Genshi templates. """ return [resource_filename(__name__, 'templates')] class AnnouncerPreferences(AnnouncerTemplateProvider): implements(IPreferencePanelProvider) preference_boxes = ExtensionPoint(IAnnouncementPreferenceProvider) # IPreferencePanelProvider methods def get_preference_panels(self, req): if self.preference_boxes: yield ('announcer', _('Announcements')) def _get_boxes(self, req): for pr in self.preference_boxes: boxes = pr.get_announcement_preference_boxes(req) boxdata = {} if boxes: for boxname, boxlabel in boxes: if boxname == 'general_wiki' and \ not req.perm.has_permission('WIKI_VIEW'): continue if (boxname == 'legacy' or boxname == 'joinable_groups') and \ not req.perm.has_permission('TICKET_VIEW'): continue yield ((boxname, boxlabel) + pr.render_announcement_preference_box(req, boxname)) def render_preference_panel(self, req, panel, path_info=None): 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 req.method == 'POST': req.redirect(req.href.prefs('announcer')) add_stylesheet(req, 'announcer/css/announcer_prefs.css') return 'prefs_announcer.html', {"boxes": streams} class SubscriptionManagementPanel(AnnouncerTemplateProvider): implements(IPreferencePanelProvider, ITemplateStreamFilter) subscribers = ExtensionPoint(IAnnouncementSubscriber) default_subscribers = ExtensionPoint(IAnnouncementDefaultSubscriber) distributors = ExtensionPoint(IAnnouncementDistributor) formatters = ExtensionPoint(IAnnouncementFormatter) def __init__(self): self.post_handlers = { 'add-rule': self._add_rule, 'delete-rule': self._delete_rule, 'move-rule': self._move_rule, 'set-format': self._set_format } # IPreferencePanelProvider methods def get_preference_panels(self, req): yield ('subscriptions', _('Subscriptions')) def render_preference_panel(self, req, panel, path_info=None): if req.method == 'POST': method_arg = req.args.get('method', '') m = re.match('^([^_]+)_(.+)', method_arg) if m: method, arg = m.groups() method_func = self.post_handlers.get(method) if method_func: method_func(arg, req) else: pass else: pass # Refresh page after saving changes. req.redirect(req.href.prefs('subscriptions')) data = {'rules':{}, 'subscribers':[]} data['formatters'] = ('text/plain', 'text/html') data['selected_format'] = {} data['adverbs'] = ('always', 'never') desc_map = {} for i in self.subscribers: if not i.description(): continue if not req.session.authenticated and i.requires_authentication(): continue data['subscribers'].append({ 'class': i.__class__.__name__, 'description': i.description() }) desc_map[i.__class__.__name__] = i.description() for i in self.distributors: for j in i.transports(): data['rules'][j] = [] for r in Subscription.find_by_sid_and_distributor(self.env, req.session.sid, req.session.authenticated, j): if desc_map.get(r['class']): data['rules'][j].append({ 'id': r['id'], 'adverb': r['adverb'], 'description': desc_map[r['class']], 'priority': r['priority'] }) data['selected_format'][j] = r['format'] data['default_rules'] = {} defaults = [] for i in self.default_subscribers: defaults.extend(i.default_subscriptions()) for r in sorted(defaults, key=itemgetter(2)): klass, dist, _, adverb = r if not data['default_rules'].get(dist): data['default_rules'][dist] = [] if desc_map.get(klass): data['default_rules'][dist].append({ 'adverb': adverb, 'description': desc_map.get(klass) }) add_stylesheet(req, 'announcer/css/announcer_prefs.css') return "prefs_announcer_manage_subscriptions.html", dict(data=data) # ITemplateStreamFilter method def filter_stream(self, req, method, filename, stream, data): if re.match(r'/prefs/subscription', req.path_info): xpath_match = '//form[@id="userprefs"]//div[@class="buttons"]' stream |= Transformer(xpath_match).empty() return stream def _add_rule(self, arg, req): rule = Subscription(self.env) rule['sid'] = req.session.sid rule['authenticated'] = req.session.authenticated and 1 or 0 rule['distributor'] = arg rule['format'] = req.args.get('format-%s'%arg, '') rule['adverb'] = req.args['new-adverb-%s'%arg] rule['class'] = req.args['new-rule-%s'%arg] Subscription.add(self.env, rule) def _delete_rule(self, arg, req): Subscription.delete(self.env, arg) def _move_rule(self, arg, req): (rule_id, new_priority) = arg.split('-') if int(new_priority) >= 1: Subscription.move(self.env, rule_id, int(new_priority)) def _set_format(self, arg, req): Subscription.update_format_by_distributor_and_sid(self.env, arg, req.session.sid, req.session.authenticated, req.args['format-%s' % arg]) trac-announcer/trunk/announcer/filters.py0000644000175500017550000000574412350245165020721 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010-2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # """Filters can remove subscriptions after they are collected. This is commonly done based on access restrictions for Trac realm and resource ID, that the event is referring to (alias 'event target'). In some contexts like AccountManagerPlugin account change notifications (realm 'acct_mgr') an `IAnnouncementSubscriptionFilter` implementation is essential for meaningful operation (see announcer.opt.acct_mgr.announce.AccountManagerAnnouncement). Only subscriptions, that pass all filters, can trigger a distributor to emit a notification about an event for shipment via one of its associated transports. """ from trac.core import Component, implements from trac.config import ListOption from trac.perm import PermissionCache from announcer.api import IAnnouncementSubscriptionFilter from announcer.api import _, N_ from announcer.util import get_target_id class DefaultPermissionFilter(Component): """Simple view permission enforcement for common Trac realms. It checks, that each subscription has ${REALM}_VIEW permission for the corresponding event target, before the subscription is allowed to propagate to distributors. """ implements(IAnnouncementSubscriptionFilter) exception_realms = ListOption( 'announcer', 'filter_exception_realms', 'acct_mgr', doc=N_( """The PermissionFilter will filter announcements, for which the user doesn't have ${REALM}_VIEW permission. If there is some realm that doesn't use a permission called ${REALM}_VIEW, then you should add it to this list and create a custom filter to enforce it's permissions. Be careful, or permissions could be bypassed using the AnnouncerPlugin. """)) def filter_subscriptions(self, event, subscriptions): action = '%s_VIEW' % event.realm.upper() for subscription in subscriptions: if event.realm in self.exception_realms: yield subscription continue sid, auth = subscription[1:3] # PermissionCache already takes care of sid = None if not auth: sid = 'anonymous' perm = PermissionCache(self.env, sid) resource_id = get_target_id(event.target) self.log.debug( 'Checking *_VIEW permission on event for resource %s:%s' % (event.realm, resource_id) ) if perm.has_permission(action) and action in perm(event.realm, resource_id): yield subscription else: self.log.debug( "Filtering %s because of %s rule" % (sid, self.__class__.__name__) ) trac-announcer/trunk/announcer/subscribers.py0000644000175500017550000002461412047736150021576 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010,2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # # TODO: Test all anonymous subscribers # TODO: Subscriptions admin page import re from trac.config import BoolOption, Option, ListOption from trac.core import Component, implements from announcer.api import IAnnouncementDefaultSubscriber from announcer.api import IAnnouncementSubscriber from announcer.api import _ from announcer.model import Subscription """Subscribers should return a list of subscribers based on event rules. The subscriber interface is very simple and flexible. Subscriptions have an 'adverb' attached, 'always' or 'never'. A subscription can also stop a subscriber from recieving a notification, if it's adverb is 'never' and it is the highest priority matching subscription. One thing, that remains to be done, is to allow admin to control defaults for users, that never login and setup their subscriptions. Some of these should look to see, if the user has any subscriptions in the subscription table, and if not, then use the default setting from trac.ini. There should also be a screen in the admin section of the site, that let's the admin setup rules for users. It should be possible to copy rules from one user to another. We must also support unauthenticated users in the form of email addresses. An email address can be used in place of an sid in many places in Trac. Here's what I can think of: * Cc: field * Custom Cc: field (see opt.subscribers.TicketCustomFieldSubscriber) * Component owner (see opt.subscribers.TicketComponentOwnerSubscriber) * Ticket owner * Ticket reporter The final thing to consider is unauthenticated users, who have entered an email address in the preferences panel. To me this is the least important case and will probably be lowest priority. """ __all__ = ['CarbonCopySubscriber', 'TicketOwnerSubscriber', 'TicketReporterSubscriber', 'TicketUpdaterSubscriber'] class CarbonCopySubscriber(Component): """Carbon copy subscriber for cc ticket field.""" implements(IAnnouncementDefaultSubscriber, IAnnouncementSubscriber) default_on = BoolOption("announcer", "always_notify_cc", 'true', """The always_notify_cc will notify users in the cc field by default when a ticket is modified. """) default_distributor = ListOption("announcer", "always_notify_cc_distributor", "email", doc="""Comma-separated list of distributors to send the message to by default. ex. email, xmpp """) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != 'ticket': return if event.category not in ('created', 'changed', 'attachment added'): return klass = self.__class__.__name__ cc = event.target['cc'] or '' sids = set() for chunk in re.split('\s|,', cc): chunk = chunk.strip() if not chunk or chunk.startswith('@'): continue if re.match(r'^[^@]+@.+', chunk): sid, auth, addr = None, 0, chunk else: sid, auth, addr = chunk, 1, None # Default subscription for s in self.default_subscriptions(): yield (s[0], s[1], sid, auth, addr, None, s[2], s[3]) if sid: sids.add((sid,auth)) for s in Subscription.find_by_sids_and_class(self.env, sids, klass): yield s.subscription_tuple() def description(self): return _("notify me when I'm listed in the CC field of a ticket " "that is modified") def requires_authentication(self): return True # IAnnouncementDefaultSubscriber method def default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') class TicketOwnerSubscriber(Component): """Allows ticket owners to subscribe to their tickets.""" implements(IAnnouncementDefaultSubscriber, IAnnouncementSubscriber) default_on = BoolOption("announcer", "always_notify_owner", 'true', """The always_notify_owner option mimics the option of the same name in the notification section, except users can override it in their preferences. """) default_distributor = ListOption("announcer", "always_notify_owner_distributor", "email", doc="""Comma-separated list of distributors to send the message to by default. ex. email, xmpp """) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != "ticket": return if event.category not in ('created', 'changed', 'attachment added'): return ticket = event.target if (not ticket['owner'] or ticket['owner'] == 'anonymous') and \ not 'owner' in event.changes: return sid = sid_old = None if ticket['owner'] and ticket['owner'] != 'anonymous': if re.match(r'^[^@]+@.+', ticket['owner']): sid, auth, addr = None, 0, ticket['owner'] else: sid, auth, addr = ticket['owner'], 1, None if 'owner' in event.changes: previous_owner = event.changes['owner'] if re.match(r'^[^@]+@.+', previous_owner): sid_old, auth_old, addr_old = None, 0, previous_owner else: sid_old, auth_old, addr_old = previous_owner, 1, None # Default subscription for s in self.default_subscriptions(): if sid: yield (s[0], s[1], sid, auth, addr, None, s[2], s[3]) if sid_old: yield (s[0], s[1], sid_old, auth_old, addr_old, None, s[2], s[3]) if sid: klass = self.__class__.__name__ for s in Subscription.find_by_sids_and_class(self.env, ((sid, auth),), klass): yield s.subscription_tuple() if sid_old: klass = self.__class__.__name__ for s in Subscription.find_by_sids_and_class(self.env, ((sid_old, auth_old),), klass): yield s.subscription_tuple() def description(self): return _("notify me when a ticket that I own is created or modified") def requires_authentication(self): return True # IAnnouncementDefaultSubscriber method def default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') class TicketReporterSubscriber(Component): """Allows the users to subscribe to tickets that they report.""" implements(IAnnouncementDefaultSubscriber, IAnnouncementSubscriber) default_on = BoolOption("announcer", "always_notify_reporter", 'true', """The always_notify_reporter will notify the ticket reporter when a ticket is modified by default. """) default_distributor = ListOption("announcer", "always_notify_reporter_distributor", "email", doc="""Comma-separated list of distributors to send the message to by default. ex. email, xmpp """) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != "ticket": return if event.category not in ('created', 'changed', 'attachment added'): return ticket = event.target if not ticket['reporter'] or ticket['reporter'] == 'anonymous': return if re.match(r'^[^@]+@.+', ticket['reporter']): sid, auth, addr = None, 0, ticket['reporter'] else: sid, auth, addr = ticket['reporter'], 1, None # Default subscription for s in self.default_subscriptions(): yield (s[0], s[1], sid, auth, addr, None, s[2], s[3]) if sid: klass = self.__class__.__name__ for s in Subscription.find_by_sids_and_class(self.env, ((sid,auth),), klass): yield s.subscription_tuple() def description(self): return _("notify me when a ticket that I reported is modified") def requires_authentication(self): return True # IAnnouncementDefaultSubscriber method def default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') class TicketUpdaterSubscriber(Component): """Allows updaters to subscribe to their own updates.""" implements(IAnnouncementDefaultSubscriber, IAnnouncementSubscriber) default_on = BoolOption("announcer", "never_notify_updater", 'false', """The never_notify_updater stops users from recieving announcements when they update tickets. """) default_distributor = ListOption("announcer", "never_notify_updater_distributor", "email", doc="""Comma-separated list of distributors to send the message to by default. ex. email, xmpp """) # IAnnouncementSubscriber methods def matches(self, event): if event.realm != "ticket": return if event.category not in ('created', 'changed', 'attachment added'): return if not event.author or event.author == 'anonymous': return if re.match(r'^[^@]+@.+', event.author): sid, auth, addr = None, 0, event.author else: sid, auth, addr = event.author, 1, None # Default subscription for s in self.default_subscriptions(): yield (s[0], s[1], sid, auth, addr, None, s[2], s[3]) if sid: klass = self.__class__.__name__ for s in Subscription.find_by_sids_and_class(self.env, ((sid,auth),), klass): yield s.subscription_tuple() def description(self): return _("notify me when I update a ticket") def requires_authentication(self): return True # IAnnouncementDefaultSubscriber method def default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 100, 'never') trac-announcer/trunk/announcer/subscribers/0000755000175500017550000000000011447713040021211 5ustar debacledebacletrac-announcer/trunk/announcer/formatters.py0000644000175500017550000002635012051425100021416 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2012, Ryan J Ollos # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import difflib from genshi import HTML from genshi.template import NewTextTemplate, MarkupTemplate, TemplateLoader from trac.config import BoolOption, ListOption from trac.core import implements from trac.mimeview import Context from trac.test import Mock, MockPerm from trac.ticket.api import TicketSystem from trac.util.text import wrap, to_unicode from trac.versioncontrol.diff import diff_blocks, unified_diff from trac.web.chrome import Chrome from trac.web.href import Href from trac.wiki.formatter import HtmlFormatter from trac.wiki.model import WikiPage from announcer.api import IAnnouncementFormatter from announcer.api import _, N_ from announcer.compat import exception_to_unicode from announcer.pref import AnnouncerTemplateProvider def diff_cleanup(gen): for value in gen: if value.startswith('---'): continue if value.startswith('+++'): continue if value.startswith('@@'): yield '\n' else: yield value def lineup(gen): for value in gen: yield ' ' + value class TicketFormatter(AnnouncerTemplateProvider): implements(IAnnouncementFormatter) ticket_email_header_fields = ListOption('announcer', 'ticket_email_header_fields', 'owner, reporter, milestone, priority, severity', doc=N_("""Comma-separated list of fields to appear in tickets. Use * to include all headers.""")) ticket_link_with_comment = BoolOption('announcer', 'ticket_link_with_comment', 'false', N_("""Include last change anchor in the ticket URL.""")) def styles(self, transport, realm): if realm == "ticket": yield "text/plain" yield "text/html" def alternative_style_for(self, transport, realm, style): if realm == "ticket" and style != 'text/plain': return "text/plain" def format(self, transport, realm, style, event): if realm == "ticket": if style == "text/plain": return self._format_plaintext(event) elif style == "text/html": return self._format_html(event) def _ticket_link(self, ticket): ticket_link = self.env.abs_href('ticket', ticket.id) if self.ticket_link_with_comment == False: return ticket_link cnum = self._ticket_last_comment(ticket) if cnum != None: ticket_link += "#comment:%s" % str(cnum) return ticket_link def _ticket_last_comment(self, ticket): cnum = -1 for entry in ticket.get_changelog(): (time, author, field, oldvalue, newvalue, permanent) = entry if field != "comment": continue try: n = int(oldvalue) except: continue if cnum < n: cnum = n if cnum == -1: return None else: return cnum def _format_plaintext(self, event): ticket = event.target short_changes = {} long_changes = {} changed_items = [(field, to_unicode(old_value)) for \ field, old_value in event.changes.items()] for field, old_value in changed_items: new_value = to_unicode(ticket[field]) if ('\n' in new_value) or ('\n' in old_value): long_changes[field.capitalize()] = '\n'.join( lineup(wrap(new_value, cols=67).split('\n'))) else: short_changes[field.capitalize()] = (old_value, new_value) data = dict( ticket = ticket, author = event.author, comment = event.comment, fields = self._header_fields(ticket), category = event.category, ticket_link = self._ticket_link(ticket), project_name = self.env.project_name, project_desc = self.env.project_description, project_link = self.env.project_url or self.env.abs_href(), has_changes = short_changes or long_changes, long_changes = long_changes, short_changes = short_changes, attachment= event.attachment ) chrome = Chrome(self.env) dirs = [] for provider in chrome.template_providers: dirs += provider.get_templates_dirs() templates = TemplateLoader(dirs, variable_lookup='lenient') template = templates.load('ticket_email_plaintext.txt', cls=NewTextTemplate) if template: stream = template.generate(**data) output = stream.render('text') return output def _header_fields(self, ticket): headers = self.ticket_email_header_fields fields = TicketSystem(self.env).get_ticket_fields() if len(headers) and headers[0].strip() != '*': def _filter(i): return i['name'] in headers fields = filter(_filter, fields) return fields def _format_html(self, event): ticket = event.target attachment = event.attachment short_changes = {} long_changes = {} chrome = Chrome(self.env) for field, old_value in event.changes.items(): new_value = ticket[field] if (new_value and '\n' in new_value) or \ (old_value and '\n' in old_value): long_changes[field.capitalize()] = HTML( "
\n%s\n
" % ( '\n'.join( diff_cleanup( difflib.unified_diff( wrap(old_value, cols=60).split('\n'), wrap(new_value, cols=60).split('\n'), lineterm='', n=3 ) ) ) ) ) else: short_changes[field.capitalize()] = (old_value, new_value) def wiki_to_html(event, wikitext): if wikitext is None: return "" try: req = Mock( href=Href(self.env.abs_href()), abs_href=self.env.abs_href, authname=event.author, perm=MockPerm(), chrome=dict( warnings=[], notices=[] ), args={} ) context = Context.from_request(req, event.realm, event.target.id) formatter = HtmlFormatter(self.env, context, wikitext) return formatter.generate(True) except Exception, e: raise self.log.error("Failed to render %s", repr(wikitext)) self.log.error(exception_to_unicode(e, traceback=True)) return wikitext description = wiki_to_html(event, ticket['description']) if attachment: comment = wiki_to_html(event, attachment.description) else: comment = wiki_to_html(event, event.comment) data = dict( ticket = ticket, description = description, author = event.author, fields = self._header_fields(ticket), comment = comment, category = event.category, ticket_link = self._ticket_link(ticket), project_name = self.env.project_name, project_desc = self.env.project_description, project_link = self.env.project_url or self.env.abs_href(), has_changes = short_changes or long_changes, long_changes = long_changes, short_changes = short_changes, attachment = event.attachment, attachment_link = self.env.abs_href('attachment/ticket',ticket.id) ) chrome = Chrome(self.env) dirs = [] for provider in chrome.template_providers: dirs += provider.get_templates_dirs() templates = TemplateLoader(dirs, variable_lookup='lenient') template = templates.load('ticket_email_mimic.html', cls=MarkupTemplate) if template: stream = template.generate(**data) output = stream.render() return output class WikiFormatter(AnnouncerTemplateProvider): implements(IAnnouncementFormatter) wiki_email_diff = BoolOption('announcer', 'wiki_email_diff', "true", N_("""Should a wiki diff be sent with emails?""")) def styles(self, transport, realm): if realm == "wiki": yield "text/plain" def alternative_style_for(self, transport, realm, style): if realm == "wiki" and style != "text/plain": return "text/plain" def format(self, transport, realm, style, event): if realm == "wiki" and style == "text/plain": return self._format_plaintext(event) def _format_plaintext(self, event): page = event.target data = dict( action = event.category, attachment = event.attachment, page = page, author = event.author, comment = event.comment, category = event.category, page_link = self.env.abs_href('wiki', page.name), project_name = self.env.project_name, project_desc = self.env.project_description, project_link = self.env.project_url or self.env.abs_href(), ) old_page = WikiPage(self.env, page.name, page.version - 1) if page.version: data["changed"] = True data["diff_link"] = self.env.abs_href('wiki', page.name, action="diff", version=page.version) if self.wiki_email_diff: # DEVEL: Formatter needs req object to get preferred language. diff_header = _(""" Index: %(name)s ============================================================================== --- %(name)s (version: %(oldversion)s) +++ %(name)s (version: %(version)s) """) diff = "\n" diff += diff_header % { 'name': page.name, 'version': page.version, 'oldversion': page.version - 1 } for line in unified_diff(old_page.text.splitlines(), page.text.splitlines(), context=3): diff += "%s\n" % line data["diff"] = diff chrome = Chrome(self.env) dirs = [] for provider in chrome.template_providers: dirs += provider.get_templates_dirs() templates = TemplateLoader(dirs, variable_lookup='lenient') template = templates.load('wiki_email_plaintext.txt', cls=NewTextTemplate) if template: stream = template.generate(**data) output = stream.render('text') return output trac-announcer/trunk/announcer/filters/0000755000175500017550000000000011447713072020340 5ustar debacledebacletrac-announcer/trunk/announcer/resolvers.py0000644000175500017550000000702412045566432021272 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # from trac.config import Option from trac.core import Component, implements from trac.util.compat import sorted from announcer.api import IAnnouncementAddressResolver from announcer.api import IAnnouncementPreferenceProvider from announcer.api import _ from announcer.util.settings import SubscriptionSetting class DefaultDomainEmailResolver(Component): implements(IAnnouncementAddressResolver) default_domain = Option('announcer', 'email_default_domain', '', """Default host/domain to append to address that do not specify one""") def get_address_for_name(self, name, authenticated): if self.default_domain: return '%s@%s' % (name, self.default_domain) return None class SessionEmailResolver(Component): implements(IAnnouncementAddressResolver) def get_address_for_name(self, name, authenticated): db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT value FROM session_attribute WHERE sid=%s AND authenticated=%s AND name=%s """, (name, int(authenticated), 'email')) result = cursor.fetchone() if result: return result[0] return None class SpecifiedEmailResolver(Component): implements(IAnnouncementAddressResolver, IAnnouncementPreferenceProvider) def get_address_for_name(self, name, authenticated): db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT value FROM session_attribute WHERE sid=%s AND authenticated=1 AND name=%s """, (name,'announcer_specified_email')) result = cursor.fetchone() if result: return result[0] return None # IAnnouncementDistributor def get_announcement_preference_boxes(self, req): if req.authname != "anonymous": yield "emailaddress", _("Announcement Email Address") def render_announcement_preference_box(self, req, panel): cfg = self.config sess = req.session if req.method == "POST": opt = req.args.get('specified_email', '') sess['announcer_specified_email'] = opt specified = sess.get('announcer_specified_email', '') data = dict(specified_email = specified,) return "prefs_announcer_emailaddress.html", data class SpecifiedXmppResolver(Component): implements(IAnnouncementAddressResolver, IAnnouncementPreferenceProvider) def __init__(self): self.setting = SubscriptionSetting(self.env, 'specified_xmpp') def get_address_for_name(self, name, authed): return self.setting.get_user_setting(name)[1] # IAnnouncementDistributor def get_announcement_preference_boxes(self, req): if req.authname != "anonymous": yield "xmppaddress", "Announcement XMPP Address" def render_announcement_preference_box(self, req, panel): if req.method == "POST": self.setting.set_user_setting(req.session, req.args.get('specified_xmpp')) specified = self.setting.get_user_setting(req.session.sid)[1] or '' data = dict(specified_xmpp = specified,) return "prefs_announcer_xmppaddress.html", data trac-announcer/trunk/announcer/distributors/0000755000175500017550000000000012562171674021432 5ustar debacledebacletrac-announcer/trunk/announcer/distributors/mail.py0000644000175500017550000006124712562171674022740 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010,2012 Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # # TODO: pick format based on subscription. For now users will use the same # format for all announcements, but in the future we can make this more # flexible, since it's in the subscription table. import Queue import random import re import smtplib import sys import threading import time from email.Charset import Charset, QP, BASE64 from email.MIMEMultipart import MIMEMultipart from email.MIMEText import MIMEText from email.Utils import formatdate, formataddr try: from email.header import Header except: from email.Header import Header from subprocess import Popen, PIPE from trac.config import BoolOption, ExtensionOption, IntOption, Option, \ OrderedExtensionsOption from trac.core import * from trac.util import get_pkginfo, md5 from trac.util.compat import set, sorted from trac.util.datefmt import to_timestamp from trac.util.text import CRLF, to_unicode from announcer.api import AnnouncementSystem from announcer.api import IAnnouncementAddressResolver from announcer.api import IAnnouncementDistributor from announcer.api import IAnnouncementFormatter from announcer.api import IAnnouncementPreferenceProvider from announcer.api import IAnnouncementProducer from announcer.api import _ from announcer.model import Subscription from announcer.util.mail import set_header from announcer.util.mail_crypto import CryptoTxt class IEmailSender(Interface): """Extension point interface for components that allow sending e-mail.""" def send(self, from_addr, recipients, message): """Send message to recipients.""" class IAnnouncementEmailDecorator(Interface): def decorate_message(event, message, decorators): """Manipulate the message before it is sent on it's way. The callee should call the next decorator by popping decorators and calling the popped decorator. If decorators is empty, don't worry about it. """ class EmailDistributor(Component): implements(IAnnouncementDistributor) formatters = ExtensionPoint(IAnnouncementFormatter) # Make ordered decorators = ExtensionPoint(IAnnouncementEmailDecorator) resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers', IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\ 'SessionEmailResolver, DefaultDomainEmailResolver', """Comma seperated list of email resolver components in the order they will be called. If an email address is resolved, the remaining resolvers will not be called. """) email_sender = ExtensionOption('announcer', 'email_sender', IEmailSender, 'SmtpEmailSender', """Name of the component implementing `IEmailSender`. This component is used by the announcer system to send emails. Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided. """) enabled = BoolOption('announcer', 'email_enabled', 'true', """Enable email notification.""") email_from = Option('announcer', 'email_from', 'trac@localhost', """Sender address to use in notification emails.""") from_name = Option('announcer', 'email_from_name', '', """Sender name to use in notification emails.""") reply_to = Option('announcer', 'email_replyto', 'trac@localhost', """Reply-To address to use in notification emails.""") mime_encoding = Option('announcer', 'mime_encoding', 'base64', """Specifies the MIME encoding scheme for emails. Valid options are 'base64' for Base64 encoding, 'qp' for Quoted-Printable, and 'none' for no encoding. Note that the no encoding means that non-ASCII characters in text are going to cause problems with notifications. """) use_public_cc = BoolOption('announcer', 'use_public_cc', 'false', """Recipients can see email addresses of other CC'ed recipients. If this option is disabled (the default), recipients are put on BCC """) # used in email decorators, but not here subject_prefix = Option('announcer', 'email_subject_prefix', '__default__', """Text to prepend to subject line of notification emails. If the setting is not defined, then the [$project_name] prefix. If no prefix is desired, then specifying an empty option will disable it. """) to_default = 'undisclosed-recipients: ;' to = Option('announcer', 'email_to', to_default, 'Default To: field') use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery', 'false', """Do message delivery in a separate thread. Enabling this will improve responsiveness for requests that end up with an announcement being sent over email. It requires building Python with threading support enabled-- which is usually the case. To test, start Python and type 'import threading' to see if it raises an error. """) default_email_format = Option('announcer', 'default_email_format', 'text/plain', """The default mime type of the email notifications. This can be overridden on a per user basis through the announcer preferences panel. """) rcpt_allow_regexp = Option('announcer', 'rcpt_allow_regexp', '', """A whitelist pattern to match any address to before adding to recipients list. """) rcpt_local_regexp = Option('announcer', 'rcpt_local_regexp', '', """A whitelist pattern to match any address, that should be considered local. This will be evaluated only if msg encryption is set too. Recipients with matching email addresses will continue to receive unencrypted email messages. """) crypto = Option('announcer', 'email_crypto', '', """Enable cryptographically operation on email msg body. Empty string, the default for unset, disables all crypto operations. Valid values are: sign sign msg body with given privkey encrypt encrypt msg body with pubkeys of all recipients sign,encrypt sign, than encrypt msg body """) # get GnuPG configuration options gpg_binary = Option('announcer', 'gpg_binary', 'gpg', """GnuPG binary name, allows for full path too. Value 'gpg' is same default as in python-gnupg itself. For usual installations location of the gpg binary is auto-detected. """) gpg_home = Option('announcer', 'gpg_home', '', """Directory containing keyring files. In case of wrong configuration missing keyring files without content will be created in the configured location, provided necessary write permssion is granted for the corresponding parent directory. """) private_key = Option('announcer', 'gpg_signing_key', None, """Keyid of private key (last 8 chars or more) used for signing. If unset, a private key will be selected from keyring automagicly. The password must be available i.e. provided by running gpg-agent or empty (bad security). On failing to unlock the private key, msg body will get emptied. """) def __init__(self): self.delivery_queue = None self._init_pref_encoding() def get_delivery_queue(self): if not self.delivery_queue: self.delivery_queue = Queue.Queue() thread = DeliveryThread(self.delivery_queue, self.send) thread.start() return self.delivery_queue # IAnnouncementDistributor def transports(self): yield "email" def formats(self, transport, realm): "Find valid formats for transport and realm" formats = {} for f in self.formatters: for style in f.styles(transport, realm): formats[style] = f self.log.debug( "EmailDistributor has found the following formats capable " "of handling '%s' of '%s': %s"%(transport, realm, ', '.join(formats.keys()))) if not formats: self.log.error("EmailDistributor is unable to continue " \ "without supporting formatters.") return formats def distribute(self, transport, recipients, event): found = False for supported_transport in self.transports(): if supported_transport == transport: found = True if not self.enabled or not found: self.log.debug("EmailDistributer email_enabled set to false") return fmtdict = self.formats(transport, event.realm) if not fmtdict: self.log.error( "EmailDistributer No formats found for %s %s"%( transport, event.realm)) return msgdict = {} msgdict_encrypt = {} msg_pubkey_ids = [] # compile pattern before use for better performance RCPT_ALLOW_RE = re.compile(self.rcpt_allow_regexp) RCPT_LOCAL_RE = re.compile(self.rcpt_local_regexp) if self.crypto != '': self.log.debug("EmailDistributor attempts crypto operation.") self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home) for name, authed, addr in recipients: fmt = name and \ self._get_preferred_format(event.realm, name, authed) or \ self._get_default_format() if fmt not in fmtdict: self.log.debug(("EmailDistributer format %s not available " + "for %s %s, looking for an alternative")%( fmt, transport, event.realm)) # If the fmt is not available for this realm, then try to find # an alternative oldfmt = fmt fmt = None for f in fmtdict.values(): fmt = f.alternative_style_for( transport, event.realm, oldfmt) if fmt: break if not fmt: self.log.error( "EmailDistributer was unable to find a formatter " + "for format %s"%k ) continue rslvr = None if name and not addr: # figure out what the addr should be if it's not defined for rslvr in self.resolvers: addr = rslvr.get_address_for_name(name, authed) if addr: break if addr: self.log.debug("EmailDistributor found the " \ "address '%s' for '%s (%s)' via: %s"%( addr, name, authed and \ 'authenticated' or 'not authenticated', rslvr.__class__.__name__)) # ok, we found an addr, add the message # but wait, check for allowed rcpt first, if set if RCPT_ALLOW_RE.search(addr) is not None: # check for local recipients now local_match = RCPT_LOCAL_RE.search(addr) if self.crypto in ['encrypt', 'sign,encrypt'] and \ local_match is None: # search available public keys for matching UID pubkey_ids = self.enigma.get_pubkey_ids(addr) if len(pubkey_ids) > 0: msgdict_encrypt.setdefault(fmt, set()).add((name, authed, addr)) msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids self.log.debug("EmailDistributor got pubkeys " \ "for %s: %s" % (addr, pubkey_ids)) else: self.log.debug("EmailDistributor dropped %s " \ "after missing pubkey with corresponding " \ "address %s in any UID" % (name, addr)) else: msgdict.setdefault(fmt, set()).add((name, authed, addr)) if local_match is not None: self.log.debug("EmailDistributor expected " \ "local delivery for %s to: %s" % (name, addr)) else: self.log.debug("EmailDistributor dropped %s for " \ "not matching allowed recipient pattern %s" % \ (addr, self.rcpt_allow_regexp)) else: self.log.debug("EmailDistributor was unable to find an " \ "address for: %s (%s)"%(name, authed and \ 'authenticated' or 'not authenticated')) for k, v in msgdict.items(): if not v or not fmtdict.get(k): continue self.log.debug( "EmailDistributor is sending event as '%s' to: %s"%( fmt, ', '.join(x[2] for x in v))) self._do_send(transport, event, k, v, fmtdict[k]) for k, v in msgdict_encrypt.items(): if not v or not fmtdict.get(k): continue self.log.debug( "EmailDistributor is sending encrypted info on event " \ "as '%s' to: %s"%(fmt, ', '.join(x[2] for x in v))) self._do_send(transport, event, k, v, fmtdict[k], msg_pubkey_ids) def _get_default_format(self): return self.default_email_format def _get_preferred_format(self, realm, sid, authenticated): if authenticated is None: authenticated = 0 # Format is unified for all subscriptions of a user. result = Subscription.find_by_sid_and_distributor( self.env, sid, authenticated, 'email') if result: chosen = result[0]['format'] self.log.debug("EmailDistributor determined the preferred format" \ " for '%s (%s)' is: %s"%(sid, authenticated and \ 'authenticated' or 'not authenticated', chosen)) return chosen else: return self._get_default_format() def _init_pref_encoding(self): self._charset = Charset() self._charset.input_charset = 'utf-8' pref = self.mime_encoding.lower() if pref == 'base64': self._charset.header_encoding = BASE64 self._charset.body_encoding = BASE64 self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref in ['qp', 'quoted-printable']: self._charset.header_encoding = QP self._charset.body_encoding = QP self._charset.output_charset = 'utf-8' self._charset.input_codec = 'utf-8' self._charset.output_codec = 'utf-8' elif pref == 'none': self._charset.header_encoding = None self._charset.body_encoding = None self._charset.input_codec = None self._charset.output_charset = 'ascii' else: raise TracError(_('Invalid email encoding setting: %s'%pref)) def _message_id(self, realm): """Generate a predictable, but sufficiently unique message ID.""" modtime = time.time() rand = random.randint(0,32000) s = '%s.%d.%d.%s' % (self.env.project_url, modtime, rand, realm.encode('ascii', 'ignore')) dig = md5(s).hexdigest() host = self.email_from[self.email_from.find('@') + 1:] msgid = '<%03d.%s@%s>' % (len(s), dig, host) return msgid def _filter_recipients(self, rcpt): return rcpt def _do_send(self, transport, event, format, recipients, formatter, pubkey_ids=[]): # Prepare sender for use in IEmailSender component and message header. from_header = formataddr( (self.from_name and self.from_name or self.env.project_name, self.email_from) ) headers = dict() headers['Message-ID'] = self._message_id(event.realm) headers['Date'] = formatdate() headers['From'] = from_header headers['Reply-To'] = self.reply_to recip_adds = [x[2] for x in recipients if x] if self.use_public_cc: headers['Cc'] = ', '.join(recip_adds) else: # Use localized Bcc: hint for default To: content. if self.to == self.to_default: headers['To'] = _('undisclosed-recipients: ;') else: headers['To'] = '"%s"' % self.to if self.to: recip_adds += [self.to] if not recip_adds: self.log.debug( "EmailDistributor stopped (no recipients)." ) return self.log.debug("All email recipients: %s" % recip_adds) rootMessage = MIMEMultipart("related") # TODO: Is this good? (from jabber branch) #rootMessage.set_charset(self._charset) # Write header data into message object. for k, v in headers.iteritems(): set_header(rootMessage, k, v) output = formatter.format(transport, event.realm, format, event) # DEVEL: Currently crypto operations work with format text/plain only. if self.crypto != '' and pubkey_ids != []: if self.crypto == 'sign': output = self.enigma.sign(output, self.private_key) elif self.crypto == 'encrypt': output = self.enigma.encrypt(output, pubkey_ids) elif self.crypto == 'sign,encrypt': output = self.enigma.sign_encrypt(output, pubkey_ids, self.private_key) self.log.debug(output) self.log.debug("EmailDistributor crypto operation successful.") alternate_output = None else: alternate_style = formatter.alternative_style_for( transport, event.realm, format ) if alternate_style: alternate_output = formatter.format( transport, event.realm, alternate_style, event ) else: alternate_output = None # Sanity check for suitable encoding setting. if not self._charset.body_encoding: try: dummy = output.encode('ascii') except UnicodeDecodeError: raise TracError(_("Ticket contains non-ASCII chars. " \ "Please change encoding setting")) rootMessage.preamble = 'This is a multi-part message in MIME format.' if alternate_output: parentMessage = MIMEMultipart('alternative') rootMessage.attach(parentMessage) alt_msg_format = 'html' in alternate_style and 'html' or 'plain' if isinstance(alternate_output, unicode): alternate_output = alternate_output.encode('utf-8') msgText = MIMEText(alternate_output, alt_msg_format) msgText.set_charset(self._charset) parentMessage.attach(msgText) else: parentMessage = rootMessage msg_format = 'html' in format and 'html' or 'plain' if isinstance(output, unicode): output = output.encode('utf-8') msgText = MIMEText(output, msg_format) del msgText['Content-Transfer-Encoding'] msgText.set_charset(self._charset) # According to RFC 2046, the last part of a multipart message is best # and preferred. parentMessage.attach(msgText) # DEVEL: Decorators can interfere with crypto operation here. Fix it. decorators = self._get_decorators() if len(decorators) > 0: decorator = decorators.pop() decorator.decorate_message(event, rootMessage, decorators) package = (from_header, recip_adds, rootMessage.as_string()) start = time.time() if self.use_threaded_delivery: self.get_delivery_queue().put(package) else: self.send(*package) stop = time.time() self.log.debug("EmailDistributor took %s seconds to send." % (round(stop - start, 2))) def send(self, from_addr, recipients, message): """Send message to recipients via e-mail.""" # Ensure the message complies with RFC2822: use CRLF line endings message = CRLF.join(re.split("\r?\n", message)) self.email_sender.send(from_addr, recipients, message) def _get_decorators(self): return self.decorators[:] class SmtpEmailSender(Component): """E-mail sender connecting to an SMTP server.""" implements(IEmailSender) server = Option('smtp', 'server', 'localhost', """SMTP server hostname to use for email notifications.""") timeout = IntOption('smtp', 'timeout', 10, """SMTP server connection timeout. (requires python-2.6)""") port = IntOption('smtp', 'port', 25, """SMTP server port to use for email notification.""") user = Option('smtp', 'user', '', """Username for SMTP server.""") password = Option('smtp', 'password', '', """Password for SMTP server.""") use_tls = BoolOption('smtp', 'use_tls', 'false', """Use SSL/TLS to send notifications over SMTP.""") use_ssl = BoolOption('smtp', 'use_ssl', 'false', """Use ssl for smtp connection.""") debuglevel = IntOption('smtp', 'debuglevel', 0, """Set to 1 for useful smtp debugging on stdout.""") def send(self, from_addr, recipients, message): # use defaults to make sure connect() is called in the constructor smtpclass = smtplib.SMTP if self.use_ssl: smtpclass = smtplib.SMTP_SSL args = { 'host': self.server, 'port': self.port } # timeout isn't supported until python 2.6 vparts = sys.version_info[0:2] if vparts[0] >= 2 and vparts[1] >= 6: args['timeout'] = self.timeout smtp = smtpclass(**args) smtp.set_debuglevel(self.debuglevel) if self.use_tls: smtp.ehlo() if not smtp.esmtp_features.has_key('starttls'): raise TracError(_("TLS enabled but server does not support " \ "TLS")) smtp.starttls() smtp.ehlo() if self.user: smtp.login( self.user.encode('utf-8'), self.password.encode('utf-8') ) smtp.sendmail(from_addr, recipients, message) if self.use_tls or self.use_ssl: # avoid false failure detection when the server closes # the SMTP connection with TLS/SSL enabled import socket try: smtp.quit() except socket.sslerror: pass else: smtp.quit() class SendmailEmailSender(Component): """E-mail sender using a locally-installed sendmail program.""" implements(IEmailSender) sendmail_path = Option('sendmail', 'sendmail_path', 'sendmail', """Path to the sendmail executable. The sendmail program must accept the `-i` and `-f` options. """) def send(self, from_addr, recipients, message): self.log.info("Sending notification through sendmail at %s to %s" % (self.sendmail_path, recipients)) cmdline = [self.sendmail_path, "-i", "-f", from_addr] cmdline.extend(recipients) self.log.debug("Sendmail command line: %s" % ' '.join(cmdline)) try: child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE, stderr=PIPE) (out, err) = child.communicate(message) if child.returncode or err: raise Exception("Sendmail failed with (%s, %s), command: '%s'" % (child.returncode, err.strip(), cmdline)) except OSError, e: self.log.error("Failed to run sendmail[%s] with error %s"%\ (self.sendmail_path, e)) class DeliveryThread(threading.Thread): def __init__(self, queue, sender): threading.Thread.__init__(self) self._sender = sender self._queue = queue self.setDaemon(True) def run(self): while 1: sendfrom, recipients, message = self._queue.get() self._sender(sendfrom, recipients, message) trac-announcer/trunk/announcer/distributors/__init__.py0000644000175500017550000000000011335531124023514 0ustar debacledebacletrac-announcer/trunk/announcer/distributors/xmppd.py0000644000175500017550000002363012350245165023130 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import Queue import time from threading import Thread from xmpp import Client from xmpp.protocol import Message, JID from trac.config import Option, BoolOption, IntOption, OrderedExtensionsOption from trac.core import * from trac.util.compat import set from announcer.api import IAnnouncementDistributor from announcer.api import IAnnouncementPreferenceProvider from announcer.api import IAnnouncementAddressResolver from announcer.api import IAnnouncementFormatter from announcer.api import IAnnouncementProducer from announcer.resolvers import SpecifiedXmppResolver from announcer.util.settings import SubscriptionSetting class XmppDistributor(Component): """Distribute announcements to XMPP clients.""" implements(IAnnouncementDistributor) formatters = ExtensionPoint(IAnnouncementFormatter) resolvers = OrderedExtensionsOption('announcer', 'xmpp_resolvers', IAnnouncementAddressResolver, 'SpecifiedXmppResolver', """Comma seperated list of xmpp resolver components in the order they will be called. If an xmpp address is resolved, the remaining resolvers will no be called. """) default_format = Option('announcer', 'default_xmpp_format', 'text/plain', """Default format for xmpp messages.""") server = Option('xmpp', 'server', None, """XMPP server hostname to use for jabber notifications.""") port = IntOption('xmpp', 'port', 5222, """XMPP server port to use for jabber notification.""") user = Option('xmpp', 'user', 'trac@localhost', """Sender address to use in xmpp message.""") resource = Option('xmpp', 'resource', 'TracAnnouncerPlugin', """Sender resource to use in xmpp message.""") password = Option('xmpp', 'password', None, """Password for XMPP server.""") use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery', False, """If true, the actual delivery of the message will occur in a separate thread. Enabling this will improve responsiveness for requests that end up with an announcement being sent over email. It requires building Python with threading support enabled-- which is usually the case. To test, start Python and type 'import threading' to see if it raises an error. """) def __init__(self): self.connections = {} self.delivery_queue = None self.xmpp_format_setting = SubscriptionSetting(self.env, 'xmpp_format', self.default_format) def get_delivery_queue(self): if not self.delivery_queue: self.delivery_queue = Queue.Queue() thread = DeliveryThread(self.delivery_queue, self.send) thread.start() return self.delivery_queue # IAnnouncementDistributor def transports(self): yield "xmpp" def distribute(self, transport, recipients, event): self.log.info('XmppDistributor called') if transport != 'xmpp': return fmtdict = self._formats(transport, event.realm) if not fmtdict: self.log.error( "XmppDistributor No formats found for %s %s"%( transport, event.realm)) return msgdict = {} for name, authed, addr in recipients: fmt = name and \ self._get_preferred_format(name, event.realm) if fmt not in fmtdict: self.log.debug(("XmppDistributor format %s not available " + "for %s %s, looking for an alternative")%( fmt, transport, event.realm)) # If the fmt is not available for this realm, then try to find # an alternative oldfmt = fmt fmt = None for f in fmtdict.values(): fmt = f.alternative_style_for( transport, event.realm, oldfmt) if fmt: break if not fmt: self.log.error( "XmppDistributor was unable to find a formatter " + "for format %s"%k ) continue # TODO: This won't work with multiple distributors #rslvr = None # figure out what the addr should be if it's not defined #for rslvr in self.resolvers: # addr = rslvr.get_address_for_name(name, authed) # if addr: break rslvr = SpecifiedXmppResolver(self.env) addr = rslvr.get_address_for_name(name, authed) if addr: self.log.debug("XmppDistributor found the " \ "address '%s' for '%s (%s)' via: %s"%( addr, name, authed and \ 'authenticated' or 'not authenticated', rslvr.__class__.__name__)) # ok, we found an addr, add the message msgdict.setdefault(fmt, set()).add((name, authed, addr)) else: self.log.debug("XmppDistributor was unable to find an " \ "address for: %s (%s)"%(name, authed and \ 'authenticated' or 'not authenticated')) for k, v in msgdict.items(): if not v or not fmtdict.get(k): continue self.log.debug( "XmppDistributor is sending event as '%s' to: %s"%( fmt, ', '.join(x[2] for x in v))) self._do_send(transport, event, k, v, fmtdict[k]) def _formats(self, transport, realm): "Find valid formats for transport and realm" formats = {} for f in self.formatters: for style in f.styles(transport, realm): formats[style] = f self.log.debug( "XmppDistributor has found the following formats capable " "of handling '%s' of '%s': %s"%(transport, realm, ', '.join(formats.keys()))) if not formats: self.log.error("XmppDistributor is unable to continue " \ "without supporting formatters.") return formats def _get_preferred_format(self, sid, realm=None): if realm: name = 'xmpp_format_%s'%realm else: name = 'xmpp_format' setting = SubscriptionSetting(self.env, name, self.xmpp_format_setting.default) return self.xmpp_format_setting.get_user_setting(sid)[0] def _do_send(self, transport, event, format, recipients, formatter): message = formatter.format(transport, event.realm, format, event) package = (recipients, message) start = time.time() if self.use_threaded_delivery: self.get_delivery_queue().put(package) else: self.send(*package) stop = time.time() self.log.debug("XmppDistributor took %s seconds to send."\ %(round(stop-start,2))) def send(self, recipients, message): """Send message to recipients via xmpp.""" jid = JID(self.user) if self.server: server = self.server else: server = jid.getDomain() cl = Client(server, port=self.port, debug=[]) if not cl.connect(): raise IOError("Couldn't connect to xmpp server %s"%server) if not cl.auth(jid.getNode(), self.password, resource=self.resource): cl.Connection.disconnect() raise IOError("Xmpp auth erro using %s to %s"%(jid, server)) default_domain = jid.getDomain() for recip in recipients: cl.send(Message(recip[2], message)) class XmppPreferencePanel(Component): implements(IAnnouncementPreferenceProvider) formatters = ExtensionPoint(IAnnouncementFormatter) producers = ExtensionPoint(IAnnouncementProducer) distributors = ExtensionPoint(IAnnouncementDistributor) def get_announcement_preference_boxes(self, req): yield "xmpp", "XMPP Formats" def render_announcement_preference_box(self, req, panel): supported_realms = {} for producer in self.producers: for realm in producer.realms(): for distributor in self.distributors: for transport in distributor.transports(): for fmtr in self.formatters: for style in fmtr.styles(transport, realm): if realm not in supported_realms: supported_realms[realm] = set() supported_realms[realm].add(style) settings = {} for realm in supported_realms: name = 'xmpp_format_%s'%realm settings[realm] = SubscriptionSetting(self.env, name, XmppDistributor(self.env).xmpp_format_setting.default) if req.method == "POST": for realm, setting in settings.items(): name = 'xmpp_format_%s'%realm setting.set_user_setting(req.session, req.args.get(name), save=False) req.session.save() prefs = {} for realm, setting in settings.items(): prefs[realm] = setting.get_user_setting(req.session.sid)[0] data = dict( realms = supported_realms, preferences = prefs, ) return "prefs_announcer_xmpp.html", data class DeliveryThread(Thread): def __init__(self, queue, sender): Thread.__init__(self) self._sender = sender self._queue = queue self.setDaemon(True) def run(self): while 1: sendfrom, recipients, message = self._queue.get() self._sender(sendfrom, recipients, message) trac-announcer/trunk/announcer/upgrades/0000755000175500017550000000000012350245165020477 5ustar debacledebacletrac-announcer/trunk/announcer/upgrades/db6.py0000644000175500017550000000300312350245165021520 0ustar debacledebacleimport time from datetime import datetime from trac.util.datefmt import utc from announcer.compat import to_utimestamp def do_upgrade(env, ver, cursor): """Convert time stamp data and register announcer db schema in `system` db table. """ cursor.execute(""" SELECT id,time,changetime FROM subscription """) result = cursor.fetchall() if result: cursor.executemany(""" UPDATE subscription SET time=%s,changetime=%s WHERE id=%s """, [(_iso8601_to_ts(row[1]), _iso8601_to_ts(row[2]), row[0]) for row in result]) cursor.execute(""" SELECT COUNT(*) FROM system WHERE name='announcer_version' """) exists = cursor.fetchone() if not exists[0]: # Play safe for upgrades from announcer<1.0, that had no version entry. cursor.execute(""" INSERT INTO system (name, value) VALUES ('announcer_version', '6') """) def _iso8601_to_ts(s): """Parse ISO-8601 string to microsecond POSIX timestamp.""" try: s = str(s) if s.isnumeric(): # Valid type, no conversion required. return long(s) tm = time.strptime(s, '%Y-%m-%d %H:%M:%S') dt = datetime(*(tm[0:6] + (0, utc))) return to_utimestamp(dt) except (AttributeError, TypeError, ValueError): # Create a valid timestamp anyway. return to_utimestamp(datetime.now(utc)) trac-announcer/trunk/announcer/upgrades/db2.py0000644000175500017550000000272312350245165021524 0ustar debacledebaclefrom trac.db import Table, Column, Index, DatabaseManager schema = [ Table('subscriptions', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('authenticated', type='int'), Column('enabled', type='int'), Column('managed'), Column('realm'), Column('category'), Column('rule'), Column('transport'), Index(['id']), Index(['realm', 'category', 'enabled']), ] ] def do_upgrade(env, ver, cursor): """Changes to subscription db table: - 'subscriptions.destination', 'subscriptions.format' + 'subscriptions.authenticated', 'subscriptions.transport' 'subscriptions.managed' type='int' --> (default == char) """ cursor.execute(""" CREATE TEMPORARY TABLE subscriptions_old AS SELECT * FROM subscriptions """) cursor.execute("DROP TABLE subscriptions") connector = DatabaseManager(env)._get_connector()[0] for table in schema: for stmt in connector.to_sql(table): cursor.execute(stmt) cursor.execute(""" INSERT INTO subscriptions (sid,authenticated,enabled,managed, realm,category,rule,transport) SELECT o.sid,s.authenticated,o.enabled,'watcher', o.realm,o.category,rule,'email' FROM subscriptions_old AS o LEFT JOIN session AS s ON o.sid=s.sid """) cursor.execute("DROP TABLE subscriptions_old") trac-announcer/trunk/announcer/upgrades/__init__.py0000644000175500017550000000000012045565406022602 0ustar debacledebacletrac-announcer/trunk/announcer/upgrades/db5.py0000644000175500017550000000243112045565613021527 0ustar debacledebaclefrom trac.db import Table, Column, Index, DatabaseManager schema = [ Table('subscription_attribute', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('authenticated', type='int'), Column('class'), Column('realm'), Column('target') ] ] def do_upgrade(env, ver, cursor): """Change `subscription_attribute` db table: + 'subscription_attribute.authenticated' """ cursor.execute(""" CREATE TEMPORARY TABLE subscription_attribute_old AS SELECT * FROM subscription_attribute """) cursor.execute("DROP TABLE subscription_attribute") connector = DatabaseManager(env)._get_connector()[0] for table in schema: for stmt in connector.to_sql(table): cursor.execute(stmt) cursor.execute(""" INSERT INTO subscription_attribute (sid,authenticated,class,realm,target) SELECT o.sid,s.authenticated,o.class,o.realm,o.target FROM subscription_attribute_old AS o LEFT JOIN session AS s ON o.sid=s.sid """) cursor.execute("DROP TABLE subscription_attribute_old") # DEVEL: Think that an old 'subscriptions' db table may still exist here. cursor.execute("DROP TABLE IF EXISTS subscriptions") trac-announcer/trunk/announcer/upgrades/db4.py0000644000175500017550000000453012045565562021533 0ustar debacledebaclefrom trac.db import Table, Column, Index, DatabaseManager schema = [ Table('subscription', key='id')[ Column('id', auto_increment=True), Column('time', type='int64'), Column('changetime', type='int64'), Column('class'), Column('sid'), Column('authenticated', type='int'), Column('distributor'), Column('format'), Column('priority', type='int'), Column('adverb') ], Table('subscription_attribute', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('class'), Column('realm'), Column('target') ] ] def do_upgrade(env, ver, cursor): """Migrate old `subscriptions` db table. Changes to other tables: 'subscription.priority' type=(default == char) --> 'int' 'subscription_attribute.name --> 'subscription_attribute.realm' 'subscription_attribute.value --> 'subscription_attribute.target' """ cursor.execute(""" CREATE TEMPORARY TABLE subscription_old AS SELECT * FROM subscription """) cursor.execute("DROP TABLE subscription") cursor.execute(""" CREATE TEMPORARY TABLE subscription_attribute_old AS SELECT * FROM subscription_attribute """) cursor.execute("DROP TABLE subscription_attribute") connector = DatabaseManager(env)._get_connector()[0] for table in schema: for stmt in connector.to_sql(table): cursor.execute(stmt) db = env.get_db_cnx() # Convert priority values to integer. cursor.execute(""" INSERT INTO subscription (time,changetime,class,sid,authenticated, distributor,format,priority,adverb) SELECT o.time,o.changetime,o.class,o.sid,o.authenticated, o.distributor,o.format,%s,o.adverb FROM subscription_old AS o """ % db.cast('o.priority', 'int')) cursor.execute("DROP TABLE subscription_old") # Copy table on column name change. cursor.execute(""" INSERT INTO subscription_attribute (sid,class,realm,target) SELECT o.sid,o.class,o.name,o.value FROM subscription_attribute_old AS o """) cursor.execute("DROP TABLE subscription_attribute_old") # DEVEL: Migrate old subscription db table data. cursor.execute("DROP TABLE IF EXISTS subscriptions") trac-announcer/trunk/announcer/upgrades/db3.py0000644000175500017550000000161612045565525021533 0ustar debacledebaclefrom trac.db import Table, Column, Index, DatabaseManager schema = [ Table('subscription', key='id')[ Column('id', auto_increment=True), Column('time', type='int64'), Column('changetime', type='int64'), Column('class'), Column('sid'), Column('authenticated', type='int'), Column('distributor'), Column('format'), Column('priority'), Column('adverb') ], Table('subscription_attribute', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('class'), Column('name'), Column('value') ] ] def do_upgrade(env, ver, cursor): """Add two more subscription db tables for a better normalized schema.""" connector = DatabaseManager(env)._get_connector()[0] for table in schema: for stmt in connector.to_sql(table): cursor.execute(stmt) trac-announcer/trunk/announcer/locale/0000755000175500017550000000000012004067606020122 5ustar debacledebacletrac-announcer/trunk/announcer/locale/.placeholder0000644000175500017550000000024411660505707022413 0ustar debacledebacle# DO NOT REMOVE # This file helps setuptools to always include the 'locale' directory on # packaging, so Trac doesn't blow-up on loading plugins from Python eggs. trac-announcer/trunk/announcer/locale/zh_CN/0000755000175500017550000000000011471761426021133 5ustar debacledebacletrac-announcer/trunk/announcer/locale/zh_CN/LC_MESSAGES/0000755000175500017550000000000011471761426022720 5ustar debacledebacletrac-announcer/trunk/announcer/locale/zh_CN/LC_MESSAGES/announcer.po0000644000175500017550000005672711471761426025271 0ustar debacledebacle# Chinese (China) translations for TracAnnouncer. # Copyright (C) 2010 # This file is distributed under the same license as the AnnouncerPlugin # project. # Jake Li , 2010. # zhangyingneng , 2010 # msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "Report-Msgid-Bugs-To: hoff.st@web.de\n" "POT-Creation-Date: 2010-11-09 22:45+0100\n" "PO-Revision-Date: 2010-07-23 15:35+0800\n" "Last-Translator: zhangyingneng \n" "Language-Team: Simplified Chinese\n" "Plural-Forms: nplurals=1; plural=0\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 1.0dev-r482\n" #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "订阅设置" #: announcer/pref.py:129 #, fuzzy msgid "Subscriptions" msgstr "描述" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "邮件地址设置" #: announcer/subscribers.py:98 #, fuzzy msgid "notify me when any ticket changes" msgstr "别通知我关于那些我自己作的改动" #: announcer/subscribers.py:159 msgid "notify me when a ticket that I own is created or modified" msgstr "" #: announcer/subscribers.py:221 #, fuzzy msgid "" "notify me when a ticket that belongs to a component that I own is created" " or modified" msgstr "我的组件被改动时通知我" #: announcer/subscribers.py:274 #, fuzzy msgid "notify me when I update a ticket" msgstr "当我改动一个任务单时通知我" #: announcer/subscribers.py:327 #, fuzzy msgid "notify me when a ticket that I reported is modified" msgstr "当我创建的任务单有改动时通知我" #: announcer/subscribers.py:385 msgid "notify me when I'm listed in the CC field of a ticket that is modified" msgstr "" #: announcer/subscribers.py:424 msgid "" "notify me when a ticket associated with a component I'm watching is " "modified" msgstr "" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "" #: announcer/subscribers.py:526 #, python-format msgid "notify me when I'm listed in any of the (%s) fields" msgstr "" #: announcer/subscribers.py:583 msgid "notify me on ticket changes in one of my subscribed groups" msgstr "" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "" #: announcer/subscribers.py:641 msgid "notify me when one of my watched users changes something" msgstr "" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "订阅用户的动作" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "您不会再收到该页面改动时的邮件通知" #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "您将收到该页面改动时的邮件通知" #: announcer/subscribers.py:847 msgid "notify me when one of my watched wiki or tickets is updated" msgstr "" #: announcer/subscribers.py:896 msgid "" "notify me when a wiki that matches my wiki watch pattern is created, or " "updated" msgstr "" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "高级Wiki订阅" #: announcer/distributors/mail.py:411 #, python-format msgid "Invalid email encoding setting: %s" msgstr "无效的邮件编码设置: %s" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "" #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "任务单中含有非ASCII字符,请调整字符编码设置" #: announcer/distributors/mail.py:519 msgid "undisclosed-recipients: ;" msgstr "秘送" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "邮件服务器不支持TLS" #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "帐号改动订阅" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "成功" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "失败" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "" #: announcer/opt/fullblog/announce.py:95 msgid "notify me when any blog is modified, changed, deleted or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:145 msgid "notify me when any blog that I posted is modified or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "取消订阅" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "订阅" #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:238 msgid "You are now watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" ">\n" "用户名:" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "密码:" #: announcer/templates/acct_mgr_user_change_plaintext.txt:1 msgid "for user" msgstr "" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "build of" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "" "]\n" "---------------------------------------------------------------------\n" "\n" " Changeset:" msgstr "" #: announcer/templates/bitten_plaintext.txt:4 msgid "" ">\n" " Committed by:" msgstr "" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "" #: announcer/templates/bitten_plaintext.txt:9 #, python-format msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "" #: announcer/templates/bitten_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "--\n" "Build URL: <" msgstr "" #: announcer/templates/fullblog_plaintext.txt:1 #, python-format msgid "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Added post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "\" by" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "at" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Changed post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:8 #, python-format msgid "" "{% end %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:15 #, python-format msgid "" "{% if comment %}\\\n" "Comment:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Deleted post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:25 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 #, python-format msgid "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Comment added to post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "页面URL:" #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "内容:" #: announcer/templates/fullblog_plaintext.txt:37 #, python-format msgid "{% end %}" msgstr "" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to you; \n" " the creation of a ticket, the change of a Wiki page, and so on. " "Under\n" " the Announcement system, you will only receive notifications to " "those\n" " topics that you subscribe to." msgstr "订阅系统会以邮件形式通知您Trac中发生的事件,比如:创建任务单,修改wiki页面." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "订阅用户帐号的改动" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "当有新用户注册成功时通知我" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 msgid "Send me announcements when users accounts are changed." msgstr "当用户帐号信息改变时通知我" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "当用户销户时通知我" #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "设置是否邮件通知我自己的改动" #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "别通知我关于那些我自己作的改动" #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a plaintext " "format. You\n" " may override this for each realm that may generate announcements." msgstr "" "默认情况下,将以纯文本方式通知您\n" "您可以自定义格式:" #: announcer/templates/prefs_announcer_email.html:9 #: announcer/templates/prefs_announcer_xmpp.html:7 msgid "announcements:" msgstr "邮件格式:" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different " "address then the main one provided\n" " in Trac, you may specify the address here:" msgstr "默认会使用您在trac中的邮件地址. 您也可以在此设置特定的邮件地址:" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "邮件地址:" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following components " "have been defined by the Trac administrators. If you subscribe to any of" " these components, you will receive an notification anytime a ticket " "related to that component is changed or created." msgstr "以下是现有的一些组件.如果您订阅了一个组件,那么该所有属于该组件的任务单的改动都会通知您." #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. They " "are general topics that may be added onto the CC list of tickets (by " "prepending their name with @). Case does matter." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "我的组件被改动时通知我" #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "当指派给我的任务单有改动时通知我" #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "当我创建的任务单有改动时通知我" #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "当我改动一个任务单时通知我" #: announcer/templates/prefs_announcer_manage_subscriptions.html:23 msgid "rules" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "down" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "up" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 msgid "Add" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as the" " default rules. Any rules defined by you will take higher priority then " "these rules. This can be confusing if you don't understand how the " "system works. Only the first matching rule is applied when system events" " occur. For example, if you have a rule like \"always notify me of any " "ticket changes\" in your custom rules, and there is a default rule " "\"never notify me when I update a ticket\", then the always rule will " "take precedent and you will still recieve announcements on ticket " "changes, even when you are the updater. In the preceding case, you would" " need to add your own \"never notify me..\" rule above the \"always " "notify me..\" to get the proper behavior." msgstr "" #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows you " "to use filters to specify which events you are interested in hearing " "about." msgstr "" #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" #: announcer/templates/prefs_announcer_ticket_all.html:8 #, fuzzy msgid "Notify me when any ticket changes." msgstr "别通知我关于那些我自己作的改动" #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "防打扰设置" #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "我不想收到任何通知" #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:5 msgid "" "A comma separated list of users you would like to watch. A watched user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "您将收到这些用户对wiki和任务单的改动" #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "订阅用户,用户名以分号(;)分隔:" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki " "pages, you may list here\n" " pages that are of interest to you. Each page should be on a separate " "line." msgstr "您可以在此订阅Wiki页面,每行一个页面." #: announcer/templates/prefs_announcer_wiki.html:12 msgid "" "You may use wild cards, so that if you want to hear about any page that " "starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "您可以使用通配符*,例如 [1:Trac*] 表示订阅所有以Trac开头的页面" #: announcer/templates/prefs_announcer_wiki.html:17 msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by " "itself." msgstr "[1:*] 将订阅所有Wiki页面" #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber " "announcements delivered." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:6 #, fuzzy msgid "XMPP address:" msgstr "邮件地址:" #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "任务单 #" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "描述" #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "改动内容: (by" #: announcer/templates/ticket_email_mimic.html:132 msgid "" "changed \n" " from" msgstr "从" #: announcer/templates/ticket_email_mimic.html:133 msgid "to" msgstr "改为" #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "附件:" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "文件" #: announcer/templates/ticket_email_mimic.html:147 msgid "added" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:1 #, python-format msgid "" "---------------------------------------------------------------------\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:5 #, python-format msgid "" "{% end %}\\\n" "{% otherwise %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:7 #, python-format msgid "" ": (None)\n" "{% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% if category == 'created' %}\\\n" "---------------------------------------------------------------------" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:12 #, python-format msgid "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------\n" "Changes (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:16 #, python-format msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 msgid "from '" msgstr "从" #: announcer/templates/ticket_email_plaintext.txt:18 #, python-format msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:21 #, python-format msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:28 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Attachment:\n" " * File '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "' added{% if attachment.description %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------\n" "Comment{% if not has_changes %} (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:38 #, python-format msgid "){% end %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:39 #, python-format msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: ' because of rule: carbon copied" #~ msgstr "" #~ msgid "Notify me of any changes to my blog posts." #~ msgstr "" #~ msgid "Notify me of any new blog posts." #~ msgstr "" #~ msgid "Notify me of any blog changes." #~ msgstr "" #~ msgid "" #~ "' has been deleted. {% end %}\\\n" #~ "{% end %}\\\n" #~ "--\n" #~ "Page URL: , 2010. msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "Report-Msgid-Bugs-To: hoff.st@web.de\n" "POT-Creation-Date: 2010-11-20 15:54+0100\n" "PO-Revision-Date: 2010-07-23 11:39+0200\n" "Last-Translator: Steffen Hoffmann \n" "Language-Team: German de_DE \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 1.0dev-r482\n" #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "Benachrichtigungen" #: announcer/pref.py:129 msgid "Subscriptions" msgstr "Abonnements" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "E-mail-Adresse für Benachrichtigungen" #: announcer/subscribers.py:98 msgid "notify me when any ticket changes" msgstr "Benachrichtigung an mich bei Ticketänderung" #: announcer/subscribers.py:159 msgid "notify me when a ticket that I own is created or modified" msgstr "" "Benachrichtigung an mich bei Erstellung oder Änderung eines Tickets, für " "das ich verantwortlich bin" #: announcer/subscribers.py:221 msgid "" "notify me when a ticket that belongs to a component that I own is created" " or modified" msgstr "" "Benachrichtigung an mich bei Erstellung oder Änderung eines Tickets, das " "zu einer Komponente gehört, für die ich verantwortlich bin" #: announcer/subscribers.py:274 msgid "notify me when I update a ticket" msgstr "Benachrichtigung an mich, wenn ich selbst ein Ticket aktualisiere" #: announcer/subscribers.py:327 msgid "notify me when a ticket that I reported is modified" msgstr "Benachrichtigung an mich bei Änderung eines Tickets, das ich erstellt habe" #: announcer/subscribers.py:385 msgid "notify me when I'm listed in the CC field of a ticket that is modified" msgstr "" "Benachrichtigung an mich bei Änderung eines Tickets, wenn ich in dessen " "Kopie-Feld eingetragen bin" #: announcer/subscribers.py:424 msgid "" "notify me when a ticket associated with a component I'm watching is " "modified" msgstr "" "Benachrichtigung an mich bei Änderung eines Tickets, das einer von mir " "beobachteten Komponente zugeordnet ist" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "Ticket-Abonnement basierend auf Komponenten" #: announcer/subscribers.py:526 #, python-format msgid "notify me when I'm listed in any of the (%s) fields" msgstr "" "Benachrichtigung an mich, wenn ich in einem der (%s) Felder eingetragen " "bin" #: announcer/subscribers.py:583 msgid "notify me on ticket changes in one of my subscribed groups" msgstr "" "Benachrichtigung an mich bei Ticketänderungen in einem meiner Gruppen-" "Abonnements" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "Gruppen-Abonnements" #: announcer/subscribers.py:641 msgid "notify me when one of my watched users changes something" msgstr "" "Benachrichtigung an mich bei Änderungen durch einen von mir beobachteten " "Nutzer" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "Beobachtung von Nutzern" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "" "Sie erhalten zu diesem Dokument keine weiteren " "Änderungsbenachrichtigungen." #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "Sie erhalten nun Änderungsbenachrichtigungen zu diesem Dokument." #: announcer/subscribers.py:847 msgid "notify me when one of my watched wiki or tickets is updated" msgstr "" "Benachrichtigung an mich bei Aktualisierung einer von mir beobachteten " "Wikiseite oder eines Tickets" #: announcer/subscribers.py:896 msgid "" "notify me when a wiki that matches my wiki watch pattern is created, or " "updated" msgstr "" "Benachrichtigung an mich bei Erstellung oder Änderung einer Wikiseite, " "die zu meinem Beobachtungsraster passt" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "Allgemeine Wiki-Abonnements" #: announcer/distributors/mail.py:411 #, python-format msgid "Invalid email encoding setting: %s" msgstr "Ungültige Einstellung der E-Mail-Codierung: %s" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "Erfolgreiche kryptographische Behandlung bei der E-Mail-Verteilung." #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "" "Das Ticket enthält Nicht-ASCII-Zeichen. Bitte ändern Sie die " "Codierungseinstellung" #: announcer/distributors/mail.py:519 msgid "undisclosed-recipients: ;" msgstr "Verborgene_Empfaenger: ;" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "TLS aktiviert, wird vom Server aber nicht unterstützt" #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "Abonnement für Nutzerkontenverwaltung («Account Manager»)" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "Erfolgreich" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "Fehlgeschlagen" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "Bitten-Abonnement" #: announcer/opt/fullblog/announce.py:95 msgid "notify me when any blog is modified, changed, deleted or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:145 msgid "notify me when any blog that I posted is modified or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "" #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:238 msgid "You are now watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" "Ihr Kennwort für Trac wurde zurückgesetzt.\n" "\n" "Hier ist Ihre Nutzerkonteninformation:\n" "\n" "Anmelde-URL: <" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" ">\n" "Benutzername:" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "Kennwort:" #: announcer/templates/acct_mgr_user_change_plaintext.txt:1 msgid "for user" msgstr "für Nutzer" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" "Bitte gehen Sie zu der folgenden URL, um Ihre E-mail-Adresse zu " "bestätigen.\n" "\n" "Bestätigungs-URL: <" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "Bestätigungsschlüssel:" #: announcer/templates/bitten_plaintext.txt:1 msgid "build of" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "" "]\n" "---------------------------------------------------------------------\n" "\n" " Changeset:" msgstr "" #: announcer/templates/bitten_plaintext.txt:4 msgid "" ">\n" " Committed by:" msgstr "" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "" #: announcer/templates/bitten_plaintext.txt:9 #, python-format msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "Fehler:" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "Protokoll:" #: announcer/templates/bitten_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "--\n" "Build URL: <" msgstr "" #: announcer/templates/fullblog_plaintext.txt:1 #, python-format msgid "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Added post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "\" by" msgstr "\" von" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "at" msgstr "in" #: announcer/templates/fullblog_plaintext.txt:4 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Changed post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:8 #, python-format msgid "" "{% end %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:15 #, python-format msgid "" "{% if comment %}\\\n" "Comment:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Deleted post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:25 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 #, python-format msgid "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Comment added to post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "Inhalt:" #: announcer/templates/fullblog_plaintext.txt:37 #, python-format msgid "{% end %}" msgstr "" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to you; \n" " the creation of a ticket, the change of a Wiki page, and so on. " "Under\n" " the Announcement system, you will only receive notifications to " "those\n" " topics that you subscribe to." msgstr "" "Benachrichtigungen dienen in Trac dazu, Ihnen Ereignisse mitzuteilen: das" " Erstellen eines Tickets, die Änderung einer Wiki-Seite und anderes. " "Durch das Benachrichtigungssystem erhalten Sie nur Nachrichten zu den " "Bereichen, die Sie anfordern." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "Abonnieren Sie Nutzer-Konten-bezogene Benachrichtigungen." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "Sende mir eine Benachrichtigung, wenn neue Nutzer erstellt werden." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 msgid "Send me announcements when users accounts are changed." msgstr "Sende mir eine Benachrichtigung, wenn Nutzer-Konten geändert werden." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "Sende mir eine Benachrichtigung, wenn Nutzer-Konten gelöscht werden." #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "Benachrichtigungen über eigene Änderungen werden ablehnt." #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "Benachrichte mich niemals, wenn ich selbst Änderungen vornehme." #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a plaintext " "format. You\n" " may override this for each realm that may generate announcements." msgstr "" "Das Benachrichtigungssystem wird Ihnen alle Nachrichten normalerweise als" " reiner Text formatiert zusenden. Sie können für jeden Bereich, der " "Benachrichtigungen erzeugen kann, eine abweichende Einstellung vornehmen." #: announcer/templates/prefs_announcer_email.html:9 #: announcer/templates/prefs_announcer_xmpp.html:7 msgid "announcements:" msgstr "Benachrichtigungen:" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different " "address then the main one provided\n" " in Trac, you may specify the address here:" msgstr "" "Falls Sie Benachrichtigungen an eine andere als die in Trac gespeicherte " "Adresse versenden lassen möchten,\n" " dann können Sie diese Adresse hier angeben:" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "E-mail-Adresse:" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following components " "have been defined by the Trac administrators. If you subscribe to any of" " these components, you will receive an notification anytime a ticket " "related to that component is changed or created." msgstr "" "Komponenten stellen eine Möglichkeit zur Klassifizierung von Trac-Tickets" " dar. Die folgenden Komponenten sind von Trac-Administratoren vorgegeben " "worden. Falls Sie eine davon abonnieren, werden Sie benachrichtigt, wenn " "ein Ticket zu dieser Komponente erstellt oder geändert wird. " #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. They " "are general topics that may be added onto the CC list of tickets (by " "prepending their name with @). Case does matter." msgstr "" "Die folgenden Gruppen sind von Trac-Administratoren vorgegeben worden. " "Dies sind übergeordnete Themen, die in Kopie-Listen (Ticket-Cc) eingefügt" " werden können (durch Voranstellen von @ vor deren Namen). " "Groß-/Kleinschreibung ist zu beachten." #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "" "Benachrichtige mich über alle Änderungen an Tickets, die zu Komponenten " "gehören, für die ich verantwortlich bin." #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "" "Benachrichtige mich über Änderungen an Tickets, für die ich " "verantwortlich bin." #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "" "Benachrichtige mich über alle Änderungen an Tickets, die ich erstellt " "habe." #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "Benachrichtige mich, wenn ich selbst ein Ticket aktualisiere." #: announcer/templates/prefs_announcer_manage_subscriptions.html:23 msgid "rules" msgstr "Regeln" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "Benutzereigene Regeln:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "Format:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "Speichern" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "Löschen" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "down" msgstr "tiefer" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "up" msgstr "höher" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 msgid "Add" msgstr "Hinzufügen" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "Standardregeln:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as the" " default rules. Any rules defined by you will take higher priority then " "these rules. This can be confusing if you don't understand how the " "system works. Only the first matching rule is applied when system events" " occur. For example, if you have a rule like \"always notify me of any " "ticket changes\" in your custom rules, and there is a default rule " "\"never notify me when I update a ticket\", then the always rule will " "take precedent and you will still recieve announcements on ticket " "changes, even when you are the updater. In the preceding case, you would" " need to add your own \"never notify me..\" rule above the \"always " "notify me..\" to get the proper behavior." msgstr "" "Die folgenden Regeln sind von System-Administratoren als Standardregeln " "eingerichet worden. Jede Ihrer eigenen Regeln wird eine höhere " "Wichtigkeit als diese Regeln erhalten. Bei einem Ereignis wird nur die " "erste zutreffende Regel angewendet. Wenn Sie beispielsweise eine Regel " "wie \"immer Benachrichtigung an mich bei allen Ticketänderungen\" in " "Ihren benutzeigenen Regeln haben, und wenn es eine Standardregel " "\"niemals Benachrichtigung an mich, wenn ich selbst ein Ticket " "aktualisiere\" gibt, dann wird die immer-Regel Vorrang erhalten, und Sie " "werden auch dann Benachrichtigungen über Ticketänderungen erhalten, wenn " "Sie diese selbst ausgeführt haben. Folglich müssten Sie Ihre eigene " "\"niemals Benachrichtigung an mich ...\"-Regel über dem \"immer " "Benachrichtigung an mich ...\" hinzufügen, um das richige Verhalten zu " "erreichen." #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows you " "to use filters to specify which events you are interested in hearing " "about." msgstr "" "Das regelgestützte Abonnement für erfahrene Nutzer erlaubt Ihnen, Filter " "zur Beschreibung von Ereignissen zu nutzen, von denen Sie gern " "benachrichtigt werden möchten." #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" "Jede Regel hat die folgende Form: [1:[2:realm], [3:category]: [4:query " "rule]]" #: announcer/templates/prefs_announcer_ticket_all.html:8 msgid "Notify me when any ticket changes." msgstr "Benachrichtige mich bei allen Ticketänderungen." #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "Alle Benachrichtigungen werden ablehnt." #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "Benachrichte mich niemals über Änderungen." #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:5 msgid "" "A comma separated list of users you would like to watch. A watched user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "" "Das ist eine Liste mit durch Kommata getrennten Nutzernamen, die Sie " "beobachten möchten. Ein beobachteter Nutzer erzeugt immer dann eine " "Benachrichtigung, wenn er/sie eine Wiki-Seite oder ein Ticket erstellt " "oder ändert." #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "Beobachte Nutzer:" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki " "pages, you may list here\n" " pages that are of interest to you. Each page should be on a separate " "line." msgstr "" "Ergänzend zu anderen Methoden, die Sie über Änderungen von Wiki-Seiten " "benachrichtigen, können Sie hier Seiten angeben, die Sie interessieren. " "Jede Seite gehört dabei auf eine eigene Zeile." #: announcer/templates/prefs_announcer_wiki.html:12 msgid "" "You may use wild cards, so that if you want to hear about any page that " "starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "" "Sie können Platzhalter verwenden. Wenn Sie über alle Seiten " "benachrichtigt werden möchten, deren Name mit 'Trac' beginnt, dann geben " "Sie auf einer Zeile ein: [1:Trac*]" #: announcer/templates/prefs_announcer_wiki.html:17 msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by " "itself." msgstr "" "Um Benachrichtigungen über alle Wiki-Seiten-Änderungen zu erhalten, geben" " Sie bitte nur [1:*] ein." #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber " "announcements delivered." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:6 msgid "XMPP address:" msgstr "XMPP-Adresse:" #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "" #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "" #: announcer/templates/ticket_email_mimic.html:132 msgid "" "changed \n" " from" msgstr "" #: announcer/templates/ticket_email_mimic.html:133 msgid "to" msgstr "" #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "added" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:1 #, python-format msgid "" "---------------------------------------------------------------------\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:5 #, python-format msgid "" "{% end %}\\\n" "{% otherwise %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:7 #, python-format msgid "" ": (None)\n" "{% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% if category == 'created' %}\\\n" "---------------------------------------------------------------------" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:12 #, python-format msgid "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------\n" "Changes (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:16 #, python-format msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 msgid "from '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 #, python-format msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:21 #, python-format msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:28 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Attachment:\n" " * File '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "' added{% if attachment.description %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------\n" "Comment{% if not has_changes %} (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:38 #, python-format msgid "){% end %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:39 #, python-format msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: , 2010. # msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "Report-Msgid-Bugs-To: hoff.st@web.de\n" "POT-Creation-Date: 2010-11-09 22:45+0100\n" "PO-Revision-Date: 2010-06-23 22:31+0200\n" "Last-Translator: Dmitri Bogomolov <4glitch@gmail.com>\n" "Language-Team: ru \n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 1.0dev-r482\n" #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "Извещения" #: announcer/pref.py:129 #, fuzzy msgid "Subscriptions" msgstr "Подписки на блог" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "Почтовый адрес для извещений" #: announcer/subscribers.py:98 #, fuzzy msgid "notify me when any ticket changes" msgstr "Никогда не извещать о моих изменениях." #: announcer/subscribers.py:159 msgid "notify me when a ticket that I own is created or modified" msgstr "" #: announcer/subscribers.py:221 #, fuzzy msgid "" "notify me when a ticket that belongs to a component that I own is created" " or modified" msgstr "Уведомлять меня об изменениях билетов из компонентов, которыми я владею." #: announcer/subscribers.py:274 #, fuzzy msgid "notify me when I update a ticket" msgstr "Уведомлять меня о моих изменениях в билетах." #: announcer/subscribers.py:327 #, fuzzy msgid "notify me when a ticket that I reported is modified" msgstr "Уведомлять меня об изменениях в билетах, которые я открыл." #: announcer/subscribers.py:385 msgid "notify me when I'm listed in the CC field of a ticket that is modified" msgstr "" #: announcer/subscribers.py:424 msgid "" "notify me when a ticket associated with a component I'm watching is " "modified" msgstr "" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "Подписки по компонентам" #: announcer/subscribers.py:526 #, python-format msgid "notify me when I'm listed in any of the (%s) fields" msgstr "" #: announcer/subscribers.py:583 msgid "notify me on ticket changes in one of my subscribed groups" msgstr "" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "Групповые подписки" #: announcer/subscribers.py:641 msgid "notify me when one of my watched users changes something" msgstr "" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "Наблюдение за пользователями" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "Вы больше не получаете уведомления по изменениям этомго ресурса." #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "Вы теперь получаете уведомления по изменениям этого ресурса." #: announcer/subscribers.py:847 msgid "notify me when one of my watched wiki or tickets is updated" msgstr "" #: announcer/subscribers.py:896 msgid "" "notify me when a wiki that matches my wiki watch pattern is created, or " "updated" msgstr "" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "Общие извещения Wiki" #: announcer/distributors/mail.py:411 #, python-format msgid "Invalid email encoding setting: %s" msgstr "Неправильный выбор почтовой кодировки: %s" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "" #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "Билет содержит не-ASCII символы. Пожалуйста измените настройку кодировки" #: announcer/distributors/mail.py:519 msgid "undisclosed-recipients: ;" msgstr "скрытые адресаты: ;" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "TLS включен, но сервер его не поддерживает" #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "Подписки Account Manager" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "Успешно" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "Неудачно" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "Подписки Bitten" #: announcer/opt/fullblog/announce.py:95 msgid "notify me when any blog is modified, changed, deleted or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:145 msgid "notify me when any blog that I posted is modified or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "Не наблюдать" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "Наблюдать" #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:238 #, fuzzy msgid "You are now watching this blog post." msgstr "Уведомлять о любых изменениях моих заметок в блоге." #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "Блог: ${blog.name} ${action}" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" "Ваш пароль trac был сброшен.\n" "\n" "Здесь информация по вашей учетной записи:\n" "\n" "URL входа: " #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" ">\n" "Имя:" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "Пароль:" #: announcer/templates/acct_mgr_user_change_plaintext.txt:1 msgid "for user" msgstr "для пользователя" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" "Пожалуйста посетите следующий URL для подтверждения вашего почтового " "адреса.\n" "\n" "Проверочный URL:" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "Проверочное слово:" #: announcer/templates/bitten_plaintext.txt:1 msgid "build of" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "" "]\n" "---------------------------------------------------------------------\n" "\n" " Changeset:" msgstr "" "]\n" "---------------------------------------------------------------------\n" " Набор изменений:" #: announcer/templates/bitten_plaintext.txt:4 msgid "" ">\n" " Committed by:" msgstr "" ">\n" " Зафиксирован пользователем:" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "Конфигурация сборки:" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "Номер сборки:" #: announcer/templates/bitten_plaintext.txt:9 #, python-format msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "Ошибки:" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "Журнал:" #: announcer/templates/bitten_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "--\n" "Build URL: <" msgstr "" #: announcer/templates/fullblog_plaintext.txt:1 #, python-format msgid "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Added post \"" msgstr "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Добавлена заметка \"" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "\" by" msgstr "\" от" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "at" msgstr "в" #: announcer/templates/fullblog_plaintext.txt:4 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Changed post \"" msgstr "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Изменена заметка \"" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" ". \n" "Правка:" #: announcer/templates/fullblog_plaintext.txt:8 #, python-format msgid "" "{% end %}\\\n" "Page URL:" msgstr "" "{% end %}\\\n" "URL страницы:" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" "Содержимое:\n" "\n" "Заголовок:" #: announcer/templates/fullblog_plaintext.txt:15 #, python-format msgid "" "{% if comment %}\\\n" "Comment:" msgstr "" "{% if comment %}\\\n" "Комментарий:" #: announcer/templates/fullblog_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Deleted post \"" msgstr "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Удалена заметка \"" #: announcer/templates/fullblog_plaintext.txt:25 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "Page URL:" msgstr "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "URL страницы:" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "Удалена версия \"" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "\" заметки \"" #: announcer/templates/fullblog_plaintext.txt:29 #, python-format msgid "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Comment added to post \"" msgstr "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Добавлен комментарий к заметке \"" #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "URL страницы:" #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "Содержимое:" #: announcer/templates/fullblog_plaintext.txt:37 #, python-format msgid "{% end %}" msgstr "" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to you; \n" " the creation of a ticket, the change of a Wiki page, and so on. " "Under\n" " the Announcement system, you will only receive notifications to " "those\n" " topics that you subscribe to." msgstr "" "Извещения это способ сообщать вам о событиях Trac: \n" " создание билета, изменения страницы Wiki, и т.п. В подходе " "извещений вы получаете уведомления только о тех темах,\n" " на которые вы сами подписались." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "Подписаться на извещения о пользовательских профилях." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "Посылать извещения при создании новых пользователей." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 #, fuzzy msgid "Send me announcements when users accounts are changed." msgstr "Посылать извещения при изменении пользовательских профилей." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "Посылать извещения при удалении пользовательских профилей." #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "Отказаться от извещений о моих собственных действиях." #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "Никогда не извещать о моих изменениях." #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a plaintext " "format. You\n" " may override this for each realm that may generate announcements." msgstr "" "По умолчанию все уведомления доставляются в виде простого текста. Вы " "можете переопределить это поведение для каждого раздела, который может " "генерировать извещения." #: announcer/templates/prefs_announcer_email.html:9 #: announcer/templates/prefs_announcer_xmpp.html:7 msgid "announcements:" msgstr "извещения:" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different " "address then the main one provided\n" " in Trac, you may specify the address here:" msgstr "" "Если вы хотите получать извещения на адрес, отличный от указанного " "глобально в Trac, вы можете указать его здесь:" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "Почтовый адрес" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following components " "have been defined by the Trac administrators. If you subscribe to any of" " these components, you will receive an notification anytime a ticket " "related to that component is changed or created." msgstr "" "Компоненты это способ классификации билетов. Следующие компоненты созданы" " администратором Trac. Если вы подпишетесь на компонент, то будете " "получать уведомление при создании или изменении билетов, связанных с этим" " компонентом." #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. They " "are general topics that may be added onto the CC list of tickets (by " "prepending their name with @). Case does matter." msgstr "" "Следующие группы созданы администратором Trac. Они являются обобщенными " "адресатами, которые могут быть добавлены в список CC билета (имена " "предваряются @). Регистр не имеет значения." #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "Уведомлять меня об изменениях билетов из компонентов, которыми я владею." #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "Уведомлять меня об изменениях в моих билетах." #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "Уведомлять меня об изменениях в билетах, которые я открыл." #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "Уведомлять меня о моих изменениях в билетах." #: announcer/templates/prefs_announcer_manage_subscriptions.html:23 msgid "rules" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "down" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "up" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 #, fuzzy msgid "Add" msgstr "добавлен" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as the" " default rules. Any rules defined by you will take higher priority then " "these rules. This can be confusing if you don't understand how the " "system works. Only the first matching rule is applied when system events" " occur. For example, if you have a rule like \"always notify me of any " "ticket changes\" in your custom rules, and there is a default rule " "\"never notify me when I update a ticket\", then the always rule will " "take precedent and you will still recieve announcements on ticket " "changes, even when you are the updater. In the preceding case, you would" " need to add your own \"never notify me..\" rule above the \"always " "notify me..\" to get the proper behavior." msgstr "" #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows you " "to use filters to specify which events you are interested in hearing " "about." msgstr "" "Модуль подписок по правилам предназначен для опытных пользователей и " "позволяет вам использовать фильтры для указания событий, о которых вам " "интересно узнать." #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" "Каждое правило в формате: [1:[2:раздел], [3:категория]: [4:правило " "запроса]]" #: announcer/templates/prefs_announcer_ticket_all.html:8 #, fuzzy msgid "Notify me when any ticket changes." msgstr "Никогда не извещать о моих изменениях." #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "Отказаться от всех извещений." #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "Не уведомлять меня ни о каких изменениях." #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:5 #, fuzzy msgid "" "A comma separated list of users you would like to watch. A watched user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "" "Список наблюдаемых пользователей, разделенных запятыми. Извещение будет " "приходить каждый раз, когда один из указанных пользователей создает или " "меняет страницу wiki или билет." #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "Наблюдать за пользователями:" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki " "pages, you may list here\n" " pages that are of interest to you. Each page should be on a separate " "line." msgstr "" "В дополнение к прочим методом уведемления об изменениях в wiki, здесь вы " "можете указать интересующие вас страницы. Каждое имя должно быть на " "отдельной строке." #: announcer/templates/prefs_announcer_wiki.html:12 #, fuzzy msgid "" "You may use wild cards, so that if you want to hear about any page that " "starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "" "Вы можете использовать сокращения, так, если вы хотите следить за всеми " "страницами, с именем, начинающимся 'Trac', нужно в новой строке вписать: " "[1:Trac*]" #: announcer/templates/prefs_announcer_wiki.html:17 #, fuzzy msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by " "itself." msgstr "" "Для получения сообщения о любых изменениях в wiki просто вставьте [1:*] " "саму по себе" #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber " "announcements delivered." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:6 #, fuzzy msgid "XMPP address:" msgstr "Почтовый адрес" #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "Билет №" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "Описание" #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "Изменения: (от" #: announcer/templates/ticket_email_mimic.html:132 msgid "" "changed \n" " from" msgstr "" "изменено \n" " с" #: announcer/templates/ticket_email_mimic.html:133 msgid "to" msgstr "на" #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "Вложения:" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "Файл" #: announcer/templates/ticket_email_mimic.html:147 msgid "added" msgstr "добавлен" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "Комментарии:" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "(от" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "Адрес билета:" #: announcer/templates/ticket_email_plaintext.txt:1 #, python-format msgid "" "---------------------------------------------------------------------\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:5 #, python-format msgid "" "{% end %}\\\n" "{% otherwise %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:7 #, python-format msgid "" ": (None)\n" "{% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% if category == 'created' %}\\\n" "---------------------------------------------------------------------" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:12 #, python-format msgid "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------\n" "Changes (by" msgstr "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------\n" "Изменения (от" #: announcer/templates/ticket_email_plaintext.txt:16 #, python-format msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 msgid "from '" msgstr "с '" #: announcer/templates/ticket_email_plaintext.txt:18 #, python-format msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" "' на \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" #: announcer/templates/ticket_email_plaintext.txt:21 #, python-format msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:28 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Attachment:\n" " * File '" msgstr "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Вложение:\n" " * Файл '" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "' added{% if attachment.description %}:" msgstr "' добавлен{% if attachment.description %}:" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------\n" "Comment{% if not has_changes %} (by" msgstr "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------\n" "Комментарий{% if not has_changes %} (от" #: announcer/templates/ticket_email_plaintext.txt:38 #, python-format msgid "){% end %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:39 #, python-format msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: ' because of rule: carbon copied" #~ msgstr "CarbonCopySubscriber добавил '%s <%s>' по правилу: точная копия" #~ msgid "Notify me of any new blog posts." #~ msgstr "Уведомлять о любых новых заметках в блоге." #~ msgid "Notify me of any blog changes." #~ msgstr "Уведомлять о любых изменениях в блоге." trac-announcer/trunk/announcer/locale/fr/0000755000175500017550000000000012004067606020531 5ustar debacledebacletrac-announcer/trunk/announcer/locale/fr/LC_MESSAGES/0000755000175500017550000000000012004067606022316 5ustar debacledebacletrac-announcer/trunk/announcer/locale/fr/LC_MESSAGES/announcer.po0000644000175500017550000007173212004067606024660 0ustar debacledebacle# French translations for TracAnnouncer. # Copyright (C) 2012 # This file is distributed under the same license as the AnnouncerPlugin # project. # Stephan Geulette , 2012. # msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "POT-Creation-Date: 2012-07-25 11:41+0100\n" "PO-Revision-Date: 2012-07-25 21:43+0200\n" "Last-Translator: Stephan Geulette \n" "Language-Team: fr \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "Language-Code: fr\n" "Language-Name: French\n" "X-is-fallback-for: fr-be fr-ca fr-lu fr-mc fr-ch fr-fr\n" "Preferred-Encodings: utf-8 latin1\n" "Domain: announcer\n" # , python-format #: announcer/templates/ticket_email_plaintext.txt:33 msgid "' added{% if attachment.description %}:" msgstr "' ajouté{% if attachment.description %}:" #: announcer/templates/wiki_email_plaintext.txt:6 msgid "' has added the attachment '" msgstr "' a ajouté la pièce jointe '" # , python-format #: announcer/templates/wiki_email_plaintext.txt:8 msgid "" "' has been deleted. {% end %}\\\n" "{% end %}\\\n" "--\n" "Page URL: <" msgstr "" "' a été effacé. {% end %}\\\n" "{% end %}\\\n" "--\n" "URL: <" # , python-format #: announcer/templates/wiki_email_plaintext.txt:7 msgid "" "' has been reverted to its previous version. {% end %}\\\n" "{% when action == \"deleted\" %} * The '" msgstr "" "' a été remis à une version précédente. {% end %}\\\n" "{% when action == \"deleted\" %} * Le '" #: announcer/templates/wiki_email_plaintext.txt:3 msgid "' has changed the page:" msgstr "' a changé la page:" #: announcer/templates/wiki_email_plaintext.txt:2 msgid "' has created the page:" msgstr "' a créé la page:" # , python-format #: announcer/templates/ticket_email_plaintext.txt:18 msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" "' à \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" #: announcer/templates/wiki_email_plaintext.txt:6 msgid "' to the page:" msgstr "' à la page:" # , python-format #: announcer/templates/ticket_email_plaintext.txt:21 msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(effacé){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "(par" # , python-format #: announcer/templates/ticket_email_plaintext.txt:16 msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" # , python-format #: announcer/templates/ticket_email_plaintext.txt:38 msgid "){% end %}:" msgstr "" # , python-format #: announcer/templates/ticket_email_plaintext.txt:1 msgid "" "---------------------------------------------------------------------\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" ". \n" "Révision:" # , python-format #: announcer/templates/wiki_email_plaintext.txt:2 msgid "" ". {% end %}\\\n" "{% when action == \"changed\" %} * The user '" msgstr "" ". {% end %}\\\n" "{% when action == \"changed\" %} * L'utilisateur '" # , python-format #: announcer/templates/wiki_email_plaintext.txt:6 msgid "" ". {% end %}\\\n" "{% when action == \"version deleted\" %} * The page '" msgstr "" ". {% end %}\\\n" "{% when action == \"version deleted\" %} * La page '" #: announcer/templates/wiki_email_plaintext.txt:3 msgid "" ".\n" " * Diff link: \n" " Committed by:" msgstr "" ">\n" " Soumis par:" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" ">\n" "Utilisateur:" # , python-format #: announcer/templates/bitten_plaintext.txt:9 msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Erreurs:\n" "{% for step in build.failed_steps %}\\\n" " Etape:" #: announcer/templates/prefs_announcer_watch_users.html:5 msgid "" "A comma separated list of users you would like to watch. A watched user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "" "Une liste d'utilisateurs à suivre, séparé par une virgule. Un utilisateur " "'suivi' \n" " va produire une notification chaque fois qu'il/elle crée ou change\n" " une page wiki ou un ticket." #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "Abonnement à la gestion des comptes" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 msgid "Add" msgstr "Ajouter" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "Adresse pour les notifications par mail " #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "Notifications" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to you; \n" " the creation of a ticket, the change of a Wiki page, and so on. Under\n" " the Announcement system, you will only receive notifications to those\n" " topics that you subscribe to." msgstr "" "Le système de notifications permet de vous tenir au courant des " "changements;\n" " la création d'un ticket, le changement d'une page Wiki, etc. Avec le \n" " système de notification, vous recevrez uniquement des notifications\n" " concernant les éléments auxquels vous vous êtes abonnés." #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "Pièces jointes:" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "Abonnement outil Bitten" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "Configuration de compilation:" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "Numéro de compilation:" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "Client de compilation:" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a plaintext " "format. You\n" " may override this for each realm that may generate announcements." msgstr "" "Par défaut, les notifications sont au format texte. Vous pouvez changer " "cette option. " #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "Changements: (par" #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "Liste des auteurs de blog à suivre, séparés par une virgule" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "Commentaires:" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following components " "have been defined by the Trac administrators. If you subscribe to any of " "these components, you will receive an notification anytime a ticket related " "to that component is changed or created." msgstr "" "Les composants suivants ont été définis dans le Trac. Si vous vous abonnez à " "certains composants, vous recevrez une notification à chaque fois qu'un " "ticket relatif à ces composants est changé ou modifié. Pour activer la " "fonctionnalité, vous devez également choisir via l'onglet 'Abonnements' la " "règle 'me notifier quand un ticket correspondant à un composant sélectionné " "dans le panneau 'Notifications' est modifié'." #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "Contenu:" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" "Contenu:\n" "\n" "Titre:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "Règles particulières:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "Règles par défaut:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "Effacer" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "Suppression de la version \"" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "Description" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "Adresse email:" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "Opération réussie pour EmailDistributor crypto" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "Erreurs:" #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" "Chaque règle est de la forme: \n" " [1:[2:realm], [3:category]: [4:query rule]]" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "Echec" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "Fichier" #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "Bloggers suivis" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "Format:" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "Notifications wiki générale" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "Abonnements par groupe" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "Je préfère recevoir les notifications via" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different address " "then the main one provided\n" " in Trac, you may specify the address here:" msgstr "" "Si vous désirez avoir les notifications sur une adresse différente de " "l'adresse de votre compte, \n" " vous pouvez la spécifier ici:" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki pages, " "you may list here\n" " pages that are of interest to you. Each page should be on a separate " "line." msgstr "" "En plus des autres méthodes de suivi sur les pages wiki, vous pouvez " "indiquer ici les pages qui\n" " vous intéressent. Chaque page doit être sur une ligne différente." # , python-format #: announcer/distributors/mail.py:411 msgid "Invalid email encoding setting: %s" msgstr "Encodage email invalide: %s" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "Log:" #: announcer/util/mail_crypto.py:64 msgid "" "Missing the crypto binary. Please check and set full path with option " "'gpg_binary'." msgstr "" "Le binaire crypto est manquant. Vérifier et encoder le chemin complet dans " "l'option 'gpg_binary'." #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "Ne jamais me notifier aucun changement." #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "Ne jamais me notifier mes propres changements." #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "Me notifier les changements sur les tickets dont je suis responsable." #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "Me notifier les changements sur les tickets que j'ai créés." #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "" "Me notifier les changements sur les tickets liés aux composants dont je suis " "responsable." #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "Me notifier quand je modifie un ticket." #: announcer/templates/prefs_announcer_ticket_all.html:8 msgid "Notify me when any ticket changes." msgstr "Me notifier tous les changements sur les tickets." #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "Opt-out sur toutes les notifications." #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "Opt-out sur les notifications concernant ses propres changements." #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "URL:" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "Mot de passe:" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" "Veuillez suivre l'adresse suivante afin de confirmer votre email.\n" "\n" "URL de vérification: <" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "Sauver" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "Me notifier quand de nouveaux utilisateurs sont créés." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 msgid "Send me announcements when users accounts are changed." msgstr "Me notifier quand des comptes utilisateur ont changé." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "Me notifier quand des comptes utilisateur sont effacés." #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber announcements " "delivered." msgstr "Spécifier son adresse XMPP(jabber) pour les notifications." #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "Me notifier une compilation avortée. " #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "Me notifier une compilation aboutie." #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "Me notifier une compilation démarrée." #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "S'abonner aux notifications de compilation." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "S'abonner aux notifications concernant les comptes utilisateur." #: announcer/pref.py:129 msgid "Subscriptions" msgstr "Abonnements" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "Réussi" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "TLS activé alors que le serveur ne le supporte pas" #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. They are " "general topics that may be added onto the CC list of tickets (by prepending " "their name with @). Case does matter." msgstr "" "Les groupes suivants ont été définis. Ils peuvent être ajoutés dans la liste " "CC des tickets (en préfixant leur nom avec @). La casse n'est pas prise en " "compte. " #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as the " "default rules. Any rules defined by you will take higher priority then " "these rules. This can be confusing if you don't understand how the system " "works. Only the first matching rule is applied when system events occur. " "For example, if you have a rule like \"always notify me of any ticket changes" "\" in your custom rules, and there is a default rule \"never notify me when " "I update a ticket\", then the always rule will take precedent and you will " "still recieve announcements on ticket changes, even when you are the " "updater. In the preceding case, you would need to add your own \"never " "notify me..\" rule above the \"always notify me..\" to get the proper " "behavior." msgstr "" "Les règles suivantes ont été définies par l'administrateur comme règles par " "défaut. Chaque règle particulière que vous définissez prendra l'ascendant " "sur les règles par défaut. Donc seulement la première règle concernée par " "l'événement déclenché sera appliquée. Par exemple, si vous choisissez dans " "les règles particulières \"toujours me notifier tous les changements sur les " "tickets\", et qu'il y a une règle par défaut 'ne jamais me notifier quand je " "modifie un ticket', alors la règle particulière 'toujours ...' va prendre " "l'ascendant et vous recevrez les notifications sur les changements dans les " "tickets, même si vous êtes celui qui le modifie. " #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows you to " "use filters to specify which events you are interested in hearing about." msgstr "" "Le module d'abonnement utilisant les règles est pour les utilisateurs " "avancés, et permet de filtrer les notifications qui vous intéressent." #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "Ticket #" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "Abonnements aux tickets par composant" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "URL ticket:" #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "" "Le ticket contient des caractères non-ASCII. Veuillez changer le paramètre " "d'encodage" #: announcer/templates/prefs_announcer_wiki.html:17 msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by itself." msgstr "" "Pour recevoir une notification concernant toutes les pages wiki, indiquez " "simplement [1:*]." #: announcer/templates/wiki_email_plaintext.txt:11 msgid "URL: <" msgstr "" #: announcer/util/mail_crypto.py:57 msgid "" "Unable to load the python-gnupg module. Please check and correct your " "installation." msgstr "" "Impossible de charger le module python-gnupg. Vérifiez et corrigez votre " "installation." #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "Ne plus suivre ceci" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "Code de vérification:" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "Suivre ceci" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "Suivre les utilisateurs" #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "Suivre les utilisateurs:" #: announcer/templates/prefs_announcer_xmppaddress.html:6 msgid "XMPP address:" msgstr "Adresse XMPP:" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "Vous ne recevrez plus de notifications concernant cet élément." #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "Vous ne suivez plus ce poste." #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "Vous recevrez maintenant des notifications concernant cet élément." #: announcer/opt/fullblog/announce.py:238 msgid "You are now watching this blog post." msgstr "Vous suivez maintenant ce poste." #: announcer/templates/prefs_announcer_wiki.html:12 msgid "" "You may use wild cards, so that if you want to hear about any page that " "starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "" "Vous pouvez utilisez des caractères de remplacement. Si vous voulez des " "notifications concernant tous les éléments dont le nom commence par 'Trac'\n" " vous pouvez indiquer: [1:Trac*]" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" "Votre mot de passe pour le Trac a été réinitialisé.\n" "\n" "Voici vos informations de compte:\n" "\n" "URL de connexion: <" #: announcer/templates/fullblog_plaintext.txt:4 msgid "\" by" msgstr "\" par" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "\" du poste \"" # , python-format #: announcer/templates/ticket_email_plaintext.txt:39 msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: , 2010. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "Report-Msgid-Bugs-To: hoff.st@web.de\n" "POT-Creation-Date: 2010-11-20 15:54+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 1.0dev-r482\n" #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "" #: announcer/pref.py:129 msgid "Subscriptions" msgstr "" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "" #: announcer/subscribers.py:98 msgid "notify me when any ticket changes" msgstr "" #: announcer/subscribers.py:159 msgid "notify me when a ticket that I own is created or modified" msgstr "" #: announcer/subscribers.py:221 msgid "" "notify me when a ticket that belongs to a component that I own is " "created or modified" msgstr "" #: announcer/subscribers.py:274 msgid "notify me when I update a ticket" msgstr "" #: announcer/subscribers.py:327 msgid "notify me when a ticket that I reported is modified" msgstr "" #: announcer/subscribers.py:385 msgid "notify me when I'm listed in the CC field of a ticket that is modified" msgstr "" #: announcer/subscribers.py:424 msgid "" "notify me when a ticket associated with a component I'm watching is " "modified" msgstr "" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "" #: announcer/subscribers.py:526 #, python-format msgid "notify me when I'm listed in any of the (%s) fields" msgstr "" #: announcer/subscribers.py:583 msgid "notify me on ticket changes in one of my subscribed groups" msgstr "" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "" #: announcer/subscribers.py:641 msgid "notify me when one of my watched users changes something" msgstr "" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "" #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "" #: announcer/subscribers.py:847 msgid "notify me when one of my watched wiki or tickets is updated" msgstr "" #: announcer/subscribers.py:896 msgid "" "notify me when a wiki that matches my wiki watch pattern is created, " "or updated" msgstr "" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "" #: announcer/distributors/mail.py:411 #, python-format msgid "Invalid email encoding setting: %s" msgstr "" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "" #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "" #: announcer/distributors/mail.py:519 msgid "undisclosed-recipients: ;" msgstr "" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "" #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "" #: announcer/opt/fullblog/announce.py:95 msgid "notify me when any blog is modified, changed, deleted or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:145 msgid "notify me when any blog that I posted is modified or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "" #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:238 msgid "You are now watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "" #: announcer/templates/acct_mgr_user_change_plaintext.txt:1 msgid "for user" msgstr "" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "build of" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "" "]\n" "---------------------------------------------------------------------" "\n" "\n" " Changeset:" msgstr "" #: announcer/templates/bitten_plaintext.txt:4 msgid "" ">\n" " Committed by:" msgstr "" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "" #: announcer/templates/bitten_plaintext.txt:9 #, python-format msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "" #: announcer/templates/bitten_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "--\n" "Build URL: <" msgstr "" #: announcer/templates/fullblog_plaintext.txt:1 #, python-format msgid "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Added post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "\" by" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "at" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Changed post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:8 #, python-format msgid "" "{% end %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:15 #, python-format msgid "" "{% if comment %}\\\n" "Comment:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Deleted post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:25 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 #, python-format msgid "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Comment added to post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:37 #, python-format msgid "{% end %}" msgstr "" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to " "you; \n" " the creation of a ticket, the change of a Wiki page, and so on." " Under\n" " the Announcement system, you will only receive notifications to" " those\n" " topics that you subscribe to." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 msgid "Send me announcements when users accounts are changed." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "" #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "" #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a " "plaintext format. You\n" " may override this for each realm that may generate announcements." msgstr "" #: announcer/templates/prefs_announcer_email.html:9 #: announcer/templates/prefs_announcer_xmpp.html:7 msgid "announcements:" msgstr "" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different " "address then the main one provided\n" " in Trac, you may specify the address here:" msgstr "" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following " "components have been defined by the Trac administrators. If you " "subscribe to any of these components, you will receive an " "notification anytime a ticket related to that component is changed or" " created." msgstr "" #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. " "They are general topics that may be added onto the CC list of tickets" " (by prepending their name with @). Case does matter." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:23 msgid "rules" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "down" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "up" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 msgid "Add" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as" " the default rules. Any rules defined by you will take higher " "priority then these rules. This can be confusing if you don't " "understand how the system works. Only the first matching rule is " "applied when system events occur. For example, if you have a rule " "like \"always notify me of any ticket changes\" in your custom rules," " and there is a default rule \"never notify me when I update a " "ticket\", then the always rule will take precedent and you will still" " recieve announcements on ticket changes, even when you are the " "updater. In the preceding case, you would need to add your own " "\"never notify me..\" rule above the \"always notify me..\" to get " "the proper behavior." msgstr "" #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows " "you to use filters to specify which events you are interested in " "hearing about." msgstr "" #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" #: announcer/templates/prefs_announcer_ticket_all.html:8 msgid "Notify me when any ticket changes." msgstr "" #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "" #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "" #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:5 msgid "" "A comma separated list of users you would like to watch. A watched " "user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki " "pages, you may list here\n" " pages that are of interest to you. Each page should be on a " "separate line." msgstr "" #: announcer/templates/prefs_announcer_wiki.html:12 msgid "" "You may use wild cards, so that if you want to hear about any page " "that starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "" #: announcer/templates/prefs_announcer_wiki.html:17 msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by" " itself." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber " "announcements delivered." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:6 msgid "XMPP address:" msgstr "" #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "" #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "" #: announcer/templates/ticket_email_mimic.html:132 msgid "" "changed \n" " from" msgstr "" #: announcer/templates/ticket_email_mimic.html:133 msgid "to" msgstr "" #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "added" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:1 #, python-format msgid "" "---------------------------------------------------------------------" "\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:5 #, python-format msgid "" "{% end %}\\\n" "{% otherwise %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:7 #, python-format msgid "" ": (None)\n" "{% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% if category == 'created' %}\\\n" "---------------------------------------------------------------------" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:12 #, python-format msgid "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------" "\n" "Changes (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:16 #, python-format msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 msgid "from '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 #, python-format msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:21 #, python-format msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:28 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Attachment:\n" " * File '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "' added{% if attachment.description %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------" "\n" "Comment{% if not has_changes %} (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:38 #, python-format msgid "){% end %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:39 #, python-format msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: #1: Some ticket summary Ticket #1 (new defect)
Some ticket summary
Reporter: None
Owner: None
Changes (by user1):
Attachment:
  • File somefile.txt added:

    Some WikiFormatted text




trac-announcer/trunk/announcer/tests/model.py0000644000175500017550000001263312350245165021506 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import shutil import tempfile import unittest from trac.db.api import DatabaseManager from trac.test import EnvironmentStub from announcer.api import AnnouncementSystem from announcer.model import Subscription, SubscriptionAttribute class SubscriptionTestSetup(unittest.TestCase): def setUp(self): self.env = EnvironmentStub(enable=['trac.*']) self.env.path = tempfile.mkdtemp() self.db_mgr = DatabaseManager(self.env) self.db = self.env.get_db_cnx() # Setup current announcer db schema tables. self.an_sys = AnnouncementSystem(self.env) self.an_sys.upgrade_environment(self.db) def tearDown(self): self.db.close() # Really close db connections. self.env.shutdown() shutil.rmtree(self.env.path) class SubscriptionTestCase(SubscriptionTestSetup): def setUp(self): SubscriptionTestSetup.setUp(self) self.sub = Subscription(self.env) self.sub['sid'] = 'user' self.sub['authenticated'] = 1 self.sub['distributor'] = 'email' self.sub['format'] = 'text/plain' self.sub['priority'] = 1 self.sub['adverb'] = 'always' self.sub['class'] = 'GeneralWikiSubscriber' def test_init(self): # Examine properties of the initialized objekt. fields = ('id', 'sid', 'authenticated', 'distributor', 'format', 'priority', 'adverb', 'class') sub = Subscription(self.env) for field in fields: self.assertEqual(sub[field], None) # Check basic class method for subscription presentation too. sub = self.sub self.assertEqual(sub.subscription_tuple(), (sub['class'], sub['distributor'], sub['sid'], sub['authenticated'], None, sub['format'], sub['priority'], sub['adverb'])) def test_add_move_delete(self): sub = self.sub sub.add(self.env, sub) sql = """ SELECT class,distributor,sid,authenticated, NULL,format,priority,adverb FROM subscription WHERE priority=%s """ cursor = self.db.cursor() cursor.execute(sql, (1,)) for subscription in cursor.fetchall(): self.assertEqual(subscription, sub.subscription_tuple()) sub['class'] = 'UserChangeSubscriber' sub.add(self.env, sub) cursor.execute("SELECT COUNT(*) FROM subscription") count = cursor.fetchone() self.assertEqual(count[0], 2) sub.move(self.env, 1, 2) cursor.execute(sql, (1,)) for subscription in cursor.fetchall(): self.assertEqual(subscription[0], sub['class']) sub.delete(self.env, 1) cursor.execute("SELECT COUNT(*) FROM subscription") count = cursor.fetchone() self.assertEqual(count[0], 1) # Make sure, that we really deleted the 1st subscription. cursor.execute(sql, (1,)) for subscription in cursor.fetchall(): self.assertEqual(subscription[0], sub['class']) # Can't delete the same subscription twice. self.assertRaises(TypeError, sub.delete, self.env, 1) #def test_update_format_by_distributor_and_sid(self): #def test_find_by_sid_and_distributor(self): #def test_find_by_sids_and_class(self): #def test_find_by_class(self): class SubscriptionAttributeTestCase(SubscriptionTestSetup): def test_init(self): # Examine properties of the initialized objekt. fields = ('id', 'sid', 'authenticated', 'class', 'realm', 'target') attr = SubscriptionAttribute(self.env) for field in fields: self.assertEqual(attr[field], None) def test_add_delete(self): attr = SubscriptionAttribute(self.env) attr.add(self.env, 'user', 1, 'GeneralWikiSubscriber','wiki', ('TracWiki', 'TracWiki')) cursor = self.db.cursor() cursor.execute("SELECT COUNT(*) FROM subscription_attribute") count = cursor.fetchone() self.assertEqual(count[0], 2) attr.delete(self.env, 1) # Make sure, that we really deleted the 1st attribute. cursor.execute("SELECT target FROM subscription_attribute") for attribute in cursor.fetchone(): self.assertEqual(attribute, 'TracWiki') cursor.execute("SELECT COUNT(*) FROM subscription_attribute") count = cursor.fetchone() self.assertEqual(count[0], 1) # Deleting non-existent subscriptions is handled gracefully. attr.delete(self.env, 1) #def test_delete_by_sid_and_class(self): #def test_delete_by_sid_class_and_target(self): #def test_delete_by_class_realm_and_target(self): #def test_find_by_sid_and_class(self): #def test_find_by_sid_class_and_target(self): #def test_find_by_sid_class_realm_and_target(self): #def test_find_by_class_realm_and_target(self): #def test_find_by_class_and_realm(self): def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(SubscriptionTestCase, 'test')) suite.addTest(unittest.makeSuite(SubscriptionAttributeTestCase, 'test')) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer/trunk/announcer/tests/__init__.py0000644000175500017550000000163612350245165022146 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2012, Ryan J Ollos # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import unittest from announcer.tests import api, filters, formatters, model, pref, subscribers from announcer.opt.tests import test_suite as opt_test_suite def test_suite(): suite = unittest.TestSuite() suite.addTest(api.suite()) suite.addTest(filters.suite()) suite.addTest(formatters.suite()) suite.addTest(model.suite()) suite.addTest(pref.suite()) suite.addTest(subscribers.suite()) suite.addTest(opt_test_suite()) return suite # Start test suite directly from command line like so: # $> PYTHONPATH=$PWD python announcer/tests/__init__.py if __name__ == '__main__': unittest.main(defaultTest="test_suite") trac-announcer/trunk/announcer/tests/pref.py0000644000175500017550000000374412350245165021345 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import shutil import tempfile import unittest from trac.db.api import DatabaseManager from trac.test import EnvironmentStub from trac.web.chrome import Chrome from announcer.pref import AnnouncerTemplateProvider from announcer.pref import AnnouncerPreferences from announcer.pref import SubscriptionManagementPanel class AnnouncerPreferencesTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub(enable=['trac.*']) self.env.path = tempfile.mkdtemp() self.db_mgr = DatabaseManager(self.env) self.db = self.env.get_db_cnx() def tearDown(self): self.db.close() # Really close db connections. self.env.shutdown() shutil.rmtree(self.env.path) # Tests def test_init(self): # Test just to confirm that IPreferencePanelProviders initialize # cleanly and that setUp and tearDown both work. AnnouncerPreferences(self.env) SubscriptionManagementPanel(self.env) pass class AnnouncerTemplateProviderTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub( enable=['trac.*', 'announcer.pref.*']) self.env.path = tempfile.mkdtemp() # AnnouncerTemplateProvider is abstract, test using a subclass. self.sm_panel = SubscriptionManagementPanel(self.env) def tearDown(self): shutil.rmtree(self.env.path) def test_template_dirs_added(self): self.assertTrue(self.sm_panel in Chrome(self.env).template_providers) def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(AnnouncerPreferencesTestCase, 'test')) suite.addTest(unittest.makeSuite(AnnouncerTemplateProviderTestCase, 'test')) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer/trunk/announcer/tests/filters.py0000644000175500017550000000202412350245165022047 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Ryan J Ollos # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import shutil import tempfile import unittest from trac.test import EnvironmentStub from announcer.filters import DefaultPermissionFilter class DefaultPermissionFilterTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub(enable=['trac.*', 'announcer.filters.*']) self.env.path = tempfile.mkdtemp() def tearDown(self): shutil.rmtree(self.env.path) def test_init(self): # Test just to confirm that DefaultPermissionFilter initializes cleanly # and that setUp and tearDown both work. DefaultPermissionFilter(self.env) def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(DefaultPermissionFilterTestCase, 'test')) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer/trunk/announcer/tests/subscribers.py0000644000175500017550000000451412350245165022733 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import shutil import tempfile import unittest from trac.db.api import DatabaseManager from trac.test import EnvironmentStub from announcer.subscribers import CarbonCopySubscriber from announcer.subscribers import TicketOwnerSubscriber from announcer.subscribers import TicketReporterSubscriber from announcer.subscribers import TicketUpdaterSubscriber class SubscriberTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub( enable=['trac.*', 'announcer.subscribers.*']) self.env.path = tempfile.mkdtemp() self.db_mgr = DatabaseManager(self.env) self.db = self.env.get_db_cnx() def tearDown(self): self.db.close() # Really close db connections. self.env.shutdown() shutil.rmtree(self.env.path) class CarbonCopySubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that CarbonCopySubscriber initializes cleanly. CarbonCopySubscriber(self.env) pass class TicketOwnerSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that TicketOwnerSubscriber initializes cleanly. TicketOwnerSubscriber(self.env) pass class TicketReporterSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that TicketReporterSubscriber initializes # cleanly. TicketReporterSubscriber(self.env) pass class TicketUpdaterSubscriberTestCase(SubscriberTestCase): def test_init(self): # Test just to confirm that TicketUpdaterSubscriber initializes # cleanly. TicketUpdaterSubscriber(self.env) pass def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(CarbonCopySubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(TicketOwnerSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(TicketReporterSubscriberTestCase, 'test')) suite.addTest(unittest.makeSuite(TicketUpdaterSubscriberTestCase, 'test')) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer/trunk/announcer/tests/formatters.py0000644000175500017550000001024512350245165022571 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2012, Steffen Hoffmann # Copyright (c) 2012, Ryan J Ollos # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import shutil import tempfile import unittest from pkg_resources import resource_filename from trac.attachment import Attachment from trac.test import EnvironmentStub from trac.ticket.model import Ticket from trac.web.chrome import Chrome from announcer.formatters import TicketFormatter, WikiFormatter from announcer.producers import TicketChangeEvent class FormatterTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub(enable=['trac.*', 'announcer.formatters.*']) self.env.path = tempfile.mkdtemp() def tearDown(self): shutil.rmtree(self.env.path) class TicketFormatterTestCase(FormatterTestCase): def setUp(self): FormatterTestCase.setUp(self) self.tf = TicketFormatter(self.env) # Tests def test_add_attachment_html_notification(self): ticket = Ticket(self.env) ticket['description'] = 'Some ticket description' ticket['summary'] = 'Some ticket summary' ticket['type'] = 'defect' ticket['status'] = 'new' ticket.insert() attachment = Attachment(self.env, ticket) attachment.description = "`Some` '''!WikiFormatted''' ''text''" attachment.filename = 'somefile.txt' event = TicketChangeEvent('ticket', 'changed', ticket, author='user1', attachment=attachment) actual = self.tf.format([], 'ticket', 'text/html', event) filename = resource_filename(__name__, 'attachment_notification.html') file = open(filename, 'r') expected = file.read() file.close() self.assertEqual(expected, actual) def test_styles(self): self.assertTrue('text/html' in self.tf.styles('email', 'ticket')) self.assertTrue('text/plain' in self.tf.styles('email', 'ticket')) self.assertFalse('text/plain' in self.tf.styles('email', 'wiki')) self.assertEqual('text/plain', self.tf.alternative_style_for('email', 'ticket', 'text/blah')) self.assertEqual('text/plain', self.tf.alternative_style_for('email', 'ticket', 'text/html')) self.assertEqual(None, self.tf.alternative_style_for('email', 'ticket', 'text/plain')) def test_template_dirs_added(self): self.assertTrue(self.tf in Chrome(self.env).template_providers) class WikiFormatterTestCase(FormatterTestCase): def setUp(self): FormatterTestCase.setUp(self) self.wf = WikiFormatter(self.env) # Tests def test_styles(self): # HMTL format for email notifications is yet unsupported for wiki. #self.assertTrue('text/html' in self.tf.styles('email', 'wiki')) self.assertTrue('text/plain' in self.wf.styles('email', 'wiki')) self.assertFalse('text/plain' in self.wf.styles('email', 'ticket')) self.assertEqual('text/plain', self.wf.alternative_style_for('email', 'wiki', 'text/blah')) self.assertEqual('text/plain', self.wf.alternative_style_for('email', 'wiki', 'text/html')) self.assertEqual(None, self.wf.alternative_style_for('email', 'wiki', 'text/plain')) def test_template_dirs_added(self): self.assertTrue(self.wf in Chrome(self.env).template_providers) def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(TicketFormatterTestCase, 'test')) suite.addTest(unittest.makeSuite(WikiFormatterTestCase, 'test')) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer/trunk/announcer/tests/api.py0000644000175500017550000005035512350245165021162 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import shutil import tempfile import unittest from trac import __version__ as trac_version from trac.core import Component, implements from trac.db import Table, Column, Index from trac.db.api import DatabaseManager from trac.test import EnvironmentStub from announcer import db_default from announcer.api import AnnouncementSystem, AnnouncementEvent from announcer.api import IAnnouncementSubscriptionFilter from announcer.api import SubscriptionResolver class AnnouncementEventTestCase(unittest.TestCase): def setUp(self): self.event = AnnouncementEvent('realm', 'category', 'target') # Tests def test_init(self): # Examine properties of the initialized objekt. event = self.event event_props = [event.realm, event.category, event.target, event.author] self.assertEquals(event_props, ['realm', 'category', 'target', '']) def test_get_basic_terms(self): # Method doesn't accept any argument. self.assertRaises(TypeError, self.event.get_basic_terms, None) self.assertEquals(self.event.get_basic_terms(), ('realm', 'category')) def test_get_session_terms(self): # While having mandatory argument, return value is a constant. self.assertRaises(TypeError, self.event.get_session_terms) self.assertEquals(self.event.get_session_terms(None), tuple()) self.assertEquals(self.event.get_session_terms('anonymous'), tuple()) class AnnouncementSystemSetupTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub(enable=['trac.*']) self.env.path = tempfile.mkdtemp() self.db_mgr = DatabaseManager(self.env) self.db = self.env.get_db_cnx() self.an_sys = AnnouncementSystem(self.env) def tearDown(self): self.db.close() # Really close db connections. self.env.shutdown() shutil.rmtree(self.env.path) # Helpers def _get_cursor_description(self, cursor): # Cursors don't look the same across Trac versions if trac_version < '0.12': return cursor.description else: return cursor.cursor.description def _schema_init(self, schema=None): # Current announcer schema is setup with enabled component anyway. # Revert these changes for clean install testing. cursor = self.db.cursor() cursor.execute("DROP TABLE IF EXISTS subscriptions") cursor.execute("DROP TABLE IF EXISTS subscription") cursor.execute("DROP TABLE IF EXISTS subscription_attribute") cursor.execute("DELETE FROM system WHERE name='announcer_version'") if schema: connector = self.db_mgr._get_connector()[0] for table in schema: for stmt in connector.to_sql(table): cursor.execute(stmt) def _verify_curr_schema(self): self.assertFalse(self.an_sys.environment_needs_upgrade(self.db)) cursor = self.db.cursor() cursor.execute("SELECT * FROM subscription_attribute") columns = [col[0] for col in self._get_cursor_description(cursor)] self.assertTrue('name' not in columns) self.assertTrue('value' not in columns) self.assertEquals( ['id', 'sid', 'authenticated', 'class', 'realm', 'target'], columns ) cursor.execute(""" SELECT value FROM system WHERE name='announcer_version' """) version = int(cursor.fetchone()[0]) self.assertEquals(db_default.schema_version, version) def _verify_version_unregistered(self): cursor = self.db.cursor() cursor.execute(""" SELECT value FROM system WHERE name='announcer_version' """) self.assertFalse(cursor.fetchone()) # Tests def test_new_install(self): # Just do db table clean-up. self._schema_init() self.assertEquals(0, self.an_sys.get_schema_version(self.db)) self.assertTrue(self.an_sys.environment_needs_upgrade(self.db)) self.an_sys.upgrade_environment(self.db) self._verify_curr_schema() def test_upgrade_v1_to_current(self): # The initial db schema from r3015 - 10-Jan-2008 by Stephen Hansen. schema = [ Table('subscriptions', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('enabled', type='int'), Column('managed', type='int'), Column('realm'), Column('category'), Column('rule'), Column('destination'), Column('format'), Index(['id']), Index(['realm', 'category', 'enabled']), ] ] self._schema_init(schema) # Populate tables with test data. cursor = self.db.cursor() cursor.executemany(""" INSERT INTO session (sid,authenticated,last_visit) VALUES (%s,%s,%s) """, (('somebody','0','0'), ('user','1','0'))) cursor.executemany(""" INSERT INTO session_attribute (sid,authenticated,name,value) VALUES (%s,1,%s,%s) """, (('user','announcer_email_format_ticket','text/html'), ('user','announcer_specified_email',''))) cursor.executemany(""" INSERT INTO subscriptions (sid,enabled,managed, realm,category,rule,destination,format) VALUES (%s,%s,0,%s,%s,%s,%s,%s) """, (('somebody',1,'ticket','changed','1','1','email'), ('user',1,'ticket','attachment added','1','1','email'))) self.assertEquals(1, self.an_sys.get_schema_version(self.db)) target = 6 db_default.schema_version = target self.assertTrue(self.an_sys.environment_needs_upgrade(self.db)) self.an_sys.upgrade_environment(self.db) self._verify_curr_schema() def test_upgrade_to_schema_v2(self): # The initial db schema from r3015 - 10-Jan-2008 by Stephen Hansen. schema = [ Table('subscriptions', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('enabled', type='int'), Column('managed', type='int'), Column('realm'), Column('category'), Column('rule'), Column('destination'), Column('format'), Index(['id']), Index(['realm', 'category', 'enabled']), ] ] self._schema_init(schema) # Populate tables with test data. cursor = self.db.cursor() cursor.executemany(""" INSERT INTO session (sid,authenticated,last_visit) VALUES (%s,%s,%s) """, (('somebody','0','0'), ('user','1','0'))) cursor.executemany(""" INSERT INTO session_attribute (sid,authenticated,name,value) VALUES (%s,1,%s,%s) """, (('user','announcer_email_format_ticket','text/html'), ('user','announcer_specified_email',''))) cursor.executemany(""" INSERT INTO subscriptions (sid,enabled,managed, realm,category,rule,destination,format) VALUES (%s,%s,0,%s,%s,%s,%s,%s) """, (('somebody',1,'ticket','changed','1','1','email'), ('user',1,'ticket','attachment added','1','1','email'))) self.assertEquals(1, self.an_sys.get_schema_version(self.db)) target = 2 db_default.schema_version = target self.assertTrue(self.an_sys.environment_needs_upgrade(self.db)) # Change from r3047 - 13-Jan-2008 for announcer-0.2 by Stephen Hansen. # - 'subscriptions.destination', 'subscriptions.format' # + 'subscriptions.authenticated', 'subscriptions.transport' # 'subscriptions.managed' type='int' --> (default == char) self.an_sys.upgrade_environment(self.db) self.assertEquals(target, self.an_sys.get_schema_version(self.db)) self._verify_version_unregistered() cursor = self.db.cursor() cursor.execute("SELECT * FROM subscriptions") columns = [col[0] for col in self._get_cursor_description(cursor)] self.assertEquals(['id', 'sid', 'authenticated', 'enabled', 'managed', 'realm', 'category', 'rule', 'transport'], columns ) def test_upgrade_to_schema_v3(self): # Schema from r3047 - 13-Jan-2008 for announcer-0.2 by Stephen Hansen. schema = [ Table('subscriptions', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('authenticated', type='int'), Column('enabled', type='int'), Column('managed'), Column('realm'), Column('category'), Column('rule'), Column('transport'), Index(['id']), Index(['realm', 'category', 'enabled']), ] ] self._schema_init(schema) # Populate tables with test data. cursor = self.db.cursor() cursor.executemany(""" INSERT INTO session_attribute (sid,authenticated,name,value) VALUES (%s,1,%s,%s) """, (('user','announcer_email_format_ticket','text/html'), ('user','announcer_email_format_wiki','text/plain'), ('user','announcer_specified_email',''))) cursor.executemany(""" INSERT INTO subscriptions (sid,authenticated,enabled,managed, realm,category,rule,transport) VALUES (%s,%s,1,%s,%s,%s,%s,%s) """, (('user',1,'watcher','ticket','changed','1','email'), ('user',1,'watcher','wiki','*','WikiStart','email'))) self.assertEquals(2, self.an_sys.get_schema_version(self.db)) target = 3 db_default.schema_version = target self.assertTrue(self.an_sys.environment_needs_upgrade(self.db)) # From r9116 - 25-Sep-2010 for announcer-0.12.1 by Robert Corsaro. # + table 'subscription', 'subscription_attribute' self.an_sys.upgrade_environment(self.db) self.assertEquals(target, self.an_sys.get_schema_version(self.db)) def test_upgrade_to_schema_v4(self): # Schema from r9116 - 25-Sep-2010 for announcer-0.12.1 by R. Corsaro. schema = [ Table('subscriptions', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('authenticated', type='int'), Column('enabled', type='int'), Column('managed'), Column('realm'), Column('category'), Column('rule'), Column('transport'), Index(['id']), Index(['realm', 'category', 'enabled']), ], Table('subscription', key='id')[ Column('id', auto_increment=True), Column('time', type='int64'), Column('changetime', type='int64'), Column('class'), Column('sid'), Column('authenticated', type='int'), Column('distributor'), Column('format'), Column('priority'), Column('adverb') ], Table('subscription_attribute', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('class'), Column('name'), Column('value') ] ] self._schema_init(schema) # Populate tables with test data. cursor = self.db.cursor() cursor.execute(""" INSERT INTO subscription (time,changetime,class,sid,authenticated, distributor,format,priority,adverb) VALUES ('0','0','GeneralWikiSubscriber','user','1', 'email','text/plain','1','always') """) cursor.executemany(""" INSERT INTO subscription_attribute (sid,class,name,value) VALUES (%s,%s,%s,%s) """, (('somebody','GeneralWikiSubscriber','wiki', '*'), ('somebody','UserChangeSubscriber','wiki','created'), ('user','GeneralWikiSubscriber','wiki', 'TracWiki'))) self.assertEquals(3, self.an_sys.get_schema_version(self.db)) target = 4 db_default.schema_version = target self.assertTrue(self.an_sys.environment_needs_upgrade(self.db)) # From r9210 - 29-Sep-2010 for announcer-0.12.1 by Robert Corsaro. # - table 'subscriptions' # 'subscription.priority' type=(default == char) --> 'int' # 'subscription_attribute.name --> 'subscription_attribute.realm' # 'subscription_attribute.value --> 'subscription_attribute.target' self.an_sys.upgrade_environment(self.db) self.assertEquals(target, self.an_sys.get_schema_version(self.db)) # Check type of priority value. cursor = self.db.cursor() cursor.execute("SELECT priority FROM subscription") for priority in cursor: # Shouldn't raise an TypeError with appropriate column type. result = priority[0] + 0 def test_upgrade_to_schema_v5(self): # Schema from r9210 - 29-Sep-2010 for announcer-0.12.1 by R. Corsaro. schema = [ Table('subscription', key='id')[ Column('id', auto_increment=True), Column('time', type='int64'), Column('changetime', type='int64'), Column('class'), Column('sid'), Column('authenticated', type='int'), Column('distributor'), Column('format'), Column('priority', type='int'), Column('adverb') ], Table('subscription_attribute', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('class'), Column('realm'), Column('target') ] ] self._schema_init(schema) # Populate tables with test data. cursor = self.db.cursor() cursor.executemany(""" INSERT INTO session (sid,authenticated,last_visit) VALUES (%s,%s,%s) """, (('somebody','0','0'), ('user','1','0'))) cursor.executemany(""" INSERT INTO subscription_attribute (sid,class,realm,target) VALUES (%s,%s,%s,%s) """, (('somebody','GeneralWikiSubscriber','wiki', '*'), ('somebody','UserChangeSubscriber','wiki','created'), ('user','GeneralWikiSubscriber','wiki', 'TracWiki'))) self.assertEquals(4, self.an_sys.get_schema_version(self.db)) target = 5 db_default.schema_version = target self.assertTrue(self.an_sys.environment_needs_upgrade(self.db)) # From r9235 - 01-Oct-2010 for announcer-0.12.1 by Robert Corsaro. # + 'subscription_attribute.authenticated' self.an_sys.upgrade_environment(self.db) self.assertEquals(target, self.an_sys.get_schema_version(self.db)) self._verify_version_unregistered() cursor = self.db.cursor() cursor.execute("SELECT * FROM subscription_attribute") columns = [col[0] for col in self._get_cursor_description(cursor)] self.assertTrue('name' not in columns) self.assertTrue('value' not in columns) self.assertEquals( ['id', 'sid', 'authenticated', 'class', 'realm', 'target'], columns ) # Check authenticated attribute for session IDs. subscriptions = [(row[1],(row[2])) for row in cursor] for sub in subscriptions: self.assertTrue((sub[0] == 'user' and sub[1] == 1) or sub[1] == 0) def test_upgrade_to_schema_v6(self): # Check data migration and registration of unversioned schema. # Table definitions are identical to current schema here, see # schema from r9235 - 01-Oct-2010 for announcer-0.12.1 by R. Corsaro. self._schema_init(db_default.schema) # Populate table with test data. cursor = self.db.cursor() if self.env.config.get('trac', 'database').startswith('sqlite'): # Add dataset with CURRENT_TIMESTAMP strings. cursor.execute(""" INSERT INTO subscription (time,changetime, class,sid,authenticated, distributor,format,priority,adverb) VALUES ('1970-01-01 00:00:00','2012-10-31 23:59:59', 'GeneralWikiSubscriber','user','1', 'email','text/plain','1','always') """) else: cursor.execute(""" INSERT INTO subscription (time,changetime, class,sid,authenticated, distributor,format,priority,adverb) VALUES ('0','1351724399', 'GeneralWikiSubscriber','user','1', 'email','text/plain','1','always') """) cursor.execute(""" INSERT INTO subscription_attribute (sid,authenticated,class,realm,target) VALUES ('user','1','GeneralWikiSubscriber','wiki', 'TracWiki') """) self.assertEquals(5, self.an_sys.get_schema_version(self.db)) target = 6 db_default.schema_version = target self.assertTrue(self.an_sys.environment_needs_upgrade(self.db)) # Data migration and registration of unversioned schema. self.an_sys.upgrade_environment(self.db) self._verify_curr_schema() cursor.execute("SELECT time,changetime FROM subscription") for time in cursor: # Shouldn't raise an TypeError with proper int/long values. check = time[1] - time[0] class SubscriptionResolverTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub(enable=['trac.*']) self.env.path = tempfile.mkdtemp() self.db_mgr = DatabaseManager(self.env) self.db = self.env.get_db_cnx() def tearDown(self): self.db.close() # Really close db connections. self.env.shutdown() shutil.rmtree(self.env.path) # Tests def test_init(self): # Test just to confirm that SubscriptionResolver initializes cleanly # and that setUp and tearDown both work. SubscriptionResolver(self.env) pass class AnnouncementSystemSendTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub(enable=['trac.*', 'announcer.*']) self.env.path = tempfile.mkdtemp() self.db_mgr = DatabaseManager(self.env) self.db = self.env.get_db_cnx() self.an_sys = AnnouncementSystem(self.env) def tearDown(self): self.db.close() # Really close db connections. self.env.shutdown() shutil.rmtree(self.env.path) # Tests def test_filter_added(self): class DummySubscriptionFilter(Component): """Test implementation for checking the filter ExtensionPoint.""" implements(IAnnouncementSubscriptionFilter) def filter_subscriptions(self, event, subscriptions): """Just a pass-through.""" return subscriptions dummy = DummySubscriptionFilter(self.env) self.assertTrue(dummy in self.an_sys.subscription_filters) def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(AnnouncementEventTestCase, 'test')) suite.addTest(unittest.makeSuite(AnnouncementSystemSetupTestCase, 'test')) suite.addTest(unittest.makeSuite(AnnouncementSystemSendTestCase, 'test')) suite.addTest(unittest.makeSuite(SubscriptionResolverTestCase, 'test')) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer/trunk/announcer/htdocs/0000755000175500017550000000000011335531124020144 5ustar debacledebacletrac-announcer/trunk/announcer/htdocs/css/0000755000175500017550000000000011335531124020734 5ustar debacledebacletrac-announcer/trunk/announcer/htdocs/css/announcer_prefs.css0000644000175500017550000000062011335531124024633 0ustar debacledebaclediv.announcer_preference_box { background-color: #f0f6fe; width: 95%; border: thin outset #d4d1ff; padding: 0.5em; text-align: left; margin: 1em; } div.announcer_preference_title { background-color: #213da4; color: #fff; padding: 5px; font-family: "Lucida Grande", Lucida, Verdana, sans-serif; } div.announcer_preference_options { padding: 2px; } trac-announcer/trunk/announcer/htdocs/css/rulediv.css0000644000175500017550000000022011335531124023112 0ustar debacledebacle/* @override http://home.killnine.net/falcon/chrome/announcerplugin/css/rulediv.css */ #announcer_rules .syntax { background-color: #e6e6e6; }trac-announcer/trunk/announcer/formatters/0000755000175500017550000000000011447713103021051 5ustar debacledebacletrac-announcer/trunk/announcer/email_decorators.py0000644000175500017550000002160612346652667022576 0ustar debacledebacle# This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import re from email.Utils import parseaddr from genshi.template import NewTextTemplate, TemplateError from trac import __version__ as trac_version from trac.config import ListOption, Option from trac.core import Component, implements from trac.util.text import to_unicode from announcer import __version__ as announcer_version from announcer.distributors.mail import IAnnouncementEmailDecorator from announcer.util.mail import msgid, next_decorator, set_header, uid_encode """Email decorators have the chance to modify emails or their headers, before the email distributor sends them out. """ class ThreadingEmailDecorator(Component): """Add Message-ID, In-Reply-To and References message headers for resources. All message ids are derived from the properties of the ticket so that they can be regenerated later. """ implements(IAnnouncementEmailDecorator) supported_realms = ListOption('announcer', 'email_threaded_realms', 'ticket,wiki', doc="""These are realms with announcements that should be threaded emails. In order for email threads to work, the announcer system needs to give the email recreatable Message-IDs based on the resources in the realm. The resources must have a unique and immutable id, name or str() representation in it's realm """) def decorate_message(self, event, message, decorates=None): """ Added headers to the outgoing email to track it's relationship with a ticket. References, In-Reply-To and Message-ID are just so email clients can make sense of the threads. """ if to_unicode(event.realm) in self.supported_realms: uid = uid_encode(self.env.abs_href(), event.realm, event.target) email_from = self.config.get('announcer', 'email_from', 'localhost') _, email_addr = parseaddr(email_from) host = re.sub('^.+@', '', email_addr) mymsgid = msgid(uid, host) if event.category == 'created': set_header(message, 'Message-ID', mymsgid) else: set_header(message, 'In-Reply-To', mymsgid) set_header(message, 'References', mymsgid) return next_decorator(event, message, decorates) class StaticEmailDecorator(Component): """The static ticket decorator implements a policy to -always- send an email to a certain address. Controlled via the always_cc and always_bcc option in the announcer section of the trac.ini. If no subscribers are found, then even if always_cc and always_bcc addresses are specified, no announcement will be sent. Since these fields are added after announcers subscription system, filters such as never_announce and never_notify author won't work with these addresses. These settings are considered dangerous if you are using the verify email or reset password features of the accountmanager plugin. """ # FIXME: mark that emails as 'private' in AcctMgr and eval that mark here implements(IAnnouncementEmailDecorator) always_cc = Option("announcer", "email_always_cc", None, """Email addresses specified here will always be cc'd on all announcements. This setting is dangerous if accountmanager is present. """) always_bcc = Option("announcer", "email_always_bcc", None, """Email addresses specified here will always be bcc'd on all announcements. This setting is dangerous if accountmanager is present. """) def decorate_message(self, event, message, decorates=None): for k, v in {'Cc': self.always_cc, 'Bcc': self.always_bcc}.items(): if v: self.log.debug("StaticEmailDecorator added '%s' " "because of rule: email_always_%s"%(v, k.lower())), if message[k] and len(str(message[k]).split(',')) > 0: recips = ", ".join([str(message[k]), v]) else: recips = v set_header(message, k, recips) return next_decorator(event, message, decorates) class AnnouncerEmailDecorator(Component): """Add some boring headers that should be set.""" implements(IAnnouncementEmailDecorator) def decorate_message(self, event, message, decorators): mailer = 'AnnouncerPlugin v%s on Trac v%s' % ( announcer_version, trac_version ) set_header(message, 'Auto-Submitted', 'auto-generated') set_header(message, 'Precedence', 'bulk') set_header(message, 'X-Announcer-Version', announcer_version) set_header(message, 'X-Mailer', mailer) set_header(message, 'X-Trac-Announcement-Realm', event.realm) set_header(message, 'X-Trac-Project', self.env.project_name) set_header(message, 'X-Trac-Version', trac_version) return next_decorator(event, message, decorators) class TicketSubjectEmailDecorator(Component): """Formats ticket announcement subject headers based on the ticket_email_subject configuration. """ implements(IAnnouncementEmailDecorator) ticket_email_subject = Option('announcer', 'ticket_email_subject', "Ticket #${ticket.id}: ${ticket['summary']} " \ "{% if action %}[${action}]{% end %}", """Format string for ticket email subject. This is a mini genshi template that is passed the ticket event and action objects.""") def decorate_message(self, event, message, decorates=None): if event.realm == 'ticket': if 'status' in event.changes: action = 'Status -> %s' % (event.target['status']) template = NewTextTemplate( self.ticket_email_subject.encode('utf8')) # Create a fallback for invalid custom Genshi template in option. default_template = NewTextTemplate( Option.registry[('announcer', 'ticket_email_subject') ].default.encode('utf8')) try: subject = template.generate( ticket=event.target, event=event, action=event.category ).render('text', encoding=None) except TemplateError: # Use fallback template. subject = default_template.generate( ticket=event.target, event=event, action=event.category ).render('text', encoding=None) prefix = self.config.get('announcer', 'email_subject_prefix') if prefix == '__default__': prefix = '[%s] ' % self.env.project_name if prefix: subject = "%s%s" % (prefix, subject) if event.category != 'created': subject = 'Re: %s' % subject set_header(message, 'Subject', subject) return next_decorator(event, message, decorates) class TicketAddlHeaderEmailDecorator(Component): """Adds X-Announcement-(id,priority and severity) headers to ticket emails. This is useful for automated handling of incoming emails or customized filtering. """ implements(IAnnouncementEmailDecorator) def decorate_message(self, event, message, decorates=None): if event.realm == 'ticket': for k in ('id', 'priority', 'severity'): name = 'X-Announcement-%s'%k.capitalize() set_header(message, name, event.target[k]) return next_decorator(event, message, decorates) class WikiSubjectEmailDecorator(Component): """Formats wiki announcement subject headers based on the wiki_email_subject configuration. """ implements(IAnnouncementEmailDecorator) wiki_email_subject = Option('announcer', 'wiki_email_subject', "Page: ${page.name} ${action}", """Format string for the wiki email subject. This is a mini genshi template and it is passed the page, event and action objects.""") def decorate_message(self, event, message, decorates=None): if event.realm == 'wiki': template = NewTextTemplate(self.wiki_email_subject.encode('utf8')) subject = template.generate( page=event.target, event=event, action=event.category ).render('text', encoding=None) prefix = self.config.get('announcer', 'email_subject_prefix') if prefix == '__default__': prefix = '[%s] ' % self.env.project_name if prefix: subject = "%s%s"%(prefix, subject) if event.category != 'created': subject = 'Re: %s'%subject set_header(message, 'Subject', subject) return next_decorator(event, message, decorates) trac-announcer/trunk/announcer/api.py0000644000175500017550000005636712346652667020047 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010-2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import time from operator import itemgetter from pkg_resources import resource_filename from trac import __version__ as trac_version from trac.config import ExtensionOption from trac.core import Component, ExtensionPoint, Interface, TracError, \ implements from trac.db import DatabaseManager from trac.env import IEnvironmentSetupParticipant from announcer import db_default class IAnnouncementProducer(Interface): """Producer converts Trac events from different subsystems, into AnnouncerEvents. """ def realms(): """Returns an iterable that lists all the realms that this producer is capable of producing events for. """ class IAnnouncementSubscriber(Interface): """IAnnouncementSubscriber provides an interface where a Plug-In can register realms and categories of subscriptions it is able to provide. An IAnnouncementSubscriber component can use any means to determine if a user is interested in hearing about a given event. More then one component can handle the same realms and categories. The subscriber must also indicate not just that a user is interested in receiving a particular notice. Again, how it makes that decision is entirely up to a particular implementation.""" def matches(event): """Returns a list of subscriptions that match the given event. Responses should be yielded as 7 part tuples as follows: (distributor, sid, authenticated, address, format, priority, adverb) The default installation includes email and xmpp distributors. The default installation includes formats for text/plain and text/html. If an unknown format is return, it will be replaced by a default known format. Priority is used to resolve conflicting subscriptions for the same user/distribution pair. adverb is either always or never. """ def description(): """A description of the subscription that shows up in the users preferences. """ def requires_authentication(): """Returns True or False. If the user is required to be authenticated to create the subscription, then return True. This applies to things like ticket owner subscriber, since the ticket owner can never be the sid of an unauthenticated user and we have no way to lookup users by email address (as of yet). """ class IAnnouncementDefaultSubscriber(Interface): """Default subscriptions that the module will automatically generate. This should only be used in reasonable situations, where users can be determined by the event itself. For instance, ticket author has a default subscription that is controlled via trac.ini. This is because we can lookup the ticket author during the event and create a subscription for them. Default subscriptions should be low priority so that the user can easily override them. """ def default_subscriptions(): """Yields 5 part tuple containing (class, distributor, priority, adverb). This is used to display default subscriptions in the user UI and can also be used by matches to figure out what default subscriptions it should yield. """ class IAnnouncementSubscriptionFilter(Interface): """IAnnouncementSubscriptionFilter provides an interface where a component can filter subscribers from the final distribution list. """ def filter_subscriptions(event, subscriptions): """Returns a filtered iterator of subscriptions. This method is called after all get_subscriptions_for_event calls are made to allow components to remove addresses from the distribution list. This can be used for things like "never notify updater" functionality. """ class IAnnouncementFormatter(Interface): """Formatters are responsible for converting an event into a message appropriate for a given transport. For transports like 'aim' or 'irc', this may be a short summary of a change. For 'email', it may be a plaintext or html overview of all the changes and perhaps the existing state. It's up to a formatter to determine what ends up ultimately being sent to the end-user. It's capable of pulling data out of the target object that wasn't changed, picking and choosing details for whatever reason. Since a formatter must be intimately familiar with the realm that originated the event, formatters are tied to specific transport + realm combinations. This means there may be a proliferation of formatters as options expand. """ def format_styles(transport, realm): """Returns an iterable of styles that this formatter supports for a specified transport and realm. Many formatters may simply return a single style and never have more; that's fine. But if its useful to encapsulate code for several similar styles a formatter can handle more then one. For example, 'text/plain' and 'text/html' may be useful variants the same formatter handles. Formatters retain the ability to descriminate by transport, but don't need to. """ def alternative_style_for(transport, realm, style): """Returns an alternative style for the given style if one is available. """ def format(transport, realm, style, event): """Converts the event into the specified style. If the transport or realm passed into this method are not ones this formatter can handle, it should return silently and without error. The exact return type of this method is intentionally undefined. It will be whatever the distributor that it is designed to work with expects. """ class IAnnouncementDistributor(Interface): """The Distributor is responsible for actually delivering an event to the desired subscriptions. A distributor should attempt to avoid blocking; using subprocesses is preferred to threads. Each distributor handles a single transport, and only one distributor in the system should handle that. For example, there should not be two distributors for the 'email' transport. """ def transports(): """Returns an iter of the transport supported.""" def distribute(transport, recipients, event): """This method is meant to actually distribute the event to the specified recipients, over the specified transport. If it is passed a transport it does not support, it should return silently and without error. The recipients is a list of (name, address) pairs with either (but not both) being allowed to be None. If name is provided but address isn't, then the distributor should defer to IAnnouncementAddressResolver implementations to determine what the address should be. If the name is None but the address is not, then the distributor should rely on the address being correct and use it-- if possible. The distributor may initiate as many transactions as are necessecary to deliver a message, but should use as few as possible; for example in the EmailDistributor, if all of the recipients are receiving a plain text form of the message, a single message with many BCC's should be used. The distributor is responsible for determining which of the IAnnouncementFormatters should get the privilege of actually turning an event into content. In cases where multiple formatters are capable of converting an event into a message for a given transport, a user preference would be a dandy idea. """ class IAnnouncementPreferenceProvider(Interface): """Represents a single 'box' in the Announcements preference panel. Any component can always implement IPreferencePanelProvider to get preferences from users, of course. However, considering there may be several components related to the Announcement system, and many may have different preferences for a user to set, that would clutter up the preference interfac quite a bit. The IAnnouncementPreferenceProvider allows several boxes to be chained in the same panel to group the preferenecs related to the Announcement System. Implementing announcement preference boxes should be essentially identical to implementing entire panels. """ def get_announcement_preference_boxes(req): """Accepts a request object, and returns an iterable of (name, label) pairs; one for each box that the implementation can generate. If a single item is returned, be sure to 'yield' it instead of returning it.""" def render_announcement_preference_box(req, box): """Accepts a request object, and the name (as from the previous method) of the box that should be rendered. Returns a tuple of (template, data) with the template being a filename in a directory provided by an ITemplateProvider which shall be rendered into a single
element, when combined with the data member. """ class IAnnouncementAddressResolver(Interface): """Handles mapping Trac usernames to addresses for distributors to use.""" def get_address_for_name(name, authenticated): """Accepts a session name, and returns an address. This address explicitly does not always have to mean an email address, nor does it have to be an address stored within the Trac system at all. Implementations of this interface are never 'detected' automatically, and must instead be specifically named for a particular distributor. This way, some may find email addresses (for EmailDistributor), and others may find AIM screen name. If no address for the specified name can be found, None should be returned. The next resolver will be attempted in the chain. """ class AnnouncementEvent(object): """AnnouncementEvent This packages together in a single place all data related to a particular event; notably the realm, category, and the target that represents the initiator of the event. In some (rare) cases, the target may be None; in cases where the message is all that matters and there's no possible data you could conceivably get beyond just the message. """ def __init__(self, realm, category, target, author=""): self.realm = realm self.category = category self.target = target self.author = author def get_basic_terms(self): return (self.realm, self.category) def get_session_terms(self, session_id): return tuple() class IAnnouncementSubscriptionResolver(Interface): """Supports new and old style of subscription resolution until new code is complete.""" def subscriptions(event): """Return all subscriptions as (dist, sid, auth, address, format) priority 1 is highest. adverb is 'always' or 'never'. """ class SubscriptionResolver(Component): """Collect, and resolve subscriptions.""" implements(IAnnouncementSubscriptionResolver) subscribers = ExtensionPoint(IAnnouncementSubscriber) def subscriptions(self, event): """Yields all subscriptions for a given event.""" subscriptions = [] for sp in self.subscribers: subscriptions.extend( [x for x in sp.matches(event) if x] ) """ This logic is meant to generate a list of subscriptions for each distribution method. The important thing is, that we pick the rule with the highest priority for each (sid, distribution) pair. If it is "never", then the user is dropped from the list, if it is "always", then the user is kept. Only users highest priority rule is used and all others are skipped. """ # sort by dist, sid, authenticated, priority subscriptions.sort(key=lambda i:(i[1],i[2],i[3],i[6])) resolved_subs = [] # collect highest priority for each (sid, dist) pair state = { 'last': None } for s in subscriptions: if (s[1], s[2], s[3]) == state['last']: continue if s[-1] == 'always': self.log.debug("Adding (%s [%s]) for 'always' on rule (%s) " "for (%s)"%(s[2], s[3], s[0], s[1])) resolved_subs.append(s[1:6]) else: self.log.debug("Ignoring (%s [%s]) for 'never' on rule (%s) " "for (%s)"%(s[2], s[3], s[0], s[1])) # if s[1] is None, then the subscription is for a raw email # address that has been set in some field and we shouldn't skip # the next raw email subscription. In other words, all raw email # subscriptions should be added. if s[2]: state['last'] = (s[1], s[2], s[3]) return resolved_subs _TRUE_VALUES = ('yes', 'true', 'enabled', 'on', 'aye', '1', 1, True) def istrue(value, otherwise=False): return True and (value in _TRUE_VALUES) or otherwise # Import i18n methods. Fallback modules maintain compatibility to Trac 0.11 # by keeping Babel optional here. try: from trac.util.translation import domain_functions add_domain, _, N_ , tag_= \ domain_functions('announcer', ('add_domain', '_', 'N_', 'tag_')) except ImportError: from genshi.builder import tag as tag_ from trac.util.translation import gettext _ = gettext N_ = lambda text: text def add_domain(a, b, c=None): pass class AnnouncementSystem(Component): """AnnouncementSystem represents the entry-point into the announcement system, and is also the central controller that handles passing notices around. An announcement begins when something-- an announcement provider-- constructs an AnnouncementEvent (or subclass) and calls the send method on the AnnouncementSystem. Every event is classified by two required fields-- realm and category. In general, the realm corresponds to the realm of a Resource within Trac; ticket, wiki, milestone, and such. This is not a requirement, however. Realms can be anything distinctive-- if you specify novel realms to solve a particular problem, you'll simply also have to specify subscribers and formatters who are able to deal with data in those realms. The other classifier is a category that is defined by the providers and has no particular meaning; for the providers that implement the I*ChangeListener interfaces, the categories will often correspond to the kinds of events they receive. For tickets, they would be 'created', 'changed' and 'deleted'. There is no requirement for an event to have more then realm and category to classify an event, but if more is provided in a subclass that the subscribers can use to pick through events, all power to you. """ implements(IEnvironmentSetupParticipant) subscribers = ExtensionPoint(IAnnouncementSubscriber) subscription_filters = ExtensionPoint(IAnnouncementSubscriptionFilter) subscription_resolvers = ExtensionPoint(IAnnouncementSubscriptionResolver) distributors = ExtensionPoint(IAnnouncementDistributor) resolver = ExtensionOption('announcer', 'subscription_resolvers', IAnnouncementSubscriptionResolver, 'SubscriptionResolver', """Comma-separated list of subscription resolver components in the order they will be called. """) def __init__(self): # Bind the 'announcer' catalog to the specified locale directory. locale_dir = resource_filename(__name__, 'locale') add_domain(self.env.path, locale_dir) # IEnvironmentSetupParticipant methods def environment_created(self): self._upgrade_db(self.env.get_db_cnx()) def environment_needs_upgrade(self, db): schema_ver = self.get_schema_version(db) if schema_ver == db_default.schema_version: return False if schema_ver > db_default.schema_version: raise TracError(_("""A newer plugin version has been installed before, but downgrading is unsupported.""")) self.log.info("TracAnnouncer db schema version is %d, should be %d" % (schema_ver, db_default.schema_version)) return True def upgrade_environment(self, db): self._upgrade_db(db) # Internal methods def get_schema_version(self, db=None): """Return the current schema version for this plugin.""" db = db and db or self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT value FROM system WHERE name='announcer_version' """) row = cursor.fetchone() if not (row and int(row[0]) > 5): # Care for pre-announcer-1.0 installations. dburi = self.config.get('trac', 'database') tables = self._get_tables(dburi, cursor) if 'subscription' in tables: # Version > 2 cursor.execute("SELECT * FROM subscription_attribute") columns = [col[0] for col in self._get_cursor_description(cursor)] if 'authenticated' in columns: self.env.log.debug( "TracAnnouncer needs to register schema version") return 5 if 'realm' in columns: self.env.log.debug( "TracAnnouncer needs to change a table") return 4 self.env.log.debug("TracAnnouncer needs to change tables") return 3 if 'subscriptions' in tables: cursor.execute("SELECT * FROM subscriptions") columns = [col[0] for col in self._get_cursor_description(cursor)] if not 'format' in columns: self.env.log.debug("TracAnnouncer needs to add new tables") return 2 self.env.log.debug("TracAnnouncer needs to add/change tables") return 1 # This is a new installation. return 0 # The expected outcome for any up-to-date installation. return row and int(row[0]) or 0 def _get_cursor_description(self, cursor): # Cursors don't look the same across Trac versions if trac_version < '0.12': return cursor.description else: return cursor.cursor.description def _get_tables(self, dburi, cursor): """Code from TracMigratePlugin by Jun Omae (see tracmigrate.admin).""" if dburi.startswith('sqlite:'): sql = """ SELECT name FROM sqlite_master WHERE type='table' AND NOT name='sqlite_sequence' """ elif dburi.startswith('postgres:'): sql = """ SELECT tablename FROM pg_tables WHERE schemaname = ANY (current_schemas(false)) """ elif dburi.startswith('mysql:'): sql = "SHOW TABLES" else: raise TracError('Unsupported database type "%s"' % dburi.split(':')[0]) cursor.execute(sql) return sorted([row[0] for row in cursor]) def _upgrade_db(self, db): """Each schema version should have its own upgrade module, named upgrades/dbN.py, where 'N' is the version number (int). """ db_mgr = DatabaseManager(self.env) schema_ver = self.get_schema_version(db) cursor = db.cursor() # Is this a new installation? if not schema_ver: # Perform a single-step install: Create plugin schema and # insert default data into the database. connector = db_mgr._get_connector()[0] for table in db_default.schema: for stmt in connector.to_sql(table): cursor.execute(stmt) for table, cols, vals in db_default.get_data(db): cursor.executemany("INSERT INTO %s (%s) VALUES (%s)" % (table, ','.join(cols), ','.join(['%s' for c in cols])), vals) else: # Perform incremental upgrades. for i in range(schema_ver + 1, db_default.schema_version + 1): name = 'db%i' % i try: upgrades = __import__('announcer.upgrades', globals(), locals(), [name]) script = getattr(upgrades, name) except AttributeError: raise TracError(_(""" No upgrade module for version %(num)i (%(version)s.py) """, num=i, version=name)) script.do_upgrade(self.env, i, cursor) cursor.execute(""" UPDATE system SET value=%s WHERE name='announcer_version' """, (db_default.schema_version,)) self.log.info("Upgraded TracAnnouncer db schema from version %d to %d" % (schema_ver, db_default.schema_version)) db.commit() # AnnouncementSystem core methods def send(self, evt): start = time.time() self._real_send(evt) stop = time.time() self.log.debug("AnnouncementSystem sent event in %s seconds." % (round(stop - start, 2))) def _real_send(self, evt): """Accepts a single AnnouncementEvent instance (or subclass), and returns nothing. There is no way (intentionally) to determine what the AnnouncementSystem did with a particular event besides looking through the debug logs. """ try: subscriptions = self.resolver.subscriptions(evt) for sf in self.subscription_filters: subscriptions = set( sf.filter_subscriptions(evt, subscriptions) ) self.log.debug( "AnnouncementSystem has found the following 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, subs_format \ in subscriptions: if transport not in packages: packages[transport] = set() packages[transport].add((sid,authenticated,address)) for distributor in self.distributors: for transport in distributor.transports(): if transport in packages: distributor.distribute(transport, packages[transport], evt) except: self.log.error("AnnouncementSystem failed.", exc_info=True) trac-announcer/trunk/announcer/producers/0000755000175500017550000000000011447713062020675 5ustar debacledebacletrac-announcer/trunk/announcer/db_default.py0000644000175500017550000000274012045565562021342 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # from trac.db import Table, Column, Index schema_version = 6 ## Database schema # """The 'subscriptions' db table has been dropped in favor of the new subscriber interface, that uses two other tables. TODO: We still need to create an upgrade script, that will port subscriptions from 'subscriptions' and 'session_attribute' db tables to 'subscription' and 'subscription_attribute'. """ schema = [ Table('subscription', key='id')[ Column('id', auto_increment=True), Column('time', type='int64'), Column('changetime', type='int64'), Column('class'), Column('sid'), Column('authenticated', type='int'), Column('distributor'), Column('format'), Column('priority', type='int'), Column('adverb') ], Table('subscription_attribute', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('authenticated', type='int'), Column('class'), Column('realm'), Column('target') ] ] ## Default database values # # (table, (column1, column2), ((row1col1, row1col2), (row2col1, row2col2))) def get_data(db): return (('system', ('name', 'value'), (('announcer_version', str(schema_version)),)),) trac-announcer/trunk/announcer/util/0000755000175500017550000000000012350245165017642 5ustar debacledebacletrac-announcer/trunk/announcer/util/mail.py0000644000175500017550000000350412350245165021140 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2009, Robert Corsaro # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # from base64 import b32encode, b32decode try: from email.header import Header except: from email.Header import Header from announcer.util import get_target_id MAXHEADERLEN = 76 def next_decorator(event, message, decorates): """ Helper method for IAnnouncerEmailDecorators. Call the next decorator or return. """ if decorates and len(decorates) > 0: next = decorates.pop() return next.decorate_message(event, message, decorates) def set_header(message, key, value, charset=None): if not charset: charset = message.get_charset() or 'ascii' # Don't encode pure ASCII headers. try: value = Header(value, 'ascii', MAXHEADERLEN-(len(key)+2)) except: value = Header(value, charset, MAXHEADERLEN-(len(key)+2)) if message.has_key(key): message.replace_header(key, value) else: message[key] = value return message def uid_encode(projurl, realm, target): """ Unique identifier used to track resources in relation to emails. Returns a base32 encode UID string. projurl included to avoid Message-ID collisions. Returns a base32 encode UID string. Set project_url in trac.ini for proper results. """ uid = ','.join((projurl, realm, get_target_id(target))) return b32encode(uid.encode('utf8')) def uid_decode(encoded_uid): """ Returns a tuple of projurl, realm, id and change_num. """ uid = b32decode(encoded_uid).decode('utf8') return uid.split(',') def msgid(uid, host='localhost'): """ Formatted id for email headers. ie. """ return "<%s@%s>"%(uid, host) trac-announcer/trunk/announcer/util/__init__.py0000644000175500017550000000103512046577066021764 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # # DEVEL: Add unit testing. def get_target_id(target): """Extract the resource ID from event targets.""" # Common Trac resource. if hasattr(target, 'id'): return str(target.id) # Wiki page special case. elif hasattr(target, 'name'): return target.name # Last resort: just stringify. return str(target) trac-announcer/trunk/announcer/util/settings.py0000644000175500017550000001253312350245165022060 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import pickle from announcer.api import istrue def encode(*args): return pickle.dumps(args) def decode(v): try: return pickle.loads(str(v)) except Exception, e: return (tuple(),None) class SubscriptionSetting(object): """Encapsulate user text subscription and filter settings. Subscription settings have default values, usually trac properties, and user session attribute settings. If the user setting is unset, then the default value will be returned. """ def __init__(self, env, name, default_value=None, default_dists=('email',)): self.default = { 'value': default_value, 'dists': default_dists } self.env = env self.name = name def set_user_setting(self, session, value=None, dists=('email',), save=True): """Sets session attribute.""" session[self._attr_name()] = encode(dists,value) if save: session.save() def get_user_setting(self, sid): """Returns tuple of (value, authenticated).""" db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT value, authenticated FROM session_attribute WHERE sid=%s AND name=%s """, (sid, self._attr_name())) row = cursor.fetchone() if row: pair = decode(row[0]) authenticated = istrue(row[1]) else: pair = (self.default['dists'], self.default['value']) authenticated = False # We use None here so that Genshi templates check their checkboxes # properly and without confusion. return pair + (authenticated,) def get_subscriptions(self, match): """Generates tuples of (distributor, sid, authenticated, email). `match` should is passed the string value of the setting and should return true or false depending on whether the subscription matches. Tuples are suitable for yielding from IAnnouncementSubscriber's subscriptions method. """ db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT sid, authenticated, value FROM session_attribute WHERE name=%s """, (self._attr_name(),)) for result in cursor.fetchall(): dists, val = decode(result[2]) for dist in dists: if match(dist, val): authenticated = istrue(result[1]) yield (dist, result[0], authenticated, None) def _attr_name(self): return "sub_%s"%(self.name) class BoolSubscriptionSetting(object): """Encapsulate boolean user subscription and filter settings. Subscription settings have default values, usually trac properties, and user session attribute settings. If the user setting is unset, then the default value will be returned. """ def __init__(self, env, name, default_value=None, default_dists=('email',)): self.default = { 'value': default_value, 'dists': default_dists } self.env = env self.name = name def set_user_setting(self, session, value=None, dists=('email',), save=True): """Sets session attribute to 1 or 0.""" if istrue(value): session[self._attr_name()] = encode(dists, '1') else: session[self._attr_name()] = encode(dists, '0') if save: session.save() def get_user_setting(self, sid): """Returns tuple of (value, authenticated). Value is always True or None. This will work with Genshi template checkbox logic. """ db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT value, authenticated FROM session_attribute WHERE sid=%s AND name=%s """, (sid, self._attr_name())) row = cursor.fetchone() if row: dists, v = decode(row[0]) value = istrue(v) authenticated = istrue(row[1]) else: dists = self.default['dists'] value = istrue(self.default['value']) authenticated = False # We use None here so that Genshi templates check their checkboxes # properly and without confusion. return (dists, value and True or None, authenticated) def get_subscriptions(self): """Generates tuples of (distributor, sid, authenticated, email). Tuples are suitable for yielding from IAnnouncementSubscriber's subscriptions method. """ db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT sid, authenticated, value FROM session_attribute WHERE name=%s """, (self._attr_name(),)) for result in cursor.fetchall(): dists, val = decode(result[2]) for dist in dists: if istrue(val): authenticated = istrue(result[1]) yield (dist, result[0], authenticated, None) def _attr_name(self): return "sub_%s"%(self.name) trac-announcer/trunk/announcer/util/mail_crypto.py0000644000175500017550000001036112350245165022537 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2010, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # import re from time import time from trac.core import * from trac.config import Option, BoolOption from trac.util.translation import _ __all__ = ['CryptoTxt',] class CryptoTxt: """Crypto operation provider for plaintext. We use GnuPG for now. Support for X.509 and other options might appear in the future. """ def __init__(self, gpg_binary, gpg_home): """Initialize the GnuPG instance.""" self.gpg_binary = gpg_binary self.gpg_home = gpg_home try: from gnupg import GPG except ImportError: raise TracError(_("Unable to load the python-gnupg module. " \ "Please check and correct your installation.")) try: self.gpg = GPG(gpgbinary=self.gpg_binary, gnupghome=self.gpg_home) # get list of available public keys once for later use self.pubkeys = self.gpg.list_keys() # same as gpg.list_keys(False) except ValueError: raise TracError(_("Missing the crypto binary. " \ "Please check and set full path " \ "with option 'gpg_binary'.")) def sign(self, content, private_key=None): private_key = self._get_private_key(private_key) cipher = self.gpg.sign(content, keyid=private_key, passphrase='') return str(cipher) def encrypt(self, content, pubkeys): # always_trust needed for making it work with just any pubkey cipher = self.gpg.encrypt(content, pubkeys, always_trust=True) return str(cipher) def sign_encrypt(self, content, pubkeys, private_key=None): private_key = self._get_private_key(private_key) # always_trust needed for making it work with just any pubkey cipher = self.gpg.encrypt(content, pubkeys, always_trust=True, sign=private_key, passphrase='') return str(cipher) def get_pubkey_ids(self, addr): """Find public key with UID matching address to encrypt to.""" pubkey_ids = [] if len(self.pubkeys) > 0 and self.pubkeys[-1].has_key('uids') and \ self.pubkeys[-1].has_key('fingerprint'): # compile pattern before use for better performance RCPT_RE = re.compile(addr) for k in self.pubkeys: for uid in k['uids']: match = RCPT_RE.search(uid) if match is not None: # check for key expiration if k['expires'] == '': pubkey_ids.append(k['fingerprint'][-16:]) elif (time()+60) < float(k['expires']): pubkey_ids.append(k['fingerprint'][-16:]) break return pubkey_ids def _get_private_key(self, privkey=None): """Find private (secret) key to sign with.""" # read private keys from keyring privkeys = self.gpg.list_keys(True) # True => private keys if len(privkeys) > 0 and privkeys[-1].has_key('fingerprint'): fingerprints = [] for k in privkeys: fingerprints.append(k['fingerprint']) else: # no private key in keyring return None if privkey: # check for existence of private key received as argument # DEVEL: check for expiration as well if len(privkey) > 7 and len(privkey) <= 40: for fp in fingerprints: if fp.endswith(privkey): # work with last 16 significant chars internally, # even if only 8 are required in trac.ini privkey = fp[-16:] break # no fingerprint matching key ID else: privkey = None else: # reset invalid key ID privkey = None else: # select (last) private key from keyring privkey = fingerprints[-1][-16:] return privkey trac-announcer/trunk/announcer/compat.py0000644000175500017550000000262212046577066020536 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2012, Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # """Various classes and functions to provide backwards-compatibility with previous versions of Python from 2.4 and Trac from 0.11 onwards. """ try: from trac.util.datefmt import to_utimestamp except ImportError: # Cheap fallback for Trac 0.11 compatibility. from trac.util.datefmt import to_timestamp def to_utimestamp(dt): return to_timestamp(dt) * 1000000L from trac.util.text import to_unicode try: # Method only available in Trac 0.11.3 or higher. from trac.util.text import exception_to_unicode except: def exception_to_unicode(e, traceback=False): """Convert an `Exception` to an `unicode` object. In addition to `to_unicode`, this representation of the exception also contains the class name and optionally the traceback. This replicates the Trac core method for backwards-compatibility. """ message = '%s: %s' % (e.__class__.__name__, to_unicode(e)) if traceback: from trac.util import get_last_traceback traceback_only = get_last_traceback().split('\n')[:-2] message = '\n%s\n%s' % (to_unicode('\n'.join(traceback_only)), message) return message trac-announcer/trunk/announcer/resolvers/0000755000175500017550000000000011447713051020711 5ustar debacledebacletrac-announcer/trunk/setup.cfg0000644000175500017550000000074412607775302016531 0ustar debacledebacle[egg_info] tag_build = dev [extract_messages] add_comments = TRANSLATOR: msgid_bugs_address = hoff.st@web.de output_file = announcer/locale/messages.pot keywords = _ ngettext:1,2 N_ tag_ width = 72 [init_catalog] input_file = announcer/locale/messages.pot output_dir = announcer/locale domain = announcer [compile_catalog] directory = announcer/locale domain = announcer [update_catalog] input_file = announcer/locale/messages.pot output_dir = announcer/locale domain = announcer trac-announcer/trunk/setup.py0000644000175500017550000000574312556766113016431 0ustar debacledebacle# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010-2012 Steffen Hoffmann # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. # from setuptools import find_packages, setup extra = {} try: from trac.util.dist import get_l10n_cmdclass cmdclass = get_l10n_cmdclass() if cmdclass: extra['cmdclass'] = cmdclass extractors = [ ('**.py', 'python', None), ('**/templates/**.html', 'genshi', None), ('**/templates/**.txt', 'genshi', { 'template_class': 'genshi.template:TextTemplate' }), ] extra['message_extractors'] = {'announcer': extractors} # i18n is implemented to be optional here. except ImportError: pass setup( name = 'TracAnnouncer', version = '1.0', author = 'Robert Corsaro', author_email = 'rcorsaro@gmail.com', description = 'Customizable notification system for Trac', license = """ Copyright (c) 2008, Stephen Hansen. Copyright (c) 2009, Robert Corsaro. All rights reserved. Released under the 3-clause BSD license. """, url = 'http://www.trac-hacks.org/wiki/AnnouncerPlugin', packages = find_packages(exclude=['*.tests*']), package_data = { 'announcer': [ 'htdocs/*.*', 'htdocs/css/*.*', 'locale/*/LC_MESSAGES/*.mo', 'locale/.placeholder', 'templates/*.html', 'templates/*.txt' ] }, install_requires = ['Trac'], extras_require={ 'Babel': 'Babel>= 0.9.5', 'acct_mgr': 'TracAccountManager', 'bitten': 'Bitten', 'fullblog': 'TracFullBlogPlugin', 'xmpp': 'xmpppy', }, entry_points = { 'trac.plugins': [ 'announcer.api = announcer.api', 'announcer.distributors.mail = announcer.distributors.mail', 'announcer.distributors.xmppd = announcer.distributors.xmppd[xmpp]', 'announcer.email_decorators = announcer.email_decorators', 'announcer.filters = announcer.filters', 'announcer.formatters = announcer.formatters', 'announcer.model = announcer.model', 'announcer.pref = announcer.pref', 'announcer.producers = announcer.producers', 'announcer.resolvers = announcer.resolvers', 'announcer.subscribers = announcer.subscribers', 'announcer.util.mail = announcer.util.mail', 'announcer.opt.acct_mgr.announce = announcer.opt.acct_mgr.announce[acct_mgr]', 'announcer.opt.bitten.announce = announcer.opt.bitten.announce[bitten]', 'announcer.opt.fullblog.announce = announcer.opt.fullblog.announce[fullblog]', 'announcer.opt.subscribers = announcer.opt.subscribers', ] }, test_suite = 'announcer.tests.test_suite', tests_require = [], **extra ) trac-announcer/trunk/AUTHORS0000644000175500017550000000053511445450744015755 0ustar debacledebacleFounder : ixokai Maintainer : doki_pen@doki-pen.org Contributers: * acamac * davidf@sjsoft.com * doki_pen@doki-pen.org * ebray * hasienda * ixokai * jdio * leorachael * martin_s * mixedpuppy * pipern * rea * rjollos * robrien * spcamp * thomas.moschny@gmx.de If you've been left off this list and you shouldn't have been, email me.