# -*- coding: utf-8 -*-
#   Copyright 2008 Agile42 GmbH - Andrea Tomasini 
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.
# 
# Authors:
#     - Felix Schwarz <felix.schwarz__at__agile42.com>
#     - Andrea Tomasini <andrea.tomasini__at__agile42.com>

from datetime import datetime
import inspect
import re
import string

from trac.core import TracError
from trac.resource import Resource
import trac.ticket.model
from trac.util.datefmt import to_timestamp
from trac.util.text import to_unicode
from trac.util.translation import _

from agilo.core import PersistentObjectModelManager, \
    UnableToLoadObjectError, safe_execute
from agilo.ticket.api import AgiloTicketSystem
from agilo.ticket.links import LINKS_TABLE
from agilo.utils import Key, Realm
from agilo.utils.db import get_db_for_write
from agilo.utils.config import AgiloConfig, get_label
from agilo.utils.log import debug, error, warning
from agilo.utils.sorting import By, Column
from agilo.ticket.renderers import Renderer

__all__ = ['AgiloTicket']


TICKET_COLUMNS = ['id', 'type', 'time', 'changetime',
                  'component', 'severity', 'priority', 
                  'owner', 'reporter', 'cc', 'version',
                  'milestone', 'status', 'resolution',
                  'summary', 'description', 'keywords']


class TicketValueWrapper(dict):
# Trac accesses ticket.values directly sometimes (especially in 
# _init_defaults). However we need to intercept type changes to use 
# the right list of ticket fields so we use this wrapper.
    def __init__(self, ticket):
        dict.__init__(self)
        self._ticket = ticket
    
    def __setitem__(self, name, value):
        if name == Key.TYPE:
            self._ticket._reset_type_fields(value)
        dict.__setitem__(self, name, value)
    
    def setdefault(self, name, value):
        if (name == Key.TYPE) and (Key.TYPE not in self):
            self._ticket._reset_type_fields(value)
        dict.setdefault(self, name, value)


class AgiloTicket(trac.ticket.model.Ticket):
    """
    Represent a typed ticket, with Link capabilities as well as 
    aliases and properties check. It wraps the standard Trac Ticket.
    """
    def __init__(self, env, tkt_id=None, db=None, version=None, 
                 t_type=None, load=False):
        """
        Initializes an AgiloTicket, making sure that there are only 
        the fields allowed for this type as well as the defined agilo 
        properties
        """
        # We define a custom time_changed property so we need to have this 
        # variable in order to store the real value even for non-agilo 
        # environments because there is no way to undo the property definition
        self._time_changed = None
        # Set debug string
        self._DEBUG = "[id<%s> AgiloTicket(#%s)]" % (id(self), 
                                                     tkt_id or 0)
        # Check the if the ticket is in an agilo enabled environment
        # takes care of Monkey patching in mod_python envs
        if AgiloConfig(env).is_agilo_enabled:
            self.env = env
            # Store the logger
            self.log = env.log
            # Store a reference to the agilo ticket manager
            self.tm = AgiloTicketModelManager(self.env)
            # Store a reference to the agilo ticket system
            self.ats = AgiloTicketSystem(self.env)
            self.is_old_trac = not self.ats.uses_field_caching()
            
            debug(self, "Initializing with: %s, %s, %s" % (tkt_id, 
                                                           version, 
                                                           t_type))
            
            self.resource = Resource(Realm.TICKET, tkt_id, version)
            self.values = TicketValueWrapper(self)
            # Keeps old values
            self._old = {}
            # Links lists
            self._incoming = dict()
            self._outgoing = dict()
            # ticket fields
            self.fields = None
            # self._calculated contains a dictionary (key is the 
            # calculated property name, value the operator callable to
            # compute the value).
            self._calculated = None
            self._alloweds = self._sort = self._show_on_link = None
            if tkt_id is not None:
                self._fetch_ticket(tkt_id, db=db, t_type=t_type)
            else:
                self.id = self.time_created = self.time_changed = None
                # Store the ticket type
                if t_type is not None:
                    self[Key.TYPE] = t_type
                else:
                    # Unknown type, load all the fields
                    self.fields = self.ats.get_ticket_fields()
                self._init_defaults(db)
        else:
            super(AgiloTicket, self).__init__(env, tkt_id=tkt_id, 
                                              db=db, 
                                              version=version)
        
    # OVERRIDE
    def _fetch_ticket(self, tkt_id, db=None, t_type=None):
        """Overrides the trac method to reset the ticket fields for the 
        type"""
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self)._fetch_ticket(tkt_id, db=db)
        if not t_type:
            t_type = self._get_type(tkt_id, db)
        self._reset_type_fields(t_type)
        super(AgiloTicket, self)._fetch_ticket(tkt_id, db)
        # Check if the team members should be set in the owner field
        self._set_team_members()
    
    @property
    def fields_for_type(self):
        """Returns the list of alloweds fields for this type of ticket"""
        return AgiloConfig(self.env).TYPES.get(self.get_type(), [])
    
    def _set_time_changed(self, value):
        """Set the local time_changed attribute, removing the microsecond
        precision"""
        if not AgiloConfig(self.env).is_agilo_enabled:
            self._time_changed = value
        else:
            # Need to remove microsecond precision because trac 
            # doesn't do if a ticket is kept in memory the validation 
            # will fail because of the microseconds.
            if value is not None and isinstance(value, datetime):
                self._time_changed = value.replace(microsecond=0)
    
    def _get_time_changed(self):
        """Returns the time_changed attribute"""
        return self._time_changed
    
    # Sets time_changed as property, so that it will always call the
    # setter
    time_changed = property(_get_time_changed, _set_time_changed)
            
    def _set_team_members(self, sprint_name=None):
        """
        Sets a list of team members that are member of the team to 
        which the Sprint of this ticket has been assigned. If the 
        ticket has no Sprint assigned it does nothing.
        """
        debug(self, "Called _set_team_members() => '%s'" % \
                    (sprint_name or self[Key.SPRINT]))
        ats = AgiloTicketSystem(self.env)
        if ats.restrict_owner and Key.SPRINT in self.fields_for_type:
            ats.eventually_restrict_owner(self.get_field(Key.OWNER), 
                                          ticket=self,
                                          sprint_name=sprint_name)
    
    def _update_values_from_fields_on_type_change(self, t_type):
        # We need to fill self.values as well - otherwise trac 
        # might store NULL values for standard fields upon 
        # ticket.insert() and there is an implicit assumption in 
        # trac's property change rendering that no field is None.
        for field in self.fields:
            name = field[Key.NAME]
            is_custom_field = field.get(Key.CUSTOM, False)
            field_already_present = (name in self.values)
            if field_already_present:
                # don't overwrite existing values
                continue
            elif is_custom_field:
                # We don't need to check for custom fields to satisfy 
                # trac's assumption that all standard fields are not 
                # None but without this check we will create at least 
                # two additional rows with empty values in the db 
                # every time.
                continue
            elif name == Key.TYPE:
                # prevent recursive type resets
                continue
            elif name == Key.RESOLUTION:
                # trac has a special case not to set resolution for new tickets
                # so we should not fill in a default resolution upon type change
                # resolution is a default trac field so it must not be None
                self.values[Key.RESOLUTION] = ''
                continue
            self.values[name] = field.get(Key.VALUE, '')
        #AT: we also need to remove from the _old keys anything which is not
        # matching the current ticket type, or trac will try to save the not
        # found custom properties as ticket table members
        # we need to reload it separately because here the new type is not yet
        # set
        fields_for_new_type = AgiloConfig(self.env).TYPES.get(t_type, [])
        for key in self._old.keys():
            if key not in fields_for_new_type:
                del self._old[key]
        
    def _reset_type_fields(self, t_type):
        """Cleanup the local fields dictionary removing the fields which
        are not allowed for this type"""
        if not self.ats:
            self.ats = AgiloTicketSystem(self.env)
        # Normalize the ticket type
        t_type = self.ats.normalize_type(t_type)
        old_type = self.get_type()
        
        if old_type is None or t_type != old_type:
            debug(self, u"Resetting fields for type(%s): %s" % \
                  (t_type, self.fields_for_type))
            # get a ticket system
            self.fields = self.ats.get_ticket_fields(t_type)
            self._update_values_from_fields_on_type_change(t_type)
            agilo_properties = self.ats.get_agilo_properties(t_type)
            self._calculated, self._alloweds, self._sort, self._show_on_link = \
                agilo_properties
            debug(self, u"Loaded agilo properties: CALCULATED:%s " \
                        "ALLOWED:%s SORT:%s SHOW:%s" % agilo_properties)
            # Inform the caller that there as been a change of type
            return True
        # No type change, inform the caller that is not needed to take 
        # any action
        return False
    
    def __str__(self):
        """
        Returns an ASCII string representation of the AgiloTicket
        """
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self).__str__()
        return '<%s #%d (%s)>' % (self.__class__.__name__, 
                                  self.get_id(), 
                                  repr(self.get_type()))
    
    def __repr__(self):
        """Returns the representation for this AgiloTicket"""
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self).__repr__()
        return '<%s@%s #%d (%s)>' % (self.__class__.__name__,
                                     id(self), 
                                     self.get_id(), 
                                     repr(self.get_type()))
    
    def get_id(self):
        """Returns the id of the ticket"""
        return self.id or 0
    
    def get_type(self, tkt_id=None, db=None):
        """Returns the ticket type"""
        t_type = self[Key.TYPE]
        if not t_type and (tkt_id or (hasattr(self, 'id') and self.id)):
            # if we have an id, try to get it from there
            t_id = tkt_id or self.id
            if t_id is not None:
                t_type = self._get_type(t_id, db)
                
        return t_type
    
    def get_field(self, field_name):
        """Returns the field dictionary corresponding to the given 
        field_name. If not found returns None."""
        if field_name not in (None, ''):
            if field_name in self.get_calculated_fields_names():
                return self.get_calculated_field(field_name)
            for f in self.fields:
                if f[Key.NAME] == field_name:
                    return f
        return None
    
    def is_readable_field(self, field_name):
        """
        Return True if the given field name is allowed for this ticket 
        type
        """
        return (field_name in self.get_calculated_fields_names()) or \
                self.get_field(field_name) is not None
    
    def is_writeable_field(self, field_name):
        """
        Return True if the given field name is allowed for this 
        ticket type
        """
        return self.get_field(field_name) is not None
    
    @property
    def has_owner(self):
        """Returns true if this ticket has an owner set"""
        if self.is_readable_field(Key.OWNER):
            return self[Key.OWNER] not in ('', None)
        return False
    
    def get_resource_list(self, include_owner=False):
        """
        Returns a list of resources for this ticket. If the ticket 
        has no resource field (or it is empty), this method will 
        return an empty list.
        If include_owner is True (default False), the owner (if 
        present) will be included in the resource list. If the owner 
        is already in the list of resources, he won't be included 
        twice.
        """
        resource_list = list()
        if include_owner and (self[Key.OWNER] not in [None, '']):
            resource_list.append(self[Key.OWNER].strip())
        
        resource_string = self[Key.RESOURCES] or ''
        for resource in resource_string.split(','):
            resource = resource.strip()
            if len(resource) > 0 and (resource not in resource_list):
                resource_list.append(resource)
        return resource_list
    
    def get_sprint(self):
        """
        Loads the Sprint from the database and returns it (as Python 
        instance, not the sprint name) if this ticket has a sprint configured
        (return None otherwise).
        """
        if self[Key.SPRINT] != None:
            if not hasattr(self, '_sprint'):
                setattr(self, '_sprint', None)
            if self._sprint is None or \
                    self[Key.SPRINT] != self._sprint.name:
                # sprint is changed
                from agilo.scrum.sprint import SprintModelManager
                self._sprint = SprintModelManager(self.env).get(name=self[Key.SPRINT])
            return self._sprint
        return None
    
    def get_alias(self, ticket_type=None):
        """Returns the type alias for this ticket"""
        if ticket_type is None:
            ticket_type = self.get_type()
        return AgiloConfig(self.env).ALIASES.get(ticket_type, None)
    
    def get_label(self, name=None):
        label = AgiloConfig(self.env).LABELS.get(name, None)
        if label is None:
            label = get_label(name)
        return label
    
    def get_alloweds(self):
        """Returns a list of all the allowed end points for linking"""
        return self._alloweds.values()
        
    def is_link_to_allowed(self, end_point):
        """
        Returns True if the link between this endpoint type and 
        the given end_point type is allowed.
        """
        debug(self, u"Self Type: %s, Dest Type: %s, Alloweds: %s" % \
                    (self.get_type(), end_point.get_type(), 
                     self.get_alloweds()))
        return end_point.get_type() in [t.get_dest_type() for t in \
                                        self.get_alloweds()]
        
    def is_link_from_allowed(self, end_point):
        """
        Returns True if the link between the given endpoint type 
        and this endpoint type is allowed.
        """
        return end_point.is_link_to_allowed(self)
    
    def get_calculated_fields(self):
        """
        Returns a list of the calculated fields for this ticket. 
        You should be aware that the value of a calculated field may 
        change but the value in the dict previously returned won't be 
        updated.
        
        Attention: Calling this method can be extremely expensive 
        because it needs to calculate the value of every calculated 
        property which may trigger loading of linked tickets. This may 
        turn out quite expensive as long there is no object identity 
        in our database abstraction layer!
        """
        calculated_fields = list()
        for name in self.get_calculated_fields_names():
            field_dict = self.get_calculated_field(name)
            calculated_fields.append(field_dict)
        return calculated_fields
    
    def get_calculated_field(self, name):
        """
        Returns the field dictionary for the given calculated property. 
        Raises a KeyError if no such field exists.
        """
        if name not in self.get_calculated_fields_names():
            raise KeyError(name)
        field_dict = {Key.NAME: name, Key.VALUE: self[name], 
                      Key.LABEL: self.get_label(name), 
                      Key.RENDERED: Renderer(self, name)}
        return field_dict
    
    def get_calculated_fields_names(self):
        """
        Returns the list of the calculated field names for this ticket.
        """
        if hasattr(self, '_calculated') and self._calculated:
            return self._calculated.keys()
        return []
    
    def is_linked_to(self, end_point):
        """
        Returns True if self is linked to the given end_point. To 
        check the existance of the link, given the time of propagation 
        needed by the Trac extension points, it checks first the to 
        endpoints incoming and outgoing link caches, and than checks 
        on the db directly.
        """
        debug(self, u"Called %s.is_linked_to(%s)" % (self, end_point))
        if not self._outgoing.has_key(end_point.get_id()) \
           and not end_point._incoming.has_key(self.get_id()):
            debug(self, u"Called %s._is_link(%d, %d)" % \
                  (self, self.get_id(), end_point.get_id()))
            return self._is_link(self.get_id(), end_point.get_id())
        return True
        
    def is_linked_from(self, end_point):
        """
        Returns True if the given end_point is linked to self. To 
        check the existence of the link, given the time of propagation 
        needed by the Trac extension points, it checks first the to 
        endpoints incoming and outgoing link caches, and than checks 
        on the db directly.
        """
        debug(self, u"Called %s.is_linked_from(%s)" % (self, 
                                                       end_point))
        if not self._incoming.has_key(end_point.get_id()) \
           and not end_point._outgoing.has_key(self.get_id()):
            debug(self, u"Called %s._is_link(%d, %d)" % \
                  (self, end_point.get_id(), self.get_id()))
            return self._is_link(end_point.get_id(), self.get_id())
        return True
    
    def link_to(self, end_point, db=None):
        """Creates an outgoing link to the given endpoint."""
        handle_ta = False
        # Check if the end_point is a new ticket not yet inserted
        if end_point.id is None and db is None:
            db, handle_ta = self._get_db_for_write(db)
        # Creates the database link
        if self.is_link_to_allowed(end_point):
            # If the end_point is new we have to save it first
            if end_point.id is None:
                end_point.insert()
            if not self.is_linked_to(end_point):
                try:
                    self._create_link(self.get_id(), 
                                      end_point.get_id(), db)
                    self._outgoing[end_point.get_id()] = end_point
                    end_point._incoming[self.get_id()] = self
                    debug(self, u"Link created: %s => %s" % \
                          (self, end_point))
                    debug(self, u"Check is linked: %s" % \
                          self.is_linked_to(end_point))
                    if handle_ta:
                        db.commit()
                    return True
                except Exception, e:
                    error(to_unicode(e))
            else:
                error(self, "Link failed: %s => %s, the link is " \
                      "already existing!" % (self, end_point))
        else:    
            error(self, "Link failed: %s => %s, the link is not " \
                  "allowed!" % (self, end_point))
        # Roll back if db was created here
        if handle_ta:
            db.rollback()
        return False
        
    def link_from(self, end_point, db=None):
        """Creates an incoming link to the given endpoint."""
        handle_ta = False
        # Check if the end_point is a new ticket not yet inserted
        if end_point.id is None and db is None:
            db, handle_ta = self._get_db_for_write(db)
        # Creates the database link
        if self.is_link_from_allowed(end_point):
            # If the end_point is new we have to save it first
            if end_point.id is None:
                end_point.insert()
            if not self.is_linked_from(end_point):
                try:
                    self._create_link(end_point.get_id(), 
                                      self.get_id(), db)
                    self._incoming[end_point.get_id()] = end_point
                    end_point._outgoing[self.get_id()] = self
                    debug(self, u"Link created: %s <= %s" % \
                          (self, end_point))
                    debug(self, u"Check is linked: %s" % \
                          self.is_linked_from(end_point))
                    if handle_ta:
                        db.commit()
                    return True
                except Exception, e:
                    error(to_unicode(e))
            else:
                error(self, "Link failed: %s <= %s, the link is " \
                      "already existing!" % (self, end_point))
        else:
            error(self, "Link failed: %s <= %s, the link is not " \
                  "allowed!" % (self, end_point))
        # Roll back if db was created here
        if handle_ta:
            db.rollback()
        return False
        
    def del_link_to(self, end_point, db=None):
        """Deletes the outgoing link to the specified endpoint."""
        # Make sure the links are loaded if needed
        self.get_outgoing()
        try:
            # Delete the link from the DB
            self._delete_link(src=self.get_id(), 
                              dest=end_point.get_id())
            self._outgoing.has_key(end_point.get_id())
            # Load incoming links if needed
            end_point.get_incoming()
            if end_point._incoming.has_key(self.get_id()):
                del end_point._incoming[self.get_id()]
            else:
                warning(self, "%s is linked to %s, but there is no " \
                        "incoming link..." % (self, end_point))
            del self._outgoing[end_point.get_id()]
            return True
        except:
            warning(self, "%s is not linked to %s, not deleting." % \
                    (self, end_point))
            return False
        
    def del_link_from(self, end_point, db=None):
        """Deletes the outgoing link to the specified endpoint."""
        # Make sure the links are loaded if needed
        self.get_incoming()
        try:
            self._delete_link(src=end_point.get_id(), 
                              dest=self.get_id())
            self._incoming.has_key(end_point.get_id())
            # Make sure the end_point links are loaded
            end_point.get_outgoing()
            if end_point._outgoing.has_key(self.get_id()):
                del end_point._outgoing[self.get_id()]
            else:
                warning(self, "%s is linked from %s, but there is " \
                        "no outgoing link..." % (self, end_point))
            del self._incoming[end_point.get_id()]
            return True
        except: 
            warning(self, "%s is not linked from %s, not deleting..." % (self, end_point))
            return False
        
    def del_all_links(self, db=None):
        """Deletes all the links from this endpoint"""
        try:
            self._delete_all_links(self.get_id(), db=db)
            for dle in self.get_outgoing():
                dle.del_link_from(self, db=db)
            self._outgoing = dict()
            for sle in self.get_incoming():
                sle.del_link_to(self, db=db)    
            self._incoming = dict()
            return True
        except:
            return False
        
    def get_outgoing(self, db=None):
        """Returns the list of outgoing links"""
        if hasattr(self, '_outgoing'):
            if len(self._outgoing) == 0:
                self._load_outgoing_links(db=db)
            return self._outgoing.values()
        return []

    def get_incoming(self, db=None):
        """Returns the list of incoming links"""
        if hasattr(self, '_incoming'):
            if len(self._incoming) == 0:
                self._load_incoming_links(db=db)
            return self._incoming.values()
        return []
    
    def _build_dict(self, agilo_ticket):
        """Returns the dictionary for the given end_point"""
        d_ep = {'id': agilo_ticket.get_id(),
                'type': agilo_ticket.get_alias(), # It will be used for display only
                'summary': agilo_ticket[Key.SUMMARY],
                'status': agilo_ticket[Key.STATUS]}
        
        # Now fills specific type field as Options
        if self._show_on_link is not None and \
                self._show_on_link.has_key(agilo_ticket.get_type()):
            debug(self, u"Link Fields: %s for type: %s" % \
                        (self._show_on_link[agilo_ticket.get_type()], 
                         agilo_ticket.get_type()))
            options = []
            for opt in self._show_on_link[agilo_ticket.get_type()]:
                val = agilo_ticket[opt]
                if val:
                    options.append((self.get_label(opt), val))
            if len(options) > 0:
                d_ep['options'] = options
        return d_ep
    
    def _add_links_to_serialized_dict(self, serialized_dict):
        for name, tickets in [('outgoing_links', self.get_outgoing()), 
                              ('incoming_links', self.get_incoming())]:
            serialized_dict[name] = []
            for ticket in tickets:
                serialized_dict[name].append(ticket.id)
    
    def as_dict(self):
        """Serialize this ticket in a dictionary. This dictionary does not 
        contain fields which are not allowed for this ticket type. Also the 
        returned dictionary contains the ID although it is not a real trac 
        'field'."""
        if self.id is None:
            raise ValueError('Ticket is not yet in the database.')
        dict_data = {Key.ID: int(self.id)}
        for field in self.fields:
            name = field[Key.NAME]
            if field.get(Key.SKIP, False):
                continue
            dict_data[name] = self[name]
        for name in self.get_calculated_fields_names():
            dict_data[name] = self[name]
        self._add_links_to_serialized_dict(dict_data)
        dict_data['time_of_last_change'] = to_timestamp(self.time_changed)
        return dict_data
    
    @classmethod
    def as_agilo_ticket(cls, ticket):
        """Return the equivalent AgiloTicket instance for the specified ticket
        (even if it is a trac.Ticket."""
        if not isinstance(ticket, AgiloTicket):
            return AgiloTicket(ticket.env, ticket.id)
        return ticket
    
    def get_outgoing_dict(self):
        """
        Process the linked end point parameter applying the defined operator,
        an storing the result of the operation into the local end point variable
        result.
        """
        res = []
        outs = self.get_outgoing()
        # Sort the links by summary or by special property sort
        outs.sort(By(Column(Key.SUMMARY)))
        if len(outs) > 0 and self._sort and self._sort.has_key(outs[0].get_type()):
            props = self._sort.get(outs[0].get_type(), [])
            # We want the logical order of sort put in trac.ini to match the stable
            # sorting of python list sort
            opt = None
            for prop in props:
                if prop.find(':') != -1:
                    prop, opt = prop.split(':')
                debug(self, u"Sorting by: %s, desc: %s" % (prop, opt=='desc'))
                outs.sort(By(Column(prop), desc=(opt=='desc')))
        for ep in outs:
            res.append(self._build_dict(ep))
        debug(self, u"Sorted outgoing links: %s" % res)
        return res
        
    def get_incoming_dict(self):
        """
        Process the linked enpoint parameter applying the defined operator,
        an storing the result of the operation into the local endpoint variable
        result.
        """
        res = []
        for ep in self.get_incoming():
            res.append(self._build_dict(ep))
        return res
    
    # OVERRIDE
    # This method is only present since 0.11.2 but we have a special hack in
    # agilo_ticket_edit so that is also used in Trac 0.11.1
    def get_value_or_default(self, name):
        """This method is used from the template engine Genshi to get safely 
        value of the fields. It can be overridden to return special values for
        specific fields, such as type <-> alias"""
        if (name != Key.TYPE) or (not AgiloConfig(self.env).is_agilo_enabled):
            return super(AgiloTicket, self).get_value_or_default(name)
        else:
            return self.get_alias()
        
    def __setitem__(self, attr, value):
        """Sets the ticket attribute (attr) to the given value"""
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self).__setitem__(attr, value)
        elif attr in self.get_calculated_fields_names():
            debug(self, u"%s not setting calculated property named %s" % \
                        (self, attr))
        else:
            if attr == Key.TYPE:
                # If the type is changed the configuration will be updated
                if not self._reset_type_fields(value):
                    # not a valid type or the same type
                    return
            if attr == Key.SPRINT and self.values.get(Key.SPRINT) != value:
                # Reset the team members if the restrict owner option is active
                self._set_team_members(sprint_name=value)
            # set the value in the ticket
            return super(AgiloTicket, self).__setitem__(attr, value)
    
    def _check_business_rules(self):
        """Checks if this ticket validates against business rules defined"""
        # Validate business rules
        from agilo.scrum.workflow.api import RuleEngine
        RuleEngine(self.env).validate_rules(self)
        
    # OVERRIDE
    def save_changes(self, author, comment, when=None, db=None, cnum=''):
        """
        Store ticket changes in the database. The ticket must already exist in
        the database.  Returns False if there were no changes to save, True
        otherwise.
        """
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self).save_changes(author, comment, 
                                                         when=when, db=db, cnum=cnum)
        debug(self, "Called save_changes(%s, %s)..." % (author, comment))
        self._check_business_rules()
        debug(self, "Saving ticket changes (%s, %s, %s, %s, %s)" % \
                    (author, comment, when, db, cnum))
        res = super(AgiloTicket, self).save_changes(author, comment, when, db, cnum)
        return res
    
    # OVERRIDE
    def insert(self, when=None, db=None):
        """
        Intercept the insert() of the trac Ticket, and after that initialize
        the AgiloTicket properties.
        """
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self).insert(when=when, db=db)
        debug(self, "Called insert()...")
        self._check_business_rules()
        t_id = super(AgiloTicket, self).insert(when=when, db=db)
        self._reset_type_fields(self.get_type())
        # Setting debug string now that we have an id, used by agilo.utils.log.*
        self._DEBUG = "[%s]: " % self
        # Update the resource identifier, cause it is created in the Ticket __init__
        # when there is not yet and ID for new tickets.
        self.resource.id = t_id
        return t_id # Respect normal ticket behavior
    
    # OVERRIDE
    def delete(self, db=None):
        """
        Intercept the delete of the ticket to remove the links
        """
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self).delete(db=db)
        debug(self, "Called delete() for ticket #%s..." % self.id)
        db, handle_ta = self._get_db_for_write(db)
        super(AgiloTicket, self).delete(db=db)
        # if deleted we remove all the links too
        self.del_all_links(db=db)
        if handle_ta:
            db.commit()
        # Return for convenience the previous ticket id, still
        # existing in the DB, used by the TicketModule to redirect
        # to a sensible ticket.
        cursor = db.cursor()
        cursor.execute("SELECT id FROM ticket WHERE id < %s ORDER BY id DESC" % self.id)
        row = cursor.fetchone()
        if row is not None:
            return row[0]
    
    def _get_calculated_attribute_value(self, attr):
        """Returns the value of a calculated attribute (assumes that the 
        attribute name really exists in this ticket)."""
        debug(self, "Requested calculated value %s for type %s" % (attr, self.get_type()))
        calc_value = None
        if hasattr(self, '_calculated') and attr in self._calculated:
            operator = self._calculated[attr]
            try:
                calc_value = operator(self)
            except Exception, e:
                error(self, u"Calculation Error: %s" % e)
        return calc_value
    
    def _alias_for_genshi(self):
        value = None
        try:
            if inspect.stack()[1][3] == 'lookup_item':
                value = self.get_alias()
        except IndexError:
            # For some reason unknown to me inspect.stack() may fail
            # with an index error sometimes (burndown chart embedded
            # in a wiki macro) so we need to guard against this.
            #  File "/usr/lib64/python2.5/inspect.py", line 885, in stack
            #    return getouterframes(sys._getframe(1), context)
            #  File "/usr/lib64/python2.5/inspect.py", line 866, in getouterframes
            #    framelist.append((frame,) + getframeinfo(frame, context))
            #  File "/usr/lib64/python2.5/inspect.py", line 841, in getframeinfo
            #    lines, lnum = findsource(frame)
            #  File "/usr/lib64/python2.5/inspect.py", line 510, in findsource
            #    if pat.match(lines[lnum]): break
            #IndexError: list index out of range
            pass
        return value
    
    def __getitem__(self, attr):
        """Gets the local ticket attribute (attr) value and returns it"""
        if not AgiloConfig(self.env).is_agilo_enabled:
            return super(AgiloTicket, self).__getitem__(attr)
        value = None
        if hasattr(self, '_calculated') and self._calculated is not None and \
                attr in self._calculated:
            value = self._get_calculated_attribute_value(attr)
            debug(self, "Requested calculated property: %s = %s (%s)"  % \
                  (attr, value, type(value)))
        else:
            try:
                # Hack for backwards compatibility with trac 0.11.1
                if attr == Key.TYPE and self.is_old_trac:
                    value = self._alias_for_genshi()
                # The super method is doing nothing as of 0.11.2
                #value = super(AgiloTicket, self).__getitem__(attr)
                if not value:
                    value = self.values.get(attr)
                # with 0.11.2 we don't save anymore all the fields, therefore
                # we need to fake the milestone field in case some milestone
                # backlog want to see all the sprint ticket too
                if attr == Key.MILESTONE and not value and \
                        Key.SPRINT in self.fields_for_type:
                    sprint = self.get_sprint()
                    if sprint:
                        value = sprint.milestone
                
                if isinstance(value, basestring) and value == 'None':
                    value = None
                debug(self, u"Requested attribute: %s = %s (%s)" % \
                      (attr, value, type(value))) 
            except KeyError:
                warning(self, u"%s(%d, %s) has no property named %s, can't get value..." % \
                               (self, self.id, self.get_type(), attr))
        return value
    
    #######################################################################
    ## Database connection API for AgiloTicket                           ##
    #######################################################################
    def _get_type(self, tkt_id, db=None):
        """Get the type of the ticket given the id from the DB"""
        db, handle_ta = self._get_db_for_write(db)
        sql_get_type = "SELECT type FROM ticket WHERE id=%s" % tkt_id
        cursor = db.cursor()
        cursor.execute(sql_get_type)
        t_type = cursor.fetchone()
        if t_type:
            return t_type[0]
        
    def _is_link(self, src, dest):
        """
        Checks if a link is already existing on the database directly.
        This method is called directly by one of the is_linked_to or
        is_linked_from method
        """
        db = self.env.get_db_cnx()
        sql_query = "SELECT 1 FROM %s WHERE src=%d AND dest=%d" % (LINKS_TABLE, src, dest)
        debug(self, "SQL Query: %s" % sql_query)
        cursor = db.cursor()
        cursor.execute(sql_query)
        if cursor.fetchone():
            return True
        return False
        
    def _create_link(self, src, dest, db=None):
        """
        creates a link between src and dest. The link is directional 
        and has to be allowed from config (trac.ini)
        """
        db, handle_ta = self._get_db_for_write(db)
        sql_query = "INSERT INTO %s (src, dest) VALUES (%d, %d)" % (LINKS_TABLE, src, dest)
        debug(self, "SQL Query: %s" % sql_query)
        try:            
            cursor = db.cursor()
            cursor.execute(sql_query)
            if handle_ta:
                db.commit()
                debug(self, "DB Committed, created link %d => %d" % (src, dest))
        except Exception, e:
            error(self, to_unicode(e))
            if handle_ta:
                db.rollback()
            raise TracError("Link Already existing %d => %d! ERROR: %s" % (src, dest, to_unicode(e)))
        
    def _delete_link(self, src, dest, db=None):
        """deletes the link between src and dest"""
        db, handle_ta = self._get_db_for_write(db)
        sql_query = "DELETE FROM %s WHERE src=%d AND dest=%d" % (LINKS_TABLE, src, dest)
        debug(self, "SQL Query: %s" % sql_query)
        try:
            cursor = db.cursor()
            cursor.execute(sql_query)
            if handle_ta:
                db.commit()
            debug(self, "DB Committed, deleted link %d => %d" % (src, dest))
        except Exception, e:
            error(self, to_unicode(e))
            if handle_ta:
                db.rollback()
            raise TracError("ERROR: An error occurred while trying to delete link %d => %d, %s" % \
                            (src, dest, to_unicode(e)))
        
    def _delete_all_links(self, t_id, db=None):
        """deletes all the links with src or dest equal t_id"""
        db, handle_ta = self._get_db_for_write(db)
        sql_query = "DELETE FROM %s WHERE src=%d OR dest=%d" % (LINKS_TABLE, t_id, t_id)
        try:
            cursor = db.cursor()
            cursor.execute(sql_query)
            if handle_ta:
                db.commit()
        except Exception, e:
            error(self, to_unicode(e))
            if handle_ta:
                db.rollback()
            raise TracError(to_unicode(e))
            
    # Get links from link table, both incoming and outgoing
    # needed for AgiloTypesModule to load links before showing
    # ticket details.
    def _load_incoming_links(self, db=None):
        """
        Returns a list of dictionaries representing the incoming links to 
        this ticket id.
        """
        sql_template = "SELECT t.id, t.type FROM ticket t INNER JOIN " + \
                       "%s l ON l.$from = t.id AND " % LINKS_TABLE + \
                       "l.$to = $id ORDER BY t.type"
        sql_query = string.Template(sql_template)
        
        db, handle_ta = self._get_db_for_write(db)
        try:
            cursor = db.cursor()
            cursor.execute(sql_query.substitute({'from': 'src', 'to': 'dest', 'id': self.get_id()}))
        except Exception, e:
            error(self, to_unicode(e))
            if handle_ta:
                db.rollback()
            raise TracError("An error occurred while loading incoming links: %s" % to_unicode(e))
            
        # Now fetch data and build the incoming endpoints list
        for t_id, t_type in cursor:
            self._incoming[t_id] = self.tm.get(tkt_id=t_id)
    
    def _load_outgoing_links(self, db=None):
        """
        Returns a list of dictionaries representing the incoming links to 
        this ticket id.
        """
        sql_template = "SELECT t.id, t.type FROM ticket t INNER JOIN " + \
                       "%s l ON l.$from = t.id AND " % LINKS_TABLE + \
                       "l.$to = $id ORDER BY t.type"
        sql_query = string.Template(sql_template)
        
        db, handle_ta = self._get_db_for_write(db)
        try:
            cursor = db.cursor()
            cursor.execute(sql_query.substitute({'from': 'dest', 'to': 'src', 'id': self.get_id()}))
        except Exception, e:
            error(self, to_unicode(e))
            if handle_ta:
                db.rollback()
            raise TracError("An error occurred while loading outgoing links: %s" % to_unicode(e))
            
        # Now fetch data and build the outgoing endpoints tree
        for t_id, t_type in cursor:
            self._outgoing[t_id] = self.tm.get(tkt_id=t_id)


class AgiloTicketModelManager(PersistentObjectModelManager):
    """A ModelManager to manage AgiloTicket Objects"""
    
    model = AgiloTicket
    
    def _get_model_key(self, model_instance=None):
        """
        Private method to return either a list of primary keys or a tuple with
        all the primary keys and unique constraints needed to identify a ticket.
        For ticket is enough the id.
        """
        if isinstance(model_instance, AgiloTicket):
            return ((model_instance.id,), None)
        else:
            return [['tkt_id',], None]
    
    def create(self, *args, **kwargs):
        """Specialized method to create a ticket, first create the 
        ticket, than sets all the parameters, if allowed, than saves 
        the ticket"""
        constructor_params = ['env', 'tkt_id', 'db', 'version', 't_type', 'load']
        save = kwargs.get('save', True)
        # remove it to make sure is not causing any trouble
        if 'save' in kwargs:
            del kwargs['save']
        
        ticket_params = dict()
        for k, v in kwargs.items():
            if k not in constructor_params:
                ticket_params[k] = kwargs.pop(k)
        
        ticket = self.model(self.env, **kwargs)
    
        for k, v in ticket_params.items():
            if hasattr(ticket, k):
                setattr(ticket, k, v)
            else:
                ticket[k] = v
        
        if save:
            self.save(ticket)
        
        return ticket
    
    def get(self, **kwargs):
        """
        It will retrieve a Ticket, we need to set explicitly the parameter 
        load=True in the constructor to make sure the ticket will not be 
        bounced back to the Manager
        """
        kwargs['load'] = True
        return super(AgiloTicketModelManager, self).get(**kwargs)
    
    def save(self, model_instance, **kwargs):
        """
        Method called to save a model instance, in case of AgiloTicket we
        have to pass also comments and author inside.
        """
        if model_instance:
            res = None
            if model_instance.exists:
                author = kwargs.pop('author', None)
                comment = kwargs.pop('comment', None)
                # now it is safe to pass extra parameters
                res = model_instance.save_changes(author, comment, **kwargs)
            else:
                res = model_instance.insert()
            # now store into the cache
            if AgiloConfig(self.env).is_agilo_enabled:
                self.get_cache().set(self._get_model_key(model_instance), 
                                     model_instance)
            return res
    
    def _prepare_query(self, criteria):
        """
        Prepares the query to select tickets, depending on the criteria
        will return a filter string to append to the sql query, and a 
        value dictionary with the replacement for the filter query
        """
        filter = ""
        values = {}
        
        if criteria is not None:
            conditions = []
            for column, condition in criteria.items():
                operator = '='
                if condition is None:
                    operator = ' IS NULL'
                    conditions.append('%s %s' % (column, operator))
                    # NULL is a special keyword/operator in SQL (not a
                    # value) so you can't use named parameters for this.
                    # therefore we assembled the condition by ourselves
                    # and just skip building values/conditions
                    continue
                else:
                    value = condition or ''
                    match = re.match(r'((not)?\s?in)\s*[\[|\(](.+(\s,)?)+[\]|\)]', value )
                    if match:
                        operator = match.group(1)
                        value = eval(match.group(3))
                        param = '%s, '
                        if not isinstance(value, (list, tuple)):
                            value = [value]
                        params = param * len(value)
                        operator += ' (%s)' % params[:-2] # the last comma
                        # special treatment
                        values['_multi_'] = value
                        conditions.append('%s %s' % (column, operator))
                        # this must be the last condition
                        break
                    else:
                        match = re.match(r'(!=|<>|>=|<=)\s*', value)
                        if not match:
                            match = re.match(r'(=|>|<)\s*', value)
                        # get operators from condition and remove
                        if match:
                            operator = match.group(1)
                            value = value.replace(match.group(0), '', 1)
                    
                values[column] = value
                # append a conditions in the form column<operator>%(column)s
                if column in TICKET_COLUMNS:
                    conditions.append('ticket.%s%s%%(%s)s' % (column, operator, column))
                else:
                    conditions.append("ticket_custom.name='%s' AND ticket_custom.value=%%(%s)s" % \
                                      (column, column))
            # Build the filter
            filter = " WHERE " + " AND ".join(conditions)
        
        return (filter, values)
    
    def _build_order_by_clause(self, order_by):
        sql = ''
        order_pairs = list()
        for order_clause in order_by:
            desc = False
            if isinstance(order_clause, basestring) and \
                    order_clause.startswith('-'):
                order_clause = order_clause[1:]
                desc = True
            if order_clause in TICKET_COLUMNS:
                order_pairs.append('%s%s' % \
                                   (order_clause, 
                                    desc and ' DESC' or ''))
            else:
                order_pairs.append('ticket_custom.value%s' % \
                                   (desc and ' DESC' or ''))
                # we can only sort one property if it is
                # custom
                break
        if len(order_pairs) > 0:
            sql = ' ORDER BY ' + ', '.join(order_pairs)
        return sql
    
    def select(self, criteria=None, order_by=None, limit=None, db=None):
        """
        Selects Tickets from the database, given the specified criteria.
        The criteria is expressed as a dictionary of conditions (object 
        fields) to be appended to the select query. The order_by adds 
        the order in which results should be sorted. the '-' sign in 
        front will make the order DESC. The limit parameter limits the 
        results of the SQL query.
        
        Example::
            
            criteria = {'name': 'test', 'users': '> 3', 'team': TheTeam}
            order_by = ['-name', 'users']
            limit = 10
            
        """
        assert criteria is None or isinstance(criteria, dict)
        assert order_by is None or isinstance(order_by, list)
        assert limit is None or isinstance(limit, int)
        # Select statetement for ticket
        sql = "SELECT DISTINCT id, type FROM ticket LEFT OUTER JOIN " \
              "ticket_custom ON ticket.id=ticket_custom.ticket"
        
        db, handle_ta = get_db_for_write(self.env, db)
        tickets = []
        filter, values = self._prepare_query(criteria)
        
        try:
            # Now compute the order if there
            if order_by:
                filter += self._build_order_by_clause(order_by)
            if limit:
                filter = filter + " LIMIT %d" % limit
            
            cursor = db.cursor()
            debug(self, "SELECT => Executing Query: %s %s" % \
                        (sql + filter, values))
            safe_execute(cursor, sql + filter, values)
            for row in cursor:
                params = {
                    'tkt_id': int(row[0]),
                    't_type': row[1]
                }
                tickets.append(self.get(**params))
        
        except Exception, e:
            raise UnableToLoadObjectError(_("An error occurred while " \
                                            "getting ticket from the " \
                                            "database: %s" % to_unicode(e)))
        return tickets
    
    def select_tickets_having_properties(self, properties, 
                                         criteria=None, order_by=None,
                                         limit=None, db=None):
        """
        Returns a list of tickets having the defined list of 
        properties, combined with the other normal select paramenters
        """
        types = []
        for prop in properties:
            for t_type, fields in AgiloConfig(self.env).TYPES.items():
                if prop in fields:
                    types.append(t_type)
        # check if we have types to append to the criteria or not
        if len(types) > 0:
            if criteria:
                criteria.update({'type': 'in %s' % types})
            else:
                criteria = {'type': 'in %s' % types}
        # return the select
        return self.select(criteria=criteria, order_by=order_by, 
                           limit=limit, db=db)