class ChartGenerator(Component): """The ChartGenerator can be asked to generate a widget for one of the implemented charts. Furthermore it can decide to cache charts.""" widget_generators = ExtensionPoint(IAgiloWidgetGenerator) def __init__(self): self.cache = {} self.config = AgiloConfig(self.env).get_section(AGILO_CHARTS) def _get_generator(self, name): generator = None for widget_generator in self.widget_generators: if widget_generator.can_generate_widget(name): generator = widget_generator break if generator == None: raise TracError(u'Unknown widget type %s' % name) return generator def _get_cache_key(self, generator, name, values): data = dict(name=name, **values) if hasattr(generator, 'get_cache_components'): cache_components = generator.get_cache_components(data.keys()) else: cache_components = ('name', 'sprint_name') cache_values = [] for item in cache_components: if item not in data: return None cache_values.append((item, data[item])) # We need a hashable (non-mutable) type here therefore a dict won't # work. I did not want to resort to some url-like encoding because then # it is not 100% clear how to identify the separate items afterwards. # So a tuple of tuples (key, value) should work ok. cache_key = tuple(cache_values) return cache_key # REFACT: get rid of this configurability - it's not used and not supported on newer versions def _add_configured_colors(self, kwargs): if 'FORE_COLOR' not in kwargs: # The config key here is chosen because it was used in the constant # Option.FORE_COLOR until 0.7.3 and I did not want to break # backwards compatibility without good reason (fs, 2009-01-23) color = self.config.get('sprintstats.foreground', default='#4180b3') kwargs['FORE_COLOR'] = color if 'BACK_COLOR' not in kwargs: color = self.config.get('sprintstats.background', default='#94d31a') kwargs['BACK_COLOR'] = color def _get_widget(self, name, kwargs, use_cache): # The idea behind this caching is that getting the widget from the # generator is by far the most expensive operation (because it will # populate the widget with all necessary data). On the other hand, # creating the real chart is cheap (this is especially true for charts # generated by flot but holds true also for matplotlib). # Furthermore I assume that making a copy of dicts with some (<20) keys # is probably also cheap. The copy frees us from any thoughts about # threading and multiple instances. new_widget = None generator = self._get_generator(name) cache_key = None if use_cache: cache_key = self._get_cache_key(generator, name, kwargs) if cache_key is not None and cache_key in self.cache: cached_widget = self.cache[cache_key] new_widget = cached_widget.copy() else: self._add_configured_colors(kwargs) new_widget = generator.generate_widget(name, **kwargs) if cache_key is not None: # Maybe clean cache after a certain amount of time/certain size? self.cache[cache_key] = new_widget.copy() kwargs = {} # don't overwrite everything again with .update() below return new_widget def _set_widget_dimensions(self, widget, name, kwargs): """Sets the dimensions if they were specified by the user. If width/height were in kwargs, these will be deleted.""" width, height = kwargs.get(Key.WIDTH), kwargs.get(Key.HEIGHT) if width == None or height == None: config_width = self.config.get_int('%s.%s' % (name, Key.WIDTH)) config_height = self.config.get_int('%s.%s' % (name, Key.HEIGHT)) if width != None and height != None: (width, height) = (config_width, config_height) if width is not None and height is not None: widget.set_dimensions(width, height) # do not set these using update_data kwargs.pop(Key.HEIGHT, None) kwargs.pop(Key.WIDTH, None) def get_chartwidget(self, name, use_cache=True, **kwargs): """Return a widget instance which will generate the chart's HTML.""" new_widget = self._get_widget(name, kwargs, use_cache) self._set_widget_dimensions(new_widget, name, kwargs) new_widget.update_data(**kwargs) return new_widget def _add_names_for_persistent_objects(self, changed_items): """We don't want to care if a user passes a PersistentObject instance or its name so this method just grabs a name if it is available and adds it the changed_items (this is no problem because as long as *one* cache component is in the cache key, the cache item will be invalidated.""" new_items = {} for key in changed_items: value = changed_items[key] if hasattr(value, 'name'): new_key = key + '_name' new_items[new_key] = value.name new_items.update(changed_items) return new_items def invalidate_cache(self, **changed_items): changed_items = self._add_names_for_persistent_objects(changed_items) if len(changed_items) == 0: self.cache = {} else: del_keys = [] for cache_key in self.cache: for key, value in cache_key: if (key in changed_items) and (value == changed_items[key]): del_keys.append(cache_key) break for key in del_keys: del self.cache[key]
class CustomFields(Component): """ These methods should be part of TicketSystem API/Data Model. Adds update_custom_field and delete_custom_field methods. (The get_custom_fields is already part of the API - just redirect here, and add option to only get one named field back.) """ def __init__(self, *args, **kwargs): """Initialize the component and set a TracConfig""" self.ticket_custom = \ AgiloConfig(self.env).get_section(AgiloConfig.TICKET_CUSTOM) def get_custom_fields(self, field_name=None): """ Returns the custom fields from TicketSystem component. Use a field name to find a specific custom field only """ if not field_name: # return full list return AgiloTicketSystem(self.env).get_custom_fields() else: # only return specific item with cfname all = AgiloTicketSystem(self.env).get_custom_fields() for item in all: if item[Key.NAME] == field_name: return item return None # item not found def _store_all_options_for_custom_field(self, customfield): added_keys = list() changed = False for key in customfield: if key == Key.NAME: continue elif key == Key.TYPE: config_key = customfield[Key.NAME] else: config_key = '%s.%s' % (customfield[Key.NAME], key) value = customfield[key] if isinstance(value, list): value = '|'.join(value) if value not in ['', None]: changed = True self.ticket_custom.change_option(config_key, value, save=False) added_keys.append(key) if changed: self._remove_old_keys(customfield[Key.NAME], added_keys) self.ticket_custom.save() def _del_custom_field_value(self, customfield, prop=None): """Deletes a property from a custom field""" if not prop: self.ticket_custom.remove_option(customfield[Key.NAME]) else: self.ticket_custom.remove_option('%s.%s' % (customfield[Key.NAME], prop)) def _validate_input(self, customfield, create): """Checks the input values and raises a TracError if severe problems are detected.""" # Name, Type are required if not (customfield.get(Key.NAME) and customfield.get(Key.TYPE)): raise TracError("Custom field needs at least a name and type.") # Use lowercase custom fieldnames only f_name = unicode(customfield[Key.NAME]).lower() # Only alphanumeric characters (and [-_]) allowed for custom fieldname if re.search('^[a-z0-9-_]+$', f_name) == None: raise TracError("Only alphanumeric characters allowed for custom field name (a-z or 0-9 or -_).") # If Create, check that field does not already exist if create and self.ticket_custom.get(f_name): raise TracError("Can not create as field already exists.") # Check that it is a valid field type f_type = customfield[Key.TYPE] if not f_type in ('text', 'checkbox', 'select', 'radio', 'textarea'): raise TracError("%s is not a valid field type" % f_type) if (Key.ORDER in customfield) and (not str(customfield.get(Key.ORDER)).isdigit()): raise TracError("%s is not a valid number for %s" % (customfield.get(Key.ORDER), Key.ORDER)) customfield[Key.NAME] = f_name def update_custom_field(self, customfield, create=False): """ Update or create a new custom field (if requested). customfield is a dictionary with the following possible keys: name = name of field (alphanumeric only) type = text|checkbox|select|radio|textarea label = label description value = default value for field content options = options for select and radio types (list, leave first empty for optional) cols = number of columns for text area rows = number of rows for text area order = specify sort order for field """ self._validate_input(customfield, create) f_type = customfield[Key.TYPE] if f_type == 'textarea': def set_default_value(key, default): if (key not in customfield) or \ (not unicode(customfield[key]).isdigit()): customfield[key] = unicode(default) # dwt: why is this called twice? set_default_value(Key.COLS, 60) set_default_value(Key.COLS, 5) if create: number_of_custom_fields = len(self.get_custom_fields()) # We assume that the currently added custom field is not present in # the return value of get_custom_fields and we start counting from 0 customfield[Key.ORDER] = str(number_of_custom_fields) self._store_all_options_for_custom_field(customfield) AgiloTicketSystem(self.env).reset_ticket_fields() # TODO: Check that you can change the type from select to something different # and the options are gone afterwards def _set_custom_field_value(self, customfield, prop=None): """Sets a value in the custom fields for a given property key""" config_key = value = None if prop: value = customfield.get(prop) if isinstance(value, list): value = '|'.join(value) config_key = '%s.%s' % (customfield[Key.NAME], prop) else: # Used to set the type config_key = customfield[Key.NAME] value = customfield[Key.TYPE] self.ticket_custom.change_option(config_key, value) def _remove_old_keys(self, fieldname, added_keys): for key in (Key.VALUE, Key.OPTIONS, Key.COLS, Key.ROWS): if key not in added_keys: self.ticket_custom.remove_option('%s.%s' % (fieldname, key), save=False) def delete_custom_field(self, field_name): """Deletes a custom field""" if not self.ticket_custom.get(field_name): return # Nothing to do here - cannot find field # Need to redo the order of fields that are after the field to be deleted order_to_delete = self.ticket_custom.get_int('%s.%s' % (field_name, Key.ORDER)) cfs = self.get_custom_fields() for field in cfs: if field[Key.ORDER] > order_to_delete: field[Key.ORDER] -= 1 self._set_custom_field_value(field, Key.ORDER) elif field[Key.NAME] == field_name: # Remove any data for the custom field (covering all bases) self._del_custom_field_value(field) # Save settings self.ticket_custom.save() AgiloTicketSystem(self.env).reset_ticket_fields()
class ChartGenerator(Component): """The ChartGenerator can be asked to generate a widget for one of the implemented charts. Furthermore it can decide to cache charts.""" widget_generators = ExtensionPoint(IAgiloWidgetGenerator) def __init__(self): self.cache = {} self.config = AgiloConfig(self.env).get_section(AGILO_CHARTS) def _get_generator(self, name): generator = None for widget_generator in self.widget_generators: if widget_generator.can_generate_widget(name): generator = widget_generator break if generator == None: raise TracError(u'Unknown widget type %s' % name) return generator def _get_cache_key(self, generator, name, values): data = dict(name=name, **values) if hasattr(generator, 'get_cache_components'): cache_components = generator.get_cache_components(data.keys()) else: cache_components = ('name', 'sprint_name') cache_values = [] for item in cache_components: if item not in data: return None cache_values.append((item, data[item])) # We need a hashable (non-mutable) type here therefore a dict won't # work. I did not want to resort to some url-like encoding because then # it is not 100% clear how to identify the separate items afterwards. # So a tuple of tuples (key, value) should work ok. cache_key = tuple(cache_values) return cache_key # REFACT: get rid of this configurability - it's not used and not supported on newer versions def _add_configured_colors(self, kwargs): if 'FORE_COLOR' not in kwargs: # The config key here is chosen because it was used in the constant # Option.FORE_COLOR until 0.7.3 and I did not want to break # backwards compatibility without good reason (fs, 2009-01-23) color = self.config.get('sprintstats.foreground', default='#4180b3') kwargs['FORE_COLOR'] = color if 'BACK_COLOR' not in kwargs: color = self.config.get('sprintstats.background', default='#94d31a') kwargs['BACK_COLOR'] = color def _get_widget(self, name, kwargs, use_cache): # The idea behind this caching is that getting the widget from the # generator is by far the most expensive operation (because it will # populate the widget with all necessary data). On the other hand, # creating the real chart is cheap (this is especially true for charts # generated by flot but holds true also for matplotlib). # Furthermore I assume that making a copy of dicts with some (<20) keys # is probably also cheap. The copy frees us from any thoughts about # threading and multiple instances. new_widget = None generator = self._get_generator(name) cache_key = None if use_cache: cache_key = self._get_cache_key(generator, name, kwargs) if cache_key is not None and cache_key in self.cache: cached_widget = self.cache[cache_key] new_widget = cached_widget.copy() else: self._add_configured_colors(kwargs) new_widget = generator.generate_widget(name, **kwargs) if cache_key is not None: # Maybe clean cache after a certain amount of time/certain size? self.cache[cache_key] = new_widget.copy() kwargs = { } # don't overwrite everything again with .update() below return new_widget def _set_widget_dimensions(self, widget, name, kwargs): """Sets the dimensions if they were specified by the user. If width/height were in kwargs, these will be deleted.""" width, height = kwargs.get(Key.WIDTH), kwargs.get(Key.HEIGHT) if width == None or height == None: config_width = self.config.get_int('%s.%s' % (name, Key.WIDTH)) config_height = self.config.get_int('%s.%s' % (name, Key.HEIGHT)) if width != None and height != None: (width, height) = (config_width, config_height) if width is not None and height is not None: widget.set_dimensions(width, height) # do not set these using update_data kwargs.pop(Key.HEIGHT, None) kwargs.pop(Key.WIDTH, None) def get_chartwidget(self, name, use_cache=True, **kwargs): """Return a widget instance which will generate the chart's HTML.""" new_widget = self._get_widget(name, kwargs, use_cache) self._set_widget_dimensions(new_widget, name, kwargs) new_widget.update_data(**kwargs) return new_widget def _add_names_for_persistent_objects(self, changed_items): """We don't want to care if a user passes a PersistentObject instance or its name so this method just grabs a name if it is available and adds it the changed_items (this is no problem because as long as *one* cache component is in the cache key, the cache item will be invalidated.""" new_items = {} for key in changed_items: value = changed_items[key] if hasattr(value, 'name'): new_key = key + '_name' new_items[new_key] = value.name new_items.update(changed_items) return new_items def invalidate_cache(self, **changed_items): changed_items = self._add_names_for_persistent_objects(changed_items) if len(changed_items) == 0: self.cache = {} else: del_keys = [] for cache_key in self.cache: for key, value in cache_key: if (key in changed_items) and (value == changed_items[key]): del_keys.append(cache_key) break for key in del_keys: del self.cache[key]