trac-announcer-0.12.1+r10986/0000755000175000017500000000000011702277637013527 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/0000755000175000017500000000000011702277637014672 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/.gitignore0000644000175000017500000000007711443757362016666 0ustar wmbwmb*.egg-info *.patch *.pyc *.mo .stgit-* build/ dist/ patches-*/ trac-announcer-0.12.1+r10986/trunk/announcer/0000755000175000017500000000000011702277637016662 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/__init__.py0000644000175000017500000000341211443762466020774 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- __version__ = __import__('pkg_resources').get_distribution('TracAnnouncer').version trac-announcer-0.12.1+r10986/trunk/announcer/locale/0000755000175000017500000000000011702277637020121 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/locale/messages.pot0000644000175000017500000005116211471761426022456 0ustar wmbwmb# Translations template for TracAnnouncer. # Copyright (C) 2010 ORGANIZATION # This file is distributed under the same license as the TracAnnouncer # project. # FIRST AUTHOR , 2010. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "Report-Msgid-Bugs-To: hoff.st@web.de\n" "POT-Creation-Date: 2010-11-20 15:54+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 1.0dev-r482\n" #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "" #: announcer/pref.py:129 msgid "Subscriptions" msgstr "" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "" #: announcer/subscribers.py:98 msgid "notify me when any ticket changes" msgstr "" #: announcer/subscribers.py:159 msgid "notify me when a ticket that I own is created or modified" msgstr "" #: announcer/subscribers.py:221 msgid "" "notify me when a ticket that belongs to a component that I own is " "created or modified" msgstr "" #: announcer/subscribers.py:274 msgid "notify me when I update a ticket" msgstr "" #: announcer/subscribers.py:327 msgid "notify me when a ticket that I reported is modified" msgstr "" #: announcer/subscribers.py:385 msgid "notify me when I'm listed in the CC field of a ticket that is modified" msgstr "" #: announcer/subscribers.py:424 msgid "" "notify me when a ticket associated with a component I'm watching is " "modified" msgstr "" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "" #: announcer/subscribers.py:526 #, python-format msgid "notify me when I'm listed in any of the (%s) fields" msgstr "" #: announcer/subscribers.py:583 msgid "notify me on ticket changes in one of my subscribed groups" msgstr "" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "" #: announcer/subscribers.py:641 msgid "notify me when one of my watched users changes something" msgstr "" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "" #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "" #: announcer/subscribers.py:847 msgid "notify me when one of my watched wiki or tickets is updated" msgstr "" #: announcer/subscribers.py:896 msgid "" "notify me when a wiki that matches my wiki watch pattern is created, " "or updated" msgstr "" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "" #: announcer/distributors/mail.py:411 #, python-format msgid "Invalid email encoding setting: %s" msgstr "" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "" #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "" #: announcer/distributors/mail.py:519 msgid "undisclosed-recipients: ;" msgstr "" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "" #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "" #: announcer/opt/fullblog/announce.py:95 msgid "notify me when any blog is modified, changed, deleted or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:145 msgid "notify me when any blog that I posted is modified or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "" #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:238 msgid "You are now watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "" #: announcer/templates/acct_mgr_user_change_plaintext.txt:1 msgid "for user" msgstr "" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "build of" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "" "]\n" "---------------------------------------------------------------------" "\n" "\n" " Changeset:" msgstr "" #: announcer/templates/bitten_plaintext.txt:4 msgid "" ">\n" " Committed by:" msgstr "" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "" #: announcer/templates/bitten_plaintext.txt:9 #, python-format msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "" #: announcer/templates/bitten_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "--\n" "Build URL: <" msgstr "" #: announcer/templates/fullblog_plaintext.txt:1 #, python-format msgid "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Added post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "\" by" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "at" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Changed post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:8 #, python-format msgid "" "{% end %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:15 #, python-format msgid "" "{% if comment %}\\\n" "Comment:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Deleted post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:25 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 #, python-format msgid "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Comment added to post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:37 #, python-format msgid "{% end %}" msgstr "" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to " "you; \n" " the creation of a ticket, the change of a Wiki page, and so on." " Under\n" " the Announcement system, you will only receive notifications to" " those\n" " topics that you subscribe to." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 msgid "Send me announcements when users accounts are changed." msgstr "" #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "" #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "" #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a " "plaintext format. You\n" " may override this for each realm that may generate announcements." msgstr "" #: announcer/templates/prefs_announcer_email.html:9 #: announcer/templates/prefs_announcer_xmpp.html:7 msgid "announcements:" msgstr "" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different " "address then the main one provided\n" " in Trac, you may specify the address here:" msgstr "" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following " "components have been defined by the Trac administrators. If you " "subscribe to any of these components, you will receive an " "notification anytime a ticket related to that component is changed or" " created." msgstr "" #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. " "They are general topics that may be added onto the CC list of tickets" " (by prepending their name with @). Case does matter." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "" #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:23 msgid "rules" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "down" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "up" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 msgid "Add" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as" " the default rules. Any rules defined by you will take higher " "priority then these rules. This can be confusing if you don't " "understand how the system works. Only the first matching rule is " "applied when system events occur. For example, if you have a rule " "like \"always notify me of any ticket changes\" in your custom rules," " and there is a default rule \"never notify me when I update a " "ticket\", then the always rule will take precedent and you will still" " recieve announcements on ticket changes, even when you are the " "updater. In the preceding case, you would need to add your own " "\"never notify me..\" rule above the \"always notify me..\" to get " "the proper behavior." msgstr "" #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows " "you to use filters to specify which events you are interested in " "hearing about." msgstr "" #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" #: announcer/templates/prefs_announcer_ticket_all.html:8 msgid "Notify me when any ticket changes." msgstr "" #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "" #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "" #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:5 msgid "" "A comma separated list of users you would like to watch. A watched " "user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki " "pages, you may list here\n" " pages that are of interest to you. Each page should be on a " "separate line." msgstr "" #: announcer/templates/prefs_announcer_wiki.html:12 msgid "" "You may use wild cards, so that if you want to hear about any page " "that starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "" #: announcer/templates/prefs_announcer_wiki.html:17 msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by" " itself." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber " "announcements delivered." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:6 msgid "XMPP address:" msgstr "" #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "" #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "" #: announcer/templates/ticket_email_mimic.html:132 msgid "" "changed \n" " from" msgstr "" #: announcer/templates/ticket_email_mimic.html:133 msgid "to" msgstr "" #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "added" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:1 #, python-format msgid "" "---------------------------------------------------------------------" "\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:5 #, python-format msgid "" "{% end %}\\\n" "{% otherwise %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:7 #, python-format msgid "" ": (None)\n" "{% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% if category == 'created' %}\\\n" "---------------------------------------------------------------------" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:12 #, python-format msgid "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------" "\n" "Changes (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:16 #, python-format msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 msgid "from '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 #, python-format msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:21 #, python-format msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:28 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Attachment:\n" " * File '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "' added{% if attachment.description %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------" "\n" "Comment{% if not has_changes %} (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:38 #, python-format msgid "){% end %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:39 #, python-format msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: , 2010. msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "Report-Msgid-Bugs-To: hoff.st@web.de\n" "POT-Creation-Date: 2010-11-20 15:54+0100\n" "PO-Revision-Date: 2010-07-23 11:39+0200\n" "Last-Translator: Steffen Hoffmann \n" "Language-Team: German de_DE \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 1.0dev-r482\n" #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "Benachrichtigungen" #: announcer/pref.py:129 msgid "Subscriptions" msgstr "Abonnements" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "E-mail-Adresse für Benachrichtigungen" #: announcer/subscribers.py:98 msgid "notify me when any ticket changes" msgstr "Benachrichtigung an mich bei Ticketänderung" #: announcer/subscribers.py:159 msgid "notify me when a ticket that I own is created or modified" msgstr "" "Benachrichtigung an mich bei Erstellung oder Änderung eines Tickets, für " "das ich verantwortlich bin" #: announcer/subscribers.py:221 msgid "" "notify me when a ticket that belongs to a component that I own is created" " or modified" msgstr "" "Benachrichtigung an mich bei Erstellung oder Änderung eines Tickets, das " "zu einer Komponente gehört, für die ich verantwortlich bin" #: announcer/subscribers.py:274 msgid "notify me when I update a ticket" msgstr "Benachrichtigung an mich, wenn ich selbst ein Ticket aktualisiere" #: announcer/subscribers.py:327 msgid "notify me when a ticket that I reported is modified" msgstr "Benachrichtigung an mich bei Änderung eines Tickets, das ich erstellt habe" #: announcer/subscribers.py:385 msgid "notify me when I'm listed in the CC field of a ticket that is modified" msgstr "" "Benachrichtigung an mich bei Änderung eines Tickets, wenn ich in dessen " "Kopie-Feld eingetragen bin" #: announcer/subscribers.py:424 msgid "" "notify me when a ticket associated with a component I'm watching is " "modified" msgstr "" "Benachrichtigung an mich bei Änderung eines Tickets, das einer von mir " "beobachteten Komponente zugeordnet ist" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "Ticket-Abonnement basierend auf Komponenten" #: announcer/subscribers.py:526 #, python-format msgid "notify me when I'm listed in any of the (%s) fields" msgstr "" "Benachrichtigung an mich, wenn ich in einem der (%s) Felder eingetragen " "bin" #: announcer/subscribers.py:583 msgid "notify me on ticket changes in one of my subscribed groups" msgstr "" "Benachrichtigung an mich bei Ticketänderungen in einem meiner Gruppen-" "Abonnements" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "Gruppen-Abonnements" #: announcer/subscribers.py:641 msgid "notify me when one of my watched users changes something" msgstr "" "Benachrichtigung an mich bei Änderungen durch einen von mir beobachteten " "Nutzer" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "Beobachtung von Nutzern" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "" "Sie erhalten zu diesem Dokument keine weiteren " "Änderungsbenachrichtigungen." #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "Sie erhalten nun Änderungsbenachrichtigungen zu diesem Dokument." #: announcer/subscribers.py:847 msgid "notify me when one of my watched wiki or tickets is updated" msgstr "" "Benachrichtigung an mich bei Aktualisierung einer von mir beobachteten " "Wikiseite oder eines Tickets" #: announcer/subscribers.py:896 msgid "" "notify me when a wiki that matches my wiki watch pattern is created, or " "updated" msgstr "" "Benachrichtigung an mich bei Erstellung oder Änderung einer Wikiseite, " "die zu meinem Beobachtungsraster passt" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "Allgemeine Wiki-Abonnements" #: announcer/distributors/mail.py:411 #, python-format msgid "Invalid email encoding setting: %s" msgstr "Ungültige Einstellung der E-Mail-Codierung: %s" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "Erfolgreiche kryptographische Behandlung bei der E-Mail-Verteilung." #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "" "Das Ticket enthält Nicht-ASCII-Zeichen. Bitte ändern Sie die " "Codierungseinstellung" #: announcer/distributors/mail.py:519 msgid "undisclosed-recipients: ;" msgstr "Verborgene_Empfaenger: ;" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "TLS aktiviert, wird vom Server aber nicht unterstützt" #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "Abonnement für Nutzerkontenverwaltung («Account Manager»)" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "Erfolgreich" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "Fehlgeschlagen" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "Bitten-Abonnement" #: announcer/opt/fullblog/announce.py:95 msgid "notify me when any blog is modified, changed, deleted or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:145 msgid "notify me when any blog that I posted is modified or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "" #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:238 msgid "You are now watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" "Ihr Kennwort für Trac wurde zurückgesetzt.\n" "\n" "Hier ist Ihre Nutzerkonteninformation:\n" "\n" "Anmelde-URL: <" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" ">\n" "Benutzername:" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "Kennwort:" #: announcer/templates/acct_mgr_user_change_plaintext.txt:1 msgid "for user" msgstr "für Nutzer" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" "Bitte gehen Sie zu der folgenden URL, um Ihre E-mail-Adresse zu " "bestätigen.\n" "\n" "Bestätigungs-URL: <" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "Bestätigungsschlüssel:" #: announcer/templates/bitten_plaintext.txt:1 msgid "build of" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "" "]\n" "---------------------------------------------------------------------\n" "\n" " Changeset:" msgstr "" #: announcer/templates/bitten_plaintext.txt:4 msgid "" ">\n" " Committed by:" msgstr "" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "" #: announcer/templates/bitten_plaintext.txt:9 #, python-format msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "Fehler:" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "Protokoll:" #: announcer/templates/bitten_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "--\n" "Build URL: <" msgstr "" #: announcer/templates/fullblog_plaintext.txt:1 #, python-format msgid "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Added post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "\" by" msgstr "\" von" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "at" msgstr "in" #: announcer/templates/fullblog_plaintext.txt:4 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Changed post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:8 #, python-format msgid "" "{% end %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:15 #, python-format msgid "" "{% if comment %}\\\n" "Comment:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Deleted post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:25 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:29 #, python-format msgid "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Comment added to post \"" msgstr "" #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "" #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "Inhalt:" #: announcer/templates/fullblog_plaintext.txt:37 #, python-format msgid "{% end %}" msgstr "" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to you; \n" " the creation of a ticket, the change of a Wiki page, and so on. " "Under\n" " the Announcement system, you will only receive notifications to " "those\n" " topics that you subscribe to." msgstr "" "Benachrichtigungen dienen in Trac dazu, Ihnen Ereignisse mitzuteilen: das" " Erstellen eines Tickets, die Änderung einer Wiki-Seite und anderes. " "Durch das Benachrichtigungssystem erhalten Sie nur Nachrichten zu den " "Bereichen, die Sie anfordern." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "Abonnieren Sie Nutzer-Konten-bezogene Benachrichtigungen." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "Sende mir eine Benachrichtigung, wenn neue Nutzer erstellt werden." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 msgid "Send me announcements when users accounts are changed." msgstr "Sende mir eine Benachrichtigung, wenn Nutzer-Konten geändert werden." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "Sende mir eine Benachrichtigung, wenn Nutzer-Konten gelöscht werden." #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "Benachrichtigungen über eigene Änderungen werden ablehnt." #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "Benachrichte mich niemals, wenn ich selbst Änderungen vornehme." #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a plaintext " "format. You\n" " may override this for each realm that may generate announcements." msgstr "" "Das Benachrichtigungssystem wird Ihnen alle Nachrichten normalerweise als" " reiner Text formatiert zusenden. Sie können für jeden Bereich, der " "Benachrichtigungen erzeugen kann, eine abweichende Einstellung vornehmen." #: announcer/templates/prefs_announcer_email.html:9 #: announcer/templates/prefs_announcer_xmpp.html:7 msgid "announcements:" msgstr "Benachrichtigungen:" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different " "address then the main one provided\n" " in Trac, you may specify the address here:" msgstr "" "Falls Sie Benachrichtigungen an eine andere als die in Trac gespeicherte " "Adresse versenden lassen möchten,\n" " dann können Sie diese Adresse hier angeben:" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "E-mail-Adresse:" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following components " "have been defined by the Trac administrators. If you subscribe to any of" " these components, you will receive an notification anytime a ticket " "related to that component is changed or created." msgstr "" "Komponenten stellen eine Möglichkeit zur Klassifizierung von Trac-Tickets" " dar. Die folgenden Komponenten sind von Trac-Administratoren vorgegeben " "worden. Falls Sie eine davon abonnieren, werden Sie benachrichtigt, wenn " "ein Ticket zu dieser Komponente erstellt oder geändert wird. " #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. They " "are general topics that may be added onto the CC list of tickets (by " "prepending their name with @). Case does matter." msgstr "" "Die folgenden Gruppen sind von Trac-Administratoren vorgegeben worden. " "Dies sind übergeordnete Themen, die in Kopie-Listen (Ticket-Cc) eingefügt" " werden können (durch Voranstellen von @ vor deren Namen). " "Groß-/Kleinschreibung ist zu beachten." #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "" "Benachrichtige mich über alle Änderungen an Tickets, die zu Komponenten " "gehören, für die ich verantwortlich bin." #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "" "Benachrichtige mich über Änderungen an Tickets, für die ich " "verantwortlich bin." #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "" "Benachrichtige mich über alle Änderungen an Tickets, die ich erstellt " "habe." #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "Benachrichtige mich, wenn ich selbst ein Ticket aktualisiere." #: announcer/templates/prefs_announcer_manage_subscriptions.html:23 msgid "rules" msgstr "Regeln" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "Benutzereigene Regeln:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "Format:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "Speichern" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "Löschen" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "down" msgstr "tiefer" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "up" msgstr "höher" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 msgid "Add" msgstr "Hinzufügen" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "Standardregeln:" #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as the" " default rules. Any rules defined by you will take higher priority then " "these rules. This can be confusing if you don't understand how the " "system works. Only the first matching rule is applied when system events" " occur. For example, if you have a rule like \"always notify me of any " "ticket changes\" in your custom rules, and there is a default rule " "\"never notify me when I update a ticket\", then the always rule will " "take precedent and you will still recieve announcements on ticket " "changes, even when you are the updater. In the preceding case, you would" " need to add your own \"never notify me..\" rule above the \"always " "notify me..\" to get the proper behavior." msgstr "" "Die folgenden Regeln sind von System-Administratoren als Standardregeln " "eingerichet worden. Jede Ihrer eigenen Regeln wird eine höhere " "Wichtigkeit als diese Regeln erhalten. Bei einem Ereignis wird nur die " "erste zutreffende Regel angewendet. Wenn Sie beispielsweise eine Regel " "wie \"immer Benachrichtigung an mich bei allen Ticketänderungen\" in " "Ihren benutzeigenen Regeln haben, und wenn es eine Standardregel " "\"niemals Benachrichtigung an mich, wenn ich selbst ein Ticket " "aktualisiere\" gibt, dann wird die immer-Regel Vorrang erhalten, und Sie " "werden auch dann Benachrichtigungen über Ticketänderungen erhalten, wenn " "Sie diese selbst ausgeführt haben. Folglich müssten Sie Ihre eigene " "\"niemals Benachrichtigung an mich ...\"-Regel über dem \"immer " "Benachrichtigung an mich ...\" hinzufügen, um das richige Verhalten zu " "erreichen." #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows you " "to use filters to specify which events you are interested in hearing " "about." msgstr "" "Das regelgestützte Abonnement für erfahrene Nutzer erlaubt Ihnen, Filter " "zur Beschreibung von Ereignissen zu nutzen, von denen Sie gern " "benachrichtigt werden möchten." #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" "Jede Regel hat die folgende Form: [1:[2:realm], [3:category]: [4:query " "rule]]" #: announcer/templates/prefs_announcer_ticket_all.html:8 msgid "Notify me when any ticket changes." msgstr "Benachrichtige mich bei allen Ticketänderungen." #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "Alle Benachrichtigungen werden ablehnt." #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "Benachrichte mich niemals über Änderungen." #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:5 msgid "" "A comma separated list of users you would like to watch. A watched user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "" "Das ist eine Liste mit durch Kommata getrennten Nutzernamen, die Sie " "beobachten möchten. Ein beobachteter Nutzer erzeugt immer dann eine " "Benachrichtigung, wenn er/sie eine Wiki-Seite oder ein Ticket erstellt " "oder ändert." #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "Beobachte Nutzer:" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki " "pages, you may list here\n" " pages that are of interest to you. Each page should be on a separate " "line." msgstr "" "Ergänzend zu anderen Methoden, die Sie über Änderungen von Wiki-Seiten " "benachrichtigen, können Sie hier Seiten angeben, die Sie interessieren. " "Jede Seite gehört dabei auf eine eigene Zeile." #: announcer/templates/prefs_announcer_wiki.html:12 msgid "" "You may use wild cards, so that if you want to hear about any page that " "starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "" "Sie können Platzhalter verwenden. Wenn Sie über alle Seiten " "benachrichtigt werden möchten, deren Name mit 'Trac' beginnt, dann geben " "Sie auf einer Zeile ein: [1:Trac*]" #: announcer/templates/prefs_announcer_wiki.html:17 msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by " "itself." msgstr "" "Um Benachrichtigungen über alle Wiki-Seiten-Änderungen zu erhalten, geben" " Sie bitte nur [1:*] ein." #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber " "announcements delivered." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:6 msgid "XMPP address:" msgstr "XMPP-Adresse:" #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "" #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "" #: announcer/templates/ticket_email_mimic.html:132 msgid "" "changed \n" " from" msgstr "" #: announcer/templates/ticket_email_mimic.html:133 msgid "to" msgstr "" #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "" #: announcer/templates/ticket_email_mimic.html:147 msgid "added" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:1 #, python-format msgid "" "---------------------------------------------------------------------\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:5 #, python-format msgid "" "{% end %}\\\n" "{% otherwise %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:7 #, python-format msgid "" ": (None)\n" "{% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% if category == 'created' %}\\\n" "---------------------------------------------------------------------" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:12 #, python-format msgid "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------\n" "Changes (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:16 #, python-format msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 msgid "from '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 #, python-format msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:21 #, python-format msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:28 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Attachment:\n" " * File '" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "' added{% if attachment.description %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------\n" "Comment{% if not has_changes %} (by" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:38 #, python-format msgid "){% end %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:39 #, python-format msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: , 2010. # msgid "" msgstr "" "Project-Id-Version: TracAnnouncer 0.12.1\n" "Report-Msgid-Bugs-To: hoff.st@web.de\n" "POT-Creation-Date: 2010-11-09 22:45+0100\n" "PO-Revision-Date: 2010-06-23 22:31+0200\n" "Last-Translator: Dmitri Bogomolov <4glitch@gmail.com>\n" "Language-Team: ru \n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 1.0dev-r482\n" #: announcer/pref.py:74 announcer/templates/prefs_announcer.html:11 #: announcer/templates/prefs_announcer_manage_subscriptions.html:11 msgid "Announcements" msgstr "Извещения" #: announcer/pref.py:129 #, fuzzy msgid "Subscriptions" msgstr "Подписки на блог" #: announcer/resolvers.py:92 msgid "Announcement Email Address" msgstr "Почтовый адрес для извещений" #: announcer/subscribers.py:98 #, fuzzy msgid "notify me when any ticket changes" msgstr "Никогда не извещать о моих изменениях." #: announcer/subscribers.py:159 msgid "notify me when a ticket that I own is created or modified" msgstr "" #: announcer/subscribers.py:221 #, fuzzy msgid "" "notify me when a ticket that belongs to a component that I own is created" " or modified" msgstr "Уведомлять меня об изменениях билетов из компонентов, которыми я владею." #: announcer/subscribers.py:274 #, fuzzy msgid "notify me when I update a ticket" msgstr "Уведомлять меня о моих изменениях в билетах." #: announcer/subscribers.py:327 #, fuzzy msgid "notify me when a ticket that I reported is modified" msgstr "Уведомлять меня об изменениях в билетах, которые я открыл." #: announcer/subscribers.py:385 msgid "notify me when I'm listed in the CC field of a ticket that is modified" msgstr "" #: announcer/subscribers.py:424 msgid "" "notify me when a ticket associated with a component I'm watching is " "modified" msgstr "" #: announcer/subscribers.py:433 msgid "Ticket Component Subscriptions" msgstr "Подписки по компонентам" #: announcer/subscribers.py:526 #, python-format msgid "notify me when I'm listed in any of the (%s) fields" msgstr "" #: announcer/subscribers.py:583 msgid "notify me on ticket changes in one of my subscribed groups" msgstr "" #: announcer/subscribers.py:592 msgid "Group Subscriptions" msgstr "Групповые подписки" #: announcer/subscribers.py:641 msgid "notify me when one of my watched users changes something" msgstr "" #: announcer/subscribers.py:649 msgid "Watch Users" msgstr "Наблюдение за пользователями" #: announcer/subscribers.py:721 msgid "You are no longer receiving change notifications about this resource." msgstr "Вы больше не получаете уведомления по изменениям этомго ресурса." #: announcer/subscribers.py:725 msgid "You are now receiving change notifications about this resource." msgstr "Вы теперь получаете уведомления по изменениям этого ресурса." #: announcer/subscribers.py:847 msgid "notify me when one of my watched wiki or tickets is updated" msgstr "" #: announcer/subscribers.py:896 msgid "" "notify me when a wiki that matches my wiki watch pattern is created, or " "updated" msgstr "" #: announcer/subscribers.py:904 msgid "General Wiki Announcements" msgstr "Общие извещения Wiki" #: announcer/distributors/mail.py:411 #, python-format msgid "Invalid email encoding setting: %s" msgstr "Неправильный выбор почтовой кодировки: %s" #: announcer/distributors/mail.py:444 msgid "EmailDistributor crypto operaton successful." msgstr "" #: announcer/distributors/mail.py:467 msgid "Ticket contains non-ASCII chars. Please change encoding setting" msgstr "Билет содержит не-ASCII символы. Пожалуйста измените настройку кодировки" #: announcer/distributors/mail.py:519 msgid "undisclosed-recipients: ;" msgstr "скрытые адресаты: ;" #: announcer/distributors/mail.py:597 msgid "TLS enabled but server does not support TLS" msgstr "TLS включен, но сервер его не поддерживает" #: announcer/opt/acct_mgr/announce.py:120 msgid "Account Manager Subscription" msgstr "Подписки Account Manager" #: announcer/opt/bitten/announce.py:65 msgid "Successful" msgstr "Успешно" #: announcer/opt/bitten/announce.py:66 msgid "Failed" msgstr "Неудачно" #: announcer/opt/bitten/announce.py:134 msgid "Bitten Subscription" msgstr "Подписки Bitten" #: announcer/opt/fullblog/announce.py:95 msgid "notify me when any blog is modified, changed, deleted or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:145 msgid "notify me when any blog that I posted is modified or commented on." msgstr "" #: announcer/opt/fullblog/announce.py:204 msgid "Unwatch This" msgstr "Не наблюдать" #: announcer/opt/fullblog/announce.py:207 msgid "Watch This" msgstr "Наблюдать" #: announcer/opt/fullblog/announce.py:232 msgid "You are no longer watching this blog post." msgstr "" #: announcer/opt/fullblog/announce.py:238 #, fuzzy msgid "You are now watching this blog post." msgstr "Уведомлять о любых изменениях моих заметок в блоге." #: announcer/opt/fullblog/announce.py:275 msgid "Followed Bloggers" msgstr "" #: announcer/opt/fullblog/announce.py:304 msgid "Blog: ${blog.name} ${action}" msgstr "Блог: ${blog.name} ${action}" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:1 msgid "" "Your Trac password has been reset.\n" "\n" "Here is your account information:\n" "\n" "Login URL: <" msgstr "" "Ваш пароль trac был сброшен.\n" "\n" "Здесь информация по вашей учетной записи:\n" "\n" "URL входа: " #: announcer/templates/acct_mgr_reset_password_plaintext.txt:5 #: announcer/templates/acct_mgr_verify_plaintext.txt:3 msgid "" ">\n" "Username:" msgstr "" ">\n" "Имя:" #: announcer/templates/acct_mgr_reset_password_plaintext.txt:6 msgid "Password:" msgstr "Пароль:" #: announcer/templates/acct_mgr_user_change_plaintext.txt:1 msgid "for user" msgstr "для пользователя" #: announcer/templates/acct_mgr_verify_plaintext.txt:1 msgid "" "Please visit the following URL to confirm your email address.\n" "\n" "Verification URL: <" msgstr "" "Пожалуйста посетите следующий URL для подтверждения вашего почтового " "адреса.\n" "\n" "Проверочный URL:" #: announcer/templates/acct_mgr_verify_plaintext.txt:4 msgid "Verification Token:" msgstr "Проверочное слово:" #: announcer/templates/bitten_plaintext.txt:1 msgid "build of" msgstr "" #: announcer/templates/bitten_plaintext.txt:1 msgid "" "]\n" "---------------------------------------------------------------------\n" "\n" " Changeset:" msgstr "" "]\n" "---------------------------------------------------------------------\n" " Набор изменений:" #: announcer/templates/bitten_plaintext.txt:4 msgid "" ">\n" " Committed by:" msgstr "" ">\n" " Зафиксирован пользователем:" #: announcer/templates/bitten_plaintext.txt:5 msgid "Build Configuration:" msgstr "Конфигурация сборки:" #: announcer/templates/bitten_plaintext.txt:7 msgid "Build Slave:" msgstr "" #: announcer/templates/bitten_plaintext.txt:8 msgid "Build Number:" msgstr "Номер сборки:" #: announcer/templates/bitten_plaintext.txt:9 #, python-format msgid "" ">\n" "{% if build.failed_steps %}\\\n" "\n" " Failures:\n" "{% for step in build.failed_steps %}\\\n" " Step:" msgstr "" #: announcer/templates/bitten_plaintext.txt:14 msgid "Errors:" msgstr "Ошибки:" #: announcer/templates/bitten_plaintext.txt:15 msgid "Log:" msgstr "Журнал:" #: announcer/templates/bitten_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "--\n" "Build URL: <" msgstr "" #: announcer/templates/fullblog_plaintext.txt:1 #, python-format msgid "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Added post \"" msgstr "" "{% if category == 'post created' or category == 'post updated' %}\n" "{% if category == 'post created' %}\n" "Добавлена заметка \"" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "\" by" msgstr "\" от" #: announcer/templates/fullblog_plaintext.txt:4 #: announcer/templates/fullblog_plaintext.txt:7 #: announcer/templates/fullblog_plaintext.txt:25 #: announcer/templates/fullblog_plaintext.txt:29 #: announcer/templates/fullblog_plaintext.txt:33 msgid "at" msgstr "в" #: announcer/templates/fullblog_plaintext.txt:4 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Changed post \"" msgstr "" "{% end %}\\\n" "{% if category == 'post updated' %}\\\n" "Изменена заметка \"" #: announcer/templates/fullblog_plaintext.txt:7 msgid "" ". \n" "Revision:" msgstr "" ". \n" "Правка:" #: announcer/templates/fullblog_plaintext.txt:8 #, python-format msgid "" "{% end %}\\\n" "Page URL:" msgstr "" "{% end %}\\\n" "URL страницы:" #: announcer/templates/fullblog_plaintext.txt:10 msgid "" "Content:\n" "\n" "Title:" msgstr "" "Содержимое:\n" "\n" "Заголовок:" #: announcer/templates/fullblog_plaintext.txt:15 #, python-format msgid "" "{% if comment %}\\\n" "Comment:" msgstr "" "{% if comment %}\\\n" "Комментарий:" #: announcer/templates/fullblog_plaintext.txt:20 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Deleted post \"" msgstr "" "{% end %}\\\n" "{% end %}\\\n" "\n" "{% if category == 'post deleted' %}\\\n" "Удалена заметка \"" #: announcer/templates/fullblog_plaintext.txt:25 #, python-format msgid "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "Page URL:" msgstr "" "{% end %}\\\n" "{% if category == 'post deleted' %}\\\n" "URL страницы:" #: announcer/templates/fullblog_plaintext.txt:28 msgid "Deleted version \"" msgstr "Удалена версия \"" #: announcer/templates/fullblog_plaintext.txt:29 msgid "\" of post \"" msgstr "\" заметки \"" #: announcer/templates/fullblog_plaintext.txt:29 #, python-format msgid "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Comment added to post \"" msgstr "" "{% end %}\\\n" "\n" "{% if category == 'comment created' %}\\\n" "Добавлен комментарий к заметке \"" #: announcer/templates/fullblog_plaintext.txt:33 msgid "Page URL:" msgstr "URL страницы:" #: announcer/templates/fullblog_plaintext.txt:34 msgid "Content:" msgstr "Содержимое:" #: announcer/templates/fullblog_plaintext.txt:37 #, python-format msgid "{% end %}" msgstr "" #: announcer/templates/prefs_announcer.html:14 #: announcer/templates/prefs_announcer_manage_subscriptions.html:14 msgid "" "Announcements serve as a method for Trac to communicate events to you; \n" " the creation of a ticket, the change of a Wiki page, and so on. " "Under\n" " the Announcement system, you will only receive notifications to " "those\n" " topics that you subscribe to." msgstr "" "Извещения это способ сообщать вам о событиях Trac: \n" " создание билета, изменения страницы Wiki, и т.п. В подходе " "извещений вы получаете уведомления только о тех темах,\n" " на которые вы сами подписались." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:5 msgid "Subscribe to user account announcements." msgstr "Подписаться на извещения о пользовательских профилях." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:9 msgid "Send me announcements when new users are created." msgstr "Посылать извещения при создании новых пользователей." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:10 #, fuzzy msgid "Send me announcements when users accounts are changed." msgstr "Посылать извещения при изменении пользовательских профилей." #: announcer/templates/prefs_announcer_acct_mgr_subscription.html:11 msgid "Send me announcements when users accounts are deleted." msgstr "Посылать извещения при удалении пользовательских профилей." #: announcer/templates/prefs_announcer_author_filter.html:5 msgid "Opt-out of announcements about my own changes." msgstr "Отказаться от извещений о моих собственных действиях." #: announcer/templates/prefs_announcer_author_filter.html:8 msgid "Never notify me when I make a change." msgstr "Никогда не извещать о моих изменениях." #: announcer/templates/prefs_announcer_bitten.html:5 msgid "Subscribe to build announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:9 msgid "Subscribe me to build started announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:10 msgid "Subscribe me to build aborted announcements." msgstr "" #: announcer/templates/prefs_announcer_bitten.html:11 msgid "Subscribe me to build completed announcements." msgstr "" #: announcer/templates/prefs_announcer_distributor.html:3 msgid "I prefer to recieve announcements via" msgstr "" #: announcer/templates/prefs_announcer_email.html:5 #: announcer/templates/prefs_announcer_xmpp.html:3 msgid "" "By default, the Announcer will deliver all notices to you in a plaintext " "format. You\n" " may override this for each realm that may generate announcements." msgstr "" "По умолчанию все уведомления доставляются в виде простого текста. Вы " "можете переопределить это поведение для каждого раздела, который может " "генерировать извещения." #: announcer/templates/prefs_announcer_email.html:9 #: announcer/templates/prefs_announcer_xmpp.html:7 msgid "announcements:" msgstr "извещения:" #: announcer/templates/prefs_announcer_emailaddress.html:5 msgid "" "If you would like to have announcement notices sent to a different " "address then the main one provided\n" " in Trac, you may specify the address here:" msgstr "" "Если вы хотите получать извещения на адрес, отличный от указанного " "глобально в Trac, вы можете указать его здесь:" #: announcer/templates/prefs_announcer_emailaddress.html:10 msgid "Email address:" msgstr "Почтовый адрес" #: announcer/templates/prefs_announcer_joinable_components.html:5 msgid "" "Components are a way to classify trac tickets. The following components " "have been defined by the Trac administrators. If you subscribe to any of" " these components, you will receive an notification anytime a ticket " "related to that component is changed or created." msgstr "" "Компоненты это способ классификации билетов. Следующие компоненты созданы" " администратором Trac. Если вы подпишетесь на компонент, то будете " "получать уведомление при создании или изменении билетов, связанных с этим" " компонентом." #: announcer/templates/prefs_announcer_joinable_groups.html:5 msgid "" "The following groups have been defined by the Trac administrators. They " "are general topics that may be added onto the CC list of tickets (by " "prepending their name with @). Case does matter." msgstr "" "Следующие группы созданы администратором Trac. Они являются обобщенными " "адресатами, которые могут быть добавлены в список CC билета (имена " "предваряются @). Регистр не имеет значения." #: announcer/templates/prefs_announcer_legacy.html:8 msgid "Notify me of changes to tickets that belong to components that I own." msgstr "Уведомлять меня об изменениях билетов из компонентов, которыми я владею." #: announcer/templates/prefs_announcer_legacy.html:12 msgid "Notify me of changes to tickets that I own." msgstr "Уведомлять меня об изменениях в моих билетах." #: announcer/templates/prefs_announcer_legacy.html:16 msgid "Notify me of changes to tickets that I reported." msgstr "Уведомлять меня об изменениях в билетах, которые я открыл." #: announcer/templates/prefs_announcer_legacy.html:20 msgid "Notify me when I update a ticket." msgstr "Уведомлять меня о моих изменениях в билетах." #: announcer/templates/prefs_announcer_manage_subscriptions.html:23 msgid "rules" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:25 msgid "Custom Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:28 msgid "Format:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:32 msgid "Save" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "Delete" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "down" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:37 msgid "up" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:48 #, fuzzy msgid "Add" msgstr "добавлен" #: announcer/templates/prefs_announcer_manage_subscriptions.html:53 msgid "Default Rules:" msgstr "" #: announcer/templates/prefs_announcer_manage_subscriptions.html:55 msgid "" "The following rules have been configured by the system admistrator as the" " default rules. Any rules defined by you will take higher priority then " "these rules. This can be confusing if you don't understand how the " "system works. Only the first matching rule is applied when system events" " occur. For example, if you have a rule like \"always notify me of any " "ticket changes\" in your custom rules, and there is a default rule " "\"never notify me when I update a ticket\", then the always rule will " "take precedent and you will still recieve announcements on ticket " "changes, even when you are the updater. In the preceding case, you would" " need to add your own \"never notify me..\" rule above the \"always " "notify me..\" to get the proper behavior." msgstr "" #: announcer/templates/prefs_announcer_rules.html:6 msgid "" "The rule-based subscription module is for advanced users, and allows you " "to use filters to specify which events you are interested in hearing " "about." msgstr "" "Модуль подписок по правилам предназначен для опытных пользователей и " "позволяет вам использовать фильтры для указания событий, о которых вам " "интересно узнать." #: announcer/templates/prefs_announcer_rules.html:9 msgid "" "Every rule is in the form of: \n" " [1:[2:realm], [3:category]: [4:query rule]]" msgstr "" "Каждое правило в формате: [1:[2:раздел], [3:категория]: [4:правило " "запроса]]" #: announcer/templates/prefs_announcer_ticket_all.html:8 #, fuzzy msgid "Notify me when any ticket changes." msgstr "Никогда не извещать о моих изменениях." #: announcer/templates/prefs_announcer_unsubscribe_all.html:5 msgid "Opt-out of all announcements." msgstr "Отказаться от всех извещений." #: announcer/templates/prefs_announcer_unsubscribe_all.html:8 msgid "Never notify me of any changes." msgstr "Не уведомлять меня ни о каких изменениях." #: announcer/templates/prefs_announcer_watch_bloggers.html:8 msgid "Comma seperated list of blog authors to follow:" msgstr "" #: announcer/templates/prefs_announcer_watch_users.html:5 #, fuzzy msgid "" "A comma separated list of users you would like to watch. A watched user \n" " will create an announcement each time he/she creates or changes\n" " a wiki page or ticket." msgstr "" "Список наблюдаемых пользователей, разделенных запятыми. Извещение будет " "приходить каждый раз, когда один из указанных пользователей создает или " "меняет страницу wiki или билет." #: announcer/templates/prefs_announcer_watch_users.html:9 msgid "Watch Users:" msgstr "Наблюдать за пользователями:" #: announcer/templates/prefs_announcer_wiki.html:7 msgid "" "In addition to other methods that may notify you of changes to Wiki " "pages, you may list here\n" " pages that are of interest to you. Each page should be on a separate " "line." msgstr "" "В дополнение к прочим методом уведемления об изменениях в wiki, здесь вы " "можете указать интересующие вас страницы. Каждое имя должно быть на " "отдельной строке." #: announcer/templates/prefs_announcer_wiki.html:12 #, fuzzy msgid "" "You may use wild cards, so that if you want to hear about any page that " "starts with the name 'Trac'\n" " you would enter on it's own line: [1:Trac*]" msgstr "" "Вы можете использовать сокращения, так, если вы хотите следить за всеми " "страницами, с именем, начинающимся 'Trac', нужно в новой строке вписать: " "[1:Trac*]" #: announcer/templates/prefs_announcer_wiki.html:17 #, fuzzy msgid "" "To receive a notice about all wiki changes, simply include a [1:*] by " "itself." msgstr "" "Для получения сообщения о любых изменениях в wiki просто вставьте [1:*] " "саму по себе" #: announcer/templates/prefs_announcer_xmppaddress.html:3 msgid "" "Specify your XMPP(jabber) address where you would like jabber " "announcements delivered." msgstr "" #: announcer/templates/prefs_announcer_xmppaddress.html:6 #, fuzzy msgid "XMPP address:" msgstr "Почтовый адрес" #: announcer/templates/ticket_email_mimic.html:114 msgid "Ticket #" msgstr "Билет №" #: announcer/templates/ticket_email_mimic.html:124 msgid "Description" msgstr "Описание" #: announcer/templates/ticket_email_mimic.html:129 msgid "Changes: (by" msgstr "Изменения: (от" #: announcer/templates/ticket_email_mimic.html:132 msgid "" "changed \n" " from" msgstr "" "изменено \n" " с" #: announcer/templates/ticket_email_mimic.html:133 msgid "to" msgstr "на" #: announcer/templates/ticket_email_mimic.html:145 msgid "Attachments:" msgstr "Вложения:" #: announcer/templates/ticket_email_mimic.html:147 msgid "File" msgstr "Файл" #: announcer/templates/ticket_email_mimic.html:147 msgid "added" msgstr "добавлен" #: announcer/templates/ticket_email_mimic.html:151 msgid "Comments:" msgstr "Комментарии:" #: announcer/templates/ticket_email_mimic.html:151 msgid "(by" msgstr "(от" #: announcer/templates/ticket_email_mimic.html:157 msgid "Ticket URL:" msgstr "Адрес билета:" #: announcer/templates/ticket_email_plaintext.txt:1 #, python-format msgid "" "---------------------------------------------------------------------\n" "{% for field in fields %}\\\n" "{% choose %}\\\n" "{% when ticket[field['name']] %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:5 #, python-format msgid "" "{% end %}\\\n" "{% otherwise %}\\" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:7 #, python-format msgid "" ": (None)\n" "{% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% if category == 'created' %}\\\n" "---------------------------------------------------------------------" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:12 #, python-format msgid "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------\n" "Changes (by" msgstr "" "{% end %}\\\n" "{% if has_changes or attachment %}\\\n" "---------------------------------------------------------------------\n" "Изменения (от" #: announcer/templates/ticket_email_plaintext.txt:16 #, python-format msgid "" "): \n" "{% for change in short_changes %}\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:18 msgid "from '" msgstr "с '" #: announcer/templates/ticket_email_plaintext.txt:18 #, python-format msgid "" "' to \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" msgstr "" "' на \\\n" "{% choose %}\\\n" "{% when short_changes[change][1] %}\\\n" "'" #: announcer/templates/ticket_email_plaintext.txt:21 #, python-format msgid "" "'{% end %}\\\n" "{% otherwise %}\\\n" "(deleted){% end %}\\\n" "{% end %}\\\n" "{% end %}\\\n" "{% for change in long_changes %}\\\n" "\n" " *" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:28 #, python-format msgid "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Attachment:\n" " * File '" msgstr "" "{% end %}\\\n" "{% end %}\\\n" "{% if attachment %}\\\n" "Вложение:\n" " * Файл '" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "' added{% if attachment.description %}:" msgstr "' добавлен{% if attachment.description %}:" #: announcer/templates/ticket_email_plaintext.txt:33 #, python-format msgid "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------\n" "Comment{% if not has_changes %} (by" msgstr "" "{% end %}\n" "{% end %}\\\n" "{% if comment %}\\\n" "\n" "---------------------------------------------------------------------\n" "Комментарий{% if not has_changes %} (от" #: announcer/templates/ticket_email_plaintext.txt:38 #, python-format msgid "){% end %}:" msgstr "" #: announcer/templates/ticket_email_plaintext.txt:39 #, python-format msgid "" "\\\n" "{% end %}\\\n" "\n" "--\n" "Ticket URL: ' because of rule: carbon copied" #~ msgstr "CarbonCopySubscriber добавил '%s <%s>' по правилу: точная копия" #~ msgid "Notify me of any new blog posts." #~ msgstr "Уведомлять о любых новых заметках в блоге." #~ msgid "Notify me of any blog changes." #~ msgstr "Уведомлять о любых изменениях в блоге." trac-announcer-0.12.1+r10986/trunk/announcer/locale/zh_CN/0000755000175000017500000000000011702277637021122 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/locale/zh_CN/LC_MESSAGES/0000755000175000017500000000000011702277637022707 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/locale/zh_CN/LC_MESSAGES/announcer.po0000644000175000017500000005672711471761426025255 0ustar wmbwmb# 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: 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- 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, self.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-0.12.1+r10986/trunk/announcer/distributors/mail.py0000644000175000017500000006352711660505210022710 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- # 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.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.""") replyto = 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 = Option('announcer', 'email_to', 'undisclosed-recipients: ;', '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 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 """, (sid, int(authenticated), 'announcer_email_format_%s' % realm)) result = cursor.fetchone() if result: chosen = result[0] 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=[]): output = formatter.format(transport, event.realm, format, event) # DEVEL: force message body plaintext style for crypto operations 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 operaton 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 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 = MIMEMultipart("related") # TODO: is this good? (from jabber branch) #rootMessage.set_charset(self._charset) headers = dict() headers['Message-ID'] = self._message_id(event.realm) headers['Date'] = formatdate() from_header = formataddr(( self.from_name or self.env.project_name, self.email_from )) headers['From'] = from_header headers['To'] = '"%s"'%(self.to) if self.use_public_cc: headers['Cc'] = ', '.join([x[2] for x in recipients if x]) headers['Reply-To'] = self.replyto for k, v in headers.iteritems(): set_header(rootMessage, k, v) 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' msgText = MIMEText(alternate_output, alt_msg_format) parentMessage.attach(msgText) else: parentMessage = rootMessage msg_format = 'html' in format and 'html' or 'plain' msgText = MIMEText(output, msg_format) del msgText['Content-Transfer-Encoding'] msgText.set_charset(self._charset) parentMessage.attach(msgText) decorators = self._get_decorators() if len(decorators) > 0: decorator = decorators.pop() decorator.decorate_message(event, rootMessage, decorators) recip_adds = [x[2] for x in recipients if x] # Append any to, cc or bccs added to the recipient list for field in ('To', 'Cc', 'Bcc'): if rootMessage[field] and \ len(str(rootMessage[field]).split(',')) > 0: for addy in str(rootMessage[field]).split(','): self._add_recipient(recip_adds, addy) # replace with localized bcc hint if headers['To'] == 'undisclosed-recipients: ;': set_header(rootMessage, 'To', _('undisclosed-recipients: ;')) self.log.debug("Content of recip_adds: %s" %(recip_adds)) 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[:] def _add_recipient(self, recipients, addy): if addy.strip() != '"undisclosed-recipients: ;"': recipients.append(addy) 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-0.12.1+r10986/trunk/announcer/templates/0000755000175000017500000000000011702277637020660 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_xmppaddress.html0000644000175000017500000000057311450260670027341 0ustar wmbwmb Specify your XMPP(jabber) address where you would like jabber announcements delivered.
  • XMPP address:
trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_author_filter.html0000644000175000017500000000064011434334742027655 0ustar wmbwmb Opt-out of announcements about my own changes.

Never notify me when I make a change.

trac-announcer-0.12.1+r10986/trunk/announcer/templates/acct_mgr_verify_plaintext.txt0000644000175000017500000000033411337147242026644 0ustar wmbwmbPlease 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-0.12.1+r10986/trunk/announcer/templates/bitten_plaintext.txt0000644000175000017500000000134111337147230024762 0ustar wmbwmb${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-0.12.1+r10986/trunk/announcer/templates/fullblog_plaintext.txt0000644000175000017500000000143211337147214025306 0ustar wmbwmb#${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-0.12.1+r10986/trunk/announcer/templates/acct_mgr_user_change_plaintext.txt0000644000175000017500000000014611337147242027624 0ustar wmbwmb${account.action} for user ${account.username} -- ${project.name} <${project.url}> ${project.descr} trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_watch_bloggers.html0000644000175000017500000000071711450706240027777 0ustar wmbwmb
trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_watch_users.html0000644000175000017500000000112311434334742027332 0ustar wmbwmb 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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_manage_subscriptions.html0000644000175000017500000000745511451362244031235 0ustar wmbwmb Announcements

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

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

Default Rules:

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

  • ${rule['adverb']} ${rule['description']}
trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_xmpp.html0000644000175000017500000000115111450260670025764 0ustar wmbwmb 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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_unsubscribe_all.html0000644000175000017500000000061511434334742030164 0ustar wmbwmb Opt-out of all announcements.

Never notify me of any changes.

trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_rules.html0000644000175000017500000000246411434334742026146 0ustar wmbwmb

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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_joinable_groups.html0000644000175000017500000000112011434334742030162 0ustar wmbwmb The following groups have been defined by the Trac administrators. They are general topics that may be added onto the CC list of tickets (by prepending their name with @). Case does matter.
  • @${grp}
trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_email.html0000644000175000017500000000127211434334742026077 0ustar wmbwmb 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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_emailaddress.html0000644000175000017500000000101011434334742027433 0ustar wmbwmb 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-0.12.1+r10986/trunk/announcer/templates/ticket_email_plaintext.txt0000644000175000017500000000252311335531124026127 0ustar wmbwmb#${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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_joinable_components.html0000644000175000017500000000121711434334742031037 0ustar wmbwmb Components are a way to classify trac tickets. The following components have been defined by the Trac administrators. If you subscribe to any of these components, you will receive an notification anytime a ticket related to that component is changed or created.
  • ${name}
trac-announcer-0.12.1+r10986/trunk/announcer/templates/acct_mgr_reset_password_plaintext.txt0000644000175000017500000000032611337147242030405 0ustar wmbwmbYour 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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer.html0000644000175000017500000000172611434334742024734 0ustar wmbwmb 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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_wiki.html0000644000175000017500000000150411434334742025751 0ustar wmbwmb

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-0.12.1+r10986/trunk/announcer/templates/wiki_email_plaintext.txt0000644000175000017500000000127711444016466025624 0ustar wmbwmb{% 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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_ticket_all.html0000644000175000017500000000061111444454570027122 0ustar wmbwmb
  • Notify me when any ticket changes.
trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_bitten.html0000644000175000017500000000136411434334742026277 0ustar wmbwmb Subscribe to build announcements.
  • Subscribe me to build started announcements.
  • Subscribe me to build aborted announcements.
  • Subscribe me to build completed announcements.
trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_legacy.html0000644000175000017500000000167611434334742026264 0ustar wmbwmb
  • 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-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_distributor.html0000644000175000017500000000076511450260670027364 0ustar wmbwmb I prefer to recieve announcements via trac-announcer-0.12.1+r10986/trunk/announcer/templates/prefs_announcer_acct_mgr_subscription.html0000644000175000017500000000141711434334742031374 0ustar wmbwmb 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-0.12.1+r10986/trunk/announcer/templates/ticket_email_mimic.html0000644000175000017500000001214311444444272025351 0ustar wmbwmb #${ticket.id}: ${ticket['summary']} Ticket #${ticket.id} (${ticket['status']} ${ticket['type']})
${ticket['summary']}
${field['label']}: ${ticket[field['name']] or 'None'}
Description
${description}
Changes: (by ${author})
  • ${change} changed from ${short_changes[change][0]} to ${short_changes[change][1]}.
  • ${change}:
    ${content}
Attachments:
  • File ${attachment.filename} added: ${attachment.description}
Comments: (by ${author})
${comment}



trac-announcer-0.12.1+r10986/trunk/announcer/tests/0000755000175000017500000000000011702277637020024 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/tests/__init__.py0000644000175000017500000000357111335531124022125 0ustar wmbwmb# -*- coding: utf-8 -*- # # 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- import unittest def suite(): suite = unittest.TestSuite() suite.addTest(ticket_compat.suite()) suite.addTest(ticket_formatter.suite()) return suite if __name__ == '__main__': unittest.main(defaultTest="suite") trac-announcer-0.12.1+r10986/trunk/announcer/tests/ticket_formatter.py0000644000175000017500000000522511335533462023741 0ustar wmbwmb# -*- coding: utf-8 -*- # # 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- import unittest from trac.core import * from trac.test import EnvironmentStub from announcer.formatters.ticket import * class TicketFormatTestCase(unittest.TestCase): def setUp(self): self.env = EnvironmentStub() self.out = TicketFormatter(self.env) def test_styles(self): self.assertTrue('text/html' in self.out.styles('email', 'ticket')) self.assertTrue('text/plain' in self.out.styles('email', 'ticket')) self.assertFalse('text/plain' in self.out.styles('email', 'wiki')) self.assertEqual('text/plain', self.out.alternative_style_for('email', 'ticket', 'text/blah')) self.assertEqual('text/plain', self.out.alternative_style_for('email', 'ticket', 'text/html')) self.assertEqual(None, self.out.alternative_style_for('email', 'ticket', 'text/plain')) def suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(TicketFormatTestCase, 'test')) return suite if __name__ == '__main__': unittest.main(defaultTest='suite') trac-announcer-0.12.1+r10986/trunk/announcer/model.py0000644000175000017500000004057711455572060020342 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2010, 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- # 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 trac.util.datefmt import utc __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 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 cursor.execute(""" INSERT INTO subscription (time, changetime, sid, authenticated, distributor, format, priority, adverb, class) VALUES (CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, %s, %s, %s, %s, %s, %s, %s) """, (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, 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,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,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() cursor.execute(""" UPDATE subscription SET changetime=CURRENT_TIMESTAMP, priority=%s WHERE id=%s """, (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, 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, 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, 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,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,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,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 trac-announcer-0.12.1+r10986/trunk/announcer/htdocs/0000755000175000017500000000000011702277637020146 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/htdocs/css/0000755000175000017500000000000011702277637020736 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/htdocs/css/announcer_prefs.css0000644000175000017500000000062011335531124024617 0ustar wmbwmbdiv.announcer_preference_box { background-color: #f0f6fe; width: 95%; border: thin outset #d4d1ff; padding: 0.5em; text-align: left; margin: 1em; } div.announcer_preference_title { background-color: #213da4; color: #fff; padding: 5px; font-family: "Lucida Grande", Lucida, Verdana, sans-serif; } div.announcer_preference_options { padding: 2px; } trac-announcer-0.12.1+r10986/trunk/announcer/htdocs/css/rulediv.css0000644000175000017500000000022011335531124023076 0ustar wmbwmb/* @override http://home.killnine.net/falcon/chrome/announcerplugin/css/rulediv.css */ #announcer_rules .syntax { background-color: #e6e6e6; }trac-announcer-0.12.1+r10986/trunk/announcer/producers.py0000644000175000017500000001526411447713144021244 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- 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-0.12.1+r10986/trunk/announcer/formatters.py0000644000175000017500000003044411660462652021423 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- import difflib from genshi import HTML from genshi.template import NewTextTemplate, MarkupTemplate, TemplateLoader from trac.config import Option, IntOption, ListOption, BoolOption from trac.core import * 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.util.mail import exception_to_unicode 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 diff_header = """Index: %(name)s ============================================================================== --- %(name)s (version: %(oldversion)s) +++ %(name)s (version: %(version)s) """ class TicketFormatter(Component): implements(IAnnouncementFormatter) ticket_email_header_fields = ListOption('announcer', 'ticket_email_header_fields', 'owner, reporter, milestone, priority, severity', doc="""Comma seperated list of fields to appear in tickets. Use * to include all headers.""") ticket_link_with_comment = BoolOption('announcer', 'ticket_link_with_comment', 'false', """Include last change anchor to 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 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 render_wiki_to_html_without_req(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 = render_wiki_to_html_without_req(event, ticket['description']) temp = render_wiki_to_html_without_req(event, event.comment) data = dict( ticket = ticket, description = description, author = event.author, fields = self._header_fields(ticket), comment = temp, 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(Component): implements(IAnnouncementFormatter) wiki_email_diff = BoolOption('announcer', 'wiki_email_diff', "true", """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: 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-0.12.1+r10986/trunk/announcer/opt/0000755000175000017500000000000011702277637017464 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/__init__.py0000644000175000017500000000000011337147214021552 0ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/fullblog/0000755000175000017500000000000011702277637021272 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/fullblog/__init__.py0000644000175000017500000000000011337147214023360 0ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/fullblog/announce.py0000644000175000017500000004017411451362234023445 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- 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-0.12.1+r10986/trunk/announcer/opt/bitten/0000755000175000017500000000000011702277637020751 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/bitten/__init__.py0000644000175000017500000000000011337147230023035 0ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/bitten/announce.py0000644000175000017500000002120511450252216023113 0ustar wmbwmb#-*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- 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 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-0.12.1+r10986/trunk/announcer/opt/acct_mgr/0000755000175000017500000000000011702277637021243 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/acct_mgr/__init__.py0000644000175000017500000000000011337147242023332 0ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/opt/acct_mgr/announce.py0000644000175000017500000001755311450252216023420 0ustar wmbwmb#-*- coding: utf-8 -*- # # Copyright (c) 2010, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- 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 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, IAnnouncementSubscriber, IAnnouncementFormatter, IAnnouncementEmailDecorator, IAnnouncementPreferenceProvider ) # IAccountChangeListener interface 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): self._notify('reset', username, password) def user_email_verification_requested(self, username, token): self._notify('verify', username, token=token) # IAnnouncementSubscriber interface 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): yield def description(self): return 'notify me an account changes NOT IMPLEMENTED' # IAnnouncementFormatter interface 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 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) # IAnnouncementPreferenceProvider interface def get_announcement_preference_boxes(self, req): if req.authname == "anonymous" and 'email' not in req.session: return yield "acct_mgr_subscription", _("Account Manager 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('acct_mgr_%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_acct_mgr_subscription.html", data # 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 _settings(self): ret = {} for n in ('created', 'change', 'delete'): ret[n] = BoolSubscriptionSetting(self.env, "acct_mgr_%s"%n, None) return ret def _get_membership(self, event): settings = self._settings() if event.category in settings.keys(): for result in settings[event.category].get_subscriptions(): yield result elif event.category in ('verify', 'reset'): yield ('email', event.username, True, None) 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' } 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-0.12.1+r10986/trunk/announcer/api.py0000644000175000017500000005506411660505706020011 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010,2011 Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- """ 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 """ import pkg_resources import time from operator import itemgetter from trac.config import ExtensionOption from trac.core import * from trac.db import Table, Column, Index from trac.db import DatabaseManager from trac.env import IEnvironmentSetupParticipant from trac.util.compat import set class IAnnouncementProducer(Interface): """Producer converts Trac events from different subsystems, into AnnouncerEvents. """ def realms(): """Returns an iterable that lists all the realms that this producer is capable of producing events for. """ class IAnnouncementSubscriber(Interface): """IAnnouncementSubscriber provides an interface where a Plug-In can register realms and categories of subscriptions it is able to provide. An IAnnouncementSubscriber component can use any means to determine if a user is interested in hearing about a given event. More then one component can handle the same realms and categories. The subscriber must also indicate not just that a user is interested in receiving a particular notice. Again, how it makes that decision is entirely up to a particular implementation.""" def matches(event): """Returns a list of subscriptions that match the given event. Responses should be yielded as 7 part tuples as follows: (distributor, sid, authenticated, address, format, priority, adverb) The default installation includes email and xmpp distributors. The default installation includes formats for text/plain and text/html. If an unknown format is return, it will be replaced by a default known format. Priority is used to resolve conflicting subscriptions for the same user/distribution pair. adverb is either always or never. """ def description(): """A description of the subscription that shows up in the users preferences. """ def requires_authentication(): """Returns True or False. If the user is required to be authenticated to create the subscription, then return True. This applies to things like ticket owner subscriber, since the ticket owner can never be the sid of an unauthenticated user and we have no way to lookup users by email address (as of yet). """ class IAnnouncementDefaultSubscriber(Interface): """Default subscriptions that the module will automatically generate. This should only be used in reasonable situations, where users can be determined by the event itself. For instance, ticket author has a default subscription that is controlled via trac.ini. This is because we can lookup the ticket author during the event and create a subscription for them. Default subscriptions should be low priority so that the user can easily override them. """ def default_subscriptions(): """Yields 5 part tuple containing (class, distributor, priority, adverb). This is used to display default subscriptions in the user UI and can also be used by matches to figure out what default subscriptions it should yield. """ class IAnnouncementSubscriptionFilter(Interface): """IAnnouncementSubscriptionFilter provides an interface where a component can filter subscribers from the final distribution list. """ def filter_subscriptions(event, subscriptions): """Returns a filtered iterator of subscriptions. This method is called after all get_subscriptions_for_event calls are made to allow components to remove addresses from the distribution list. This can be used for things like "never notify updater" functionality. """ class IAnnouncementFormatter(Interface): """Formatters are responsible for converting an event into a message appropriate for a given transport. For transports like 'aim' or 'irc', this may be a short summary of a change. For 'email', it may be a plaintext or html overview of all the changes and perhaps the existing state. It's up to a formatter to determine what ends up ultimately being sent to the end-user. It's capable of pulling data out of the target object that wasn't changed, picking and choosing details for whatever reason. Since a formatter must be intimately familiar with the realm that originated the event, formatters are tied to specific transport + realm combinations. This means there may be a proliferation of formatters as options expand. """ def format_styles(transport, realm): """Returns an iterable of styles that this formatter supports for a specified transport and realm. Many formatters may simply return a single style and never have more; that's fine. But if its useful to encapsulate code for several similar styles a formatter can handle more then one. For example, 'text/plain' and 'text/html' may be useful variants the same formatter handles. Formatters retain the ability to descriminate by transport, but don't need to. """ def alternative_style_for(transport, realm, style): """Returns an alternative style for the given style if one is available. """ def format(transport, realm, style, event): """Converts the event into the specified style. If the transport or realm passed into this method are not ones this formatter can handle, it should return silently and without error. The exact return type of this method is intentionally undefined. It will be whatever the distributor that it is designed to work with expects. """ class IAnnouncementDistributor(Interface): """The Distributor is responsible for actually delivering an event to the desired subscriptions. A distributor should attempt to avoid blocking; using subprocesses is preferred to threads. Each distributor handles a single transport, and only one distributor in the system should handle that. For example, there should not be two distributors for the 'email' transport. """ def transports(): """Returns an iter of the transport supported.""" def distribute(transport, recipients, event): """This method is meant to actually distribute the event to the specified recipients, over the specified transport. If it is passed a transport it does not support, it should return silently and without error. The recipients is a list of (name, address) pairs with either (but not both) being allowed to be None. If name is provided but address isn't, then the distributor should defer to IAnnouncementAddressResolver implementations to determine what the address should be. If the name is None but the address is not, then the distributor should rely on the address being correct and use it-- if possible. The distributor may initiate as many transactions as are necessecary to deliver a message, but should use as few as possible; for example in the EmailDistributor, if all of the recipients are receiving a plain text form of the message, a single message with many BCC's should be used. The distributor is responsible for determining which of the IAnnouncementFormatters should get the privilege of actually turning an event into content. In cases where multiple formatters are capable of converting an event into a message for a given transport, a user preference would be a dandy idea. """ class IAnnouncementPreferenceProvider(Interface): """Represents a single 'box' in the Announcements preference panel. Any component can always implement IPreferencePanelProvider to get preferences from users, of course. However, considering there may be several components related to the Announcement system, and many may have different preferences for a user to set, that would clutter up the preference interfac quite a bit. The IAnnouncementPreferenceProvider allows several boxes to be chained in the same panel to group the preferenecs related to the Announcement System. Implementing announcement preference boxes should be essentially identical to implementing entire panels. """ def get_announcement_preference_boxes(req): """Accepts a request object, and returns an iterable of (name, label) pairs; one for each box that the implementation can generate. If a single item is returned, be sure to 'yield' it instead of returning it.""" def render_announcement_preference_box(req, box): """Accepts a request object, and the name (as from the previous method) of the box that should be rendered. Returns a tuple of (template, data) with the template being a filename in a directory provided by an ITemplateProvider which shall be rendered into a single
element, when combined with the data member. """ class IAnnouncementAddressResolver(Interface): """Handles mapping Trac usernames to addresses for distributors to use.""" def get_address_for_name(name, authenticated): """Accepts a session name, and returns an address. This address explicitly does not always have to mean an email address, nor does it have to be an address stored within the Trac system at all. Implementations of this interface are never 'detected' automatically, and must instead be specifically named for a particular distributor. This way, some may find email addresses (for EmailDistributor), and others may find AIM screen name. If no address for the specified name can be found, None should be returned. The next resolver will be attempted in the chain. """ class AnnouncementEvent(object): """AnnouncementEvent This packages together in a single place all data related to a particular event; notably the realm, category, and the target that represents the initiator of the event. In some (rare) cases, the target may be None; in cases where the message is all that matters and there's no possible data you could conceivably get beyond just the message. """ def __init__(self, realm, category, target, author=""): self.realm = realm self.category = category self.target = target self.author = author def get_basic_terms(self): return (self.realm, self.category) def get_session_terms(self, session_id): return tuple() class IAnnouncementSubscriptionResolver(Interface): """Supports new and old style of subscription resolution until new code is complete.""" def subscriptions(event): """Return all subscriptions as (dist, sid, auth, address, format) priority 1 is highest. adverb is 'always' or 'never'. """ class SubscriptionResolver(Component): """Collect, and resolve subscriptions.""" implements(IAnnouncementSubscriptionResolver) subscribers = ExtensionPoint(IAnnouncementSubscriber) def subscriptions(self, event): """Yields all subscriptions for a given event.""" subscriptions = [] for sp in self.subscribers: subscriptions.extend( [x for x in sp.matches(event) if x] ) """ This logic is meant to generate a list of subscriptions for each distirbution method. The important thing is that we pick the rule with the highest priority for each (sid, distribution) pair. If it is "never", then the user is dropped from the list. If it is always, then the user is kept. Only the users highest priority rule is used and all others are skipped. """ # sort by dist, sid, authenticated, priority ordered_subs = sorted(subscriptions, key=itemgetter(1,2,3,6)) resolved_subs = [] # collect highest priority for each (sid, dist) pair state = { 'last': None } for s in ordered_subs: if (s[1], s[2], s[3]) == state['last']: continue if s[-1] == 'always': self.log.debug("Adding (%s [%s]) for 'always' on rule (%s) " "for (%s)"%(s[2], s[3], s[0], s[1])) resolved_subs.append(s[1:6]) else: self.log.debug("Ignoring (%s [%s]) for 'never' on rule (%s) " "for (%s)"%(s[2], s[3], s[0], s[1])) # if s[1] is None, then the subscription is for a raw email # address that has been set in some field and we shouldn't skip # the next raw email subscription. In other words, all raw email # subscriptions should be added. if s[2]: state['last'] = (s[1], s[2], s[3]) return resolved_subs _TRUE_VALUES = ('yes', 'true', 'enabled', 'on', 'aye', '1', 1, True) def istrue(value, otherwise=False): return True and (value in _TRUE_VALUES) or otherwise # Import i18n methods. Fallback modules maintain compatibility to Trac 0.11 # by keeping Babel optional here. try: from trac.util.translation import domain_functions add_domain, _, N_ , tag_= \ domain_functions('announcer', ('add_domain', '_', 'N_', 'tag_')) except ImportError: from genshi.builder import tag as tag_ from trac.util.translation import gettext _ = gettext N_ = lambda text: text def add_domain(a, b, c=None): pass class AnnouncementSystem(Component): """AnnouncementSystem represents the entry-point into the announcement system, and is also the central controller that handles passing notices around. An announcement begins when something-- an announcement provider-- constructs an AnnouncementEvent (or subclass) and calls the send method on the AnnouncementSystem. Every event is classified by two required fields-- realm and category. In general, the realm corresponds to the realm of a Resource within Trac; ticket, wiki, milestone, and such. This is not a requirement, however. Realms can be anything distinctive-- if you specify novel realms to solve a particular problem, you'll simply also have to specify subscribers and formatters who are able to deal with data in those realms. The other classifier is a category that is defined by the providers and has no particular meaning; for the providers that implement the I*ChangeListener interfaces, the categories will often correspond to the kinds of events they receive. For tickets, they would be 'created', 'changed' and 'deleted'. There is no requirement for an event to have more then realm and category to classify an event, but if more is provided in a subclass that the subscribers can use to pick through events, all power to you. """ implements(IEnvironmentSetupParticipant) subscribers = ExtensionPoint(IAnnouncementSubscriber) subscription_filters = ExtensionPoint(IAnnouncementSubscriptionFilter) subscription_resolvers = ExtensionPoint(IAnnouncementSubscriptionResolver) distributors = ExtensionPoint(IAnnouncementDistributor) resolver = ExtensionOption('announcer', 'subscription_resolvers', IAnnouncementSubscriptionResolver, 'SubscriptionResolver', """Comma seperated list of subscription resolver components in the order they will be called. """) # IEnvironmentSetupParticipant implementation """Subscriptions table will is deprecated in favor of the new subscriber interface. TODO: We still need to create an upgrade script that will port subscriptions from the subscription table and the session_attribute table to the subscription_attribute table. """ SCHEMA = [ Table('subscription', key='id')[ Column('id', auto_increment=True), Column('time', type='int64'), Column('changetime', type='int64'), Column('class'), Column('sid'), Column('authenticated', type='int'), Column('distributor'), Column('format'), Column('priority', type='int'), Column('adverb') ], Table('subscription_attribute', key='id')[ Column('id', auto_increment=True), Column('sid'), Column('authenticated', type='int'), Column('class'), Column('realm'), Column('target') ] ] def __init__(self): # bind the 'announcer' catalog to the locale directory locale_dir = pkg_resources.resource_filename(__name__, 'locale') add_domain(self.env.path, locale_dir) def environment_created(self): self._upgrade_db(self.env.get_db_cnx()) def environment_needs_upgrade(self, db): cursor = db.cursor() for table in self.SCHEMA: try: cursor.execute("select count(*) from %s"%table.name) cursor.fetchone() except: db.rollback() return True return False def upgrade_environment(self, db): self._upgrade_db(db) def _upgrade_db(self, db): try: db_backend, _ = DatabaseManager(self.env)._get_connector() for table in self.SCHEMA: try: cursor = db.cursor() cursor.execute("select count(*) from %s"%table.name) cursor.fetchone() except: db.rollback() cursor = db.cursor() for stmt in db_backend.to_sql(table): self.log.debug(stmt) cursor.execute(stmt) db.commit() except Exception, e: db.rollback() self.log.error(e, exc_info=True) raise TracError(str(e)) # The actual AnnouncementSystem now.. def send(self, evt): start = time.time() self._real_send(evt) stop = time.time() self.log.debug("AnnouncementSystem sent event in %s seconds."\ %(round(stop-start,2))) def _real_send(self, evt): """Accepts a single AnnouncementEvent instance (or subclass), and returns nothing. There is no way (intentionally) to determine what the AnnouncementSystem did with a particular event besides looking through the debug logs. """ try: subscriptions = self.resolver.subscriptions(evt) for sf in self.subscription_filters: subscriptions = set( sf.filter_subscriptions(evt, subscriptions) ) self.log.debug( "AnnouncementSystem has found the following subscriptions: " \ "%s"%(', '.join(['[%s(%s) via %s]' % ((s[1] or s[3]),\ s[2] and 'authenticated' or 'not authenticated',s[0])\ for s in subscriptions] ) ) ) packages = {} for transport, sid, authenticated, address, subs_format \ in subscriptions: if transport not in packages: packages[transport] = set() packages[transport].add((sid,authenticated,address)) for distributor in self.distributors: for transport in distributor.transports(): if transport in packages: distributor.distribute(transport, packages[transport], evt) except: self.log.error("AnnouncementSystem failed.", exc_info=True) trac-announcer-0.12.1+r10986/trunk/announcer/filters.py0000644000175000017500000000644211522107710020672 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- """Filters can remove subscriptions after they are collected. """ import re from trac.core import * from trac.config import ListOption from trac.perm import PermissionCache from announcer.api import IAnnouncementSubscriptionFilter from announcer.api import _ class DefaultPermissionFilter(Component): """DefaultPermissionFilter simply checks that each subscription has ${REALM}_VIEW permissions before allow the subscription notice to be sent. """ implements(IAnnouncementSubscriptionFilter) exception_realms = ListOption('announcer', 'filter_exception_realms', '', """The PermissionFilter will filter an announcements for with 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 because permissions can be bypassed using the AnnouncerPlugin. """) def filter_subscriptions(self, event, subscriptions): action = '%s_VIEW'%event.realm.upper() for subscription in subscriptions: 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 rule: DefaultPermissionFilter"\ %sid ) trac-announcer-0.12.1+r10986/trunk/announcer/subscribers.py0000644000175000017500000010214111666037174021560 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010, Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- # TODO: Test all anonymous subscribers # TODO: Subscriptions admin page import re, urllib from fnmatch import fnmatch from trac.config import BoolOption, Option, ListOption from trac.core import * from trac.resource import ResourceNotFound, get_resource_url from trac.ticket import model from trac.ticket.api import ITicketChangeListener from trac.util.text import to_unicode from trac.web.api import IRequestFilter, IRequestHandler, Href from trac.web.chrome import ITemplateProvider, add_ctxtnav, add_stylesheet from trac.web.chrome import add_warning, add_script, add_notice from trac.wiki.api import IWikiChangeListener from genshi.builder import tag 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.settings import BoolSubscriptionSetting from announcer.util.settings import SubscriptionSetting """Subscribers should return a list of subscribers based on event rules. The subscriber interface is very simple and flexible. Subscription 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 set their subscriptions up. Some of these should look to see if the user has any subscriptions in the subscription table, and if it doesn't, 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: * Component owner * CC field * Custom cc field * 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. """ class AllTicketSubscriber(Component): """Subscriber for all ticket changes.""" implements(IAnnouncementSubscriber) def description(self): return _("notify me when any ticket changes") 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 requires_authentication(self): return False class TicketOwnerSubscriber(Component): """Allows ticket owners to subscribe to their tickets.""" implements(IAnnouncementSubscriber) implements(IAnnouncementDefaultSubscriber) 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 in their preferences. """) default_distributor = ListOption("announcer", "always_notify_owner_distributor", "email", doc="""Comma seperated list of distributors to send the message to by default. ex. email, xmpp """) 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': return if re.match(r'^[^@]+@.+', ticket['owner']): sid, auth, addr = None, 0, ticket['owner'] else: sid, auth, addr = ticket['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() def description(self): return _("notify me when a ticket that I own is created or modified") def default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') def requires_authentication(self): return True class TicketComponentOwnerSubscriber(Component): """Allows component owners to subscribe to tickets assigned to their components. """ implements(IAnnouncementDefaultSubscriber) implements(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 seperated list of distributors to send the message to by default. ex. email, xmpp """) 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 default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') def requires_authentication(self): return True class TicketUpdaterSubscriber(Component): """Allows updaters to subscribe to their own updates.""" implements(IAnnouncementDefaultSubscriber) implements(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 seperated list of distributors to send the message to by default. ex. email, xmpp """) 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 default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 100, 'never') def requires_authentication(self): return True class TicketReporterSubscriber(Component): """Allows the users to subscribe to tickets that they report.""" implements(IAnnouncementSubscriber) implements(IAnnouncementDefaultSubscriber) 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 seperated list of distributors to send the message to by default. ex. email, xmpp """) 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 default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') def requires_authentication(self): return True class CarbonCopySubscriber(Component): """Carbon copy subscriber for cc ticket field.""" implements(IAnnouncementDefaultSubscriber) implements(IAnnouncementSubscriber) default_on = BoolOption("announcer", "always_notify_cc", 'true', """The always_notify_cc will notify the users in the cc field by default when a ticket is modified. """) default_distributor = ListOption("announcer", "always_notify_cc_distributor", "email", doc="""Comma seperated list of distributors to send the message to by default. ex. email, xmpp """) 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 default_subscriptions(self): if self.default_on: for d in self.default_distributor: yield (self.__class__.__name__, d, 101, 'always') def requires_authentication(self): return True class TicketComponentSubscriber(Component): """Allows users to subscribe to ticket assigned to the components of their choice. """ implements(IAnnouncementSubscriber) implements(IAnnouncementPreferenceProvider) 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 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) implements(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 seperated list of distributors to send the message to by default. ex. email, xmpp """) 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[3], s[4]) 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 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') def requires_authentication(self): return True 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(IAnnouncementSubscriber) implements(IAnnouncementPreferenceProvider) 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. """) 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 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 UserChangeSubscriber(Component): """Allows users to get notified anytime a particular user change triggers an event. """ implements(IAnnouncementSubscriber) implements(IAnnouncementPreferenceProvider) 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 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): SubscriptionAttribute.delete_by_sid_and_class(self.env, req.session.sid, req.session.authenticated, klass, db) users = map(lambda x: x.strip(), req.args.get("announcer_watch_users").split(',')) SubscriptionAttribute.add(self.env, req.session.sid, req.session.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 ) )) return "prefs_announcer_watch_users.html", dict(data=dict( announcer_watch_users=','.join(attrs) )) class WatchSubscriber(Component): """Allows user to subscribe to ticket or wikinotification 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) implements(IRequestHandler) implements(IAnnouncementSubscriber) implements(ITicketChangeListener) implements(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) if self.is_watching(req.session.sid, req.session.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 # IWikiChangeListener 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', page.name) def wiki_page_version_deleted(*args): pass # ITicketChangeListener 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', page.name) db = self.env.get_db_cnx() def matches(self, event): klass = self.__class__.__name__ attrs = SubscriptionAttribute.find_by_class_realm_and_target(self.env, klass, event.realm, self._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 def _get_target_id(self, target): if hasattr(target, 'id'): tid = str(target.id) elif hasattr(target, 'name'): tid = target.name else: tid = str(target) return tid 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(IAnnouncementSubscriber) implements(IAnnouncementPreferenceProvider) 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 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__ 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) SubscriptionAttribute.add(self.env, req.session.sid, req.session.authenticated, klass, 'wiki', req.args.get('wiki_interests', db)) (interests,) = SubscriptionAttribute.find_by_sid_and_class( self.env, req.session.sid, req.session.authenticated, klass ) or ({'target':''},) return "prefs_announcer_wiki.html", dict( wiki_interests = '\n'.join( urllib.unquote(x) for x in interests['target'].split(' ') ) ) trac-announcer-0.12.1+r10986/trunk/announcer/pref.py0000644000175000017500000002153011455571376020173 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010, 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- import re from operator import itemgetter from pkg_resources import resource_filename from trac.core import Component, implements, ExtensionPoint from trac.prefs.api import IPreferencePanelProvider from trac.web.api import ITemplateStreamFilter from trac.web.chrome import ITemplateProvider, add_stylesheet, Chrome from genshi.filters.transform import Transformer 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 SubscriptionSetting, encode, decode def truth(v): if v in (False, 'False', 'false', 0, '0', ''): return None return True class AnnouncerPreferences(Component): implements(IPreferencePanelProvider) implements(ITemplateProvider) preference_boxes = ExtensionPoint(IAnnouncementPreferenceProvider) def get_htdocs_dirs(self): return [('announcer', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): resource_dir = resource_filename(__name__, 'templates') return [resource_dir] 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(Component): implements(IPreferencePanelProvider) implements(ITemplateProvider) implements(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 } def get_htdocs_dirs(self): return [('announcer', resource_filename(__name__, 'htdocs'))] def get_templates_dirs(self): resource_dir = resource_filename(__name__, 'templates') return [resource_dir] 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 req.redirect(req.href.prefs('subscriptions')) data = {'rules':{}, 'subscribers':[]} desc_map = {} data['formatters'] = ('text/plain', 'text/html') data['selected_format'] = {} data['adverbs'] = ('always', 'never') 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 def filter_stream(self, req, method, filename, stream, data): if re.match(r'/prefs/subscription', req.path_info): stream |= Transformer('//form[@id="userprefs"]//div[@class="buttons"]').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-0.12.1+r10986/trunk/announcer/email_decorators.py0000644000175000017500000002331311501751376022544 0ustar wmbwmb# All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- import announcer import re import trac from email.utils import parseaddr from trac.config import ListOption, Option from trac.core import * from trac.util.text import to_unicode from genshi.template import NewTextTemplate from announcer.distributors.mail import IAnnouncementEmailDecorator from announcer.util.mail import set_header, msgid, next_decorator, uid_encode """Email decorators have the chance to modify emails or their headers before the email distributor sends them out. """ class ThreadingEmailDecorator(Component): """Add Message-ID, In-Reply-To and References message headers for resources. All message ids are derived from the properties of the ticket so that they can be regenerated later. """ implements(IAnnouncementEmailDecorator) supported_realms = ListOption('announcer', 'email_threaded_realms', 'ticket,wiki', doc="""These are realms with announcements that should be threaded emails. In order for email threads to work, the announcer system needs to give the email recreatable Message-IDs based on the resources in the realm. The resources must have a unique and immutable id, name or str() representation in it's realm """) def decorate_message(self, event, message, decorates=None): """ Added headers to the outgoing email to track it's relationship with a ticket. References, In-Reply-To and Message-ID are just so email clients can make sense of the threads. """ if to_unicode(event.realm) in self.supported_realms: uid = uid_encode(self.env.abs_href(), event.realm, event.target) email_from = self.config.get('announcer', 'email_from', 'localhost') _, email_addr = parseaddr(email_from) host = re.sub('^.+@', '', email_addr) mymsgid = msgid(uid, host) if event.category == 'created': set_header(message, 'Message-ID', mymsgid) else: set_header(message, 'In-Reply-To', mymsgid) set_header(message, 'References', mymsgid) return next_decorator(event, message, decorates) class StaticEmailDecorator(Component): """The static ticket decorator implements a policy to -always- send an email to a certain address. Controlled via the always_cc and always_bcc option in the announcer section of the trac.ini. If no subscribers are found, then even if always_cc and always_bcc addresses are specified, no announcement will be sent. Since these fields are added after announcers subscription system, filters such as never_announce and never_notify author won't work with these addresses. These settings are considered dangerous if you are using the verify email or reset password features of the accountmanager plugin. """ # FIXME: mark that emails as 'private' in AcctMgr and eval that mark here implements(IAnnouncementEmailDecorator) always_cc = Option("announcer", "email_always_cc", None, """Email addresses specified here will always be cc'd on all announcements. This setting is dangerous if accountmanager is present. """) always_bcc = Option("announcer", "email_always_bcc", None, """Email addresses specified here will always be bcc'd on all announcements. This setting is dangerous if accountmanager is present. """) def decorate_message(self, event, message, decorates=None): for k, v in {'Cc': self.always_cc, 'Bcc': self.always_bcc}.items(): if v: self.log.debug("StaticEmailDecorator added '%s' " "because of rule: email_always_%s"%(v, k.lower())), if message[k] and len(str(message[k]).split(',')) > 0: recips = ", ".join([str(message[k]), v]) else: recips = v set_header(message, k, recips) return next_decorator(event, message, decorates) class AnnouncerEmailDecorator(Component): """Add some boring headers that should be set.""" implements(IAnnouncementEmailDecorator) def decorate_message(self, event, message, decorators): mailer = 'AnnouncerPlugin v%s on Trac v%s'%( announcer.__version__, trac.__version__ ) set_header(message, 'Auto-Submitted', 'auto-generated') set_header(message, 'Precedence', 'bulk') set_header(message, 'X-Announcer-Version', announcer.__version__) set_header(message, 'X-Mailer', mailer) set_header(message, 'X-Trac-Announcement-Realm', event.realm) set_header(message, 'X-Trac-Project', self.env.project_name) set_header(message, 'X-Trac-Version', trac.__version__) return next_decorator(event, message, decorators) class TicketSubjectEmailDecorator(Component): """Formats ticket announcement subject headers based on the ticket_email_subject configuration. """ implements(IAnnouncementEmailDecorator) ticket_email_subject = Option('announcer', 'ticket_email_subject', "Ticket #${ticket.id}: ${ticket['summary']} " \ "{% if action %}[${action}]{% end %}", """Format string for ticket email subject. This is a mini genshi template that is passed the ticket event and action objects.""") def decorate_message(self, event, message, decorates=None): if event.realm == 'ticket': if event.changes: if 'status' in event.changes: action = 'Status -> %s' % (event.target['status']) template = NewTextTemplate(self.ticket_email_subject.encode('utf8')) subject = template.generate( ticket=event.target, event=event, action=event.category ).render('text', encoding=None) prefix = self.config.get('announcer', 'email_subject_prefix') if prefix == '__default__': prefix = '[%s] ' % self.env.project_name if prefix: subject = "%s%s"%(prefix, subject) if event.category != 'created': subject = 'Re: %s'%subject set_header(message, 'Subject', subject) return next_decorator(event, message, decorates) class TicketAddlHeaderEmailDecorator(Component): """Adds X-Announcement-(id,priority and severity) headers to ticket emails. This is useful for automated handling of incoming emails or customized filtering. """ implements(IAnnouncementEmailDecorator) def decorate_message(self, event, message, decorates=None): if event.realm == 'ticket': for k in ('id', 'priority', 'severity'): name = 'X-Announcement-%s'%k.capitalize() set_header(message, name, event.target[k]) return next_decorator(event, message, decorates) class WikiSubjectEmailDecorator(Component): """Formats wiki announcement subject headers based on the wiki_email_subject configuration. """ implements(IAnnouncementEmailDecorator) wiki_email_subject = Option('announcer', 'wiki_email_subject', "Page: ${page.name} ${action}", """Format string for the wiki email subject. This is a mini genshi template and it is passed the page, event and action objects.""") def decorate_message(self, event, message, decorates=None): if event.realm == 'wiki': template = NewTextTemplate(self.wiki_email_subject.encode('utf8')) subject = template.generate( page=event.target, event=event, action=event.category ).render('text', encoding=None) prefix = self.config.get('announcer', 'email_subject_prefix') if prefix == '__default__': prefix = '[%s] ' % self.env.project_name if prefix: subject = "%s%s"%(prefix, subject) if event.category != 'created': subject = 'Re: %s'%subject set_header(message, 'Subject', subject) return next_decorator(event, message, decorates) trac-announcer-0.12.1+r10986/trunk/announcer/resolvers.py0000644000175000017500000001167411450260670021256 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- 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.util.settings import SubscriptionSetting from announcer.api import _ 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, authenticated and 1 or 0, '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-0.12.1+r10986/trunk/announcer/util/0000755000175000017500000000000011702277637017637 5ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/util/__init__.py0000644000175000017500000000000011335531124021720 0ustar wmbwmbtrac-announcer-0.12.1+r10986/trunk/announcer/util/mail_crypto.py0000644000175000017500000001330011501746632022521 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2010, Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- import re from time import time from trac.core import * from trac.config import Option, BoolOption from trac.util.translation import _ __all__ = ['CryptoTxt',] class CryptoTxt: """Crypto operation provider for plaintext. We use GnuPG for now. Support for X.509 and other options might appear in the future. """ def __init__(self, gpg_binary, gpg_home): """Initialize the GnuPG instance.""" self.gpg_binary = gpg_binary self.gpg_home = gpg_home try: from gnupg import GPG except ImportError: raise TracError(_("Unable to load the python-gnupg module. " \ "Please check and correct your installation.")) try: self.gpg = GPG(gpgbinary=self.gpg_binary, gnupghome=self.gpg_home) # get list of available public keys once for later use self.pubkeys = self.gpg.list_keys() # same as gpg.list_keys(False) except ValueError: raise TracError(_("Missing the crypto binary. " \ "Please check and set full path " \ "with option 'gpg_binary'.")) def sign(self, content, private_key=None): private_key = self._get_private_key(private_key) cipher = self.gpg.sign(content, keyid=private_key, passphrase='') return str(cipher) def encrypt(self, content, pubkeys): # always_trust needed for making it work with just any pubkey cipher = self.gpg.encrypt(content, pubkeys, always_trust=True) return str(cipher) def sign_encrypt(self, content, pubkeys, private_key=None): private_key = self._get_private_key(private_key) # always_trust needed for making it work with just any pubkey cipher = self.gpg.encrypt(content, pubkeys, always_trust=True, sign=private_key, passphrase='') return str(cipher) def get_pubkey_ids(self, addr): """Find public key with UID matching address to encrypt to.""" pubkey_ids = [] if len(self.pubkeys) > 0 and self.pubkeys[-1].has_key('uids') and \ self.pubkeys[-1].has_key('fingerprint'): # compile pattern before use for better performance RCPT_RE = re.compile(addr) for k in self.pubkeys: for uid in k['uids']: match = RCPT_RE.search(uid) if match is not None: # check for key expiration if k['expires'] == '': pubkey_ids.append(k['fingerprint'][-16:]) elif (time()+60) < float(k['expires']): pubkey_ids.append(k['fingerprint'][-16:]) break return pubkey_ids def _get_private_key(self, privkey=None): """Find private (secret) key to sign with.""" # read private keys from keyring privkeys = self.gpg.list_keys(True) # True => private keys if len(privkeys) > 0 and privkeys[-1].has_key('fingerprint'): fingerprints = [] for k in privkeys: fingerprints.append(k['fingerprint']) else: # no private key in keyring return None if privkey: # check for existence of private key received as argument # DEVEL: check for expiration as well if len(privkey) > 7 and len(privkey) <= 40: for fp in fingerprints: if fp.endswith(privkey): # work with last 16 significant chars internally, # even if only 8 are required in trac.ini privkey = fp[-16:] break # no fingerprint matching key ID else: privkey = None else: # reset invalid key ID privkey = None else: # select (last) private key from keyring privkey = fingerprints[-1][-16:] return privkey trac-announcer-0.12.1+r10986/trunk/announcer/util/settings.py0000644000175000017500000001547511337762764022071 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2010, 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- import pickle from announcer.api import istrue def encode(*args): return pickle.dumps(args) def decode(v): try: return pickle.loads(str(v)) except Exception, e: return (tuple(),None) class SubscriptionSetting(object): """Encapsulate user text subscription and filter settings. Subscription settings have default values, usually trac properties, and user session attribute settings. If the user setting is unset, then the default value will be returned. """ def __init__(self, env, name, default_value=None, default_dists=('email',)): self.default = { 'value': default_value, 'dists': default_dists } self.env = env self.name = name def set_user_setting(self, session, value=None, dists=('email',), save=True): """Sets session attribute.""" session[self._attr_name()] = encode(dists,value) if save: session.save() def get_user_setting(self, sid): """Returns tuple of (value, authenticated).""" db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT value, authenticated FROM session_attribute WHERE sid=%s AND name=%s """, (sid, self._attr_name())) row = cursor.fetchone() if row: pair = decode(row[0]) authenticated = istrue(row[1]) else: pair = (self.default['dists'], self.default['value']) authenticated = False # We use None here so that Genshi templates check their checkboxes # properly and without confusion. return pair + (authenticated,) def get_subscriptions(self, match): """Generates tuples of (distributor, sid, authenticated, email). `match` should is passed the string value of the setting and should return true or false depending on whether the subscription matches. Tuples are suitable for yielding from IAnnouncementSubscriber's subscriptions method. """ db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT sid, authenticated, value FROM session_attribute WHERE name=%s """, (self._attr_name(),)) for result in cursor.fetchall(): dists, val = decode(result[2]) for dist in dists: if match(dist, val): authenticated = istrue(result[1]) yield (dist, result[0], authenticated, None) def _attr_name(self): return "sub_%s"%(self.name) class BoolSubscriptionSetting(object): """Encapsulate boolean user subscription and filter settings. Subscription settings have default values, usually trac properties, and user session attribute settings. If the user setting is unset, then the default value will be returned. """ def __init__(self, env, name, default_value=None, default_dists=('email',)): self.default = { 'value': default_value, 'dists': default_dists } self.env = env self.name = name def set_user_setting(self, session, value=None, dists=('email',), save=True): """Sets session attribute to 1 or 0.""" if istrue(value): session[self._attr_name()] = encode(dists, '1') else: session[self._attr_name()] = encode(dists, '0') if save: session.save() def get_user_setting(self, sid): """Returns tuple of (value, authenticated). Value is always True or None. This will work with Genshi template checkbox logic. """ db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT value, authenticated FROM session_attribute WHERE sid=%s AND name=%s """, (sid, self._attr_name())) row = cursor.fetchone() if row: dists, v = decode(row[0]) value = istrue(v) authenticated = istrue(row[1]) else: dists = self.default['dists'] value = istrue(self.default['value']) authenticated = False # We use None here so that Genshi templates check their checkboxes # properly and without confusion. return (dists, value and True or None, authenticated) def get_subscriptions(self): """Generates tuples of (distributor, sid, authenticated, email). Tuples are suitable for yielding from IAnnouncementSubscriber's subscriptions method. """ db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(""" SELECT sid, authenticated, value FROM session_attribute WHERE name=%s """, (self._attr_name(),)) for result in cursor.fetchall(): dists, val = decode(result[2]) for dist in dists: if istrue(val): authenticated = istrue(result[1]) yield (dist, result[0], authenticated, None) def _attr_name(self): return "sub_%s"%(self.name) trac-announcer-0.12.1+r10986/trunk/announcer/util/mail.py0000644000175000017500000001027311660462652021132 0ustar wmbwmb# -*- coding: utf-8 -*- # # 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: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- from base64 import b32encode, b32decode try: from email.header import Header except: from email.Header import Header from trac.util.text import to_unicode try: # Method only available in Trac 0.11.3 or higher. from trac.util.text import exception_to_unicode except: def exception_to_unicode(e, traceback=False): """Convert an `Exception` to an `unicode` object. In addition to `to_unicode`, this representation of the exception also contains the class name and optionally the traceback. This replicates the Trac core method for backwards-compatibility. """ message = '%s: %s' % (e.__class__.__name__, to_unicode(e)) if traceback: from trac.util import get_last_traceback traceback_only = get_last_traceback().split('\n')[:-2] message = '\n%s\n%s' % (to_unicode('\n'.join(traceback_only)), message) return message MAXHEADERLEN = 76 def next_decorator(event, message, decorates): """ Helper method for IAnnouncerEmailDecorators. Call the next decorator or return. """ if decorates and len(decorates) > 0: next = decorates.pop() return next.decorate_message(event, message, decorates) def set_header(message, key, value, charset=None): if not charset: charset = message.get_charset() or 'ascii' # Don't encode pure ASCII headers. try: value = Header(value, 'ascii', MAXHEADERLEN-(len(key)+2)) except: value = Header(value, charset, MAXHEADERLEN-(len(key)+2)) if message.has_key(key): message.replace_header(key, value) else: message[key] = value return message def uid_encode(projurl, realm, target): """ Unique identifier used to track resources in relation to emails. Returns a base64 encode UID string. projurl included to avoid Message-ID collisions. Returns a base64 encode UID string. Set project_url in trac.ini for proper results. """ if hasattr(target, 'id'): id = str(target.id) elif hasattr(target, 'name'): id = target.name else: id = str(target) uid = ','.join((projurl, realm, id)) return b32encode(uid.encode('utf8')) def uid_decode(encoded_uid): """ Returns a tuple of projurl, realm, id and change_num. """ uid = b32decode(encoded_uid).decode('utf8') return uid.split(',') def msgid(uid, host='localhost'): """ Formatted id for email headers. ie. """ return "<%s@%s>"%(uid, host) trac-announcer-0.12.1+r10986/trunk/setup.cfg0000644000175000017500000000077411665651122016514 0ustar wmbwmb[egg_info] tag_build = dev tag_svn_revision = true [extract_messages] add_comments = TRANSLATOR: msgid_bugs_address = hoff.st@web.de output_file = announcer/locale/messages.pot keywords = _ ngettext:1,2 N_ tag_ width = 72 [init_catalog] input_file = announcer/locale/messages.pot output_dir = announcer/locale domain = announcer [compile_catalog] directory = announcer/locale domain = announcer [update_catalog] input_file = announcer/locale/messages.pot output_dir = announcer/locale domain = announcer trac-announcer-0.12.1+r10986/trunk/setup.py0000755000175000017500000001066011660505706016404 0ustar wmbwmb# -*- coding: utf-8 -*- # # Copyright (c) 2008, Stephen Hansen # Copyright (c) 2009, Robert Corsaro # Copyright (c) 2010,2011 Steffen Hoffmann # # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of the 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 COPYRIGHT HOLDERS AND CONTRIBUTORS # "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 COPYRIGHT OWNER OR # CONTRIBUTORS 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. # ---------------------------------------------------------------------------- # Maintained by doki_pen from setuptools import find_packages, setup extra = {} try: from trac.util.dist import get_l10n_cmdclass cmdclass = get_l10n_cmdclass() if cmdclass: extra['cmdclass'] = cmdclass extractors = [ ('**.py', 'python', None), ('**/templates/**.html', 'genshi', None), ('**/templates/**.txt', 'genshi', { 'template_class': 'genshi.template:TextTemplate' }), ] extra['message_extractors'] = {'announcer': extractors} # i18n is implemented to be optional here. except ImportError: pass setup( name = 'TracAnnouncer', version = '0.12.1', author = 'Robert Corsaro', author_email = 'rcorsaro@gmail.com', description = 'Customizable notification system', license = """ Copyright (c) 2008, Stephen Hansen. Copyright (c) 2009, Robert Corsaro. All rights reserved. Released under the 3-clause BSD license. """, url = 'http://www.trac-hacks.org/wiki/AnnouncerPlugin', packages = find_packages(exclude=['*.tests*']), package_data = { 'announcer': [ 'htdocs/*.*', 'htdocs/css/*.*', 'locale/*/LC_MESSAGES/*.mo', 'locale/.placeholder', 'templates/*.html', 'templates/*.txt' ] }, install_requires = ['Genshi >= 0.5', 'Trac >= 0.11'], extras_require={ 'Babel': 'Babel>= 0.9.5', 'Trac': 'Trac >= 0.12', 'acct_mgr': 'TracAccountManager', 'bitten': 'Bitten', 'fullblog': 'TracFullBlogPlugin', 'xmpp': 'xmpppy', }, entry_points = { 'trac.plugins': [ 'announcer.api = announcer.api', 'announcer.distributors.mail = announcer.distributors.mail', 'announcer.distributors.xmppd = announcer.distributors.xmppd[xmpp]', 'announcer.email_decorators = announcer.email_decorators', 'announcer.filters = announcer.filters', 'announcer.formatters = announcer.formatters', 'announcer.model = announcer.model', 'announcer.pref = announcer.pref', 'announcer.producers = announcer.producers', 'announcer.resolvers = announcer.resolvers', 'announcer.subscribers = announcer.subscribers', 'announcer.util.mail = announcer.util.mail', 'announcer.opt.acct_mgr.announce = announcer.opt.acct_mgr.announce[acct_mgr]', 'announcer.opt.bitten.announce = announcer.opt.bitten.announce[bitten]', 'announcer.opt.fullblog.announce = announcer.opt.fullblog.announce[fullblog]', ] }, test_suite = 'announcer.tests', **extra ) trac-announcer-0.12.1+r10986/trunk/LICENSE0000644000175000017500000000277411336044070015673 0ustar wmbwmbCopyright (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: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * 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. * Neither the name of the Steven Hanson or Robert Corsaro 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 COPYRIGHT HOLDERS AND CONTRIBUTORS "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 COPYRIGHT HOLDER OR CONTRIBUTORS 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. trac-announcer-0.12.1+r10986/trunk/AUTHORS0000644000175000017500000000053511445450744015741 0ustar wmbwmbFounder : ixokai Maintainer : doki_pen@doki-pen.org Contributers: * acamac * davidf@sjsoft.com * doki_pen@doki-pen.org * ebray * hasienda * ixokai * jdio * leorachael * martin_s * mixedpuppy * pipern * rea * rjollos * robrien * spcamp * thomas.moschny@gmx.de If you've been left off this list and you shouldn't have been, email me. trac-announcer-0.12.1+r10986/trunk/COPYING0000644000175000017500000000277411335531124015721 0ustar wmbwmbCopyright (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: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * 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. * Neither the name of the Steven Hanson or Robert Corsaro 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 COPYRIGHT HOLDERS AND CONTRIBUTORS "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 COPYRIGHT HOLDER OR CONTRIBUTORS 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.