Source code for stalker.models.ticket

# -*- coding: utf-8 -*-
# Stalker a Production Asset Management System
# Copyright (C) 2009-2017 Erkan Ozgur Yilmaz
#
# This file is part of Stalker.
#
# Stalker is free software: you can redistribute it and/or modify
# it under the terms of the Lesser GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
#
# Stalker is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# Lesser GNU General Public License for more details.
#
# You should have received a copy of the Lesser GNU General Public License
# along with Stalker.  If not, see <http://www.gnu.org/licenses/>

import uuid

from sqlalchemy.exc import UnboundExecutionError
from sqlalchemy.orm import synonym, relationship
from sqlalchemy.orm.mapper import validates
from sqlalchemy import Column, Integer, String, Text
from sqlalchemy.schema import ForeignKey, Table
from sqlalchemy.types import Enum

from stalker.db.declarative import Base
from stalker.models.entity import Entity, SimpleEntity
from stalker.models.mixins import StatusMixin

from stalker.log import logging_level
import logging

logger = logging.getLogger(__name__)
logger.setLevel(logging_level)

# RESOLUTIONS
FIXED = 'fixed'
INVALID = 'invalid'
WONTFIX = 'wontfix'
DUPLICATE = 'duplicate'
WORKSFORME = 'worksforme'
CANTFIX = 'cantfix'


[docs]class Ticket(Entity, StatusMixin): """Tickets are the way of reporting errors or asking for changes. The Stalker Ticketing system is based on Trac Basic Workflow. For more information please visit `Trac Workflow`_ _`Trac Workflow`:: http://trac.edgewall.org/wiki/TracWorkflow Stalker Ticket system is very flexible, to customize the workflow please update the :class:`.Config.ticket_workflow` dictionary. In the default setup, there are four actions available; ``accept``, ``resolve``, ``reopen``, ``reassign``, and five statuses available ``New``, ``Assigned``, ``Accepted``, ``Reopened``, ``Closed``. :param project: The Project that this Ticket is assigned to. A Ticket in Stalker must be assigned to a Project. ``project`` argument can not be skipped or can not be None. :type project: :class:`.Project` :param str summary: A string which contains the title or a short description of this Ticket. :param enum priority: The priority of the Ticket which is an enum value. Possible values are: +--------------+-------------------------------------------------+ | 0 / TRIVIAL | defect with little or no impact / cosmetic | | | enhancement | +--------------+-------------------------------------------------+ | 1 / MINOR | defect with minor impact / small enhancement | +--------------+-------------------------------------------------+ | 2 / MAJOR | defect with major impact / big enhancement | +--------------+-------------------------------------------------+ | 3 / CRITICAL | severe loss of data due to the defect or highly | | | needed enhancement | +--------------+-------------------------------------------------+ | 4 / BLOCKER | basic functionality is not available until this | | | is fixed | +--------------+-------------------------------------------------+ :param reported_by: An instance of :class:`.User` who created this Ticket. It is basically a synonym for the :attr:`.SimpleEntity.created_by` attribute. Changing the :class:`.Ticket`\ .\ :attr`.Ticket.status` will create a new :class:`.TicketLog` instance showing the previous operation. Even though Tickets needs statuses they don't need to be supplied a :class:`.StatusList` nor :class:`.Status` for the Tickets. It will be automatically filled accordingly. For newly created Tickets the status of the ticket is ``NEW`` and can be changed to other statuses as follows: Status -> Action -> New Status NEW -> resolve -> CLOSED NEW -> accept -> ACCEPTED NEW -> reassign -> ASSIGNED ASSIGNED -> resolve -> CLOSED ASSIGNED -> accept -> ACCEPTED ASSIGNED -> reassign -> ASSIGNED ACCEPTED -> resolve -> CLOSED ACCEPTED -> accept -> ACCEPTED ACCEPTED -> reassign -> ASSIGNED REOPENED -> resolve -> CLOSED REOPENED -> accept -> ACCEPTED REOPENED -> reassign -> ASSIGNED CLOSED -> reopen -> REOPENED actions available: resolve reassign accept reopen The :attr:`.Ticket.name` is automatically generated by using the ``stalker.config.Config.ticket_label`` attribute and :attr:`.Ticket.ticket_number`\ . So if defaults are used the first ticket name will be "Ticket#1" and the second "Ticket#2" and so on. For every project the number will restart from 1. Use the :meth:`.Ticket.resolve`, :meth:`.Ticket.reassign`, :meth:`.Ticket.accept`, :meth:`.Ticket.reopen` methods to change the status of the current Ticket. Changing the status of the Ticket will create :class:`.TicketLog` entries reflecting the change made. """ # logs attribute __auto_name__ = True __tablename__ = "Tickets" #__table_args__ = ( # UniqueConstraint("project_id", 'number'), {} #) __mapper_args__ = {"polymorphic_identity": "Ticket"} ticket_id = Column( "id", Integer, ForeignKey("Entities.id"), primary_key=True ) # TODO: use ProjectMixin project_id = Column('project_id', Integer, ForeignKey('Projects.id'), nullable=False) _project = relationship( 'Project', primaryjoin='Tickets.c.project_id==Projects.c.id', back_populates='tickets' ) _number = Column( 'number', Integer, autoincrement=True, default=1, nullable=False, unique=True, ) related_tickets = relationship( 'Ticket', secondary='Ticket_Related_Tickets', primaryjoin='Tickets.c.id==Ticket_Related_Tickets.c.ticket_id', secondaryjoin='Ticket_Related_Tickets.c.related_ticket_id==' 'Tickets.c.id', doc="""A list of other Ticket instances which are related to this one. Can be used to related Tickets to point to a common problem. The Ticket itself can not be assigned to this list """ ) summary = Column(Text) logs = relationship( 'TicketLog', primaryjoin='Tickets.c.id==TicketLogs.c.ticket_id', back_populates='ticket', cascade='all, delete-orphan' ) links = relationship( 'SimpleEntity', secondary='Ticket_SimpleEntities' ) comments = synonym( 'notes', doc="""A list of :class:`.Note` instances showing the comments made for this Ticket instance. It is a synonym for the :attr:`.Ticket.notes` attribute. """ ) reported_by = synonym('created_by', doc="Shows who created this Ticket") owner_id = Column('owner_id', Integer, ForeignKey('Users.id')) owner = relationship( 'User', primaryjoin='Tickets.c.owner_id==Users.c.id' ) resolution = Column(String(128)) priority = Column( Enum('TRIVIAL', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER', name='PriorityType'), default='TRIVIAL', doc="""The priority of the Ticket which is an enum value. Possible values are: +--------------+-------------------------------------------------+ | 0 / TRIVIAL | defect with little or no impact / cosmetic | | | enhancement | +--------------+-------------------------------------------------+ | 1 / MINOR | defect with minor impact / small enhancement | +--------------+-------------------------------------------------+ | 2 / MAJOR | defect with major impact / big enhancement | +--------------+-------------------------------------------------+ | 3 / CRITICAL | severe loss of data due to the defect or highly | | | needed enhancement | +--------------+-------------------------------------------------+ | 4 / BLOCKER | basic functionality is not available until this | | | is fixed | +--------------+-------------------------------------------------+ """ ) def __init__(self, project=None, links=None, priority='TRIVIAL', summary=None, **kwargs): # just force auto name generation self._number = self._generate_ticket_number() from stalker import defaults kwargs['name'] = '%s #%i' % (defaults.ticket_label, self.number) super(Ticket, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) self._project = project self.priority = priority if links is None: links = [] self.links = links self.summary = summary def _number_getter(self): """returns the number attribute """ return self._number number = synonym( '_number', descriptor=property(_number_getter), doc="""The automatically generated number for the tickets. """ ) def _project_getter(self): """returns the project attribute """ return self._project project = synonym( '_project', descriptor=property(_project_getter) ) @classmethod def _maximum_number(cls): """returns the maximum available number from the database :return: integer """ try: # do your query from stalker.db.session import DBSession with DBSession.no_autoflush: max_ticket = Ticket.query \ .order_by(Ticket.number.desc()) \ .first() except UnboundExecutionError: max_ticket = None return max_ticket.number if max_ticket is not None else 0 def _generate_ticket_number(self): """auto generates a number for the ticket :return: integer """ # TODO: try to make it atomic return self._maximum_number() + 1 @validates('related_tickets') def _validate_related_tickets(self, key, related_ticket): """validates the given related_ticket attribute """ if not isinstance(related_ticket, Ticket): raise TypeError( '%s.related_ticket attribute should be a list of other ' 'stalker.models.ticket.Ticket instances not %s' % (self.__class__.__name__, related_ticket.__class__.__name__) ) if related_ticket is self: raise ValueError( '%s.related_ticket attribute can not have itself in the list' % self.__class__.__name__ ) return related_ticket @validates('_project') def _validate_project(self, key, project): """validates the given project instance """ from stalker import Project if project is None or not isinstance(project, Project): raise TypeError( '%s.project should be an instance of ' 'stalker.models.project.Project, not %s' % (self.__class__.__name__, project.__class__.__name__) ) return project @validates('summary') def _validate_summary(self, key, summary): """validates the given summary value """ if summary is None: summary = '' from stalker import __string_types__ if not isinstance(summary, __string_types__): raise TypeError( '%s.summary should be an instance of str, not %s' % (self.__class__.__name__, summary.__class__.__name__) ) return summary def __action__(self, action, created_by, action_arg=None): """updates the ticket status and creates a ticket log according to the Ticket.__available_actions__ dictionary :param str action: The name of the action :param stalker.models.auth.User created_by: The User creating this action """ from stalker import defaults statuses = defaults.ticket_workflow[action].keys() status = self.status.name return_value = None if status in statuses: action_data = defaults.ticket_workflow[action][status] new_status_code = action_data['new_status'] action_name = action_data['action'] # there is an action defined for this status # get the to_status from_status = self.status to_status = self.status_list[new_status_code] self.status = to_status # call the action with action_arg func = getattr(self, action_name) func(action_arg) ticket_log = TicketLog( self, from_status, to_status, action, created_by=created_by ) # create log entry self.logs.append(ticket_log) return_value = ticket_log return return_value
[docs] def resolve(self, created_by=None, resolution=''): """resolves the ticket """ return self.__action__('resolve', created_by, resolution)
[docs] def accept(self, created_by=None): """accepts the ticket """ return self.__action__('accept', created_by, created_by)
[docs] def reassign(self, created_by=None, assign_to=None): """reassigns the ticket """ return self.__action__('reassign', created_by, assign_to)
[docs] def reopen(self, created_by=None): """reopens the ticket """ return self.__action__('reopen', created_by)
# actions
[docs] def set_owner(self, *args): """sets owner to the given owner """ self.owner = args[0]
[docs] def set_resolution(self, *args): """sets the timing_resolution """ self.resolution = args[0]
[docs] def del_resolution(self, *args): """deletes the timing_resolution """ self.resolution = ''
def __eq__(self, other): """the equality operator """ return super(Ticket, self).__eq__(other) and \ isinstance(other, Ticket) and \ other.name == self.name and \ other.number == self.number and \ other.status == self.status and \ other.logs == self.logs and \ other.priority == self.priority def __hash__(self): """the overridden __hash__ method """ return super(Ticket, self).__hash__()
[docs]class TicketLog(SimpleEntity): """Holds :class:`.Ticket`\ .\ :attr:`.Ticket.status` change operations. :param ticket: An instance of :class:`.Ticket` which the subject to the operation. :type ticket: :class:`.Ticket` :param from_status: Holds a reference to a :class:`.Status` instance which is the previous status of the :class:`.Ticket`\ . :param to_status: Holds a reference to a :class:`.Status` instance which is the new status of the :class;`.Ticket`\ . :param operation: An Enumerator holding the type of the operation. Possible values are: RESOLVE or REOPEN Operations follow the `Track Workflow`_\ , .. image:: http://trac.edgewall.org/chrome/common/guide/original-workflow.png :width: 787 px :height: 509 px :align: left .. _Track Workflow: http://trac.edgewall.org/wiki/TracWorkflow """ from stalker import defaults # need to limit it with a scope # TODO: there are no tests for the TicketLog class __tablename__ = 'TicketLogs' __mapper_args__ = {'polymorphic_identity': 'TicketLog'} ticket_log_id = Column('id', ForeignKey('SimpleEntities.id'), primary_key=True) from_status_id = Column(Integer, ForeignKey('Statuses.id')) to_status_id = Column(Integer, ForeignKey('Statuses.id')) from_status = relationship( 'Status', primaryjoin='TicketLogs.c.from_status_id==Statuses.c.id' ) to_status = relationship( 'Status', primaryjoin='TicketLogs.c.to_status_id==Statuses.c.id' ) action = Column( Enum(*defaults.ticket_workflow.keys(), name='TicketActions') ) ticket_id = Column(Integer, ForeignKey('Tickets.id')) ticket = relationship( 'Ticket', primaryjoin='TicketLogs.c.ticket_id==Tickets.c.id', back_populates='logs' ) def __init__(self, ticket=None, from_status=None, to_status=None, action=None, **kwargs): kwargs['name'] = 'TicketLog_' + uuid.uuid4().hex super(TicketLog, self).__init__(**kwargs) self.ticket = ticket self.from_status = from_status self.to_status = to_status self.action = action
# A secondary Table for Ticket to Ticket relations Ticket_Related_Tickets = Table( 'Ticket_Related_Tickets', Base.metadata, Column('ticket_id', Integer, ForeignKey('Tickets.id'), primary_key=True), Column('related_ticket_id', Integer, ForeignKey('Tickets.id'), primary_key=True), extend_existing=True ) # Ticket SimpleEntity Relation, link anything to a ticket Ticket_SimpleEntities = Table( 'Ticket_SimpleEntities', Base.metadata, Column('ticket_id', Integer, ForeignKey('Tickets.id'), primary_key=True), Column('simple_entity_id', Integer, ForeignKey('SimpleEntities.id'), primary_key=True) )