+ Back to manage your domains +
+diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d6109a0cc..af487bd78 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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,54 +176,96 @@ 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): - PLACE_HOLD = "_place_client_hold" - EDIT_DOMAIN = "_edit_domain" - if PLACE_HOLD in request.POST: - try: - obj.place_client_hold() - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ( - "%s is in client hold. This domain is no longer accessible on" - " the public internet." - ) - % obj.name, - ) - return HttpResponseRedirect(".") - elif EDIT_DOMAIN in request.POST: - # 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,))) + # 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: + self.message_user( + request, + ( + "%s is in client hold. This domain is no longer accessible on" + " the public internet." + ) + % obj.name, + ) + return HttpResponseRedirect(".") + + 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 @@ -222,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 @@ -400,13 +555,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) diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index b87257344..a2e32bd21 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -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); +} \ No newline at end of file diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 76b01abf7..30924b8bf 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -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 = [ diff --git a/src/registrar/migrations/0031_alter_domain_state.py b/src/registrar/migrations/0031_alter_domain_state.py new file mode 100644 index 000000000..2545adb27 --- /dev/null +++ b/src/registrar/migrations/0031_alter_domain_state.py @@ -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, + ), + ), + ] diff --git a/src/registrar/migrations/0031_transitiondomain.py b/src/registrar/migrations/0031_transitiondomain.py new file mode 100644 index 000000000..e72a8d85a --- /dev/null +++ b/src/registrar/migrations/0031_transitiondomain.py @@ -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, + }, + ), + ] diff --git a/src/registrar/migrations/0032_merge_0031_alter_domain_state_0031_transitiondomain.py b/src/registrar/migrations/0032_merge_0031_alter_domain_state_0031_transitiondomain.py new file mode 100644 index 000000000..4c0a38427 --- /dev/null +++ b/src/registrar/migrations/0032_merge_0031_alter_domain_state_0031_transitiondomain.py @@ -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 = [] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index 542cb00e1..fa4ce7e2a 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -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) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 41b033d47..69d7bac7a 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -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 diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py new file mode 100644 index 000000000..31da70704 --- /dev/null +++ b/src/registrar/models/transition_domain.py @@ -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 diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index fb5934470..1c7f6007f 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -4,12 +4,20 @@ {% for app in app_list %}
+ {{ app.name }} + | + {% else %} ++ {{ app.name }} + | + {% endif %} +|||
---|---|---|---|---|
Model | Add | diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index 6b641722f..dcdd29e2f 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -2,6 +2,24 @@ {% load static %} {% load i18n %} +{% block extrahead %} + + + + + +{% endblock %} + {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} {% block extrastyle %}{{ block.super }} diff --git a/src/registrar/templates/admin/change_form.html b/src/registrar/templates/admin/change_form.html index e0f9ae1a4..78dac9ac0 100644 --- a/src/registrar/templates/admin/change_form.html +++ b/src/registrar/templates/admin/change_form.html @@ -9,4 +9,4 @@ {% endblock %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/registrar/templates/admin/change_form_no_submit.html b/src/registrar/templates/admin/change_form_no_submit.html new file mode 100644 index 000000000..04a491aae --- /dev/null +++ b/src/registrar/templates/admin/change_form_no_submit.html @@ -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 %} +