Source code for stalker.models.mixins

# -*- coding: utf-8 -*-
# Stalker a Production Asset Management System
# Copyright (C) 2009-2013 Erkan Ozgur Yilmaz
# 
# This file is part of Stalker.
# 
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation;
# version 2.1 of the License.
# 
# This library 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 GNU
# Lesser General Public License for more details.
# 
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301 USA

import datetime
import logging

from sqlalchemy import (Table, Column, String, Integer, ForeignKey, Interval,
                        DateTime, PickleType)
from sqlalchemy.exc import UnboundExecutionError
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import synonym, relationship, validates

from stalker import defaults
from stalker.db import Base
from stalker.db.session import DBSession
from stalker.log import logging_level
from stalker.models import make_plural

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


def create_secondary_table(
        primary_cls_name,
        secondary_cls_name,
        primary_cls_table_name,
        secondary_cls_table_name,
        secondary_table_name=None
):
    """creates any secondary table
    """

    plural_secondary_cls_name = make_plural(secondary_cls_name)

    # use the given class_name and the class_table
    if not secondary_table_name:
        secondary_table_name = \
            primary_cls_name + "_" + plural_secondary_cls_name

    # check if the table is already defined
    if secondary_table_name not in Base.metadata:
        secondary_table = Table(
            secondary_table_name, Base.metadata,
            Column(
                primary_cls_name.lower() + "_id",
                Integer,
                ForeignKey(primary_cls_table_name + ".id"),
                primary_key=True,
            ),

            Column(
                secondary_cls_name.lower() + "_id",
                Integer,
                ForeignKey(secondary_cls_table_name + ".id"),
                primary_key=True,
            )
        )
    else:
        secondary_table = Base.metadata.tables[secondary_table_name]

    return secondary_table


[docs]class TargetEntityTypeMixin(object): """Adds target_entity_type attribute to mixed in class. :param target_entity_type: The target entity type which this class is designed for. Should be a class or a class name. For example:: from stalker import SimpleEntity, TargetEntityTypeMixin, Project class A(SimpleEntity, TargetEntityTypeMixin): __tablename__ = "As" __mapper_args__ = {"polymorphic_identity": "A"} def __init__(self, **kwargs): super(A, self).__init__(**kwargs) TargetEntityTypeMixin.__init__(self, **kwargs) a_obj = A(target_entity_type=Project) The ``a_obj`` will only be accepted by :class:`~stalker.models.project.Project` instances. You can not assign it to any other class which accepts a :class:`~stalker.models.type.Type` instance. To control the mixed-in class behaviour add these class variables to the mixed in class: __nullable_target__ : controls if the target_entity_type can be nullable or not. Default is False. __unique_target__ : controls if the target_entity_type should be unique, so there is only one object for one type. Default is False. """ __nullable_target__ = False __unique_target__ = False @declared_attr def _target_entity_type(cls): return Column( "target_entity_type", String(128), nullable=cls.__nullable_target__, unique=cls.__unique_target__ )
[docs] def __init__(self, target_entity_type=None, **kwargs): self._target_entity_type = \ self._validate_target_entity_type(target_entity_type)
def _validate_target_entity_type(self, target_entity_type_in): """validates the given target_entity_type value """ # it can not be None if target_entity_type_in is None: raise TypeError("%s.target_entity_type can not be None" % self.__class__.__name__) if str(target_entity_type_in) == "": raise ValueError("%s.target_entity_type can not be empty" % self.__class__.__name__) # check if it is a class if isinstance(target_entity_type_in, type): target_entity_type_in = target_entity_type_in.__name__ return str(target_entity_type_in) def _target_entity_type_getter(self): return self._target_entity_type @declared_attr def target_entity_type(cls): return synonym( "_target_entity_type", descriptor=property( fget=cls._target_entity_type_getter, doc="""The entity type which this object is valid for. Usually it is set to the TargetClass directly. """ ) )
[docs]class StatusMixin(object): """Makes the mixed in object statusable. This mixin adds status and status_list attributes to the mixed in class. Any object that needs a status and a corresponding status list can include this mixin. When mixed with a class which don't have an __init__ method, the mixin supplies one, and in this case the parameters below must be defined. :param status_list: this attribute holds a status list object, which shows the possible statuses that this entity could be in. This attribute can not be empty or None. Giving a StatusList object, the StatusList.target_entity_type should match the current class. .. versionadded:: 0.1.2.a4 The status_list argument now can be skipped or can be None if there is an active database connection (stalker.models.DBSession is not None) and there is a suitable :class:`~stalker.models.status.StatusList` instance in the database whom :attr:`~stalker.models.status.StatusList.target_entity_type` attribute is set to the current mixed-in class name. :param status: It is a :class:`~stalker.models.status.Status` instance which shows the current status of the statusable object. Integer values are also accepted, which shows the index of the desired status in the ``status_list`` attribute of the current statusable object. If a :class:`~stalker.models.status.Status` instance is supplied, it should also be present in the ``status_list`` attribute. If set to None then the first :class:`~stalker.models.status.Status` instance in the ``status_list`` will be used. .. versionadded:: 0.2.0 Status attribute as Status instance: It is now possible to set the status of the instance by a :class:`~stalker.models.status.Status` instance directly. And the :attr:`~stalker.models.mixins.StatusMixin.status` will return a proper :class:`~stalker.models.status.Status` instance. """
[docs] def __init__(self, status=None, status_list=None, **kwargs): self.status_list = status_list self.status = status # logger.debug('%s.status: %s' % (self.__class__.__name__, status))
@declared_attr def status_id(cls): return Column( 'status_id', Integer, ForeignKey('Statuses.id'), nullable=False # This is set to nullable=True but it is impossible to set the # status to None by using this Declarative approach. # # This is done in that way cause SQLAlchemy was flushing the data # (AutoFlush) preliminarily while checking if the given Status was # in the related StatusList, and it was complaining about the # status can not be null ) @declared_attr def status(cls): return relationship( 'Status', primaryjoin= \ "%s.status_id==Status.status_id" % cls.__name__, doc="""The current status of the object. It is a :class:`~stalker.models.status.Status` instance which is one of the Statuses stored in the ``status_list`` attribute of this object. """ ) @declared_attr def status_list_id(cls): return Column( 'status_list_id', Integer, ForeignKey('StatusLists.id'), #, use_alter=True, name="x"), nullable=False ) @declared_attr def status_list(cls): return relationship( "StatusList", primaryjoin= \ "%s.status_list_id==StatusList.status_list_id" % cls.__name__, ) @validates("status_list") def _validate_status_list(self, key, status_list): """validates the given status_list_in value """ from stalker.models.status import StatusList if status_list is None: # check if there is a db setup and try to get the appropriate # StatusList from the database # disable autoflush to prevent premature class initialization with DBSession.no_autoflush: try: # try to get a StatusList with the target_entity_type is # matching the class name status_list = DBSession.query(StatusList) \ .filter_by(target_entity_type=self.__class__.__name__) \ .first() except UnboundExecutionError: # it is not mapped just skip it pass # if it is still None if status_list is None: # there is no db so raise an error because there is no way # to get an appropriate StatusList raise TypeError( "%s instances can not be initialized without a " "stalker.models.status.StatusList instance, please pass a " "suitable StatusList (StatusList.target_entity_type=%s) " "with the 'status_list' argument" % (self.__class__.__name__, self.__class__.__name__) ) else: # it is not an instance of status_list if not isinstance(status_list, StatusList): raise TypeError( "%s.status_list should be an instance of " "stalker.models.status.StatusList not %s" % (self.__class__.__name__, status_list.__class__.__name__) ) # check if the entity_type matches to the StatusList.target_entity_type if self.__class__.__name__ != status_list.target_entity_type: raise TypeError( "the given StatusLists' target_entity_type is %s, " "whereas the entity_type of this object is %s" % \ (status_list.target_entity_type, self.__class__.__name__)) return status_list @validates('status') def _validate_status(self, key, status): """validates the given status value """ from stalker.models.status import Status, StatusList if not isinstance(self.status_list, StatusList): raise TypeError("please set the %s.status_list attribute first" % self.__class__.__name__) # it is set to None if status is None: with DBSession.no_autoflush: status = self.status_list.statuses[0] # it is not an instance of status or int if not isinstance(status, (Status, int)): raise TypeError("%s.status must be an instance of " "stalker.models.status.Status or an integer " "showing the index of the Status object in the " "%s.status_list, not %s" % (self.__class__.__name__, self.__class__.__name__, status.__class__.__name__)) if isinstance(status, int): # if it is not in the correct range: if status < 0: raise ValueError("%s.status must be a non-negative integer" % self.__class__.__name__) if status >= len(self.status_list.statuses): raise ValueError("%s.status can not be bigger than the length " "of the status_list" % self.__class__.__name__) # get the tatus instance out of the status_list instance status = self.status_list[status] # check if the given status is in the status_list # logger.debug('self.status_list: %s' % self.status_list) # logger.debug('given status: %s' % status) if status not in self.status_list: raise ValueError("The given Status instance for %s.status is not " "in the %s.status_list, please supply a status " "from that list." % (self.__class__.__name__, self.__class__.__name__) ) return status
[docs]class ScheduleMixin(object): """Adds schedule info to the mixed in class. Adds schedule information like ``start``, ``end`` and ``duration``. These attributes will be used in TaskJuggler. Because ``effort`` is only meaningful if there are some ``resources`` this attribute has been left special for :class:`~stalker.models.task.Task` class. The ``length`` has not been implemented because of its rare use. The preceding order for the attributes is as follows:: start > end > duration So if all of the parameters are given only the ``start`` and the ``end`` will be used and the ``duration`` will be calculated accordingly. In any other conditions the missing parameter will be calculated from the following table: +-------+-----+----------+----------------------------------------+ | start | end | duration | DEFAULTS | +=======+=====+==========+========================================+ | | | | start = datetime.datetime.now() | | | | | | | | | | duration = datetime.timedelta(days=10) | | | | | | | | | | end = start + duration | +-------+-----+----------+----------------------------------------+ | X | | | duration = datetime.timedelta(days=10) | | | | | | | | | | end = start + duration | +-------+-----+----------+----------------------------------------+ | X | X | | duration = end - start | +-------+-----+----------+----------------------------------------+ | X | | X | end = start + duration | +-------+-----+----------+----------------------------------------+ | X | X | X | duration = end - start | +-------+-----+----------+----------------------------------------+ | | X | X | start = end - duration | +-------+-----+----------+----------------------------------------+ | | X | | duration = datetime.timedelta(days=10) | | | | | | | | | | start = end - duration | +-------+-----+----------+----------------------------------------+ | | | X | start = datetime.datetime.now() | | | | | | | | | | end = start + duration | +-------+-----+----------+----------------------------------------+ Only the ``start``, ``end`` will be stored. The ``duration`` attribute is the direct difference of the the ``start`` and ``end`` attributes, so there is no need to store it. But if will be used in calculation of the start and end values. The start and end attributes have a ``computed`` companion. Which are the return values from TaskJuggler. so for start there is the ``computed_start`` and for end there is the ``computed_end`` attributes. These values are going to be used in Gantt Charts. The date attributes can be managed with timezones. Follow the Python idioms shown in the `documentation of datetime`_ .. _documentation of datetime: http://docs.python.org/library/datetime.html :param start: the start date of the entity, should be a datetime.datetime instance, the start is the pin point for the date calculation. In any condition if the start is available then the value will be preserved. If start passes the end the end is also changed to a date to keep the timedelta between dates. The default value is datetime.datetime.now() :type start: :class:`datetime.datetime` :param end: the end of the entity, should be a datetime.datetime instance, when the start is changed to a date passing the end, then the end is also changed to a later date so the timedelta between the dates is kept. :type end: :class:`datetime.datetime` or :class:`datetime.timedelta` :param duration: The duration of the entity. It is a :class:`datetime.timedelta` instance. The default value is read from the :class:`~stalker.config.Config` class. See the table above for the initialization rules. :type duration: :class:`datetime.timedelta` :param timing_resolution: The timing_resolution of the datetime.datetime object in datetime.timedelta. Uses ``timing_resolution`` settings in the :class:`stalker.config.Config` class which defaults to 1 hour. Setting the timing_resolution to less then 5 minutes is not suggested because it is a limit for TaskJuggler. :type timing_resolution: datetime.timedelta """ # # add this lines for Sphinx # __tablename__ = "ScheduleMixins"
[docs] def __init__(self, start=None, end=None, duration=None, timing_resolution=None, **kwargs): self.timing_resolution = timing_resolution self._validate_dates(start, end, duration)
@declared_attr def _end(cls): return Column("end", DateTime) def _end_getter(self): """The date that the entity should be delivered. The end can be set to a datetime.timedelta and in this case it will be calculated as an offset from the start and converted to datetime.datetime again. Setting the start to a date passing the end will also set the end, so the timedelta between them is preserved, default value is 10 days """ return self._end def _end_setter(self, end_in): self._validate_dates(self.start, end_in, self.duration) @declared_attr def end(cls): return synonym( "_end", descriptor=property( cls._end_getter, cls._end_setter ) ) @declared_attr def _start(cls): return Column("start", DateTime) def _start_getter(self): """The date that this entity should start. Also effects the :attr:`~stalker.models.mixins.ScheduleMixin.end` attribute value in certain conditions, if the :attr:`~stalker.models.mixins.ScheduleMixin.start` is set to a time passing the :attr:`~stalker.models.mixins.ScheduleMixin.end` it will also offset the :attr:`~stalker.models.mixins.ScheduleMixin.end` to keep the :attr:`~stalker.models.mixins.ScheduleMixin.duration` value fixed. :attr:`~stalker.models.mixins.ScheduleMixin.start` should be an instance of class:`datetime.datetime` and the default value is :func:`datetime.datetime.now()` """ return self._start def _start_setter(self, start_in): self._validate_dates(start_in, self.end, self.duration) @declared_attr def start(cls): return synonym( "_start", descriptor=property( cls._start_getter, cls._start_setter, ) ) @declared_attr def _duration(cls): return Column('duration', Interval) def _duration_getter(self): return self._duration def _duration_setter(self, duration_in): if duration_in is not None: if isinstance(duration_in, datetime.timedelta): # set the end to None # to make it recalculated self._validate_dates(self.start, None, duration_in) else: self._validate_dates(self.start, self.end, duration_in) else: self._validate_dates(self.start, self.end, duration_in) @declared_attr def duration(self): return synonym( '_duration', descriptor=property( self._duration_getter, self._duration_setter, doc="""Duration of the entity. It is a datetime.timedelta instance. Showing the difference of the :attr:`.start` and the :attr:`.end`. If edited it changes the :attr:`.end` attribute value.""" ) ) def _validate_dates(self, start, end, duration): """updates the date values """ # logger.debug('start : %s' % start) # logger.debug('end : %s' % end) # logger.debug('duration : %s' % duration) if not isinstance(start, datetime.datetime): start = None if not isinstance(end, datetime.datetime): end = None if not isinstance(duration, datetime.timedelta): duration = None # check start if start is None: # try to calculate the start from end and duration if end is None: # set the defaults start = datetime.datetime.now() if duration is None: # set the defaults duration = defaults.task_duration end = start + duration else: if duration is None: duration = defaults.task_duration start = end - duration # check end if end is None: if duration is None: duration = defaults.task_duration end = start + duration if end < start: # check duration if duration is None or duration < datetime.timedelta(1): duration = datetime.timedelta(1) end = start + duration # round the dates to the timing_resolution self._start = self.round_time(start) self._end = self.round_time(end) self._duration = self._end - self._start @declared_attr def _timing_resolution(cls): return Column("timing_resolution", Interval) def _timing_resolution_getter(self): """returns the timing_resolution """ return self._timing_resolution def _timing_resolution_setter(self, res_in): """sets the timing_resolution """ self._timing_resolution = self._validate_timing_resolution(res_in) logger.debug('self._timing_resolution: %s' % self._timing_resolution) # update date values if self.start and self.end and self.duration: self._validate_dates( self.round_time(self.start), self.round_time(self.end), None ) @declared_attr def timing_resolution(cls): return synonym( '_timing_resolution', descriptor=property( cls._timing_resolution_getter, cls._timing_resolution_setter, doc="""The timing_resolution of this object. Can be set to any value that is representable with datetime.timedelta. The default value is 1 hour. Whenever it is changed the start, end and duration values will be updated. """ ) ) def _validate_timing_resolution(self, timing_resolution): """validates the given timing_resolution value """ if timing_resolution is None: timing_resolution = defaults.timing_resolution if not isinstance(timing_resolution, datetime.timedelta): raise TypeError('%s.timing_resolution should be an instance of ' 'datetime.timedelta not, %s' % (self.__class__.__name__, timing_resolution.__class__.__name__)) return timing_resolution @declared_attr def computed_start(cls): return Column('computed_start', DateTime) @declared_attr def computed_end(cls): return Column('computed_end', DateTime) @property
[docs] def computed_duration(self): """returns the computed_duration as the difference of computed_start and computed_end if there are computed_start and computed_end otherwise returns None """ return self.computed_end - self.computed_start \ if self.computed_end and self.computed_start else None
[docs] def round_time(self, dt): """Round a datetime object to any time laps in seconds. Uses class property timing_resolution as the closest number of seconds to round to, defaults 1 hour. :param dt: datetime.datetime object, defaults now. Based on Thierry Husson's answer in `Stackoverflow`_ _`Stackoverflow` : http://stackoverflow.com/a/10854034/1431079 """ # to be compatible with python 2.6 use the following instead of # total_seconds() trs = self.timing_resolution.days * 86400 + \ self.timing_resolution.seconds # convert to seconds # FIX: using strftime(%s) is dangerous, it uses system time zone epoch = datetime.datetime(1970, 1, 1) diff = dt - epoch diff_in_seconds = diff.days * 86400 + diff.seconds return epoch + datetime.timedelta( seconds=(diff_in_seconds + trs * 0.5) // trs * trs )
@property
[docs] def total_seconds(self): """returns the duration as seconds """ return self.duration.days * 86400 + self.duration.seconds
@property
[docs] def computed_total_seconds(self): """returns the duration as seconds """ return self.computed_duration.days * 86400 + \ self.computed_duration.seconds
[docs]class ProjectMixin(object): """Gives the ability to connect to a :class:`~stalker.models.project.Project` to the mixed in object. :param project: A :class:`~stalker.models.project.Project` instance holding the project which this object is related to. It can not be None, or anything other than a :class:`~stalker.models.project.Project` instance. :type project: :class:`~stalker.models.project.Project` """ # # add this lines for Sphinx # __tablename__ = "ProjectMixins" @declared_attr def project_id(cls): return Column( "project_id", Integer, ForeignKey("Projects.id", use_alter=True, name="project_x_id"), #ForeignKey("Projects.id"), # cannot use nullable cause a Project object needs # insert itself as the project and it needs post_update # thus nullable should be True #nullable=False, ) @declared_attr def project(cls): backref = cls.__tablename__.lower() doc = """The :class:`~stalker.models.project.Project` instance that this object belongs to. """ return relationship( "Project", primaryjoin= \ cls.__tablename__ + ".c.project_id==Projects.c.id", post_update=True, # for project itself uselist=False, backref=backref, doc=doc )
[docs] def __init__(self, project=None, **kwargs): self.project = project
@validates("project") def _validate_project(self, key, project): """validates the given project value """ from stalker.models.project import Project if project is None: raise TypeError("%s.project can not be None it must be an " "instance of stalker.models.project.Project" % self.__class__.__name__) if not isinstance(project, Project): raise TypeError("%s.project should be an instance of " "stalker.models.project.Project instance not %s" % (self.__class__.__name__, project.__class__.__name__)) return project
[docs]class ReferenceMixin(object): """Adds reference capabilities to the mixed in class. References are :class:`stalker.models.entity.Entity` instances or anything derived from it, which adds information to the attached objects. The aim of the References are generally to give more info to direct the evolution of the object. :param references: A list of :class:`~stalker.models.entity.Entity` objects. :type references: list of :class:`~stalker.models.entity.Entity` objects. """ # add this lines for Sphinx # __tablename__ = "ReferenceMixins"
[docs] def __init__(self, references=None, **kwargs): if references is None: references = [] self.references = references
@declared_attr def references(cls): # TODO: there is something wrong here, the documentation and the implementation is not telling the same story # get secondary table secondary_table = create_secondary_table( cls.__name__, 'Link', cls.__tablename__, 'Links', cls.__name__ + "_References" ) # return the relationship return relationship("Link", secondary=secondary_table) @validates("references") def _validate_references(self, key, reference): """validates the given reference """ from stalker.models.entity import SimpleEntity # all the elements should be instance of stalker.models.entity.Entity if not isinstance(reference, SimpleEntity): raise TypeError("%s.references should be all instances of " "stalker.models.entity.SimpleEntity not %s" % (self.__class__.__name__, reference.__class__.__name__)) return reference
[docs]class ACLMixin(object): """A Mixin for adding ACLs to mixed in class. Access control lists or ACLs are used to determine if the given resource has the permission to access the given data. It is based on Pyramids Authorization system but organized to fit in Stalker style. The ACLMixin adds an attribute called ``permissions`` and a property called ``__acl__`` to be able to pass the permission data to Pyramid framework. """ @declared_attr def permissions(cls): # get the secondary table secondary_table = create_secondary_table( cls.__name__, 'Permission', cls.__tablename__, 'Permissions' ) return relationship('Permission', secondary=secondary_table) @validates('permissions') def _validate_permissions(self, key, permission): """validates the given permission value """ from stalker.models.auth import Permission if not isinstance(permission, Permission): raise TypeError("%s.permissions should be all instances of " "stalker.models.auth.Permission not %s" % (self.__class__.__name__, permission.__class__.__name__)) return permission @property def __acl__(self): """Returns Pyramid friendly ACL list composed by the: * Permission.access (Ex: 'Allow' or 'Deny') * The Mixed in class name and the object name (Ex: 'User:eoyilmaz') * The Action and the target class name (Ex: 'Create_Asset') Thus a list of tuple is returned as follows:: __acl__ = [ ('Allow', 'User:eoyilmaz', 'Create_Asset'), ] For the last example user eoyilmaz can grant access to views requiring 'Add_Project' permission. """ return [(perm.access, self.__class__.__name__ + ':' + self.name, perm.action + '_' + perm.class_name) for perm in self.permissions]
[docs]class CodeMixin(object): """Adds code info to the mixed in class. .. versionadded:: 0.2.0 The code attribute of the SimpleEntity is now introduced as a separate mixin. To let it be used by the classes it is really needed. The CodeMixin just adds a new field called ``code``. It is a very simple attribute and is used for simplifying long names (like Project.name etc.). Contrary to previous implementations the code attribute is not formatted in anyway, so care needs to be taken if the code attribute is going to be used in filesystem as file and directory names. :param str code: The code attribute is a string, can not be empty or can not be None. """
[docs] def __init__( self, code=None, **kwargs): logger.debug('code: %s' % code) self.code = code
@declared_attr def code(cls): return Column( 'code', String(256), nullable=False, doc="""The code name of this object. It accepts strings. Can not be None.""" ) @validates('code') def _validate_code(self, key, code): """validates the given code attribute """ logger.debug('validating code value of: %s' % code) if code is None: raise TypeError("%s.code cannot be None" % self.__class__.__name__) if not isinstance(code, (str, unicode)): raise TypeError('%s.code should be an instance of string or ' 'unicode not %s' % (self.__class__.__name__, code.__class__.__name__) ) if code == '': raise ValueError('%s.code can not be an empty string') return code
[docs]class WorkingHoursMixin(object): """Sets working hours for the mixed in class. Generally is meaningful for users, departments and studio. :param working_hours: A :class:`~stalker.models.project.WorkingHours` instance showing the working hours settings for that project. This data is stored as a PickleType in the database. """
[docs] def __init__(self, working_hours=None, **kwargs): self.working_hours = working_hours
@declared_attr def working_hours(cls): return Column(PickleType) @validates('working_hours') def _validate_working_hours(self, key, wh): """validates the given working hours value """ if wh is None: # use the default one from stalker import WorkingHours wh = WorkingHours() return wh