import uuid
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.signals import user_logged_in
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from djangoplugins.models import Plugin
from markupfield.fields import MarkupField
from .constants import get_sodar_constants
from .utils import set_user_group
# Access Django user model
AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User')
# Global SODAR constants
SODAR_CONSTANTS = get_sodar_constants()
# Local constants
PROJECT_TYPE_CHOICES = [('CATEGORY', 'Category'), ('PROJECT', 'Project')]
APP_SETTING_TYPES = ['BOOLEAN', 'INTEGER', 'STRING', 'JSON']
APP_SETTING_TYPE_CHOICES = [
('BOOLEAN', 'Boolean'),
('INTEGER', 'Integer'),
('STRING', 'String'),
('JSON', 'Json'),
]
APP_SETTING_VAL_MAXLENGTH = 255
PROJECT_SEARCH_TYPES = ['project']
PROJECT_TAG_STARRED = 'STARRED'
# Project ----------------------------------------------------------------------
[docs]class ProjectManager(models.Manager):
"""Manager for custom table-level Project queries"""
[docs] def find(self, search_term, keywords=None, project_type=None):
"""
Return projects with a partial match in full title or, including titles
of parent Project objects, or the description of the current object.
Restrict to project type if project_type is set.
:param search_term: Search term (string)
:param keywords: Optional search keywords as key/value pairs (dict)
:param project_type: Project type or None
:return: List of Project objects
"""
search_term = search_term.lower()
projects = super().get_queryset().order_by('title')
if project_type:
projects = projects.filter(type=project_type)
# NOTE: Can't use a custom function in filter()
result = [
p
for p in projects
if (
search_term in p.get_full_title().lower()
or (p.description and search_term in p.description.lower())
)
]
return sorted(result, key=lambda x: x.get_full_title())
[docs]class Project(models.Model):
"""
A SODAR project. Can have one parent category in case of nested
projects. The project must be of a specific type, of which "CATEGORY" and
"PROJECT" are currently implemented. "CATEGORY" projects are used as
containers for other projects
"""
#: Project title
title = models.CharField(
max_length=255, unique=False, help_text='Project title'
)
#: Type of project ("CATEGORY", "PROJECT")
type = models.CharField(
max_length=64,
choices=PROJECT_TYPE_CHOICES,
default=SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'],
help_text='Type of project ("CATEGORY", "PROJECT")',
)
#: Parent category if nested, otherwise null
parent = models.ForeignKey(
'self',
blank=True,
null=True,
related_name='children',
help_text='Parent category if nested',
)
#: Short project description
description = models.CharField(
max_length=512,
unique=False,
blank=True,
null=True,
help_text='Short project description',
)
#: Project README (optional, supports markdown)
readme = MarkupField(
null=True,
blank=True,
markup_type='markdown',
help_text='Project README (optional, supports markdown)',
)
#: Status of project creation
submit_status = models.CharField(
max_length=64,
default=SODAR_CONSTANTS['SUBMIT_STATUS_OK'],
help_text='Status of project creation',
)
#: Project SODAR UUID
sodar_uuid = models.UUIDField(
default=uuid.uuid4, unique=True, help_text='Project SODAR UUID'
)
# Set manager for custom queries
objects = ProjectManager()
class Meta:
unique_together = ('title', 'parent')
ordering = ['parent__title', 'title']
def __str__(self):
parents = self.get_parents()
ret = ' / '.join([p.title for p in parents]) if parents else ''
if ret:
ret += ' / '
ret += self.title
return ret
def __repr__(self):
values = (
self.title,
self.type,
self.parent.title if self.parent else None,
)
return 'Project({})'.format(', '.join(repr(v) for v in values))
[docs] def save(self, *args, **kwargs):
"""Version of save() to include custom validation for Project"""
self._validate_parent()
self._validate_title()
self._validate_parent_type()
super().save(*args, **kwargs)
def _validate_parent(self):
"""Validate parent value to ensure project can't be set as its own
parent"""
if self.parent == self:
raise ValidationError('Project can not be set as its own parent')
def _validate_parent_type(self):
"""Validate parent value to ensure parent can not be a project"""
if (
self.parent
and self.parent.type == SODAR_CONSTANTS['PROJECT_TYPE_PROJECT']
):
raise ValidationError(
'Subprojects are only allowed within categories'
)
def _validate_title(self):
"""Validate title against parent title to ensure they don't equal
parent"""
if self.parent and self.title == self.parent.title:
raise ValidationError('Project and parent titles can not be equal')
def get_absolute_url(self):
return reverse(
'projectroles:detail', kwargs={'project': self.sodar_uuid}
)
# Custom row-level functions
[docs] def get_children(self):
"""Return child objects for the Project sorted by title"""
return self.children.filter(
submit_status=SODAR_CONSTANTS['SUBMIT_STATUS_OK']
).order_by('title')
[docs] def get_depth(self):
"""Return depth of project in the project tree structure (root=0)"""
ret = 0
p = self
while p.parent:
ret += 1
p = p.parent
return ret
[docs] def get_owner(self):
"""Return RoleAssignment for owner or None if not set"""
try:
return self.roles.get(
role__name=SODAR_CONSTANTS['PROJECT_ROLE_OWNER']
)
except RoleAssignment.DoesNotExist:
return None
[docs] def get_delegates(self):
"""Return RoleAssignments for delegates"""
return self.roles.filter(
role__name=SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE']
)
[docs] def get_members(self):
"""Return RoleAssignments for members of project excluding owner and
delegates"""
return self.roles.filter(
~Q(role__name=SODAR_CONSTANTS['PROJECT_ROLE_OWNER'])
& ~Q(role__name=SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'])
)
[docs] def has_role(self, user, include_children=False):
"""Return whether user has roles in Project. If include_children is
True, return True if user has roles in ANY child project"""
if self.roles.filter(user=user).count() > 0:
return True
if include_children:
for child in self.children.all():
if child.has_role(user, include_children=True):
return True
return False
[docs] def get_parents(self):
"""Return an array of parent projects in inheritance order"""
if not self.parent:
return None
ret = []
parent = self.parent
while parent:
ret.append(parent)
parent = parent.parent
return reversed(ret)
[docs] def get_full_title(self):
"""Return full title of project (just an alias for __str__())"""
return str(self)
[docs] def get_source_site(self):
"""Return source site or None if this is a locally defined project"""
if (
settings.PROJECTROLES_SITE_MODE
== SODAR_CONSTANTS['SITE_MODE_SOURCE']
):
return None
RemoteProject = apps.get_model('projectroles', 'RemoteProject')
try:
return RemoteProject.objects.get(
project_uuid=self.sodar_uuid,
site__mode=SODAR_CONSTANTS['SITE_MODE_SOURCE'],
).site
except RemoteProject.DoesNotExist:
pass
return None
[docs] def is_remote(self):
"""Return True if current project has been retrieved from a remote
SODAR site"""
if (
settings.PROJECTROLES_SITE_MODE
== SODAR_CONSTANTS['SITE_MODE_TARGET']
and self.get_source_site()
):
return True
return False
[docs] def is_revoked(self):
"""Return True if remote access has been revoked for the project"""
if self.is_remote():
remote_project = RemoteProject.objects.filter(
project=self, site=self.get_source_site()
).first()
if (
remote_project
and remote_project.level
== SODAR_CONSTANTS['REMOTE_LEVEL_REVOKED']
):
return True
return False
# Role -------------------------------------------------------------------------
[docs]class Role(models.Model):
"""Role definition, used to assign roles to projects in RoleAssignment"""
#: Name of role
name = models.CharField(
max_length=64, unique=True, help_text='Name of role'
)
#: Role description
description = models.TextField(help_text='Role description')
def __str__(self):
return self.name
def __repr__(self):
return 'Role({})'.format(repr(self.name))
# RoleAssignment ---------------------------------------------------------------
[docs]class RoleAssignmentManager(models.Manager):
"""Manager for custom table-level RoleAssignment queries"""
[docs] def get_assignment(self, user, project):
"""Return assignment of user to project, or None if not found"""
if not user.is_authenticated: # Anonymous users can't have roles
return None
try:
return super().get_queryset().get(user=user, project=project)
except RoleAssignment.DoesNotExist:
return None
[docs]class RoleAssignment(models.Model):
"""
Assignment of an user to a role in a project. One role per user is
allowed for each project. Roles of project owner and project delegate
assignements might be limited (to PROJECTROLES_DELEGATE_LIMIT) per project.
"""
#: Project in which role is assigned
project = models.ForeignKey(
Project,
related_name='roles',
help_text='Project in which role is assigned',
)
#: User for whom role is assigned
user = models.ForeignKey(
AUTH_USER_MODEL,
related_name='roles',
help_text='User for whom role is assigned',
)
#: Role to be assigned
role = models.ForeignKey(
Role, related_name='assignments', help_text='Role to be assigned'
)
#: RoleAssignment SODAR UUID
sodar_uuid = models.UUIDField(
default=uuid.uuid4, unique=True, help_text='RoleAssignment SODAR UUID'
)
# Set manager for custom queries
objects = RoleAssignmentManager()
class Meta:
ordering = [
'project__parent__title',
'project__title',
'role__name',
'user__username',
]
def __str__(self):
return '{}: {}: {}'.format(self.project, self.role, self.user)
def __repr__(self):
values = (self.project.title, self.user.username, self.role.name)
return 'RoleAssignment({})'.format(', '.join(repr(v) for v in values))
[docs] def save(self, *args, **kwargs):
"""Version of save() to include custom validation for RoleAssignment"""
self._validate_user()
self._validate_owner()
self._validate_delegate()
self._validate_category()
super().save(*args, **kwargs)
def _validate_user(self):
"""Validate fields to ensure user has only one role set for the
project"""
assignment = RoleAssignment.objects.get_assignment(
self.user, self.project
)
if assignment and (not self.pk or assignment.pk != self.pk):
raise ValidationError(
'Role {} already set for {} in {}'.format(
assignment.role, assignment.user, assignment.project
)
)
def _validate_owner(self):
"""Validate role to ensure no more than one project owner is assigned
to a project"""
if self.role.name == SODAR_CONSTANTS['PROJECT_ROLE_OWNER']:
owner = self.project.get_owner()
if owner and (not self.pk or owner.pk != self.pk):
raise ValidationError(
'User {} already set as owner of {}'.format(
owner, self.project
)
)
def _validate_delegate(self):
"""Validate role to ensure no more than project delegate is
assigned to a project"""
# No validation if the project is a remote one
if not (self.project.is_remote()):
# Get project delegate limit
delegate_limit = (
settings.PROJECTROLES_DELEGATE_LIMIT
if hasattr(settings, 'PROJECTROLES_DELEGATE_LIMIT')
else 1
)
if self.role.name == SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE']:
delegates = self.project.get_delegates()
# No delegate limit if PROJECTROLES_DELEGATE_LIMIT is set to 0
if delegate_limit != 0:
if len(delegates) >= delegate_limit and (
not self.pk or (delegates.filter(pk=self.pk) is None)
):
raise ValidationError(
'The limit ({}) of delegates for this project has '
'already been reached.'.format(delegate_limit)
)
def _validate_category(self):
"""Validate project and role types to ensure roles other than project
owner are not set for category-type projects"""
if (
self.project.type == SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY']
and self.role.name != SODAR_CONSTANTS['PROJECT_ROLE_OWNER']
):
raise ValidationError(
'Only the role of project owner is allowed for categories'
)
# AppSetting ---------------------------------------------------------------
[docs]class AppSettingManager(models.Manager):
"""Manager for custom table-level AppSetting queries"""
[docs] def get_setting_value(
self, app_name, setting_name, project=None, user=None
):
"""
Return value of setting_name for app_name in project or for user.
Note that project and/or user must be set.
:param app_name: App plugin name (string)
:param setting_name: Name of setting (string)
:param project: Project object or pk
:param user: User object or pk
:return: Value (string)
:raise: AppSetting.DoesNotExist if setting is not found
"""
if (project is None) and (user is None):
raise ValueError('Project and user unset.')
setting = (
super()
.get_queryset()
.get(
app_plugin__name=app_name,
name=setting_name,
project=project,
user=user,
)
)
return setting.get_value()
[docs]class AppSetting(models.Model):
"""
Project and users settings value.
The settings are defined in the "app_settings" member in a SODAR project
app's plugin. The scope of each setting can be either "USER" or "PROJECT",
defined for each setting in app_settings. Project AND user-specific settings
or settings which don't belong to either are are currently not supported.
"""
#: App to which the setting belongs
app_plugin = models.ForeignKey(
Plugin,
null=False,
unique=False,
related_name='settings',
help_text='App to which the setting belongs',
)
#: Project to which the setting belongs
project = models.ForeignKey(
Project,
null=True,
blank=True,
related_name='settings',
help_text='Project to which the setting belongs',
)
#: Project to which the setting belongs
user = models.ForeignKey(
AUTH_USER_MODEL,
null=True,
blank=True,
related_name='user_settings',
help_text='User to which the setting belongs',
)
#: Name of the setting
name = models.CharField(
max_length=255, unique=False, help_text='Name of the setting'
)
#: Type of the setting
type = models.CharField(
max_length=64, unique=False, help_text='Type of the setting'
)
#: Value of the setting
value = models.CharField(
max_length=APP_SETTING_VAL_MAXLENGTH,
unique=False,
null=True,
blank=True,
help_text='Value of the setting',
)
#: Optional JSON value for the setting
value_json = JSONField(
default=dict, help_text='Optional JSON value for the setting'
)
#: Setting visibility in forms
user_modifiable = models.BooleanField(
default=True, help_text='Setting visibility in forms'
)
#: AppSetting SODAR UUID
sodar_uuid = models.UUIDField(
default=uuid.uuid4, unique=True, help_text='AppSetting SODAR UUID'
)
# Set manager for custom queries
objects = AppSettingManager()
class Meta:
ordering = ['project__title', 'app_plugin__name', 'name']
unique_together = ('project', 'app_plugin', 'name')
def __str__(self):
if self.project:
label = self.project.title
else:
label = self.user.username
return '{}: {} / {}'.format(label, self.app_plugin.name, self.name)
def __repr__(self):
values = (
self.project.title if self.project else None,
self.user.username if self.user else None,
self.app_plugin.name,
self.name,
)
return 'AppSetting({})'.format(', '.join(repr(v) for v in values))
[docs] def save(self, *args, **kwargs):
"""Version of save() to convert 'value' data according to 'type'"""
if self.type == 'BOOLEAN':
self.value = str(int(self.value))
elif self.type == 'INTEGER':
self.value = str(self.value)
super().save(*args, **kwargs)
# Custom row-level functions
[docs] def get_value(self):
"""Return value of the setting in the format specified in 'type'"""
if self.type == 'INTEGER':
return int(self.value)
elif self.type == 'BOOLEAN':
return bool(int(self.value))
elif self.type == 'JSON':
return self.value_json
return self.value
# ProjectInvite ----------------------------------------------------------------
[docs]class ProjectInvite(models.Model):
"""
Invite which is sent to a non-logged in user, determining their role in
the project.
"""
#: Email address of the person to be invited
email = models.EmailField(
unique=False,
null=False,
blank=False,
help_text='Email address of the person to be invited',
)
#: Project to which the person is invited
project = models.ForeignKey(
Project,
null=False,
related_name='invites',
help_text='Project to which the person is invited',
)
#: Role assigned to the person
role = models.ForeignKey(
Role, null=False, help_text='Role assigned to the person'
)
#: User who issued the invite
issuer = models.ForeignKey(
AUTH_USER_MODEL,
null=False,
related_name='issued_invites',
help_text='User who issued the invite',
)
#: DateTime of invite creation
date_created = models.DateTimeField(
auto_now_add=True, help_text='DateTime of invite creation'
)
#: Expiration of invite as DateTime
date_expire = models.DateTimeField(
null=False, help_text='Expiration of invite as DateTime'
)
#: Message to be included in the invite email (optional)
message = models.TextField(
blank=True,
help_text='Message to be included in the invite email (optional)',
)
#: Secret token provided to user with the invite
secret = models.CharField(
max_length=255,
unique=True,
blank=False,
null=False,
help_text='Secret token provided to user with the invite',
)
#: Status of the invite (False if claimed or revoked)
active = models.BooleanField(
default=True,
help_text='Status of the invite (False if claimed or revoked)',
)
#: ProjectInvite SODAR UUID
sodar_uuid = models.UUIDField(
default=uuid.uuid4, unique=True, help_text='ProjectInvite SODAR UUID'
)
class Meta:
ordering = ['project__title', 'email', 'role__name']
def __str__(self):
return '{}: {} ({}){}'.format(
self.project,
self.email,
self.role.name,
' [ACTIVE]' if self.active else '',
)
def __repr__(self):
values = (self.project.title, self.email, self.role.name, self.active)
return 'ProjectInvite({})'.format(', '.join(repr(v) for v in values))
# ProjectUserTag ---------------------------------------------------------------
[docs]class ProjectUserTag(models.Model):
"""Tag assigned by a user to a project"""
#: Project to which the tag is assigned
project = models.ForeignKey(
Project,
null=False,
related_name='tags',
help_text='Project in which the tag is assigned',
)
#: User for whom the tag is assigned
user = models.ForeignKey(
AUTH_USER_MODEL,
null=False,
related_name='project_tags',
help_text='User for whom the tag is assigned',
)
#: Name of tag to be assigned
name = models.CharField(
max_length=64,
unique=False,
null=False,
blank=False,
default=PROJECT_TAG_STARRED,
help_text='Name of tag to be assigned',
)
#: ProjectUserTag SODAR UUID
sodar_uuid = models.UUIDField(
default=uuid.uuid4, unique=True, help_text='ProjectUserTag SODAR UUID'
)
class Meta:
ordering = ['project__title', 'user__username', 'name']
def __str__(self):
return '{}: {}: {}'.format(
self.project.title, self.user.username, self.name
)
def __repr__(self):
values = (self.project.title, self.user.username, self.name)
return 'ProjectUserTag({})'.format(', '.join(repr(v) for v in values))
# RemoteSite--------------------------------------------------------------------
[docs]class RemoteSite(models.Model):
"""Remote SODAR site"""
#: Site name
name = models.CharField(
max_length=255,
unique=True,
blank=False,
null=False,
help_text='Site name',
)
#: Site URL
url = models.URLField(
max_length=2000,
blank=False,
null=False,
unique=False,
help_text='Site URL',
)
#: Site mode
mode = models.CharField(
max_length=64,
unique=False,
blank=False,
null=False,
default=SODAR_CONSTANTS['SITE_MODE_TARGET'],
help_text='Site mode',
)
#: Site description
description = models.TextField(help_text='Site description')
#: Secret token used to connect to the master site
secret = models.CharField(
max_length=255,
unique=False,
blank=False,
null=True, # Can be NULL for Peer Mode
help_text='Secret token for connecting to the source site',
)
#: RemoteSite relation UUID (local)
sodar_uuid = models.UUIDField(
default=uuid.uuid4,
unique=True,
help_text='RemoteSite relation UUID (local)',
)
#: RemoteSite's link visibilty for users
user_display = models.BooleanField(
default=True, unique=False, help_text='RemoteSite visibility to users'
)
class Meta:
ordering = ['name']
unique_together = ['url', 'mode', 'secret']
def __str__(self):
return '{} ({})'.format(self.name, self.mode, self.name)
def __repr__(self):
values = (self.name, self.mode, self.url)
return 'RemoteSite({})'.format(', '.join(repr(v) for v in values))
[docs] def save(self, *args, **kwargs):
"""Version of save() to include custom validation"""
self._validate_mode()
super().save(*args, **kwargs)
def _validate_mode(self):
"""Validate mode value"""
if self.mode not in SODAR_CONSTANTS['SITE_MODES']:
raise ValidationError(
'Mode "{}" not found in SITE_MODES'.format(self.mode)
)
# Custom row-level functions
[docs] def get_access_date(self):
"""Return date of latest project access by remote site"""
projects = RemoteProject.objects.filter(site=self).order_by(
'-date_access'
)
if projects.count() > 0:
return projects.first().date_access
[docs] def get_url(self):
"""Return sanitized site URL"""
if self.url[-1] == '/':
return self.url[:-1]
return self.url
# RemoteProject ----------------------------------------------------------------
[docs]class RemoteProject(models.Model):
"""Remote project relation"""
#: Related project UUID
project_uuid = models.UUIDField(
default=None, unique=False, help_text='Project UUID'
)
#: Related project object (if created locally)
project = models.ForeignKey(
Project,
related_name='remotes',
blank=True,
null=True,
help_text='Related project object (if created locally)',
)
#: Related remote SODAR site
site = models.ForeignKey(
RemoteSite,
null=False,
related_name='projects',
help_text='Remote SODAR site',
)
#: Project access level
level = models.CharField(
max_length=255,
unique=False,
blank=False,
null=False,
default=SODAR_CONSTANTS['REMOTE_LEVEL_NONE'],
help_text='Project access level',
)
#: DateTime of last access from/to remote site
date_access = models.DateTimeField(
null=True,
auto_now_add=False,
help_text='DateTime of last access from/to remote site',
)
#: RemoteProject relation UUID (local)
sodar_uuid = models.UUIDField(
default=uuid.uuid4,
unique=True,
help_text='RemoteProject relation UUID (local)',
)
class Meta:
ordering = ['site__name', 'project_uuid']
def __str__(self):
return '{}: {} ({})'.format(
self.site.name, str(self.project_uuid), self.site.mode
)
def __repr__(self):
values = (self.site.name, str(self.project_uuid), self.site.mode)
return 'RemoteProject({})'.format(', '.join(repr(v) for v in values))
# Custom row-level functions
[docs] def get_project(self):
"""Get the related Project object"""
return (
self.project
or Project.objects.filter(sodar_uuid=self.project_uuid).first()
)
# Abstract User Model ----------------------------------------------------------
# TODO: Use/extend this in your projectroles-based project
[docs]class SODARUser(AbstractUser):
"""SODAR compatible abstract user model"""
# First Name and Last Name do not cover name patterns
# around the globe.
name = models.CharField(_('Name of User'), blank=True, max_length=255)
#: User SODAR UUID
sodar_uuid = models.UUIDField(
default=uuid.uuid4, unique=True, help_text='User SODAR UUID'
)
class Meta:
abstract = True
def __str__(self):
return self.username
[docs] def get_full_name(self):
"""Return full name or username if not set"""
if hasattr(self, 'name') and self.name:
return self.name
elif self.first_name and self.last_name:
return '{} {}'.format(self.first_name, self.last_name)
return self.username
# User signals -----------------------------------------------------------------
[docs]def handle_ldap_login(sender, user, **kwargs):
"""Signal for LDAP login handling"""
if hasattr(user, 'ldap_username'):
# Make domain in username uppercase
if (
user.username.find('@') != -1
and user.username.split('@')[1].islower()
):
u_split = user.username.split('@')
user.username = u_split[0] + '@' + u_split[1].upper()
user.save()
# Save user name from first_name and last_name into name
if user.name in ['', None]:
if user.first_name != '':
user.name = user.first_name + (
' ' + user.last_name if user.last_name != '' else ''
)
user.save()
[docs]def assign_user_group(sender, user, **kwargs):
"""Signal for user group assignment"""
set_user_group(user)
user_logged_in.connect(handle_ldap_login)
user_logged_in.connect(assign_user_group)