trac-announcer/ 0000755 0001755 0001755 00000000000 12627703146 013537 5 ustar debacle debacle trac-announcer/trunk/ 0000755 0001755 0001755 00000000000 12627703105 014675 5 ustar debacle debacle trac-announcer/trunk/changelog 0000644 0001755 0001755 00000006170 12051274703 016551 0 ustar debacle debacle Authors: 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/.gitignore 0000644 0001755 0001755 00000000140 12057772533 016670 0 ustar debacle debacle *.egg-info
*.patch
*.pyc
*.mo
.stgit-*
build/
dist/
patches-*/
.settings
.project
.pydevproject
trac-announcer/trunk/COPYING 0000644 0001755 0001755 00000003235 12037604531 015731 0 ustar debacle debacle Copyright (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/README 0000644 0001755 0001755 00000002410 12037604531 015550 0 ustar debacle debacle TracAnnouncer 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/ 0000755 0001755 0001755 00000000000 12607775473 016704 5 ustar debacle debacle trac-announcer/trunk/announcer/model.py 0000644 0001755 0001755 00000037647 12557162602 020363 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 12607775473 017506 5 ustar debacle debacle trac-announcer/trunk/announcer/opt/acct_mgr/ 0000755 0001755 0001755 00000000000 12350245165 021246 5 ustar debacle debacle trac-announcer/trunk/announcer/opt/acct_mgr/__init__.py 0000644 0001755 0001755 00000000000 11337147243 023347 0 ustar debacle debacle trac-announcer/trunk/announcer/opt/acct_mgr/announce.py 0000644 0001755 0001755 00000017420 12350245165 023432 0 ustar debacle debacle #-*- 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__.py 0000644 0001755 0001755 00000000000 11337147215 021567 0 ustar debacle debacle trac-announcer/trunk/announcer/opt/subscribers.py 0000644 0001755 0001755 00000057460 12607775473 022422 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 12350245165 021275 5 ustar debacle debacle trac-announcer/trunk/announcer/opt/fullblog/__init__.py 0000644 0001755 0001755 00000000000 11337147215 023375 0 ustar debacle debacle trac-announcer/trunk/announcer/opt/fullblog/announce.py 0000644 0001755 0001755 00000035261 12350245165 023464 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 12350245165 020754 5 ustar debacle debacle trac-announcer/trunk/announcer/opt/bitten/__init__.py 0000644 0001755 0001755 00000000000 11337147230 023051 0 ustar debacle debacle trac-announcer/trunk/announcer/opt/bitten/announce.py 0000644 0001755 0001755 00000016265 12350245165 023146 0 ustar debacle debacle #-*- 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/ 0000755 0001755 0001755 00000000000 12557162602 020634 5 ustar debacle debacle trac-announcer/trunk/announcer/opt/tests/__init__.py 0000644 0001755 0001755 00000001116 12350245165 022741 0 ustar debacle debacle # -*- 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.py 0000644 0001755 0001755 00000011364 12557162602 023541 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 11447713115 022202 5 ustar debacle debacle trac-announcer/trunk/announcer/templates/ 0000755 0001755 0001755 00000000000 12071115211 020647 5 ustar debacle debacle trac-announcer/trunk/announcer/templates/prefs_announcer_author_filter.html 0000644 0001755 0001755 00000000640 11434334743 027672 0 ustar debacle debacle
Opt-out of announcements about my own changes.
trac-announcer/trunk/announcer/templates/prefs_announcer_acct_mgr_subscription.html 0000644 0001755 0001755 00000001417 11434334743 031411 0 ustar debacle debacle
Subscribe to user account announcements.
Send me announcements when new users are created.
Send me announcements when users accounts are changed.
Send me announcements when users accounts are deleted.
trac-announcer/trunk/announcer/templates/bitten_plaintext.txt 0000644 0001755 0001755 00000001341 11337147230 024776 0 ustar debacle debacle ${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.html 0000644 0001755 0001755 00000001010 11434334743 027450 0 ustar debacle debacle
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:
Email address:
trac-announcer/trunk/announcer/templates/ticket_email_plaintext.txt 0000644 0001755 0001755 00000002524 12044074724 026152 0 ustar debacle debacle #${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.html 0000644 0001755 0001755 00000001120 11434334743 030177 0 ustar debacle debacle
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.
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.html 0000644 0001755 0001755 00000001272 11434334743 026114 0 ustar debacle debacle
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.
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.txt 0000644 0001755 0001755 00000000146 11337147243 027641 0 ustar debacle debacle ${account.action} for user ${account.username}
--
${project.name} <${project.url}>
${project.descr}
trac-announcer/trunk/announcer/templates/prefs_announcer_distributor.html 0000644 0001755 0001755 00000000765 11450260670 027400 0 ustar debacle debacle
I prefer to recieve announcements via
trac-announcer/trunk/announcer/templates/prefs_announcer_manage_subscriptions.html 0000644 0001755 0001755 00000007455 11451362245 031252 0 ustar debacle debacle
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.
${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.txt 0000644 0001755 0001755 00000000334 11337147243 026661 0 ustar debacle debacle Please 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.txt 0000644 0001755 0001755 00000001432 11337147215 025323 0 ustar debacle debacle #${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.html 0000644 0001755 0001755 00000001676 11434334743 026301 0 ustar debacle debacle
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.html 0000644 0001755 0001755 00000001151 11450260670 026000 0 ustar debacle debacle
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.html 0000644 0001755 0001755 00000000573 11450260670 027355 0 ustar debacle debacle
Specify your XMPP(jabber) address where you would like jabber announcements delivered.
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.html 0000644 0001755 0001755 00000000615 11434334743 030201 0 ustar debacle debacle
Opt-out of all announcements.
Never notify me of any changes.
trac-announcer/trunk/announcer/templates/wiki_email_plaintext.txt 0000644 0001755 0001755 00000001277 11444016466 025640 0 ustar debacle debacle {% 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.html 0000644 0001755 0001755 00000001217 11434334743 031054 0 ustar debacle debacle
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.
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.txt 0000644 0001755 0001755 00000000236 12071115211 027014 0 ustar debacle debacle Please 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.html 0000644 0001755 0001755 00000000717 11450706240 030013 0 ustar debacle debacle
trac-announcer/trunk/announcer/templates/acct_mgr_reset_password_plaintext.txt 0000644 0001755 0001755 00000000326 11337147243 030422 0 ustar debacle debacle Your 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.py 0000644 0001755 0001755 00000012351 12037604531 021245 0 ustar debacle debacle # -*- 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__.py 0000644 0001755 0001755 00000000565 12556767271 021023 0 ustar debacle debacle # -*- 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.py 0000644 0001755 0001755 00000017460 12046602033 020174 0 ustar debacle debacle # -*- 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.py 0000644 0001755 0001755 00000005744 12350245165 020721 0 ustar debacle debacle # -*- 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.py 0000644 0001755 0001755 00000024614 12047736150 021576 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 11447713040 021211 5 ustar debacle debacle trac-announcer/trunk/announcer/formatters.py 0000644 0001755 0001755 00000026350 12051425100 021416 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 11447713072 020340 5 ustar debacle debacle trac-announcer/trunk/announcer/resolvers.py 0000644 0001755 0001755 00000007024 12045566432 021272 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 12562171674 021432 5 ustar debacle debacle trac-announcer/trunk/announcer/distributors/mail.py 0000644 0001755 0001755 00000061247 12562171674 022740 0 ustar debacle debacle # -*- 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__.py 0000644 0001755 0001755 00000000000 11335531124 023514 0 ustar debacle debacle trac-announcer/trunk/announcer/distributors/xmppd.py 0000644 0001755 0001755 00000023630 12350245165 023130 0 ustar debacle debacle # -*- 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/ 0000755 0001755 0001755 00000000000 12350245165 020477 5 ustar debacle debacle trac-announcer/trunk/announcer/upgrades/db6.py 0000644 0001755 0001755 00000003003 12350245165 021520 0 ustar debacle debacle import 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.py 0000644 0001755 0001755 00000002723 12350245165 021524 0 ustar debacle debacle from 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__.py 0000644 0001755 0001755 00000000000 12045565406 022602 0 ustar debacle debacle trac-announcer/trunk/announcer/upgrades/db5.py 0000644 0001755 0001755 00000002431 12045565613 021527 0 ustar debacle debacle from 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.py 0000644 0001755 0001755 00000004530 12045565562 021533 0 ustar debacle debacle from 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.py 0000644 0001755 0001755 00000001616 12045565525 021533 0 ustar debacle debacle from 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/ 0000755 0001755 0001755 00000000000 12004067606 020122 5 ustar debacle debacle trac-announcer/trunk/announcer/locale/.placeholder 0000644 0001755 0001755 00000000244 11660505707 022413 0 ustar debacle debacle # 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/ 0000755 0001755 0001755 00000000000 11471761426 021133 5 ustar debacle debacle trac-announcer/trunk/announcer/locale/zh_CN/LC_MESSAGES/ 0000755 0001755 0001755 00000000000 11471761426 022720 5 ustar debacle debacle trac-announcer/trunk/announcer/locale/zh_CN/LC_MESSAGES/announcer.po 0000644 0001755 0001755 00000056727 11471761426 025271 0 ustar debacle debacle # 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: