From c13e63ec63977e3c2404271d145f4a9643b583ec Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Mon, 22 Apr 2024 09:49:35 -0400
Subject: [PATCH 01/22] base import export code in admin
---
src/Pipfile | 1 +
src/registrar/admin.py | 52 +++++++++++++++++++++++++++++---
src/registrar/config/settings.py | 2 ++
3 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/src/Pipfile b/src/Pipfile
index 9366423f1a..0c0e845003 100644
--- a/src/Pipfile
+++ b/src/Pipfile
@@ -32,6 +32,7 @@ fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"}
pyzipper="*"
tblib = "*"
django-admin-multiple-choice-list-filter = "*"
+django-import-export = "*"
[dev-packages]
django-debug-toolbar = "*"
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 31f031456c..7fbf4dff4a 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -28,12 +28,20 @@
from django.utils.html import escape
from django.contrib.auth.forms import UserChangeForm, UsernameField
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
+from import_export import resources
+from import_export.admin import ImportExportModelAdmin
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
+class UserResource(resources.ModelResource):
+
+ class Meta:
+ model = models.User
+
+
class MyUserAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs.
@@ -468,9 +476,11 @@ class UserContactInline(admin.StackedInline):
model = models.Contact
-class MyUserAdmin(BaseUserAdmin):
+class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"""Custom user admin class to use our inlines."""
+ resource_classes = [UserResource]
+
form = MyUserAdminForm
class Meta:
@@ -848,9 +858,17 @@ def response_change(self, request, obj):
return response
-class UserDomainRoleAdmin(ListHeaderAdmin):
+class UserDomainRoleResource(resources.ModelResource):
+
+ class Meta:
+ model = models.UserDomainRole
+
+
+class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom user domain role admin class."""
+ resource_classes = [UserDomainRoleResource]
+
class Meta:
"""Contains meta information about this class"""
@@ -931,9 +949,17 @@ class Meta:
change_form_template = "django/admin/email_clipboard_change_form.html"
-class DomainInformationAdmin(ListHeaderAdmin):
+class DomainInformationResource(resources.ModelResource):
+
+ class Meta:
+ model = models.DomainInformation
+
+
+class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Customize domain information admin class."""
+ resource_classes = [DomainInformationResource]
+
form = DomainInformationAdminForm
# Columns
@@ -1060,9 +1086,17 @@ def get_readonly_fields(self, request, obj=None):
return readonly_fields # Read-only fields for analysts
-class DomainRequestAdmin(ListHeaderAdmin):
+class DomainRequestResource(resources.ModelResource):
+
+ class Meta:
+ model = models.DomainRequest
+
+
+class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom domain requests admin class."""
+ resource_classes = [DomainRequestResource]
+
form = DomainRequestAdminForm
change_form_template = "django/admin/domain_request_change_form.html"
@@ -1590,9 +1624,17 @@ def get_readonly_fields(self, request, obj=None):
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
-class DomainAdmin(ListHeaderAdmin):
+class DomainResource(resources.ModelResource):
+
+ class Meta:
+ model = models.Domain
+
+
+class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom domain admin class to add extra buttons."""
+ resource_classes = [DomainResource]
+
class ElectionOfficeFilter(admin.SimpleListFilter):
"""Define a custom filter for is_election_board"""
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 54b65e83e2..62e6768390 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -148,6 +148,8 @@
"corsheaders",
# library for multiple choice filters in django admin
"django_admin_multiple_choice_list_filter",
+ # library for export and import of data
+ 'import_export',
]
# Middleware are routines for processing web requests.
From 2514caa3d838afa0ed785730fc5409f1d7ac2ec6 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Mon, 22 Apr 2024 10:35:40 -0400
Subject: [PATCH 02/22] merged freezing of django-fsm
---
src/Pipfile.lock | 133 ++++++++++++++++++++++++++++++++++++++++++-
src/requirements.txt | 10 ++++
2 files changed, 141 insertions(+), 2 deletions(-)
diff --git a/src/Pipfile.lock b/src/Pipfile.lock
index 5940f455ed..e485a5202a 100644
--- a/src/Pipfile.lock
+++ b/src/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "ce10883aef7e1ce10421d99b3ac35ebf419857a3fe468f0e2d93785f4323eaa8"
+ "sha256": "99cf9a4f3912639c02105889046a9eede7a29822fab6f9a04ca25f95e29513c0"
},
"pipfile-spec": 6,
"requires": {},
@@ -272,6 +272,14 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.7.1"
},
+ "diff-match-patch": {
+ "hashes": [
+ "sha256:953019cdb9c9d2c9e47b5b12bcff3cf4746fc4598eb406076fa1fc27e6a1f15c",
+ "sha256:dce43505fb7b1b317de7195579388df0746d90db07015ed47a85e5e44930ef93"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==20230430"
+ },
"dj-database-url": {
"hashes": [
"sha256:04bc34b248d4c21aaa13e4ab419ae6575ef5f10f3df735ce7da97722caa356e0",
@@ -352,6 +360,15 @@
"index": "pypi",
"version": "==2.8.1"
},
+ "django-import-export": {
+ "hashes": [
+ "sha256:2eac09e8cec8670f36e24314760448011ad23c51e8fb930d55f50d0c3c926da0",
+ "sha256:4deabc557801d368093608c86fd0f4831bc9540e2ea41ca2f023e2efb3eb6f48"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==3.3.8"
+ },
"django-login-required-middleware": {
"hashes": [
"sha256:847ae9a69fd7a07618ed53192b3c06946af70a0caf6d0f4eb40a8f37593cd970"
@@ -390,6 +407,14 @@
"markers": "python_version >= '3.8'",
"version": "==11.0.0"
},
+ "et-xmlfile": {
+ "hashes": [
+ "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c",
+ "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==1.1.0"
+ },
"faker": {
"hashes": [
"sha256:34b947581c2bced340c39b35f89dbfac4f356932cfff8fe893bde854903f0e6e",
@@ -725,6 +750,12 @@
"markers": "python_version >= '3.8'",
"version": "==1.3.3"
},
+ "markuppy": {
+ "hashes": [
+ "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f"
+ ],
+ "version": "==1.14"
+ },
"markupsafe": {
"hashes": [
"sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf",
@@ -799,6 +830,13 @@
"markers": "python_version >= '3.8'",
"version": "==3.21.1"
},
+ "odfpy": {
+ "hashes": [
+ "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec",
+ "sha256:fc3b8d1bc098eba4a0fda865a76d9d1e577c4ceec771426bcb169a82c5e9dfe0"
+ ],
+ "version": "==1.4.1"
+ },
"oic": {
"hashes": [
"sha256:385a1f64bb59519df1e23840530921bf416740240f505ea6d161e331d3d39fad",
@@ -808,6 +846,13 @@
"markers": "python_version ~= '3.7'",
"version": "==1.6.1"
},
+ "openpyxl": {
+ "hashes": [
+ "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184",
+ "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5"
+ ],
+ "version": "==3.1.2"
+ },
"orderedmultidict": {
"hashes": [
"sha256:04070bbb5e87291cc9bfa51df413677faf2141c73c61d2a5f7b26bea3cd882ad",
@@ -1080,6 +1125,62 @@
"markers": "python_version >= '3.8'",
"version": "==1.0.1"
},
+ "pyyaml": {
+ "hashes": [
+ "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5",
+ "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc",
+ "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df",
+ "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741",
+ "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206",
+ "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27",
+ "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595",
+ "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62",
+ "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98",
+ "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696",
+ "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290",
+ "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9",
+ "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d",
+ "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6",
+ "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867",
+ "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47",
+ "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486",
+ "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6",
+ "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3",
+ "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007",
+ "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938",
+ "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0",
+ "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c",
+ "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735",
+ "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d",
+ "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28",
+ "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4",
+ "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba",
+ "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8",
+ "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef",
+ "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5",
+ "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd",
+ "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3",
+ "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0",
+ "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515",
+ "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c",
+ "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c",
+ "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924",
+ "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34",
+ "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43",
+ "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859",
+ "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673",
+ "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54",
+ "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a",
+ "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b",
+ "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab",
+ "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa",
+ "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c",
+ "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585",
+ "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d",
+ "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"
+ ],
+ "version": "==6.0.1"
+ },
"pyzipper": {
"hashes": [
"sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc",
@@ -1130,6 +1231,21 @@
"markers": "python_version >= '3.8'",
"version": "==0.5.0"
},
+ "tablib": {
+ "extras": [
+ "html",
+ "ods",
+ "xls",
+ "xlsx",
+ "yaml"
+ ],
+ "hashes": [
+ "sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9",
+ "sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33"
+ ],
+ "markers": "python_version >= '3.8'",
+ "version": "==3.5.0"
+ },
"tblib": {
"hashes": [
"sha256:80a6c77e59b55e83911e1e607c649836a69c103963c5f28a46cbeef44acf8129",
@@ -1165,6 +1281,20 @@
"markers": "python_version >= '3.8'",
"version": "==6.6.0"
},
+ "xlrd": {
+ "hashes": [
+ "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd",
+ "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88"
+ ],
+ "version": "==2.0.1"
+ },
+ "xlwt": {
+ "hashes": [
+ "sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e",
+ "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88"
+ ],
+ "version": "==1.3.0"
+ },
"zope.event": {
"hashes": [
"sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26",
@@ -1589,7 +1719,6 @@
"sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d",
"sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"
],
- "markers": "python_version >= '3.6'",
"version": "==6.0.1"
},
"rich": {
diff --git a/src/requirements.txt b/src/requirements.txt
index 3c005f1622..067a174e9e 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -10,6 +10,7 @@ cffi==1.16.0; platform_python_implementation != 'PyPy'
charset-normalizer==3.3.2; python_full_version >= '3.7.0'
cryptography==42.0.5; python_version >= '3.7'
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
+diff-match-patch==20230430; python_version >= '3.7'
dj-database-url==2.1.0
dj-email-url==1.0.6
django==4.2.10; python_version >= '3.8'
@@ -20,10 +21,12 @@ django-cache-url==3.4.5
django-cors-headers==4.3.1; python_version >= '3.8'
django-csp==3.8
django-fsm==2.8.1
+django-import-export==3.3.8; python_version >= '3.8'
django-login-required-middleware==0.9.0
django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8'
django-widget-tweaks==1.5.0; python_version >= '3.8'
environs[django]==11.0.0; python_version >= '3.8'
+et-xmlfile==1.1.0; python_version >= '3.6'
faker==24.11.0; python_version >= '3.8'
fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
furl==2.1.3
@@ -35,9 +38,12 @@ idna==3.7; python_version >= '3.5'
jmespath==1.0.1; python_version >= '3.7'
lxml==5.2.1; python_version >= '3.6'
mako==1.3.3; python_version >= '3.8'
+markuppy==1.14
markupsafe==2.1.5; python_version >= '3.7'
marshmallow==3.21.1; python_version >= '3.8'
+odfpy==1.4.1
oic==1.6.1; python_version ~= '3.7'
+openpyxl==3.1.2
orderedmultidict==1.0.1
packaging==24.0; python_version >= '3.7'
phonenumberslite==8.13.35
@@ -50,15 +56,19 @@ pydantic-settings==2.2.1; python_version >= '3.8'
pyjwkest==1.4.2
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
python-dotenv==1.0.1; python_version >= '3.8'
+pyyaml==6.0.1
pyzipper==0.3.6; python_version >= '3.4'
requests==2.31.0; python_version >= '3.7'
s3transfer==0.10.1; python_version >= '3.8'
setuptools==69.5.1; python_version >= '3.8'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sqlparse==0.5.0; python_version >= '3.8'
+tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'
tblib==3.0.0; python_version >= '3.8'
typing-extensions==4.11.0; python_version >= '3.8'
urllib3==2.2.1; python_version >= '3.8'
whitenoise==6.6.0; python_version >= '3.8'
+xlrd==2.0.1
+xlwt==1.3.0
zope.event==5.0; python_version >= '3.7'
zope.interface==6.3; python_version >= '3.7'
From 18658d027b2c885d92a8965a008501cf76dbb497 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 2 May 2024 08:29:38 -0400
Subject: [PATCH 03/22] wip, hardcoded with lots of debugging, needs to be
generalized
---
src/registrar/admin.py | 313 ++++++++++++++++++++++++++++++++++++++++-
1 file changed, 311 insertions(+), 2 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 40cecc3670..3219be625c 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1,17 +1,21 @@
from datetime import date
import logging
import copy
+import traceback
+import warnings
from django import forms
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
+from django.db.transaction import TransactionManagementError
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
-from django_fsm import get_available_FIELD_transitions
+from django_fsm import get_available_FIELD_transitions, FSMField
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError
@@ -28,7 +32,8 @@
from django.utils.html import escape
from django.contrib.auth.forms import UserChangeForm, UsernameField
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
-from import_export import resources
+from import_export import resources, widgets
+from import_export.results import RowResult
from import_export.admin import ImportExportModelAdmin
from django.utils.translation import gettext_lazy as _
@@ -40,6 +45,42 @@ class UserResource(resources.ModelResource):
class Meta:
model = models.User
+ import_id_fields = ('id', 'username',)
+
+ def import_data(
+ self,
+ dataset,
+ dry_run=False,
+ raise_errors=False,
+ use_transactions=None,
+ collect_failed_rows=False,
+ rollback_on_validation_errors=False,
+ **kwargs
+ ):
+ logger.info("in import_data")
+ logger.info(dataset)
+ return super().import_data(dataset,dry_run,raise_errors,use_transactions,collect_failed_rows,rollback_on_validation_errors,**kwargs)
+
+
+ def before_import(self, dataset, using_transactions, dry_run, **kwargs):
+ logger.info("in before_import")
+
+ def import_row(
+ self,
+ row,
+ instance_loader,
+ using_transactions=True,
+ dry_run=False,
+ raise_errors=None,
+ **kwargs
+ ):
+ logger.info("in import_row")
+ logger.info(row)
+ return super().import_row(row,instance_loader,using_transactions,dry_run,raise_errors,**kwargs)
+
+ def after_import_row(self, row, row_result, **kwargs):
+ logger.info(row_result.original)
+ logger.info(row_result.instance)
class MyUserAdminForm(UserChangeForm):
@@ -1630,8 +1671,276 @@ def get_readonly_fields(self, request, obj=None):
class DomainResource(resources.ModelResource):
+ WIDGETS_MAP = {
+ "ManyToManyField": "get_m2m_widget",
+ "OneToOneField": "get_fk_widget",
+ "ForeignKey": "get_fk_widget",
+ "CharField": widgets.CharWidget,
+ "DecimalField": widgets.DecimalWidget,
+ "DateTimeField": widgets.DateTimeWidget,
+ "DateField": widgets.DateWidget,
+ "TimeField": widgets.TimeWidget,
+ "DurationField": widgets.DurationWidget,
+ "FloatField": widgets.FloatWidget,
+ "IntegerField": widgets.IntegerWidget,
+ "PositiveIntegerField": widgets.IntegerWidget,
+ "BigIntegerField": widgets.IntegerWidget,
+ "PositiveSmallIntegerField": widgets.IntegerWidget,
+ "SmallIntegerField": widgets.IntegerWidget,
+ "SmallAutoField": widgets.IntegerWidget,
+ "AutoField": widgets.IntegerWidget,
+ "BigAutoField": widgets.IntegerWidget,
+ "NullBooleanField": widgets.BooleanWidget,
+ "BooleanField": widgets.BooleanWidget,
+ "JSONField": widgets.JSONWidget,
+ }
+
class Meta:
model = models.Domain
+ # exclude = ('state')
+
+
+ def import_row(
+ self,
+ row,
+ instance_loader,
+ using_transactions=True,
+ dry_run=False,
+ raise_errors=None,
+ **kwargs
+ ):
+ """
+ Imports data from ``tablib.Dataset``. Refer to :doc:`import_workflow`
+ for a more complete description of the whole import process.
+
+ :param row: A ``dict`` of the row to import
+
+ :param instance_loader: The instance loader to be used to load the row
+
+ :param using_transactions: If ``using_transactions`` is set, a transaction
+ is being used to wrap the import
+
+ :param dry_run: If ``dry_run`` is set, or error occurs, transaction
+ will be rolled back.
+ """
+ if raise_errors is not None:
+ warnings.warn(
+ "raise_errors argument is deprecated and "
+ "will be removed in a future release.",
+ category=DeprecationWarning,
+ )
+
+ logger.info("in import_row")
+ skip_diff = self._meta.skip_diff
+ row_result = self.get_row_result_class()()
+ if self._meta.store_row_values:
+ row_result.row_values = row
+ original = None
+ try:
+ self.before_import_row(row, **kwargs)
+ logger.info("after before_import_row")
+ instance, new = self.get_or_init_instance(instance_loader, row)
+ logger.info("after get_or_init_instance")
+ logger.info(type(instance))
+ for f in sorted(instance._meta.fields):
+ if callable(getattr(f, "get_internal_type", None)):
+ internal_type = f.get_internal_type()
+ logger.info(f"{f.name} {internal_type} {type(f)}")
+ logger.info(f"type == fsmfield: {isinstance(f, FSMField)}")
+ for attr_name, attr_value in vars(instance).items():
+ logger.info(f"{attr_name}: {attr_value}")
+ if isinstance(attr_value, FSMField):
+ logger.info(f"FSMField: {attr_name}: {attr_value}")
+ self.after_import_instance(instance, new, **kwargs)
+ if new:
+ row_result.import_type = RowResult.IMPORT_TYPE_NEW
+ else:
+ row_result.import_type = RowResult.IMPORT_TYPE_UPDATE
+ row_result.new_record = new
+ if not skip_diff:
+ original = copy.deepcopy(instance)
+ diff = self.get_diff_class()(self, original, new)
+ if self.for_delete(row, instance):
+ if new:
+ row_result.import_type = RowResult.IMPORT_TYPE_SKIP
+ if not skip_diff:
+ diff.compare_with(self, None, dry_run)
+ else:
+ row_result.import_type = RowResult.IMPORT_TYPE_DELETE
+ row_result.add_instance_info(instance)
+ if self._meta.store_instance:
+ row_result.instance = instance
+ self.delete_instance(instance, using_transactions, dry_run)
+ if not skip_diff:
+ diff.compare_with(self, None, dry_run)
+ else:
+ import_validation_errors = {}
+ try:
+ logger.info("about to import_obj")
+ self.import_obj(instance, row, dry_run, **kwargs)
+ logger.info("got past import_obj")
+ except ValidationError as e:
+ # Validation errors from import_obj() are passed on to
+ # validate_instance(), where they can be combined with model
+ # instance validation errors if necessary
+ import_validation_errors = e.update_error_dict(
+ import_validation_errors
+ )
+
+ if self.skip_row(instance, original, row, import_validation_errors):
+ row_result.import_type = RowResult.IMPORT_TYPE_SKIP
+ else:
+ self.validate_instance(instance, import_validation_errors)
+ self.save_instance(instance, new, using_transactions, dry_run)
+ self.save_m2m(instance, row, using_transactions, dry_run)
+ row_result.add_instance_info(instance)
+ if self._meta.store_instance:
+ row_result.instance = instance
+ if not skip_diff:
+ diff.compare_with(self, instance, dry_run)
+ if not new:
+ row_result.original = original
+
+ if not skip_diff and not self._meta.skip_html_diff:
+ row_result.diff = diff.as_html()
+ self.after_import_row(row, row_result, **kwargs)
+
+ except ValidationError as e:
+ row_result.import_type = RowResult.IMPORT_TYPE_INVALID
+ row_result.validation_error = e
+ except Exception as e:
+ row_result.import_type = RowResult.IMPORT_TYPE_ERROR
+ # There is no point logging a transaction error for each row
+ # when only the original error is likely to be relevant
+ if not isinstance(e, TransactionManagementError):
+ logger.debug(e, exc_info=e)
+ tb_info = traceback.format_exc()
+ row_result.errors.append(self.get_error_result_class()(e, tb_info, row))
+
+ return row_result
+
+ # def get_fsm_fields_from_model(self):
+ # logger.info("in get_fsm_fields_from_model")
+ # fsm_fields = []
+ # for f in sorted(self._meta.model._meta.fields):
+ # # if callable(getattr(f, "get_internal_type", None)):
+ # # internal_type = f.get_internal_type()
+ # # logger.info(f"{f.name} {internal_type} {type(f)}")
+ # # logger.info(f"type == fsmfield: {isinstance(f, FSMField)}")
+ # if isinstance(f, FSMField):
+ # fsm_fields.append(f.name)
+ # return fsm_fields
+
+ # def get_fsm_fields_from_row(self, fsm_fields, row=None):
+ # fields_and_values = []
+ # for f in fsm_fields:
+ # if f in row:
+ # fields_and_values.append((f, row[f]))
+ # return fields_and_values
+
+ # def init_instance(self, row=None):
+ # logger.info("initializing instance")
+ # logger.info(f"row: {row}")
+ # #get fields which are fsm fields
+ # fsm_fields = self.get_fsm_fields_from_model()
+ # #then get field values from row and return an array of tuples
+ # fields_and_values = self.get_fsm_fields_from_row(fsm_fields, row)
+ # logger.info(fields_and_values)
+ # #then set those tuples to kwargs
+ # kwargs = dict(fields_and_values)
+ # #then pass kwargs to model()
+ # #return self._meta.model(state='ready')
+ # return self._meta.model(**kwargs)
+ # #return super().init_instance(row)
+
+ def init_instance(self, row=None):
+ logger.info("Initializing instance")
+ logger.info(f"Row: {row}")
+
+ # Get fields which are fsm fields
+ fsm_fields = []
+
+ for f in sorted(self._meta.model._meta.fields):
+ if isinstance(f, FSMField):
+ if row and f.name in row:
+ fsm_fields.append((f.name, row[f.name]))
+
+ logger.info(f"Fsm fields: {fsm_fields}")
+
+ # Convert fields_and_values to kwargs
+ kwargs = dict(fsm_fields)
+
+ # Initialize model instance with kwargs
+ return self._meta.model(**kwargs)
+
+ def get_instance(self, instance_loader, row):
+ """
+ If all 'import_id_fields' are present in the dataset, calls
+ the :doc:`InstanceLoader `. Otherwise,
+ returns `None`.
+ """
+ logger.info("get_instance is called")
+ import_id_fields = [self.fields[f] for f in self.get_import_id_fields()]
+ for field in import_id_fields:
+ if field.column_name not in row:
+ return
+ return instance_loader.get_instance(row)
+
+ def import_data(
+ self,
+ dataset,
+ dry_run=False,
+ raise_errors=False,
+ use_transactions=None,
+ collect_failed_rows=False,
+ rollback_on_validation_errors=False,
+ **kwargs
+ ):
+ logger.info("in import_data")
+ logger.info(dataset)
+ return super().import_data(dataset,dry_run,raise_errors,use_transactions,collect_failed_rows,rollback_on_validation_errors,**kwargs)
+
+
+ def before_import(self, dataset, using_transactions, dry_run, **kwargs):
+ logger.info("in before_import")
+
+ # def import_row(
+ # self,
+ # row,
+ # instance_loader,
+ # using_transactions=True,
+ # dry_run=False,
+ # raise_errors=None,
+ # **kwargs
+ # ):
+ # logger.info("in import_row")
+ # logger.info(row)
+ # return super().import_row(row,instance_loader,using_transactions,dry_run,raise_errors,**kwargs)
+
+ def after_import_row(self, row, row_result, **kwargs):
+ logger.info(row_result.original)
+ logger.info(row_result.instance)
+
+ def import_field(self, field, obj, data, is_m2m=False, **kwargs):
+ logger.info(f"import_field: ${field}")
+ logger.info(field.attribute)
+ logger.info(field.widget)
+ logger.info(type(obj))
+ logger.info(obj._meta.fields)
+ is_fsm = False
+ for f in obj._meta.fields:
+ logger.info(f.name)
+ logger.info(type(f))
+ if field.attribute == f.name and isinstance(f, FSMField):
+ is_fsm = True
+ # if field.attribute in sorted(obj._meta.fields):
+ # logger.info("in fields")
+ # if not isinstance(obj._meta.fields[field.attribute], FSMField):
+ if not is_fsm:
+ logger.info("not fsm field")
+ #if (field.attribute != 'state'):
+ super().import_field(field, obj, data, is_m2m, **kwargs)
+ logger.info("finished importing")
class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
From 011a974a1e39c3365e53fbeb68312d2e472a97bb Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 2 May 2024 13:17:04 -0400
Subject: [PATCH 04/22] abstracted into FsmModelResource
---
src/registrar/admin.py | 316 ++++++-----------------------------------
1 file changed, 46 insertions(+), 270 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 3219be625c..1be5131e1f 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -41,6 +41,50 @@
logger = logging.getLogger(__name__)
+class FsmModelResource(resources.ModelResource):
+
+ def init_instance(self, row=None):
+ logger.info("Initializing instance")
+ logger.info(f"Row: {row}")
+
+ # Get fields which are fsm fields
+ fsm_fields = []
+
+ for f in sorted(self._meta.model._meta.fields):
+ if isinstance(f, FSMField):
+ if row and f.name in row:
+ fsm_fields.append((f.name, row[f.name]))
+
+ logger.info(f"Fsm fields: {fsm_fields}")
+
+ # Convert fields_and_values to kwargs
+ kwargs = dict(fsm_fields)
+
+ # Initialize model instance with kwargs
+ return self._meta.model(**kwargs)
+
+ def import_field(self, field, obj, data, is_m2m=False, **kwargs):
+ # logger.info(f"import_field: ${field}")
+ # logger.info(field.attribute)
+ # logger.info(field.widget)
+ # logger.info(type(obj))
+ # logger.info(obj._meta.fields)
+ is_fsm = False
+ for f in obj._meta.fields:
+ # logger.info(f.name)
+ # logger.info(type(f))
+ if field.attribute == f.name and isinstance(f, FSMField):
+ is_fsm = True
+ # if field.attribute in sorted(obj._meta.fields):
+ # logger.info("in fields")
+ # if not isinstance(obj._meta.fields[field.attribute], FSMField):
+ if not is_fsm:
+ # logger.info("not fsm field")
+ #if (field.attribute != 'state'):
+ super().import_field(field, obj, data, is_m2m, **kwargs)
+ # logger.info("finished importing")
+
+
class UserResource(resources.ModelResource):
class Meta:
@@ -1129,7 +1173,7 @@ def get_readonly_fields(self, request, obj=None):
return readonly_fields # Read-only fields for analysts
-class DomainRequestResource(resources.ModelResource):
+class DomainRequestResource(FsmModelResource):
class Meta:
model = models.DomainRequest
@@ -1669,278 +1713,10 @@ def get_readonly_fields(self, request, obj=None):
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
-class DomainResource(resources.ModelResource):
-
- WIDGETS_MAP = {
- "ManyToManyField": "get_m2m_widget",
- "OneToOneField": "get_fk_widget",
- "ForeignKey": "get_fk_widget",
- "CharField": widgets.CharWidget,
- "DecimalField": widgets.DecimalWidget,
- "DateTimeField": widgets.DateTimeWidget,
- "DateField": widgets.DateWidget,
- "TimeField": widgets.TimeWidget,
- "DurationField": widgets.DurationWidget,
- "FloatField": widgets.FloatWidget,
- "IntegerField": widgets.IntegerWidget,
- "PositiveIntegerField": widgets.IntegerWidget,
- "BigIntegerField": widgets.IntegerWidget,
- "PositiveSmallIntegerField": widgets.IntegerWidget,
- "SmallIntegerField": widgets.IntegerWidget,
- "SmallAutoField": widgets.IntegerWidget,
- "AutoField": widgets.IntegerWidget,
- "BigAutoField": widgets.IntegerWidget,
- "NullBooleanField": widgets.BooleanWidget,
- "BooleanField": widgets.BooleanWidget,
- "JSONField": widgets.JSONWidget,
- }
+class DomainResource(FsmModelResource):
class Meta:
model = models.Domain
- # exclude = ('state')
-
-
- def import_row(
- self,
- row,
- instance_loader,
- using_transactions=True,
- dry_run=False,
- raise_errors=None,
- **kwargs
- ):
- """
- Imports data from ``tablib.Dataset``. Refer to :doc:`import_workflow`
- for a more complete description of the whole import process.
-
- :param row: A ``dict`` of the row to import
-
- :param instance_loader: The instance loader to be used to load the row
-
- :param using_transactions: If ``using_transactions`` is set, a transaction
- is being used to wrap the import
-
- :param dry_run: If ``dry_run`` is set, or error occurs, transaction
- will be rolled back.
- """
- if raise_errors is not None:
- warnings.warn(
- "raise_errors argument is deprecated and "
- "will be removed in a future release.",
- category=DeprecationWarning,
- )
-
- logger.info("in import_row")
- skip_diff = self._meta.skip_diff
- row_result = self.get_row_result_class()()
- if self._meta.store_row_values:
- row_result.row_values = row
- original = None
- try:
- self.before_import_row(row, **kwargs)
- logger.info("after before_import_row")
- instance, new = self.get_or_init_instance(instance_loader, row)
- logger.info("after get_or_init_instance")
- logger.info(type(instance))
- for f in sorted(instance._meta.fields):
- if callable(getattr(f, "get_internal_type", None)):
- internal_type = f.get_internal_type()
- logger.info(f"{f.name} {internal_type} {type(f)}")
- logger.info(f"type == fsmfield: {isinstance(f, FSMField)}")
- for attr_name, attr_value in vars(instance).items():
- logger.info(f"{attr_name}: {attr_value}")
- if isinstance(attr_value, FSMField):
- logger.info(f"FSMField: {attr_name}: {attr_value}")
- self.after_import_instance(instance, new, **kwargs)
- if new:
- row_result.import_type = RowResult.IMPORT_TYPE_NEW
- else:
- row_result.import_type = RowResult.IMPORT_TYPE_UPDATE
- row_result.new_record = new
- if not skip_diff:
- original = copy.deepcopy(instance)
- diff = self.get_diff_class()(self, original, new)
- if self.for_delete(row, instance):
- if new:
- row_result.import_type = RowResult.IMPORT_TYPE_SKIP
- if not skip_diff:
- diff.compare_with(self, None, dry_run)
- else:
- row_result.import_type = RowResult.IMPORT_TYPE_DELETE
- row_result.add_instance_info(instance)
- if self._meta.store_instance:
- row_result.instance = instance
- self.delete_instance(instance, using_transactions, dry_run)
- if not skip_diff:
- diff.compare_with(self, None, dry_run)
- else:
- import_validation_errors = {}
- try:
- logger.info("about to import_obj")
- self.import_obj(instance, row, dry_run, **kwargs)
- logger.info("got past import_obj")
- except ValidationError as e:
- # Validation errors from import_obj() are passed on to
- # validate_instance(), where they can be combined with model
- # instance validation errors if necessary
- import_validation_errors = e.update_error_dict(
- import_validation_errors
- )
-
- if self.skip_row(instance, original, row, import_validation_errors):
- row_result.import_type = RowResult.IMPORT_TYPE_SKIP
- else:
- self.validate_instance(instance, import_validation_errors)
- self.save_instance(instance, new, using_transactions, dry_run)
- self.save_m2m(instance, row, using_transactions, dry_run)
- row_result.add_instance_info(instance)
- if self._meta.store_instance:
- row_result.instance = instance
- if not skip_diff:
- diff.compare_with(self, instance, dry_run)
- if not new:
- row_result.original = original
-
- if not skip_diff and not self._meta.skip_html_diff:
- row_result.diff = diff.as_html()
- self.after_import_row(row, row_result, **kwargs)
-
- except ValidationError as e:
- row_result.import_type = RowResult.IMPORT_TYPE_INVALID
- row_result.validation_error = e
- except Exception as e:
- row_result.import_type = RowResult.IMPORT_TYPE_ERROR
- # There is no point logging a transaction error for each row
- # when only the original error is likely to be relevant
- if not isinstance(e, TransactionManagementError):
- logger.debug(e, exc_info=e)
- tb_info = traceback.format_exc()
- row_result.errors.append(self.get_error_result_class()(e, tb_info, row))
-
- return row_result
-
- # def get_fsm_fields_from_model(self):
- # logger.info("in get_fsm_fields_from_model")
- # fsm_fields = []
- # for f in sorted(self._meta.model._meta.fields):
- # # if callable(getattr(f, "get_internal_type", None)):
- # # internal_type = f.get_internal_type()
- # # logger.info(f"{f.name} {internal_type} {type(f)}")
- # # logger.info(f"type == fsmfield: {isinstance(f, FSMField)}")
- # if isinstance(f, FSMField):
- # fsm_fields.append(f.name)
- # return fsm_fields
-
- # def get_fsm_fields_from_row(self, fsm_fields, row=None):
- # fields_and_values = []
- # for f in fsm_fields:
- # if f in row:
- # fields_and_values.append((f, row[f]))
- # return fields_and_values
-
- # def init_instance(self, row=None):
- # logger.info("initializing instance")
- # logger.info(f"row: {row}")
- # #get fields which are fsm fields
- # fsm_fields = self.get_fsm_fields_from_model()
- # #then get field values from row and return an array of tuples
- # fields_and_values = self.get_fsm_fields_from_row(fsm_fields, row)
- # logger.info(fields_and_values)
- # #then set those tuples to kwargs
- # kwargs = dict(fields_and_values)
- # #then pass kwargs to model()
- # #return self._meta.model(state='ready')
- # return self._meta.model(**kwargs)
- # #return super().init_instance(row)
-
- def init_instance(self, row=None):
- logger.info("Initializing instance")
- logger.info(f"Row: {row}")
-
- # Get fields which are fsm fields
- fsm_fields = []
-
- for f in sorted(self._meta.model._meta.fields):
- if isinstance(f, FSMField):
- if row and f.name in row:
- fsm_fields.append((f.name, row[f.name]))
-
- logger.info(f"Fsm fields: {fsm_fields}")
-
- # Convert fields_and_values to kwargs
- kwargs = dict(fsm_fields)
-
- # Initialize model instance with kwargs
- return self._meta.model(**kwargs)
-
- def get_instance(self, instance_loader, row):
- """
- If all 'import_id_fields' are present in the dataset, calls
- the :doc:`InstanceLoader `. Otherwise,
- returns `None`.
- """
- logger.info("get_instance is called")
- import_id_fields = [self.fields[f] for f in self.get_import_id_fields()]
- for field in import_id_fields:
- if field.column_name not in row:
- return
- return instance_loader.get_instance(row)
-
- def import_data(
- self,
- dataset,
- dry_run=False,
- raise_errors=False,
- use_transactions=None,
- collect_failed_rows=False,
- rollback_on_validation_errors=False,
- **kwargs
- ):
- logger.info("in import_data")
- logger.info(dataset)
- return super().import_data(dataset,dry_run,raise_errors,use_transactions,collect_failed_rows,rollback_on_validation_errors,**kwargs)
-
-
- def before_import(self, dataset, using_transactions, dry_run, **kwargs):
- logger.info("in before_import")
-
- # def import_row(
- # self,
- # row,
- # instance_loader,
- # using_transactions=True,
- # dry_run=False,
- # raise_errors=None,
- # **kwargs
- # ):
- # logger.info("in import_row")
- # logger.info(row)
- # return super().import_row(row,instance_loader,using_transactions,dry_run,raise_errors,**kwargs)
-
- def after_import_row(self, row, row_result, **kwargs):
- logger.info(row_result.original)
- logger.info(row_result.instance)
-
- def import_field(self, field, obj, data, is_m2m=False, **kwargs):
- logger.info(f"import_field: ${field}")
- logger.info(field.attribute)
- logger.info(field.widget)
- logger.info(type(obj))
- logger.info(obj._meta.fields)
- is_fsm = False
- for f in obj._meta.fields:
- logger.info(f.name)
- logger.info(type(f))
- if field.attribute == f.name and isinstance(f, FSMField):
- is_fsm = True
- # if field.attribute in sorted(obj._meta.fields):
- # logger.info("in fields")
- # if not isinstance(obj._meta.fields[field.attribute], FSMField):
- if not is_fsm:
- logger.info("not fsm field")
- #if (field.attribute != 'state'):
- super().import_field(field, obj, data, is_m2m, **kwargs)
- logger.info("finished importing")
class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
From 780504c9f26c96672c920d449d62073bf7225cce Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 3 May 2024 07:14:06 -0400
Subject: [PATCH 05/22] update to allow DomainRequest to import properly
---
src/registrar/admin.py | 24 +-------
.../models/utility/generic_helper.py | 59 ++++++++++---------
2 files changed, 32 insertions(+), 51 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 1be5131e1f..7231c634ee 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1,13 +1,10 @@
from datetime import date
import logging
import copy
-import traceback
-import warnings
from django import forms
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
-from django.db.transaction import TransactionManagementError
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
@@ -15,7 +12,6 @@
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError
@@ -32,8 +28,7 @@
from django.utils.html import escape
from django.contrib.auth.forms import UserChangeForm, UsernameField
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
-from import_export import resources, widgets
-from import_export.results import RowResult
+from import_export import resources
from import_export.admin import ImportExportModelAdmin
from django.utils.translation import gettext_lazy as _
@@ -44,8 +39,6 @@
class FsmModelResource(resources.ModelResource):
def init_instance(self, row=None):
- logger.info("Initializing instance")
- logger.info(f"Row: {row}")
# Get fields which are fsm fields
fsm_fields = []
@@ -55,8 +48,6 @@ def init_instance(self, row=None):
if row and f.name in row:
fsm_fields.append((f.name, row[f.name]))
- logger.info(f"Fsm fields: {fsm_fields}")
-
# Convert fields_and_values to kwargs
kwargs = dict(fsm_fields)
@@ -64,25 +55,12 @@ def init_instance(self, row=None):
return self._meta.model(**kwargs)
def import_field(self, field, obj, data, is_m2m=False, **kwargs):
- # logger.info(f"import_field: ${field}")
- # logger.info(field.attribute)
- # logger.info(field.widget)
- # logger.info(type(obj))
- # logger.info(obj._meta.fields)
is_fsm = False
for f in obj._meta.fields:
- # logger.info(f.name)
- # logger.info(type(f))
if field.attribute == f.name and isinstance(f, FSMField):
is_fsm = True
- # if field.attribute in sorted(obj._meta.fields):
- # logger.info("in fields")
- # if not isinstance(obj._meta.fields[field.attribute], FSMField):
if not is_fsm:
- # logger.info("not fsm field")
- #if (field.attribute != 'state'):
super().import_field(field, obj, data, is_m2m, **kwargs)
- # logger.info("finished importing")
class UserResource(resources.ModelResource):
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
index 892298967a..12ec85e57d 100644
--- a/src/registrar/models/utility/generic_helper.py
+++ b/src/registrar/models/utility/generic_helper.py
@@ -102,34 +102,37 @@ def _handle_new_instance(self):
def _handle_existing_instance(self, force_update_when_no_are_changes_found=False):
# == Init variables == #
- # Instance is already in the database, fetch its current state
- current_instance = self.sender.objects.get(id=self.instance.id)
-
- # Check the new and old values
- generic_org_type_changed = self.instance.generic_org_type != current_instance.generic_org_type
- is_election_board_changed = self.instance.is_election_board != current_instance.is_election_board
- organization_type_changed = self.instance.organization_type != current_instance.organization_type
-
- # == Check for invalid conditions before proceeding == #
- if organization_type_changed and (generic_org_type_changed or is_election_board_changed):
- # Since organization type is linked with generic_org_type and election board,
- # we have to update one or the other, not both.
- # This will not happen in normal flow as it is not possible otherwise.
- raise ValueError("Cannot update organization_type and generic_org_type simultaneously.")
- elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed):
- # No changes found
- if force_update_when_no_are_changes_found:
- # If we want to force an update anyway, we can treat this record like
- # its a new one in that we check for "None" values rather than changes.
- self._handle_new_instance()
- else:
- # == Update the linked values == #
- # Find out which field needs updating
- organization_type_needs_update = generic_org_type_changed or is_election_board_changed
- generic_org_type_needs_update = organization_type_changed
-
- # Update the field
- self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
+ try:
+ # Instance is already in the database, fetch its current state
+ current_instance = self.sender.objects.get(id=self.instance.id)
+
+ # Check the new and old values
+ generic_org_type_changed = self.instance.generic_org_type != current_instance.generic_org_type
+ is_election_board_changed = self.instance.is_election_board != current_instance.is_election_board
+ organization_type_changed = self.instance.organization_type != current_instance.organization_type
+
+ # == Check for invalid conditions before proceeding == #
+ if organization_type_changed and (generic_org_type_changed or is_election_board_changed):
+ # Since organization type is linked with generic_org_type and election board,
+ # we have to update one or the other, not both.
+ # This will not happen in normal flow as it is not possible otherwise.
+ raise ValueError("Cannot update organization_type and generic_org_type simultaneously.")
+ elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed):
+ # No changes found
+ if force_update_when_no_are_changes_found:
+ # If we want to force an update anyway, we can treat this record like
+ # its a new one in that we check for "None" values rather than changes.
+ self._handle_new_instance()
+ else:
+ # == Update the linked values == #
+ # Find out which field needs updating
+ organization_type_needs_update = generic_org_type_changed or is_election_board_changed
+ generic_org_type_needs_update = organization_type_changed
+
+ # Update the field
+ self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
+ except:
+ pass
def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update):
"""
From b5fb28e1568105a0c96b63a2af76de3e19a0eeb2 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 3 May 2024 09:29:20 -0400
Subject: [PATCH 06/22] added Contacts
---
src/registrar/admin.py | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 744babb95a..da630c04ce 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -785,9 +785,17 @@ class MyHostAdmin(AuditedAdmin):
inlines = [HostIPInline]
-class ContactAdmin(ListHeaderAdmin):
+class ContactResource(resources.ModelResource):
+
+ class Meta:
+ model = models.Contact
+
+
+class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom contact admin class to add search."""
+ resource_classes = [ContactResource]
+
search_fields = ["email", "first_name", "last_name"]
search_help_text = "Search by first name, last name or email."
list_display = [
From e32525563ec4dcc3ae71c095f2cca9b1f216780f Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 3 May 2024 16:46:18 -0400
Subject: [PATCH 07/22] commented and reformatted
---
src/registrar/admin.py | 59 +++++++++++---------------------
src/registrar/config/settings.py | 2 +-
2 files changed, 21 insertions(+), 40 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index da630c04ce..89f8391b3c 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -37,26 +37,43 @@
class FsmModelResource(resources.ModelResource):
+ """ModelResource is extended to support importing of tables which
+ have FSMFields. ModelResource is extended with the following changes
+ to existing behavior:
+ When new objects are to be imported, FSMFields are initialized before
+ the object is initialized. This is because FSMFields do not allow
+ direct modification.
+ When objects, which are to be imported, are updated, the FSMFields
+ are skipped."""
def init_instance(self, row=None):
+ """Overrides the init_instance method of ModelResource. Returns
+ an instance of the model, with the FSMFields already initialized
+ from data in the row."""
# Get fields which are fsm fields
fsm_fields = []
- for f in sorted(self._meta.model._meta.fields):
+ for f in self._meta.model._meta.fields:
if isinstance(f, FSMField):
if row and f.name in row:
fsm_fields.append((f.name, row[f.name]))
- # Convert fields_and_values to kwargs
+ # Convert fsm_fields fields_and_values to kwargs
kwargs = dict(fsm_fields)
# Initialize model instance with kwargs
return self._meta.model(**kwargs)
def import_field(self, field, obj, data, is_m2m=False, **kwargs):
+ """Overrides the import_field method of ModelResource. If the
+ field being imported is an FSMField, it is not imported."""
+
is_fsm = False
+
+ # check each field in the object
for f in obj._meta.fields:
+ # if the field is an instance of FSMField
if field.attribute == f.name and isinstance(f, FSMField):
is_fsm = True
if not is_fsm:
@@ -67,42 +84,6 @@ class UserResource(resources.ModelResource):
class Meta:
model = models.User
- import_id_fields = ('id', 'username',)
-
- def import_data(
- self,
- dataset,
- dry_run=False,
- raise_errors=False,
- use_transactions=None,
- collect_failed_rows=False,
- rollback_on_validation_errors=False,
- **kwargs
- ):
- logger.info("in import_data")
- logger.info(dataset)
- return super().import_data(dataset,dry_run,raise_errors,use_transactions,collect_failed_rows,rollback_on_validation_errors,**kwargs)
-
-
- def before_import(self, dataset, using_transactions, dry_run, **kwargs):
- logger.info("in before_import")
-
- def import_row(
- self,
- row,
- instance_loader,
- using_transactions=True,
- dry_run=False,
- raise_errors=None,
- **kwargs
- ):
- logger.info("in import_row")
- logger.info(row)
- return super().import_row(row,instance_loader,using_transactions,dry_run,raise_errors,**kwargs)
-
- def after_import_row(self, row, row_result, **kwargs):
- logger.info(row_result.original)
- logger.info(row_result.instance)
class MyUserAdminForm(UserChangeForm):
@@ -1761,7 +1742,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom domain admin class to add extra buttons."""
resource_classes = [DomainResource]
-
+
class ElectionOfficeFilter(admin.SimpleListFilter):
"""Define a custom filter for is_election_board"""
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 062e2492e8..a18adabde5 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -149,7 +149,7 @@
# library for multiple choice filters in django admin
"django_admin_multiple_choice_list_filter",
# library for export and import of data
- 'import_export',
+ "import_export",
]
# Middleware are routines for processing web requests.
From 80aca78194a6a63785c97580e0a5522f4df7f582 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 3 May 2024 17:16:16 -0400
Subject: [PATCH 08/22] only show import on non_production
---
.../admin/import_export/change_list_import_item.html | 8 ++++++++
1 file changed, 8 insertions(+)
create mode 100644 src/registrar/templates/admin/import_export/change_list_import_item.html
diff --git a/src/registrar/templates/admin/import_export/change_list_import_item.html b/src/registrar/templates/admin/import_export/change_list_import_item.html
new file mode 100644
index 0000000000..b640331cba
--- /dev/null
+++ b/src/registrar/templates/admin/import_export/change_list_import_item.html
@@ -0,0 +1,8 @@
+{% load i18n %}
+{% load admin_urls %}
+
+{% if has_import_permission %}
+ {% if not IS_PRODUCTION %}
+{% trans "Import" %}
+ {% endif %}
+{% endif %}
\ No newline at end of file
From c5748c6bc1bbd896ce63c7b440c398d48aadcbf0 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 3 May 2024 18:04:02 -0400
Subject: [PATCH 09/22] added documentation
---
docs/operations/import_export.md | 57 ++++++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 docs/operations/import_export.md
diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md
new file mode 100644
index 0000000000..3e57e5152e
--- /dev/null
+++ b/docs/operations/import_export.md
@@ -0,0 +1,57 @@
+# Export / Import Tables
+
+A means is provided to export and import individual tables from
+one environment to another. This allows for replication of
+production data in a development environment. Import and export
+are provided through the django admin interface, through a modified
+library, django-import-export. Each supported model has an Import
+and an Export button on the list view.
+
+### Export
+
+When exporting models from the source environment, make sure that
+no filters are selected. This will ensure that all rows of the model
+are exported. Due to database dependencies, the following models
+need to be exported:
+
+* User
+* Contact
+* Domain
+* DomainRequest
+* DomainInformation
+* DomainUserRole
+
+### Import
+
+When importing into the target environment, if the target environment
+is different than the source environment, it must be prepared for the
+import. This involves clearing out rows in the appropriate tables so
+that there are no database conflicts on import.
+
+#### Preparing Target Environment
+
+Delete all rows from tables in the following order through django admin:
+
+* DomainInformation
+* DomainRequest
+* Domain
+* User (all but the current user)
+* Contact
+
+It may not be necessary, but advisable to also remove rows from these tables:
+
+* Websites
+* DraftDomain
+* Host
+
+#### Importing into Target Environment
+
+Once target environment is prepared, files can be imported in the following
+order:
+
+* User
+* Contact
+* Domain
+* DomainRequest
+* DomainInformation
+* UserDomainRole
\ No newline at end of file
From 54c65a6d85ea49c9e9da101f32fc25aaf65f652d Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Mon, 6 May 2024 12:16:09 -0400
Subject: [PATCH 10/22] fixed failing tests, formatted for linter
---
src/registrar/models/utility/generic_helper.py | 2 +-
src/registrar/tests/test_admin.py | 5 ++---
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
index d1d890da4c..7d3586770d 100644
--- a/src/registrar/models/utility/generic_helper.py
+++ b/src/registrar/models/utility/generic_helper.py
@@ -131,7 +131,7 @@ def _handle_existing_instance(self, force_update_when_no_are_changes_found=False
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
- except:
+ except self.sender.DoesNotExist:
pass
def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update):
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index cf00994be9..97e48279dc 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -508,10 +508,9 @@ def test_short_org_name_in_domains_list(self):
domain_request.approve()
response = self.client.get("/admin/registrar/domain/")
-
# There are 4 template references to Federal (4) plus four references in the table
# for our actual domain_request
- self.assertContains(response, "Federal", count=36)
+ self.assertContains(response, "Federal", count=48)
# This may be a bit more robust
self.assertContains(response, 'Federal ', count=1)
# Now let's make sure the long description does not exist
@@ -1267,7 +1266,7 @@ def test_short_org_name_in_domain_requests_list(self):
response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal")
# There are 2 template references to Federal (4) and two in the results data
# of the request
- self.assertContains(response, "Federal", count=34)
+ self.assertContains(response, "Federal", count=46)
# This may be a bit more robust
self.assertContains(response, 'Federal ', count=1)
# Now let's make sure the long description does not exist
From af3877f2e351d2df4b25d8a98b116a1b17582d71 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 07:35:02 -0400
Subject: [PATCH 11/22] unit tests completed
---
src/registrar/tests/test_admin.py | 48 ++++++++++++++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 97e48279dc..d8f4975e88 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -21,6 +21,7 @@
MyHostAdmin,
UserDomainRoleAdmin,
VerifiedByStaffAdmin,
+ FsmModelResource,
)
from registrar.models import (
Domain,
@@ -52,7 +53,7 @@
)
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model
-from unittest.mock import ANY, call, patch
+from unittest.mock import ANY, call, patch, Mock
from unittest import skip
from django.conf import settings
@@ -62,6 +63,42 @@
logger = logging.getLogger(__name__)
+class TestFsmModelResource(TestCase):
+ def setUp(self):
+ self.resource = FsmModelResource()
+
+ def test_init_instance(self):
+ """Test initializing an instance of a class with a FSM field"""
+
+ # Mock a row with FSMField data
+ row_data = {'state': 'ready'}
+
+ self.resource._meta.model = Domain
+
+ instance = self.resource.init_instance(row=row_data)
+
+ # Assert that the instance is initialized correctly
+ self.assertIsInstance(instance, Domain)
+ self.assertEqual(instance.state, 'ready')
+
+ def test_import_field(self):
+ """Test that importing a field does not import FSM field"""
+ # Mock a field and object
+ field_mock = Mock(attribute='state')
+ obj_mock = Mock(_meta=Mock(fields=[Mock(name='state', spec=['name'], __class__=Mock)]))
+
+ # Mock the super() method
+ super_mock = Mock()
+ self.resource.import_field = super_mock
+
+ # Call the method with FSMField and non-FSMField
+ self.resource.import_field(field_mock, obj_mock, data={}, is_m2m=False)
+
+ # Assert that super().import_field() is called only for non-FSMField
+ super_mock.assert_called_once_with(field_mock, obj_mock, data={}, is_m2m=False)
+
+
+
class TestDomainAdmin(MockEppLib, WebTest):
# csrf checks do not work with WebTest.
# We disable them here. TODO for another ticket.
@@ -759,6 +796,15 @@ def test_analyst_deletes_domain_idempotent(self):
def test_place_and_remove_hold_epp(self):
raise
+ @override_settings(IS_PRODUCTION=True)
+ def test_prod_only_shows_export(self):
+ """Test that production environment only displays export"""
+ with less_console_noise():
+ response = self.client.get("/admin/registrar/domain/")
+ self.assertContains(response, ">Export<")
+ # Now let's make sure the long description does not exist
+ self.assertNotContains(response, ">Import<")
+
def tearDown(self):
super().tearDown()
PublicContact.objects.all().delete()
From 2d9f96c6d01b4759a35af31953215dc168466ef9 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 08:10:38 -0400
Subject: [PATCH 12/22] formatted for linter
---
src/registrar/tests/test_admin.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 6dbff5d53e..98ba994d88 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -80,7 +80,7 @@ def test_init_instance(self):
"""Test initializing an instance of a class with a FSM field"""
# Mock a row with FSMField data
- row_data = {'state': 'ready'}
+ row_data = {"state": "ready"}
self.resource._meta.model = Domain
@@ -88,13 +88,13 @@ def test_init_instance(self):
# Assert that the instance is initialized correctly
self.assertIsInstance(instance, Domain)
- self.assertEqual(instance.state, 'ready')
+ self.assertEqual(instance.state, "ready")
def test_import_field(self):
"""Test that importing a field does not import FSM field"""
# Mock a field and object
- field_mock = Mock(attribute='state')
- obj_mock = Mock(_meta=Mock(fields=[Mock(name='state', spec=['name'], __class__=Mock)]))
+ field_mock = Mock(attribute="state")
+ obj_mock = Mock(_meta=Mock(fields=[Mock(name="state", spec=["name"], __class__=Mock)]))
# Mock the super() method
super_mock = Mock()
@@ -107,7 +107,6 @@ def test_import_field(self):
super_mock.assert_called_once_with(field_mock, obj_mock, data={}, is_m2m=False)
-
class TestDomainAdmin(MockEppLib, WebTest):
# csrf checks do not work with WebTest.
# We disable them here. TODO for another ticket.
From aca85227ace3ef0152122e14052505333c70e73c Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 08:18:26 -0400
Subject: [PATCH 13/22] fixed tests
---
src/registrar/tests/test_admin.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 98ba994d88..f901d5ce0e 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -638,7 +638,7 @@ def test_short_org_name_in_domains_list(self):
response = self.client.get("/admin/registrar/domain/")
# There are 4 template references to Federal (4) plus four references in the table
# for our actual domain_request
- self.assertContains(response, "Federal", count=48)
+ self.assertContains(response, "Federal", count=54)
# This may be a bit more robust
self.assertContains(response, 'Federal ', count=1)
# Now let's make sure the long description does not exist
@@ -1420,7 +1420,7 @@ def test_short_org_name_in_domain_requests_list(self):
response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal")
# There are 2 template references to Federal (4) and two in the results data
# of the request
- self.assertContains(response, "Federal", count=46)
+ self.assertContains(response, "Federal", count=52)
# This may be a bit more robust
self.assertContains(response, 'Federal ', count=1)
# Now let's make sure the long description does not exist
From fcef18658cb1335e5d0802aca9bf8016974e469f Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 11:08:46 -0400
Subject: [PATCH 14/22] adding newline to a html template
---
.../templates/admin/import_export/change_list_import_item.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/templates/admin/import_export/change_list_import_item.html b/src/registrar/templates/admin/import_export/change_list_import_item.html
index b640331cba..8255a8ba7c 100644
--- a/src/registrar/templates/admin/import_export/change_list_import_item.html
+++ b/src/registrar/templates/admin/import_export/change_list_import_item.html
@@ -5,4 +5,4 @@
{% if not IS_PRODUCTION %}
{% trans "Import" %}
{% endif %}
-{% endif %}
\ No newline at end of file
+{% endif %}
From 8b1d692457a6ce66d1a5d4f2dac277d04dd4720d Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 12:18:57 -0400
Subject: [PATCH 15/22] updated unit test
---
src/registrar/tests/test_admin.py | 27 +++++++++++++++++----------
1 file changed, 17 insertions(+), 10 deletions(-)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index f901d5ce0e..b14016cf88 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -92,19 +92,27 @@ def test_init_instance(self):
def test_import_field(self):
"""Test that importing a field does not import FSM field"""
- # Mock a field and object
- field_mock = Mock(attribute="state")
- obj_mock = Mock(_meta=Mock(fields=[Mock(name="state", spec=["name"], __class__=Mock)]))
- # Mock the super() method
- super_mock = Mock()
- self.resource.import_field = super_mock
+ # Mock a FSMField and a non-FSM-field
+ fsm_field_mock = Mock(attribute="state", column_name="state")
+ field_mock = Mock(attribute="name", column_name="name")
+ # Mock the data
+ data_mock = {"state": "unknown", "name": "test"}
+ # Define a mock Domain
+ obj = Domain(state=Domain.State.UNKNOWN, name="test")
+
+ # Mock the save() method of fields so that we can test if save is called
+ # save() is only supposed to be called for non FSM fields
+ field_mock.save = Mock()
+ fsm_field_mock.save = Mock()
# Call the method with FSMField and non-FSMField
- self.resource.import_field(field_mock, obj_mock, data={}, is_m2m=False)
+ self.resource.import_field(fsm_field_mock, obj, data=data_mock, is_m2m=False)
+ self.resource.import_field(field_mock, obj, data=data_mock, is_m2m=False)
- # Assert that super().import_field() is called only for non-FSMField
- super_mock.assert_called_once_with(field_mock, obj_mock, data={}, is_m2m=False)
+ # Assert that field.save() in super().import_field() is called only for non-FSMField
+ field_mock.save.assert_called_once()
+ fsm_field_mock.save.assert_not_called()
class TestDomainAdmin(MockEppLib, WebTest):
@@ -893,7 +901,6 @@ def test_prod_only_shows_export(self):
with less_console_noise():
response = self.client.get("/admin/registrar/domain/")
self.assertContains(response, ">Export<")
- # Now let's make sure the long description does not exist
self.assertNotContains(response, ">Import<")
def tearDown(self):
From ab5b0be46641957c2985af3dff9e8296ab844517 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 13:23:30 -0400
Subject: [PATCH 16/22] included DraftDomain, Websites and Host
---
docs/operations/import_export.md | 11 +++++----
src/registrar/admin.py | 41 ++++++++++++++++++++++++--------
2 files changed, 38 insertions(+), 14 deletions(-)
diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md
index 3e57e5152e..1b413809c4 100644
--- a/docs/operations/import_export.md
+++ b/docs/operations/import_export.md
@@ -20,6 +20,9 @@ need to be exported:
* DomainRequest
* DomainInformation
* DomainUserRole
+* DraftDomain
+* Websites
+* Host
### Import
@@ -37,9 +40,6 @@ Delete all rows from tables in the following order through django admin:
* Domain
* User (all but the current user)
* Contact
-
-It may not be necessary, but advisable to also remove rows from these tables:
-
* Websites
* DraftDomain
* Host
@@ -49,8 +49,11 @@ It may not be necessary, but advisable to also remove rows from these tables:
Once target environment is prepared, files can be imported in the following
order:
-* User
+* User (After importing User table, you need to delete all rows from Contact table before importing Contacts)
* Contact
+* Host
+* DraftDomain
+* Websites
* Domain
* DomainRequest
* DomainInformation
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 3562869361..040039e1e5 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -54,18 +54,15 @@ def init_instance(self, row=None):
from data in the row."""
# Get fields which are fsm fields
- fsm_fields = []
+ fsm_fields = {}
for f in self._meta.model._meta.fields:
if isinstance(f, FSMField):
if row and f.name in row:
- fsm_fields.append((f.name, row[f.name]))
+ fsm_fields[f.name] = row[f.name]
- # Convert fsm_fields fields_and_values to kwargs
- kwargs = dict(fsm_fields)
-
- # Initialize model instance with kwargs
- return self._meta.model(**kwargs)
+ # Initialize model instance with fsm_fields
+ return self._meta.model(**fsm_fields)
def import_field(self, field, obj, data, is_m2m=False, **kwargs):
"""Overrides the import_field method of ModelResource. If the
@@ -760,9 +757,17 @@ class HostIPInline(admin.StackedInline):
model = models.HostIP
-class MyHostAdmin(AuditedAdmin):
+class HostResource(resources.ModelResource):
+
+ class Meta:
+ model = models.Host
+
+
+class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
"""Custom host admin class to use our inlines."""
+ resource_classes = [HostResource]
+
search_fields = ["name", "domain__name"]
search_help_text = "Search by domain or host name."
inlines = [HostIPInline]
@@ -899,9 +904,17 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
return super().change_view(request, object_id, form_url, extra_context=extra_context)
-class WebsiteAdmin(ListHeaderAdmin):
+class WebsiteResource(resources.ModelResource):
+
+ class Meta:
+ model = models.Website
+
+
+class WebsiteAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom website admin class."""
+ resource_classes = [WebsiteResource]
+
# Search
search_fields = [
"website",
@@ -2139,9 +2152,17 @@ def has_change_permission(self, request, obj=None):
return super().has_change_permission(request, obj)
-class DraftDomainAdmin(ListHeaderAdmin):
+class DraftDomainResource(resources.ModelResource):
+
+ class Meta:
+ model = models.DraftDomain
+
+
+class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom draft domain admin class."""
+ resource_classes = [DraftDomainResource]
+
search_fields = ["name"]
search_help_text = "Search by draft domain name."
From 5ee316b7afda229eb12d3bc82277e6d0ba65347b Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 13:28:04 -0400
Subject: [PATCH 17/22] reformatted for linter
---
src/registrar/admin.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 040039e1e5..bcda7e0486 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -762,7 +762,7 @@ class HostResource(resources.ModelResource):
class Meta:
model = models.Host
-
+
class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
"""Custom host admin class to use our inlines."""
From 943b14fe010a4a03aa5c31bb86911355969c7221 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Tue, 7 May 2024 13:51:03 -0400
Subject: [PATCH 18/22] updated documentation
---
docs/operations/import_export.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md
index 1b413809c4..897e8a01a8 100644
--- a/docs/operations/import_export.md
+++ b/docs/operations/import_export.md
@@ -51,10 +51,10 @@ order:
* User (After importing User table, you need to delete all rows from Contact table before importing Contacts)
* Contact
+* Domain
* Host
* DraftDomain
* Websites
-* Domain
* DomainRequest
* DomainInformation
* UserDomainRole
\ No newline at end of file
From 55e47d03cddf7c9c6201a9b241a9cbedba57e910 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Thu, 9 May 2024 11:31:11 -0400
Subject: [PATCH 19/22] added HostIP
---
docs/operations/import_export.md | 3 +++
src/registrar/admin.py | 16 ++++++++++++++--
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md
index 897e8a01a8..e04a79cd2b 100644
--- a/docs/operations/import_export.md
+++ b/docs/operations/import_export.md
@@ -23,6 +23,7 @@ need to be exported:
* DraftDomain
* Websites
* Host
+* HostIP
### Import
@@ -42,6 +43,7 @@ Delete all rows from tables in the following order through django admin:
* Contact
* Websites
* DraftDomain
+* HostIP
* Host
#### Importing into Target Environment
@@ -53,6 +55,7 @@ order:
* Contact
* Domain
* Host
+* HostIP
* DraftDomain
* Websites
* DomainRequest
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index bcda7e0486..ae6e02c286 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -773,6 +773,19 @@ class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
inlines = [HostIPInline]
+class HostIpResource(resources.ModelResource):
+
+ class Meta:
+ model = models.HostIP
+
+
+class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin):
+ """Custom host ip admin class"""
+
+ resource_classes = [HostIpResource]
+ model = models.HostIP
+
+
class ContactResource(resources.ModelResource):
class Meta:
@@ -2298,9 +2311,8 @@ class Meta:
admin.site.register(models.Domain, DomainAdmin)
admin.site.register(models.DraftDomain, DraftDomainAdmin)
admin.site.register(models.FederalAgency, FederalAgencyAdmin)
-# Host and HostIP removed from django admin because changes in admin
-# do not propagate to registry and logic not applied
admin.site.register(models.Host, MyHostAdmin)
+admin.site.register(models.HostIP, HostIpAdmin)
admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin)
From 5673889a55e6b11f73ef968010d7f0dab4beac9e Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 10 May 2024 07:21:31 -0400
Subject: [PATCH 20/22] updated templates with css
---
src/registrar/assets/sass/_theme/_admin.scss | 4 +
.../templates/admin/import_export/import.html | 191 ++++++++++++++++++
.../import_export/resource_fields_list.html | 21 ++
3 files changed, 216 insertions(+)
create mode 100644 src/registrar/templates/admin/import_export/import.html
create mode 100644 src/registrar/templates/admin/import_export/resource_fields_list.html
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 9f5ea7a973..c716ad49c2 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -717,6 +717,10 @@ div.dja__model-description{
}
+.import_export_text {
+ color: var(--secondary);
+}
+
.text-underline {
text-decoration: underline !important;
}
diff --git a/src/registrar/templates/admin/import_export/import.html b/src/registrar/templates/admin/import_export/import.html
new file mode 100644
index 0000000000..ef1160a2dd
--- /dev/null
+++ b/src/registrar/templates/admin/import_export/import.html
@@ -0,0 +1,191 @@
+{% extends "admin/import_export/base.html" %}
+{% load i18n %}
+{% load admin_urls %}
+{% load import_export_tags %}
+{% load static %}
+
+{% block extrastyle %}{{ block.super }} {% endblock %}
+
+{% block extrahead %}{{ block.super }}
+
+ {% if confirm_form %}
+ {{ confirm_form.media }}
+ {% else %}
+ {{ form.media }}
+ {% endif %}
+{% endblock %}
+
+{% block breadcrumbs_last %}
+{% trans "Import" %}
+{% endblock %}
+
+{% block content %}
+
+ {% if confirm_form %}
+ {% block confirm_import_form %}
+
+ {% endblock %}
+ {% else %}
+ {% block import_form %}
+
+ {% endblock %}
+ {% endif %}
+
+ {% if result %}
+
+ {% if result.has_errors %}
+ {% block errors %}
+ {% trans "Errors" %}
+
+ {% for error in result.base_errors %}
+
+ {{ error.error }}
+ {{ error.traceback|linebreaks }}
+
+ {% endfor %}
+ {% for line, errors in result.row_errors %}
+ {% for error in errors %}
+
+ {% trans "Line number" %}: {{ line }} - {{ error.error }}
+ {{ error.row.values|join:", " }}
+ {{ error.traceback|linebreaks }}
+
+ {% endfor %}
+ {% endfor %}
+
+ {% endblock %}
+
+ {% elif result.has_validation_errors %}
+
+ {% block validation_errors %}
+ {% trans "Some rows failed to validate" %}
+
+ {% trans "Please correct these errors in your data where possible, then reupload it using the form above." %}
+
+
+
+
+ {% trans "Row" %}
+ {% trans "Errors" %}
+ {% for field in result.diff_headers %}
+ {{ field }}
+ {% endfor %}
+
+
+
+ {% for row in result.invalid_rows %}
+
+ {{ row.number }}
+
+ {{ row.error_count }}
+
+
+ {% for field_name, error_list in row.field_specific_errors.items %}
+
+ {{ field_name }}
+
+ {% for error in error_list %}
+ {{ error }}
+ {% endfor %}
+
+
+ {% endfor %}
+ {% if row.non_field_specific_errors %}
+
+ {% trans "Non field specific" %}
+
+ {% for error in row.non_field_specific_errors %}
+ {{ error }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+ {% for field in row.values %}
+ {{ field }}
+ {% endfor %}
+
+ {% endfor %}
+
+
+ {% endblock %}
+
+ {% else %}
+
+ {% block preview %}
+ {% trans "Preview" %}
+
+
+
+
+
+ {% for field in result.diff_headers %}
+ {{ field }}
+ {% endfor %}
+
+
+ {% for row in result.valid_rows %}
+
+
+ {% if row.import_type == 'new' %}
+ {% trans "New" %}
+ {% elif row.import_type == 'skip' %}
+ {% trans "Skipped" %}
+ {% elif row.import_type == 'delete' %}
+ {% trans "Delete" %}
+ {% elif row.import_type == 'update' %}
+ {% trans "Update" %}
+ {% endif %}
+
+ {% for field in row.diff %}
+ {{ field }}
+ {% endfor %}
+
+ {% endfor %}
+
+ {% endblock %}
+
+ {% endif %}
+
+ {% endif %}
+{% endblock %}
diff --git a/src/registrar/templates/admin/import_export/resource_fields_list.html b/src/registrar/templates/admin/import_export/resource_fields_list.html
new file mode 100644
index 0000000000..3f5483ea7c
--- /dev/null
+++ b/src/registrar/templates/admin/import_export/resource_fields_list.html
@@ -0,0 +1,21 @@
+{% load i18n %}
+{% block fields_help %}
+
+ {% if import_or_export == "export" %}
+ {% trans "This exporter will export the following fields: " %}
+ {% elif import_or_export == "import" %}
+ {% trans "This importer will import the following fields: " %}
+ {% endif %}
+
+ {% if fields_list|length <= 1 %}
+ {{ fields_list.0.1|join:", " }}
+ {% else %}
+
+ {% for resource, fields in fields_list %}
+ {{ resource }}
+ {{ fields|join:", " }}
+ {% endfor %}
+
+ {% endif %}
+
+{% endblock %}
\ No newline at end of file
From 645c85e1476c152e0a636b934eea855801f7544d Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 10 May 2024 07:36:23 -0400
Subject: [PATCH 21/22] updated documentation and code comments
---
docs/operations/import_export.md | 5 ++++-
src/registrar/admin.py | 10 ++++++++++
src/registrar/models/utility/generic_helper.py | 6 ++++--
3 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md
index e04a79cd2b..7c3ee11591 100644
--- a/docs/operations/import_export.md
+++ b/docs/operations/import_export.md
@@ -60,4 +60,7 @@ order:
* Websites
* DomainRequest
* DomainInformation
-* UserDomainRole
\ No newline at end of file
+* UserDomainRole
+
+Optional step:
+* Run fixtures to load fixture users back in
\ No newline at end of file
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index ae6e02c286..49574496de 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -80,6 +80,7 @@ def import_field(self, field, obj, data, is_m2m=False, **kwargs):
class UserResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.User
@@ -758,6 +759,7 @@ class HostIPInline(admin.StackedInline):
class HostResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.Host
@@ -774,6 +776,7 @@ class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
class HostIpResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.HostIP
@@ -787,6 +790,7 @@ class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin):
class ContactResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.Contact
@@ -918,6 +922,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
class WebsiteResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.Website
@@ -976,6 +981,7 @@ def response_change(self, request, obj):
class UserDomainRoleResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.UserDomainRole
@@ -1067,6 +1073,7 @@ class Meta:
class DomainInformationResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.DomainInformation
@@ -1208,6 +1215,7 @@ def get_readonly_fields(self, request, obj=None):
class DomainRequestResource(FsmModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.DomainRequest
@@ -1761,6 +1769,7 @@ def get_readonly_fields(self, request, obj=None):
class DomainResource(FsmModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.Domain
@@ -2166,6 +2175,7 @@ def has_change_permission(self, request, obj=None):
class DraftDomainResource(resources.ModelResource):
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
class Meta:
model = models.DraftDomain
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
index 7d3586770d..0befd66270 100644
--- a/src/registrar/models/utility/generic_helper.py
+++ b/src/registrar/models/utility/generic_helper.py
@@ -100,7 +100,7 @@ def _handle_new_instance(self):
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
- def _handle_existing_instance(self, force_update_when_no_are_changes_found=False):
+ def _handle_existing_instance(self, force_update_when_no_changes_are_found=False):
# == Init variables == #
try:
# Instance is already in the database, fetch its current state
@@ -119,7 +119,7 @@ def _handle_existing_instance(self, force_update_when_no_are_changes_found=False
raise ValueError("Cannot update organization_type and generic_org_type simultaneously.")
elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed):
# No changes found
- if force_update_when_no_are_changes_found:
+ if force_update_when_no_changes_are_found:
# If we want to force an update anyway, we can treat this record like
# its a new one in that we check for "None" values rather than changes.
self._handle_new_instance()
@@ -132,6 +132,8 @@ def _handle_existing_instance(self, force_update_when_no_are_changes_found=False
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
except self.sender.DoesNotExist:
+ # this exception should only be raised when import_export utility attempts to import
+ # a new row and already has an id
pass
def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update):
From b95c70ff83718382ee88113ec09bf4a78008acc1 Mon Sep 17 00:00:00 2001
From: David Kennedy
Date: Fri, 10 May 2024 07:43:49 -0400
Subject: [PATCH 22/22] formatted for readability
---
src/registrar/admin.py | 30 ++++++++++++++++++++----------
1 file changed, 20 insertions(+), 10 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 49574496de..ab281c32f7 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -80,7 +80,8 @@ def import_field(self, field, obj, data, is_m2m=False, **kwargs):
class UserResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.User
@@ -759,7 +760,8 @@ class HostIPInline(admin.StackedInline):
class HostResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.Host
@@ -776,7 +778,8 @@ class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
class HostIpResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.HostIP
@@ -790,7 +793,8 @@ class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin):
class ContactResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.Contact
@@ -922,7 +926,8 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
class WebsiteResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.Website
@@ -981,7 +986,8 @@ def response_change(self, request, obj):
class UserDomainRoleResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.UserDomainRole
@@ -1073,7 +1079,8 @@ class Meta:
class DomainInformationResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.DomainInformation
@@ -1215,7 +1222,8 @@ def get_readonly_fields(self, request, obj=None):
class DomainRequestResource(FsmModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.DomainRequest
@@ -1769,7 +1777,8 @@ def get_readonly_fields(self, request, obj=None):
class DomainResource(FsmModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.Domain
@@ -2175,7 +2184,8 @@ def has_change_permission(self, request, obj=None):
class DraftDomainResource(resources.ModelResource):
- """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file"""
+ """defines how each field in the referenced model should be mapped to the corresponding fields in the
+ import/export file"""
class Meta:
model = models.DraftDomain