v3 with import/export

This commit is contained in:
Flatlogic Bot 2026-04-02 16:25:16 +00:00
parent 376d533341
commit 8443b777b1
7 changed files with 399 additions and 23 deletions

View File

@ -17,18 +17,125 @@
</div> </div>
</div> </div>
<div class="card-surface mb-4"> <div class="row g-4 mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center"> <div class="col-xl-6">
<div> <div class="card-surface h-100">
<span class="eyebrow-pill">Backup</span> <div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<h2 class="h4 mt-3 mb-2">Download a full event backup</h2> <div>
<p class="section-copy mb-0">Export every event into one JSON file so you can keep an offline backup before larger edits or restore the calendar later if needed.</p> <span class="eyebrow-pill">Backup</span>
<h2 class="h4 mt-3 mb-2">Download a full event backup</h2>
<p class="section-copy mb-0">Export every event into one JSON file so you can keep an offline backup before larger edits or restore the calendar later if needed.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary-brand" href="{% url 'event_export' %}">Download backup JSON</a>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card-surface h-100">
<span class="eyebrow-pill">Restore</span>
<h2 class="h4 mt-3 mb-2">Import a previous backup</h2>
<p class="section-copy">Upload an exported JSON file, review the preview, then confirm before anything in the live calendar is replaced.</p>
<form method="post" action="{% url 'event_import_preview' %}" enctype="multipart/form-data" class="row g-3">
{% csrf_token %}
<div class="col-12">
<label class="form-label" for="backupFile">Backup JSON file</label>
<input class="form-control" id="backupFile" type="file" name="backup_file" accept="application/json,.json" required>
<div class="form-text">Uploading only prepares a preview. The actual restore is a separate confirmation step.</div>
</div>
<div class="col-12 d-flex gap-2 flex-wrap align-items-center">
<button class="btn btn-primary-brand" type="submit">Preview restore</button>
<span class="small text-muted">Recommended flow: export a fresh backup first, then test your restore file.</span>
</div>
</form>
</div>
</div>
</div> </div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary-brand" href="{% url 'event_export' %}">Download backup JSON</a> {% if pending_import %}
<div class="card-surface mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-start mb-4">
<div>
<span class="eyebrow-pill">Restore preview</span>
<h2 class="h4 mt-3 mb-2">Ready to replace the current calendar</h2>
<p class="section-copy mb-0">This backup contains {{ pending_import.event_count }} event{{ pending_import.event_count|pluralize }}. If you confirm, Django will replace the current {{ dashboard_count }} live item{{ dashboard_count|pluralize }} in one transaction.</p>
</div>
<form method="post" action="{% url 'event_import_restore' %}" class="d-grid">
{% csrf_token %}
<button class="btn btn-danger" type="submit">Confirm restore and replace events</button>
</form>
</div>
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Backup events</small>
<strong class="d-block fs-4">{{ pending_import.event_count }}</strong>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Published</small>
<strong class="d-block fs-4">{{ pending_import.published_count }}</strong>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Drafts</small>
<strong class="d-block fs-4">{{ pending_import.draft_count }}</strong>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Exported</small>
<strong class="d-block">{{ pending_import.exported_at_display }}</strong>
</div>
</div>
</div>
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<h3 class="h5 mb-3">First events in this backup</h3>
<div class="list-group">
{% for event in pending_import.preview_events %}
<div class="list-group-item border rounded-4 mb-2">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<div>
<strong>{{ event.name }}</strong>
<div class="small text-muted">{{ event.start|date:"M j, Y g:i A" }} {{ event.end|date:"M j, Y g:i A" }}</div>
</div>
<div class="text-md-end small">
<div>{{ event.location|default:"—" }}</div>
<div>{% if event.is_published %}Published{% else %}Draft{% endif %}</div>
</div>
</div>
</div>
{% empty %}
<p class="section-copy mb-0">This backup has no events. Confirming the restore would clear the current calendar.</p>
{% endfor %}
</div>
{% if pending_import.remaining_count %}
<p class="small text-muted mt-2 mb-0">Plus {{ pending_import.remaining_count }} more event{{ pending_import.remaining_count|pluralize }} in the same backup.</p>
{% endif %}
</div>
<div class="col-lg-5">
<h3 class="h5 mb-3">Restore source</h3>
<dl class="row mb-3">
<dt class="col-sm-4">Project</dt>
<dd class="col-sm-8">{{ pending_import.project }}</dd>
<dt class="col-sm-4">Version</dt>
<dd class="col-sm-8">{{ pending_import.version }}</dd>
<dt class="col-sm-4">Action</dt>
<dd class="col-sm-8">Replace all current events</dd>
</dl>
<p class="small text-muted mb-0">Safety note: the restore deletes the current event table contents and recreates them from this uploaded backup inside one database transaction.</p>
</div>
</div>
</div> </div>
</div> {% endif %}
</div>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-xl-5"> <div class="col-xl-5">

View File

@ -2,6 +2,7 @@ from datetime import datetime
import json import json
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -47,6 +48,7 @@ class CalendarViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Manage your public event calendar securely') self.assertContains(response, 'Manage your public event calendar securely')
self.assertContains(response, 'Generate a copy-ready widget snippet') self.assertContains(response, 'Generate a copy-ready widget snippet')
self.assertContains(response, 'Import a previous backup')
def test_embed_page_can_hide_header(self): def test_embed_page_can_hide_header(self):
response = self.client.get(reverse('calendar_embed') + '?header=0') response = self.client.get(reverse('calendar_embed') + '?header=0')
@ -65,6 +67,13 @@ class EventDashboardMutationTests(TestCase):
is_published=True, is_published=True,
) )
def _backup_file(self, payload):
return SimpleUploadedFile(
'events-backup.json',
json.dumps(payload).encode('utf-8'),
content_type='application/json',
)
def test_staff_can_export_event_backup(self): def test_staff_can_export_event_backup(self):
self.client.login(username='staffer', password='pass12345') self.client.login(username='staffer', password='pass12345')
response = self.client.get(reverse('event_export')) response = self.client.get(reverse('event_export'))
@ -77,6 +86,70 @@ class EventDashboardMutationTests(TestCase):
self.assertEqual(payload['events'][0]['slug'], self.event.slug) self.assertEqual(payload['events'][0]['slug'], self.event.slug)
self.assertEqual(payload['events'][0]['name'], self.event.name) self.assertEqual(payload['events'][0]['name'], self.event.name)
def test_staff_can_preview_event_backup_restore(self):
self.client.login(username='staffer', password='pass12345')
payload = {
'version': 1,
'project': 'Roadshow Calendar',
'exported_at': '2026-04-01T10:30:00+00:00',
'event_count': 1,
'events': [
{
'name': 'Recovered Market',
'slug': 'recovered-market-2026-04-20',
'location': 'Harbor Plaza',
'start': '2026-04-20T10:00:00+00:00',
'end': '2026-04-20T14:00:00+00:00',
'event_url': 'https://example.com/recovered',
'summary': 'Recovered from backup',
'is_published': False,
}
],
}
response = self.client.post(reverse('event_import_preview'), {'backup_file': self._backup_file(payload)})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Ready to replace the current calendar')
self.assertContains(response, 'Recovered Market')
self.assertContains(response, 'Confirm restore and replace events')
self.assertIn('event_backup_import_payload', self.client.session)
def test_staff_can_restore_event_backup(self):
self.client.login(username='staffer', password='pass12345')
payload = {
'version': 1,
'project': 'Roadshow Calendar',
'exported_at': '2026-04-01T10:30:00+00:00',
'event_count': 1,
'events': [
{
'name': 'Recovered Market',
'slug': 'recovered-market-2026-04-20',
'location': 'Harbor Plaza',
'start': '2026-04-20T10:00:00+00:00',
'end': '2026-04-20T14:00:00+00:00',
'event_url': 'https://example.com/recovered',
'summary': 'Recovered from backup',
'is_published': False,
}
],
}
preview_response = self.client.post(reverse('event_import_preview'), {'backup_file': self._backup_file(payload)})
self.assertEqual(preview_response.status_code, 200)
response = self.client.post(reverse('event_import_restore'))
self.assertRedirects(response, reverse('event_dashboard'))
self.assertEqual(Event.objects.count(), 1)
restored_event = Event.objects.get()
self.assertEqual(restored_event.name, 'Recovered Market')
self.assertEqual(restored_event.slug, 'recovered-market-2026-04-20')
self.assertEqual(restored_event.location, 'Harbor Plaza')
self.assertFalse(restored_event.is_published)
self.assertFalse(Event.objects.filter(pk=self.event.pk).exists())
self.assertNotIn('event_backup_import_payload', self.client.session)
def test_staff_can_open_edit_page(self): def test_staff_can_open_edit_page(self):
self.client.login(username='staffer', password='pass12345') self.client.login(username='staffer', password='pass12345')
response = self.client.get(reverse('event_edit', args=[self.event.slug])) response = self.client.get(reverse('event_edit', args=[self.event.slug]))

View File

@ -9,6 +9,8 @@ from .views import (
event_dashboard_detail, event_dashboard_detail,
event_delete, event_delete,
event_export, event_export,
event_import_preview,
event_import_restore,
event_detail, event_detail,
event_edit, event_edit,
event_list, event_list,
@ -23,6 +25,8 @@ urlpatterns = [
path('events/<slug:slug>/', event_detail, name='event_detail'), path('events/<slug:slug>/', event_detail, name='event_detail'),
path('dashboard/events/', event_dashboard, name='event_dashboard'), path('dashboard/events/', event_dashboard, name='event_dashboard'),
path('dashboard/events/export/', event_export, name='event_export'), path('dashboard/events/export/', event_export, name='event_export'),
path('dashboard/events/import/preview/', event_import_preview, name='event_import_preview'),
path('dashboard/events/import/restore/', event_import_restore, name='event_import_restore'),
path('dashboard/events/new/', event_create, name='event_create'), path('dashboard/events/new/', event_create, name='event_create'),
path('dashboard/events/<slug:slug>/edit/', event_edit, name='event_edit'), path('dashboard/events/<slug:slug>/edit/', event_edit, name='event_edit'),
path('dashboard/events/<slug:slug>/delete/', event_delete, name='event_delete'), path('dashboard/events/<slug:slug>/delete/', event_delete, name='event_delete'),

View File

@ -5,8 +5,9 @@ import json
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -17,6 +18,7 @@ from .models import Event
PROJECT_NAME = 'Roadshow Calendar' PROJECT_NAME = 'Roadshow Calendar'
DEFAULT_META_DESCRIPTION = 'Track where your business will be each day with a polished public calendar and secure staff-only event management.' DEFAULT_META_DESCRIPTION = 'Track where your business will be each day with a polished public calendar and secure staff-only event management.'
BACKUP_IMPORT_SESSION_KEY = 'event_backup_import_payload'
def _parse_month(month_value: str | None): def _parse_month(month_value: str | None):
@ -141,22 +143,158 @@ def _build_event_backup_payload():
} }
def _parse_backup_datetime(value, label):
if not isinstance(value, str) or not value.strip():
raise ValueError(f'{label} is missing.')
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise ValueError(f'{label} is invalid.') from exc
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
return parsed
def _parse_backup_bool(value, label):
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {'1', 'true', 'yes', 'on'}:
return True
if normalized in {'0', 'false', 'no', 'off'}:
return False
raise ValueError(f'{label} must be true or false.')
def _normalize_event_backup_payload(payload):
if not isinstance(payload, dict):
raise ValueError('Backup file must contain one JSON object.')
if payload.get('version') != 1:
raise ValueError('Only backup version 1 is supported right now.')
raw_events = payload.get('events')
if not isinstance(raw_events, list):
raise ValueError('Backup file is missing a valid events list.')
normalized_events = []
seen_slugs = set()
for index, raw_event in enumerate(raw_events, start=1):
if not isinstance(raw_event, dict):
raise ValueError(f'Event #{index} is invalid.')
name = str(raw_event.get('name') or '').strip()
if not name:
raise ValueError(f'Event #{index} is missing a name.')
slug = str(raw_event.get('slug') or '').strip()
if slug:
if slug in seen_slugs:
raise ValueError(f'Backup contains a duplicate slug: {slug}.')
seen_slugs.add(slug)
start = _parse_backup_datetime(raw_event.get('start'), f'Event #{index} start')
end = _parse_backup_datetime(raw_event.get('end'), f'Event #{index} end')
if end <= start:
raise ValueError(f'Event #{index} ends before it starts.')
normalized_events.append(
{
'name': name,
'slug': slug,
'location': str(raw_event.get('location') or '').strip(),
'start': start,
'end': end,
'event_url': str(raw_event.get('event_url') or '').strip(),
'summary': str(raw_event.get('summary') or '').strip(),
'is_published': _parse_backup_bool(raw_event.get('is_published', True), f'Event #{index} publication flag'),
}
)
declared_count = payload.get('event_count')
if declared_count is not None and declared_count != len(normalized_events):
raise ValueError('Backup event count does not match the number of event records.')
return {
'version': 1,
'project': str(payload.get('project') or PROJECT_NAME),
'exported_at': payload.get('exported_at'),
'event_count': len(normalized_events),
'events': normalized_events,
}
def _format_backup_timestamp(value):
if not value:
return 'Unknown'
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return str(value)
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
return timezone.localtime(parsed).strftime('%b %d, %Y, %I:%M %p %Z')
def _build_event_import_preview(payload):
normalized = _normalize_event_backup_payload(payload)
published_count = sum(1 for event in normalized['events'] if event['is_published'])
preview_events = normalized['events'][:5]
return {
'version': normalized['version'],
'project': normalized['project'],
'event_count': normalized['event_count'],
'published_count': published_count,
'draft_count': normalized['event_count'] - published_count,
'exported_at': normalized['exported_at'],
'exported_at_display': _format_backup_timestamp(normalized['exported_at']),
'preview_events': preview_events,
'remaining_count': max(normalized['event_count'] - len(preview_events), 0),
}
def _get_pending_event_import_preview(request):
payload = request.session.get(BACKUP_IMPORT_SESSION_KEY)
if not payload:
return None
try:
return _build_event_import_preview(payload)
except ValueError:
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
return None
def _build_dashboard_context(request, import_preview=None):
events = Event.objects.all().order_by('start', 'name')
return _base_context(
page_title='Manage events',
events=events,
dashboard_count=events.count(),
embed_base_url=_embed_base_url(request),
default_embed_month=timezone.localdate().replace(day=1).strftime('%Y-%m'),
pending_import=import_preview if import_preview is not None else _get_pending_event_import_preview(request),
)
def _restore_events_from_backup(payload):
normalized = _normalize_event_backup_payload(payload)
with transaction.atomic():
Event.objects.all().delete()
for event_data in normalized['events']:
event = Event(**event_data)
event.full_clean()
event.save()
return normalized['event_count']
@login_required(login_url='login') @login_required(login_url='login')
def event_dashboard(request): def event_dashboard(request):
if not request.user.is_staff: if not request.user.is_staff:
raise PermissionDenied raise PermissionDenied
events = Event.objects.all().order_by('start', 'name') return render(request, 'core/event_dashboard.html', _build_dashboard_context(request))
return render(
request,
'core/event_dashboard.html',
_base_context(
page_title='Manage events',
events=events,
dashboard_count=events.count(),
embed_base_url=_embed_base_url(request),
default_embed_month=timezone.localdate().replace(day=1).strftime('%Y-%m'),
),
)
@login_required(login_url='login') @login_required(login_url='login')
@ -174,6 +312,60 @@ def event_export(request):
return response return response
@login_required(login_url='login')
def event_import_preview(request):
if not request.user.is_staff:
raise PermissionDenied
if request.method != 'POST':
return redirect('event_dashboard')
uploaded_file = request.FILES.get('backup_file')
if not uploaded_file:
messages.error(request, 'Choose a backup JSON file before previewing the restore.')
return redirect('event_dashboard')
try:
payload = json.loads(uploaded_file.read().decode('utf-8'))
preview = _build_event_import_preview(payload)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
messages.error(request, f'Backup import failed: {exc}')
return redirect('event_dashboard')
request.session[BACKUP_IMPORT_SESSION_KEY] = payload
request.session.modified = True
messages.success(request, 'Backup file loaded. Review the restore preview below before replacing live events.')
return render(request, 'core/event_dashboard.html', _build_dashboard_context(request, import_preview=preview))
@login_required(login_url='login')
def event_import_restore(request):
if not request.user.is_staff:
raise PermissionDenied
if request.method != 'POST':
return redirect('event_dashboard')
payload = request.session.get(BACKUP_IMPORT_SESSION_KEY)
if not payload:
messages.error(request, 'Upload a backup JSON and preview it before running a restore.')
return redirect('event_dashboard')
try:
restored_count = _restore_events_from_backup(payload)
except ValueError as exc:
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
messages.error(request, f'Restore failed: {exc}')
return redirect('event_dashboard')
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
event_label = 'event' if restored_count == 1 else 'events'
messages.success(request, f'Restore complete. Replaced the live calendar with {restored_count} {event_label} from the backup.')
return redirect('event_dashboard')
@login_required(login_url='login') @login_required(login_url='login')
def event_create(request): def event_create(request):
if not request.user.is_staff: if not request.user.is_staff: