#
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='id' %}
{% if request_filter == 'pending' or adj == 'pending' %}
Date Requested
+ {% include 'common/table_sorter.html' with table_sorter_field='request_time' %}
{% else %}
Date Completed
+ {% include 'common/table_sorter.html' with table_sorter_field='completion_time' %}
{% endif %}
-
-
User Email
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='project_user__user__email' %}
User
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='project_user__user__username' %}
Requester
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='requester__username' %}
Project
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='project_user__project__name' %}
Status
diff --git a/coldfront/core/project/templates/project/project_renewal/project_renewal_request_list_table.html b/coldfront/core/project/templates/project/project_renewal/project_renewal_request_list_table.html
index a13811d24..7045cf82e 100644
--- a/coldfront/core/project/templates/project/project_renewal/project_renewal_request_list_table.html
+++ b/coldfront/core/project/templates/project/project_renewal/project_renewal_request_list_table.html
@@ -3,48 +3,23 @@
#
-
-
-
-
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='id' %}
Requested
-
-
-
-
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='request_time' %}
Requester
-
-
-
-
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='requester__email' %}
Project
-
-
-
-
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='post_project__name' %}
PI
-
-
-
-
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='pi__email' %}
#
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='id' %}
Date Requested/ Last Modified
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='modified' %}
Requester Email
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='requester__email' %}
Project
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='project' %}
PI
-
-
+ {% include 'common/table_sorter.html' with table_sorter_field='pi__email' %}
Status
diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py
index 09a78d50c..7f0a29852 100644
--- a/coldfront/core/project/views.py
+++ b/coldfront/core/project/views.py
@@ -34,12 +34,14 @@
ProjectSearchForm,
ProjectUpdateForm,
ProjectUserUpdateForm)
+from coldfront.core.project.forms_.new_project_forms.request_forms import SavioProjectSurveyForm
from coldfront.core.project.models import (Project, ProjectReview,
ProjectReviewStatusChoice,
ProjectStatusChoice, ProjectUser,
ProjectUserRoleChoice,
ProjectUserStatusChoice,
- ProjectUserRemovalRequest)
+ ProjectUserRemovalRequest,
+ SavioProjectAllocationRequest)
from coldfront.core.project.utils import (annotate_queryset_with_cluster_name,
is_primary_cluster_project)
from coldfront.core.project.utils_.addition_utils import can_project_purchase_service_units
@@ -269,6 +271,24 @@ def get_context_data(self, **kwargs):
context['can_request_sec_dir'] = \
pi_eligible_to_request_secure_dir(self.request.user)
+ # show survey responses if available
+ allocation_requests = SavioProjectAllocationRequest.objects.filter(
+ project=self.object, status__name='Approved - Complete').order_by('request_time')
+
+ if allocation_requests.exists():
+ survey_answers_list = list(map(
+ lambda x: SavioProjectSurveyForm(
+ initial=x.survey_answers,
+ disable_fields=True
+ ),
+ allocation_requests
+ ))
+
+ context['survey_answers'] = list(zip(
+ allocation_requests,
+ survey_answers_list
+ ))
+
context['user_agreement_signed'] = \
access_agreement_signed(self.request.user)
diff --git a/coldfront/core/project/views_/addition_views/approval_views.py b/coldfront/core/project/views_/addition_views/approval_views.py
index 8eaf4da98..8fdaaebc6 100644
--- a/coldfront/core/project/views_/addition_views/approval_views.py
+++ b/coldfront/core/project/views_/addition_views/approval_views.py
@@ -240,7 +240,7 @@ def get_order_by(self):
direction = '-'
order_by = direction + order_by
else:
- order_by = 'id'
+ order_by = '-modified'
return order_by
def test_func(self):
diff --git a/coldfront/core/project/views_/join_views/approval_views.py b/coldfront/core/project/views_/join_views/approval_views.py
index 0e1f25a96..89bd92935 100644
--- a/coldfront/core/project/views_/join_views/approval_views.py
+++ b/coldfront/core/project/views_/join_views/approval_views.py
@@ -244,9 +244,9 @@ def get_queryset(self):
direction = ''
else:
direction = '-'
- order_by = direction + 'created'
+ order_by = direction + order_by
else:
- order_by = '-created'
+ order_by = '-modified'
project_join_requests = \
ProjectUserJoinRequest.objects.filter(
diff --git a/coldfront/core/project/views_/new_project_views/approval_views.py b/coldfront/core/project/views_/new_project_views/approval_views.py
index 9d42339bb..0b5d1b5c0 100644
--- a/coldfront/core/project/views_/new_project_views/approval_views.py
+++ b/coldfront/core/project/views_/new_project_views/approval_views.py
@@ -69,7 +69,7 @@ def get_queryset(self):
direction = '-'
order_by = direction + order_by
else:
- order_by = 'id'
+ order_by = '-request_time'
return annotate_queryset_with_allocation_period_not_started_bool(
SavioProjectAllocationRequest.objects.order_by(order_by))
@@ -910,7 +910,7 @@ def get_queryset(self):
direction = '-'
order_by = direction + order_by
else:
- order_by = 'id'
+ order_by = '-modified'
return VectorProjectAllocationRequest.objects.order_by(order_by)
def get_context_data(self, **kwargs):
diff --git a/coldfront/core/project/views_/new_project_views/request_views.py b/coldfront/core/project/views_/new_project_views/request_views.py
index d38fdd7bd..e4bfa52d8 100644
--- a/coldfront/core/project/views_/new_project_views/request_views.py
+++ b/coldfront/core/project/views_/new_project_views/request_views.py
@@ -1,3 +1,5 @@
+from allauth.account.models import EmailAddress
+
from coldfront.core.allocation.models import Allocation
from coldfront.core.allocation.models import AllocationStatusChoice
from coldfront.core.billing.forms import BillingIDValidationForm
@@ -465,14 +467,14 @@ def __handle_pi_data(self, form_data):
# Create a new User object intended to be a new PI.
step_number = self.step_numbers_by_form_name['new_pi']
data = form_data[step_number]
+ email = data['email']
try:
- email = data['email']
pi = User.objects.create(
username=email,
first_name=data['first_name'],
last_name=data['last_name'],
email=email,
- is_active=False)
+ is_active=True)
except IntegrityError as e:
self.logger.error(f'User {email} unexpectedly exists.')
raise e
@@ -488,6 +490,18 @@ def __handle_pi_data(self, form_data):
pi_profile.upgrade_request = utc_now_offset_aware()
pi_profile.save()
+ # Create an unverified, primary EmailAddress for the new User object.
+ try:
+ EmailAddress.objects.create(
+ user=pi,
+ email=email,
+ verified=False,
+ primary=True)
+ except IntegrityError as e:
+ self.logger.error(
+ f'EmailAddress {email} unexpectedly already exists.')
+ raise e
+
return pi
def __handle_recharge_allowance(self, form_data,
diff --git a/coldfront/core/project/views_/removal_views.py b/coldfront/core/project/views_/removal_views.py
index 79ad39184..2ef834b69 100644
--- a/coldfront/core/project/views_/removal_views.py
+++ b/coldfront/core/project/views_/removal_views.py
@@ -217,7 +217,7 @@ def get_queryset(self):
direction = '-'
order_by = direction + order_by
else:
- order_by = 'id'
+ order_by = '-modified'
project_removal_status_complete, _ = \
ProjectUserRemovalRequestStatusChoice.objects.get_or_create(
diff --git a/coldfront/core/project/views_/renewal_views/approval_views.py b/coldfront/core/project/views_/renewal_views/approval_views.py
index c58418efc..a1231c008 100644
--- a/coldfront/core/project/views_/renewal_views/approval_views.py
+++ b/coldfront/core/project/views_/renewal_views/approval_views.py
@@ -55,7 +55,8 @@ def get_queryset(self):
direction = '-'
order_by = direction + order_by
else:
- order_by = 'id'
+ order_by = '-request_time'
+
return annotate_queryset_with_allocation_period_not_started_bool(
AllocationRenewalRequest.objects.order_by(order_by))
diff --git a/coldfront/core/socialaccount/adapter.py b/coldfront/core/socialaccount/adapter.py
index 3f1c5c200..5c2345f67 100644
--- a/coldfront/core/socialaccount/adapter.py
+++ b/coldfront/core/socialaccount/adapter.py
@@ -1,15 +1,21 @@
from allauth.account.models import EmailAddress
-from allauth.account.utils import user_email
+from allauth.account.utils import user_email as user_email_func
from allauth.account.utils import user_field
from allauth.account.utils import user_username
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
+from allauth.socialaccount.models import SocialAccount
+from allauth.socialaccount.providers.base import AuthProcess
from allauth.utils import valid_email_or_none
+from coldfront.core.account.utils.login_activity import LoginActivityVerifier
+from coldfront.core.utils.context_processors import portal_and_program_names
from collections import defaultdict
from django.conf import settings
from django.http import HttpResponseBadRequest
from django.http import HttpResponseServerError
from django.template.loader import render_to_string
+from django.urls import reverse
+from flags.state import flag_enabled
import logging
@@ -19,6 +25,11 @@
class CILogonAccountAdapter(DefaultSocialAccountAdapter):
"""An adapter that adjusts handling for the CILogon provider."""
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._flag_multiple_email_addresses_allowed = flag_enabled(
+ 'MULTIPLE_EMAIL_ADDRESSES_ALLOWED')
+
def populate_user(self, request, sociallogin, data):
"""Handle logins using the CILogon provider differently. In
particular, use the given email address as the username; raise
@@ -45,18 +56,29 @@ def populate_user(self, request, sociallogin, data):
f'Provider {provider} did not provide an email address '
f'for User with UID {user_uid}.')
logger.error(log_message)
- self.raise_server_error(self.get_auth_error_message())
+ self._raise_server_error(self._get_auth_error_message())
validated_email = validated_email.lower()
user = sociallogin.user
user_username(user, validated_email)
- user_email(user, validated_email)
+ user_email_func(user, validated_email)
user_field(user, 'first_name', first_name)
user_field(user, 'last_name', last_name)
return user
return super().populate_user(request, sociallogin, data)
+ def get_connect_redirect_url(self, request, socialaccount):
+ """
+ Returns the default URL to redirect to after successfully
+ connecting a social account.
+ """
+ if self._flag_multiple_email_addresses_allowed:
+ url = reverse('socialaccount_connections')
+ else:
+ url = reverse('home')
+ return url
+
def pre_social_login(self, request, sociallogin):
"""At this point, the user is authenticated by a provider. If
the provider is not CILogon, do nothing. Otherwise, if this
@@ -65,6 +87,9 @@ def pre_social_login(self, request, sociallogin):
EmailAddress matching one of those given by the provider,
connect the two accounts.
+ Note that login is blocked outside (after) this method if the
+ user is inactive.
+
Adapted from:
https://github.com/pennersr/django-allauth/issues/418#issuecomment-137259550
"""
@@ -86,19 +111,40 @@ def pre_social_login(self, request, sociallogin):
if provider != 'cilogon':
return
+ # If users are not allowed to have multiple emails, and the user is
+ # attempting to connect another SocialAccount to their account (as
+ # opposed to logging in with an existing one), raise an error.
+ if not self._flag_multiple_email_addresses_allowed:
+ if sociallogin.state.get('process', None) == AuthProcess.CONNECT:
+ message = (
+ 'You may not connect more than one third-party account to '
+ 'your portal account.')
+ self._raise_client_error(message)
+
# If a SocialAccount already exists, meaning the provider account is
# connected to a local account, proceed with login.
if sociallogin.is_existing:
return
- # If the provider does not provide any addresses, raise an error.
provider_addresses = sociallogin.email_addresses
- if not provider_addresses:
+ num_provider_addresses = len(provider_addresses)
+ # If the provider does not provide any addresses, raise an error.
+ if num_provider_addresses == 0:
log_message = (
f'Provider {provider} did not provide any email addresses for '
f'User with email {user_email} and UID {user_uid}.')
logger.error(log_message)
- self.raise_server_error(self.get_auth_error_message())
+ self._raise_server_error(self._get_auth_error_message())
+ # In general, it is expected that a provider will only give one address.
+ # If multiple are given, allow all of them to be associated with the
+ # user (agnostic of whether users are allowed to have multiple), but log
+ # a warning.
+ elif num_provider_addresses > 1:
+ log_message = (
+ f'Provider {provider} provided more than one email address for '
+ f'User with email {user_email} and UID {user_uid}: '
+ f'{", ".join(provider_addresses)}.')
+ logger.warning(log_message)
# SOCIALACCOUNT_PROVIDERS['cilogon']['VERIFIED_EMAIL'] should be True,
# so all provider-given addresses should be interpreted as verified.
@@ -110,7 +156,7 @@ def pre_social_login(self, request, sociallogin):
f'None of the email addresses in '
f'[{", ".join(provider_addresses)}] are verified.')
logger.error(log_message)
- self.raise_server_error(self.get_auth_error_message())
+ self._raise_server_error(self._get_auth_error_message())
# Fetch EmailAddresses matching those given by the provider, divided by
# the associated User.
@@ -129,64 +175,93 @@ def pre_social_login(self, request, sociallogin):
if not matching_addresses_by_user:
return
elif len(matching_addresses_by_user) == 1:
- # If at least one address was verified, connect the two accounts.
- # Otherwise, raise an error with instructions on how to proceed.
user = next(iter(matching_addresses_by_user))
addresses = matching_addresses_by_user[user]
if any([a.verified for a in addresses]):
- log_message = (
- f'Attempting to connect data for User with email '
- f'{user_email} and UID {user_uid} from provider '
- f'{provider} to local User {user.pk}.')
- logger.info(log_message)
- sociallogin.connect(request, user)
- log_message = f'Successfully connected data to User {user.pk}.'
- logger.info(log_message)
+ # After this, allauth.account.adapter.pre_login blocks login if
+ # the user is inactive. Regardless of that, connect the user
+ # (and trigger signals for creating EmailAddresses).
+ self._connect_user(
+ request, sociallogin, provider, user, user_email, user_uid)
else:
- log_message = (
- f'Found only unverified email addresses associated with '
- f'local User {user.pk} matching those given by provider '
- f'{provider} for User with email {user_email} and UID '
- f'{user_uid}.')
- logger.warning(log_message)
- message = (
- 'You are attempting to login using an email address '
- 'associated with an existing User, but it is unverified. '
- 'Please login to that account using a different provider, '
- 'verify the email address, and try again.')
- self.raise_client_error(message)
+ self._block_login_for_verification(
+ request, sociallogin, provider, user, user_email, user_uid,
+ addresses)
else:
- user_pks = [user.pk for user in matching_addresses_by_user]
+ user_pks = sorted([user.pk for user in matching_addresses_by_user])
log_message = (
f'Unexpectedly found multiple Users ([{", ".join(user_pks)}]) '
f'that had email addresses matching those provided by '
f'provider {provider} for User with email {user_email} and '
f'UID {user_uid}.')
logger.error(log_message)
- self.raise_server_error(self.get_auth_error_message())
+ self._raise_server_error(self._get_auth_error_message())
+
+ def _block_login_for_verification(self, request, sociallogin, provider,
+ user, user_email, user_uid,
+ email_addresses):
+ """Block the login attempt and send verification emails to the
+ given EmailAddresses."""
+ log_message = (
+ f'Found only unverified email addresses associated with local User '
+ f'{user.pk} matching those given by provider {provider} for User '
+ f'with email {user_email} and UID {user_uid}.')
+ logger.warning(log_message)
+
+ try:
+ cilogon_idp = sociallogin.serialize()[
+ 'account']['extra_data']['idp_name']
+ request_login_method_str = f'CILogon - {cilogon_idp}'
+ except Exception as e:
+ logger.exception(f'Failed to determine CILogon IDP. Details:\n{e}')
+ request_login_method_str = 'CILogon'
+ for email_address in email_addresses:
+ verifier = LoginActivityVerifier(
+ request, email_address, request_login_method_str)
+ verifier.send_email()
+
+ message = (
+ 'You are attempting to log in using an email address associated '
+ 'with an existing user, but it is unverified. Please check the '
+ 'address for a verification email.')
+ self._raise_client_error(message)
+
+ @staticmethod
+ def _connect_user(request, sociallogin, provider, user, user_email,
+ user_uid):
+ """Connect the provider account to the User's account in the
+ database."""
+ sociallogin.connect(request, user)
+ log_message = (
+ f'Successfully connected data for User with email {user_email} and '
+ f'UID {user_uid} from provider {provider} to local User {user.pk}.')
+ logger.info(log_message)
@staticmethod
- def get_auth_error_message():
+ def _get_auth_error_message():
"""Return the generic message the user should receive if
authentication-related errors occur."""
return (
f'Unexpected authentication error. Please contact '
f'{settings.CENTER_HELP_EMAIL} for further assistance.')
- @staticmethod
- def raise_client_error(message):
+ def _raise_client_error(self, message):
"""Raise an ImmediateHttpResponse with a client error and the
given message."""
+ self._raise_error(HttpResponseBadRequest, message)
+
+ @staticmethod
+ def _raise_error(response_class, message):
+ """Raise an ImmediateHttpResponse with an error HttpResponse
+ class (e.g., HttpResponseBadRequest or HttpResponseServerError)
+ error and the given message."""
template = 'error_with_message.html'
- html = render_to_string(template, context={'message': message})
- response = HttpResponseBadRequest(html)
+ context = {'message': message, **portal_and_program_names(None)}
+ html = render_to_string(template, context=context)
+ response = response_class(html)
raise ImmediateHttpResponse(response)
- @staticmethod
- def raise_server_error(message):
+ def _raise_server_error(self, message):
"""Raise an ImmediateHttpResponse with a server error and the
given message."""
- template = 'error_with_message.html'
- html = render_to_string(template, context={'message': message})
- response = HttpResponseServerError(html)
- raise ImmediateHttpResponse(response)
+ self._raise_error(HttpResponseServerError, message)
diff --git a/coldfront/core/socialaccount/signals.py b/coldfront/core/socialaccount/signals.py
index 25d363c64..662529647 100644
--- a/coldfront/core/socialaccount/signals.py
+++ b/coldfront/core/socialaccount/signals.py
@@ -6,7 +6,7 @@
from django.db import transaction
from django.dispatch import receiver
-from allauth.socialaccount.models import EmailAddress
+from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialLogin
from allauth.socialaccount.providers.base import AuthProcess
from allauth.socialaccount.signals import social_account_added
diff --git a/coldfront/core/socialaccount/urls.py b/coldfront/core/socialaccount/urls.py
index 0b98ebbd8..06e59322a 100644
--- a/coldfront/core/socialaccount/urls.py
+++ b/coldfront/core/socialaccount/urls.py
@@ -1,13 +1,40 @@
from allauth.socialaccount.urls import urlpatterns as all_patterns
+from flags.urls import flagged_paths
+
"""Include a subset of patterns from allauth.socialaccount."""
urlpatterns = []
-names_to_include = {
- 'socialaccount_login_cancelled',
- 'socialaccount_connections',
-}
-for pattern in all_patterns:
- if pattern.name in names_to_include:
- urlpatterns.append(pattern)
+
+
+# TODO: Come up with a more elegant solution for dealing with views protected by
+# multiple flags.
+with flagged_paths('SSO_ENABLED') as sso_f_path:
+ names_to_include = {
+ 'socialaccount_login_cancelled',
+ }
+ for pattern in all_patterns:
+ if pattern.name in names_to_include:
+ urlpatterns.append(sso_f_path(
+ str(pattern.pattern), pattern.callback,
+ pattern.default_args, pattern.name)
+ )
+
+ # Only include the view for connecting additional social accounts if users
+ # are allowed to have multiple emails.
+ names_to_include_if_multiple_emails_allowed = {
+ 'socialaccount_connections',
+ }
+ with flagged_paths('MULTIPLE_EMAIL_ADDRESSES_ALLOWED') as multi_email_f_path:
+ for pattern in all_patterns:
+ if pattern.name in names_to_include_if_multiple_emails_allowed:
+ # The URL is not correctly disabled unless passed through both
+ # context managers.
+ tmp_pattern = multi_email_f_path(
+ str(pattern.pattern), pattern.callback,
+ pattern.default_args, pattern.name)
+ final_pattern = sso_f_path(
+ str(tmp_pattern.pattern), tmp_pattern.callback,
+ tmp_pattern.default_args, tmp_pattern.name)
+ urlpatterns.append(final_pattern)
diff --git a/coldfront/core/statistics/management/__init__.py b/coldfront/core/statistics/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/coldfront/core/statistics/management/commands/__init__.py b/coldfront/core/statistics/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/coldfront/core/statistics/management/commands/free_qos_jobs.py b/coldfront/core/statistics/management/commands/free_qos_jobs.py
new file mode 100644
index 000000000..dc2f0ef18
--- /dev/null
+++ b/coldfront/core/statistics/management/commands/free_qos_jobs.py
@@ -0,0 +1,252 @@
+import json
+import logging
+
+from decimal import Decimal
+
+from django.core.management.base import BaseCommand
+
+from coldfront.core.allocation.utils import get_project_compute_allocation
+from coldfront.core.project.models import Project
+from coldfront.core.statistics.models import Job
+from coldfront.core.statistics.utils_.accounting_utils import set_job_amount
+from coldfront.core.statistics.utils_.accounting_utils import validate_job_dates
+from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
+from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
+from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterfaceError
+from coldfront.core.utils.common import add_argparse_dry_run_argument
+
+
+"""An admin command for listing and updating jobs under free QoSes that
+have non-zero amounts."""
+
+
+class Command(BaseCommand):
+
+ help = (
+ 'Manage jobs under free QoSes that have non-zero service unit amounts. '
+ 'List statistics about them or reset their amounts to zero, updating '
+ 'the associated usages.')
+
+ logger = logging.getLogger(__name__)
+
+ def add_arguments(self, parser):
+ subparsers = parser.add_subparsers(
+ dest='subcommand',
+ help='The subcommand to run.',
+ title='subcommands')
+ subparsers.required = True
+ self._add_reset_subparser(subparsers)
+ self._add_summary_subparser(subparsers)
+ add_argparse_dry_run_argument(parser)
+
+ def handle(self, *args, **options):
+ subcommand = options['subcommand']
+ if subcommand == 'reset':
+ self._handle_reset(*args, **options)
+ elif subcommand == 'summary':
+ self._handle_summary(*args, **options)
+
+ @staticmethod
+ def _add_reset_subparser(parsers):
+ """Add a subparser for the 'reset' subcommand."""
+ parser = parsers.add_parser(
+ 'reset',
+ help=(
+ 'Set amounts for relevant jobs to zero, and update associated '
+ 'usages.'))
+ parser.add_argument(
+ 'qos_names',
+ help='A space-separated list of free QoS names.',
+ nargs='+',
+ type=str)
+ parser.add_argument(
+ '--project',
+ help='The name of a specific project to perform the reset for.',
+ type=str)
+ add_argparse_dry_run_argument(parser)
+
+ @staticmethod
+ def _add_summary_subparser(parsers):
+ """Add a subparser for the 'summary' subcommand."""
+ parser = parsers.add_parser(
+ 'summary', help='Get a JSON summary of relevant jobs.')
+ parser.add_argument(
+ 'qos_names',
+ help='A space-separated list of free QoS names.',
+ nargs='+',
+ type=str)
+ parser.add_argument(
+ '--project',
+ help='The name of a specific project to get a summary for.',
+ type=str)
+
+ def _handle_reset(self, *args, **options):
+ """Handle the 'reset' subcommand."""
+ if options['project'] is not None:
+ project = Project.objects.get(name=options['project'])
+ else:
+ project = None
+ self._zero_out_free_qos_jobs(
+ options['qos_names'], project=project, dry_run=options['dry_run'])
+
+ def _handle_summary(self, *args, **options):
+ """Handle the 'summary' subcommand."""
+ if options['project'] is not None:
+ project = Project.objects.get(name=options['project'])
+ else:
+ project = None
+ output_json = self._summary_json(options['qos_names'], project=project)
+ self.stdout.write(json.dumps(output_json, indent=4, sort_keys=True))
+
+ @staticmethod
+ def _summary_json(qos_names, project=None):
+ """Return a dictionary detailing the number of jobs with the
+ given QoSes that have non-zero amounts, as well as the total
+ associated usage. Optionally only consider jobs under the given
+ Project. """
+ zero = Decimal('0.00')
+
+ num_jobs = 0
+ total_by_project_id = {}
+ kwargs = {
+ 'qos__in': qos_names,
+ 'amount__gt': zero,
+ }
+ if project is not None:
+ kwargs['accountid'] = project
+ for job in Job.objects.filter(**kwargs).iterator():
+ num_jobs += 1
+ # Use accountid_id to avoid a foreign key lookup.
+ project_id = job.accountid_id
+ if project_id not in total_by_project_id:
+ total_by_project_id[project_id] = zero
+ total_by_project_id[project_id] += job.amount
+
+ total_by_project_name = {}
+ total_by_allowance = {}
+ for project_id, amount in total_by_project_id.items():
+ project = Project.objects.get(id=project_id)
+ total_by_project_name[project.name] = str(amount)
+ if '_' in project.name:
+ allowance_type = project.name.split('_')[0]
+ else:
+ allowance_type = project.name
+ if allowance_type not in total_by_allowance:
+ total_by_allowance[allowance_type] = zero
+ total_by_allowance[allowance_type] += amount
+
+ for allowance_name, amount in total_by_allowance.items():
+ total_by_allowance[allowance_name] = str(amount)
+
+ return {
+ 'num_jobs': num_jobs,
+ 'total_by_allowance': total_by_allowance,
+ 'total_by_project': total_by_project_name,
+ }
+
+ def _zero_out_free_qos_jobs(self, qos_names, project=None, dry_run=False):
+ """For each job with one of the given QoSes, reset the job's
+ amount to zero, and update the associated usages if
+ appropriate. Optionally only consider jobs under the given
+ Project. Optionally display updates instead of performing
+ them."""
+ computing_allowance_interface = ComputingAllowanceInterface()
+ periodic_project_name_prefixes = tuple([
+ computing_allowance_interface.code_from_name(allowance.name)
+ for allowance in computing_allowance_interface.allowances()
+ if ComputingAllowance(allowance).is_periodic()])
+
+ total_by_project_name = {}
+ project_cache = {}
+ allocation_cache = {}
+
+ zero = Decimal('0.00')
+ num_jobs = 0
+ kwargs = {
+ 'qos__in': qos_names,
+ 'amount__gt': zero,
+ }
+ if project is not None:
+ kwargs['accountid'] = project
+ for job in Job.objects.filter(**kwargs).iterator():
+ num_jobs += 1
+ jobslurmid = job.jobslurmid
+
+ project_id = job.accountid_id
+ if project_id in project_cache:
+ project = project_cache[project_id]
+ else:
+ project = job.accountid
+ project_cache[project_id] = project
+
+ # Skip updating usages for any job that is outside its allocation's
+ # allowance period. Some projects don't have meaningful periods;
+ # avoid expensive lookups for them.
+ try:
+ computing_allowance_interface.allowance_from_project(project)
+ except ComputingAllowanceInterfaceError:
+ # Non-primary cluster project --> no allowance period
+ update_usages = True
+ else:
+ if project.name.startswith(periodic_project_name_prefixes):
+ # Has a periodic allowance --> defined allowance period
+ # Only update usages if the job is within the current
+ # period.
+ if project_id in allocation_cache:
+ allocation = allocation_cache[project_id]
+ else:
+ allocation = get_project_compute_allocation(project)
+ allocation_cache[project_id] = allocation
+ job_data = job.__dict__
+ job_data['accountid'] = project
+ update_usages = validate_job_dates(
+ job_data, allocation, end_date_expected=True)
+ else:
+ # Does not have a periodic allowance --> no allowance period
+ update_usages = True
+
+ if project.name not in total_by_project_name:
+ total_by_project_name[project.name] = {
+ 'num_jobs': 0,
+ 'usage': zero,
+ }
+ total_by_project_name[project.name]['num_jobs'] += 1
+ if update_usages:
+ total_by_project_name[project.name]['usage'] += job.amount
+ else:
+ message = (
+ f'Job {jobslurmid} outside of allowance period. Skipping '
+ f'usage update.')
+ self.stdout.write(self.style.WARNING(message))
+
+ if not dry_run:
+ try:
+ set_job_amount(
+ jobslurmid, zero, update_usages=update_usages)
+ except Exception as e:
+ self.logger.exception(e)
+ message = (
+ f'Failed to update amount for Job {jobslurmid}. '
+ f'Details:\n{e}')
+ self.stderr.write(self.style.ERROR(message))
+
+ for project_name in total_by_project_name:
+ usage_str = str(
+ total_by_project_name[project_name]['usage'])
+ total_by_project_name[project_name]['usage'] = usage_str
+ result_json = json.dumps(
+ total_by_project_name, indent=4, sort_keys=True)
+
+ message = (
+ f'Corrected amounts for {num_jobs} jobs under free QoSes '
+ f'{", ".join(sorted(qos_names))} to zero and associated usages. '
+ f'Summary:\n{result_json}')
+ self.stdout.write(message)
+
+ if not dry_run:
+ compact_result_json = json.dumps(
+ total_by_project_name, sort_keys=True)
+ self.logger.info(
+ f'Corrected amounts for {num_jobs} jobs under free QoSes '
+ f'{", ".join(sorted(qos_names))} to zero and associated '
+ f'usages. Summary: {compact_result_json}')
diff --git a/coldfront/core/statistics/utils_/accounting_utils.py b/coldfront/core/statistics/utils_/accounting_utils.py
new file mode 100644
index 000000000..9f440677c
--- /dev/null
+++ b/coldfront/core/statistics/utils_/accounting_utils.py
@@ -0,0 +1,154 @@
+import logging
+import pytz
+
+from datetime import date
+from datetime import datetime
+from datetime import timedelta
+from decimal import Decimal
+
+from django.db import transaction
+
+from coldfront.api.statistics.utils import get_accounting_allocation_objects
+from coldfront.core.allocation.models import AllocationAttributeUsage
+from coldfront.core.allocation.models import AllocationUserAttributeUsage
+from coldfront.core.resource.utils_.allowance_utils.computing_allowance import ComputingAllowance
+from coldfront.core.resource.utils_.allowance_utils.interface import ComputingAllowanceInterface
+from coldfront.core.statistics.models import Job
+from coldfront.core.utils.common import display_time_zone_date_to_utc_datetime
+
+
+def set_job_amount(jobslurmid, amount, update_usages=True):
+ """Set the number of service units for the Job with the given Slurm
+ ID to the given amount. Optionally update associated usages.
+
+ Parameters:
+ - jobslurmid (str)
+ - amount (Decimal)
+
+ Returns:
+ - None
+
+ Raises:
+
+ """
+ assert isinstance(jobslurmid, str)
+ assert isinstance(amount, Decimal)
+
+ with transaction.atomic():
+ job = Job.objects.select_for_update().get(jobslurmid=jobslurmid)
+
+ if update_usages:
+ account = job.accountid
+ user = job.userid
+ allocation_objects = get_accounting_allocation_objects(
+ account, user=user, enforce_allocation_active=False)
+
+ account_usage = (
+ AllocationAttributeUsage.objects.select_for_update().get(
+ pk=allocation_objects.allocation_attribute_usage.pk))
+ user_account_usage = (
+ AllocationUserAttributeUsage.objects.select_for_update().get(
+ pk=allocation_objects.allocation_user_attribute_usage.pk))
+
+ difference = amount - job.amount
+
+ new_account_usage = max(
+ account_usage.value + difference, Decimal('0.00'))
+ account_usage.value = new_account_usage
+ account_usage.save()
+
+ new_user_account_usage = max(
+ user_account_usage.value + difference, Decimal('0.00'))
+ user_account_usage.value = new_user_account_usage
+ user_account_usage.save()
+
+ # Do not update the job.amount before calculating the difference.
+ job.amount = amount
+ job.save()
+
+
+def validate_job_dates(job_data, allocation, end_date_expected=False):
+ """Given a dictionary representing a Job, its corresponding
+ Allocation, and whether the Job is expected to include an end date,
+ return whether:
+ (a) The Job has the expected dates,
+ (b) The Job's corresponding Allocation has the expected dates,
+ and
+ (c) The Job started and ended within the Allocation's dates.
+
+ Write errors or warnings to the log if not."""
+ logger = logging.getLogger(__name__)
+
+ date_format = '%Y-%m-%d %H:%M:%SZ'
+
+ jobslurmid = job_data['jobslurmid']
+ account_name = job_data['accountid'].name
+
+ # The Job should have submit, start, and, if applicable, end dates.
+ expected_date_keys = ['submitdate', 'startdate']
+ if end_date_expected:
+ expected_date_keys.append('enddate')
+ expected_dates = {
+ key: job_data.get(key, None) for key in expected_date_keys}
+ for key, expected_date in expected_dates.items():
+ if not isinstance(expected_date, datetime):
+ logger.error(f'Job {jobslurmid} does not have a {key}.')
+ return False
+
+ # The Job's corresponding Allocation should have a start date.
+ allocation_start_date = allocation.start_date
+ if not isinstance(allocation_start_date, date):
+ logger.error(
+ f'Allocation {allocation.pk} (Project {account_name}) does not '
+ f'have a start date.')
+ return False
+
+ # The Job should not have started before its corresponding Allocation's
+ # start date.
+ job_start_dt_utc = expected_dates['startdate']
+ allocation_start_dt_utc = display_time_zone_date_to_utc_datetime(
+ allocation_start_date)
+ if job_start_dt_utc < allocation_start_dt_utc:
+ logger.warning(
+ f'Job {jobslurmid} start date '
+ f'({job_start_dt_utc.strftime(date_format)}) is before Allocation '
+ f'{allocation.pk} (Project {account_name}) start date '
+ f'({allocation_start_dt_utc.strftime(date_format)}).')
+ return False
+
+ if not end_date_expected:
+ return True
+
+ # The Job's corresponding Allocation may have an end date. (Compare
+ # against the maximum date if not.)
+ computing_allowance_interface = ComputingAllowanceInterface()
+ periodic_project_name_prefixes = tuple([
+ computing_allowance_interface.code_from_name(allowance.name)
+ for allowance in computing_allowance_interface.allowances()
+ if ComputingAllowance(allowance).is_periodic()])
+ if account_name.startswith(periodic_project_name_prefixes):
+ allocation_end_date = allocation.end_date
+ if not isinstance(allocation_end_date, date):
+ logger.error(
+ f'Allocation {allocation.pk} (Project {account_name}) does not '
+ f'have an end date.')
+ return False
+ allocation_end_dt_utc = (
+ display_time_zone_date_to_utc_datetime(allocation_end_date) +
+ timedelta(hours=24) -
+ timedelta(microseconds=1))
+ else:
+ allocation_end_dt_utc = datetime.max.replace(tzinfo=pytz.utc)
+
+ # The Job should not have ended after the last microsecond of its
+ # corresponding Allocation's end date.
+ job_end_dt_utc = expected_dates['enddate']
+ if job_end_dt_utc > allocation_end_dt_utc:
+ logger.warning(
+ f'Job {jobslurmid} end date '
+ f'({job_end_dt_utc.strftime(date_format)}) is after Allocation '
+ f'{allocation.pk} (Project {account_name}) end date '
+ f'({allocation_end_dt_utc.strftime(date_format)}).')
+ return False
+
+ return True
diff --git a/coldfront/core/user/admin.py b/coldfront/core/user/admin.py
index be0ae38a6..40bff30ca 100644
--- a/coldfront/core/user/admin.py
+++ b/coldfront/core/user/admin.py
@@ -1,14 +1,8 @@
from django.contrib import admin
-from django.contrib import messages
-from django.core.exceptions import ValidationError
-from coldfront.core.user.models import UserProfile, EmailAddress
-from coldfront.core.user.utils import update_user_primary_email_address
+from allauth.account.models import EmailAddress
-import logging
-
-
-logger = logging.getLogger(__name__)
+from coldfront.core.user.models import UserProfile
@admin.register(UserProfile)
@@ -28,79 +22,6 @@ def last_name(self, obj):
return obj.user.last_name
-@admin.register(EmailAddress)
-class EmailAddressAdmin(admin.ModelAdmin):
- list_display = ('user', 'email', 'is_primary', 'is_verified', )
- ordering = ('user', '-is_primary', '-is_verified', 'email', )
- list_filter = ('is_primary', 'is_verified', )
- search_fields = [
- 'user__username', 'user__first_name', 'user__last_name', 'email']
- fields = ('email', 'user', 'is_primary', 'is_verified', )
- actions = ('make_primary', )
- readonly_fields = ('is_primary', 'is_verified', )
-
- def delete_model(self, request, obj):
- if obj.is_primary:
- raise ValidationError(
- 'Cannot delete primary email. Unset primary status in list '
- 'display before deleting.')
- else:
- super().delete_model(request, obj)
-
- @admin.action(description='Make selected primary')
- def make_primary(self, request, queryset):
- """Set the EmailAddresses in the given queryset as the primary
- addresses of their respective users.
-
- Currently, admins are limited to setting at most one address at
- a time."""
- if queryset.count() > 1:
- raise ValidationError(
- 'Cannot set more than one primary email address at a time.')
- for email_address in queryset:
- user = email_address.user
- try:
- update_user_primary_email_address(email_address)
- except ValueError:
- raise ValidationError(
- 'Cannot set an unverified email address as primary.')
- except Exception as e:
- message = (
- f'Encountered unexpected exception when updating User '
- f'{user.pk}\'s primary EmailAddress to '
- f'{email_address.pk}. Details:')
- logger.error(message)
- logger.exception(e)
- raise ValidationError(
- f'Failed to set {email_address.pk} as primary. See the '
- f'log for details.')
- else:
- message = (
- f'Set User {user.pk}\'s primary EmailAddress to '
- f'{email_address.email}.')
- messages.success(request, message)
-
- def delete_queryset(self, request, queryset):
- """Delete EmailAddresses in the given queryset, skipping those
- that are primary."""
- num_primary, num_non_primary = 0, 0
- for email_address in queryset:
- if email_address.is_primary:
- num_primary = num_primary + 1
- else:
- email_address.delete()
- num_non_primary = num_non_primary + 1
-
- success_message = (
- f'Deleted {num_non_primary} non-primary EmailAddresses.')
- messages.success(request, success_message)
-
- if num_primary > 0:
- error_message = (
- f'Skipped deleting {num_primary} primary EmailAddresses.')
- messages.error(request, error_message)
-
-
class EmailAddressInline(admin.TabularInline):
model = EmailAddress
extra = 0
diff --git a/coldfront/core/user/auth.py b/coldfront/core/user/auth.py
index 4e1022c0d..8616118d9 100644
--- a/coldfront/core/user/auth.py
+++ b/coldfront/core/user/auth.py
@@ -1,16 +1,24 @@
-from coldfront.core.user.models import EmailAddress
-from coldfront.core.user.utils import send_email_verification_email
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.models import User
+
+from allauth.account.models import EmailAddress
+from sesame.backends import ModelBackend as BaseSesameBackend
+
+from coldfront.core.user.utils import send_email_verification_email
+from coldfront.core.user.utils_.link_login_utils import UserLoginLinkIneligible
+from coldfront.core.user.utils_.link_login_utils import validate_user_eligible_for_login_link
+
import logging
+logger = logging.getLogger(__name__)
+
+
class EmailAddressBackend(BaseBackend):
"""An authentication backend that allows a user to authenticate
using any of their verified EmailAddress objects."""
def authenticate(self, request, username=None, password=None, **kwargs):
- logger = logging.getLogger(__name__)
if username is None:
return None
username = username.lower()
@@ -24,7 +32,7 @@ def authenticate(self, request, username=None, password=None, **kwargs):
# If the EmailAddress exists, but is not verified, send a verification
# email to it. Only do this if the user is already active; otherwise,
# a separate account activation email will handle email verification.
- if user.is_active and not email_address.is_verified:
+ if user.is_active and not email_address.verified:
try:
send_email_verification_email(email_address)
except Exception as e:
@@ -42,3 +50,19 @@ def get_user(self, user_id):
return User.objects.get(id=user_id)
except User.DoesNotExist:
return None
+
+
+class SesameBackend(BaseSesameBackend):
+ """A subclass of django-sesame's ModelBackend that limits who is
+ eligible to log in using tokens."""
+
+ def user_can_authenticate(self, user):
+ try:
+ validate_user_eligible_for_login_link(user)
+ except UserLoginLinkIneligible as e:
+ message = (
+ f'User {user.username} was blocked from Sesame authentication '
+ f'because: {str(e)}')
+ logger.info(message)
+ return False
+ return True
diff --git a/coldfront/core/user/forms.py b/coldfront/core/user/forms.py
index ae742651d..88bc01bff 100644
--- a/coldfront/core/user/forms.py
+++ b/coldfront/core/user/forms.py
@@ -8,15 +8,16 @@
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
-from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.html import mark_safe
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _
+from allauth.account.models import EmailAddress
+
from coldfront.core.user.utils import send_account_activation_email
-from coldfront.core.user.models import UserProfile, EmailAddress
+from coldfront.core.user.models import UserProfile
from coldfront.core.utils.mail import dummy_email_address
from phonenumber_field.formfields import PhoneNumberField
@@ -240,40 +241,11 @@ def set_acknowledgement_help_text(self):
field.help_text = template.format('LBNL', 'LBNL IT Division')
-class EmailAddressAddForm(forms.Form):
-
- email = forms.EmailField(max_length=100, required=True)
-
- def clean_email(self):
- cleaned_data = super().clean()
- email = cleaned_data['email'].lower()
- if (User.objects.filter(email=email).exists() or
- EmailAddress.objects.filter(email=email).exists()):
- raise forms.ValidationError(
- f'Email address {email} is already in use.')
- return email
-
-
class UserReactivateForm(forms.Form):
email = forms.EmailField(max_length=100, required=True)
-class PrimaryEmailAddressSelectionForm(forms.Form):
-
- email_address = forms.ModelChoiceField(
- label='New Primary Email Address',
- queryset=EmailAddress.objects.none(),
- required=True,
- widget=forms.RadioSelect())
-
- def __init__(self, *args, **kwargs):
- user = kwargs.pop('user')
- super().__init__(*args, **kwargs)
- self.fields['email_address'].queryset = EmailAddress.objects.filter(
- user=user, is_verified=True, is_primary=False)
-
-
class VerifiedEmailAddressPasswordResetForm(PasswordResetForm):
"""A subclass of django.contrib.auth.forms.PasswordResetForm that
uses EmailAddress."""
@@ -284,7 +256,7 @@ def get_email_address(email):
if one exists, else None."""
try:
return EmailAddress.objects.select_related('user').get(
- email=email, is_verified=True, user__is_active=True)
+ email=email, verified=True, user__is_active=True)
except EmailAddress.DoesNotExist:
return None
diff --git a/coldfront/core/user/forms_/__init__.py b/coldfront/core/user/forms_/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/coldfront/core/user/forms_/link_login_forms.py b/coldfront/core/user/forms_/link_login_forms.py
new file mode 100644
index 000000000..b5367eb62
--- /dev/null
+++ b/coldfront/core/user/forms_/link_login_forms.py
@@ -0,0 +1,10 @@
+from django import forms
+
+
+class RequestLoginLinkForm(forms.Form):
+
+ email = forms.EmailField()
+
+ def clean_email(self):
+ email = self.cleaned_data['email']
+ return email.lower()
diff --git a/coldfront/core/user/management/commands/create_email_addresses.py b/coldfront/core/user/management/commands/create_email_addresses.py
index 357217f14..f5a71f065 100644
--- a/coldfront/core/user/management/commands/create_email_addresses.py
+++ b/coldfront/core/user/management/commands/create_email_addresses.py
@@ -1,5 +1,8 @@
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
+
+from allauth.account.models import EmailAddress
+
import logging
@@ -15,27 +18,9 @@ class Command(BaseCommand):
'one-time use for existing users loaded in from spreadsheets.')
logger = logging.getLogger(__name__)
- def add_arguments(self, parser):
- # TODO: Remove this once all emails are transitioned to
- # TODO: allauth.account.models.EmailAddress.
- parser.add_argument(
- 'module',
- choices=['allauth.account.models', 'coldfront.core.user.models'],
- help=(
- 'There are temporarily two EmailAddress models, until all can '
- 'be transitioned under allauth.account.models.'),
- type=str)
-
def handle(self, *args, **options):
"""For each User that has no EmailAddress, create a verified,
primary instance using the User's email field."""
- if options['module'] == 'allauth.account.models':
- from allauth.account.models import EmailAddress
- verified_field, primary_field = 'verified', 'primary'
- else:
- from coldfront.core.user.models import EmailAddress
- verified_field, primary_field = 'is_verified', 'is_primary'
-
user_pks_with_emails = EmailAddress.objects.values_list(
'user', flat=True)
users_without_emails = User.objects.exclude(
@@ -46,8 +31,8 @@ def handle(self, *args, **options):
kwargs = {
'user': user,
'email': email,
- verified_field: True,
- primary_field: True,
+ 'verified': True,
+ 'primary': True,
}
if not email:
message = f'User {user.pk} email is empty.'
diff --git a/coldfront/core/user/management/commands/lower_email_case.py b/coldfront/core/user/management/commands/lower_email_case.py
index c467a5291..521079712 100644
--- a/coldfront/core/user/management/commands/lower_email_case.py
+++ b/coldfront/core/user/management/commands/lower_email_case.py
@@ -1,6 +1,8 @@
-from coldfront.core.user.models import EmailAddress
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
+
+from allauth.account.models import EmailAddress
+
import logging
diff --git a/coldfront/core/user/management/commands/migrate_email_address_model.py b/coldfront/core/user/management/commands/migrate_email_address_model.py
new file mode 100644
index 000000000..3593dca8b
--- /dev/null
+++ b/coldfront/core/user/management/commands/migrate_email_address_model.py
@@ -0,0 +1,235 @@
+import logging
+import sys
+import traceback
+
+from django.contrib.auth.models import User
+from django.core.management.base import BaseCommand
+
+from allauth.account.models import EmailAddress as NewEmailAddress
+
+from coldfront.core.user.models import EmailAddress as OldEmailAddress
+from coldfront.core.utils.common import add_argparse_dry_run_argument
+
+
+"""An admin command that creates or updates corresponding instances of
+allauth.account.models.EmailAddress for existing emails in the User
+model and coldfront.core.user.models.EmailAddress model."""
+
+
+class Command(BaseCommand):
+
+ help = (
+ 'Create or update instances of allauth.account.models.EmailAddress '
+ 'based on emails stored in the User model and the deprecated '
+ 'coldfront.core.users.models.EmailAddress model. This command strictly '
+ 'updates whether an email is primary or verified from False to True, '
+ 'and never the other way around (i.e., it respects existing values in '
+ 'the new model). It is intended for a one-time migration, and may not '
+ 'be suited for general reuse.')
+
+ logger = logging.getLogger(__name__)
+
+ def add_arguments(self, parser):
+ add_argparse_dry_run_argument(parser)
+
+ def handle(self, *args, **options):
+ dry_run = options['dry_run']
+ if not dry_run:
+ user_confirmation = input(
+ 'Are you sure you wish to proceed? [Y/y/N/n]: ')
+ if user_confirmation.strip().lower() != 'y':
+ self.stdout.write(self.style.WARNING('Migration aborted.'))
+ sys.exit(0)
+ for user in User.objects.iterator():
+ try:
+ old_email_data = self._get_old_email_data(user)
+ self._process_emails_for_user(
+ user, old_email_data, dry_run=dry_run)
+ except Exception:
+ message = (
+ f'Failed to process User {user.pk}. Details:\n'
+ f'{traceback.format_exc()}')
+ self.stdout.write(self.style.ERROR(message))
+
+ def _create_address_for_user(self, email, user, primary=False,
+ verified=False, dry_run=True):
+ """Create an instance of the new EmailAddress model for the
+ given email (str) and User, setting its primary and verified
+ fields. Optionally display the update instead of performing it.
+
+ If the email already belongs to a different user, raise an
+ error.
+
+ This method makes the following assumptions:
+ - The User does not already have a corresponding instance.
+ - If primary is True, the User does not already have a
+ primary instance.
+
+ It should not be called if the assumptions are not true.
+ """
+ try:
+ other_user_address = NewEmailAddress.objects.get(email=email)
+ except NewEmailAddress.DoesNotExist:
+ if dry_run:
+ phrase = 'Would create'
+ pk = 'PK'
+ style = self.style.WARNING
+ else:
+ email_address = NewEmailAddress.objects.create(
+ user=user,
+ email=email,
+ primary=primary,
+ verified=verified)
+ phrase = 'Created'
+ pk = email_address.pk
+ style = self.style.SUCCESS
+
+ message = (
+ f'{phrase} EmailAddress {pk} for User {user.pk} '
+ f'({user.username}) with primary={primary} and '
+ f'verified={verified}.')
+ self.stdout.write(style(message))
+ if not dry_run:
+ self.logger.info(message)
+ else:
+ message = (
+ f'A different User {other_user_address.user.pk} already has '
+ f'email "{email}".')
+ self.stdout.write(self.style.WARNING(message))
+ if not dry_run:
+ self.logger.warning(message)
+
+ @staticmethod
+ def _get_old_email_data(user):
+ """Return a dict mapping emails (str) for the given User from
+ the old model and the User instance to a dict with keys
+ 'primary' and 'verified', denoting whether the address was
+ primary and verified.
+
+ The email stored in the User instance is interpreted as being
+ primary.
+
+ Raise an error if the User does not have exactly one primary
+ email under the old model.
+ """
+ old_addresses = OldEmailAddress.objects.filter(user=user)
+ old_emails = {}
+
+ for old_address in old_addresses:
+ email_str = old_address.email.strip().lower()
+ old_emails[email_str] = {
+ 'verified': old_address.is_verified,
+ 'primary': old_address.is_primary,
+ }
+ user_email_str = user.email.strip().lower()
+ if user_email_str:
+ if user_email_str in old_emails:
+ old_emails[user_email_str]['primary'] = True
+ else:
+ old_emails[user_email_str] = {
+ 'verified': False,
+ 'primary': True,
+ }
+
+ old_primaries = []
+ for email, attributes in old_emails.items():
+ if attributes['primary']:
+ old_primaries.append(email)
+
+ num_primaries = len(old_primaries)
+ if num_primaries == 0:
+ raise Exception(
+ f'Found no old primary emails for User {user.pk} '
+ f'({user.username}).')
+ elif num_primaries == 1:
+ pass
+ else:
+ raise Exception(
+ f'Found multiple old primary emails for User {user.pk} '
+ f'({user.username}): {", ".join(old_primaries)}.')
+
+ return old_emails
+
+ def _process_emails_for_user(self, user, old_email_data, dry_run=True):
+ """Given a User and a dict containing information about emails
+ in the old model, create or update emails in the new model.
+ Optionally display updates instead of performing them.
+
+ old_email_data is assumed to contain exactly one primary
+ address.
+ """
+ new_addresses = NewEmailAddress.objects.filter(user=user)
+ new_address_lower_strs = set(
+ [email.lower()
+ for email in new_addresses.values_list('email', flat=True)])
+
+ try:
+ new_primary = new_addresses.get(primary=True)
+ except NewEmailAddress.DoesNotExist:
+ new_primary = None
+ except NewEmailAddress.MultipleObjectsReturned as e:
+ raise e
+
+ for old_email, attributes in old_email_data.items():
+ # If this was a primary address, but the user already has a
+ # different primary, do not set this as primary.
+ if attributes['primary']:
+ if (new_primary is not None and
+ new_primary.email.lower() != old_email):
+ attributes['primary'] = False
+ if old_email in new_address_lower_strs:
+ email_address = new_addresses.get(email=old_email)
+ self._update_address_for_user(
+ email_address, user, primary=attributes['primary'],
+ verified=attributes['verified'], dry_run=dry_run)
+ else:
+ self._create_address_for_user(
+ old_email, user, primary=attributes['primary'],
+ verified=attributes['verified'], dry_run=dry_run)
+
+ def _update_address_for_user(self, email_address, user, primary=False,
+ verified=False, dry_run=True):
+ """Update the given EmailAddress instance (new model) for the
+ given User, setting its primary and verified fields.
+
+ It only sets fields if they would go from False to True.
+
+ This method makes the following assumptions:
+ - If primary is True, the User does not already have a
+ primary instance other than the given instance.
+
+ It should not be called if the assumptions are not true.
+ """
+ # Only update "primary" if it would go from False to True, not
+ # the other way around.
+ primary_updated = not email_address.primary and primary
+ if primary_updated:
+ email_address.primary = True
+ # Only update "verified" if it would go from False to True, not
+ # the other way around.
+ verified_updated = not email_address.verified and verified
+ if verified_updated:
+ email_address.verified = True
+
+ if primary_updated or verified_updated:
+ if dry_run:
+ phrase = 'Would update'
+ style = self.style.WARNING
+ else:
+ email_address.save()
+ phrase = 'Updated'
+ style = self.style.SUCCESS
+
+ updates = []
+ if primary_updated:
+ updates.append('set primary to True')
+ if verified_updated:
+ updates.append('set verified to True')
+
+ message = (
+ f'{phrase} EmailAddress {email_address.pk} '
+ f'({email_address.email}) for User {user.pk} '
+ f'({user.username}): {", ".join(updates)}.')
+ self.stdout.write(style(message))
+ if not dry_run:
+ self.logger.info(message)
diff --git a/coldfront/core/user/templates/user/request_login_link.html b/coldfront/core/user/templates/user/request_login_link.html
new file mode 100644
index 000000000..8cf24dd0c
--- /dev/null
+++ b/coldfront/core/user/templates/user/request_login_link.html
@@ -0,0 +1,41 @@
+{% extends "common/base.html" %}
+{% load static %}
+{% load common_tags %}
+{% load crispy_forms_tags %}
+
+{% block title %}
+Request Login Link
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ Request Login Link
+
+
+
+ If you are an existing user, but your institution is not listed in
+ CILogon, you may use this form to request a short-lived link to log in
+ to the portal.
+
+
+ New users must select an institution listed in CILogon.
+
+
+ Attention: For users who have logged into {{ PORTAL_NAME }} in the past,
+ password-based authentication is no longer in use. To connect to your
+ existing portal account, select the identity provider corresponding to the
+ email address associated with your portal account.
+
+
+ If you are unsure which provider to choose, or do not see the expected
+ user and project information upon logging in, please refer to the "Hints"
+ section below and our documentation before contacting us.
+
+ Authentication is managed by
+ CILogon. You will be
+ redirected there, where you will select a provider to authenticate
+ with (University of California, Berkeley for most users).
+
+
+ If you have a CalNet ID, please authenticate using the first option
+ above. UC Berkeley users who do not use their CalNet ID may
+ experience delays when requesting cluster access.
+
+
+ External collaborators should select their home institution as the
+ provider. If you are a new user and your institution is not listed,
+ select the Google provider.
+
+ {% flag_enabled 'LINK_LOGIN_ENABLED' as link_login_enabled %}
+ {% if link_login_enabled %}
+
+ If you are an existing external collaborator whose institution is
+ not listed, you may request a short-lived login link
+ here.
+
- External collaborators should select the provider of their home
- institution. If your provider is not listed, select the
- Google provider.
+ External collaborators should select their home institution as the
+ provider. If you are a new user and your institution is not listed,
+ select the Google provider.
+ {% flag_enabled 'LINK_LOGIN_ENABLED' as link_login_enabled %}
+ {% if link_login_enabled %}
+
+ If you are an existing external collaborator whose institution is
+ not listed, you may request a short-lived login link
+ here.
+
Below are other email addresses associated with your account. You may authenticate to this portal using any verified addresses.
-
You can verify an address by clicking the “Verify” button, which will send a verification email to the address. The address will remain unverified until you have clicked the link provided in that email.