source: TicketModerator/trunk/ticketmoderator/api.py @ 2586

Revision 2586, 33.8 KB checked in by jdsiiro, 3 years ago (diff)

TicketModerator: fixing handling of unicode text in ticket fields (patch
from #3891).

Line 
1# TicketModerator: A ticket moderation plugin for Trac
2# Copyright (c) 2008 Sandia Corporation.
3# All rights resserved.
4#
5# TicketModerator is part of the FAST software project, see
6#   https://software.sandia.gov/trac/fast
7#
8# This software is distributed under the BSD License; for the full text
9# of the license, see the LICENSE.txt file included with this
10# distribution.  Under the terms of Contract DE-AC04-94AL85000, there is
11# a non-exclusive license for use of this work by or on behalf of the
12# U.S. Government.
13#
14# Created by John D. Siirola <jdsiiro@sandia.gov>
15# Sandia National Laboratories
16# 18 December 2008
17
18import cPickle
19import os
20import re
21
22from cgi import FieldStorage as cgi_FieldStorage
23from datetime import datetime
24from pkg_resources import resource_filename
25
26from genshi import Markup
27
28from trac.attachment import Attachment, AttachmentModule, \
29    IAttachmentManipulator, ILegacyAttachmentPolicyDelegate
30from trac.core import *
31from trac.config import *
32from trac.db.schema import Table, Column, Index
33from trac.env import IEnvironmentSetupParticipant
34from trac.perm import PermissionSystem, IPermissionRequestor, IPermissionPolicy
35from trac.resource import ResourceNotFound, Resource
36from trac.ticket.api import ITicketManipulator
37from trac.ticket.model import Ticket
38from trac.ticket.notification import TicketNotifyEmail
39from trac.util import get_reporter_id
40from trac.util.datefmt import utc, to_timestamp
41from trac.util.text import pretty_size, to_unicode
42from trac.util.translation import _
43from trac.web import IRequestFilter
44from trac.web.chrome import add_warning, add_notice, Chrome, ITemplateProvider
45
46from ticketmoderator.notification import ModeratorNofityEmail
47
48schema = [
49    # Pending tickets
50    Table('moderator_tickets', key='id')[
51        Column('id', auto_increment=True), # moderation ID
52        Column('ip'),                      # submitter IP address
53        Column('ticket', type='int'),      # ticket ID
54        Column('time', type='int'),        # original submission time
55        Column('status'),        # submission status (pending, reject, approve)
56        Column('moderator'),     # assigned moderator / resolution owner
57        Column('resolved', type='int'),    # resolution time
58        Column('accepted_id'),   # created ticket / comment id, if accepted
59        Column('author'),        # submission author
60        Column('action'),        # submission ticket action
61        Column('args'),          # submission session req.args
62        Column('new_fields'),    # current submission ticket fields
63        Column('orig_fields'),   # original value for any changed ticket fields
64        ],
65]
66
67def to_sql(env, table):
68    """ Convenience function to get the to_sql for the active connector."""
69    from trac.db.api import DatabaseManager
70    dm = env.components[DatabaseManager]
71    dc = dm._get_connector()[0]
72    return dc.to_sql(table)
73
74# We need to make sure that all the field values are picklable.  Trac is
75# not terribly good about making their objects picklable, so we need to
76# go through the fields and look for values that we know cause problems:
77#   - Trac's TZ management is not picklable, so convert all
78#     datetime objects to naive UTC
79#   - Trac's trac.util.text.empty is not picklable: convert to ''
80#   - We want to strip out any attachment FieldSets (that is handled
81#     separately), but we cannot delete it entirely or the "I have files
82#     to attach" checkbox will not work
83def pickle_field(fields, compat):
84    saved_fields = {}
85    for f, v in fields.items():
86        if isinstance(v, datetime) and v.tzinfo is not None:
87            saved_fields[f] = v
88            fields[f] = v.astimezone(utc).replace(tzinfo=None)
89        elif v is compat.empty:
90            saved_fields[f] = v
91            fields[f] = ''
92        elif isinstance(v, cgi_FieldStorage):
93            saved_fields[f] = v
94            fields[f] = 'cgi.FieldStorage(!stripped!)'
95        elif isinstance(v, basestring):
96            saved_fields[f] = v
97            fields[f] = v.encode('utf-8', 'replace')
98
99    ans = cPickle.dumps(fields)
100
101    for f, v in saved_fields.iteritems():
102        fields[f] = v
103    return ans
104   
105def unpickle_field(value):
106    # NB: pickled data appears to come back from the DB as a unicode
107    # string: convert to standard string before unpickling.
108    if isinstance(value, unicode):
109        value = value.encode('ascii', 'replace')
110    ans = cPickle.loads(value)
111
112    # Undo the special processing that pickle_field() performs
113    for f, v in ans.iteritems():
114        if isinstance(v, datetime) and v.tzinfo is None:
115            ans[f] = v.replace(tzinfo=utc)
116        elif isinstance(v, str):
117            ans[f] = v.decode('utf-8')
118           
119    return ans
120
121class compatibility:
122    def __init__(self, env):
123        self.env = env
124        if hasattr(env, 'get_read_db'):
125            self.get_read_db = env.get_read_db
126        else:
127            self.get_read_db = env.get_db_cnx
128           
129        if hasattr(Attachment, 'reparent'):
130            self.reparent_attachment = self._reparent_trac12
131        else:
132            self.reparent_attachment = self._reparent_trac11
133           
134        try:
135            from trac.util.text import empty
136            self.empty = empty
137        except:
138            self.empty = ''
139
140           
141           
142    def _reparent_trac11(self, attachment, new_realm, new_id):
143        new_id = unicode(new_id)
144
145        old_realm, old_id = attachment.parent_realm, attachment.parent_id
146        old_path = attachment._get_path()
147       
148        attachment.parent_realm, attachment.parent_id = new_realm, new_id
149        new_path = attachment._get_path()
150               
151        if os.path.exists(new_path):
152            raise TracError(_('Cannot reparent attachment "%(att)s" as '
153                              'it already exists in %(realm)s:%(id)s',
154                              att=attachment.filename, realm=new_realm,
155                              id=new_id))
156
157        db = self.env.get_db_cnx()
158        cursor = db.cursor()
159        cursor.execute("""
160            UPDATE attachment SET type=%s, id=%s
161            WHERE type=%s AND id=%s AND filename=%s
162            """, (new_realm, new_id, old_realm, old_id, attachment.filename))
163        dirname = os.path.dirname(new_path)
164        if not os.path.exists(dirname):
165            os.makedirs(dirname)
166        if os.path.isfile(old_path):
167            try:
168                os.rename(old_path, new_path)
169            except OSError, e:
170                self.env.log.error('Failed to move attachment file %s: %s',
171                                   old_path,
172                                   exception_to_unicode(e, traceback=True))
173                raise TracError(_('Could not reparent attachment %(name)s',
174                                  name=attachment.filename))
175        db.commit()
176       
177        attachment.resource = Resource(new_realm, new_id). \
178                              child('attachment', attachment.filename)
179               
180        self.env.log.info('Attachment reparented: %s' % attachment.title)
181       
182        for listener in AttachmentModule(self.env).change_listeners:
183            listener.attachment_added(attachment)
184
185    def _reparent_trac12(self, attachment, new_realm, new_id):
186        attachment.reparent(new_realm, new_id)
187
188
189class TicketModerator(Component):
190    """Central functionality for the TicketModerator plugin."""
191
192    implements( IEnvironmentSetupParticipant, IPermissionRequestor,
193                ITemplateProvider, ITicketManipulator, IAttachmentManipulator,
194                IRequestFilter, ILegacyAttachmentPolicyDelegate,
195                IPermissionPolicy )
196
197    default_moderator = Option('ticketmoderator', 'default_moderator', '',
198          """The default moderator for ticket modifications that require
199          moderation, used if the ticket owner and default component
200          owner are not Moderators (''since 0.2'').""")
201
202    show_moderator_email = BoolOption('ticketmoderator',
203                                      'show_moderator_email', False,
204          """Publish the assigned Moderator's e-mail address as part of
205          the response to the submitter (''since 0.2'').""")
206
207    subject_template = Option('ticketmoderator', 'subject_template',
208          "$prefix [Moderate] ${new and 'New Ticket' or 'Comment'}: $summary",
209          """A Genshi text template snippet used to get the notification
210          subject (''since 0.2'').""")
211
212    review_promise = Option('ticketmoderator', 'review_promise',
213          "We make every effort to review submissions within one working day.",
214          """Text displayed as part of the default confirmation indicating
215          the moderators expected response time (''since 0.6'').""")
216
217    unmoderated_attachments = BoolOption('ticketmoderator',
218                                         'unmoderated_attachments', False,
219          """Allow users requiring moderation to submit ticket
220          attachments without moderation (''since 0.3'';
221          deprecated in 0.4, use `MODERATOR_PASS_ATTACH` permission).""")
222
223    # Get the list of all ticket manipulators (of which I am one)
224    ticket_manipulators = ExtensionPoint(ITicketManipulator)
225
226    # Get the list of all attachment manipulators (of which I am one)
227    attachment_manipulators = ExtensionPoint(IAttachmentManipulator)
228
229    # Flag to prevent me from running when I am called
230    currently_validating = False
231
232    # The current version for our portion of the database
233    db_version = 7
234
235    submission_id_re = re.compile(r'/attachment/moderator/(\d+)')
236
237    def __init__(self):
238        self.compat = compatibility(self.env)
239
240    # IEnvironmentSetupParticipant methods
241    def environment_created(self):
242        cursor = db.cursor()
243        self._create_tables(cursor, self.env)
244        cursor.execute( "INSERT INTO system VALUES " + \
245                        "('ticketmoderator_version', %s)",
246                        (self.db_version,) )
247        db.commit()
248
249    def environment_needs_upgrade(self, db):
250        """Called when Trac checks whether the environment needs to be
251        upgraded.  Returns `True` if upgrade is needed, `False` otherwise."""
252        cursor = db.cursor()
253        return self._get_version(cursor) != self.db_version
254
255    def upgrade_environment(self, db):
256        """Actually perform an environment upgrade, but don't commit as that
257        is done by the common upgrade procedure when all plugins are done."""
258        cursor = db.cursor()
259        ver = self._get_version(cursor)
260        if ver == self.db_version:
261            return
262       
263        if ver == 0:
264            self._create_tables(cursor, self.env)
265            cursor.execute( "INSERT INTO system VALUES " + \
266                            "('ticketmoderator_version', %s)",
267                            (self.db_version,) )
268            return
269
270        # do database schema upgrades here...
271        if ver == 1:
272            # rename the 'old' column -> 'orig'
273            fields = [ 'id', 'ticket', 'time', 'status', 'moderator',
274                       'resolved', 'accepted_id', 'author', 'action',
275                       'args', 'fields', 'old as orig' ]
276            cursor.execute("CREATE TABLE moderator_tickets_NEW AS SELECT " + \
277                           "%s FROM moderator_tickets" % ', '.join(fields))
278            cursor.execute("DROP TABLE moderator_tickets")
279            cursor.execute("ALTER TABLE moderator_tickets_NEW RENAME TO " + \
280                           "moderator_tickets")
281            ver += 1
282           
283        if ver == 2:
284            # add a new column (ip)
285            cursor.execute("ALTER TABLE moderator_tickets ADD COLUMN ip text")
286            ver += 1
287
288        table_v5 = Table('moderator_tickets', key='id')[
289            Column('id', auto_increment=True),
290            Column('ip'),
291            Column('ticket', type='int'),
292            Column('time', type='int'),
293            Column('status'),
294            Column('moderator'),
295            Column('resolved', type='int'),
296            Column('accepted_id'),
297            Column('author'),
298            Column('action'),
299            Column('args'),
300            Column('fields'),
301            Column('orig'),
302            ]
303
304        if ver == 3:
305            # correcting missing auto_increment field lost in v1->v2 upgrade
306            cursor.execute("ALTER TABLE moderator_tickets RENAME TO " + \
307                           "moderator_tickets_OLD")
308            for stmt in to_sql(self.env, table_v5):
309                cursor.execute(stmt)
310            fields = ', '.join([c.name for c in table_v5.columns])
311            cursor.execute(("INSERT INTO moderator_tickets (%s) SELECT %s" + \
312                            " FROM moderator_tickets_OLD") % (fields, fields))
313            cursor.execute("DROP TABLE moderator_tickets_OLD")
314            ver += 1
315
316        if ver == 4:
317            # rebuild table and insert primary key (not sure if this
318            # upgrade is strictly necessary, but originally v4 did not
319            # specify the 'id' column as a key, which causes havoc in
320            # some non-sqlite backends)
321            cursor.execute("ALTER TABLE moderator_tickets RENAME TO " + \
322                           "moderator_tickets_OLD")
323            for stmt in to_sql(self.env, table_v5):
324                cursor.execute(stmt)
325            fields = ', '.join([c.name for c in table_v5.columns])
326            cursor.execute(("INSERT INTO moderator_tickets (%s) SELECT %s" + \
327                            " FROM moderator_tickets_OLD") % (fields, fields))
328            cursor.execute("DROP TABLE moderator_tickets_OLD")
329            ver += 1
330
331        table_v6 = Table('moderator_tickets', key='id')[
332            Column('id', auto_increment=True),
333            Column('ip'),
334            Column('ticket', type='int'),
335            Column('time', type='int'),
336            Column('status'),
337            Column('moderator'),
338            Column('resolved', type='int'),
339            Column('accepted_id'),
340            Column('author'),
341            Column('action'),
342            Column('args'),
343            Column('new_fields'),
344            Column('orig_fields'),
345            ]
346
347        if ver == 5:
348            # rename the 'fields' column -> 'new_fields'
349            # rename the 'orig' column -> 'orig_fields'
350            cursor.execute("ALTER TABLE moderator_tickets RENAME TO " + \
351                           "moderator_tickets_OLD")
352            for stmt in to_sql(self.env, table_v6):
353                cursor.execute(stmt)
354            old_fields = ', '.join([c.name for c in table_v5.columns])
355            new_fields = ', '.join([c.name for c in table_v6.columns])
356            cursor.execute(("INSERT INTO moderator_tickets (%s) SELECT %s" + \
357                            " FROM moderator_tickets_OLD") %
358                           (new_fields, old_fields))
359            cursor.execute("DROP TABLE moderator_tickets_OLD")
360            ver += 1
361
362        if ver == 6:
363            # pickle the new_fields, orig_fields, and args dictionaries
364            #   (instead of using str() / eval())
365
366            # Bogus version of the cgi.FieldStorage for parsing the args
367            # dictionary
368            class FieldStorage(object):
369                def __init__(self, *args):
370                    pass
371           
372            pickle_fields = ('args','new_fields','orig_fields')
373            cursor.execute("SELECT %s,id FROM moderator_tickets"
374                           % ( ','.join(pickle_fields), ))
375            new_data = []
376            for row in cursor:
377                new_row = []
378                for i in xrange(len(pickle_fields)):
379                    if row[i] is None:
380                        new_row.append(row[i])
381                        continue
382                    tmp = re.sub( '<ticketmoderator.web_ui.FieldStorage[^>]*>',
383                                  "'yes'", row[i] )
384                    tmp = re.sub( 'tzinfo=<FixedOffset "UTC" 0:00:00>',
385                                  'tzinfo=utc', tmp )
386                    tmp = re.sub( 'datetime\.datetime', 'datetime', tmp )
387                    if re.match('tzinfo=<', tmp):
388                        raise TracError("Database upgrade: Moderated ticket "
389                                        "information contains unrecognized "
390                                        "time zone information")
391                    try:
392                        data = eval(tmp)
393                        if type(data.get('attachment', None)) is FieldStorage:
394                            data['attachment'] = 'yes'
395                        new_row.append(pickle_field(data, self.compat))
396                    except Exception, e:
397                        raise TracError("Database upgrade: pickling moderated "
398                                        + "ticket information failed for "
399                                        + "submission id %s field %s: " %
400                                        (row[-1], pickle_fields[i]) + str(e))
401
402                new_row.append(row[-1])
403                new_data.append(tuple(new_row))
404            for row in new_data:
405                cursor.execute("UPDATE moderator_tickets SET "
406                               "args=%s, new_fields=%s, orig_fields=%s "
407                               "WHERE id=%s", row)
408            ver += 1
409
410        # Record the current version of the db environment
411        cursor.execute( "UPDATE system SET value=%s WHERE "
412                        "name='ticketmoderator_version'", (ver,) )
413        if ver != self.db_version:
414            raise TracError("TicketModerator failed to fully "
415                            "upgrade environment.")
416
417
418    def _get_version(self, cursor):
419        try:
420            sql = "SELECT value FROM system WHERE name=" + \
421                "'ticketmoderator_version'"
422            cursor.execute(sql)
423            return int(cursor.fetchone()[0] or 0)
424        except:
425            return 0
426
427    def _create_tables(self, cursor, env):
428        """ Creates the basic tables as defined by schema.
429        using the active database connector. """
430        for table in schema:
431            for stmt in to_sql(env, table):
432                cursor.execute(stmt)
433
434
435    # IPermissionRequestor methods
436    def get_permission_actions(self):
437        # MODERATOR_PASS_CREATE = new tickets w/out moderation
438        # MODERATOR_PASS_MODIFY = new ticket comments w/out moderation
439        # MODERATOR_PASS_ATTACH = new ticket attachments w/out moderation
440        # MODERATOR_UNMODERATED = bypass any need for moderation
441        # MODERATOR_MODERATOR   = can approve/reject posts pending moderation
442        return [ 'MODERATOR_PASS_CREATE',
443                 'MODERATOR_PASS_MODIFY',
444                 'MODERATOR_PASS_ATTACH',
445                 ('MODERATOR_UNMODERATED', ['MODERATOR_PASS_CREATE',
446                                            'MODERATOR_PASS_MODIFY',
447                                            'MODERATOR_PASS_ATTACH', ]),
448                 'MODERATOR_MODERATOR' ]
449
450
451    # ITemplateProvider methods
452    def get_htdocs_dirs(self):
453        """Return the absolute path of a directory containing additional
454        static resources (such as images, style sheets, etc).
455        """
456        return []
457        #return [('ticketmoderator', resource_filename(__name__, 'htdocs'))]
458
459    def get_templates_dirs(self):
460        """Return the absolute path of the directory containing the provided
461        Genshi templates.
462        """
463        return [resource_filename(__name__, 'templates')]
464
465
466    # IRequestFilter
467    def pre_process_request(self, req, handler):
468        return handler
469   
470    def post_process_request(self, req, template, data, content_type):
471        if req.path_info.startswith('/ticket/'):
472            if req.perm.has_permission('TICKET_APPEND') and \
473                    not req.perm.has_permission('MODERATOR_PASS_MODIFY') and \
474                    req.args.get('action', None) not in ['diff']:
475                self._add_notice(req, 'Comments')
476        elif req.path_info == '/newticket':
477            if not req.perm.has_permission('MODERATOR_PASS_CREATE'):
478                self._add_notice(req, 'New tickets')
479        elif req.path_info.startswith('/attachment/ticket/'):
480            if not req.perm.has_permission('MODERATOR_PASS_ATTACH'):
481                self._add_notice(req, 'Attachments')
482        elif req.path_info.startswith('/attachment/moderator/') and \
483                 req.args.get('action','') != 'new':
484            if not req.perm.has_permission('MODERATOR_PASS_ATTACH'):
485                id = self.submission_id_re.match(req.path_info)
486                more = '';
487                if id and id.group(1) and req.perm.has_permission \
488                       ('ATTACHMENT_CREATE',
489                        Resource('moderator',id.group(1)).child('attachment')):
490                    more = '<br/><br/>You may continue to attach additional ' \
491                           'files to this moderation request.'
492                add_notice(req, Markup(
493                    _('Your attachment has been successfully routed for '
494                      'moderation.  For security reasons, you will not be '
495                      'able to view your attachment until the Moderator has '
496                      'approved it.%s' % more)))
497
498        return template, data, content_type
499
500    def _add_notice(self, req, type):
501        auth = ''
502        if req.authname == 'anonymous':
503            auth = '  If you have an account, please '\
504                   '<a href="%(href)s">log in</a> first.'
505           
506        add_notice(req, Markup(
507            _("%s you submit will be routed for moderation.%s" % (type, auth),
508              href=req.href.login())))
509
510
511    # ILegacyAttachmentPolicyDelegate
512    #
513    # This is exactly the same permission requirements as we implement
514    # for our own IPermissionPolicy, but since *most* Trac sites still
515    # maintain the LegacyAttachmentPolicyDelegate permission in their
516    # permission_policies list, duplicating our permission checks here
517    # simplifies life for the end-user (they don't have to add
518    # TicketModerator to their permission_policies trac.ini line)
519    def check_attachment_permission(self, action, username, resource, perm):
520        return self.check_permission(action, username, resource, perm)
521
522    # IPermissionPolicy methods
523    def check_permission(self, action, username, resource, perm):
524        if not resource:
525            return
526        if resource.realm == 'attachment' and \
527                resource.parent.realm == 'moderator':
528            # Special case: we only allow attaching files to pending submissions
529            if action == 'ATTACHMENT_CREATE':
530                if 'TICKET_APPEND' not in perm:
531                    return False
532                db = self.compat.get_read_db()
533                cursor = db.cursor()
534                cursor.execute( "SELECT status FROM moderator_tickets "+
535                                "WHERE id=%s", (resource.parent.id,) )
536                submission = cursor.fetchone()
537                if submission is None or submission[0] != 'pending':
538                    return False
539
540            # Standard map lookup
541            _perm_map = {
542                'ATTACHMENT_CREATE': 'TICKET_APPEND',
543                'ATTACHMENT_VIEW'  : 'MODERATOR_MODERATOR',
544                'ATTACHMENT_DELETE': 'MODERATOR_MODERATOR',
545                }
546            if action in _perm_map:
547                decision = _perm_map[action] in perm
548                if not decision:
549                    self.env.log.debug('TicketModerator denied %s '
550                                       'access to attachment %s.' %
551                                       (username, resource))
552                return decision
553        return
554
555
556    # IAttachmentManipulator methods
557    def prepare_attachment(self, req, attachment, fields):
558        pass
559
560    def validate_attachment(self, req, attachment):
561        # Prevents recursion
562        if self.currently_validating:
563            return []
564
565        if attachment.parent_realm != 'ticket':
566            return []
567        if self.config.getbool('ticketmoderator', 'unmoderated_attachments'):
568            return []
569        if req.perm.has_permission('MODERATOR_PASS_ATTACH'):
570            return []
571
572        # We still need to guarantee that all other plugin validators
573        # pass.  Simply returning if one fails should be OK, as the
574        # validator will be re-called to add the warning by the main
575        # attachment api.  However, just to be absolutely sure, we will
576        # return the validation errors we receive here (and the user may
577        # see the same warning twice).
578        self.currently_validating = True
579        for manipulator in self.attachment_manipulators:
580            errors = []
581            for field, msg in manipulator.validate_attachment(req, attachment):
582                errors.append((field, msg))
583            # Bail out after the first validator that fails
584            if len(errors):
585                self.currently_validating = False
586                return errors
587        self.currently_validating = False
588
589        db = self.env.get_db_cnx()
590
591        # 0) basic validation of the attachment
592        size = 0
593        upload = req.args.get('attachment' or None)
594        if upload is not None:
595            if not hasattr(upload, 'filename') or not upload.filename:
596                raise TracError(_('No file uploaded'))
597            if hasattr(upload.file, 'fileno'):
598                size = os.fstat(upload.file.fileno())[6]
599            else:
600                upload.file.seek(0, 2) # seek to end of file
601                size = upload.file.tell()
602                upload.file.seek(0)
603        if size == 0:
604            raise TracError(_("Can't upload empty file"))
605
606        # 1) identify moderator & write ticket info to a "pending
607        # moderation" table
608        ticket = Ticket(self.env, attachment.parent_id, db)
609        (tn, id) = self._save_submission(req, ticket, db)
610
611        # 2) send e-mail to the appropriate moderator....
612        tn.comment = "Added attachment " + \
613            (upload.filename or "") + " (" + \
614            pretty_size(size) + "): " + \
615            (attachment.description or "")
616        tn.notify(req, ticket, id)
617
618        # 3) short-circuit the remainder of the attachment processing. 
619       
620        # Commandeer the validation process and save the attachment into
621        # the moderator realm (_do_save sends a redirect request, so
622        # this function will not return.
623        new_attachment = Attachment(self.env, 'moderator', str(id))
624        am = self.env.components[AttachmentModule]
625        am._do_save(req, new_attachment)
626
627        # This line should not be reachable, but we will leave it here
628        # in case some future Trac version removes the redirect from
629        # _do_save() [since we are depending on functionality from a
630        # private method).
631        return [(None, 'Attachment Requires Moderation')]
632
633
634    # ITicketManipulator methods
635    def prepare_ticket(self, req, ticket, fields, actions):
636        pass
637
638    def validate_ticket(self, req, ticket):
639        # Prevents recursion
640        if self.currently_validating:
641            return []
642
643        # Do no moderation for previews
644        if req.method == 'POST' and 'preview' in req.args:
645            return []
646
647        # Is moderation required?
648        if ticket.id > 0:
649            if req.perm.has_permission('MODERATOR_PASS_MODIFY'):
650                return []
651        else:
652            if req.perm.has_permission('MODERATOR_PASS_CREATE'):
653                return []
654
655        # If there are already warnings, then silently exit (the ticket
656        #     shouldn't be able to be created).
657        # NB: I may not need the try block, but I can't tell if the
658        #     request will always have a chrome member...
659        try:
660            if len(req.chrome['warnings']) > 0:
661                # In practice, the way you end up with warnings is if
662                # the ticket is failing validation.  However, we might
663                # want to return a bogus warning here to *guarantee*
664                # that the validation step fails and that the ticket is
665                # not "accidentally" committed to the database.  See #3875.
666                return [( None, 'Warnings detected while validating a '
667                          'Moderated Ticket' )]
668        except:
669            pass
670     
671        # We still need to guarantee that all other plugin validators
672        # pass.  Simply returning if one fails should be OK, as the
673        # validator will be re-called to add the warning by the main
674        # ticket api.  However, just to be absolutely sure, we will
675        # return the validation errors we receive here (and the user may
676        # see the same warning twice).
677        self.currently_validating = True
678        for manipulator in self.ticket_manipulators:
679            errors = []
680            for field, message in manipulator.validate_ticket(req, ticket):
681                errors.append((field, message))
682            # Bail out after the first validator that fails
683            if len(errors):
684                self.currently_validating = False
685                return errors
686        self.currently_validating = False
687
688        # At this point, we have a valid ticket / ticket comment that
689        # would normally be written to the database but now requires
690        # moderation.
691
692        db = self.env.get_db_cnx()
693
694        # My plan...
695
696        # 1) identify moderator & write ticket info to a "pending
697        # moderation" table
698        (tn, id) = self._save_submission(req, ticket, db)
699
700        # 2) send e-mail to the appropriate moderator...
701        tn.notify(req, ticket, id)
702
703        # 3) short-circuit the remainder of the ticket processing. 
704
705        if 'attachment' in req.args:
706            # If the user asked to attach files to this ticket, then
707            # redirect to the moderated submission's attachment
708            req.redirect(req.href.attachment('moderator', id, action='new'))
709        else:
710            # Otherwise, send the "your submission has been sent for
711            # moderation" page.  The template should be part of this egg.
712            req.redirect(req.href.moderator('confirm', id))
713           
714        # This is a temporary line to keep the ticket from being written
715        # to the DB.  Once we implement the redirection to the "your
716        # ticket has been routed for moderation" page, this line will no
717        # longer be needed (or reachable).
718        return [(None, 'Ticket Requires Moderation')]
719
720
721    def _save_submission(self, req, ticket, db):
722        # 0) Identify the appropriate moderator...
723        moderators = PermissionSystem(self.env).get_users_with_permission \
724                     ('MODERATOR_MODERATOR')
725        tn = ModeratorNofityEmail(self.env)
726
727        # This loop iterates through moderators until it finds one it
728        # can email to.
729        while True:
730            moderator = self._get_moderator(moderators, ticket, db)
731            moderators.remove(moderator)
732            if tn.set_moderator(moderator):
733                break
734
735
736        # 1) write ticket info to a "pending moderation" table
737        #   - author
738        #   - ticket.id, ticket.values, ticket._old
739        #   - action [we could regenerate it from req.args, but this is easier]
740        #   - req.args
741        #
742        # It is an open question as to if this is enough... the workflow
743        # controller's apply_action_side_effects() method gets handed
744        # (req, ticket, action) -- and the req includes things like the
745        # incoming cookies and the environment... should we try to
746        # pickle those???  It's hard to tell, because I have no examples
747        # other than the DefaultTicketWorkflow, which implements that
748        # method as a "pass."
749        now = datetime.now(utc)
750
751        data={}
752        data['ip'] = req.remote_addr
753        data['time'] = to_timestamp(now)
754        data['status'] = 'pending'
755        data['author'] = get_reporter_id(req, 'author')
756        data['ticket'] = ticket.id
757        data['moderator'] = tn.moderator
758        data['action'] = req.args.get\
759            ('action', ('history' in req.args and 'history' or 'view'))
760
761        # For safety (and out own sanity), pickle the dictionaries
762        for key, field in ( ('new_fields', ticket.values),
763                             ('orig_fields', ticket._old),
764                             ('args', req.args) ):
765            data[key] = pickle_field(field, self.compat)
766        if not ticket.exists:
767            del data['orig_fields']
768
769        cursor = db.cursor()
770        fields = data.keys()
771        cursor.execute("INSERT INTO moderator_tickets (%s) VALUES (%s)"
772                       % (','.join(fields),
773                          ','.join(['%s'] * len(fields))),
774                       [data[name] for name in fields])
775        id = db.get_last_id(cursor, 'moderator_tickets')
776        db.commit()
777        return tn, id
778
779
780    def _get_moderator(self, moderators, ticket, db):
781        if len(moderators) == 0:
782            raise TracError("""
783The TicketModerator plugin is active, but there are no available users
784with MODERATOR permissions.  As a result, we cannot accept your
785submission as there there is no one to route your submission to for
786approval.  Please contact the site administrator to correct this
787problem.""", "TicketModerator configuration error")
788
789        self.env.log.debug("Available moderators: " + ','.join(moderators))
790
791        # Who's the moderator?  We will use the following priority:
792        #   owner > default_owner > default_moderator > *any* moderator
793        if ticket['owner'] and ticket['owner'] in moderators:
794            self.env.log.debug("  ...chose ticket owner: " + ticket['owner'])
795            return ticket['owner']
796        if ticket['component']:
797            try:
798                import trac.ticket.model
799                comp = trac.ticket.model.Component \
800                       (self.env, ticket['component'], db=db)
801                if comp.owner and comp.owner in moderators:
802                    self.env.log.debug("  ...chose component: " + comp.owner)
803                    return comp.owner
804            except ResourceNotFound, e:
805                # No such component exists
806                pass
807        default = self.config.get('ticketmoderator','default_moderator')
808        if default and default in moderators:
809            self.env.log.debug("  ...chose default moderator: " + default)
810            return default
811
812        self.env.log.debug("  ...chose random moderator: " + moderators[0])
813        return moderators[0]
Note: See TracBrowser for help on using the repository browser.