Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 142 additions & 2 deletions layers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ def export_theme_details(self, request, queryset):


class ThemeAdmin(ImportExportMixin,admin.ModelAdmin):
fixture_import_session_key = "layers.theme_fixture_import_rows"
list_display = ('display_name', 'name', 'order', 'date_modified', 'is_top_theme', 'primary_site', 'preview_site')
search_fields = ['display_name', 'name',]
form = ThemeForm
Expand Down Expand Up @@ -308,6 +309,144 @@ class Media:
js = ['theme_admin.js',]

change_form_template = os.path.join(CURRENT_DIR, 'templates', 'admin', 'layers', 'Theme', 'change_form.html')
change_list_template = os.path.join(CURRENT_DIR, 'templates', 'admin', 'layers', 'Theme', 'change_list.html')

def values_match(self, current_value, new_value):
from datetime import date, datetime
from uuid import UUID

if current_value == new_value:
return True
if isinstance(current_value, UUID):
return current_value == UUID(str(new_value))
if isinstance(current_value, (date, datetime)):
return current_value == type(current_value).fromisoformat(str(new_value))
return False

def _fixture_preview_rows(self, rows):
preview_rows = []
for row in rows:
model_label = row['model']
uuid_value = row['uuid']
existing_record = None
if uuid_value:
model = apps.get_model(model_label)
if any(field.name == 'uuid' for field in model._meta.fields):
manager = getattr(model, 'all_objects', model._base_manager)
existing_record = manager.filter(uuid=uuid_value).first()

changes = []
if existing_record is not None:
for field_name, new_value in row['fields'].items():
current_value = getattr(existing_record, field_name)
if not self.values_match(current_value, new_value):
changes.append({
'name': field_name,
'current_value': current_value,
'new_value': new_value,
})

if existing_record is not None:
action = f'Update existing record: "{existing_record}"'
elif uuid_value:
action = 'Create new record'
else:
action = 'Create or merge relationship record'

preview_rows.append({
'model': model_label,
'uuid': uuid_value,
'action': action,
'fields': row['fields'],
'changes': changes,
})
return preview_rows

def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
'import-fixture/',
self.admin_site.admin_view(self.import_fixture),
name='layers_theme_import_fixture',
),
]
return custom_urls + urls

def import_fixture(self, request):
if not self.has_change_permission(request):
raise PermissionDenied

changelist_url = reverse('admin:layers_theme_changelist')
context = {
**self.admin_site.each_context(request),
'opts': self.model._meta,
'title': 'Import theme fixture',
'changelist_url': changelist_url,
}

if request.method == 'POST' and 'cancel' in request.POST:
request.session.pop(self.fixture_import_session_key, None)
return redirect(changelist_url)

if request.method == 'POST' and 'confirm' in request.POST:
rows = request.session.get(self.fixture_import_session_key)
if rows is None:
context['error'] = 'No validated fixture is available to import.'
return render(request, 'admin/layers/Theme/import_theme_fixture.html', context)

try:
result = import_fixture_rows(
rows,
dry_run=False,
associate_all_sites=True,
missing_ref_policy='error',
duplicate_uuid_policy='error',
)
except ValueError as error:
context['error'] = str(error)
context['fixture_rows'] = rows
context['preview_rows'] = self._fixture_preview_rows(rows)
return render(request, 'admin/layers/Theme/import_theme_fixture.html', context)

request.session.pop(self.fixture_import_session_key, None)
self.message_user(request, 'Imported {} fixture rows.'.format(result['imported']))
return redirect(changelist_url)

if request.method == 'POST':
fixture_file = request.FILES.get('fixture_file')
if fixture_file is None:
context['error'] = 'Choose a fixture JSON file to import.'
return render(request, 'admin/layers/Theme/import_theme_fixture.html', context)

try:
rows = json.loads(fixture_file.read().decode('utf-8'))
if not isinstance(rows, list):
raise ValueError('Fixture JSON must contain a list of rows.')
for row in rows:
validate_node_shape(row)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error:
context['error'] = 'Upload valid JSON fixture data: {}'.format(error)
return render(request, 'admin/layers/Theme/import_theme_fixture.html', context)

try:
result = import_fixture_rows(
rows,
dry_run=True,
associate_all_sites=True,
missing_ref_policy='error',
duplicate_uuid_policy='error',
)
except ValueError as error:
context['error'] = str(error)
return render(request, 'admin/layers/Theme/import_theme_fixture.html', context)

request.session[self.fixture_import_session_key] = rows
context['fixture_rows'] = rows
context['preview_result'] = result
context['preview_rows'] = self._fixture_preview_rows(rows)

return render(request, 'admin/layers/Theme/import_theme_fixture.html', context)


def get_queryset(self, request):
Expand Down Expand Up @@ -946,8 +1085,9 @@ def _fixture_preview_rows(self, rows):
existing_record = None
if uuid_value:
model = apps.get_model(model_label)
manager = getattr(model, 'all_objects', model._base_manager)
existing_record = manager.filter(uuid=uuid_value).first()
if any(field.name == 'uuid' for field in model._meta.fields):
manager = getattr(model, 'all_objects', model._base_manager)
existing_record = manager.filter(uuid=uuid_value).first()

changes = []
if existing_record is not None:
Expand Down
40 changes: 38 additions & 2 deletions layers/fixture_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.db import transaction

Expand All @@ -22,6 +23,8 @@


LAYER_MODEL = "layers.layer"
THEME_MODEL = "layers.theme"
CHILD_ORDER_MODEL = "layers.childorder"
MULTILAYER_ASSOCIATION_MODEL = "layers.multilayerassociation"
MULTILAYER_DIMENSION_MODEL = "layers.multilayerdimension"
MULTILAYER_DIMENSION_VALUE_MODEL = "layers.multilayerdimensionvalue"
Expand Down Expand Up @@ -151,7 +154,7 @@ def _execute_import():
# First pass: upsert UUID-keyed rows that do not require relation remaps.
for row in rows:
model_label = row.get(NODE_MODEL_KEY)
if model_label not in {LAYER_MODEL, ATTRIBUTE_INFO_MODEL, LOOKUP_INFO_MODEL}:
if model_label not in {LAYER_MODEL, THEME_MODEL, ATTRIBUTE_INFO_MODEL, LOOKUP_INFO_MODEL}:
continue

row_uuid = normalize_uuid(row.get(NODE_UUID_KEY))
Expand All @@ -175,6 +178,38 @@ def _execute_import():

if model_label == LAYER_MODEL and associate_all_sites:
row_obj.site.set(Site.objects.all())
elif model_label == THEME_MODEL and associate_all_sites:
row_obj.site.set(Site.objects.all())

# Second pass: resolve theme child-order relations by UUID.
for row in rows:
if row.get(NODE_MODEL_KEY) != CHILD_ORDER_MODEL:
continue

relations = row.get(NODE_RELATIONS_KEY, {})
parent_theme = _resolve_ref_instance(
relations.get("parent_theme"),
missing_ref_policy,
)
content_object = _resolve_ref_instance(
relations.get("content_object"),
missing_ref_policy,
)
ChildOrder = apps.get_model(CHILD_ORDER_MODEL)
content_type = ContentType.objects.get_for_model(content_object)
child_order = ChildOrder.objects.filter(
parent_theme=parent_theme,
content_type=content_type,
object_id=content_object.pk,
).first()
if child_order is None:
child_order = ChildOrder(
parent_theme=parent_theme,
content_type=content_type,
object_id=content_object.pk,
)
_apply_fields(child_order, row.get(NODE_FIELDS_KEY, {}))
child_order.save()

# Second pass: resolve layer m2m attribute refs by UUID.
for row in rows:
Expand Down Expand Up @@ -347,7 +382,8 @@ def _execute_import():
"dry_run": True,
}

_execute_import()
with transaction.atomic():
_execute_import()
return {
"imported": len(rows),
"dry_run": False,
Expand Down
8 changes: 8 additions & 0 deletions layers/templates/admin/layers/Theme/change_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% extends "admin/change_list.html" %}

{% block object-tools-items %}
<li>
<a href="{% url 'admin:layers_theme_import_fixture' %}">Import theme fixture</a>
</li>
{{ block.super }}
{% endblock %}
52 changes: 52 additions & 0 deletions layers/templates/admin/layers/Theme/import_theme_fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% extends "admin/base_site.html" %}

{% block content %}
<h1>{{ title }}</h1>

{% if error %}
<p class="errornote">{{ error }}</p>
{% endif %}

{% if preview_rows %}
<h2>Preview</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>UUID</th>
<th>Action</th>
<th>Name</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
{% for row in preview_rows %}
<tr>
<td>{{ row.model }}</td>
<td>{{ row.uuid }}</td>
<td>{{ row.action }}</td>
<td>{{ row.fields.name }}</td>
<td>
{% for change in row.changes %}
{{ change.name }}: {{ change.current_value }} -> {{ change.new_value }}{% if not forloop.last %}; {% endif %}
{% empty %}
None
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% if preview_rows %}
<button type="submit" name="confirm" value="1">Confirm</button>
<button type="submit" name="cancel" value="1">Cancel</button>
{% else %}
<p><input type="file" name="fixture_file" accept="application/json" required></p>
<button type="submit">Preview</button>
{% endif %}
</form>
{% endblock %}
Loading