source: announcerplugin/trunk/announcer/api.py

Last change on this file was 16900, checked in by Ryan J Ollos, 7 years ago

TracAnnouncer 1.2.0dev: Use IEmailAddressResolver from Trac 1.2

Refs #12120.

File size: 22.3 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (c) 2008, Stephen Hansen
4# Copyright (c) 2009, Robert Corsaro
5# Copyright (c) 2010-2012, Steffen Hoffmann
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution.
9#
10
11import time
12
13from announcer import db_default
14from pkg_resources import resource_filename
15from trac.config import ExtensionOption
16from trac.core import Component, ExtensionPoint, Interface, TracError, \
17                      implements
18from trac.db import DatabaseManager
19from trac.env import IEnvironmentSetupParticipant
20
21
22class IAnnouncementProducer(Interface):
23    """Producer converts Trac events from different subsystems, into
24    AnnouncerEvents.
25    """
26
27    def realms():
28        """Returns an iterable that lists all the realms that this producer
29        is capable of producing events for.
30        """
31
32
33class IAnnouncementSubscriber(Interface):
34    """IAnnouncementSubscriber provides an interface where a Plug-In can
35    register realms and categories of subscriptions it is able to provide.
36
37    An IAnnouncementSubscriber component can use any means to determine
38    if a user is interested in hearing about a given event. More then one
39    component can handle the same realms and categories.
40
41    The subscriber must also indicate not just that a user is interested
42    in receiving a particular notice. Again, how it makes that decision is
43    entirely up to a particular implementation."""
44
45    def matches(event):
46        """Returns a list of subscriptions that match the given event.
47        Responses should be yielded as 7 part tuples as follows:
48        (distributor, sid, authenticated, address, format, priority, adverb)
49        The default installation includes email and xmpp distributors.  The
50        default installation includes formats for text/plain and text/html.
51        If an unknown format is return, it will be replaced by a default known
52        format.  Priority is used to resolve conflicting subscriptions for the
53        same user/distribution pair.  adverb is either always or never.
54        """
55
56    def description():
57        """A description of the subscription that shows up in the users
58        preferences.
59        """
60
61    def requires_authentication():
62        """Returns True or False.  If the user is required to be authenticated
63        to create the subscription, then return True.  This applies to things
64        like ticket owner subscriber, since the ticket owner can never be the
65        sid of an unauthenticated user and we have no way to lookup users by
66        email address (as of yet).
67        """
68
69
70class IAnnouncementDefaultSubscriber(Interface):
71    """Default subscriptions that the module will automatically generate.
72    This should only be used in reasonable situations, where users can be
73    determined by the event itself.  For instance, ticket author has a
74    default subscription that is controlled via trac.ini.  This is because
75    we can lookup the ticket author during the event and create a
76    subscription for them.  Default subscriptions should be low priority
77    so that the user can easily override them.
78    """
79
80    def default_subscriptions():
81        """Yields 5 part tuple containing (class, distributor, priority,
82        adverb).  This is used to display default subscriptions in the
83        user UI and can also be used by matches to figure out what
84        default subscriptions it should yield.
85        """
86
87
88class IAnnouncementSubscriptionFilter(Interface):
89    """IAnnouncementSubscriptionFilter provides an interface where a component
90    can filter subscribers from the final distribution list.
91    """
92
93    def filter_subscriptions(event, subscriptions):
94        """Returns a filtered iterator of subscriptions.  This method is called
95        after all get_subscriptions_for_event calls are made to allow
96        components to remove addresses from the distribution list.  This can
97        be used for things like "never notify updater" functionality.
98        """
99
100
101class IAnnouncementFormatter(Interface):
102    """Formatters are responsible for converting an event into a message
103    appropriate for a given transport.
104
105    For transports like 'aim' or 'irc', this may be a short summary of a
106    change. For 'email', it may be a plaintext or html overview of all
107    the changes and perhaps the existing state.
108
109    It's up to a formatter to determine what ends up ultimately being sent
110    to the end-user. It's capable of pulling data out of the target object
111    that wasn't changed, picking and choosing details for whatever reason.
112
113    Since a formatter must be intimately familiar with the realm that
114    originated the event, formatters are tied to specific transport + realm
115    combinations. This means there may be a proliferation of formatters as
116    options expand.
117    """
118
119    def format_styles(transport, realm):
120        """Returns an iterable of styles that this formatter supports for
121        a specified transport and realm.
122
123        Many formatters may simply return a single style and never have more;
124        that's fine. But if its useful to encapsulate code for several similar
125        styles a formatter can handle more then one. For example, 'text/plain'
126        and 'text/html' may be useful variants the same formatter handles.
127
128        Formatters retain the ability to descriminate by transport, but don't
129        need to.
130        """
131
132    def alternative_style_for(transport, realm, style):
133        """Returns an alternative style for the given style if one is
134        available.
135        """
136
137    def format(transport, realm, style, event):
138        """Converts the event into the specified style. If the transport or
139        realm passed into this method are not ones this formatter can handle,
140        it should return silently and without error.
141
142        The exact return type of this method is intentionally undefined. It
143        will be whatever the distributor that it is designed to work with
144        expects.
145        """
146
147
148class IAnnouncementDistributor(Interface):
149    """The Distributor is responsible for actually delivering an event to the
150    desired subscriptions.
151
152    A distributor should attempt to avoid blocking; using subprocesses is
153    preferred to threads.
154
155    Each distributor handles a single transport, and only one distributor
156    in the system should handle that. For example, there should not be
157    two distributors for the 'email' transport.
158    """
159
160    def transports():
161        """Returns an iter of the transport supported."""
162
163    def distribute(transport, recipients, event):
164        """This method is meant to actually distribute the event to the
165        specified recipients, over the specified transport.
166
167        If it is passed a transport it does not support, it should return
168        silently and without error.
169
170        The recipients is a list of (name, address) pairs with either (but not
171        both) being allowed to be None. If name is provided but address isn't,
172        then the distributor should defer to IAnnouncementAddressResolver
173        implementations to determine what the address should be.
174
175        If the name is None but the address is not, then the distributor
176        should rely on the address being correct and use it-- if possible.
177
178        The distributor may initiate as many transactions as are necessecary
179        to deliver a message, but should use as few as possible; for example
180        in the EmailDistributor, if all of the recipients are receiving a
181        plain text form of the message, a single message with many BCC's
182        should be used.
183
184        The distributor is responsible for determining which of the
185        IAnnouncementFormatters should get the privilege of actually turning
186        an event into content. In cases where multiple formatters are capable
187        of converting an event into a message for a given transport, a
188        user preference would be a dandy idea.
189        """
190
191
192class IAnnouncementPreferenceProvider(Interface):
193    """Represents a single 'box' in the Announcements preference panel.
194
195    Any component can always implement IPreferencePanelProvider to get
196    preferences from users, of course. However, considering there may be
197    several components related to the Announcement system, and many may
198    have different preferences for a user to set, that would clutter up
199    the preference interfac quite a bit.
200
201    The IAnnouncementPreferenceProvider allows several boxes to be
202    chained in the same panel to group the preferenecs related to the
203    Announcement System.
204
205    Implementing announcement preference boxes should be essentially
206    identical to implementing entire panels.
207    """
208
209    def get_announcement_preference_boxes(req):
210        """Accepts a request object, and returns an iterable of
211        (name, label) pairs; one for each box that the implementation
212        can generate.
213
214        If a single item is returned, be sure to 'yield' it instead of
215        returning it."""
216
217    def render_announcement_preference_box(req, box):
218        """Accepts a request object, and the name (as from the previous
219        method) of the box that should be rendered.
220
221        Returns a tuple of (template, data) with the template being a
222        filename in a directory provided by an ITemplateProvider which
223        shall be rendered into a single <div> element, when combined
224        with the data member.
225        """
226
227
228class AnnouncementEvent(object):
229    """AnnouncementEvent
230
231    This packages together in a single place all data related to a particular
232    event; notably the realm, category, and the target that represents the
233    initiator of the event.
234
235    In some (rare) cases, the target may be None; in cases where the message
236    is all that matters and there's no possible data you could conceivably
237    get beyond just the message.
238    """
239
240    def __init__(self, realm, category, target, author=""):
241        self.realm = realm
242        self.category = category
243        self.target = target
244        self.author = author
245
246    def get_basic_terms(self):
247        return self.realm, self.category
248
249    def get_session_terms(self, session_id):
250        return tuple()
251
252
253class IAnnouncementSubscriptionResolver(Interface):
254    """Supports new and old style of subscription resolution until new code
255    is complete."""
256
257    def subscriptions(event):
258        """Return all subscriptions as (dist, sid, auth, address, format)
259        priority 1 is highest.  adverb is 'always' or 'never'.
260        """
261
262
263class SubscriptionResolver(Component):
264    """Collect, and resolve subscriptions."""
265
266    implements(IAnnouncementSubscriptionResolver)
267
268    subscribers = ExtensionPoint(IAnnouncementSubscriber)
269
270    def subscriptions(self, event):
271        """Yields all subscriptions for a given event."""
272
273        subscriptions = []
274        for sp in self.subscribers:
275            subscriptions.extend([x for x in sp.matches(event) if x])
276
277        """
278        This logic is meant to generate a list of subscriptions for each
279        distribution method.  The important thing is, that we pick the rule
280        with the highest priority for each (sid, distribution) pair.
281        If it is "never", then the user is dropped from the list,
282        if it is "always", then the user is kept.
283        Only users highest priority rule is used and all others are skipped.
284        """
285        # sort by dist, sid, authenticated, priority
286        subscriptions.sort(key=lambda i: (i[1], i[2], i[3], i[6]))
287
288        resolved_subs = []
289
290        # collect highest priority for each (sid, dist) pair
291        state = {'last': None}
292        for s in subscriptions:
293            if (s[1], s[2], s[3]) == state['last']:
294                continue
295            if s[-1] == 'always':
296                self.log.debug("Adding (%s [%s]) for 'always' on rule (%s) "
297                               "for (%s)", s[2], s[3], s[0], s[1])
298                resolved_subs.append(s[1:6])
299            else:
300                self.log.debug("Ignoring (%s [%s]) for 'never' on rule (%s) "
301                               "for (%s)", s[2], s[3], s[0], s[1])
302
303            # if s[1] is None, then the subscription is for a raw email
304            # address that has been set in some field and we shouldn't skip
305            # the next raw email subscription.  In other words, all raw email
306            # subscriptions should be added.
307            if s[2]:
308                state['last'] = (s[1], s[2], s[3])
309
310        return resolved_subs
311
312
313# Import i18n methods. Fallback to keep Babel optional.
314try:
315    from trac.util.translation import domain_functions
316except ImportError:
317    from genshi.builder import tag as tag_
318    from trac.util.translation import gettext
319
320    _ = gettext
321
322    def N_(text):
323        return text
324
325    def add_domain(a, b, c=None):
326        pass
327else:
328    add_domain, _, N_, tag_ = \
329        domain_functions('announcer', ('add_domain', '_', 'N_', 'tag_'))
330
331
332class AnnouncementSystem(Component):
333    """AnnouncementSystem represents the entry-point into the announcement
334    system, and is also the central controller that handles passing notices
335    around.
336
337    An announcement begins when something-- an announcement provider--
338    constructs an AnnouncementEvent (or subclass) and calls the send method
339    on the AnnouncementSystem.
340
341    Every event is classified by two required fields-- realm and category.
342    In general, the realm corresponds to the realm of a Resource within Trac;
343    ticket, wiki, milestone, and such. This is not a requirement, however.
344    Realms can be anything distinctive-- if you specify novel realms to solve
345    a particular problem, you'll simply also have to specify subscribers and
346    formatters who are able to deal with data in those realms.
347
348    The other classifier is a category that is defined by the providers and
349    has no particular meaning; for the providers that implement the
350    I*ChangeListener interfaces, the categories will often correspond to the
351    kinds of events they receive. For tickets, they would be 'created',
352    'changed' and 'deleted'.
353
354    There is no requirement for an event to have more then realm and category
355    to classify an event, but if more is provided in a subclass that the
356    subscribers can use to pick through events, all power to you.
357    """
358
359    implements(IEnvironmentSetupParticipant)
360
361    subscribers = ExtensionPoint(IAnnouncementSubscriber)
362    subscription_filters = ExtensionPoint(IAnnouncementSubscriptionFilter)
363    subscription_resolvers = ExtensionPoint(IAnnouncementSubscriptionResolver)
364    distributors = ExtensionPoint(IAnnouncementDistributor)
365
366    resolver = ExtensionOption('announcer', 'subscription_resolvers',
367                               IAnnouncementSubscriptionResolver,
368                               'SubscriptionResolver',
369                               """Comma-separated list of subscription resolver components in the
370                               order they will be called.
371                               """)
372
373    def __init__(self):
374        # Bind the 'announcer' catalog to the specified locale directory.
375        locale_dir = resource_filename(__name__, 'locale')
376        add_domain(self.env.path, locale_dir)
377
378    # IEnvironmentSetupParticipant methods
379
380    def environment_created(self):
381        self._upgrade_db()
382
383    def environment_needs_upgrade(self):
384        schema_ver = self.get_schema_version()
385        if schema_ver == db_default.schema_version:
386            return False
387        if schema_ver > db_default.schema_version:
388            raise TracError(_("""A newer plugin version has been installed
389                              before, but downgrading is unsupported."""))
390        self.log.info("TracAnnouncer db schema version is %d, should be %d",
391                      schema_ver, db_default.schema_version)
392        return True
393
394    def upgrade_environment(self):
395        self._upgrade_db()
396
397    # Internal methods
398
399    def get_schema_version(self):
400        """Return the current schema version for this plugin."""
401        with self.env.db_transaction as db:
402            cursor = db.cursor()
403            cursor.execute("""
404                SELECT value
405                  FROM system
406                 WHERE name='announcer_version'
407            """)
408            row = cursor.fetchone()
409            if not (row and int(row[0]) > 5):
410                # Care for pre-announcer-1.0 installations.
411                dburi = self.config.get('trac', 'database')
412                tables = self._get_tables(dburi, cursor)
413                if 'subscription' in tables:
414                    # Version > 2
415                    cursor.execute("SELECT * FROM subscription_attribute")
416                    columns = [col[0] for col in cursor.cursor.description]
417                    if 'authenticated' in columns:
418                        self.log.debug("TracAnnouncer needs to register "
419                                       "schema version")
420                        return 5
421                    if 'realm' in columns:
422                        self.log.debug("TracAnnouncer needs to change a table")
423                        return 4
424                    self.log.debug("TracAnnouncer needs to change tables")
425                    return 3
426                if 'subscriptions' in tables:
427                    cursor.execute("SELECT * FROM subscriptions")
428                    columns = [col[0] for col in cursor.cursor.description]
429                    if 'format' not in columns:
430                        self.log.debug("TracAnnouncer needs to add new tables")
431                        return 2
432                    self.log.debug("TracAnnouncer needs to add/change tables")
433                    return 1
434                # This is a new installation.
435                return 0
436            # The expected outcome for any up-to-date installation.
437            return row and int(row[0]) or 0
438
439    @staticmethod
440    def _get_tables(dburi, cursor):
441        """Code from TracMigratePlugin by Jun Omae (see tracmigrate.admin)."""
442        if dburi.startswith('sqlite:'):
443            sql = """
444                SELECT name
445                  FROM sqlite_master
446                 WHERE type='table'
447                   AND NOT name='sqlite_sequence'
448            """
449        elif dburi.startswith('postgres:'):
450            sql = """
451                SELECT tablename
452                  FROM pg_tables
453                 WHERE schemaname = ANY (current_schemas(false))
454            """
455        elif dburi.startswith('mysql:'):
456            sql = "SHOW TABLES"
457        else:
458            raise TracError('Unsupported database type "%s"'
459                            % dburi.split(':')[0])
460        cursor.execute(sql)
461        return sorted(row[0] for row in cursor)
462
463    def _upgrade_db(self):
464        """Each schema version should have its own upgrade module, named
465        upgrades/dbN.py, where 'N' is the version number (int).
466        """
467        db_mgr = DatabaseManager(self.env)
468        schema_ver = self.get_schema_version()
469
470        with self.env.db_transaction as db:
471            cursor = db.cursor()
472            # Is this a new installation?
473            if not schema_ver:
474                # Perform a single-step install: Create plugin schema and
475                # insert default data into the database.
476                connector = db_mgr.get_connector()[0]
477                for table in db_default.schema:
478                    for stmt in connector.to_sql(table):
479                        cursor.execute(stmt)
480                for table, cols, vals in db_default.get_data(db):
481                    cursor.executemany("""
482                        INSERT INTO %s (%s) VALUES (%s)
483                        """ % (table, ','.join(cols),
484                               ','.join('%s' for c in cols)), vals)
485            else:
486                # Perform incremental upgrades.
487                for i in range(schema_ver + 1, db_default.schema_version + 1):
488                    name = 'db%i' % i
489                    try:
490                        upgrades = __import__('announcer.upgrades', globals(),
491                                              locals(), [name])
492                        script = getattr(upgrades, name)
493                    except AttributeError:
494                        raise TracError(_("""
495                            No upgrade module for version %(num)i
496                            (%(version)s.py)
497                            """, num=i, version=name))
498                    script.do_upgrade(self.env, i, cursor)
499            cursor.execute("""
500                UPDATE system
501                   SET value=%s
502                 WHERE name='announcer_version'
503                """, (db_default.schema_version,))
504            self.log.info("Upgraded TracAnnouncer db schema from version "
505                          "%d to %d", schema_ver, db_default.schema_version)
506
507    # AnnouncementSystem core methods
508
509    def send(self, evt):
510        start = time.time()
511        self._real_send(evt)
512        stop = time.time()
513        self.log.debug("AnnouncementSystem sent event in %s seconds.",
514                       round(stop - start, 2))
515
516    def _real_send(self, evt):
517        """Accepts a single AnnouncementEvent instance (or subclass), and
518        returns nothing.
519
520        There is no way (intentionally) to determine what the
521        AnnouncementSystem did with a particular event besides looking through
522        the debug logs.
523        """
524        try:
525            subscriptions = self.resolver.subscriptions(evt)
526            for sf in self.subscription_filters:
527                subscriptions = \
528                    set(sf.filter_subscriptions(evt, subscriptions))
529
530            self.log.debug("AnnouncementSystem has found the following "
531                           "subscriptions: %s",
532                           ', '.join('[%s(%s) via %s]'
533                                     % (s[1] or s[3],
534                                        s[2] if 'authenticated'
535                                        else 'not authenticated',
536                                        s[0])
537                                     for s in subscriptions))
538            packages = {}
539            for transport, sid, authenticated, address, subs_format \
540                    in subscriptions:
541                if transport not in packages:
542                    packages[transport] = set()
543                packages[transport].add((sid, authenticated, address))
544            for distributor in self.distributors:
545                for transport in distributor.transports():
546                    if transport in packages:
547                        distributor.distribute(transport, packages[transport],
548                                               evt)
549        except Exception:
550            self.log.error("AnnouncementSystem failed.", exc_info=True)
Note: See TracBrowser for help on using the repository browser.