Repository: cisagov/manage.get.gov

Issue:
EPP cache not working
### Current Behavior

The cache is empty when accessing a property getter resulting in more epp calls when in any of the manage domain pages.

### Expected Behavior

When using a domain property getter, it should pull from cache just as it does via command line or in the unit-tests. 

### Steps to Reproduce

1. add a comment on the __init__ and __get__ functions found in domain.py. May also be helpful to add a comment in the get property function
1. go to the domain management main page 
2. see the init call being called twice


### Environment

_No response_

### Additional Context

When going to the manage domain pages, this causes the domain object to be reintialized twice. Each initialization is being called before each getter call, once for the domain.nameserver call and once for domain.security email call. This should not be happening  and is not typical of Django; as normally, an app instance should stay in the context you’re in while using the app. This happens each time we do a domain.[property-value] no the security email page and the nameserver page. As a result we are seeing double the calls needed and resolving this would cut call count by half. 

[see enghuddle for 10/2](https://docs.google.com/document/d/16S6VNKOYW2HI7bKwOwOdQzon5SZA0ZgkMkGfQHLZoTw/edit#heading=h.gw2e13758zoc)
[slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1696006778478359)

### Issue Links

_No response_


Discussion:

Resolving patch:
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -652,6 +652,9 @@
 # instruct browser to only send cookie via HTTPS
 SESSION_COOKIE_SECURE = True
 
+# session engine to cache session information
+SESSION_ENGINE = "django.contrib.sessions.backends.cache"
+
 # ~ Set by django.middleware.clickjacking.XFrameOptionsMiddleware
 # prevent clickjacking by instructing the browser not to load
 # our site within an iframe
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -260,7 +260,6 @@ def _create_host(self, host, addrs):
         """Creates the host object in the registry
         doesn't add the created host to the domain
         returns ErrorCode (int)"""
-        logger.info("Creating host")
         if addrs is not None:
             addresses = [epp.Ip(addr=addr) for addr in addrs]
             request = commands.CreateHost(name=host, addrs=addresses)
@@ -1245,7 +1244,6 @@ def _get_or_create_domain(self):
         count = 0
         while not exitEarly and count < 3:
             try:
-                logger.info("Getting domain info from epp")
                 req = commands.InfoDomain(name=self.name)
                 domainInfoResponse = registry.send(req, cleaned=True)
                 exitEarly = True
@@ -1684,74 +1682,84 @@ def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
         """Contact registry for info about a domain."""
         try:
             # get info from registry
-            dataResponse = self._get_or_create_domain()
-            data = dataResponse.res_data[0]
-            # extract properties from response
-            # (Ellipsis is used to mean "null")
-            cache = {
-                "auth_info": getattr(data, "auth_info", ...),
-                "_contacts": getattr(data, "contacts", ...),
-                "cr_date": getattr(data, "cr_date", ...),
-                "ex_date": getattr(data, "ex_date", ...),
-                "_hosts": getattr(data, "hosts", ...),
-                "name": getattr(data, "name", ...),
-                "registrant": getattr(data, "registrant", ...),
-                "statuses": getattr(data, "statuses", ...),
-                "tr_date": getattr(data, "tr_date", ...),
-                "up_date": getattr(data, "up_date", ...),
-            }
+            data_response = self._get_or_create_domain()
+            cache = self._extract_data_from_response(data_response)
+
             # remove null properties (to distinguish between "a value of None" and null)
-            cleaned = {k: v for k, v in cache.items() if v is not ...}
+            cleaned = self._remove_null_properties(cache)
 
-            # statuses can just be a list no need to keep the epp object
             if "statuses" in cleaned:
                 cleaned["statuses"] = [status.state for status in cleaned["statuses"]]
 
-            # get extensions info, if there is any
-            # DNSSECExtension is one possible extension, make sure to handle
-            # only DNSSECExtension and not other type extensions
-            returned_extensions = dataResponse.extensions
-            cleaned["dnssecdata"] = None
-            for extension in returned_extensions:
-                if isinstance(extension, extensions.DNSSECExtension):
-                    cleaned["dnssecdata"] = extension
+            cleaned["dnssecdata"] = self._get_dnssec_data(data_response.extensions)
+
             # Capture and store old hosts and contacts from cache if they exist
             old_cache_hosts = self._cache.get("hosts")
             old_cache_contacts = self._cache.get("contacts")
 
-            # get contact info, if there are any
-            if (
-                fetch_contacts
-                and "_contacts" in cleaned
-                and isinstance(cleaned["_contacts"], list)
-                and len(cleaned["_contacts"]) > 0
-            ):
-                cleaned["contacts"] = self._fetch_contacts(cleaned["_contacts"])
-                # We're only getting contacts, so retain the old
-                # hosts that existed in cache (if they existed)
-                # and pass them along.
+            if fetch_contacts:
+                cleaned["contacts"] = self._get_contacts(cleaned.get("_contacts", []))
                 if old_cache_hosts is not None:
+                    logger.debug("resetting cleaned['hosts'] to old_cache_hosts")
                     cleaned["hosts"] = old_cache_hosts
 
-            # get nameserver info, if there are any
-            if (
-                fetch_hosts
-                and "_hosts" in cleaned
-                and isinstance(cleaned["_hosts"], list)
-                and len(cleaned["_hosts"])
-            ):
-                cleaned["hosts"] = self._fetch_hosts(cleaned["_hosts"])
-                # We're only getting hosts, so retain the old
-                # contacts that existed in cache (if they existed)
-                # and pass them along.
+            if fetch_hosts:
+                cleaned["hosts"] = self._get_hosts(cleaned.get("_hosts", []))
                 if old_cache_contacts is not None:
                     cleaned["contacts"] = old_cache_contacts
-            # replace the prior cache with new data
+
             self._cache = cleaned
 
         except RegistryError as e:
             logger.error(e)
 
+    def _extract_data_from_response(self, data_response):
+        data = data_response.res_data[0]
+        return {
+            "auth_info": getattr(data, "auth_info", ...),
+            "_contacts": getattr(data, "contacts", ...),
+            "cr_date": getattr(data, "cr_date", ...),
+            "ex_date": getattr(data, "ex_date", ...),
+            "_hosts": getattr(data, "hosts", ...),
+            "name": getattr(data, "name", ...),
+            "registrant": getattr(data, "registrant", ...),
+            "statuses": getattr(data, "statuses", ...),
+            "tr_date": getattr(data, "tr_date", ...),
+            "up_date": getattr(data, "up_date", ...),
+        }
+
+    def _remove_null_properties(self, cache):
+        return {k: v for k, v in cache.items() if v is not ...}
+
+    def _get_dnssec_data(self, response_extensions):
+        # get extensions info, if there is any
+        # DNSSECExtension is one possible extension, make sure to handle
+        # only DNSSECExtension and not other type extensions
+        dnssec_data = None
+        for extension in response_extensions:
+            if isinstance(extension, extensions.DNSSECExtension):
+                dnssec_data = extension
+        return dnssec_data
+
+    def _get_contacts(self, contacts):
+        choices = PublicContact.ContactTypeChoices
+        # We expect that all these fields get populated,
+        # so we can create these early, rather than waiting.
+        cleaned_contacts = {
+            choices.ADMINISTRATIVE: None,
+            choices.SECURITY: None,
+            choices.TECHNICAL: None,
+        }
+        if contacts and isinstance(contacts, list) and len(contacts) > 0:
+            cleaned_contacts = self._fetch_contacts(contacts)
+        return cleaned_contacts
+
+    def _get_hosts(self, hosts):
+        cleaned_hosts = []
+        if hosts and isinstance(hosts, list):
+            cleaned_hosts = self._fetch_hosts(hosts)
+        return cleaned_hosts
+
     def _get_or_create_public_contact(self, public_contact: PublicContact):
         """Tries to find a PublicContact object in our DB.
         If it can't, it'll create it. Returns PublicContact"""
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -53,7 +53,81 @@
 logger = logging.getLogger(__name__)
 
 
-class DomainView(DomainPermissionView):
+class DomainBaseView(DomainPermissionView):
+    """
+    Base View for the Domain. Handles getting and setting the domain
+    in session cache on GETs. Also provides methods for getting
+    and setting the domain in cache
+    """
+
+    def get(self, request, *args, **kwargs):
+        self._get_domain(request)
+        context = self.get_context_data(object=self.object)
+        return self.render_to_response(context)
+
+    def _get_domain(self, request):
+        """
+        get domain from session cache or from db and set
+        to self.object
+        set session to self for downstream functions to
+        update session cache
+        """
+        self.session = request.session
+        # domain:private_key is the session key to use for
+        # caching the domain in the session
+        domain_pk = "domain:" + str(self.kwargs.get("pk"))
+        cached_domain = self.session.get(domain_pk)
+
+        if cached_domain:
+            self.object = cached_domain
+        else:
+            self.object = self.get_object()
+        self._update_session_with_domain()
+
+    def _update_session_with_domain(self):
+        """
+        update domain in the session cache
+        """
+        domain_pk = "domain:" + str(self.kwargs.get("pk"))
+        self.session[domain_pk] = self.object
+
+
+class DomainFormBaseView(DomainBaseView, FormMixin):
+    """
+    Form Base View for the Domain. Handles getting and setting
+    domain in cache when dealing with domain forms. Provides
+    implementations of post, form_valid and form_invalid.
+    """
+
+    def post(self, request, *args, **kwargs):
+        """Form submission posts to this view.
+
+        This post method harmonizes using DomainBaseView and FormMixin
+        """
+        self._get_domain(request)
+        form = self.get_form()
+        if form.is_valid():
+            return self.form_valid(form)
+        else:
+            return self.form_invalid(form)
+
+    def form_valid(self, form):
+        # updates session cache with domain
+        self._update_session_with_domain()
+
+        # superclass has the redirect
+        return super().form_valid(form)
+
+    def form_invalid(self, form):
+        # updates session cache with domain
+        self._update_session_with_domain()
+
+        # superclass has the redirect
+        return super().form_invalid(form)
+
+
+class DomainView(DomainBaseView):
+
     """Domain detail overview page."""
 
     template_name = "domain_detail.html"
@@ -61,10 +135,10 @@ class DomainView(DomainPermissionView):
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
 
-        default_email = Domain().get_default_security_contact().email
+        default_email = self.object.get_default_security_contact().email
         context["default_security_email"] = default_email
 
-        security_email = self.get_object().get_security_email()
+        security_email = self.object.get_security_email()
         if security_email is None or security_email == default_email:
             context["security_email"] = None
             return context
@@ -72,7 +146,7 @@ def get_context_data(self, **kwargs):
         return context
 
 
-class DomainOrgNameAddressView(DomainPermissionView, FormMixin):
+class DomainOrgNameAddressView(DomainFormBaseView):
     """Organization name and mailing address view"""
 
     model = Domain
@@ -83,25 +157,13 @@ class DomainOrgNameAddressView(DomainPermissionView, FormMixin):
     def get_form_kwargs(self, *args, **kwargs):
         """Add domain_info.organization_name instance to make a bound form."""
         form_kwargs = super().get_form_kwargs(*args, **kwargs)
-        form_kwargs["instance"] = self.get_object().domain_info
+        form_kwargs["instance"] = self.object.domain_info
         return form_kwargs
 
     def get_success_url(self):
         """Redirect to the overview page for the domain."""
         return reverse("domain-org-name-address", kwargs={"pk": self.object.pk})
 
-    def post(self, request, *args, **kwargs):
-        """Form submission posts to this view.
-
-        This post method harmonizes using DetailView and FormMixin together.
-        """
-        self.object = self.get_object()
-        form = self.get_form()
-        if form.is_valid():
-            return self.form_valid(form)
-        else:
-            return self.form_invalid(form)
-
     def form_valid(self, form):
         """The form is valid, save the organization name and mailing address."""
         form.save()
@@ -114,7 +176,7 @@ def form_valid(self, form):
         return super().form_valid(form)
 
 
-class DomainAuthorizingOfficialView(DomainPermissionView, FormMixin):
+class DomainAuthorizingOfficialView(DomainFormBaseView):
     """Domain authorizing official editing view."""
 
     model = Domain
@@ -125,25 +187,13 @@ class DomainAuthorizingOfficialView(DomainPermissionView, FormMixin):
     def get_form_kwargs(self, *args, **kwargs):
         """Add domain_info.authorizing_official instance to make a bound form."""
         form_kwargs = super().get_form_kwargs(*args, **kwargs)
-        form_kwargs["instance"] = self.get_object().domain_info.authorizing_official
+        form_kwargs["instance"] = self.object.domain_info.authorizing_official
         return form_kwargs
 
     def get_success_url(self):
         """Redirect to the overview page for the domain."""
         return reverse("domain-authorizing-official", kwargs={"pk": self.object.pk})
 
-    def post(self, request, *args, **kwargs):
-        """Form submission posts to this view.
-
-        This post method harmonizes using DetailView and FormMixin together.
-        """
-        self.object = self.get_object()
-        form = self.get_form()
-        if form.is_valid():
-            return self.form_valid(form)
-        else:
-            return self.form_invalid(form)
-
     def form_valid(self, form):
         """The form is valid, save the authorizing official."""
         form.save()
@@ -156,13 +206,13 @@ def form_valid(self, form):
         return super().form_valid(form)
 
 
-class DomainDNSView(DomainPermissionView):
+class DomainDNSView(DomainBaseView):
     """DNS Information View."""
 
     template_name = "domain_dns.html"
 
 
-class DomainNameserversView(DomainPermissionView, FormMixin):
+class DomainNameserversView(DomainFormBaseView):
     """Domain nameserver editing view."""
 
     template_name = "domain_nameservers.html"
@@ -170,8 +220,7 @@ class DomainNameserversView(DomainPermissionView, FormMixin):
 
     def get_initial(self):
         """The initial value for the form (which is a formset here)."""
-        domain = self.get_object()
-        nameservers = domain.nameservers
+        nameservers = self.object.nameservers
         initial_data = []
 
         if nameservers is not None:
@@ -207,16 +256,6 @@ def get_form(self, **kwargs):
                 form.fields["server"].required = False
         return formset
 
-    def post(self, request, *args, **kwargs):
-        """Formset submission posts to this view."""
-        self.object = self.get_object()
-        formset = self.get_form()
-
-        if formset.is_valid():
-            return self.form_valid(formset)
-        else:
-            return self.form_invalid(formset)
-
     def form_valid(self, formset):
         """The formset is valid, perform something with it."""
 
@@ -229,8 +268,7 @@ def form_valid(self, formset):
             except KeyError:
                 # no server information in this field, skip it
                 pass
-        domain = self.get_object()
-        domain.nameservers = nameservers
+        self.object.nameservers = nameservers
 
         messages.success(
             self.request, "The name servers for this domain have been updated."
@@ -240,7 +278,7 @@ def form_valid(self, formset):
         return super().form_valid(formset)
 
 
-class DomainDNSSECView(DomainPermissionView, FormMixin):
+class DomainDNSSECView(DomainFormBaseView):
     """Domain DNSSEC editing view."""
 
     template_name = "domain_dnssec.html"
@@ -250,9 +288,7 @@ def get_context_data(self, **kwargs):
         """The initial value for the form (which is a formset here)."""
         context = super().get_context_data(**kwargs)
 
-        self.domain = self.get_object()
-
-        has_dnssec_records = self.domain.dnssecdata is not None
+        has_dnssec_records = self.object.dnssecdata is not None
 
         # Create HTML for the modal button
         modal_button = (
@@ -269,16 +305,16 @@ def get_context_data(self, **kwargs):
 
     def get_success_url(self):
         """Redirect to the DNSSEC page for the domain."""
-        return reverse("domain-dns-dnssec", kwargs={"pk": self.domain.pk})
+        return reverse("domain-dns-dnssec", kwargs={"pk": self.object.pk})
 
     def post(self, request, *args, **kwargs):
         """Form submission posts to this view."""
-        self.domain = self.get_object()
+        self._get_domain(request)
         form = self.get_form()
         if form.is_valid():
             if "disable_dnssec" in request.POST:
                 try:
-                    self.domain.dnssecdata = {}
+                    self.object.dnssecdata = {}
                 except RegistryError as err:
                     errmsg = "Error removing existing DNSSEC record(s)."
                     logger.error(errmsg + ": " + err)
@@ -293,7 +329,7 @@ def post(self, request, *args, **kwargs):
         return self.form_valid(form)
 
 
-class DomainDsDataView(DomainPermissionView, FormMixin):
+class DomainDsDataView(DomainFormBaseView):
     """Domain DNSSEC ds data editing view."""
 
     template_name = "domain_dsdata.html"
@@ -302,8 +338,7 @@ class DomainDsDataView(DomainPermissionView, FormMixin):
 
     def get_initial(self):
         """The initial value for the form (which is a formset here)."""
-        domain = self.get_object()
-        dnssecdata: extensions.DNSSECExtension = domain.dnssecdata
+        dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
         initial_data = []
 
         if dnssecdata is not None:
@@ -344,8 +379,7 @@ def get_context_data(self, **kwargs):
         # set the dnssec_ds_confirmed flag in the context for this view
         # based either on the existence of DS Data in the domain,
         # or on the flag stored in the session
-        domain = self.get_object()
-        dnssecdata: extensions.DNSSECExtension = domain.dnssecdata
+        dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
 
         if dnssecdata is not None and dnssecdata.dsData is not None:
             self.request.session["dnssec_ds_confirmed"] = True
@@ -357,7 +391,7 @@ def get_context_data(self, **kwargs):
 
     def post(self, request, *args, **kwargs):
         """Formset submission posts to this view."""
-        self.object = self.get_object()
+        self._get_domain(request)
         formset = self.get_form()
 
         if "confirm-ds" in request.POST:
@@ -397,9 +431,8 @@ def form_valid(self, formset):
                 # as valid; this can happen if form has been added but
                 # not been interacted with; in that case, want to ignore
                 pass
-        domain = self.get_object()
         try:
-            domain.dnssecdata = dnssecdata
+            self.object.dnssecdata = dnssecdata
         except RegistryError as err:
             errmsg = "Error updating DNSSEC data in the registry."
             logger.error(errmsg)
@@ -414,7 +447,7 @@ def form_valid(self, formset):
             return super().form_valid(formset)
 
 
-class DomainKeyDataView(DomainPermissionView, FormMixin):
+class DomainKeyDataView(DomainFormBaseView):
     """Domain DNSSEC key data editing view."""
 
     template_name = "domain_keydata.html"
@@ -423,8 +456,7 @@ class DomainKeyDataView(DomainPermissionView, FormMixin):
 
     def get_initial(self):
         """The initial value for the form (which is a formset here)."""
-        domain = self.get_object()
-        dnssecdata: extensions.DNSSECExtension = domain.dnssecdata
+        dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
         initial_data = []
 
         if dnssecdata is not None:
@@ -465,8 +497,7 @@ def get_context_data(self, **kwargs):
         # set the dnssec_key_confirmed flag in the context for this view
         # based either on the existence of Key Data in the domain,
         # or on the flag stored in the session
-        domain = self.get_object()
-        dnssecdata: extensions.DNSSECExtension = domain.dnssecdata
+        dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
 
         if dnssecdata is not None and dnssecdata.keyData is not None:
             self.request.session["dnssec_key_confirmed"] = True
@@ -478,7 +509,7 @@ def get_context_data(self, **kwargs):
 
     def post(self, request, *args, **kwargs):
         """Formset submission posts to this view."""
-        self.object = self.get_object()
+        self._get_domain(request)
         formset = self.get_form()
 
         if "confirm-key" in request.POST:
@@ -517,9 +548,8 @@ def form_valid(self, formset):
             except KeyError:
                 # no server information in this field, skip it
                 pass
-        domain = self.get_object()
         try:
-            domain.dnssecdata = dnssecdata
+            self.object.dnssecdata = dnssecdata
         except RegistryError as err:
             errmsg = "Error updating DNSSEC data in the registry."
             logger.error(errmsg)
@@ -534,7 +564,7 @@ def form_valid(self, formset):
             return super().form_valid(formset)
 
 
-class DomainYourContactInformationView(DomainPermissionView, FormMixin):
+class DomainYourContactInformationView(DomainFormBaseView):
     """Domain your contact information editing view."""
 
     template_name = "domain_your_contact_information.html"
@@ -550,16 +580,6 @@ def get_success_url(self):
         """Redirect to the your contact information for the domain."""
         return reverse("domain-your-contact-information", kwargs={"pk": self.object.pk})
 
-    def post(self, request, *args, **kwargs):
-        """Form submission posts to this view."""
-        self.object = self.get_object()
-        form = self.get_form()
-        if form.is_valid():
-            # there is a valid email address in the form
-            return self.form_valid(form)
-        else:
-            return self.form_invalid(form)
-
     def form_valid(self, form):
         """The form is valid, call setter in model."""
 
@@ -574,7 +594,7 @@ def form_valid(self, form):
         return super().form_valid(form)
 
 
-class DomainSecurityEmailView(DomainPermissionView, FormMixin):
+class DomainSecurityEmailView(DomainFormBaseView):
     """Domain security email editing view."""
 
     template_name = "domain_security_email.html"
@@ -582,9 +602,8 @@ class DomainSecurityEmailView(DomainPermissionView, FormMixin):
 
     def get_initial(self):
         """The initial value for the form."""
-        domain = self.get_object()
         initial = super().get_initial()
-        security_contact = domain.security_contact
+        security_contact = self.object.security_contact
         if security_contact is None or security_contact.email == "dotgov@cisa.dhs.gov":
             initial["security_email"] = None
             return initial
@@ -595,16 +614,6 @@ def get_success_url(self):
         """Redirect to the security email page for the domain."""
         return reverse("domain-security-email", kwargs={"pk": self.object.pk})
 
-    def post(self, request, *args, **kwargs):
-        """Form submission posts to this view."""
-        self.object = self.get_object()
-        form = self.get_form()
-        if form.is_valid():
-            # there is a valid email address in the form
-            return self.form_valid(form)
-        else:
-            return self.form_invalid(form)
-
     def form_valid(self, form):
         """The form is valid, call setter in model."""
 
@@ -615,8 +624,7 @@ def form_valid(self, form):
         if new_email is None or new_email.strip() == "":
             new_email = PublicContact.get_default_security().email
 
-        domain = self.get_object()
-        contact = domain.security_contact
+        contact = self.object.security_contact
 
         # If no default is created for security_contact,
         # then we cannot connect to the registry.
@@ -647,13 +655,13 @@ def form_valid(self, form):
         return redirect(self.get_success_url())
 
 
-class DomainUsersView(DomainPermissionView):
+class DomainUsersView(DomainBaseView):
     """User management page in the domain details."""
 
     template_name = "domain_users.html"
 
 
-class DomainAddUserView(DomainPermissionView, FormMixin):
+class DomainAddUserView(DomainFormBaseView):
     """Inside of a domain's user management, a form for adding users.
 
     Multiple inheritance is used here for permissions, form handling, and
@@ -666,15 +674,6 @@ class DomainAddUserView(DomainPermissionView, FormMixin):
     def get_success_url(self):
         return reverse("domain-users", kwargs={"pk": self.object.pk})
 
-    def post(self, request, *args, **kwargs):
-        self.object = self.get_object()
-        form = self.get_form()
-        if form.is_valid():
-            # there is a valid email address in the form
-            return self.form_valid(form)
-        else:
-            return self.form_invalid(form)
-
     def _domain_abs_url(self):
         """Get an absolute URL for this domain."""
         return self.request.build_absolute_uri(


Test patch: