source: announcerplugin/trunk/announcer/api.py @ 13968

Last change on this file since 13968 was 13968, checked in by Ryan J Ollos, 10 years ago

1.0dev: Python 2.4 compatibility patch. Refs #9106.

Patch by hasienda.

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