source: tracformsplugin/trunk/tracforms/formdb.py

Last change on this file was 16960, checked in by Ryan J Ollos, 6 years ago

TracForms 0.5dev: Remove debug print statement from r16386

Refs #13319.

File size: 28.2 KB
Line 
1# -*- coding: utf-8 -*-
2
3import re
4import time
5import urlparse
6
7from trac.cache import cached
8from trac.config import BoolOption, ListOption, Option
9from trac.core import implements
10from trac.db import Column, DatabaseManager, Index, Table
11from trac.resource import Resource, resource_exists
12from trac.search.api import search_to_sql
13from trac.web.chrome import Chrome
14
15from api import IFormDBObserver, _
16from compat import json
17from tracdb import DBComponent
18from util import is_number, parse_history, resource_from_page, xml_unescape
19
20__all__ = ['FormDBComponent', 'format_author']
21
22
23class FormDBComponent(DBComponent):
24    """Provides form update methods and schema components."""
25
26    implements(IFormDBObserver)
27
28    apply_schema = True
29
30    parent_blacklist = Option(
31        'forms', 'parent_blacklist', 'wiki:PageTemplates/*,/newticket',
32        doc="""Deaktivate form submission from these (parent) resources.
33            Resources are specified as comma-separated list of paths and/or
34            <realm>:<resource_id> pairs, even with REGEXP pattern supported
35            for resource IDs in the latter specifier type.""")
36
37    show_fullname = BoolOption(
38        'forms', 'show_fullname', False,
39        doc="Display full names instead of usernames if available.")
40
41    show_fullname_pos = ListOption(
42        'forms', 'show_fullname_position', 'macro',
43        doc="""Comma-separated list containing one or more of the possible
44            positional descriptors 'change', 'macro', 'value'.  Default is
45            to show full names in 'macro' content only.
46            """)
47
48    def __init__(self):
49        # Preprocess blacklist configuration option.
50        listd = {}
51        for elem in self.parent_blacklist.split(','):
52            res = elem.split(':', 1)
53            # Result is a Trac resource realm or a special context without
54            #   resource ID, i.e. /newticket
55            if [elem] == res:
56                if listd.get('paths'):
57                    listd['paths'].append(res[0])
58                else:
59                    listd['paths'] = res
60            else:
61                if listd.get(res[0]):
62                    listd[res[0]].append(res[1])
63                else:
64                    listd[res[0]] = [res[1]]
65        self.parent_blacklisted = listd
66
67    # abstract TracForms update methods
68
69    def get_tracform_ids(self, src):
70        """Returns all child forms of resource specified by parent realm and
71        parent id as a list of tuples (form_id and corresponding subcontext).
72        """
73        return [(int(form_id), subcontext)
74                for form_id, subcontext in self.env.db_query("""
75                    SELECT id, subcontext FROM forms
76                    WHERE realm=%s AND resource_id=%s
77                    """, (src[0], src[1]))]
78
79    def get_tracform_meta(self, src):
80        """
81        Returns the meta information about a form based on a form id (int or
82        long) or context (parent realm, parent id, TracForms subcontext).
83        """
84        sql = """
85                SELECT id, realm, resource_id, subcontext, author, time,
86                       keep_history, track_fields
87                FROM forms %s
88                """
89        if not is_number(src):
90            sql %= "WHERE realm=%s AND resource_id=%s AND subcontext=%s"
91        else:
92            sql %= "WHERE id=%s"
93        form_id = None
94        realm = None
95        resource_id = None
96        subcontext = None
97        if not is_number(src):
98            realm, resource_id, subcontext = src
99        else:
100            form_id = src
101            src = tuple([src], )
102        for row in self.env.db_query(sql, src):
103            return row
104        return form_id, realm, resource_id, subcontext, None, None, None, None
105
106    def get_tracform_state(self, src):
107        sql = "SELECT state FROM forms "
108        if not is_number(src):
109            sql += "WHERE realm=%s AND resource_id=%s AND subcontext=%s"
110        else:
111            sql += "WHERE id=%s"
112            src = tuple([src], )
113        for state, in self.env.db_query(sql, src):
114            return state
115
116    def save_tracform_allowed(self, path_or_realm, resource_id):
117        if not resource_id:
118            if path_or_realm not in self.parent_blacklisted.get('paths', []):
119                return True
120        else:
121            if path_or_realm not in self.parent_blacklisted.keys():
122                return True
123            else:
124                for pattern in self.parent_blacklisted[path_or_realm]:
125                    if re.match(pattern, resource_id):
126                        return False
127                return True
128        return False
129
130    def save_tracform(self, src, state, author,
131                      base_version=None, keep_history=False,
132                      track_fields=False):
133        (form_id, realm, resource_id, subcontext, last_updater,
134         last_updated_on, form_keep_history,
135         form_track_fields) = self.get_tracform_meta(src)
136
137        if form_keep_history is not None:
138            keep_history = form_keep_history
139        old_state = form_id and self.get_tracform_state(form_id) or '{}'
140        if form_track_fields is not None:
141            track_fields = form_track_fields
142
143        if base_version is not None:
144            base_version = int(base_version or 0)
145
146        if ((base_version is None and last_updated_on is None) or
147                (base_version == last_updated_on)):
148            if state != old_state and \
149                    self.save_tracform_allowed(realm, resource_id):
150                updated_on = int(time.time())
151                with self.env.db_transaction as db:
152                    if form_id is None:
153                        cursor = db.cursor()
154                        cursor.execute("""
155                            INSERT INTO forms (realm, resource_id, subcontext,
156                                               state, author, time)
157                            VALUES (%s, %s, %s, %s, %s, %s)
158                            """, (realm, resource_id, subcontext, state,
159                                  author, updated_on))
160                        form_id = db.get_last_id(cursor, 'forms')
161                    else:
162                        db("""
163                            UPDATE forms
164                            SET state=%s, author=%s, time=%s
165                            WHERE id=%s
166                            """, (state, author, updated_on, form_id))
167                        if keep_history:
168                            db("""
169                            INSERT INTO forms_history
170                                    (id, time, author, old_state)
171                                    VALUES (%s, %s, %s, %s)
172                            """, (form_id, last_updated_on,
173                                  last_updater, old_state))
174                    if track_fields:
175                        # Break down old version and new version.
176                        old_fields = json.loads(old_state)
177                        new_fields = json.loads(state or '{}')
178                        updated_fields = []
179                        for field, old_value in old_fields.iteritems():
180                            if new_fields.get(field) != old_value:
181                                updated_fields.append(field)
182                        for field in new_fields:
183                            if old_fields.get(field) is None:
184                                updated_fields.append(field)
185                        self.log.debug('UPDATED: ', updated_fields)
186                        for field in updated_fields:
187                            for count, in db("""
188                                    SELECT COUNT(*)
189                                    FROM forms_fields
190                                    WHERE id=%s AND field=%s""", (form_id,
191                                                                  field)):
192                                if count:
193                                    db("""
194                                        UPDATE forms_fields
195                                        SET author=%s, time=%s
196                                        WHERE id=%s AND field=%s
197                                        """, (author, updated_on,
198                                              form_id, field))
199                                else:
200                                    db("""
201                                        INSERT INTO forms_fields
202                                         (id, field, author, time)
203                                        VALUES (%s, %s, %s, %s)
204                                        """, (form_id, field,
205                                              author, updated_on))
206            else:
207                updated_on = last_updated_on
208                author = last_updater
209            return ((form_id, realm, resource_id, subcontext, state,
210                     author, updated_on),
211                    (form_id, realm, resource_id, subcontext, old_state,
212                     last_updater, last_updated_on))
213        else:
214            raise ValueError(_("Conflict"))
215
216    def get_tracform_history(self, src):
217        form_id = src if is_number(src) else self.get_tracform_meta(src)[0]
218        return self.env.db_query("""
219                SELECT author, time, old_state FROM forms_history
220                WHERE id=%s ORDER BY time DESC
221                """, (form_id,))
222
223    def get_tracform_fields(self, src):
224        form_id = src if is_number(src) else self.get_tracform_meta(src)[0]
225        return self.env.db_query("""
226                    SELECT field, author, time FROM forms_fields WHERE id=%s
227                    """, (form_id,))
228
229    def get_tracform_fieldinfo(self, src, field):
230        """Retrieve author and time of last change per field."""
231        form_id = src if is_number(src) else self.get_tracform_meta(src)[0]
232        for row in self.env.db_query("""
233                    SELECT author, time FROM forms_fields
234                    WHERE id=%s AND field=%s
235                    """, (form_id, field)):
236            return row
237        return None, None
238
239    def reset_tracform(self, src, field=None, author=None, step=0):
240        """Delete method for all TracForms db tables.
241
242        Note, that we only delete recorded values and history here, while
243        the form definition (being part of forms parent resource) is retained.
244        Reset of single fields is not implemented, because this would require
245        cumbersome and error prown history rewriting - not worth the hassle.
246        """
247        form_ids = []
248        # identify form_id(s) to reset
249        if is_number(src):
250            form_ids.append(src)
251        elif isinstance(src, tuple) and len(src) == 3:
252            if src[-1] is None:
253                # no subcontext given, reset all forms of the parent resource
254                for form_id in self.get_tracform_ids(src[0], src[1]):
255                    form_ids.append(form_id)
256            else:
257                form_ids.append(self.get_tracform_meta(src)[0])
258        # restore of old values for multiple forms is not meaningful
259        if step == -1 and len(form_ids) == 1:
260            form_id = form_ids[0]
261            now = int(time.time())
262            author, updated_on, old_state =\
263                self.get_tracform_history(form_id)[0] or \
264                (author, now, '{}')
265            if updated_on == now:
266                # no history recorded, so only form values can be reset
267                step = 0
268            else:
269                with self.env.db_transaction as db:
270                    # copy last old_state to current
271                    db("""
272                        UPDATE forms SET author=%s, time=%s, state=%s
273                        WHERE id=%s
274                        """, (author, updated_on, old_state, form_id))
275                    history = []
276                    records = self.get_tracform_history(form_id)
277                    for history_author, history_time, old_state in records:
278                        history.append({'author': history_author,
279                                        'time': history_time,
280                                        'old_state': old_state})
281                    history = parse_history(history, fieldwise=True)
282                    # delete restored history entry
283                    db("""
284                        DELETE FROM forms_history
285                        WHERE id=%s AND time=%s
286                        """, (form_id, updated_on))
287                    # rollback field info changes
288                    for field in history.keys():
289                        changes = history[field]
290                        if len(changes) > 0:
291                            # restore last field info, unconditional by
292                            # intention i.e. to not create entries, if track_
293                            # fields is False
294                            db("""
295                                UPDATE forms_fields
296                                SET author=%s, time=%s
297                                WHERE id=%s AND field=%s
298                                """, (changes[0]['author'],
299                                      changes[0]['time'],
300                                      form_id, field))
301                        else:
302                            # delete current field info
303                            db("""
304                                DELETE FROM forms_fields
305                                WHERE id=%s AND field=%s
306                                """, (form_id, field))
307        if step == 0:
308            with self.env.db_transaction as db:
309                # reset all fields and delete full history
310                for form_id in form_ids:
311                    db("DELETE FROM forms_history WHERE id=%s", (form_id,))
312                    db("DELETE FROM forms_fields WHERE id=%s", (form_id,))
313                    # don't delete basic form reference but save the reset
314                    # as a form change to prevent creation of a new form_id
315                    # for further retention data
316                    db("""
317                        UPDATE forms SET author=%s, time=%s, state=%s
318                        WHERE id=%s
319                        """, (author, int(time.time()), '{}', form_id))
320
321    def search_tracforms(self, terms):
322        """Backend method for TracForms ISearchSource implementation."""
323        with self.env.db_query as db:
324            sql, args = search_to_sql(db, ['resource_id', 'subcontext',
325                                           'author', 'state',
326                                           db.cast('id', 'text')], terms)
327            return self.env.db_query("""
328                SELECT id,realm,resource_id,subcontext,state,author,time
329                FROM forms WHERE %s
330                """ % sql, args)
331
332    def get_known_users(self):
333        # A reference is enough, as long as we ensure strictly read-only
334        # access and short lifetime of the reference. Change later, if needed.
335        # users = copy.deepcopy(self.known_users)
336        users = self.known_users
337        return users
338
339    @cached
340    def known_users(self):
341        """Cached version of trac.env.get_known_users() for TracForms."""
342        return [(username, name, email)
343                for username, name, email in self.env.db_query("""
344                    SELECT DISTINCT s.sid, n.value, e.value
345                    FROM session AS s
346                     LEFT JOIN session_attribute AS n
347                          ON (n.sid=s.sid
348                              AND n.authenticated=1
349                              AND n.name = 'name')
350                     LEFT JOIN session_attribute AS e
351                          ON (e.sid=s.sid
352                              AND e.authenticated=1
353                              AND e.name = 'email')
354                    WHERE s.authenticated=1 ORDER BY s.sid
355                    """)]
356
357    # TracForms schemas
358    # Hint: See older versions of this file for the original SQL statements.
359    #   Most of them have been rewritten to imrove compatibility with Trac.
360
361    # def dbschema_2008_06_14_0000(self, cursor):
362    #    """This was a simple test for the schema base class."""
363
364    def db00(self, env, cursor):
365        """Create the major tables."""
366        tables = [
367            Table('tracform_forms', key='id')[
368                Column('tracform_id', auto_increment=True),
369                Column('context'),
370                Column('state'),
371                Column('updater'),
372                Column('updated_on', type='int')],
373            Table('tracform_history')[
374                Column('tracform_id', type='int'),
375                Column('updater'),
376                Column('updated_on', type='int'),
377                Column('old_states')]
378        ]
379        db_connector = DatabaseManager(env).get_connector()[0]
380        for table in tables:
381            for stmt in db_connector.to_sql(table):
382                cursor.execute(stmt)
383
384    def db01(self, env, cursor):
385        """Create indices for tracform_forms table."""
386        cursor.execute("""
387            CREATE INDEX tracform_forms_context
388                ON tracform_forms(context)
389            """)
390        cursor.execute("""
391            CREATE INDEX tracform_forms_updater
392                ON tracform_forms(updater)
393            """)
394        cursor.execute("""
395            CREATE INDEX tracform_forms_updated_on
396                ON tracform_forms(updated_on)
397            """)
398
399    def db02(self, env, cursor):
400        """This was a modify table, but instead removed the data altogether.
401        """
402
403    def db03(self, env, cursor):
404        """Create indices for tracform_history table."""
405        cursor.execute("""
406            CREATE INDEX tracform_history_tracform_id
407                ON tracform_history(tracform_id)
408            """)
409        # 'DESC' order removed for compatibility with PostgreSQL
410        cursor.execute("""
411            CREATE INDEX tracform_history_updated_on
412                ON tracform_history(updated_on)
413            """)
414        cursor.execute("""
415            CREATE INDEX tracform_history_updater
416                ON tracform_history(updater)
417            """)
418
419    def db04(self, env, cursor):
420        """Recreating updated_on index for tracform_forms to be descending.
421        """
422        # Providing compatibility for PostgreSQL this is now obsolete,
423        # removing misspelled index name creation SQL statement too.
424
425    def db10(self, env, cursor):
426        """Also maintain whether history should be maintained for form."""
427        cursor.execute("""
428            ALTER TABLE tracform_forms
429                ADD keep_history INTEGER
430            """)
431
432    def db11(self, env, cursor):
433        """Make the context a unique index."""
434        if env.config.get('trac', 'database').startswith('mysql'):
435            cursor.execute("""
436                ALTER TABLE tracform_forms DROP INDEX tracform_forms_context
437                """)
438        else:
439            cursor.execute("""
440                DROP INDEX tracform_forms_context
441                """)
442        cursor.execute("""
443            CREATE UNIQUE INDEX tracform_forms_context
444                ON tracform_forms(context)
445            """)
446
447    def db12(self, env, cursor):
448        """Track who changes individual fields."""
449        cursor.execute("""
450            ALTER TABLE tracform_forms
451                ADD track_fields INTEGER
452            """)
453        table = Table('tracform_fields')[
454            Column('tracform_id', type='int'),
455            Column('field'),
456            Column('updater'),
457            Column('updated_on', type='int'),
458            Index(['tracform_id', 'field'], unique=True)
459        ]
460        db_connector = DatabaseManager(env).get_connector()[0]
461        for stmt in db_connector.to_sql(table):
462            cursor.execute(stmt)
463
464    def db13(self, env, cursor):
465        """Convert state serialization type to be more readable.
466
467        Migrate to slicker named major tables and associated indexes too.
468        """
469        table = Table('forms', key='id')[
470            Column('id', auto_increment=True),
471            Column('context'),
472            Column('state'),
473            Column('author'),
474            Column('time', type='int'),
475            Column('keep_history', type='int'),
476            Column('track_fields', type='int'),
477            Index(['context'], unique=True),
478            Index(['author']),
479            Index(['time'])
480        ]
481        db_connector = DatabaseManager(env).get_connector()[0]
482        for stmt in db_connector.to_sql(table):
483            cursor.execute(stmt)
484
485        forms_columns = ('tracform_id', 'context', 'state', 'updater',
486                         'updated_on', 'keep_history', 'track_fields')
487        forms_columns_new = ('id', 'context', 'state', 'author',
488                             'time', 'keep_history', 'track_fields')
489
490        sql = 'SELECT ' + ', '.join(forms_columns) + ' FROM tracform_forms'
491        cursor.execute(sql)
492        forms = []
493        for row in cursor:
494            row = dict(zip(forms_columns_new, row))
495            forms.append(row)
496
497        # convert current states serialization
498        for form in forms:
499            state_new = _url_to_json(form.get('state'))
500            if state_new == '{}':
501                form['state'] = form.get('state')
502            else:
503                form['state'] = state_new
504
505        for form in forms:
506            fields = form.keys()
507            values = form.values()
508            sql = "INSERT INTO forms (" + ", ".join(fields) + \
509                  ") VALUES (" + ", ".join(
510                ["%s" for I in xrange(len(fields))]) \
511                  + ")"
512            cursor.execute(sql, values)
513
514        cursor.execute("""
515            DROP TABLE tracform_forms
516            """)
517        # migrate history table
518        if env.config.get('trac', 'database').startswith('postgres'):
519            cursor.execute("""
520                CREATE TABLE forms_history
521                    AS SELECT
522                         tracform_id AS id, updated_on AS time,
523                         updater AS author, old_states AS old_state
524                    FROM tracform_history
525                """)
526        else:
527            cursor.execute("""
528                CREATE TABLE forms_history
529                    AS SELECT
530                         tracform_id 'id', updated_on 'time',
531                         updater 'author', old_states 'old_state'
532                    FROM tracform_history
533                """)
534
535        sql = 'SELECT id,time,old_state FROM forms_history'
536        cursor.execute(sql)
537        history = []
538        for row in cursor:
539            row = dict(zip(('id', 'time', 'old_state'), row))
540            history.append(row)
541
542        # convert historic states serialization
543        for row in history:
544            old_state_new = _url_to_json(row.get('old_state'))
545            if old_state_new == '{}':
546                row['old_state'] = row.get('old_state')
547            else:
548                row['old_state'] = old_state_new
549
550        for row in history:
551            sql = "UPDATE forms_history SET old_state=%s " + \
552                  "WHERE id=%s AND time=%s"
553            cursor.execute(sql, (row['old_state'], row['id'], row['time']))
554
555        cursor.execute("""
556            CREATE INDEX forms_history_id_idx
557                ON forms_history(id)
558            """)
559        # 'DESC' order removed for compatibility with PostgreSQL
560        cursor.execute("""
561            CREATE INDEX forms_history_time_idx
562                ON forms_history(time)
563            """)
564        cursor.execute("""
565            CREATE INDEX forms_history_author_idx
566                ON forms_history(author)
567            """)
568        cursor.execute("""
569            DROP TABLE tracform_history
570            """)
571        # migrate fields table
572        if env.config.get('trac', 'database').startswith('postgres'):
573            cursor.execute("""
574                CREATE TABLE forms_fields
575                    AS SELECT
576                         tracform_id AS id, field,
577                         updater AS author, updated_on AS time
578                    FROM tracform_fields
579                """)
580        else:
581            cursor.execute("""
582                CREATE TABLE forms_fields
583                    AS SELECT
584                         tracform_id 'id', field,
585                         updater 'author', updated_on 'time'
586                    FROM tracform_fields
587                """)
588        cursor.execute("""
589            CREATE UNIQUE INDEX forms_fields_id_field_idx
590                ON forms_fields(id, field)
591            """)
592        cursor.execute("""
593            DROP TABLE tracform_fields
594            """)
595        # remove old TracForms version entry
596        cursor.execute("""
597            DELETE FROM system WHERE name='TracFormDBComponent:version';
598            """)
599
600    def db14(self, env, cursor):
601        """Split context into proper Trac resource descriptors."""
602        cursor.execute("""
603            CREATE TABLE forms_old
604                AS SELECT *
605                FROM forms
606            """)
607        cursor.execute("""
608            DROP TABLE forms
609            """)
610        table = Table('forms', key='id')[
611            Column('id', auto_increment=True),
612            Column('realm'),
613            Column('resource_id'),
614            Column('subcontext'),
615            Column('state'),
616            Column('author'),
617            Column('time', type='int'),
618            Column('keep_history', type='int'),
619            Column('track_fields', type='int'),
620            Index(['realm', 'resource_id', 'subcontext'], unique=True),
621            Index(['author']),
622            Index(['time'])
623        ]
624        db_connector = DatabaseManager(env).get_connector()[0]
625        for stmt in db_connector.to_sql(table):
626            cursor.execute(stmt)
627
628        forms_columns = ('id', 'context', 'state', 'author',
629                         'time', 'keep_history', 'track_fields')
630
631        sql = 'SELECT ' + ', '.join(forms_columns) + ' FROM forms_old'
632        cursor.execute(sql)
633        forms = []
634        for row in cursor:
635            row = dict(zip(forms_columns, row))
636            # extract realm, resource_id and subcontext from context
637            row['realm'], row['resource_id'], row['subcontext'] = \
638                _context_to_resource(self.env, row.pop('context'))
639            forms.append(row)
640
641        for form in forms:
642            fields = form.keys()
643            values = form.values()
644            sql = "INSERT INTO forms (" + ", ".join(fields) + \
645                  ") VALUES (" + ", ".join(
646                ["%s" for I in xrange(len(fields))]) \
647                  + ")"
648            cursor.execute(sql, values)
649
650        cursor.execute("""
651            DROP TABLE forms_old
652            """)
653
654
655def format_author(env, req, author=None, position='macro'):
656    """Return user properties.
657
658    This is a private method optionally using CacheManager (since Trac 0.12)
659    to reduce costly access to env.get_known_users() .
660    """
661    formdb = FormDBComponent(env)
662    if formdb.show_fullname is True and position in formdb.show_fullname_pos:
663        for username, name, email in formdb.get_known_users():
664            if author == username and name:
665                author = name
666    return Chrome(env).format_author(req, author)
667
668
669def _url_to_json(state_url):
670    """Convert urlencoded state serial to JSON state serial."""
671    state = urlparse.parse_qs(state_url)
672    for name, value in state.iteritems():
673        if isinstance(value, (list, tuple)):
674            for item in value:
675                state[name] = xml_unescape(item)
676        else:
677            state[name] = xml_unescape(value)
678    return json.dumps(state, separators=(',', ':'))
679
680
681def _context_to_resource(env, context):
682    """Find parent realm and resource_id and optional TracForms subcontext.
683
684    Some guesswork is knowingly involved here to finally overcome previous
685    potentially ambiguous contexts by distinct resource parameters.
686    """
687    realm, resource_path = resource_from_page(env, context)
688    subcontext = None
689    if resource_path is not None:
690        # ambiguous: ':' could be part of resource_id or subcontext or
691        # the start of subcontext
692        segments = re.split(':', resource_path)
693        id = ''
694        resource_id = None
695        while len(segments) > 0:
696            id += segments.pop(0)
697            # guess: shortest valid resource_id is parent,
698            # the rest is a TracForms subcontext
699            if resource_exists(env, Resource(realm, id)):
700                resource_id = id
701                subcontext = ':'.join(segments)
702                break
703            id += ':'
704        # valid resource_id in context NOT FOUND
705        if resource_id is None:
706            resource_id = resource_path
707    else:
708        # realm in context NOT FOUND
709        resource_id = context
710        realm = ''
711    return realm, resource_id, subcontext
Note: See TracBrowser for help on using the repository browser.