Merge branch 'main' of https://github.com/cisagov/getgov into rh/446-remove-type-of-work-q

This commit is contained in:
Rebecca Hsieh 2023-09-12 17:11:38 -07:00
commit 02220feb7a
No known key found for this signature in database
GPG key ID: 644527A2F375A379
26 changed files with 862 additions and 71 deletions

View file

@ -6,10 +6,36 @@ from django.http.response import HttpResponseRedirect
from django.urls import reverse
from registrar.models.utility.admin_sort_fields import AdminSortFields
from . import models
from auditlog.models import LogEntry # type: ignore
from auditlog.admin import LogEntryAdmin # type: ignore
logger = logging.getLogger(__name__)
class CustomLogEntryAdmin(LogEntryAdmin):
"""Overwrite the generated LogEntry admin class"""
list_display = [
"created",
"resource",
"action",
"msg_short",
"user_url",
]
# We name the custom prop 'resource' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
def resource(self, obj):
# Return the field value without a link
return f"{obj.content_type} - {obj.object_repr}"
search_help_text = "Search by resource, changes, or user."
change_form_template = "admin/change_form_no_submit.html"
add_form_template = "admin/change_form_no_submit.html"
class AuditedAdmin(admin.ModelAdmin, AdminSortFields):
"""Custom admin to make auditing easier."""
@ -91,14 +117,12 @@ class ListHeaderAdmin(AuditedAdmin):
class UserContactInline(admin.StackedInline):
"""Edit a user's profile on the user page."""
model = models.Contact
class MyUserAdmin(BaseUserAdmin):
"""Custom user admin class to use our inlines."""
inlines = [UserContactInline]
@ -152,33 +176,62 @@ class MyUserAdmin(BaseUserAdmin):
class HostIPInline(admin.StackedInline):
"""Edit an ip address on the host page."""
model = models.HostIP
class MyHostAdmin(AuditedAdmin):
"""Custom host admin class to use our inlines."""
inlines = [HostIPInline]
class DomainAdmin(ListHeaderAdmin):
"""Custom domain admin class to add extra buttons."""
# Columns
list_display = [
"name",
"organization_type",
"state",
]
def organization_type(self, obj):
return obj.domain_info.organization_type
organization_type.admin_order_field = ( # type: ignore
"domain_info__organization_type"
)
# Filters
list_filter = ["domain_info__organization_type"]
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
readonly_fields = ["state"]
def response_change(self, request, obj):
ACTION_BUTTON = "_place_client_hold"
if ACTION_BUTTON in request.POST:
# Create dictionary of action functions
ACTION_FUNCTIONS = {
"_place_client_hold": self.do_place_client_hold,
"_remove_client_hold": self.do_remove_client_hold,
"_edit_domain": self.do_edit_domain,
}
# Check which action button was pressed and call the corresponding function
for action, function in ACTION_FUNCTIONS.items():
if action in request.POST:
return function(request, obj)
# If no matching action button is found, return the super method
return super().response_change(request, obj)
def do_place_client_hold(self, request, obj):
try:
obj.place_client_hold()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
@ -192,7 +245,42 @@ class DomainAdmin(ListHeaderAdmin):
)
return HttpResponseRedirect(".")
return super().response_change(request, obj)
def do_remove_client_hold(self, request, obj):
try:
obj.remove_client_hold()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
("%s is ready. This domain is accessible on the public internet.")
% obj.name,
)
return HttpResponseRedirect(".")
def do_edit_domain(self, request, obj):
# We want to know, globally, when an edit action occurs
request.session["analyst_action"] = "edit"
# Restricts this action to this domain (pk) only
request.session["analyst_action_location"] = obj.id
return HttpResponseRedirect(reverse("domain", args=(obj.id,)))
def change_view(self, request, object_id):
# If the analyst was recently editing a domain page,
# delete any associated session values
if "analyst_action" in request.session:
del request.session["analyst_action"]
del request.session["analyst_action_location"]
return super().change_view(request, object_id)
def has_change_permission(self, request, obj=None):
# Fixes a bug wherein users which are only is_staff
# can access 'change' when GET,
# but cannot access this page when it is a request of type POST.
if request.user.is_staff:
return True
return super().has_change_permission(request, obj)
class ContactAdmin(ListHeaderAdmin):
@ -200,11 +288,100 @@ class ContactAdmin(ListHeaderAdmin):
search_fields = ["email", "first_name", "last_name"]
search_help_text = "Search by firstname, lastname or email."
list_display = [
"contact",
"email",
]
# We name the custom prop 'contact' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
def contact(self, obj: models.Contact):
"""Duplicate the contact _str_"""
if obj.first_name or obj.last_name:
return obj.get_formatted_name()
elif obj.email:
return obj.email
elif obj.pk:
return str(obj.pk)
else:
return ""
contact.admin_order_field = "first_name" # type: ignore
class WebsiteAdmin(ListHeaderAdmin):
"""Custom website admin class."""
# Search
search_fields = [
"website",
]
search_help_text = "Search by website."
class UserDomainRoleAdmin(ListHeaderAdmin):
"""Custom domain role admin class."""
# Columns
list_display = [
"user",
"domain",
"role",
]
# Search
search_fields = [
"user__first_name",
"user__last_name",
"domain__name",
"role",
]
search_help_text = "Search by user, domain, or role."
class DomainInvitationAdmin(ListHeaderAdmin):
"""Custom domain invitation admin class."""
# Columns
list_display = [
"email",
"domain",
"status",
]
# Search
search_fields = [
"email",
"domain__name",
]
search_help_text = "Search by email or domain."
class DomainInformationAdmin(ListHeaderAdmin):
"""Customize domain information admin class."""
# Columns
list_display = [
"domain",
"organization_type",
"created_at",
"submitter",
]
# Filters
list_filter = ["organization_type"]
# Search
search_fields = [
"domain__name",
]
search_help_text = "Search by domain."
class DomainApplicationAdmin(ListHeaderAdmin):
"""Customize the applications listing view."""
"""Custom domain applications admin class."""
# Set multi-selects 'read-only' (hide selects and show data)
# based on user perms and application creator's status
@ -376,13 +553,16 @@ class DomainApplicationAdmin(ListHeaderAdmin):
return super().change_view(request, object_id, form_url, extra_context)
admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin)
admin.site.register(models.User, MyUserAdmin)
admin.site.register(models.UserDomainRole, AuditedAdmin)
admin.site.register(models.UserDomainRole, UserDomainRoleAdmin)
admin.site.register(models.Contact, ContactAdmin)
admin.site.register(models.DomainInvitation, AuditedAdmin)
admin.site.register(models.DomainInformation, AuditedAdmin)
admin.site.register(models.DomainInvitation, DomainInvitationAdmin)
admin.site.register(models.DomainInformation, DomainInformationAdmin)
admin.site.register(models.Domain, DomainAdmin)
admin.site.register(models.Host, MyHostAdmin)
admin.site.register(models.Nameserver, MyHostAdmin)
admin.site.register(models.Website, AuditedAdmin)
admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.DomainApplication, DomainApplicationAdmin)
admin.site.register(models.TransitionDomain, AuditedAdmin)

View file

@ -0,0 +1,50 @@
/**
* @file get-gov-admin.js includes custom code for the .gov registrar admin portal.
*
* Constants and helper functions are at the top.
* Event handlers are in the middle.
* Initialization (run-on-load) stuff goes at the bottom.
*/
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Helper functions.
/** Either sets attribute target="_blank" to a given element, or removes it */
function openInNewTab(el, removeAttribute = false){
if(removeAttribute){
el.setAttribute("target", "_blank");
}else{
el.removeAttribute("target", "_blank");
}
};
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Event handlers.
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Initialization code.
/** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
* Currently only appends target="_blank" to the domain_form object,
* but this can be expanded.
*/
(function (){
/*
On mouseover, appends target="_blank" on domain_form under the Domain page.
The reason for this is that the template has a form that contains multiple buttons.
The structure of that template complicates seperating those buttons
out of the form (while maintaining the same position on the page).
However, if we want to open one of those submit actions to a new tab -
such as the manage domain button - we need to dynamically append target.
As there is no built-in django method which handles this, we do it here.
*/
function prepareDjangoAdmin() {
let domainFormElement = document.getElementById("domain_form");
let domainSubmitButton = document.getElementById("manageDomainSubmitButton");
if(domainSubmitButton && domainFormElement){
domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true));
domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
}
}
prepareDjangoAdmin();
})();

View file

@ -31,7 +31,7 @@ html[data-theme="light"] {
// #{$theme-link-color} would interpolate to 'primary', so we use the source value instead
--link-fg: #{$theme-color-primary};
--link-hover-color: #{$theme-color-primary-darker};
--link-hover-color: #{$theme-color-primary};
// $theme-link-visited-color - violet-70v
--link-selected-fg: #54278f;
@ -140,11 +140,6 @@ h1, h2, h3 {
font-weight: font-weight('bold');
}
table > caption > a {
font-weight: font-weight('bold');
text-transform: none;
}
.change-list {
.usa-table--striped tbody tr:nth-child(odd) td,
.usa-table--striped tbody tr:nth-child(odd) th,
@ -158,9 +153,12 @@ table > caption > a {
padding-top: 20px;
}
// 'Delete button' layout bug
.submit-row a.deletelink {
// Fix django admin button height bugs
.submit-row a.deletelink,
.delete-confirmation form .cancel-link,
.submit-row a.closelink {
height: auto!important;
font-size: 14px;
}
// Keep th from collapsing
@ -170,3 +168,15 @@ table > caption > a {
.min-width-81 {
min-width: 81px;
}
.primary-th {
padding-top: 8px;
padding-bottom: 8px;
font-size: 0.75rem;
letter-spacing: 0.5px;
text-transform: none;
font-weight: font-weight('bold');
text-align: left;
background: var(--primary);
color: var(--header-link-color);
}

View file

@ -432,3 +432,21 @@ abbr[title] {
height: units('mobile');
}
}
// Fixes some font size disparities with the Figma
// for usa-alert alert elements
.usa-alert {
.usa-alert__heading.larger-font-sizing {
font-size: units(3);
}
}
// The icon was off center for some reason
// Fixes that issue
@media (min-width: 64em){
.usa-alert--warning{
.usa-alert__body::before {
left: 1rem !important;
}
}
}

View file

@ -63,7 +63,7 @@ class UserFixture:
"last_name": "Adkinson",
},
{
"username": "bb21f687-c773-4df3-9243-111cfd4c0be4",
"username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484",
"first_name": "Paul",
"last_name": "Kuykendall",
},
@ -77,6 +77,11 @@ class UserFixture:
"first_name": "David",
"last_name": "Kennedy",
},
{
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
"first_name": "Nicolle",
"last_name": "LeClair",
},
]
STAFF = [
@ -123,6 +128,12 @@ class UserFixture:
"last_name": "DiSarli-Analyst",
"email": "gaby@truss.works",
},
{
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
"first_name": "Nicolle-Analyst",
"last_name": "LeClair-Analyst",
"email": "nicolle.leclair@ecstech.com",
},
]
STAFF_PERMISSIONS = [

View file

@ -0,0 +1,30 @@
# Generated by Django 4.2.1 on 2023-09-07 17:53
from django.db import migrations
import django_fsm
class Migration(migrations.Migration):
dependencies = [
("registrar", "0030_alter_user_status"),
]
operations = [
migrations.AlterField(
model_name="domain",
name="state",
field=django_fsm.FSMField(
choices=[
("created", "Created"),
("deleted", "Deleted"),
("unknown", "Unknown"),
("ready", "Ready"),
("onhold", "Onhold"),
],
default="unknown",
help_text="Very basic info about the lifecycle of this domain object",
max_length=21,
protected=True,
),
),
]

View file

@ -0,0 +1,60 @@
# Generated by Django 4.2.1 on 2023-09-11 14:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0030_alter_user_status"),
]
operations = [
migrations.CreateModel(
name="TransitionDomain",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"username",
models.TextField(
help_text="Username - this will be an email address",
verbose_name="Username",
),
),
(
"domain_name",
models.TextField(blank=True, null=True, verbose_name="Domain name"),
),
(
"status",
models.CharField(
blank=True,
choices=[("created", "Created"), ("hold", "Hold")],
help_text="domain status during the transfer",
max_length=255,
verbose_name="Status",
),
),
(
"email_sent",
models.BooleanField(
default=False,
help_text="indicates whether email was sent",
verbose_name="email sent",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -0,0 +1,12 @@
# Generated by Django 4.2.1 on 2023-09-12 14:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("registrar", "0031_alter_domain_state"),
("registrar", "0031_transitiondomain"),
]
operations = []

View file

@ -13,6 +13,7 @@ from .user_domain_role import UserDomainRole
from .public_contact import PublicContact
from .user import User
from .website import Website
from .transition_domain import TransitionDomain
__all__ = [
"Contact",
@ -28,6 +29,7 @@ __all__ = [
"PublicContact",
"User",
"Website",
"TransitionDomain",
]
auditlog.register(Contact)
@ -42,3 +44,4 @@ auditlog.register(UserDomainRole)
auditlog.register(PublicContact)
auditlog.register(User)
auditlog.register(Website)
auditlog.register(TransitionDomain)

View file

@ -2,7 +2,7 @@ import logging
from datetime import date
from string import digits
from django_fsm import FSMField # type: ignore
from django_fsm import FSMField, transition # type: ignore
from django.db import models
@ -114,6 +114,12 @@ class Domain(TimeStampedModel, DomainHelper):
# the state is indeterminate
UNKNOWN = "unknown"
# the ready state for a domain object
READY = "ready"
# when a domain is on hold
ONHOLD = "onhold"
class Cache(property):
"""
Python descriptor to turn class methods into properties.
@ -311,13 +317,17 @@ class Domain(TimeStampedModel, DomainHelper):
"""Time to renew. Not implemented."""
raise NotImplementedError()
@transition(field="state", source=[State.READY], target=State.ONHOLD)
def place_client_hold(self):
"""This domain should not be active."""
raise NotImplementedError("This is not implemented yet.")
# This method is changing the state of the domain in registrar
# TODO: implement EPP call
@transition(field="state", source=[State.ONHOLD], target=State.READY)
def remove_client_hold(self):
"""This domain is okay to be active."""
raise NotImplementedError()
# This method is changing the state of the domain in registrar
# TODO: implement EPP call
def __str__(self) -> str:
return self.name

View file

@ -0,0 +1,42 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
class TransitionDomain(TimeStampedModel):
"""Transition Domain model stores information about the
state of a domain upon transition between registry
providers"""
class StatusChoices(models.TextChoices):
CREATED = "created", "Created"
HOLD = "hold", "Hold"
username = models.TextField(
null=False,
blank=False,
verbose_name="Username",
help_text="Username - this will be an email address",
)
domain_name = models.TextField(
null=True,
blank=True,
verbose_name="Domain name",
)
status = models.CharField(
max_length=255,
null=False,
blank=True,
choices=StatusChoices.choices,
verbose_name="Status",
help_text="domain status during the transfer",
)
email_sent = models.BooleanField(
null=False,
default=False,
verbose_name="email sent",
help_text="indicates whether email was sent",
)
def __str__(self):
return self.username

View file

@ -4,12 +4,20 @@
{% for app in app_list %}
<div class="app-{{ app.app_label }} module{% if app.app_url in request.path|urlencode %} current-app{% endif %}">
<table>
<caption>
<a href="{{ app.app_url }}" class="section" title="{% blocktranslate with name=app.name %}Models in the {{ name }} application{% endblocktranslate %}">{{ app.name }}</a>
</caption>
{# .gov override #}
{# .gov override: add headers #}
<thead>
<tr>
{% if show_changelinks %}
<th colspan="3" class="primary-th">
{{ app.name }}
</th>
{% else %}
<th colspan="2" class="primary-th">
{{ app.name }}
</th>
{% endif %}
</tr>
<tr>
<th scope="col">Model</th>
<th><span class="display-inline-block min-width-25">Add</span></th>

View file

@ -2,6 +2,24 @@
{% load static %}
{% load i18n %}
{% block extrahead %}
<link rel="icon" type="image/png" sizes="32x32"
href="{% static 'img/registrar/favicons/favicon-32.png' %}"
>
<link rel="icon" type="image/png" sizes="192x192"
href="{% static 'img/registrar/favicons/favicon-192.png' %}"
>
<link rel="icon" type="image/svg+xml"
href="{% static 'img/registrar/favicons/favicon.svg' %}"
>
<link rel="shortcut icon" type="image/x-icon"
href="{% static 'img/registrar/favicons/favicon.ico' %}"
>
<link rel="apple-touch-icon" size="180x180"
href="{% static 'img/registrar/favicons/favicon-180.png' %}"
>
{% endblock %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block extrastyle %}{{ block.super }}

View file

@ -0,0 +1,20 @@
{% extends "admin/change_form.html" %}
{% comment %} Replace the Django ul markup with a div. We'll edit the child markup accordingly in change_form_object_tools {% endcomment %}
{% block object-tools %}
{% if change and not is_popup %}
<div class="object-tools">
{% block object-tools-items %}
{{ block.super }}
{% endblock %}
</div>
{% endif %}
{% endblock %}
{% block submit_buttons_top %}
{# Do not render the submit buttons #}
{% endblock %}
{% block submit_buttons_bottom %}
{# Do not render the submit buttons #}
{% endblock %}

View file

@ -60,12 +60,11 @@ Load our custom filters to extract info from the django generated markup.
<tr><td colspan="{{ result|length }}">{{ result.form.non_field_errors }}</td></tr>
{% endif %}
<tr>
{% with result_value=result.0|extract_value %}
{% with result_label=result.1|extract_a_text %}
<td>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_label|default:result_value }}" class="action-select">
<label class="usa-sr-only" for="{{ result_label|default:result_value }}">{{ result_label|default:'label' }}</label>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}" class="action-select">
<label class="usa-sr-only" for="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}">{{ result_label|default:'label' }}</label>
</td>
{% endwith %}
{% endwith %}

View file

@ -6,6 +6,15 @@
{% block content %}
<main id="main-content" class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<a href="{% url 'home' %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Back to manage your domains
</p>
</a>
<h1>Domain request for {{ domainapplication.requested_domain.name }}</h1>
<div
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2"

View file

@ -1,8 +1,19 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block extrahead %}
{{ block.super }}
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
{% endblock %}
{% block field_sets %}
<div class="submit-row">
{% if original.state == original.State.READY %}
<input type="submit" value="Place hold" name="_place_client_hold">
{% elif original.state == original.State.ONHOLD %}
<input type="submit" value="Remove hold" name="_remove_client_hold">
{% endif %}
<input id="manageDomainSubmitButton" type="submit" value="Manage Domain" name="_edit_domain">
</div>
{{ block.super }}
{% endblock %}

View file

@ -10,8 +10,7 @@
<h1>Authorizing official</h1>
<p>Your authorizing official is the person within your organization who can
authorize domain requests. This is generally the highest-ranking or
highest-elected official in your organization. Read more about <a class="usa-link" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-an-authorizing-official-within-your-organization' %}">who can serve as an authorizing official</a>.</p>
authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about <a class="usa-link" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-an-authorizing-official-within-your-organization' %}">who can serve as an authorizing official</a>.</p>
{% include "includes/required_fields.html" %}

View file

@ -6,11 +6,13 @@
{% block content %}
<div class="grid-container">
<div class="grid-row">
{% if not is_analyst_or_superuser or not analyst_action or analyst_action_location != domain.pk %}
<p class="font-body-md margin-top-0 margin-bottom-2
text-primary-darker text-semibold"
>
<span class="usa-sr-only"> Domain name:</span> {{ domain.name }}
</p>
{% endif %}
</div>
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
@ -20,15 +22,26 @@
<div class="tablet:grid-col-9">
<main id="main-content" class="grid-container">
{% if is_analyst_or_superuser and analyst_action == 'edit' and analyst_action_location == domain.pk %}
<div class="usa-alert usa-alert--warning margin-bottom-2">
<div class="usa-alert__body">
<h4 class="usa-alert__heading larger-font-sizing">Attention!</h4>
<p class="usa-alert__text ">
You are making changes to a registrants domain. When finished making changes, close this tab and inform the registrant of your updates.
</p>
</div>
</div>
{% else %}
<a href="{% url 'home' %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Back to manage your domains
</p>
</a>
{% endif %}
{# messages block is under the back breadcrumb link #}
{% if messages %}
{% for message in messages %}

View file

@ -7,7 +7,8 @@
<h1>Domain contact information</h1>
<p>If youd like us to use a different name, email, or phone number you can make those changes below. Changing your contact information here wont affect your Login.gov account information.</p>
<p>If youd like us to use a different name, email, or phone number you can make those changes below. <strong>Updating your contact information here will update the contact information for all domains in your account.</strong> However, it wont affect your Login.gov account information.
</p>
{% include "includes/required_fields.html" %}

View file

@ -430,10 +430,16 @@ def create_user():
return User.objects.create_user(
username="staffuser",
email="user@example.com",
is_staff=True,
password=p,
)
def create_ready_domain():
domain, _ = Domain.objects.get_or_create(name="city.gov", state=Domain.State.READY)
return domain
def completed_application(
has_other_contacts=True,
has_current_website=True,
@ -518,3 +524,11 @@ def multiple_unalphabetical_domain_objects(
application = mock.create_full_dummy_domain_object(domain_type, object_name)
applications.append(application)
return applications
def generic_domain_object(domain_type, object_name):
"""Returns a generic domain object of
domain_type 'application', 'information', or 'invitation'"""
mock = AuditedAdminMockData()
application = mock.create_full_dummy_domain_object(domain_type, object_name)
return application

View file

@ -1,13 +1,16 @@
from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite
from django.urls import reverse
from registrar.admin import (
DomainAdmin,
DomainApplicationAdmin,
ListHeaderAdmin,
MyUserAdmin,
AuditedAdmin,
)
from registrar.models import (
Domain,
DomainApplication,
DomainInformation,
User,
@ -15,14 +18,17 @@ from registrar.models import (
)
from .common import (
completed_application,
generic_domain_object,
mock_user,
create_superuser,
create_user,
create_ready_domain,
multiple_unalphabetical_domain_objects,
)
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model
from unittest.mock import patch
from unittest import skip
from django.conf import settings
from unittest.mock import MagicMock
@ -32,6 +38,60 @@ import logging
logger = logging.getLogger(__name__)
class TestDomainAdmin(TestCase):
def setUp(self):
self.site = AdminSite()
self.admin = DomainAdmin(model=Domain, admin_site=self.site)
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
self.staffuser = create_user()
def test_place_and_remove_hold(self):
domain = create_ready_domain()
# get admin page and assert Place Hold button
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domain/{}/change/".format(domain.pk),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Place hold")
self.assertNotContains(response, "Remove hold")
# submit place_client_hold and assert Remove Hold button
response = self.client.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_place_client_hold": "Place hold", "name": domain.name},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Remove hold")
self.assertNotContains(response, "Place hold")
# submit remove client hold and assert Place hold button
response = self.client.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_remove_client_hold": "Remove hold", "name": domain.name},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Place hold")
self.assertNotContains(response, "Remove hold")
@skip("Waiting on epp lib to implement")
def test_place_and_remove_hold_epp(self):
raise
def tearDown(self):
Domain.objects.all().delete()
User.objects.all().delete()
class TestDomainApplicationAdmin(TestCase):
def setUp(self):
self.site = AdminSite()
@ -770,3 +830,129 @@ class AuditedAdminTest(TestCase):
DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete()
DomainInvitation.objects.all().delete()
class DomainSessionVariableTest(TestCase):
"""Test cases for session variables in Django Admin"""
def setUp(self):
self.factory = RequestFactory()
self.admin = DomainAdmin(Domain, None)
self.client = Client(HTTP_HOST="localhost:8080")
def test_session_vars_set_correctly(self):
"""Checks if session variables are being set correctly"""
p = "adminpass"
self.client.login(username="superuser", password=p)
dummy_domain_information = generic_domain_object("information", "session")
request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk)
self.populate_session_values(request, dummy_domain_information.domain)
self.assertEqual(request.session["analyst_action"], "edit")
self.assertEqual(
request.session["analyst_action_location"],
dummy_domain_information.domain.pk,
)
def test_session_vars_set_correctly_hardcoded_domain(self):
"""Checks if session variables are being set correctly"""
p = "adminpass"
self.client.login(username="superuser", password=p)
dummy_domain_information: Domain = generic_domain_object(
"information", "session"
)
dummy_domain_information.domain.pk = 1
request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk)
self.populate_session_values(request, dummy_domain_information.domain)
self.assertEqual(request.session["analyst_action"], "edit")
self.assertEqual(request.session["analyst_action_location"], 1)
def test_session_variables_reset_correctly(self):
"""Checks if incorrect session variables get overridden"""
p = "adminpass"
self.client.login(username="superuser", password=p)
dummy_domain_information = generic_domain_object("information", "session")
request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk)
self.populate_session_values(
request, dummy_domain_information.domain, preload_bad_data=True
)
self.assertEqual(request.session["analyst_action"], "edit")
self.assertEqual(
request.session["analyst_action_location"],
dummy_domain_information.domain.pk,
)
def test_session_variables_retain_information(self):
"""Checks to see if session variables retain old information"""
p = "adminpass"
self.client.login(username="superuser", password=p)
dummy_domain_information_list = multiple_unalphabetical_domain_objects(
"information"
)
for item in dummy_domain_information_list:
request = self.get_factory_post_edit_domain(item.domain.pk)
self.populate_session_values(request, item.domain)
self.assertEqual(request.session["analyst_action"], "edit")
self.assertEqual(request.session["analyst_action_location"], item.domain.pk)
def test_session_variables_concurrent_requests(self):
"""Simulates two requests at once"""
p = "adminpass"
self.client.login(username="superuser", password=p)
info_first = generic_domain_object("information", "session")
info_second = generic_domain_object("information", "session2")
request_first = self.get_factory_post_edit_domain(info_first.domain.pk)
request_second = self.get_factory_post_edit_domain(info_second.domain.pk)
self.populate_session_values(request_first, info_first.domain, True)
self.populate_session_values(request_second, info_second.domain, True)
# Check if anything got nulled out
self.assertNotEqual(request_first.session["analyst_action"], None)
self.assertNotEqual(request_second.session["analyst_action"], None)
self.assertNotEqual(request_first.session["analyst_action_location"], None)
self.assertNotEqual(request_second.session["analyst_action_location"], None)
# Check if they are both the same action 'type'
self.assertEqual(request_first.session["analyst_action"], "edit")
self.assertEqual(request_second.session["analyst_action"], "edit")
# Check their locations, and ensure they aren't the same across both
self.assertNotEqual(
request_first.session["analyst_action_location"],
request_second.session["analyst_action_location"],
)
def populate_session_values(self, request, domain_object, preload_bad_data=False):
"""Boilerplate for creating mock sessions"""
request.user = self.client
request.session = SessionStore()
request.session.create()
if preload_bad_data:
request.session["analyst_action"] = "invalid"
request.session["analyst_action_location"] = "bad location"
self.admin.response_change(request, domain_object)
def get_factory_post_edit_domain(self, primary_key):
"""Posts to registrar domain change
with the edit domain button 'clicked',
then returns the factory object"""
return self.factory.post(
reverse("admin:registrar_domain_change", args=(primary_key,)),
{"_edit_domain": "true"},
follow=True,
)

View file

@ -79,6 +79,7 @@ class DomainOrgNameAddressView(DomainPermissionView, FormMixin):
messages.success(
self.request, "The organization name and mailing address has been updated."
)
# superclass has the redirect
return super().form_valid(form)
@ -121,6 +122,7 @@ class DomainAuthorizingOfficialView(DomainPermissionView, FormMixin):
messages.success(
self.request, "The authorizing official for this domain has been updated."
)
# superclass has the redirect
return super().form_valid(form)
@ -187,6 +189,7 @@ class DomainNameserversView(DomainPermissionView, FormMixin):
messages.success(
self.request, "The name servers for this domain have been updated."
)
# superclass has the redirect
return super().form_valid(formset)
@ -227,6 +230,7 @@ class DomainYourContactInformationView(DomainPermissionView, FormMixin):
messages.success(
self.request, "Your contact information for this domain has been updated."
)
# superclass has the redirect
return super().form_valid(form)
@ -272,6 +276,7 @@ class DomainSecurityEmailView(DomainPermissionView, FormMixin):
messages.success(
self.request, "The security email for this domain have been updated."
)
# superclass has the redirect
return redirect(self.get_success_url())
@ -347,6 +352,7 @@ class DomainAddUserView(DomainPermissionView, FormMixin):
messages.success(
self.request, f"Invited {email_address} to this domain."
)
return redirect(self.get_success_url())
def form_valid(self, form):
@ -368,6 +374,7 @@ class DomainAddUserView(DomainPermissionView, FormMixin):
pass
messages.success(self.request, f"Added user {requested_email}.")
return redirect(self.get_success_url())

View file

@ -2,7 +2,16 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from registrar.models import UserDomainRole, DomainApplication, DomainInvitation
from registrar.models import (
DomainApplication,
DomainInvitation,
DomainInformation,
UserDomainRole,
)
import logging
logger = logging.getLogger(__name__)
class PermissionsLoginMixin(PermissionRequiredMixin):
@ -25,27 +34,80 @@ class DomainPermission(PermissionsLoginMixin):
up from the domain's primary key in self.kwargs["pk"]
"""
# ticket 806
# if self.request.user is staff or admin and
# domain.application__status = 'approved' or 'rejected' or 'action needed'
# return True
if not self.request.user.is_authenticated:
return False
# user needs to have a role on the domain
if not UserDomainRole.objects.filter(
user=self.request.user, domain__id=self.kwargs["pk"]
).exists():
return False
# The user has an ineligible flag
if self.request.user.is_restricted():
return False
pk = self.kwargs["pk"]
# If pk is none then something went very wrong...
if pk is None:
raise ValueError("Primary key is None")
if self.can_access_other_user_domains(pk):
return True
# user needs to have a role on the domain
if not UserDomainRole.objects.filter(
user=self.request.user, domain__id=pk
).exists():
return False
# if we need to check more about the nature of role, do it here.
return True
def can_access_other_user_domains(self, pk):
"""Checks to see if an authorized user (staff or superuser)
can access a domain that they did not create or was invited to.
"""
# Check if the user is permissioned...
user_is_analyst_or_superuser = (
self.request.user.is_staff or self.request.user.is_superuser
)
if not user_is_analyst_or_superuser:
return False
# Check if the user is attempting a valid edit action.
# In other words, if the analyst/admin did not click
# the 'Manage Domain' button in /admin,
# then they cannot access this page.
session = self.request.session
can_do_action = (
"analyst_action" in session
and "analyst_action_location" in session
and session["analyst_action_location"] == pk
)
if not can_do_action:
return False
# Analysts may manage domains, when they are in these statuses:
valid_domain_statuses = [
DomainApplication.APPROVED,
DomainApplication.IN_REVIEW,
DomainApplication.REJECTED,
DomainApplication.ACTION_NEEDED,
# Edge case - some domains do not have
# a status or DomainInformation... aka a status of 'None'.
# It is necessary to access those to correct errors.
None,
]
requested_domain = None
if DomainInformation.objects.filter(id=pk).exists():
requested_domain = DomainInformation.objects.get(id=pk)
if requested_domain.domain_application.status not in valid_domain_statuses:
return False
# Valid session keys exist,
# the user is permissioned,
# and it is in a valid status
return True
class DomainApplicationPermission(PermissionsLoginMixin):

View file

@ -3,7 +3,6 @@
import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView
from registrar.models import Domain, DomainApplication, DomainInvitation
from .mixins import (
@ -12,6 +11,9 @@ from .mixins import (
DomainInvitationPermission,
ApplicationWizardPermission,
)
import logging
logger = logging.getLogger(__name__)
class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
@ -27,6 +29,22 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
# variable name in template context for the model object
context_object_name = "domain"
# Adds context information for user permissions
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
context["is_analyst_or_superuser"] = user.is_staff or user.is_superuser
# Stored in a variable for the linter
action = "analyst_action"
action_location = "analyst_action_location"
# Flag to see if an analyst is attempting to make edits
if action in self.request.session:
context[action] = self.request.session[action]
if action_location in self.request.session:
context[action_location] = self.request.session[action_location]
return context
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod