# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail 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 General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Data Models for Users & Permissions
"""
import datetime
import warnings
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from rattail.db.model import Base, uuid_column, getset_factory, Person
[docs]
class Role(Base):
"""
Represents a role within the system; used to manage permissions.
"""
__tablename__ = 'role'
__table_args__ = (
sa.UniqueConstraint('name', name='role_uq_name'),
)
__versioned__ = {}
uuid = uuid_column()
name = sa.Column(sa.String(length=100), nullable=False, doc="""
Name for the role. Each role must have a name, which must be unique.
""")
adminish = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating that the role is "admin-ish" - i.e. only users who
belong to the true Administrator role, should be allowed to
(un)assign users to this role.
""")
session_timeout = sa.Column(sa.Integer(), nullable=True, doc="""
Optional session timeout value for the role, in seconds. If this is set to
zero, the role's users will have no session timeout. A value of ``None``
means the role has no say in the timeout.
""")
notes = sa.Column(sa.Text(), nullable=True, doc="""
Any arbitrary notes for the role.
""")
sync_me = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating that the Role - its primary attributes, and list
of permissions - should be synced across all nodes.
So if set, when the role changes at one node then that change
should propagate to all other nodes.
Note that this does *not* include the user list by default; see
:attr:`sync_users` to add that.
Note that if this flag is set, the role will be synced to *all*
nodes regardless of node type. See also :attr:`node_type`.
""")
sync_users = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating that the user list for the role should be synced
across all nodes. This has no effect unless :attr:`sync_me` is
also set.
Note that if this flag is set, the role's user list will be synced
to *all* nodes regardless of node type. See also
:attr:`node_type`.
""")
node_type = sa.Column(sa.String(length=100), nullable=True, doc="""
Type of node for which this role is applicable. This is probably
only useful if the :attr:`sync_me` flag is set.
If set, this value must match a node's configured type, or else it
will be ignored by that node. See also
:meth:`~rattail.config.RattailConfig.node_type()` for how a node's
type is determined. If there is no value set for this field then
the role will be honored by all nodes in which it exists (which is
just one unless ``sync_me`` is set, in which case all nodes would
have it).
It is useful in combination with ``sync_me`` in that it allows a
certain role to be "global" (synced) and yet only be "effective"
for certain nodes. Probably the most common scenario is where you
have a host node and several store nodes, and you want to manage
the store roles "globally" but avoid granting unwanted access to
the host node. So you'd set the ``sync_me`` flag but also set
``node_type`` to e.g. ``'store'``.
""")
_users = orm.relationship(
'UserRole',
cascade='all, delete-orphan',
back_populates='role',
cascade_backrefs=False)
users = association_proxy(
'_users', 'user',
creator=lambda u: UserRole(user=u),
getset_factory=getset_factory)
def __str__(self):
return self.name or ''
[docs]
class Permission(Base):
"""
Represents permission a role has to do a particular thing.
"""
__tablename__ = 'permission'
__table_args__ = (
sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'], name='permission_fk_role'),
)
# __versioned__ = {}
role_uuid = sa.Column(sa.String(length=32), primary_key=True)
permission = sa.Column(sa.String(length=254), primary_key=True)
def __str__(self):
return self.permission or ''
Role._permissions = orm.relationship(
Permission, backref='role',
cascade='save-update, merge, delete, delete-orphan')
Role.permissions = association_proxy(
'_permissions', 'permission',
creator=lambda p: Permission(permission=p),
getset_factory=getset_factory)
[docs]
class User(Base):
"""
Represents a user of the system.
This may or may not correspond to a real person, i.e. some users may exist
solely for automated tasks.
"""
__tablename__ = 'user'
__table_args__ = (
sa.ForeignKeyConstraint(['person_uuid'], ['person.uuid'], name='user_fk_person'),
sa.UniqueConstraint('username', name='user_uq_username'),
sa.Index('user_ix_person', 'person_uuid'),
)
__versioned__ = {'exclude': ['password', 'salt', 'last_login']}
uuid = uuid_column()
username = sa.Column(sa.String(length=25), nullable=False)
password = sa.Column(sa.String(length=60))
salt = sa.Column(sa.String(length=29))
person_uuid = sa.Column(sa.String(length=32))
person = orm.relationship(
Person,
uselist=False,
doc="""
Reference to the person whose user account this is.
""",
backref=orm.backref(
'users',
cascade_backrefs=False,
doc="""
List of user accounts for the person. Typically there is
only one user account per person, but technically multiple
are allowed.
"""))
active = sa.Column(sa.Boolean(), nullable=False, default=True, doc="""
Whether the user is active, e.g. allowed to log in via the UI.
""")
active_sticky = sa.Column(sa.Boolean(), nullable=True, doc="""
Optional flag, motivation behind which is as follows: If you import user
accounts from another system, esp. on a regular basis, you might be keeping
the :attr:`active` flag in sync along with that. But in some cases you
might want to *not* keep the active flag in sync, for certain accounts.
Hence this "active sticky" flag, which may be used to mark certain accounts
as off-limits from the general active flag sync.
""")
prevent_password_change = sa.Column(sa.Boolean(), nullable=True, doc="""
If set, this user cannot change their own password, *and* the
password is not editable when e.g. a manager edits this user
record. So if set, only root can change this user's password.
""")
local_only = sa.Column(sa.Boolean(), nullable=True, doc="""
Flag indicating the user account is somehow specific to the "local" app
node etc. and should not be synced elsewhere.
""")
last_login = sa.Column(sa.DateTime(), nullable=True, doc="""
Timestamp when user last logged into the system.
""")
def __str__(self):
if self.person and str(self.person):
return str(self.person)
return self.username or ''
@property
def display_name(self):
"""
Display name for the user.
Returns :attr:`Person.display_name` if available; otherwise returns
:attr:`username`.
"""
if self.person and self.person.display_name:
return self.person.display_name
return self.username
@property
def employee(self):
"""
DEPRECATED
Reference to the :class:`Employee` associated with the user, if any.
"""
warnings.warn("user.employee is deprecated; please use "
"app.get_employee(user) instead",
DeprecationWarning, stacklevel=2)
if self.person:
return self.person.employee
[docs]
def get_short_name(self):
"""
Returns "short name" for the user. This is for convenience of mobile
view, at least...
"""
warnings.warn("user.get_short_name() is deprecated; please use "
"AuthHandler.get_short_display_name(user) instead",
DeprecationWarning, stacklevel=2)
# TODO: this should reference employee.short_name
employee = self.employee
if employee and employee.display_name:
return employee.display_name
person = self.person
if person:
if person.first_name and person.last_name:
return "{} {}.".format(person.first_name, person.last_name[0])
if person.first_name:
return person.first_name
return self.username
[docs]
def get_email_address(self):
"""
DEPRECATED
Returns the primary email address for the user (as unicode string), or
``None``. Note that currently there is no direct association between a
User and an EmailAddress, so the Person and Customer relationships are
navigated in an attempt to locate an address.
"""
warnings.warn("user.get_email_address() is deprecated; please "
"use app.get_contact_email_address(user) instead",
DeprecationWarning, stacklevel=2)
if self.person:
if self.person.email:
return self.person.email.address
for customer in self.person.customers:
if customer.email:
return customer.email.address
@property
def email_address(self):
"""
DEPRECATED
Convenience attribute which invokes :meth:`get_email_address()`.
.. note::
The implementation of this may change some day, e.g. if the User is
given an association to EmailAddress in the data model.
"""
warnings.warn("user.email_address is deprecated; please "
"use app.get_contact_email_address(user) instead",
DeprecationWarning, stacklevel=2)
return self.get_email_address()
[docs]
def is_admin(self):
"""
Convenience method to determine if current user is a member of the
Administrator role.
"""
from rattail.db.auth import administrator_role
session = orm.object_session(self)
return administrator_role(session) in self.roles
def record_event(self, type_code, **kwargs):
kwargs['type_code'] = type_code
self.events.append(UserEvent(**kwargs))
Person.make_proxy(User, 'person', 'first_name')
Person.make_proxy(User, 'person', 'last_name')
Person.make_proxy(User, 'person', 'display_name')
[docs]
class UserRole(Base):
"""
Represents the association between a :class:`User` and a :class:`Role`.
"""
__tablename__ = 'user_x_role'
__table_args__ = (
sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], name='user_x_role_fk_user'),
sa.ForeignKeyConstraint(['role_uuid'], ['role.uuid'], name='user_x_role_fk_role'),
)
__versioned__ = {}
uuid = uuid_column()
user_uuid = sa.Column(sa.String(length=32), nullable=False)
role_uuid = sa.Column(sa.String(length=32), nullable=False)
role = orm.relationship(Role, back_populates='_users')
User._roles = orm.relationship(
UserRole, backref='user',
cascade='all, delete-orphan')
User.roles = association_proxy(
'_roles', 'role',
creator=lambda r: UserRole(role=r),
getset_factory=getset_factory)
[docs]
class UserEvent(Base):
"""
Represents an event associated with a user.
"""
__tablename__ = 'user_event'
__table_args__ = (
sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'],
name='user_event_fk_user'),
)
uuid = uuid_column()
user_uuid = sa.Column(sa.String(length=32), nullable=False)
user = orm.relationship(
User,
doc="""
Reference to the user whose event this is.
""",
backref=orm.backref(
'events',
cascade='all, delete-orphan',
cascade_backrefs=False,
doc="""
Sequence of events for the user.
"""))
type_code = sa.Column(sa.Integer(), nullable=False, doc="""
Type code for the event.
""")
occurred = sa.Column(sa.DateTime(), nullable=True, default=datetime.datetime.utcnow, doc="""
Timestamp at which the event occurred.
""")
[docs]
class UserAPIToken(Base):
"""
User authentication token for use with Tailbone API
"""
__tablename__ = 'user_api_token'
__table_args__ = (
sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'],
name='user_api_token_fk_user'),
)
__versioned__ = {}
model_title = "API Token"
model_title_plural = "API Tokens"
uuid = uuid_column()
user_uuid = sa.Column(sa.String(length=32), nullable=False, doc="""
Reference to the User associated with the token.
""")
user = orm.relationship(
'User',
doc="""
Reference to the User associated with the token.
""",
backref=orm.backref(
'api_tokens',
cascade_backrefs=False,
order_by='UserAPIToken.created',
doc="""
List of API tokens for the user.
"""))
description = sa.Column(sa.String(length=255), nullable=False, doc="""
Description of the token.
""")
token_string = sa.Column(sa.String(length=255), nullable=False, doc="""
Token string, to be used by API clients.
""")
created = sa.Column(sa.DateTime(), nullable=False, default=datetime.datetime.utcnow, doc="""
Date/time when the token was created.
""")
def __str__(self):
return self.description or ""