blob: 6cc658ea98157d4251ec0ce4b3b4bd2a9f29f80f [file] [log] [blame]
import uuid
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from . import forms
VERIFY_EMAIL_TEMPLATE = 1
NEW_USER_EMAIL_TEMPLATE = 2
PASSWORD_RESET_EMAIL_TEMPLATE = 3
USER_ADDED_TO_GROUP_TEMPLATE = 4
VERIFY_EMAIL_CHANGE_TEMPLATE = 5
USER_PROFILE_COMPLETED_TEMPLATE = 6
class EmailVerification(models.Model):
username = models.CharField(max_length=64)
verification_code = models.CharField(
max_length=36, unique=True, default=uuid.uuid4)
created_date = models.DateTimeField(auto_now_add=True)
verified = models.BooleanField(default=False)
next = models.CharField(max_length=255, null=True)
class EmailTemplate(models.Model):
TEMPLATE_TYPE_CHOICES = (
(VERIFY_EMAIL_TEMPLATE, 'Verify Email Template'),
(NEW_USER_EMAIL_TEMPLATE, 'New User Email Template'),
(PASSWORD_RESET_EMAIL_TEMPLATE, 'Password Reset Email Template'),
(USER_ADDED_TO_GROUP_TEMPLATE, 'User Added to Group Template'),
(VERIFY_EMAIL_CHANGE_TEMPLATE, 'Verify Email Change Template'),
(USER_PROFILE_COMPLETED_TEMPLATE, 'User Profile Completed Template'),
)
template_type = models.IntegerField(
primary_key=True, choices=TEMPLATE_TYPE_CHOICES)
subject = models.CharField(max_length=255)
body = models.TextField()
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
def __str__(self):
for choice in self.TEMPLATE_TYPE_CHOICES:
if self.template_type == choice[0]:
return choice[1]
return "Unknown"
class PasswordResetRequest(models.Model):
username = models.CharField(max_length=64)
reset_code = models.CharField(
max_length=36, unique=True, default=uuid.uuid4)
created_date = models.DateTimeField(auto_now_add=True)
class UserProfile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, related_name="user_profile")
# This flag is only used for external IDP users. It indicates that the
# username was properly initialized when the user logged in through the
# external IDP. As for now that means that the username was set to the
# user's email address. Sometimes the automatic assignment of username fails
# and an administrator needs to intervene. When an administrator sets the
# user's username this flag will also be set to true.
username_initialized = models.BooleanField(default=False)
@property
def is_complete(self):
return len(self.invalid_fields) == 0
@property
def is_username_valid(self):
# Username was provided either by external IDP or manually set by an admin
if self.username_initialized:
return True
# use forms.USERNAME_VALIDATOR
try:
forms.USERNAME_VALIDATOR(self.user.username)
validates = True
except ValidationError:
validates = False
return validates
@property
def is_first_name_valid(self):
return self.is_non_empty(self.user.first_name)
@property
def is_last_name_valid(self):
return self.is_non_empty(self.user.last_name)
@property
def is_email_valid(self):
# Only checking for non-empty only; assumption is that email is verified
# before it is set or updated
return self.is_non_empty(self.user.email)
@property
def invalid_fields(self):
result = []
if not self.is_username_valid:
result.append('username')
if not self.is_email_valid:
result.append('email')
if not self.is_first_name_valid:
result.append('first_name')
if not self.is_last_name_valid:
result.append('last_name')
return result
@property
def is_ext_user_profile_valid(self):
fields = ExtendedUserProfileField.objects.filter(deleted=False)
for field in fields:
try:
value = self.extended_profile_values.filter(ext_user_profile_field=field).get()
if not value.valid:
return False
except ExtendedUserProfileValue.DoesNotExist:
if field.required:
return False
return True
def is_non_empty(self, value: str):
return value is not None and value.strip() != ""
class UserInfo(models.Model):
claim = models.CharField(max_length=64)
value = models.CharField(max_length=255)
user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user_profile', 'claim']
def __str__(self):
return f"{self.claim}={self.value}"
class IDPUserInfo(models.Model):
idp_alias = models.CharField(max_length=64)
claim = models.CharField(max_length=64)
value = models.CharField(max_length=255)
user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="idp_userinfo")
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user_profile', 'claim', 'idp_alias']
def __str__(self):
return f"{self.idp_alias}: {self.claim}={self.value}"
class PendingEmailChange(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
email_address = models.EmailField()
verification_code = models.CharField(
max_length=36, unique=True, default=uuid.uuid4)
created_date = models.DateTimeField(auto_now_add=True)
verified = models.BooleanField(default=False)
class ExtendedUserProfileField(models.Model):
name = models.CharField(max_length=64)
help_text = models.TextField(blank=True)
order = models.IntegerField()
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
deleted = models.BooleanField(default=False)
required = models.BooleanField(default=True)
def __str__(self) -> str:
return f"{self.name} ({self.id})"
@property
def field_type(self):
if hasattr(self, 'text'):
return 'text'
elif hasattr(self, 'single_choice'):
return 'single_choice'
elif hasattr(self, 'multi_choice'):
return 'multi_choice'
elif hasattr(self, 'user_agreement'):
return 'user_agreement'
else:
raise Exception("Could not determine field_type")
class ExtendedUserProfileTextField(ExtendedUserProfileField):
field_ptr = models.OneToOneField(ExtendedUserProfileField,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="text")
class ExtendedUserProfileSingleChoiceField(ExtendedUserProfileField):
field_ptr = models.OneToOneField(ExtendedUserProfileField,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="single_choice")
other = models.BooleanField(default=False)
class ExtendedUserProfileFieldChoice(models.Model):
display_text = models.CharField(max_length=255)
order = models.IntegerField()
deleted = models.BooleanField(default=False)
class Meta:
abstract = True
def __str__(self) -> str:
return f"{self.display_text} ({self.id})"
class ExtendedUserProfileSingleChoiceFieldChoice(ExtendedUserProfileFieldChoice):
single_choice_field = models.ForeignKey(ExtendedUserProfileSingleChoiceField, on_delete=models.CASCADE, related_name="choices")
class ExtendedUserProfileMultiChoiceField(ExtendedUserProfileField):
field_ptr = models.OneToOneField(ExtendedUserProfileField,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="multi_choice")
other = models.BooleanField(default=False)
class ExtendedUserProfileMultiChoiceFieldChoice(ExtendedUserProfileFieldChoice):
multi_choice_field = models.ForeignKey(ExtendedUserProfileMultiChoiceField, on_delete=models.CASCADE, related_name="choices")
class ExtendedUserProfileAgreementField(ExtendedUserProfileField):
field_ptr = models.OneToOneField(ExtendedUserProfileField,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="user_agreement")
# if no checkbox label, then some default text will be used
checkbox_label = models.TextField(blank=True)
class ExtendedUserProfileFieldLink(models.Model):
label = models.TextField()
url = models.URLField()
order = models.IntegerField()
display_link = models.BooleanField(default=True)
display_inline = models.BooleanField(default=False)
# Technically any field can have links
field = models.ForeignKey(ExtendedUserProfileField, on_delete=models.CASCADE, related_name="links")
def __str__(self) -> str:
return f"{self.label} {self.url}"
class ExtendedUserProfileValue(models.Model):
ext_user_profile_field = models.ForeignKey(ExtendedUserProfileField, on_delete=models.SET_NULL, null=True)
user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="extended_profile_values")
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
@property
def value_type(self):
if hasattr(self, 'text'):
return 'text'
elif hasattr(self, 'single_choice'):
return 'single_choice'
elif hasattr(self, 'multi_choice'):
return 'multi_choice'
elif hasattr(self, 'user_agreement'):
return 'user_agreement'
else:
raise Exception("Could not determine value_type")
@property
def value_display(self):
if self.value_type == 'text':
return self.text.text_value
elif self.value_type == 'single_choice':
if self.single_choice.choice:
try:
choice = self.ext_user_profile_field.single_choice.choices.get(id=self.single_choice.choice)
return choice.display_text
except ExtendedUserProfileSingleChoiceFieldChoice.DoesNotExist:
return None
elif self.single_choice.other_value:
return f"Other: {self.single_choice.other_value}"
elif self.value_type == 'multi_choice':
result = []
if self.multi_choice.choices:
mc_field = self.ext_user_profile_field.multi_choice
for choice_value in self.multi_choice.choices.all():
try:
choice = mc_field.choices.get(id=choice_value.value)
result.append(choice.display_text)
except ExtendedUserProfileMultiChoiceFieldChoice.DoesNotExist:
continue
if self.multi_choice.other_value:
result.append(f"Other: {self.multi_choice.other_value}")
return result
elif self.value_type == 'user_agreement':
if self.user_agreement.agreement_value:
return "Yes"
else:
return "No"
return None
@property
def value_display_list(self):
"""Same as value_display except coerced always to a list."""
value_display = self.value_display
if value_display is not None and not isinstance(value_display, list):
return [value_display]
else:
return value_display
@property
def valid(self):
# if the field is deleted, whatever the value, consider it valid
if self.ext_user_profile_field.deleted:
return True
if self.ext_user_profile_field.required:
if self.value_type == 'text':
return self.text.text_value and len(self.text.text_value.strip()) > 0
if self.value_type == 'single_choice':
choice_exists = (self.single_choice.choice and
self.ext_user_profile_field.single_choice.choices
.filter(id=self.single_choice.choice).exists())
has_other = (self.ext_user_profile_field.single_choice.other and
self.single_choice.other_value and
len(self.single_choice.other_value.strip()) > 0)
return choice_exists or has_other
if self.value_type == 'multi_choice':
choice_ids = list(map(lambda c: c.value, self.multi_choice.choices.all()))
choice_exists = self.ext_user_profile_field.multi_choice.choices.filter(id__in=choice_ids).exists()
has_other = (self.ext_user_profile_field.multi_choice.other and
self.multi_choice.other_value and
len(self.multi_choice.other_value.strip()) > 0)
return choice_exists or has_other
if self.value_type == 'user_agreement':
return self.user_agreement.agreement_value is True
return True
class ExtendedUserProfileTextValue(ExtendedUserProfileValue):
value_ptr = models.OneToOneField(ExtendedUserProfileValue,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="text")
text_value = models.TextField()
class ExtendedUserProfileSingleChoiceValue(ExtendedUserProfileValue):
value_ptr = models.OneToOneField(ExtendedUserProfileValue,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="single_choice")
# Only one of value or other_value should be populated, not both
choice = models.BigIntegerField(null=True)
other_value = models.TextField(blank=True)
class ExtendedUserProfileMultiChoiceValue(ExtendedUserProfileValue):
value_ptr = models.OneToOneField(ExtendedUserProfileValue,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="multi_choice")
other_value = models.TextField(blank=True)
class ExtendedUserProfileMultiChoiceValueChoice(models.Model):
value = models.BigIntegerField()
multi_choice_value = models.ForeignKey(ExtendedUserProfileMultiChoiceValue, on_delete=models.CASCADE, related_name="choices")
class ExtendedUserProfileAgreementValue(ExtendedUserProfileValue):
value_ptr = models.OneToOneField(ExtendedUserProfileValue,
on_delete=models.CASCADE,
parent_link=True,
primary_key=True,
related_name="user_agreement")
agreement_value = models.BooleanField()