source: peerreviewplugin/trunk/codereview/tracgenericclass/model.py

Last change on this file was 18251, checked in by Cinc-th, 2 years ago

PeerReviewPlugin: fixes for sort() and iteritems().

Refs #14005

File size: 52.1 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2010-2015 Roberto Longobardi
4# Copyright (C) 2016-2021 Cinc
5#
6# This file is part of the Test Manager plugin for Trac.
7#
8# This software is licensed as described in the file COPYING, which
9# you should have received as part of this distribution. The terms
10# are also available at:
11#   https://trac-hacks.org/wiki/TestManagerForTracPluginLicense
12#
13# Author: Roberto Longobardi <otrebor.dev@gmail.com>
14#
15
16import copy
17import re
18try:
19    from functools import cmp_to_key
20except ImportError:
21    pass
22from codereview.tracgenericclass.util import from_any_timestamp, get_string_from_dictionary, \
23    to_any_timestamp, to_list, get_timestamp_db_type, list_available_tables, \
24    db_get_config_property
25from datetime import date, datetime
26from trac.core import Interface, TracError, Component, ExtensionPoint
27from trac.db import Table, Column, Index, DatabaseManager
28from trac.resource import Resource
29from trac.util.datefmt import utc
30from trac.util.translation import _
31from trac.wiki.model import WikiPage
32from trac.wiki.web_ui import WikiModule
33
34
35is_py3 = False
36try:
37    cmp
38except NameError:
39    # Python 3
40    is_py3 = True
41    def cmp(a, b):
42        return (a > b) - (a < b)
43
44
45class IConcreteClassProvider(Interface):
46    """
47    Extension point interface for components willing to implement
48    concrete classes based on this generic class framework.
49    """
50
51    def get_realms(self):
52        """
53        Return class realms provided by the component.
54
55        :rtype: `basestring` generator
56        """
57
58    def get_data_models(self):
59        """
60        Return database tables metadata to allow the framework to create the
61        db schema for the classes provided by the component.
62
63        :rtype: a dictionary, which keys are schema names and values
64                are dictionaries with table metadata, as in the following example:
65                return {'sample_realm':
66                            {'table':
67                                Table('samplerealm', key = ('id', 'otherid'))[
68                                      Column('id'),
69                                      Column('otherid'),
70                                      Column('prop1'),
71                                      Column('prop2'),
72                                      Column('time', type='int64')],
73                             'has_custom': True,
74                             'has_change': True},
75                       }
76        """
77
78    def get_fields(self):
79        """
80        Return the standard fields for classes in all the realms
81        provided by the component.
82
83        :rtype: a dictionary, which keys are realm names and values
84                are arrays of fields metadata, as in the following example:
85                return {'sample_realm': [
86                            {'name': 'id', 'type': 'text', 'label': N_('ID')},
87                            {'name': 'otherid', 'type': 'text', 'label': N_('Other ID')},
88                            {'name': 'prop1', 'type': 'text', 'label': N_('Property 1')},
89                            {'name': 'prop2', 'type': 'text', 'label': N_('Property 2')},
90                            {'name': 'time', 'type': 'time', 'label': N_('Last Change')}
91                       }
92        """
93
94    def get_metadata(self):
95        """
96        Return a set of metadata about the classes in all the realms
97        provided by the component.
98
99        :rtype: a dictionary, which keys are realm names and values
100                are dictionaries of properties.
101
102                Available metadata properties are:
103                    label: A User-friendly name for the objects in this class.
104                    searchable: If present and equal to True indicates the class
105                                partecipates in the Trac search framework, and
106                                must implement the get_search_results() method.
107                    'has_custom': If present and equal to True indicates the class
108                                  supports custom fields.
109                    'has_change': If present and equal to True indicates the class
110                                  supports property change history.
111
112                See the following example:
113                return {'sample_realm': {
114                                'label': "Sample Realm",
115                                'searchable': True
116                            }
117                       }
118        """
119
120    def create_instance(self, realm, props=None):
121        """
122        Return an instance of the specified realm, with the specified properties,
123        or an empty object if props is None.
124
125        :rtype: `AbstractVariableFieldsObject` sub-class instance
126        """
127
128
129    def check_permission(self, req, realm, key_str=None, operation='set', name=None, value=None):
130        """
131        Checks whether the logged in User has permission to perform
132        the specified operation on a resource of the specified realm and
133        optionally with the specified key.
134
135        Raise an exception if authorization is denied.
136
137        Possible operations are:
138            'set': set a property with a value. 'name' and 'value' parameters are required.
139            'search': search for objects of this class.
140
141        :param key_str: optional, the object's key, in the form of a string representing
142                        a dictionary. To get a dictionary back from this string, use the
143                        get_dictionary_from_string() function in the
144                        tracgenericclass.util package.
145        :param operation: optional, the operation to be performed on the object.
146        :param name: optional property name, valid for the 'set' operation type
147        :param value: optional property value, valid for the 'set' operation type
148        """
149
150
151class AbstractVariableFieldsObject(object):
152    """
153    An object which fields are declaratively specified.
154
155    The specific object "type" is specified during construction
156    as the "realm" parameter.
157    This name must also correspond to the database table storing the
158    corresponding objects, and is used as the base name for the
159    custom fields table and the change tracking table (see below).
160
161    Features:
162        * Support for custom fields, specified in the trac.ini file
163          with the same syntax as for custom Ticket fields. Custom
164          fields are kept in a "<schema>_custom" table
165        * Keeping track of all changes to any field, into a separate
166          "<schema>_change" table
167        * A set of callbacks to allow for subclasses to control and
168          perform actions pre and post any operation pertaining the
169          object's lifecycle
170        * Registering listeners, via the IGenericObjectChangeListener
171          interface, for object creation, modification and deletion.
172        * Searching objects matching any set of valorized fields,
173          (even non-key fields), applying the "dynamic record" pattern.
174          See the method list_matching_objects.
175
176    Notes on special fields:
177
178        self.exists : always tells whether the object currently exists
179                      in the database.
180
181        self.resource: points to a Resource, in the trac environment,
182                       corresponding to this object. This is used, for
183                       example, in the workflow implementation.
184
185        self.fields: points to an array of dictionary objects describing
186                     name, label, type and other properties of all of
187                     this object's fields.
188
189        self.metadata: points to a dictionary object describing
190                       further meta-data about this object.
191
192    Note: database tables for specific realms are supposed to already
193          exist, this object does not create any tables.
194          See below the GenericClassModelProvider to see how to
195          declaratively create the required tables.
196    """
197
198    def __init__(self, env, realm='variable_fields_obj', key=None, db=None):
199        """
200        Creates an empty object and also tries to fetches it from the
201        database, if an object with a matching key is found.
202
203        To create an empty, template object, do not specify a key.
204
205        To create an object to be later stored in the database:
206           1) specify a key at contruction time
207           2) set any other property via the obj['fieldname'] = value
208              syntax, including custom fields
209           3) call the insert() method.
210
211        To fetch an existing object from the database:
212           1) specify a key at contruction time: the object will be
213            filled with all of the values form the database
214           2) modify any other property via the obj['fieldname'] = value
215              syntax, including custom fields. This syntax is the only
216              one to keep track of the changes to any field
217           3) call the save_changes() method.
218        """
219        self.env = env
220
221        self.exists = False
222
223        self.realm = realm
224
225        tmmodelprovider = GenericClassModelProvider(self.env)
226
227        self.fields = tmmodelprovider.get_fields(realm)
228        self.time_fields = [f['name'] for f in self.fields
229                            if f['type'] == 'time']
230
231        self.metadata = tmmodelprovider.get_metadata(realm)
232
233        if key is not None and len(key) > 0:
234            self.key = key
235            self.resource = Resource(realm, self.gey_key_string())
236        else:
237            self.resource = None
238
239        if not key or not self._fetch_object(key, db):
240            self._init_defaults(db)
241            self.exists = False
242
243        self.env.log.debug("Exists: %s" % self.exists)
244        self.env.log.debug(self.values)
245
246        self._old = {}
247
248    def get_key_prop_names(self):
249        """
250        Returns an array with the fields representing the identity
251        of this object.
252        The specified fields are assumed being also part of the
253        self.fields array.
254        The specified fields are also assumed to correspond to
255        columns with same name in the database table.
256        """
257        return ['id']
258
259    def get_key_prop_values(self):
260        """
261        Returns an array of values for the properties returned by
262        get_key_prop_names.
263        """
264        result = []
265
266        for f in self.get_key_prop_names():
267            result.append(self.values[f])
268
269        return result
270
271    def get_resource_id(self):
272        """
273        Returns a string representation of the object's identity.
274        Used with the trac Resource API.
275        """
276        return [str(self.values[f])+'|' for f in self.get_key_prop_names()]
277
278    def _init_defaults(self, db=None):
279        """
280        Initializes default values for a new object, based on
281        default values specified in the trac.ini file.
282        """
283        for field in self.fields:
284            default = None
285            if field['name'] in self.protected_fields:
286                # Ignore for new - only change through workflow
287                pass
288            elif not field.get('custom'):
289                default = self.env.config.get(self.realm,
290                                              'default_' + field['name'])
291            else:
292                default = field.get('value')
293                options = field.get('options')
294                if default and options and default not in options:
295                    try:
296                        default = options[int(default)]
297                    except (ValueError, IndexError):
298                        self.env.log.warning('Invalid default value "%s" '
299                                             'for custom field "%s"'
300                                             % (default, field['name']))
301            if default:
302                self.values.setdefault(field['name'], default)
303
304    def _fetch_object(self, key, db=None):
305        self.env.log.debug('>>> _fetch_object')
306
307        # Fetch the standard fields
308        std_fields = [f['name'] for f in self.fields
309                      if not f.get('custom')]
310
311        with self.env.db_query as db:
312            if not self.pre_fetch_object(db):
313                self.env.log.debug('<<< _fetch_object (pre_fetch_object returned False)')
314                return
315
316            cursor = db.cursor()
317
318            sql_where = "WHERE 1=1"
319            for k in self.get_key_prop_names():
320                sql_where += " AND " + k + "=%%s"
321
322            self.env.log.debug("Searching for %s: %s" % (self.realm, sql_where))
323            for k in self.get_key_prop_names():
324                self.env.log.debug("%s = %s" % (k, self[k]))
325
326            cursor.execute(("SELECT %s FROM %s " + sql_where)
327                           % (','.join(std_fields), self.realm), self.get_key_prop_values())
328            row = cursor.fetchone()
329
330            if not row:
331                self.env.log.debug("Object NOT found.")
332                return False
333
334            self.env.log.debug("Object found.")
335
336            self.key = self.build_key_object()
337            for i, field in enumerate(std_fields):
338                value = row[i]
339                if field in self.time_fields:
340                    self.values[field] = from_any_timestamp(value)
341                # Cinc: we don't want to have '0' in text fields
342                # elif value is None:
343                #     self.values[field] = '0'
344                else:
345                    self.values[field] = value
346
347            # Fetch custom fields if available
348            custom_fields = [f['name'] for f in self.fields if f.get('custom')]
349            if len(custom_fields) > 0:
350                cursor.execute(("SELECT name,value FROM %s_custom " + sql_where)
351                               % self.realm, self.get_key_prop_values())
352
353                for name, value in cursor:
354                    if name in custom_fields:
355                        if value is None:
356                            self.values[name] = '0'
357                        else:
358                            self.values[name] = value
359
360            self.post_fetch_object(db)
361
362        self.exists = True
363
364        self.env.log.debug('<<< _fetch_object')
365        return True
366
367    def build_key_object(self):
368        """
369        Builds and returns a dictionary object with the key properties,
370        as returned by get_key_prop_names.
371        """
372        key = None
373        for k in self.get_key_prop_names():
374            if (self.values[k] is not None):
375                if key is None:
376                    key = {}
377
378                key[k] = self.values[k]
379
380        return key
381
382    def gey_key_string(self):
383        """
384        Returns a JSON string with the object key properties
385        """
386        return get_string_from_dictionary(self.key)
387
388    def get_values_as_string(self, props):
389        """
390        Returns a JSON string for the specified object properties.
391
392        :param props: An array of field names.
393        """
394        return get_string_from_dictionary(props, self.values)
395
396    def __getitem__(self, name):
397        """
398        Allows for using the syntax "obj['fieldname']" to access this
399        object's values.
400        """
401        return self.values.get(name)
402
403    def __setitem__(self, name, value):
404        """
405        Allows for using the syntax "obj['fieldname']" to access this
406        object's values.
407        Also logs object modifications so the table <realm>_change
408        can be updated.
409        """
410        if name in self.values:
411            self.env.log.debug("Value before: %s" % self.values[name])
412
413        if name in self.values and self.values[name] == value:
414            return
415        if name not in self._old: # Changed field
416            self.env.log.debug("Changing '%s' field value." % name)
417            self._old[name] = self.values.get(name)
418        elif self._old[name] == value: # Change of field reverted
419            del self._old[name]
420        if value:
421            if isinstance(value, list):
422                raise TracError(_("Multi-values fields not supported yet"))
423            field = [field for field in self.fields if field['name'] == name]
424            if field and field[0].get('type') == 'text':
425                value = value.strip()
426        self.values[name] = value
427        self.env.log.debug("Value after: %s" % self.values[name])
428
429    def get_value_or_default(self, name):
430        """
431        Return the value of a field or the default value if it is undefined
432        """
433        try:
434            value = self.values[name]
435            if value is not '0':
436                return value
437            field = [field for field in self.fields if field['name'] == name]
438            if field:
439                return field[0].get('value', '')
440        except KeyError:
441            pass
442
443    def populate(self, values):
444        """
445        Populate the object with 'suitable' values from a dictionary
446        """
447        field_names = [f['name'] for f in self.fields]
448        for name in [name for name in values.keys() if name in field_names]:
449            self[name] = values.get(name, '')
450
451        # We have to do an extra trick to catch unchecked checkboxes
452        for name in [name for name in values.keys() if name[9:] in field_names
453                     and name.startswith('checkbox_')]:
454            if name[9:] not in values:
455                self[name[9:]] = '0'
456
457    def insert(self, when=None, db=None):
458        """
459        Add object to database.
460
461        Parameters:
462            When: a datetime object to specify a creation date.
463
464        The `db` argument is deprecated
465        """
466        self.env.log.debug('>>> insert')
467
468        assert not self.exists, 'Cannot insert an existing object'
469
470        with self.env.db_transaction as db:
471            if not self.pre_insert(db):
472                self.env.log.debug('<<< insert (pre_insert returned False)')
473                return
474
475            t_when = when
476
477            # Add a timestamp
478            if t_when is None:
479                t_when = datetime.now(utc)
480            self.values['time'] = self.values['changetime'] = t_when
481
482            # Perform type conversions
483            self.env.log.debug('  Performing type conversions')
484            values = dict(self.values)
485            for field in self.time_fields:
486                if field in values:
487                    values[field] = to_any_timestamp(values[field])
488
489            # Insert record
490            self.env.log.debug('  Getting fields')
491            std_fields = []
492            custom_fields = []
493            for f in self.fields:
494                fname = f['name']
495                if fname in self.values:
496                    if f.get('custom'):
497                        custom_fields.append(fname)
498                    else:
499                        std_fields.append(fname)
500
501            self.env.log.debug('  Inserting record')
502            cursor = db.cursor()
503            cursor.execute("INSERT INTO %s (%s) VALUES (%s)"
504                           % (self.realm,
505                              ','.join(std_fields),
506                              ','.join(['%s'] * len(std_fields))),
507                           [values[name] for name in std_fields])
508
509            # Cinc: Make sure object has id in instance variable
510            id_ = db.get_last_id(cursor, self.realm)
511            for item in self.get_key_prop_names():
512                if not self[item]:
513                    self[item] = id_
514            # End Cinc
515
516            # Insert custom fields
517            key_names = self.get_key_prop_names()
518            key_values = self.get_key_prop_values()
519            if len(custom_fields) > 0:
520                self.env.log.debug('  Inserting custom fields')
521                cursor.executemany("""
522                INSERT INTO %s_custom (%s,name,value) VALUES (%s,%%s,%%s)
523                """
524                % (self.realm,
525                   ','.join(key_names),
526                   ','.join(['%s'] * len(key_names))),
527                [to_list((key_values, name, self[name])) for name in custom_fields])
528
529            self.post_insert(db)
530
531        self.env.log.debug('  Setting up internal fields')
532        self.exists = True
533
534        # Cinc: this was skipped during init when only 'id' is given as a key property. 'id' is None for new objects
535        self.key = self.build_key_object()
536        self.resource = Resource(self.realm, self.gey_key_string())
537        self._old = {}
538
539        self.env.log.debug('  Calling listeners')
540        from codereview.tracgenericclass.api import GenericClassSystem
541        for listener in GenericClassSystem(self.env).change_listeners:
542            listener.object_created(self.realm, self)
543
544        self.env.log.debug('<<< insert')
545        return self.key
546
547    def save_changes(self, author=None, comment=None, when=None, db=None, cnum=''):
548        """
549        Store object changes in the database. The object must already exist in
550        the database.  Returns False if there were no changes to save, True
551        otherwise.
552
553        The `db` argument is deprecated
554        """
555        self.env.log.debug('>>> save_changes')
556        assert self.exists, 'Cannot update a new object'
557
558        if not self._old and not comment:
559            return False # Not modified
560
561        if when is None:
562            when = datetime.now(utc)
563        when_ts = to_any_timestamp(when)
564
565        with self.env.db_transaction as db:
566            if not self.pre_save_changes(db):
567                self.env.log.debug('<<< save_changes (pre_save_changes returned False)')
568                return
569
570            cursor = db.cursor()
571
572            # store fields
573            custom_fields = [f['name'] for f in self.fields if f.get('custom')]
574
575            key_names = self.get_key_prop_names()
576            key_values = self.get_key_prop_values()
577            sql_where = '1=1'
578            for k in key_names:
579                sql_where += " AND " + k + "=%%s"
580
581            for name in self._old.keys():
582                if name in custom_fields:
583                    cursor.execute(("""
584                        SELECT * FROM %s_custom
585                        WHERE name=%%s AND
586                        """ + sql_where) % self.realm, to_list((name, key_values)))
587
588                    if cursor.fetchone():
589                        cursor.execute(("""
590                            UPDATE %s_custom SET value=%%s
591                            WHERE name=%%s AND
592                            """ + sql_where) % self.realm, to_list((self[name], name, key_values)))
593                    else:
594                        cursor.execute("""
595                            INSERT INTO %s_custom (%s,name,value)
596                            VALUES (%s,%%s,%%s)
597                            """
598                            % (self.realm,
599                            ','.join(key_names),
600                            ','.join(['%s'] * len(key_names))),
601                            to_list((key_values, name, self[name])))
602                else:
603                    cursor.execute(("""
604                        UPDATE %s SET %s=%%s WHERE
605                        """ + sql_where)
606                        % (self.realm, name),
607                        to_list((self[name], key_values)))
608
609                if self.metadata['has_change']:
610                    cursor.execute(("""
611                        INSERT INTO %s_change
612                            (%s, time,author,field,oldvalue,newvalue)
613                        VALUES (%s, %%s, %%s, %%s, %%s, %%s)
614                        """
615                        % (self.realm,
616                        ','.join(key_names),
617                        ','.join(['%s'] * len(key_names)))),
618                        to_list((key_values, when_ts, author, name,
619                        self._old[name], self[name])))
620
621            self.post_save_changes(db)
622
623        old_values = self._old
624        self._old = {}
625        self.values['changetime'] = when
626
627        from codereview.tracgenericclass.api import GenericClassSystem
628        for listener in GenericClassSystem(self.env).change_listeners:
629            listener.object_changed(self.realm, self, comment, author, old_values)
630
631        self.env.log.debug('<<< save_changes')
632        return True
633
634    def delete(self, db=None):
635        """
636        Delete the object. Also clears the change history and the
637        custom fields.
638
639        The `db` argument is deprecated in favor of `with_transaction()`.
640        """
641
642        self.env.log.debug('>>> delete')
643
644        with self.env.db_transaction as db:
645            if not self.pre_delete(db):
646                self.env.log.debug('<<< delete (pre_delete returned False)')
647                return
648
649            #Attachment.delete_all(self.env, 'ticket', self.id, db)
650
651            cursor = db.cursor()
652
653            key_names = self.get_key_prop_names()
654            key_values = self.get_key_prop_values()
655
656            sql_where = 'WHERE 1=1'
657            for k in key_names:
658                sql_where += " AND " + k + "=%%s"
659
660            self.env.log.debug("Deleting %s: %s" % (self.realm, sql_where))
661            for k in key_names:
662                self.env.log.debug("%s = %s" % (k, self[k]))
663
664            cursor.execute(("DELETE FROM %s " + sql_where)
665                % self.realm, key_values)
666
667            if self.metadata['has_change']:
668                cursor.execute(("DELETE FROM %s_change " + sql_where)
669                    % self.realm, key_values)
670
671            if self.metadata['has_custom']:
672                custom_fields = [f['name'] for f in self.fields if f.get('custom')]
673                if len(custom_fields) > 0:
674                    cursor.execute(("DELETE FROM %s_custom " + sql_where)
675                        % self.realm, key_values)
676
677            self.post_delete(db)
678
679        from codereview.tracgenericclass.api import GenericClassSystem
680        for listener in GenericClassSystem(self.env).change_listeners:
681            listener.object_deleted(self.realm, self)
682
683        self.exists = False
684        self.env.log.debug('<<< delete')
685
686    def save_as(self, new_key, when=None, db=None):
687        """
688        Saves (a copy of) the object with different key.
689        The previous object is not deleted, so if needed it must be
690        deleted explicitly.
691        """
692        self.env.log.debug('>>> save_as')
693
694        with self.env.db_transaction as db:
695            old_key = self.key
696            if not self.pre_save_as(old_key, new_key, db):
697                self.env.log.debug('<<< save_as (pre_save_as returned False)')
698                return
699
700            self.key = new_key
701
702            # Copy values from key into corresponding self.values field
703            for f in self.get_key_prop_names():
704                self.values[f] = new_key[f]
705
706            self.exists = False
707
708            # Create object with new key
709            self.insert(when, db)
710
711            self.post_save_as(old_key, new_key, db)
712
713        self.env.log.debug('<<< save_as')
714
715    def list_change_history(self):
716        """
717        Returns an ordered list of all the changes to standard and
718        custom field, with the old and new value, along with timestamp
719        and author, starting from the most recent.
720        """
721        self.env.log.debug('>>> list_change_history')
722
723        if self.metadata['has_change']:
724            sql_where = "WHERE 1=1"
725            for k in self.get_key_prop_names():
726                sql_where += " AND " + k + "=%%s"
727
728            with self.env.db_query as db:
729                cursor = db.cursor()
730                cursor.execute(("SELECT time,author,field,oldvalue,newvalue FROM %s_change " + sql_where+ " ORDER BY time DESC")
731                               % self.realm, self.get_key_prop_values())
732
733                for ts, author, fname, oldvalue, newvalue in cursor:
734                    yield ts, author, fname, oldvalue, newvalue
735
736        self.env.log.debug('<<< list_change_history')
737
738    def get_non_empty_prop_names(self):
739        """
740        Returns a list of names of the fields that are not None.
741        """
742        std_field_names = []
743        custom_field_names = []
744
745        for field in self.fields:
746            n = field.get('name')
747
748            if n in self.values and self.values[n] is not None:
749                if not field.get('custom'):
750                    std_field_names.append(n)
751                else:
752                    custom_field_names.append(n)
753
754        return std_field_names, custom_field_names
755
756    def get_values(self, prop_names):
757        """
758        Returns a list of the values for the specified properties,
759        in the same order as the property names.
760        """
761        result = []
762
763        for n in prop_names:
764            result.append(self.values[n])
765
766        return result
767
768    def set_values(self, props):
769        """
770        Sets multiple properties into this object.
771
772        Note: this method does not keep history of property changes.
773        """
774        for n in props:
775            self.values[n] = props[n]
776
777    def _get_key_from_row(self, row):
778        """
779        Given a database row with the key properties, builds a
780        dictionary with this object's key.
781        """
782        key = {}
783
784        for i, f in enumerate(self.get_key_prop_names()):
785            key[f] = row[i]
786
787        return key
788
789    def create_instance(self, key):
790        """
791        Subclasses should override this method to create an instance
792        of them with the specified key.
793        """
794        pass
795
796    def list_matching_objects(self, exact_match=True, operator=None):
797        """
798        List the objects that match the current values of this object's
799        fields.
800        To use this method, first create an instance with no key, then
801        fill some of its fields with the values you want to find a
802        match on, then call this method.
803        A collection of objects found in the database matching the
804        fields you had provided values for will be returned.
805        An exact match, i.e. an SQL '=' operator, will be used, unless you
806        specify exact_match=False, in which case the SQL 'LIKE' operator
807        will be used.
808        """
809        self.env.log.debug('>>> list_matching_objects')
810
811        non_empty_std_names, non_empty_custom_names = self.get_non_empty_prop_names()
812
813        non_empty_std_values = self.get_values(non_empty_std_names)
814        non_empty_custom_values = self.get_values(non_empty_custom_names)
815
816        with self.env.db_query as db:
817            self.pre_list_matching_objects(db)
818
819            cursor = db.cursor()
820
821            if operator == None:
822                operator = '='
823                if not exact_match:
824                    operator = ' LIKE '
825
826            sql_where = '1=1'
827            for k in non_empty_std_names:
828                sql_where += " AND " + k + operator + '%%s'
829
830            cursor.execute(('SELECT %s FROM %s WHERE ' + sql_where)
831                           % (','.join(self.get_key_prop_names()), self.realm),
832                           non_empty_std_values)
833
834            for row in cursor:
835                key = self._get_key_from_row(row)
836                self.env.log.debug('<<< list_matching_objects - returning result')
837                yield self.create_instance(key)
838
839        self.env.log.debug('<<< list_matching_objects')
840
841    def get_search_results(self, req, terms, filters):
842        """
843        Called in the context of the trac search API, to return a list
844        of objects of this class matching the specified terms.
845
846        Concrete classes should override this method to perform class-specific
847        searches.
848        """
849        if False:
850            yield None
851
852    # Following is a set of callbacks allowing subclasses to perform
853    # actions around the operations that pertain the lifecycle of
854    # this object.
855
856    def pre_fetch_object(self, db):
857        """
858        Use this method to perform initialization before fetching the
859        object from the database.
860        Return False to prevent the object from being fetched from the
861        database.
862        """
863        return True
864
865    def post_fetch_object(self, db):
866        """
867        Use this method to further fulfill your object after being
868        fetched from the database.
869        """
870        pass
871
872    def pre_insert(self, db):
873        """
874        Use this method to perform work before inserting the
875        object into the database.
876        Return False to prevent the object from being inserted into the
877        database.
878        """
879        return True
880
881    def post_insert(self, db):
882        """
883        Use this method to perform further work after your object has
884        been inserted into the database.
885
886        You should throw an exception inside here if you want the insert
887        to be aborted (i.e. all the work done so far rolled back).
888        """
889        pass
890
891    def pre_save_changes(self, db):
892        """
893        Use this method to perform work before saving the object changes
894        into the database.
895        Return False to prevent the object changes from being saved into
896        the database.
897        """
898        return True
899
900    def post_save_changes(self, db):
901        """
902        Use this method to perform further work after your object
903        changes have been saved into the database.
904        """
905        pass
906
907    def pre_delete(self, db):
908        """
909        Use this method to perform work before deleting the object from
910        the database.
911        Return False to prevent the object from being deleted from the
912        database.
913        """
914        return True
915
916    def post_delete(self, db):
917        """
918        Use this method to perform further work after your object
919        has been deleted from the database.
920        """
921        pass
922
923    def pre_save_as(self, old_key, new_key, db):
924        """
925        Use this method to perform work before saving the object with
926        a different identity into the database.
927        Return False to prevent the object from being saved into the
928        database.
929        """
930        return True
931
932    def post_save_as(self, old_key, new_key, db):
933        """
934        Use this method to perform further work after your object
935        has been saved into the database.
936        """
937        pass
938
939    def pre_list_matching_objects(self, db):
940        """
941        Use this method to perform work before finding matches in the
942        database.
943        Return False to prevent the search.
944        """
945        return True
946
947
948class AbstractWikiPageWrapper(AbstractVariableFieldsObject):
949    """
950    This subclass is a generic object that is based on a wiki page,
951    identified by the 'page_name' field.
952    The wiki page lifecycle is managed along with the normal object's
953    one.
954    """
955    def __init__(self, env, realm='wiki_wrapper_obj', key=None, db=None):
956        AbstractVariableFieldsObject.__init__(self, env, realm, key, db)
957
958    def post_fetch_object(self, db):
959        self.wikipage = WikiPage(self.env, self.values['page_name'])
960
961    def delete(self, del_wiki_page=True, db=None):
962        """
963        Delete the object. Also deletes the Wiki page if so specified in the parameters.
964
965        The `db` argument is deprecated in favor of `with_transaction()`.
966        """
967
968        # The actual wiki page deletion is delayed until pre_delete.
969        self.del_wiki_page = del_wiki_page
970
971        AbstractVariableFieldsObject.delete(self, db)
972
973    def pre_insert(self, db):
974        """
975        Assuming the following fields have been given a value before this call:
976        text, author, remote_addr, values['page_name']
977        """
978
979        wikipage = WikiPage(self.env, self.values['page_name'])
980        wikipage.text = self.text
981        wikipage.save(self.author, '', self.remote_addr)
982
983        self.wikipage = wikipage
984
985        return True
986
987    def pre_save_changes(self, db):
988        """
989        Assuming the following fields have been given a value before this call:
990        text, author, remote_addr, values['page_name']
991        """
992
993        wikipage = WikiPage(self.env, self.values['page_name'])
994        wikipage.text = self.text
995        wikipage.save(self.author, '', self.remote_addr)
996
997        self.wikipage = wikipage
998
999        return True
1000
1001    def pre_delete(self, db):
1002        """
1003        Assuming the following fields have been given a value before this call:
1004        values['page_name']
1005        """
1006
1007        if self.del_wiki_page:
1008            wikipage = WikiPage(self.env, self.values['page_name'])
1009            wikipage.delete()
1010
1011        self.wikipage = None
1012
1013        return True
1014
1015    def get_search_results(self, req, terms, filters):
1016        """
1017        Currently delegates the search to the Wiki module.
1018        """
1019        for result in WikiModule(self.env).get_search_results(req, terms, ('wiki',)):
1020            yield result
1021
1022
1023class GenericClassModelProvider(Component):
1024    """
1025    This class provides a factory for generic classes and derivatives.
1026
1027    The actual data model on the db is created starting from the
1028    SCHEMA declaration below.
1029    For each table, we specify whether to create also a '_custom' and
1030    a '_change' table.
1031
1032    This class also provides the specification of the available fields
1033    for each class, being them standard fields and the custom fields
1034    specified in the trac.ini file.
1035    The custom field specification follows the same syntax as for
1036    Tickets.
1037    Currently, only 'text' type of fields are supported.
1038    """
1039
1040    class_providers = ExtensionPoint(IConcreteClassProvider)
1041
1042    all_fields = {}
1043    all_custom_fields = {}
1044    all_metadata = {}
1045
1046    _class_providers_map = None
1047
1048    # Class providers managament
1049    def get_class_provider(self, realm):
1050        """
1051        Return the component responsible for providing the specified
1052        concrete class implementation.
1053
1054        :param  realm: the realm which uniquely identifies the class.
1055        :return: a `Component` implementing `IConcreteClassProvider`
1056                 or `None`
1057        """
1058        # build a dict of realm keys to IConcreteClassProvider
1059        # implementations
1060        if not self._class_providers_map:
1061            map = {}
1062            for provider in self.class_providers:
1063                for r in provider.get_realms() or []:
1064                    self.env.log.debug("Mapping realm %s to provider %s" % (r, provider))
1065                    map[r] = provider
1066            self._class_providers_map = map
1067
1068        if realm in self._class_providers_map:
1069            return self._class_providers_map.get(realm)
1070        else:
1071            return None
1072
1073    def get_known_realms(self):
1074        """
1075        Return a list of all the realm names of registered
1076        class providers.
1077        """
1078        realms = []
1079        for provider in self.class_providers:
1080            for realm in provider.get_realms() or []:
1081                realms.append(realm)
1082
1083        return realms
1084
1085    # Factory method
1086    def get_object(self, realm, key=None):
1087        """
1088        Returns an instance of the specified class (by means of its
1089        realm name), with the specified key.
1090        """
1091        obj = None
1092
1093        provider = self.get_class_provider(realm)
1094        self.env.log.debug("Provider for realm %s is %s" % (realm, provider))
1095
1096        if provider:
1097            self.env.log.debug("Object key is %s" % key)
1098            return provider.create_instance(realm, key)
1099        else:
1100            self.env.log.debug("Provider for realm %s not found" % realm)
1101            return None
1102
1103    # Permission check
1104    def check_permission(self, req, realm, key_str=None, operation='set', name=None, value=None):
1105        """
1106        Checks whether the logged in User has permission to perform
1107        the specified operation on a resource of the specified realm and
1108        optionally with the specified key.
1109
1110        Raise an exception if authorization is denied.
1111
1112        Actually delegates to the concrete class provider the permission check.
1113
1114        See the IConcreteClassProvider method with the same name for more details
1115        about the available operations and the function parameters.
1116        """
1117
1118        provider = self.get_class_provider(realm)
1119        if provider is not None:
1120            provider.check_permission(req, realm, key_str, operation, name, value)
1121
1122    # Fields management
1123    def reset_fields(self):
1124        """
1125        Invalidate field cache.
1126        """
1127        self.all_fields = {}
1128
1129    def get_fields(self, realm):
1130        self.env.log.debug(">>> get_fields")
1131
1132        if realm not in self.fields():
1133            raise TracError("Requested field information not found for class %s." % realm)
1134
1135        fields = copy.deepcopy(self.fields()[realm])
1136        #label = 'label' # workaround gettext extraction bug
1137        #for f in fields:
1138        #    f[label] = gettext(f[label])
1139
1140        self.env.log.debug("<<< get_fields")
1141        return fields
1142
1143    def get_metadata(self, realm):
1144        tmp_metadata = self.metadata()
1145        if realm in tmp_metadata:
1146            metadata = copy.deepcopy(tmp_metadata[realm])
1147        else:
1148            metadata = None
1149
1150        return metadata
1151
1152    def fields(self, refresh=False):
1153        """Return the list of fields available for every realm."""
1154
1155        if refresh or not self.all_fields:
1156            fields = {}
1157
1158            for provider in self.class_providers:
1159                realm_fields = provider.get_fields()
1160                for realm in realm_fields:
1161                    tmp_fields = realm_fields[realm]
1162
1163                    self.append_custom_fields(tmp_fields, self.get_custom_fields_for_realm(realm))
1164
1165                    fields[realm] = tmp_fields
1166
1167            self.all_fields = fields
1168
1169            # Print debug information about all known realms and fields
1170            for r in self.all_fields:
1171                self.env.log.debug("Fields for realm %s:" % r)
1172                for f in self.all_fields[r]:
1173                    self.env.log.debug("   %s : %s" % (f['name'], f['type']))
1174                    if 'custom' in f:
1175                        self.env.log.debug("     (custom)")
1176
1177        return self.all_fields
1178
1179    def metadata(self):
1180        """Return metadata information about concrete classes."""
1181
1182        if not self.all_metadata:
1183            metadata = {}
1184
1185            for provider in self.class_providers:
1186                realm_metadata = provider.get_metadata()
1187                for realm in realm_metadata:
1188                    metadata[realm] = realm_metadata[realm]
1189
1190            self.all_metadata = metadata
1191
1192        return self.all_metadata
1193
1194    def append_custom_fields(self, fields, custom_fields):
1195        if len(custom_fields) > 0:
1196            for f in custom_fields:
1197                fields.append(f)
1198
1199    def get_custom_fields_for_realm(self, realm):
1200        fields = []
1201
1202        for field in self.get_custom_fields(realm):
1203            field['custom'] = True
1204            fields.append(field)
1205
1206        return fields
1207
1208    def get_custom_fields(self, realm):
1209        return copy.deepcopy(self.custom_fields(realm))
1210
1211    def custom_fields(self, realm, refresh=False):
1212        """Return the list of available custom fields."""
1213
1214        if refresh or not realm in self.all_custom_fields:
1215            fields = []
1216            config = self.config[realm+'-tm_custom']
1217
1218            self.env.log.debug(config.options())
1219
1220            for name in [option for option, value in config.options()
1221                         if '.' not in option]:
1222                if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', name):
1223                    self.log.warning('Invalid name for custom field: "%s" '
1224                                     '(ignoring)', name)
1225                    continue
1226
1227                self.env.log.debug("  Option: %s" % name)
1228
1229                field = {
1230                    'name': name,
1231                    'type': config.get(name),
1232                    'order': config.getint(name + '.order', 0),
1233                    'label': config.get(name + '.label') or name.capitalize(),
1234                    'value': config.get(name + '.value', '')
1235                }
1236                if field['type'] == 'select' or field['type'] == 'radio':
1237                    field['options'] = config.getlist(name + '.options', sep='|')
1238                    if '' in field['options']:
1239                        field['optional'] = True
1240                        field['options'].remove('')
1241                elif field['type'] == 'text':
1242                    field['format'] = config.get(name + '.format', 'plain')
1243                elif field['type'] == 'textarea':
1244                    field['format'] = config.get(name + '.format', 'plain')
1245                    field['cols'] = config.getint(name + '.cols')
1246                    field['rows'] = config.getint(name + '.rows')
1247                fields.append(field)
1248
1249            if is_py3:
1250                fields.sort(key=cmp_to_key(lambda x, y: cmp(x['order'], y['order'])))
1251            else:
1252                fields.sort(lambda x, y: cmp(x['order'], y['order']))
1253            self.all_custom_fields[realm] = fields
1254
1255        return self.all_custom_fields[realm]
1256
1257
1258# Methods to help components create their databases
1259def need_db_create_for_realm(env, realm, realm_metadata, db=None):
1260    """
1261    Call this method from inside your Component IEnvironmentSetupParticipant's
1262    environment_needs_upgrade() function to check whether your Component
1263    using the generic classes needs to create the corresponding database tables.
1264
1265    :param realm_metadata: The db table metadata that, if missing, means that the
1266                database must be created.
1267    """
1268    current_version = _get_installed_version(env, realm, db)
1269    env.log.debug("Current database version for class '%s' is %s", realm, current_version)
1270
1271    if current_version is None or current_version <= 0:
1272        env.log.info("Need to create db tables for class '%s'.", realm)
1273        return True
1274
1275    env.log.debug("No need to create database for class \'%s\'.", realm)
1276
1277    return False
1278
1279def need_db_upgrade_for_realm(env, realm, realm_schema, db=None):
1280    """
1281    Call this method from inside your Component IEnvironmentSetupParticipant's
1282    environment_needs_upgrade() function to check whether your Component
1283    using the generic classes needs to update the corresponding database tables.
1284
1285    :param realm_schema: The db schema definition, as returned by
1286                   the get_data_models() function in the IConcreteClassProvider
1287                   interface.
1288    """
1289
1290    table_metadata = realm_schema['table']
1291    desired_version = realm_schema['version']
1292
1293    current_version = _get_installed_version(env, realm, db)
1294    env.log.debug("Current database version for class '%s' is %s. Desired version is %s", realm, current_version, desired_version)
1295
1296    if current_version is None or current_version < desired_version:
1297        env.log.info("Need to update db tables for class '%s'.", realm)
1298        return True
1299
1300    env.log.debug("No need to update database for class \'%s\'.", realm)
1301
1302    return False
1303
1304def create_db_for_realm(env, realm, realm_schema, db=None):
1305    """
1306    Call this method from inside your Component IEnvironmentSetupParticipant's
1307    upgrade_environment() function to create the database tables corresponding to
1308    your Component's generic classes.
1309
1310    :param realm_schema: The db schema definition, as returned by
1311                   the get_data_models() function in the IConcreteClassProvider
1312                   interface.
1313    """
1314    with env.db_transaction as db:
1315        cursor = db.cursor()
1316
1317        db_backend, _ = DatabaseManager(env).get_connector()
1318
1319        env.log.info("Creating DB for class '%s'.", realm)
1320
1321        # Create the required tables
1322        table_metadata = realm_schema['table']
1323        version = realm_schema['version']
1324        tablename = table_metadata.name
1325
1326        key_names = [k for k in table_metadata.key]
1327
1328        # Create base table
1329        env.log.info("Creating base table %s...", tablename)
1330        for stmt in db_backend.to_sql(table_metadata):
1331            env.log.debug(stmt)
1332            cursor.execute(stmt)
1333
1334        # Create custom fields table if required
1335        if realm_schema['has_custom']:
1336            cols = []
1337            for k in key_names:
1338                # Determine type of column k
1339                type = 'text'
1340                for c in table_metadata.columns:
1341                    if c.name == k:
1342                        type = c.type
1343
1344                cols.append(Column(k, type=type))
1345
1346            cols.append(Column('name'))
1347            cols.append(Column('value'))
1348
1349            custom_key = copy.deepcopy(key_names)
1350            custom_key.append('name')
1351
1352            table_custom = Table(tablename+'_custom', key = custom_key)[cols]
1353            env.log.info("Creating custom properties table %s...", table_custom.name)
1354            for stmt in db_backend.to_sql(table_custom):
1355                env.log.debug(stmt)
1356                cursor.execute(stmt)
1357
1358        # Create change history table if required
1359        if realm_schema['has_change']:
1360            cols = []
1361            for k in key_names:
1362                # Determine type of column k
1363                type = 'text'
1364                for c in table_metadata.columns:
1365                    if c.name == k:
1366                        type = c.type
1367
1368                cols.append(Column(k, type=type))
1369
1370            cols.append(Column('time', type=get_timestamp_db_type()))
1371            cols.append(Column('author'))
1372            cols.append(Column('field'))
1373            cols.append(Column('oldvalue'))
1374            cols.append(Column('newvalue'))
1375            cols.append(Index(key_names))
1376
1377            change_key = copy.deepcopy(key_names)
1378            change_key.append('time')
1379            change_key.append('field')
1380
1381            table_change = Table(tablename+'_change', key = change_key)[cols]
1382            env.log.info("Creating change history table %s...", table_change.name)
1383            for stmt in db_backend.to_sql(table_change):
1384                env.log.debug(stmt)
1385                cursor.execute(stmt)
1386
1387        _set_installed_version(env, realm, version, db)
1388
1389def upgrade_db_for_realm(env, package_name, realm, realm_schema, db=None):
1390    """
1391    Each db version should have its own upgrade module, named
1392    upgrades/db_<schema>_<N>.py, where 'N' is the version number (int).
1393    """
1394    with env.db_transaction as db:
1395        cursor = db.cursor()
1396
1397        db_backend = DatabaseManager(env).get_connector()[0]
1398
1399        env.log.info("Upgrading DB for class '%s'.", realm)
1400
1401        # Create the required tables
1402        table_metadata = realm_schema['table']
1403        version = realm_schema['version']
1404        tablename = table_metadata.name
1405
1406        cursor = db.cursor()
1407        current_version = _get_installed_version(env, realm, db)
1408
1409        for i in range(current_version + 1, version + 1):
1410            env.log.info('Upgrading database version for class \'%s\' from %d to %d', realm, i - 1, i)
1411
1412            name  = 'db_%s_%i' % (realm, i)
1413            try:
1414                upgrades = __import__(package_name, globals(), locals(), [name])
1415                script = getattr(upgrades, name)
1416            except AttributeError:
1417                raise TracError(_('No upgrade module for version %(num)i '
1418                                  '(%(version)s.py)', num=i, version=name))
1419            script.do_upgrade(env, i, db_backend, db)
1420
1421            _set_installed_version(env, realm, i, db)
1422
1423            env.log.info('Upgrade step successful.')
1424
1425
1426# DB schema management methods
1427
1428def _get_installed_version(env, realm, db=None):
1429    """
1430    :return: -1, if the DB for realm does not exist,
1431             a number greater or equals to 1 as the installed DB version for realm.
1432    """
1433    version = _get_system_value(env, realm + '_version', None)
1434    if version is None:
1435        # check for old naming schema
1436        dburi = env.config.get('trac', 'database')
1437        env.log.debug('Database backend is \'%s\'', dburi)
1438
1439        with env.db_query as db:
1440            tables = list_available_tables(dburi, db.cursor())
1441        if 'tracgenericclassconfig' in tables:
1442            version = db_get_config_property(env, 'tracgenericclassconfig', realm + "_dbversion")
1443        else:
1444            if realm in tables:
1445                version = 1
1446
1447    if version is None:
1448        version = -1
1449
1450    return int(version)
1451
1452def _set_installed_version(env, realm, version, db=None):
1453    env.log.info("Setting database version for class '%s' to %d", realm, version)
1454    _set_system_value(env, realm + '_version', version, db)
1455
1456# Trac db 'system' table management methods
1457
1458def _get_system_value(env, key, default=None):
1459    result = default
1460
1461    with env.db_query as db:
1462        cursor = db.cursor()
1463        cursor.execute("SELECT value FROM system WHERE name=%s", (key,))
1464        row = cursor.fetchone()
1465
1466        if row and row[0]:
1467            result = row[0]
1468            env.log.debug("Found system key '%s' with value %s", key, result)
1469        else:
1470            env.log.debug("System key '%s' not found", key)
1471
1472    env.log.debug("Returning system key '%s' with value %s", key, result)
1473    return result
1474
1475def _set_system_value(env, key, value, db=None):
1476    """
1477    Atomic UPSERT (i.e. UPDATE or INSERT) db transaction to save realm DB version.
1478    """
1479    with env.db_transaction as db:
1480        cursor = db.cursor()
1481        cursor.execute(
1482                "UPDATE system SET value=%s WHERE name=%s", (value, key))
1483
1484        cursor.execute("SELECT value FROM system WHERE name=%s", (key,))
1485        if not cursor.fetchone():
1486            cursor.execute(
1487                "INSERT INTO system(name, value) VALUES(%s, %s)", (key, value))
Note: See TracBrowser for help on using the repository browser.