diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 82daee6f8..d78947c85 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -130,6 +130,7 @@ class MyUserAdmin(BaseUserAdmin): inlines = [UserContactInline] list_display = ( + "username", "email", "first_name", "last_name", @@ -159,10 +160,51 @@ class MyUserAdmin(BaseUserAdmin): ("Important dates", {"fields": ("last_login", "date_joined")}), ) + analyst_fieldsets = ( + ( + None, + {"fields": ("password", "status")}, + ), + ("Personal Info", {"fields": ("first_name", "last_name", "email")}), + ( + "Permissions", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + ) + }, + ), + ("Important dates", {"fields": ("last_login", "date_joined")}), + ) + + analyst_readonly_fields = [ + "password", + "Personal Info", + "first_name", + "last_name", + "email", + "Permissions", + "is_active", + "is_staff", + "is_superuser", + "Important dates", + "last_login", + "date_joined", + ] + def get_list_display(self, request): if not request.user.is_superuser: # Customize the list display for staff users - return ("email", "first_name", "last_name", "is_staff", "is_superuser") + return ( + "email", + "first_name", + "last_name", + "is_staff", + "is_superuser", + "status", + ) # Use the default list display for non-staff users return super().get_list_display(request) @@ -171,11 +213,18 @@ class MyUserAdmin(BaseUserAdmin): if not request.user.is_superuser: # If the user doesn't have permission to change the model, # show a read-only fieldset - return ((None, {"fields": []}),) + return self.analyst_fieldsets # If the user has permission to change the model, show all fields return super().get_fieldsets(request, obj) + def get_readonly_fields(self, request, obj=None): + if request.user.is_superuser: + return () # No read-only fields for superusers + elif request.user.is_staff: + return self.analyst_readonly_fields # Read-only fields for staff + return () # No read-only fields for other users + class HostIPInline(admin.StackedInline): """Edit an ip address on the host page.""" @@ -189,9 +238,428 @@ class MyHostAdmin(AuditedAdmin): inlines = [HostIPInline] +class ContactAdmin(ListHeaderAdmin): + """Custom contact admin class to add search.""" + + 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." + + fieldsets = [ + (None, {"fields": ["creator", "domain_application"]}), + ( + "Type of organization", + { + "fields": [ + "organization_type", + "federally_recognized_tribe", + "state_recognized_tribe", + "tribe_name", + "federal_agency", + "federal_type", + "is_election_board", + "about_your_organization", + ] + }, + ), + ( + "Organization name and mailing address", + { + "fields": [ + "organization_name", + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + "urbanization", + ] + }, + ), + ("Authorizing official", {"fields": ["authorizing_official"]}), + (".gov domain", {"fields": ["domain"]}), + ("Your contact information", {"fields": ["submitter"]}), + ("Other employees from your organization?", {"fields": ["other_contacts"]}), + ( + "No other employees from your organization?", + {"fields": ["no_other_contacts_rationale"]}, + ), + ("Anything else we should know?", {"fields": ["anything_else"]}), + ( + "Requirements for operating .gov domains", + {"fields": ["is_policy_acknowledged"]}, + ), + ] + + # Read only that we'll leverage for CISA Analysts + analyst_readonly_fields = [ + "creator", + "type_of_work", + "more_organization_information", + "address_line1", + "address_line2", + "zipcode", + "domain", + "submitter", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + ] + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have 1 conditions that determine which fields are read-only: + admin user permissions. + """ + + readonly_fields = list(self.readonly_fields) + + if request.user.is_superuser: + return readonly_fields + else: + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + + +class DomainApplicationAdminForm(forms.ModelForm): + """Custom form to limit transitions to available transitions""" + + class Meta: + model = models.DomainApplication + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + application = kwargs.get("instance") + if application and application.pk: + current_state = application.status + + # first option in status transitions is current state + available_transitions = [(current_state, current_state)] + + transitions = get_available_FIELD_transitions( + application, models.DomainApplication._meta.get_field("status") + ) + + for transition in transitions: + available_transitions.append((transition.target, transition.target)) + + # only set the available transitions if the user is not restricted + # from editing the domain application; otherwise, the form will be + # readonly and the status field will not have a widget + if not application.creator.is_restricted(): + self.fields["status"].widget.choices = available_transitions + + +class DomainApplicationAdmin(ListHeaderAdmin): + + """Custom domain applications admin class.""" + + # Columns + list_display = [ + "requested_domain", + "status", + "organization_type", + "created_at", + "submitter", + "investigator", + ] + + # Filters + list_filter = ("status", "organization_type", "investigator") + + # Search + search_fields = [ + "requested_domain__name", + "submitter__email", + "submitter__first_name", + "submitter__last_name", + ] + search_help_text = "Search by domain or submitter." + + # Detail view + form = DomainApplicationAdminForm + fieldsets = [ + (None, {"fields": ["status", "investigator", "creator", "approved_domain"]}), + ( + "Type of organization", + { + "fields": [ + "organization_type", + "federally_recognized_tribe", + "state_recognized_tribe", + "tribe_name", + "federal_agency", + "federal_type", + "is_election_board", + "about_your_organization", + ] + }, + ), + ( + "Organization name and mailing address", + { + "fields": [ + "organization_name", + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + "urbanization", + ] + }, + ), + ("Authorizing official", {"fields": ["authorizing_official"]}), + ("Current websites", {"fields": ["current_websites"]}), + (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), + ("Purpose of your domain", {"fields": ["purpose"]}), + ("Your contact information", {"fields": ["submitter"]}), + ("Other employees from your organization?", {"fields": ["other_contacts"]}), + ( + "No other employees from your organization?", + {"fields": ["no_other_contacts_rationale"]}, + ), + ("Anything else we should know?", {"fields": ["anything_else"]}), + ( + "Requirements for operating .gov domains", + {"fields": ["is_policy_acknowledged"]}, + ), + ] + + # Read only that we'll leverage for CISA Analysts + analyst_readonly_fields = [ + "creator", + "about_your_organization", + "address_line1", + "address_line2", + "zipcode", + "requested_domain", + "alternative_domains", + "purpose", + "submitter", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + ] + + # Trigger action when a fieldset is changed + def save_model(self, request, obj, form, change): + if obj and obj.creator.status != models.User.RESTRICTED: + if change: # Check if the application is being edited + # Get the original application from the database + original_obj = models.DomainApplication.objects.get(pk=obj.pk) + + if ( + obj + and original_obj.status == models.DomainApplication.APPROVED + and ( + obj.status == models.DomainApplication.REJECTED + or obj.status == models.DomainApplication.INELIGIBLE + ) + and not obj.domain_is_not_active() + ): + # If an admin tried to set an approved application to + # rejected or ineligible and the related domain is already + # active, shortcut the action and throw a friendly + # error message. This action would still not go through + # shortcut or not as the rules are duplicated on the model, + # but the error would be an ugly Django error screen. + + # Clear the success message + messages.set_level(request, messages.ERROR) + + messages.error( + request, + "This action is not permitted. The domain " + + "is already active.", + ) + + else: + if obj.status != original_obj.status: + status_method_mapping = { + models.DomainApplication.STARTED: None, + models.DomainApplication.SUBMITTED: obj.submit, + models.DomainApplication.IN_REVIEW: obj.in_review, + models.DomainApplication.ACTION_NEEDED: obj.action_needed, + models.DomainApplication.APPROVED: obj.approve, + models.DomainApplication.WITHDRAWN: obj.withdraw, + models.DomainApplication.REJECTED: obj.reject, + models.DomainApplication.INELIGIBLE: ( + obj.reject_with_prejudice + ), + } + selected_method = status_method_mapping.get(obj.status) + if selected_method is None: + logger.warning("Unknown status selected in django admin") + else: + # This is an fsm in model which will throw an error if the + # transition condition is violated, so we roll back the + # status to what it was before the admin user changed it and + # let the fsm method set it. + obj.status = original_obj.status + selected_method() + + super().save_model(request, obj, form, change) + else: + # Clear the success message + messages.set_level(request, messages.ERROR) + + messages.error( + request, + "This action is not permitted for applications " + + "with a restricted creator.", + ) + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have 2 conditions that determine which fields are read-only: + admin user permissions and the application creator's status, so + we'll use the baseline readonly_fields and extend it as needed. + """ + + readonly_fields = list(self.readonly_fields) + + # Check if the creator is restricted + if obj and obj.creator.status == models.User.RESTRICTED: + # For fields like CharField, IntegerField, etc., the widget used is + # straightforward and the readonly_fields list can control their behavior + readonly_fields.extend([field.name for field in self.model._meta.fields]) + # Add the multi-select fields to readonly_fields: + # Complex fields like ManyToManyField require special handling + readonly_fields.extend( + ["current_websites", "other_contacts", "alternative_domains"] + ) + + if request.user.is_superuser: + return readonly_fields + else: + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + + def display_restricted_warning(self, request, obj): + if obj and obj.creator.status == models.User.RESTRICTED: + messages.warning( + request, + "Cannot edit an application with a restricted creator.", + ) + + def change_view(self, request, object_id, form_url="", extra_context=None): + obj = self.get_object(request, object_id) + self.display_restricted_warning(request, obj) + return super().change_view(request, object_id, form_url, extra_context) + + +class DomainInformationInline(admin.StackedInline): + """Edit a domain information on the domain page. + We had issues inheriting from both StackedInline + and the source DomainInformationAdmin since these + classes conflict, so we'll just pull what we need + from DomainInformationAdmin""" + + model = models.DomainInformation + + fieldsets = DomainInformationAdmin.fieldsets + analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields + + def get_readonly_fields(self, request, obj=None): + return DomainInformationAdmin.get_readonly_fields(self, request, obj=None) + + class DomainAdmin(ListHeaderAdmin): """Custom domain admin class to add extra buttons.""" + inlines = [DomainInformationInline] + # Columns list_display = [ "name", @@ -207,7 +675,7 @@ class DomainAdmin(ListHeaderAdmin): ) # Filters - list_filter = ["domain_info__organization_type"] + list_filter = ["domain_info__organization_type", "state"] search_fields = ["name"] search_help_text = "Search by domain name." @@ -312,306 +780,11 @@ class DomainAdmin(ListHeaderAdmin): return super().has_change_permission(request, obj) -class ContactAdmin(ListHeaderAdmin): - """Custom contact admin class to add search.""" +class DraftDomainAdmin(ListHeaderAdmin): + """Custom draft domain admin class.""" - 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 DomainApplicationAdminForm(forms.ModelForm): - """Custom form to limit transitions to available transitions""" - - class Meta: - model = models.DomainApplication - fields = "__all__" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - application = kwargs.get("instance") - if application and application.pk: - current_state = application.status - - # first option in status transitions is current state - available_transitions = [(current_state, current_state)] - - transitions = get_available_FIELD_transitions( - application, models.DomainApplication._meta.get_field("status") - ) - - for transition in transitions: - available_transitions.append((transition.target, transition.target)) - - # only set the available transitions if the user is not restricted - # from editing the domain application; otherwise, the form will be - # readonly and the status field will not have a widget - if not application.creator.is_restricted(): - self.fields["status"].widget.choices = available_transitions - - -class DomainApplicationAdmin(ListHeaderAdmin): - - """Custom domain applications admin class.""" - - # Set multi-selects 'read-only' (hide selects and show data) - # based on user perms and application creator's status - # form = DomainApplicationForm - - # Columns - list_display = [ - "requested_domain", - "status", - "organization_type", - "created_at", - "submitter", - "investigator", - ] - - # Filters - list_filter = ("status", "organization_type", "investigator") - - # Search - search_fields = [ - "requested_domain__name", - "submitter__email", - "submitter__first_name", - "submitter__last_name", - ] - search_help_text = "Search by domain or submitter." - - # Detail view - form = DomainApplicationAdminForm - fieldsets = [ - (None, {"fields": ["status", "investigator", "creator"]}), - ( - "Type of organization", - { - "fields": [ - "organization_type", - "federally_recognized_tribe", - "state_recognized_tribe", - "tribe_name", - "federal_agency", - "federal_type", - "is_election_board", - "about_your_organization", - ] - }, - ), - ( - "Organization name and mailing address", - { - "fields": [ - "organization_name", - "address_line1", - "address_line2", - "city", - "state_territory", - "zipcode", - "urbanization", - ] - }, - ), - ("Authorizing official", {"fields": ["authorizing_official"]}), - ("Current websites", {"fields": ["current_websites"]}), - (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), - ("Purpose of your domain", {"fields": ["purpose"]}), - ("Your contact information", {"fields": ["submitter"]}), - ("Other employees from your organization?", {"fields": ["other_contacts"]}), - ( - "No other employees from your organization?", - {"fields": ["no_other_contacts_rationale"]}, - ), - ("Anything else we should know?", {"fields": ["anything_else"]}), - ( - "Requirements for operating .gov domains", - {"fields": ["is_policy_acknowledged"]}, - ), - ] - - # Read only that we'll leverage for CISA Analysts - analyst_readonly_fields = [ - "creator", - "about_your_organization", - "address_line1", - "address_line2", - "zipcode", - "requested_domain", - "alternative_domains", - "purpose", - "submitter", - "no_other_contacts_rationale", - "anything_else", - "is_policy_acknowledged", - ] - - # Trigger action when a fieldset is changed - def save_model(self, request, obj, form, change): - if obj and obj.creator.status != models.User.RESTRICTED: - if change: # Check if the application is being edited - # Get the original application from the database - original_obj = models.DomainApplication.objects.get(pk=obj.pk) - - if obj.status != original_obj.status: - status_method_mapping = { - models.DomainApplication.STARTED: None, - models.DomainApplication.SUBMITTED: obj.submit, - models.DomainApplication.IN_REVIEW: obj.in_review, - models.DomainApplication.ACTION_NEEDED: obj.action_needed, - models.DomainApplication.APPROVED: obj.approve, - models.DomainApplication.WITHDRAWN: obj.withdraw, - models.DomainApplication.REJECTED: obj.reject, - models.DomainApplication.INELIGIBLE: obj.reject_with_prejudice, - } - selected_method = status_method_mapping.get(obj.status) - if selected_method is None: - logger.warning("Unknown status selected in django admin") - else: - # This is an fsm in model which will throw an error if the - # transition condition is violated, so we roll back the - # status to what it was before the admin user changed it and - # let the fsm method set it. - obj.status = original_obj.status - selected_method() - - super().save_model(request, obj, form, change) - else: - # Clear the success message - messages.set_level(request, messages.ERROR) - - messages.error( - request, - "This action is not permitted for applications " - + "with a restricted creator.", - ) - - def get_readonly_fields(self, request, obj=None): - """Set the read-only state on form elements. - We have 2 conditions that determine which fields are read-only: - admin user permissions and the application creator's status, so - we'll use the baseline readonly_fields and extend it as needed. - """ - - readonly_fields = list(self.readonly_fields) - - # Check if the creator is restricted - if obj and obj.creator.status == models.User.RESTRICTED: - # For fields like CharField, IntegerField, etc., the widget used is - # straightforward and the readonly_fields list can control their behavior - readonly_fields.extend([field.name for field in self.model._meta.fields]) - # Add the multi-select fields to readonly_fields: - # Complex fields like ManyToManyField require special handling - readonly_fields.extend( - ["current_websites", "other_contacts", "alternative_domains"] - ) - - if request.user.is_superuser: - return readonly_fields - else: - readonly_fields.extend([field for field in self.analyst_readonly_fields]) - return readonly_fields - - def display_restricted_warning(self, request, obj): - if obj and obj.creator.status == models.User.RESTRICTED: - messages.warning( - request, - "Cannot edit an application with a restricted creator.", - ) - - def change_view(self, request, object_id, form_url="", extra_context=None): - obj = self.get_object(request, object_id) - self.display_restricted_warning(request, obj) - return super().change_view(request, object_id, form_url, extra_context) + search_fields = ["name"] + search_help_text = "Search by draft domain name." admin.site.unregister(LogEntry) # Unregister the default registration @@ -622,6 +795,7 @@ admin.site.register(models.Contact, ContactAdmin) admin.site.register(models.DomainInvitation, DomainInvitationAdmin) admin.site.register(models.DomainInformation, DomainInformationAdmin) admin.site.register(models.Domain, DomainAdmin) +admin.site.register(models.DraftDomain, DraftDomainAdmin) admin.site.register(models.Host, MyHostAdmin) admin.site.register(models.Nameserver, MyHostAdmin) admin.site.register(models.Website, WebsiteAdmin) diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 30924b8bf..a4e75dd2e 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -143,13 +143,23 @@ class UserFixture: "permissions": ["view_logentry"], }, {"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]}, + { + "app_label": "registrar", + "model": "domaininformation", + "permissions": ["change_domaininformation"], + }, { "app_label": "registrar", "model": "domainapplication", "permissions": ["change_domainapplication"], }, {"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]}, - {"app_label": "registrar", "model": "user", "permissions": ["view_user"]}, + { + "app_label": "registrar", + "model": "draftdomain", + "permissions": ["change_draftdomain"], + }, + {"app_label": "registrar", "model": "user", "permissions": ["change_user"]}, ] @classmethod diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 93ec18aad..516683247 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -6,12 +6,12 @@ from phonenumber_field.formfields import PhoneNumberField # type: ignore from django import forms from django.core.validators import RegexValidator, MaxLengthValidator -from django.urls import reverse from django.utils.safestring import mark_safe from api.views import DOMAIN_API_MESSAGES from registrar.models import Contact, DomainApplication, DraftDomain, Domain +from registrar.templatetags.url_helpers import public_site_url from registrar.utility import errors logger = logging.getLogger(__name__) @@ -181,7 +181,6 @@ class TribalGovernmentForm(RegistrarForm): self.cleaned_data["federally_recognized_tribe"] or self.cleaned_data["state_recognized_tribe"] ): - todo_url = reverse("todo") raise forms.ValidationError( # no sec because we are using it to include an internal URL # into a link. There should be no user-facing input in the @@ -190,10 +189,10 @@ class TribalGovernmentForm(RegistrarForm): "You can’t complete this application yet. " "Only tribes recognized by the U.S. federal government " "or by a U.S. state government are eligible for .gov " - 'domains. Please use our contact form to ' + 'domains. Use our contact form to ' "tell us more about your tribe and why you want a .gov " "domain. We’ll review your information and get back " - "to you.".format(todo_url) + "to you.".format(public_site_url("contact")) ), code="invalid", ) diff --git a/src/registrar/migrations/0031_transitiondomain_and_more.py b/src/registrar/migrations/0031_transitiondomain_and_more.py index 79bf7eab4..e378a33de 100644 --- a/src/registrar/migrations/0031_transitiondomain_and_more.py +++ b/src/registrar/migrations/0031_transitiondomain_and_more.py @@ -1,6 +1,7 @@ -# Generated by Django 4.2.1 on 2023-09-13 22:25 +# Generated by Django 4.2.1 on 2023-09-15 21:05 from django.db import migrations, models +import django.db.models.deletion import django_fsm @@ -10,6 +11,23 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterField( + model_name="domain", + name="state", + field=django_fsm.FSMField( + choices=[ + ("unknown", "Unknown"), + ("dns needed", "Dns Needed"), + ("ready", "Ready"), + ("on hold", "On Hold"), + ("deleted", "Deleted"), + ], + default="unknown", + help_text="Very basic info about the lifecycle of this domain object", + max_length=21, + protected=True, + ), + ), migrations.CreateModel( name="TransitionDomain", fields=[ @@ -89,20 +107,27 @@ class Migration(migrations.Migration): ), ), migrations.AlterField( - model_name="domain", - name="state", - field=django_fsm.FSMField( - choices=[ - ("unknown", "Unknown"), - ("dns needed", "Dns Needed"), - ("ready", "Ready"), - ("on hold", "On Hold"), - ("deleted", "Deleted"), - ], - default="unknown", - help_text="Very basic info about the lifecycle of this domain object", - max_length=21, - protected=True, + model_name="domainapplication", + name="approved_domain", + field=models.OneToOneField( + blank=True, + help_text="The approved domain", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="domain_application", + to="registrar.domain", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="domain", + field=models.OneToOneField( + blank=True, + help_text="Domain to which this information belongs", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="domain_info", + to="registrar.domain", ), ), migrations.AlterField( diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 4e6b96de2..13405d9bb 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -332,24 +332,23 @@ class Domain(TimeStampedModel, DomainHelper): @Cache def statuses(self) -> list[str]: """ - Get or set the domain `status` elements from the registry. + Get the domain `status` elements from the registry. A domain's status indicates various properties. See Domain.Status. """ - # implementation note: the Status object from EPP stores the string in - # a dataclass property `state`, not to be confused with the `state` field here - if "statuses" not in self._cache: - self._fetch_cache() - if "statuses" not in self._cache: - raise Exception("Can't retreive status from domain info") - else: - return self._cache["statuses"] + try: + return self._get_property("statuses") + except KeyError: + logger.error("Can't retrieve status from domain info") + return [] @statuses.setter # type: ignore def statuses(self, statuses: list[str]): - # TODO: there are a long list of rules in the RFC about which statuses - # can be combined; check that here and raise errors for invalid combinations - - # some statuses cannot be set by the client at all + """ + We will not implement this. Statuses are set by the registry + when we run delete and client hold, and these are the only statuses + we will be triggering. + """ raise NotImplementedError() @Cache @@ -610,6 +609,11 @@ class Domain(TimeStampedModel, DomainHelper): """ return self.state == self.State.READY + def delete_request(self): + """Delete from host. Possibly a duplicate of _delete_host?""" + # TODO fix in ticket #901 + pass + def transfer(self): """Going somewhere. Not implemented.""" raise NotImplementedError() @@ -663,9 +667,6 @@ class Domain(TimeStampedModel, DomainHelper): help_text="Very basic info about the lifecycle of this domain object", ) - def isActive(self): - return self.state == Domain.State.CREATED - # ForeignKey on UserDomainRole creates a "permissions" member for # all of the user-roles that are in place for this domain diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index c78a510dc..7df51baf4 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -405,7 +405,7 @@ class DomainApplication(TimeStampedModel): blank=True, help_text="The approved domain", related_name="domain_application", - on_delete=models.PROTECT, + on_delete=models.SET_NULL, ) requested_domain = models.OneToOneField( @@ -471,6 +471,11 @@ class DomainApplication(TimeStampedModel): except Exception: return "" + def domain_is_not_active(self): + if self.approved_domain: + return not self.approved_domain.is_active() + return True + def _send_status_update_email( self, new_status, email_template, email_template_subject ): @@ -594,11 +599,22 @@ class DomainApplication(TimeStampedModel): "emails/domain_request_withdrawn_subject.txt", ) - @transition(field="status", source=[IN_REVIEW, APPROVED], target=REJECTED) + @transition( + field="status", + source=[IN_REVIEW, APPROVED], + target=REJECTED, + conditions=[domain_is_not_active], + ) def reject(self): """Reject an application that has been submitted. - As a side effect, an email notification is sent, similar to in_review""" + As side effects this will delete the domain and domain_information + (will cascade), and send an email notification.""" + + if self.status == self.APPROVED: + self.approved_domain.delete_request() + self.approved_domain.delete() + self.approved_domain = None self._send_status_update_email( "action needed", @@ -606,14 +622,25 @@ class DomainApplication(TimeStampedModel): "emails/status_change_rejected_subject.txt", ) - @transition(field="status", source=[IN_REVIEW, APPROVED], target=INELIGIBLE) + @transition( + field="status", + source=[IN_REVIEW, APPROVED], + target=INELIGIBLE, + conditions=[domain_is_not_active], + ) def reject_with_prejudice(self): """The applicant is a bad actor, reject with prejudice. No email As a side effect, but we block the applicant from editing any existing domains/applications and from submitting new aplications. We do this by setting an ineligible status on the user, which the - permissions classes test against""" + permissions classes test against. This will also delete the domain + and domain_information (will cascade) when they exist.""" + + if self.status == self.APPROVED: + self.approved_domain.delete_request() + self.approved_domain.delete() + self.approved_domain = None self.creator.restrict_user() diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index c1c6142d0..3b93aff48 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) class DomainInformation(TimeStampedModel): """A registrant's domain information for that domain, exported from - DomainApplication. We use these field from DomainApplication with few exceptation + DomainApplication. We use these field from DomainApplication with few exceptions which are 'removed' via pop at the bottom of this file. Most of design for domain management's user information are based on application, but we cannot change the application once approved, so copying them that way we can make changes @@ -150,7 +150,7 @@ class DomainInformation(TimeStampedModel): domain = models.OneToOneField( "registrar.Domain", - on_delete=models.PROTECT, + on_delete=models.CASCADE, blank=True, null=True, # Access this information via Domain as "domain.domain_info" diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 5cf1dd71f..5b04c628d 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -45,7 +45,7 @@ class User(AbstractUser): def __str__(self): # this info is pulled from Login.gov if self.first_name or self.last_name: - return f"{self.first_name or ''} {self.last_name or ''}" + return f"{self.first_name or ''} {self.last_name or ''} {self.email or ''}" elif self.email: return self.email else: diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 09d391dc8..22b9d18d1 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -20,7 +20,7 @@
{% for permission in domain.permissions.all %}