# -*- 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 os
import pickle
import re
import base64
import datetime
from sqlalchemy import (Table, Column, Integer, ForeignKey, String, DateTime,
Enum)
from sqlalchemy.orm import relationship, synonym, reconstructor, validates
from sqlalchemy.schema import UniqueConstraint
from stalker import defaults
from stalker.db.declarative import Base
from stalker.models.mixins import ACLMixin
from stalker.models.entity import Entity
from stalker.log import logging_level
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging_level)
def group_finder(login, request):
"""Returns the group of the given login. The login name will be in
'User:{login}' format.
:param login: The login of the user, both '{login}' and
'User:{login}' format is accepted.
:param request: The Request object
:return: Will return the groups of the user in ['Group:{group_name}']
format.
"""
if ':' in login:
login = login.split(':')[1]
# return the group of the given User object
user_obj = User.query.filter_by(login=login).first()
if user_obj:
# just return the groups names if there is any group
groups = user_obj.groups
if len(groups):
return map(lambda x:'Group:' + x.name, groups)
return []
[docs]class RootFactory(object):
"""The main purpose of having a root factory is to generate the objects
used as the context by the request. But in our case it just used to
determine the default ACLs.
"""
@property
def __acl__(self):
# create the default acl and give admins all the permissions
all_permissions = map(
lambda x: x.action + '_' + x.class_name,
Permission.query.all()
)
# start with default ACLs
from pyramid.security import Allow
ACLs = [
(Allow, 'Group:' + defaults.admin_department_name, all_permissions),
(Allow, 'User:' + defaults.admin_name, all_permissions)
]
# get all users and their ACLs
all_users = User.query.all()
for user in all_users:
ACLs.extend(user.__acl__)
# get all groups and their ACLs
all_groups = Group.query.all()
for group in all_groups:
ACLs.extend(group.__acl__)
return ACLs
[docs] def __init__(self, request):
pass
[docs]class Permission(Base):
"""A class to hold permissions.
Permissions in Stalker defines what one can do or do not. A Permission
instance is composed by three attributes; access, action and class_name.
Permissions for all the classes in SOM are generally created by Stalker
when initializing the database.
If you created any custom classes to extend SOM you are also responsible to
create the Permissions for it by calling :meth:`stalker.db.register` and
passing your class to it. See the :mod:`stalker.db` documentation for
details.
:param str access: An Enum value which can have the one of the values of
``Allow`` or ``Deny``.
:param str action: An Enum value from the list ['Create', 'Read', 'Update',
'Delete', 'List']. Can not be None. The list can be changed from
stalker.config.Config.default_actions.
:param str class_name: The name of the class that this action is applied
to. Can not be None or an empty string.
Example: Let say that you want to create a Permission specifying a Group of
Users are allowed to create Projects::
import transaction
from stalker import db
from stalker.db.session import DBSession, transaction
from stalker.models.auth import User, Group, Permission
# first setup the db with the default database
#
# stalker.db.__init_db__ will create all the Actions possible with the
# SOM classes automatically
#
# What is left to you is to create the permissions
setup.db()
user1 = User(login='test_user1', password='1234')
user2 = User(login='test_user2', password='1234')
group1 = Group(name='users')
group1.users = [user1, user2]
# get the permissions for the Project class
project_permissions = Permission.query\
.filter(Actions.access='Allow')\
.filter(Actions.action='Create')\
.filter(Actions.class_name='Project')\
.first()
# now we have the permission specifying the allowance of creating a
# Project
# to make group1 users able to create a Project we simply add this
# Permission to the groups permission attribute
group1.permissions.append(permission)
# and persist this information in the database
DBSession.add(group)
transaction.commit()
"""
__tablename__ = 'Permissions'
__table_args__ = (
UniqueConstraint('access', 'action', 'class_name'),
{"extend_existing":True}
)
id = Column(Integer, primary_key=True)
_access = Column('access', Enum('Allow', 'Deny', name='AccessNames'))
_action = Column('action',
Enum(*defaults.actions,name='ActionNames'))
_class_name = Column('class_name', String)
[docs] def __init__(self, access, action, class_name):
self._access = self._validate_access(access)
self._action = self._validate_action(action)
self._class_name = self._validate_class_name(class_name)
def _validate_access(self, access):
"""validates the given access value
"""
if not isinstance(access, (str, unicode)):
raise TypeError('Permission.access should be an instance of str '
'or unicode not %s' % access.__class__.__name__)
if access not in ['Allow', 'Deny']:
raise ValueError('Permission.access should be "Allow" or "Deny"'
'not %s' % access)
return access
def _access_getter(self):
"""returns the _access value
"""
return self._access
access = synonym('_access', descriptor=property(_access_getter))
def _validate_class_name(self, class_name):
"""validates the given class_name value
"""
if not isinstance(class_name, (str, unicode)):
raise TypeError('Permission.class_name should be an instance of '
'str or unicode not %s' %
class_name.__class__.__name__)
return class_name
def _class_name_getter(self):
"""returns the _class_name attribute value
"""
return self._class_name
class_name = synonym('_class_name', descriptor=property(_class_name_getter))
def _validate_action(self, action):
"""validates the given action value
"""
if not isinstance(action, (str, unicode)):
raise TypeError('Permission.action should be an instance of str '
'or unicode not %s' % action.__class__.__name__)
if action not in defaults.actions:
raise ValueError('Permission.action should be one of the values '
'of %s not %s' %
(defaults.actions, action))
return action
def _action_getter(self):
"""returns the _action value
"""
return self._action
action = synonym('_action', descriptor=property(_action_getter))
def __eq__(self, other):
"""the equality of two Permissions
"""
return isinstance(other, Permission) \
and other.access == self.access \
and other.action == self.action \
and other.class_name == self.class_name
def __ne__(self, other):
"""the inequality of two Permissions
"""
return not self.__eq__(other)
[docs]class Group(Entity, ACLMixin):
"""Creates groups for users to be used in authorization system.
A Group instance is nothing more than a list of
:class:`~stalker.models.auth.User`\ s created to be able to assign
permissions in a group level.
The Group class, as with the :class:`~stalker.models.auth.User` class, is
mixed with the :class:`~stalker.models.mixins.ACLMixin` which adds ability
to hold :class:`~stalker.models.mixin.Permission` instances and serve ACLs
to Pyramid.
"""
# TODO: Update Group class documentation
__auto_name__ = False
__tablename__ = "Groups"
__mapper_args__ = {"polymorphic_identity": "Group"}
gid = Column("id", Integer, ForeignKey("Entities.id"),
primary_key=True)
users = relationship(
"User",
secondary="User_Groups",
back_populates="groups",
doc="""Users in this group.
Accepts:class:`~stalker.models.auth.User` instance.
"""
)
[docs] def __init__(self, name='', users=None, **kwargs):
if users is None:
users = []
kwargs.update({'name': name})
super(Group, self).__init__(**kwargs)
self.users = users
@validates('users')
def _validate_users(self, key, user):
"""validates the given user instance
"""
if not isinstance(user, User):
raise TypeError(
'Group.users attribute must all be stalker.models.auth.User '
'instances not %s' % user.__class__.__name__
)
return user
[docs]class User(Entity, ACLMixin):
"""The user class is designed to hold data about a User in the system.
There are a couple of points to take your attention to:
* The :attr:`~stalker.models.auth.User.code` attribute is derived from
the :attr:`~stalker.models.auth.User.nice_name` as it is in a
:class:`~stalker.models.entity.SimpleEntity`, but the
:attr:`~stalker.models.auth.User.nice_name` is derived from the
:attr:`~stalker.models.auth.User.login` instead of the
:attr:`~stalker.models.auth.User.name` attribute, so the
:attr:`~stalker.models.auth.User.code` of a
:class:`~stalker.models.auth.User` and a
:class:`~stalker.models.entity.SimpleEntity` will be different then each
other. The formatting of the :attr:`~stalker.models.auth.User.code`
attribute is as follows:
* no underscore character is allowed, so while in the
:class:`~stalker.models.entity.SimpleEntity` class the code could have
underscores, in :class:`~stalker.models.auth.User` class it is not
allowed.
* all the letters in the code will be converted to lower case.
Other than these two new rules all the previous formatting rules from the
:class:`~stalker.models.entity.SimpleEntity` are valid.
.. versionadded 0.2.0: Task Watchers
New to version 0.2.0 users can be assigned to a
:class:`~stalker.models.task.Task` as a **Watcher**. Which can be used
to inform the users in watchers list about the updates of certain Tasks.
:param email: holds the e-mail of the user, should be in [part1]@[part2]
format
:type email: str, unicode
:param login: This is the login name of the user, it should be all lower
case. Giving a string or unicode that has uppercase letters, it will be
converted to lower case. It can not be an empty string or None and it can
not contain any white space inside.
:type login: str, unicode
:param departments: It is the departments that the user is a part of. It
should be a list of Department objects. One user can be listed in
multiple departments.
:type departments: list of :class:`~stalker.models.department.Department`\ s
:param password: it is the password of the user, can contain any character.
Stalker doesn't store the raw passwords of the users. To check a stored
password with a raw password use
:meth:`~stalker.models.auth.User.check_password` and to set the password
you can use the :attr:`~stalker.models.auth.User.password` property
directly.
:type password: str, unicode
:param groups: It is a list of :class:`~stalker.models.auth.Group`
instances that this user belongs to.
:type groups: list of :class:`~stalker.models.auth.Group`
:param tasks: it is a list of Task objects which holds the tasks that this
user has been assigned to
:type tasks: list of :class:`~stalker.models.task.Task`\ s
:param projects_lead: it is a list of Project objects that this user
is the leader of, it is for back referencing purposes.
:type projects_lead: list of :class:`~stalker.models.project.Project`\ s
:param sequences_lead: it is a list of Sequence objects that this
user is the leader of, it is for back referencing purposes
:type sequences_lead: list of :class:`~stalker.models.sequence.Sequence`
:param last_login: it is a datetime.datetime object holds the last login
date of the user (not implemented yet)
:type last_login: datetime.datetime
"""
__auto_name__ = False
__tablename__ = "Users"
__mapper_args__ = {"polymorphic_identity": "User"}
user_id = Column("id", Integer, ForeignKey("Entities.id"),
primary_key=True)
departments = relationship(
"Department",
secondary='User_Departments',
back_populates="members",
doc="""A list of :class:`~stalker.models.department.Department`\ s that
this user is a part of""",
)
email = Column(
String(256),
unique=True,
nullable=False,
doc="""email of the user, accepts strings or unicode"""
)
password = Column(
String(256),
nullable=False,
doc="""The password of the user.
It is scrambled before it is stored.
"""
)
last_login = Column(
DateTime,
doc="""The last login time of this user.
It is an instance of datetime.datetime class."""
)
login = Column(
String(256),
nullable=False,
doc="""The login name of the user.
Can not be empty.
"""
)
groups = relationship(
'Group',
secondary='User_Groups',
back_populates='users',
doc="""Permission groups that this users is a member of.
Accepts :class:`~stalker.models.auth.Group` object.
"""
)
projects = relationship(
'Project',
secondary='Project_Users',
back_populates='users'
)
projects_lead = relationship(
"Project",
primaryjoin="Projects.c.lead_id==Users.c.id",
#uselist=True,
back_populates="lead",
doc=""":class:`~stalker.models.project.Project`\ s lead by this user.
It is a list of :class:`~stalker.models.project.Project` instances.
"""
)
sequences_lead = relationship(
"Sequence",
primaryjoin="Sequences.c.lead_id==Users.c.id",
uselist=True,
back_populates="lead",
doc=""":class:`~stalker.models.sequence.Sequence`\ s lead by this user.
It is a list of :class:`~stalker.models.sequence.Sequence` instances.
"""
)
tasks = relationship(
"Task",
secondary="Task_Resources",
back_populates="resources",
doc=""":class:`~stalker.models.task.Task`\ s assigned to this user.
It is a list of :class:`~stalker.models.task.Task` instances.
"""
)
watching = relationship(
'Task',
secondary='Task_Watchers',
back_populates='watchers',
doc=''':class:`~stalker.models.task.Tasks`\ s that this user is
assigned as a watcher.
It is a list of :class:`~stalker.models.task.Task` instances.
'''
)
time_logs = relationship(
"TimeLog",
primaryjoin="TimeLogs.c.resource_id==Users.c.id",
back_populates="resource",
doc="""A list of :class:`~stalker.models.task.TimeLog` instances which
holds the time logs created for this :class:`~stalker.models.auth.User`.
"""
)
[docs] def __init__(
self,
name=None,
login=None,
email=None,
password=None,
departments=None,
groups=None,
projects_lead=None,
sequences_lead=None,
tasks=None,
watching=None,
last_login=None,
**kwargs
):
kwargs['name'] = name
super(User, self).__init__(**kwargs)
self.login = login
if departments is None:
departments = []
self.departments = departments
self.email = email
# to be able to mangle the password do it like this
self.password = password
if groups is None:
groups = []
self.groups = groups
if projects_lead is None:
projects_lead = []
self.projects_lead = projects_lead
if sequences_lead is None:
sequences_lead = []
self.sequences_lead = sequences_lead
if tasks is None:
tasks = []
self.tasks = tasks
if watching is None:
watching = []
self.last_login = last_login
@reconstructor
def __init_on_load__(self):
"""initialized the instance variables when the instance created with
SQLAlchemy
"""
# call the Entity __init_on_load__
super(User, self).__init_on_load__()
def __repr__(self):
"""return the representation of the current User
"""
return "<User (%s ('%s'))>" % (self.name, self.login)
def __eq__(self, other):
"""the equality operator
"""
return super(User, self).__eq__(other) and\
isinstance(other, User) and\
self.email == other.email and\
self.login == other.login and\
self.name == other.name
def __ne__(self, other):
"""the inequality operator
"""
return not self.__eq__(other)
@validates("login")
def _validate_login(self, key, login):
"""validates the given login value
"""
if login is None:
raise TypeError('%s.login can not be None' %
self.__class__.__name__)
login = self._format_login(login)
# raise a ValueError if the login is an empty string after formatting
if login == '':
raise ValueError('%s.login can not be an empty string' %
self.__class__.__name__)
logger.debug("name out: %s" % login)
return login
@validates("departments")
def _validate_department(self, key, department):
"""validates the given department value
"""
from stalker.models.department import Department
# check if it is instance of Department object
if not isinstance(department, Department):
raise TypeError("%s.department should be instance of "
"stalker.models.department.Department not %s" %
(self.__class__.__name__,
department.__class__.__name__))
return department
@validates("email")
def _validate_email(self, key, email_in):
"""validates the given email value
"""
# check if email_in is an instance of string or unicode
if not isinstance(email_in, (str, unicode)):
raise TypeError("%s.email should be an instance of string or "
"unicode not %s" %
(self.__class__.__name__,
email_in.__class__.__name__))
return self._validate_email_format(email_in)
def _validate_email_format(self, email_in):
"""formats the email
"""
# split the mail from @ sign
splits = email_in.split("@")
len_splits = len(splits)
# there should be one and only one @ sign
if len_splits > 2:
raise ValueError("check the %s.email formatting, there are more "
"than one @ sign" % self.__class__.__name__)
if len_splits < 2:
raise ValueError("check the %s.email formatting, there are no @ "
"sign" % self.__class__.__name__)
if splits[0] == "":
raise ValueError("check the %s.email formatting, the name part "
"is missing" % self.__class__.__name__)
if splits[1] == "":
raise ValueError("check the %s.email formatting, the domain part "
"is missing" % self.__class__.__name__)
return email_in
@validates("last_login")
def _validate_last_login(self, key, last_login_in):
"""validates the given last_login argument
"""
if not isinstance(last_login_in, datetime.datetime) and\
last_login_in is not None:
raise TypeError("%s.last_login should be an instance of "
"datetime.datetime or None not %s" %
(self.__class__.__name__,
last_login_in.__class__.__name__))
return last_login_in
def _format_login(self, login):
"""formats the given login value
"""
# be sure it is a string
login = str(login)
# strip white spaces from start and end
login = login.strip()
# remove all the spaces
login = login.replace(" ", "")
# make it lowercase
login = login.lower()
# remove any illegal characters
login = re.sub("[^\\(a-zA-Z0-9)]+", "", login)
# remove any number at the beginning
login = re.sub("^[0-9]+", "", login)
return login
@validates("password")
def _validate_password(self, key, password_in):
"""validates the given password
"""
if password_in is None:
raise TypeError("%s.password cannot be None" %
self.__class__.__name__)
if password_in == "":
raise ValueError("raw_password can not be empty string")
# mangle the password
return base64.encodestring(password_in)
[docs] def check_password(self, raw_password):
"""Checks the given raw_password.
Checks the given raw_password with the current Users objects encrypted
password.
Checks the given raw password with the given encrypted password. Handles
the encryption process behind the scene.
"""
return self.password == base64.encodestring(str(raw_password))
@validates("groups")
def _validate_groups(self, key, group):
"""check the given group
"""
if not isinstance(group, Group):
raise TypeError(
"any group in %s.groups should be an instance of"
"stalker.models.auth.Group not %s" %
(self.__class__.__name__, group.__class__.__name__)
)
return group
@validates("projects_lead")
def _validate_projects_lead(self, key, project):
"""validates the given projects_lead attribute
"""
from stalker.models.project import Project
if not isinstance(project, Project):
raise TypeError(
"any element in %s.projects_lead should be a"
"stalker.models.project.Project instance not %s" %
(self.__class__.__name__, project.__class__.__name__)
)
return project
@validates("sequences_lead")
def _validate_sequences_lead(self, key, sequence):
"""validates the given sequences_lead attribute
"""
from stalker.models.sequence import Sequence
if not isinstance(sequence, Sequence):
raise TypeError(
"any element in %s.sequences_lead should be an instance of "
"stalker.models.sequence.Sequence not %s " %
(self.__class__.__name__, sequence.__class__.__name__)
)
return sequence
@validates("tasks")
def _validate_tasks(self, key, task):
"""validates the given tasks attribute
"""
from stalker.models.task import Task
if not isinstance(task, Task):
raise TypeError(
"any element in %s.tasks should be an instance of "
"stalker.models.task.Task not %s" %
(self.__class__.__name__, task.__class__.__name__)
)
return task
@validates("watching")
def _validate_watching(self, key, task):
"""validates the given watching attribute
"""
from stalker.models.task import Task
if not isinstance(task, Task):
raise TypeError(
"any element in %s.watching should be an instance of "
"stalker.models.task.Task not %s" %
(self.__class__.__name__, task.__class__.__name__)
)
return task
@validates('projects')
def _validate_projects(self, key, project):
"""validates the given project instance
"""
from stalker import Project
if not isinstance(project, Project):
raise TypeError('%s.projects should a list of '
'stalker.models.project.Project instances, not '
'%s' % (self.__class__.__name__,
project.__class__.__name__))
return project
@property
[docs] def tickets(self):
"""The list of :class:`~stalker.models.ticket.Ticket`\ s that this user has.
returns a list of :class:`~stalker.models.ticket.Ticket` instances
which are derived from the :class:`~stalker.models.task.Task`\ s that
this user is assigned to
(Stalker checks the related :class:`~stalker.models.version.Version`
instances and then the `~stalker.models.ticket.Ticket` instances
assigned to the Versions.).
"""
# do it with sqlalchemy
from stalker import Ticket
return Ticket.query\
.filter(Ticket.owner==self)\
.all()
@property
[docs] def open_tickets(self):
"""The list of open :class:`~stalker.models.ticket.Ticket`\ s that this user has.
returns a list of :class:`~stalker.models.ticket.Ticket` instances
which has a status of `Open` and are derived from the
:class:`~stalker.models.task.Task`\ s that this user is assigned to
(Stalker checks the related :class:`~stalker.models.version.Version`
instances and then the `~stalker.models.ticket.Ticket` instances
assigned to the Version and has a status of `Open`.).
"""
# do it with sqlalchemy
from stalker import Ticket, Status
return Ticket.query\
.filter(Ticket.owner==self)\
.join(Ticket.status)\
.filter(Status.code != 'CLOSED')\
.all()
@property
[docs] def to_tjp(self):
"""outputs a TaskJuggler formatted string
"""
from jinja2 import Template
temp = Template(defaults.tjp_user_template)
return temp.render({'user': self})
[docs]class LocalSession(object):
"""A simple temporary session object which simple stores session data.
This class will later be removed, it is here because we need a login window
for the Qt user interfaces.
On initialize it will load the SessionData from the users .strc folder
"""
[docs] def __init__(self):
self.logged_in_user_id = None
self.valid_to = None
self.session_data = None
self.load()
[docs] def load(self):
"""loads the data from the saved local session
"""
try:
with open(LocalSession.session_file_full_path(), 'r') as s:
# try:
unpickled_object = pickle.load(s)
if unpickled_object.valid_to > datetime.datetime.now():
# fill __dict__ with the loaded one
self.__dict__ = unpickled_object.__dict__
except IOError:
pass
@property
[docs] def logged_in_user(self):
"""returns the logged in user
"""
return User.query.filter_by(id=self.logged_in_user_id).first()
[docs] def store_user(self, user):
"""stores the given user instance
:param user: The user instance.
"""
if user:
self.logged_in_user_id = user.id
[docs] def save(self):
"""remembers the data in user local file system
"""
self.valid_to = datetime.datetime.now() + datetime.timedelta(10)
# serialize self
dumped_data = pickle.dumps(self, -1)
logger.debug('dumped session data : %s' % dumped_data)
self._write_data(dumped_data)
[docs] def delete(self):
"""removes the cache file
"""
try:
os.remove(self.session_file_full_path())
except OSError:
pass
@classmethod
[docs] def session_file_full_path(cls):
"""
:return str: the session file full path
"""
return os.path.join(
defaults.local_storage_path,
defaults.local_session_data_file_name
)
def _write_data(self, data):
"""Writes the given data to the local session file
:param data: the data to be written (generally serialized LocalSession
class itself)
"""
file_full_path = self.session_file_full_path()
# create the path first
file_path = os.path.dirname(file_full_path)
try:
os.makedirs(file_path)
except OSError:
# dir exists
pass
finally:
with open(file_full_path, 'w') as data_file:
data_file.writelines(data)
# USER_PERMISSIONGROUPS
User_Groups = Table(
"User_Groups", Base.metadata,
Column("uid", Integer, ForeignKey("Users.id"), primary_key=True),
Column("gid", Integer, ForeignKey("Groups.id"), primary_key=True)
)
# USER_DEPARTMENTS
User_Departments = Table(
'User_Departments', Base.metadata,
Column('uid', Integer, ForeignKey('Users.id'), primary_key=True),
Column('did', Integer, ForeignKey('Departments.id'), primary_key=True)
)