Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add approval workflow for paid registrar requests #3659

Merged
merged 31 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
58f27ac
Enable approve_pending_registrar to accept base_rate parameter
cmsetzer Oct 24, 2024
e672880
Update "organization form" to "registrar form" for clarity
cmsetzer Oct 24, 2024
eab954f
would_be_org_admin -> registrar_user_candidate
cmsetzer Oct 24, 2024
ac33477
Overhaul paid registrar submission workflow and templates
cmsetzer Oct 24, 2024
0e572b8
Update tests to match overhauled paid registrar workflow
cmsetzer Oct 25, 2024
fe5d0d9
Fix missing user form data in template
cmsetzer Oct 25, 2024
2f33fa2
Display base rate input if not nonpaying registrar
cmsetzer Oct 27, 2024
986ac5b
Build some logic for display of registrar user
cmsetzer Oct 27, 2024
2964cf4
First pass on approval form updates
cmsetzer Nov 2, 2024
365e0bd
Do form validation in ApproveRegistrarForm.clean_registrar_user
cmsetzer Nov 4, 2024
5e3591c
Provide error message as feedback if registrar user email doesn't val…
cmsetzer Nov 4, 2024
921da1e
Display user search when a registrar user is not already assigned
cmsetzer Nov 12, 2024
697dc43
Fix ancient typo in class name on registrar management template
cmsetzer Nov 18, 2024
cbb80b3
Make registrar action button casing consistent with other interfaces
cmsetzer Nov 18, 2024
3392abd
Add pagination to user search
cmsetzer Nov 18, 2024
df4d4a6
Add required base rate parameter to approval tests
cmsetzer Nov 18, 2024
ae937e5
Merge branch 'develop' into other-org-approval-workflow
cmsetzer Nov 18, 2024
7953c4e
Remove missing imports to appease flake8
cmsetzer Nov 18, 2024
33b084d
Remove forgotten debugging print
cmsetzer Nov 19, 2024
c6768b3
Add clarifying comment on exclusion of sponsored users from user search
cmsetzer Nov 19, 2024
964172d
registrar.pending_users.first -> registrar.pending_users.exists
cmsetzer Nov 19, 2024
6d30b0c
Fix heading levels in user search output
cmsetzer Nov 20, 2024
d2fd047
Relocate valid_*_sorts from user_management_views to common module
cmsetzer Nov 20, 2024
e442029
Address UnorderedObjectListWarning by applying sort to user search re…
cmsetzer Nov 20, 2024
ae1a25d
Use GET parameters for user search
cmsetzer Nov 20, 2024
bd07527
Fix bug wherein nonpaying registrar approval errors due to null base_…
cmsetzer Nov 20, 2024
01caab2
Add sort dropdown to approval user list
cmsetzer Nov 20, 2024
7e7d109
Reference form.cleaned_data for status field
cmsetzer Nov 20, 2024
f0eb7c1
"library account" -> "registrar account" for general-purpose use
cmsetzer Nov 20, 2024
c1ca1e4
library_approved.txt -> registrar_approved.txt
cmsetzer Nov 20, 2024
8567f78
Fix bug wherein requested_account_note was not populated for some fir…
cmsetzer Nov 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
cmsetzer marked this conversation as resolved.
Show resolved Hide resolved
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')
cmsetzer marked this conversation as resolved.
Show resolved Hide resolved
)

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
Loading