Skip to content

Commit

Permalink
Add approval workflow for paid registrar requests (#3659)
Browse files Browse the repository at this point in the history
* Enable approve_pending_registrar to accept base_rate parameter

* Update "organization form" to "registrar form" for clarity

* would_be_org_admin -> registrar_user_candidate

* Overhaul paid registrar submission workflow and templates

* Update tests to match overhauled paid registrar workflow

* Fix missing user form data in template

* Display base rate input if not nonpaying registrar

* Build some logic for display of registrar user

* First pass on approval form updates

* Do form validation in ApproveRegistrarForm.clean_registrar_user

* Provide error message as feedback if registrar user email doesn't validate

* Display user search when a registrar user is not already assigned

* Fix ancient typo in class name on registrar management template

* Make registrar action button casing consistent with other interfaces

* Add pagination to user search

* Add required base rate parameter to approval tests

* Remove missing imports to appease flake8

* Remove forgotten debugging print

* Add clarifying comment on exclusion of sponsored users from user search

* registrar.pending_users.first -> registrar.pending_users.exists

Co-authored-by: Rebecca Lynn Cremona <[email protected]>

* Fix heading levels in user search output

* Relocate valid_*_sorts from user_management_views to common module

* Address UnorderedObjectListWarning by applying sort to user search results

* Use GET parameters for user search

* Fix bug wherein nonpaying registrar approval errors due to null base_rate

* Add sort dropdown to approval user list

* Reference form.cleaned_data for status field

* "library account" -> "registrar account" for general-purpose use

* library_approved.txt -> registrar_approved.txt

* Fix bug wherein requested_account_note was not populated for some firm sign-ups

---------

Co-authored-by: Rebecca Lynn Cremona <[email protected]>
  • Loading branch information
cmsetzer and rebeccacremona authored Nov 20, 2024
1 parent d7cda5e commit fc48ba8
Show file tree
Hide file tree
Showing 19 changed files with 528 additions and 220 deletions.
40 changes: 28 additions & 12 deletions perma_web/perma/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.conf import settings
from django.core.mail import EmailMessage
from django.http import HttpRequest
from django.template import Context, RequestContext, engines
from django.utils import timezone

Expand Down Expand Up @@ -101,19 +102,35 @@ def send_self_email(title, request, template="email/default.txt", context={}, de
return message.send(fail_silently=False)


def send_user_email_copy_admins(title, from_address, to_addresses, request, template="email/default.txt", context={}):
"""
Send a message on behalf of a user to another user, cc'ing
the sender and the Perma admins.
Use reply-to for the user address so we can use email services that require authenticated from addresses.
def send_user_email_copy_admins(
title: str,
from_address: str,
to_addresses: list[str],
request: HttpRequest,
template: str = 'email/default.txt',
context: dict | None = None,
):
"""Send a message to a user, CCing the sender and the Perma admins.
This can be used to send a message from one user to another while
copying the admins, or to send a message from Perma to a user while
CCing a copy to the admins.
Use reply_to for the user address so we can use email services that
require authenticated from addresses.
"""
# Handle cases where we want to email a user and CC ourselves
cc_addresses = [settings.DEFAULT_FROM_EMAIL]
if from_address != settings.DEFAULT_FROM_EMAIL:
cc_addresses.append(from_address)
context = context if context is not None else {}
message = EmailMessage(
title,
render_email(template, context, request),
settings.DEFAULT_FROM_EMAIL,
to_addresses,
cc=[settings.DEFAULT_FROM_EMAIL, from_address],
reply_to=[from_address]
subject=title,
body=render_email(template, context, request),
from_email=settings.DEFAULT_FROM_EMAIL,
to=to_addresses,
cc=cc_addresses,
reply_to=[from_address],
)
return message.send(fail_silently=False)

Expand Down Expand Up @@ -172,4 +189,3 @@ def registrar_users_plus_stats(registrars=None, year=None):
"most_active_org": registrar.most_active_org_in_time_period(start_time, end_time),
"registrar_users": registrar_users })
return users

59 changes: 51 additions & 8 deletions perma_web/perma/forms.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
from axes.utils import reset as reset_login_attempts
import string
import logging
import secrets
import string
from typing import Any, Mapping

from axes.utils import reset as reset_login_attempts
from django import forms
from django.conf import settings
from django.contrib.auth.forms import SetPasswordForm
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models.fields import BLANK_CHOICE_DASH
from django.forms import Form, ModelForm
from django.http import HttpRequest, HttpResponseRedirect
from django.urls import reverse
from django.utils.html import mark_safe

from perma.models import Registrar, Organization, LinkUser, Sponsorship, UserOrganizationAffiliation
from perma.models import LinkUser, Organization, Registrar, Sponsorship, UserOrganizationAffiliation
from perma.utils import get_client_ip

import logging
logger = logging.getLogger(__name__)

### HELPERS ###
Expand Down Expand Up @@ -96,7 +98,7 @@ def __init__(self, *args, **kwargs):

### FIRM (OTHER ORG) QUOTE FORMS ###

class FirmOrganizationForm(ModelForm):
class FirmRegistrarForm(ModelForm):
class Meta:
model = Registrar
fields = ['name', 'email', 'website']
Expand All @@ -107,6 +109,47 @@ class Meta:
}


class ApproveRegistrarForm(ModelForm):
registrar_user = forms.EmailField(required=False)

class Meta:
model = Registrar
fields = ['base_rate', 'status']

def __init__(self, data: Mapping[str, Any], registrar: Registrar, *args, **kwargs):
super().__init__(data, *args, **kwargs)

# Populate base rate default value from model
self.fields['base_rate'].initial = registrar.base_rate
self.fields['base_rate'].widget.attrs.setdefault('value', str(registrar.base_rate))

# Require base rate and status only if paid registrar has a registrar user
has_registrar_user = registrar.pending_users.exists() or registrar.users.exists()
is_paid_registrar = registrar.nonpaying is False
if has_registrar_user and is_paid_registrar:
self.fields['base_rate'].required = True
self.fields['status'].required = True
else:
self.fields['base_rate'].required = False
self.fields['status'].required = False

def clean_registrar_user(self) -> str | None:
"""Validate whether a LinkUser matching the supplied email exists."""
cleaned_value = self.cleaned_data['registrar_user'].lower()
if not cleaned_value:
return None

try:
LinkUser.objects.get(email=cleaned_value)
except ObjectDoesNotExist as error:
raise ValidationError(
'Email %(email)s does not match an existing user account',
params={'email': self.cleaned_data['registrar_user']},
) from error
else:
return cleaned_value


class FirmUsageForm(Form):
estimated_number_of_accounts = forms.ChoiceField(
choices=[(option, option) for option in ['1 - 10', '10 - 50', '50 - 100', '100+']],
Expand Down Expand Up @@ -310,21 +353,21 @@ class CreateUserFormWithFirm(UserForm):
add firm to the create user form
"""

would_be_org_admin = forms.ChoiceField(
registrar_user_candidate = forms.ChoiceField(
widget=forms.Select, choices=[(True, 'Yes'), (False, 'No')], initial=(False, 'No')
)

class Meta:
model = LinkUser
fields = ['first_name', 'last_name', 'email', 'would_be_org_admin']
fields = ['first_name', 'last_name', 'email', 'registrar_user_candidate']

def __init__(self, *args, **kwargs):
super(CreateUserFormWithFirm, self).__init__(*args, **kwargs)

self.fields['first_name'].label = 'Your first name'
self.fields['last_name'].label = 'Your last name'
self.fields['email'].label = 'Your email'
self.fields['would_be_org_admin'].label = 'Would you be an administrator on this account?'
self.fields['registrar_user_candidate'].label = 'Would you be an administrator on this account?'

# Populate and set visibility of fields based on whether user is logged in
if hasattr(self, 'request') and self.user_is_logged_in(self.request):
Expand Down
16 changes: 10 additions & 6 deletions perma_web/perma/templates/email/admin/firm_request.txt
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
{{ user_form.data.email }}{% if user_form.data.first_name or user_form.data.last_name %} ({{ user_form.data.first_name }} {{ user_form.data.last_name }}){% endif %} has requested more information about creating an account for a law firm or other organization:
A new account request from a law firm or other organization has been submitted, and is now awaiting review.

Organization name: {{ organization_form.cleaned_data.name }}
Organization email: {{ organization_form.cleaned_data.email }}
Organization website: {{ organization_form.cleaned_data.website }}
Registrar name: {{ registrar.name }}
Registrar email: {{ registrar.email }}
Registrar website: {{ registrar.website }}

Estimated number of individual accounts: {{ usage_form.cleaned_data.estimated_number_of_accounts }}
Estimated number of Perma Links created each month (per user): {{ usage_form.cleaned_data.estimated_perma_links_per_month }}

User would be an administrator on the account: {{ user_form.data.would_be_org_admin | yesno }}
User name: {{ user_name }}
User email: {{ user_email }}
User would be an administrator on the account: {{ user_form.data.registrar_user_candidate | yesno }}

This user currently {% if existing_user %}has{% else %}does not have{% endif %} an account.
This user {% if existing_user %}has{% else %}does not have{% endif %} an account.

https://{{ host }}{{ confirmation_route }}
5 changes: 3 additions & 2 deletions perma_web/perma/templates/email/admin/registrar_request.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
A new library account request from pending registrar, named {{ name }} (at {{ email }}) is awaiting review and approval.
Requesting user's email: {{ requested_by_email }}
A new account request from a pending registrar, named {{ name }} ({{ email }}), is awaiting review and approval.

Requesting user's email: {{ requested_by_email }}

http://{{ host }}{{ confirmation_route}}
7 changes: 0 additions & 7 deletions perma_web/perma/templates/email/library_approved.txt

This file was deleted.

2 changes: 1 addition & 1 deletion perma_web/perma/templates/email/pending_registrar.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
TITLE: A Perma.cc account has been created for you

We will review your library account request as soon as possible. A personal account has been created for you and will be linked to your library once that account is approved.
We will review your organization account request as soon as possible. A personal account has been created for you and will be linked to your organization once that account is approved.

To activate this personal account, please click the link below or copy it to your web browser. You will need to create a password.

Expand Down
7 changes: 7 additions & 0 deletions perma_web/perma/templates/email/registrar_approved.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
TITLE: Your Perma.cc registrar account is approved

Your request for a Perma.cc registrar account has been approved and your personal account has been linked.

To start creating organizations and users, please click the link below or copy it to your web browser.

http://{{ host }}{{ account_route }}
2 changes: 1 addition & 1 deletion perma_web/perma/templates/registration/sign-up-firms.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ <h2 class="body-ah">Request team subscription quote</h2>
<form method="post" action="">
{% csrf_token %}
<h3 class="body-bh">Your team information</h3>
{% include "includes/fieldset.html" with form=organization_form %}
{% include "includes/fieldset.html" with form=registrar_form %}
<h3 class="body-bh">Estimated usage information</h3>
<p>Quotes are based on the expected volume of use by your whole team.</p>
{% include "includes/fieldset.html" with form=usage_form %}
Expand Down
143 changes: 127 additions & 16 deletions perma_web/perma/templates/user_management/approve_pending_registrar.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{% extends "manage-layout.html" %}
{% load current_query_string %}

{% block title %} | Approve pending registrar{% endblock %}

{% block dashboardContent %}
<h2 class="body-ah">Registrars</h2>

{% if target_registrar.status == "approved" %}
<h3 class="body-bh">Already approved!</h3>
Expand All @@ -15,28 +18,136 @@ <h3 class="body-bh">Approve this registrar?</h3>
{% endif %}

<h4 class="body-ch">Registrar information</h4>
<ul class="data-list">
<li>Name: {{target_registrar.name}}</li>
<li>Email: {{target_registrar.email}}</li>
<li>Website: <a href="{{target_registrar.website}}" target="_blank">{{target_registrar.website}}</a></li>
<p class="body-text">
<strong>Name:</strong> {{ target_registrar.name }}<br />
<strong>Email:</strong> {{ target_registrar.email }}<br />
<strong>Website:</strong> <a href="{{ target_registrar.website }}" target="_blank">{{ target_registrar.website }}</a><br />
{% if target_registrar.address %}
<li>Address: {{ target_registrar.address }}</li>
Address: {{ target_registrar.address }}<br />
{% endif %}
</ul>
</p>

<h4 class="body-ch">Requested by</h4>
<ul class="data-list">
{% if target_registrar_user.first_name and target_registrar_user.last_name %}
<li>{{target_registrar_user.first_name}} {{target_registrar_user.last_name}}</li>
{% endif %}
<li>{{target_registrar_user.raw_email}}</li>
</ul>
<form id="search_form" action="" method="GET"></form>

<form action="" method="POST">
<form id="form" action="" method="POST">
{% csrf_token %}

<h4 class="body-ch">Registrar user</h4>

{% if target_registrar_user %}

<p class="body-text">
{% if target_registrar_user.first_name or target_registrar_user.last_name %}
<strong>Name:</strong> {{ target_registrar_user.first_name }} {{ target_registrar_user.last_name }}<br />
{% endif %}
<strong>Email:</strong> {{ target_registrar_user.email }}<br />
</p>

{% else %}

<p class="body-text">Before this registrar can be approved, you must assign a registrar user:</p>
{% include "user_management/includes/search_form.html" with search_placeholder="Search Users" search_query=search_query form_id="search_form" %}

{% if users.paginator.count %}
<div class="row row-no-bleed">
<div class="col admin-found col-no-gutter">
<h5 class="sr-only">User List</h3>
<div id="results" class="sort-filter-count"><strong>Found:</strong> {{ users.paginator.count }} user{{ users.paginator.count|pluralize }}</div>
<div class="sort-filter-bar">
<strong>Filter &amp; Sort:</strong>
<div class="dropdown">
<button class="btn-transparent" aria-haspopup="true" aria-expanded="false" data-toggle="dropdown">Sort <span class="caret"></span></button>
<ul class="dropdown-menu">
<li>
<a {% if sort == 'last_name' %}class="selected" aria-current="true" {% endif %}href="?{% current_query_string page='' sort="last_name" %}"><i aria-hidden="true" class="icon-ok"></i> Last name A - Z</a>
</li>
<li>
<a {% if sort == '-last_name' %}class="selected" aria-current="true" {% endif %} href="?{% current_query_string page='' sort="-last_name" %}"><i aria-hidden="true" class="icon-ok"></i> Last name Z - A</a>
</li>
<li>
<a {% if sort == '-date_joined' %}class="selected" aria-current="true" {% endif %} href="?{% current_query_string page='' sort="-date_joined" %}"><i aria-hidden="true" class="icon-ok"></i> Newest</a>
</li>
<li>
<a {% if sort == 'date_joined' %}class="selected" aria-current="true" {% endif %} href="?{% current_query_string page='' sort="date_joined" %}"><i aria-hidden="true" class="icon-ok"></i> Oldest</a>
</li>
<li>
<a {% if sort == '-last_login' %}class="selected" aria-current="true" {% endif %} href="?{% current_query_string page='' sort="-last_login" %}"><i aria-hidden="true" class="icon-ok"></i> Recently active</a>
</li>
<li>
<a {% if sort == 'last_login' %}class="selected" aria-current="true" {% endif %} href="?{% current_query_string page='' sort="last_login" %}"><i aria-hidden="true" class="icon-ok"></i> Least recently active</a>
</li>
<li>
<a {% if sort == '-link_count' %}class="selected" aria-current="true" {% endif %} href="?{% current_query_string page='' sort="-link_count" %}"><i aria-hidden="true" class="icon-ok"></i> Most links</a>
</li>
<li>
<a {% if sort == 'link_count' %}class="selected" aria-current="true" {% endif %} href="?{% current_query_string page='' sort="link_count" %}"><i aria-hidden="true" class="icon-ok"></i> Least links</a>
</li>
</ul>
</div>
</div>
</div><!-- admin found -->
</div><!-- row -->

{% csrf_token %}
<ol class="result-list">
{% for listed_user in users %}
<li class="item-container {% if not listed_user.is_active %}muted{% endif %}">
<div class="col col-sm-8 col-no-gutter">
<h6 class="item-title" id="user-{{ listed_user.id }}">
{% if not listed_user.first_name and not listed_user.last_name %}
{{ listed_user.raw_email }}
{% else %}
{{ listed_user.first_name }} {{ listed_user.last_name }}
{% endif %}
</h6>
<div class="item-subtitle">{{ listed_user.raw_email }}</div>

{% if request.user.is_staff and listed_user.requested_account_type %}
<div class="item-org">Interested in a {{listed_user.requested_account_type}} account with {{listed_user.requested_account_note}}</div>
{% endif %}
</div>

<div class="col col-sm-4 col-no-gutter sm-align-right admin-actions">
<div>
<div class="item-activity">
created {{ listed_user.date_joined|date:'F j, Y' }}
{% if listed_user.is_confirmed %}
<br>
last active {{ listed_user.last_login|date:'F j, Y' }}
{% endif %}
</div>
</div>
{% if request.user.is_staff %}
<div>
<button type="submit" name="registrar_user" value="{{ listed_user.email }}" class="btn btn-small pull-right" form="form">Select<span class="sr-only"> {{ listed_user.get_full_name }}</button>
</div>
{% endif %}
</div>
</li>
{% endfor %}
</ol>

{% elif users.paginator.count == 0 %}

<div class="item-notification">No user{{ users.paginator.count|pluralize }} found.</div>

{% endif %}

{% include "user_management/includes/paginator.html" with page=users title='User List' %}

{% endif %}

{% if target_registrar_user and not target_registrar.nonpaying %}
<h4 class="body-ch">Base rate</h4>
<label for="id_base_rate" class="body-text">Enter the registrar's base rate in dollars:</label>
{{ approve_registrar_form.base_rate }}
{% endif %}

<a class="btn cancel" href="../">Cancel</a>
<button type="submit" name="status" value="approved" class="btn delete-confirm">Approve</button>
<button type="submit" name="status" value="denied" class="btn delete-confirm">Deny</button>
{% if target_registrar_user %}
<button type="submit" name="status" value="approved" class="btn">Approve</button>
<button type="submit" name="status" value="denied" class="btn delete-confirm">Deny</button>
{% endif %}
</form>
{% endif %}

Expand Down
Loading

0 comments on commit fc48ba8

Please sign in to comment.