source: dynamicfieldsplugin/trunk/dynfields/rules.py

Last change on this file was 17752, checked in by Ryan J Ollos, 3 years ago

TracDynamicFields 2.5.0dev: Revise comments

Refs #13821.

  • Property svn:executable set to *
File size: 17.0 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2010-2014 Rob Guttman <guttman@alum.mit.edu>
4#
5# This software is licensed as described in the file COPYING, which
6# you should have received as part of this distribution.
7#
8
9import re
10
11from trac.core import Component, ExtensionPoint, Interface, implements
12from trac.perm import IPermissionGroupProvider, PermissionSystem
13
14try:
15    from trac.util import to_list
16except ImportError:
17    from dynfields.compat import to_list
18
19# Import translation functions.
20# Fallbacks make Babel still optional and provide Trac 0.11 compatibility.
21try:
22    from trac.util.translation import domain_functions
23except ImportError:
24    from trac.util.html import html as tag_
25    from trac.util.translation import gettext
26    _ = gettext
27
28    def add_domain(a, b, c=None):
29        pass
30else:
31    add_domain, _, tag_ = \
32        domain_functions('dynfields', ('add_domain', '_', 'tag_'))
33
34
35if not hasattr(PermissionSystem, 'get_permission_groups'):
36
37    PermissionSystem.group_providers = ExtensionPoint(IPermissionGroupProvider)
38
39    def get_permission_groups(self, user):
40        groups = set([user])
41        for provider in self.group_providers:
42            for group in provider.get_permission_groups(user):
43                groups.add(group)
44
45        perms = PermissionSystem(self.env).get_all_permissions()
46        repeat = True
47        while repeat:
48            repeat = False
49            for subject, action in perms:
50                if subject in groups and not action.isupper() and \
51                        action not in groups:
52                    groups.add(action)
53                    repeat = True
54        return groups
55
56    PermissionSystem.get_permission_groups = get_permission_groups
57
58
59class IRule(Interface):
60    """An extension point interface for adding rules.  Rule processing
61    is split into two parts: (1) rule specification (python), (2) rule
62    implementation (JS).
63
64    The python and JS parts are linked by instantiating the JS rule
65    implementation with the corresponding python rule's class name.
66    For example, the JS rule implementation corresponding with the
67    HideRule python class must be instantiated as follows in rules.js:
68
69      var hiderule = new Rule('HideRule');
70    """
71
72    def get_trigger(self, req, target, key, opts):
73        """Return the field name that triggers the rule, or None if not found
74        for the given target field and ticket-custom options key and dict.
75        For example, if the 'version' field is to be hidden based on the
76        ticket type, then the returned trigger field name should be 'type'."""
77
78    def update_spec(self, req, key, opts, spec):
79        """Update the spec dict with the rule's specifications needed for
80        the JS implementation.  The spec dict is defaulted to include the
81        rule's name (rule_name), the trigger field's name (trigger), the
82        target field's name (target), and the preference or default value
83        if applicable (value)."""
84
85    def update_pref(self, req, trigger, target, key, opts, pref):
86        """Update the pref dict with the data needed for preferences.
87        The primary dict keys to change are:
88
89          label (of checkbox)
90          type ('none' or 'select')
91
92        Default values for the above are provided as well as for the
93        keys below (which should usually not be changed):
94
95          id (based on unique key)
96          enabled ('1' or '0')
97          options (list of options if type is 'select')
98          value (saved preference or default value)
99        """
100
101
102class Rule(object):
103    """Abstract class for common rule properties and utilities."""
104
105    OVERWRITE = '(overwrite)'
106
107    @property
108    def name(self):
109        """Returns the rule instance's class name.  The corresponding
110        JS rule must be instantiated with this exact name."""
111        return self.__class__.__name__
112
113    @property
114    def title(self):
115        """Returns the rule class' title used for display purposes.
116        This default implementation returns the rule's name with any
117        camel case split into words and the last word made plural.
118        This property/method can be overridden as needed.
119        """
120        # split CamelCase to Camel Case
121        title = self._split_camel_case(self.name)
122        if not title.endswith('s'):
123            title += 's'
124        return title
125
126    @property
127    def desc(self):
128        """Returns the description of the rule.  This default implementation
129        returns the first paragraph of the docstring as the desc.
130        """
131        return self.__doc__.split('\n')[0]
132
133    # private methods
134    def _capitalize(self, word):
135        if len(word) <= 1:
136            return word.upper()
137        return word[0].upper() + word[1:]
138
139    def _split_camel_case(self, s):
140        return re.sub('((?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z]))', ' ', s)
141
142    def _extract_overwrite(self, target, key, opts):
143        """Extract any <overwrite> prefix from value string."""
144        value = opts[key]
145        if value.endswith(self.OVERWRITE):
146            value = value.replace(self.OVERWRITE, '').rstrip()
147            overwrite = True
148        else:
149            overwrite = opts.getbool('%s.overwrite' % target)
150        return value, overwrite
151
152
153class ClearRule(Component, Rule):
154    """Clears one field when another changes.
155
156    Example trac.ini specs:
157
158      [ticket-custom]
159      version.clear_on_change_of = milestone
160    """
161
162    implements(IRule)
163
164    @property
165    def title(self):
166        title = _("Clear Rules")
167        return title
168
169    @property
170    def desc(self):
171        desc = _("Clears one field when another changes.")
172        return desc
173
174    def get_trigger(self, req, target, key, opts):
175        if key == '%s.clear_on_change_of' % target:
176            return opts[key]
177        return None
178
179    def update_spec(self, req, key, opts, spec):
180        target = spec['target']
181        spec['op'] = 'clear'
182        spec['clear_on_change'] = \
183            opts.getbool(target + '.clear_on_change', True)
184
185    def update_pref(self, req, trigger, target, key, opts, pref):
186        # TRANSLATOR: checkbox label text for clear rules
187        pref['label'] = _("Clear %(target)s when %(trigger)s changes",
188                          target=target, trigger=trigger)
189
190
191class CopyRule(Component, Rule):
192    """Copies a field (when changed) to another field (if empty and visible).
193
194    Example trac.ini specs:
195
196      [ticket-custom]
197      captain.copy_from = owner
198
199    In this example, if the owner value changes, then the captain field's
200    value gets set to that value if the captain field is empty and visible
201    (the default).  By default, the current value if set will not be
202    overwritten.  To overwrite the current value, add "(overwrite)" as
203    follows:
204
205      [ticket-custom]
206      captain.copy_from = owner (overwrite)
207    """
208
209    implements(IRule)
210
211    @property
212    def title(self):
213        title = _("Copy Rules")
214        return title
215
216    @property
217    def desc(self):
218        desc = _("Copies field content (when changed) to another field "
219                 "(if empty and visible).")
220        return desc
221
222    def get_trigger(self, req, target, key, opts):
223        if key == '%s.copy_from' % target:
224            return self._extract_overwrite(target, key, opts)[0]
225        return None
226
227    def update_spec(self, req, key, opts, spec):
228        target = spec['target']
229        spec['op'] = 'copy'
230        spec['overwrite'] = self._extract_overwrite(target, key, opts)[1]
231
232    def update_pref(self, req, trigger, target, key, opts, pref):
233        # TRANSLATOR: checkbox label text for copy rules
234        pref['label'] = _("Copy %(trigger)s to %(target)s",
235                          trigger=trigger, target=target)
236
237
238class DefaultRule(Component, Rule):
239    """Defaults a field to a user-specified value if empty.
240
241    Example trac.ini specs:
242
243      [ticket-custom]
244      cc.default_value = (pref)
245      cc.append = true
246
247    If the field is a non-empty text field and 'append' is true, then the
248    field is presumed to be a comma-delimited list and the preference value
249    is appended if not already present.
250    """
251
252    implements(IRule)
253
254    @property
255    def title(self):
256        title = _("Default Value Rules")
257        return title
258
259    @property
260    def desc(self):
261        desc = _("Defaults a field to a user-specified value.")
262        return desc
263
264    def get_trigger(self, req, target, key, opts):
265        if key == '%s.default_value' % target:
266            return target
267        return None
268
269    def update_spec(self, req, key, opts, spec):
270        spec['op'] = 'default'
271        spec['append'] = False if opts.get(spec['target']) == 'select' else \
272                         opts.getbool(spec['target'] + '.append')
273
274    def update_pref(self, req, trigger, target, key, opts, pref):
275        # "Default trigger to <select options>"
276        # TRANSLATOR: checkbox label text for default value rules
277        pref['label'] = _("Default %(target)s to", target=target)
278        if opts.get(target) == 'select':
279            pref['type'] = 'select'
280        else:
281            pref['type'] = 'text'
282
283
284class HideRule(Component, Rule):
285    """Hides a field based on another's value, group membership, or always.
286
287    Example trac.ini specs:
288
289      [ticket-custom]
290      version.show_when_type = enhancement
291      milestone.hide_when_type = defect
292      duedate.show_if_group = production
293      milestone.hide_if_group = production
294      alwayshide.hide_always = True
295      alwayshide.clear_on_hide = False
296    """
297
298    implements(IRule)
299    group_providers = ExtensionPoint(IPermissionGroupProvider)
300
301    @property
302    def title(self):
303        title = _("Hide Rules")
304        return title
305
306    @property
307    def desc(self):
308        desc = _("Hides a field based on another field's value (or always).")
309        return desc
310
311    def get_trigger(self, req, target, key, opts):
312        rule_re = re.compile(r"%s.(?P<op>(show|hide))_when_(?P<trigger>.*)"
313                             % target)
314        match = rule_re.match(key)
315        if match:
316            return match.groupdict()['trigger']
317
318        # group rule
319        rule_re = re.compile(r"%s.(?P<op>(show|hide))_if_group" % target)
320        match = rule_re.match(key)
321        if match:
322            ps = PermissionSystem(self.env)
323            groups1 = set(opts[key].split('|'))
324            groups2 = ps.get_permission_groups(req.authname)
325            if match.groupdict()['op'] == 'hide':
326                return 'type' if groups1.intersection(groups2) else None
327            else:
328                return None if groups1.intersection(groups2) else 'type'
329
330        # try finding hide_always rule
331        if key == "%s.hide_always" % target:
332            return 'type'  # requires that 'type' field is enabled
333        return None
334
335    def update_spec(self, req, key, opts, spec):
336        target = spec['target']
337        trigger = spec['trigger']
338
339        spec_re = re.compile(r"%s.(?P<op>(show|hide))_when_%s"
340                             % (target, trigger))
341        match = spec_re.match(key)
342        if match:
343            spec['op'] = match.groupdict()['op']
344            spec['trigger_value'] = opts[key]
345            spec['hide_always'] = self._is_always_hidden(req, key, opts, spec)
346        else:  # assume 'hide_always' or group rule
347            spec['op'] = 'show'
348            spec['trigger_value'] = 'invalid_value'
349            spec['hide_always'] = True
350        spec['clear_on_hide'] = opts.getbool(target + '.clear_on_hide', True)
351        spec['link_to_show'] = opts.getbool(target + '.link_to_show')
352
353    def update_pref(self, req, trigger, target, key, opts, pref):
354        spec = {'trigger': trigger, 'target': target}
355        self.update_spec(req, key, opts, spec)
356        # TRANSLATOR: char/word to replace '|' = logic OR in 'value|value'
357        trigval = spec['trigger_value'].replace('|', _(" or "))
358        if spec['op'] == 'hide':
359            # TRANSLATOR: checkbox label text for conditional hide rules
360            pref['label'] = _("Hide %(target)s when %(trigger)s = %(trigval)s",
361                              target=target, trigger=trigger, trigval=trigval)
362        else:
363            # TRANSLATOR: checkbox label text for conditional show rules
364            pref['label'] = _("Show %(target)s when %(trigger)s = %(trigval)s",
365                              target=target, trigger=trigger, trigval=trigval)
366
367        # special case when trigger value is not a select option
368        value, options = opts.get_value_and_options(req, trigger, key)
369        value = spec['trigger_value']
370        if options and value and value not in options and '|' not in value:
371            # "Always hide/show target"
372            if spec['op'] == 'hide':
373                pref['label'] = _("Always show %(target)s", target=target)
374            else:
375                pref['label'] = _("Always hide %(target)s", target=target)
376
377    def _is_always_hidden(self, req, key, opts, spec):
378        trigger = spec['trigger']
379        value, options = opts.get_value_and_options(req, trigger, key)
380        value = spec['trigger_value']
381        if options and value and value not in options and '|' not in value:
382            return spec['op'] == 'show'
383        return False
384
385
386class ValidateRule(Component, Rule):
387    """Checks a field for an invalid value.
388
389    Example trac.ini specs:
390
391      [ticket-custom]
392      milestone.invalid_if =
393      phase.invalid_if = releasing
394      phase.invalid_when = .codereviewstatus .pending (msg:Pending reviews.)
395    """
396
397    implements(IRule)
398
399    def get_trigger(self, req, target, key, opts):
400        if key.startswith('%s.invalid_if' % target):
401            return target
402        return None
403
404    def update_spec(self, req, key, opts, spec):
405        target = spec['target']
406        spec['op'] = 'validate'
407        spec['value'] = opts[key]
408
409        # check for suffix
410        suffix_re = re.compile(r"(?P<suffix>\.[0-9]+)$")
411        match = suffix_re.search(key)
412        suffix = match.groupdict()['suffix'] if match else ''
413
414        # extract when spec
415        spec['when'] = opts.get("%s.invalid_when%s" % (target, suffix), '')
416        spec['msg'] = ''
417        if spec['when']:
418            when_re = re.compile(r"^(?P<selector>.+) \(msg:(?P<msg>.+)\)$")
419            match = when_re.match(spec['when'])
420            if match:
421                spec['when'] = match.groupdict()['selector']
422                spec['msg'] = match.groupdict()['msg']
423
424    def update_pref(self, req, trigger, target, key, opts, pref):
425        suffix = opts[key] and "= %s" % opts[key] or "is empty"
426        pref['label'] = "Invalid if %s %s" % (target, suffix)
427
428
429class SetRule(Component, Rule):
430    """Sets a field based on another field's value.
431
432    Example trac.ini specs:
433
434      [ticket-custom]
435      milestone.set_to_!_when_phase = implementation|verifying
436
437    The "!" is used only for select fields to specify the first non-empty
438    option; a common use case is to auto-select the current milestone.
439    By default, the current value if set will not be overwritten.  To
440    overwrite the current value, add "(overwrite)" as follows:
441
442      [ticket-custom]
443      milestone.set_to_!_when_phase = implementation|verifying (overwrite)
444    """
445
446    implements(IRule)
447
448    def get_trigger(self, req, target, key, opts):
449        rule_re = re.compile(r"%s.set_to_(.*)_when_(?P<not>not_)?"
450                             r"(?P<trigger>.+)" % target)
451        match = rule_re.match(key)
452        if match:
453            return match.groupdict()['trigger']
454
455    def update_spec(self, req, key, opts, spec):
456        target, trigger = spec['target'], spec['trigger']
457        spec_re = re.compile(r"%s.set_to_(?P<to>.*)_when_(?P<not>not_)?%s"
458                             % (target, trigger))
459        match = spec_re.match(key)
460        if not match:
461            return
462        spec['set_to'] = match.groupdict()['to']
463        if spec['set_to'].lower() in ('1', 'true'):
464            spec['set_to'] = True
465        elif spec['set_to'].lower() in ('0', 'false'):
466            spec['set_to'] = False
467        elif spec['set_to'] == '?' and 'value' in spec:
468            spec['set_to'] = spec['value']
469        trigger_value, spec['overwrite'] = \
470            self._extract_overwrite(target, key, opts)
471        spec['trigger_value'] = []
472        spec['trigger_not_value'] = []
473        for val in to_list(trigger_value, '|'):
474            if match.groupdict()['not']:
475                spec['trigger_not_value'].append(val)
476            else:
477                spec['trigger_value'].append(val)
478
479    def update_pref(self, req, trigger, target, key, opts, pref):
480        spec = {'target': target, 'trigger': trigger}
481        self.update_spec(req, key, opts, spec)
482        # "When trigger = value set target to"
483        if spec['trigger_value']:
484            comp = '='
485            trigval = ' or '.join(spec['trigger_value'])
486        else:
487            comp = '!='
488            trigval = ' or '.join(spec['trigger_not_value'])
489        if spec['set_to'] == '?':
490            set_to = ''
491            pref['type'] = 'select' if opts.get(target) == 'select' else 'text'
492        elif spec['set_to'] == '!':
493            set_to = 'the first non-empty option'
494        elif spec['set_to'] == '':
495            set_to = '(empty)'
496        else:
497            set_to = spec['set_to']
498        pref['label'] = "When %s %s %s, set %s to %s"\
499                        % (trigger, comp, trigval, target, set_to)
Note: See TracBrowser for help on using the repository browser.