__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
__license__ = "AGPL v3"
import datetime
import hashlib
import random
import string
from django.urls import reverse
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils import timezone
from .behaviors import TimeStampedModel, orcid_validator
from .constants import (
NORMAL_CONTRIBUTOR,
DISABLED,
TITLE_CHOICES,
INVITATION_STYLE,
INVITATION_TYPE,
INVITATION_CONTRIBUTOR,
INVITATION_FORMAL,
AUTHORSHIP_CLAIM_PENDING,
AUTHORSHIP_CLAIM_STATUS,
CONTRIBUTOR_STATUSES,
NEWLY_REGISTERED,
)
from .fields import ChoiceArrayField
from .managers import (
ContributorQuerySet,
UnavailabilityPeriodManager,
AuthorshipClaimQuerySet,
)
from conflicts.models import ConflictOfInterest
today = timezone.now().date()
[docs]def get_sentinel_user():
"""Temporary fix to be able to delete Contributor instances.
Eventually the 'to-be-removed-Contributor' should be status: "deactivated" and anonymized.
Fallback user for models relying on Contributor that is being deleted.
"""
user, __ = get_user_model().objects.get_or_create(username="deleted")
return Contributor.objects.get_or_create(status=DISABLED, user=user)[0]
[docs]class TOTPDevice(models.Model):
"""
Any device used by a User for 2-step authentication based on the RFC 6238 TOTP protocol.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=128)
token = models.CharField(max_length=16)
last_verified_counter = models.PositiveIntegerField(default=0)
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
class Meta:
default_related_name = "devices"
verbose_name = "TOTP Device"
def __str__(self):
return "{}: {}".format(self.user, self.name)
[docs]class Contributor(models.Model):
"""Contributor is an extension of the User model.
*Professionally active scientist* users of SciPost are Contributors.
The username, password, email, first_name and last_name are inherited from User.
Other information is carried by the related Profile.
"""
profile = models.OneToOneField(
"profiles.Profile", on_delete=models.SET_NULL, null=True, blank=True
)
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, unique=True
)
invitation_key = models.CharField(max_length=40, blank=True)
activation_key = models.CharField(max_length=40, blank=True)
key_expires = models.DateTimeField(default=timezone.now)
status = models.CharField(
max_length=16, choices=CONTRIBUTOR_STATUSES, default=NEWLY_REGISTERED
)
address = models.CharField(max_length=1000, verbose_name="address", blank=True)
vetted_by = models.ForeignKey(
"self",
on_delete=models.SET(get_sentinel_user),
related_name="contrib_vetted_by",
blank=True,
null=True,
)
# If this Contributor is merged into another, then this field is set to point to the new one:
duplicate_of = models.ForeignKey(
"scipost.Contributor",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="duplicates",
)
objects = ContributorQuerySet.as_manager()
class Meta:
ordering = ["user__last_name", "user__first_name"]
@property
def roles(self):
r = []
if self.user.is_superuser:
r.append("su")
if self.user.is_staff:
r.append("st")
return r if len(r) > 0 else None
def __str__(self):
val = "%s, %s" % (self.user.last_name, self.user.first_name)
if self.user.is_superuser:
val += " (su)"
return val
[docs] def save(self, *args, **kwargs):
"""Generate new activitation key if not set."""
if not self.activation_key:
self.generate_key()
return super().save(*args, **kwargs)
[docs] def get_absolute_url(self):
"""Return public information page url."""
return reverse("scipost:contributor_info", args=(self.id,))
@property
def formal_str(self):
return "%s %s" % (self.profile.get_title_display(), self.user.last_name)
@property
def is_active(self):
"""
Checks if the Contributor is registered, vetted,
and has not been deactivated for any reason.
"""
return self.user.is_active and self.status == NORMAL_CONTRIBUTOR
@property
def is_duplicate(self):
return self.duplicate_of is not None
@property
def is_currently_available(self):
"""Check if Contributor is currently not marked as unavailable."""
return not self.unavailability_periods.today().exists()
@property
def is_scipost_admin(self):
"""Check if Contributor is a SciPost Administrator."""
return (
self.user.groups.filter(name="SciPost Administrators").exists()
or self.user.is_superuser
)
@property
def is_ed_admin(self):
"""Check if Contributor is an Editorial Administrator."""
return (
self.user.groups.filter(name="Editorial Administrators").exists()
or self.user.is_superuser
)
@property
def is_in_advisory_board(self):
"""Check if Contributor is in the Advisory Board."""
return (
self.user.groups.filter(name="Advisory Board").exists()
or self.user.is_superuser
)
@property
def is_active_fellow(self):
"""Check if Contributor is a member of the Editorial College."""
return self.fellowships.active().exists() or self.user.is_superuser
@property
def is_active_senior_fellow(self):
return self.fellowships.active().senior().exists()
[docs] def session_fellowship(self, request):
"""Return session's fellowship, if any; if Fellow, set session_fellowship_id if not set."""
fellowships = self.fellowships.active()
if fellowships.exists():
if request.session["session_fellowship_id"]:
from colleges.models import Fellowship
try:
return self.fellowships.active().get(
pk=request.session["session_fellowship_id"]
)
except Fellowship.DoesNotExist:
return None
# set the session's fellowship_id to default
fellowship = fellowships.first()
request.session["session_fellowship_id"] = fellowship.id
return fellowship
return None
@property
def is_vetting_editor(self):
"""Check if Contributor is a Vetting Editor."""
return (
self.user.groups.filter(name="Vetting Editors").exists()
or self.user.is_superuser
)
[docs] def generate_key(self, feed=""):
"""Generate a new activation_key for the contributor, given a certain feed."""
for i in range(5):
feed += random.choice(string.ascii_letters)
feed = feed.encode("utf8")
salt = self.user.username.encode("utf8")
self.activation_key = hashlib.sha1(salt + feed).hexdigest()
self.key_expires = timezone.now() + datetime.timedelta(days=2)
[docs] def conflict_of_interests(self):
if not self.profile:
return ConflictOfInterest.objects.none()
return ConflictOfInterest.objects.filter_for_profile(self.profile)
[docs]class UnavailabilityPeriod(models.Model):
contributor = models.ForeignKey(
"scipost.Contributor",
on_delete=models.CASCADE,
related_name="unavailability_periods",
)
start = models.DateField()
end = models.DateField()
objects = UnavailabilityPeriodManager()
class Meta:
ordering = ["-start"]
def __str__(self):
return "%s (%s to %s)" % (self.contributor, self.start, self.end)
###############
# Invitations #
###############
[docs]class RegistrationInvitation(models.Model):
"""Deprecated: Use the `invitations` app"""
title = models.CharField(max_length=4, choices=TITLE_CHOICES)
first_name = models.CharField(max_length=30)
last_name = models.CharField(max_length=30)
email = models.EmailField()
invitation_type = models.CharField(
max_length=2, choices=INVITATION_TYPE, default=INVITATION_CONTRIBUTOR
)
cited_in_submission = models.ForeignKey(
"submissions.Submission",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="registration_invitations",
)
cited_in_publication = models.ForeignKey(
"journals.Publication", on_delete=models.CASCADE, blank=True, null=True
)
message_style = models.CharField(
max_length=1, choices=INVITATION_STYLE, default=INVITATION_FORMAL
)
personal_message = models.TextField(blank=True)
invitation_key = models.CharField(max_length=40, unique=True)
key_expires = models.DateTimeField(default=timezone.now)
date_sent = models.DateTimeField(default=timezone.now)
invited_by = models.ForeignKey(
"scipost.Contributor", on_delete=models.CASCADE, blank=True, null=True
)
nr_reminders = models.PositiveSmallIntegerField(default=0)
date_last_reminded = models.DateTimeField(blank=True, null=True)
responded = models.BooleanField(default=False)
declined = models.BooleanField(default=False)
def __str__(self):
return "DEPRECATED"
[docs]class CitationNotification(models.Model):
"""Deprecated: Use the `invitations` app"""
contributor = models.ForeignKey("scipost.Contributor", on_delete=models.CASCADE)
cited_in_submission = models.ForeignKey(
"submissions.Submission", on_delete=models.CASCADE, blank=True, null=True
)
cited_in_publication = models.ForeignKey(
"journals.Publication", on_delete=models.CASCADE, blank=True, null=True
)
processed = models.BooleanField(default=False)
[docs]class AuthorshipClaim(models.Model):
claimant = models.ForeignKey(
"scipost.Contributor", on_delete=models.CASCADE, related_name="claimant"
)
submission = models.ForeignKey(
"submissions.Submission", on_delete=models.CASCADE, blank=True, null=True
)
commentary = models.ForeignKey(
"commentaries.Commentary", on_delete=models.CASCADE, blank=True, null=True
)
thesislink = models.ForeignKey(
"theses.ThesisLink", on_delete=models.CASCADE, blank=True, null=True
)
vetted_by = models.ForeignKey(
"scipost.Contributor", on_delete=models.CASCADE, blank=True, null=True
)
status = models.SmallIntegerField(
choices=AUTHORSHIP_CLAIM_STATUS, default=AUTHORSHIP_CLAIM_PENDING
)
objects = AuthorshipClaimQuerySet.as_manager()
def __str__(self):
if self.submission:
return "Authorship claim: %s for %s %s" % (
self.claimant,
"Submission",
self.submission,
)
elif self.commentary:
return "Authorship claim: %s for %s %s" % (
self.claimant,
"Commentary",
self.commentary,
)
elif self.thesislink:
return "Authorship claim: %s for %s %s" % (
self.claimant,
"Thesis Link",
self.thesislink,
)
return "Authorship claim: %s for [undefined]" % self.claimant
[docs]class PrecookedEmail(models.Model):
"""
Each instance contains an email template in both plain and html formats.
Can only be created by Admins.
For further use in scipost:send_precooked_email method.
"""
email_subject = models.CharField(max_length=300)
email_text = models.TextField()
email_text_html = models.TextField()
date_created = models.DateField(default=timezone.now)
emailed_to = ArrayField(models.EmailField(blank=True), blank=True)
date_last_used = models.DateField(default=timezone.now)
deprecated = models.BooleanField(default=False)
def __str__(self):
return self.email_subject