v3 with import/export
This commit is contained in:
parent
376d533341
commit
8443b777b1
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -17,18 +17,125 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-surface mb-4">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
|
||||
<div>
|
||||
<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 class="row g-4 mb-4">
|
||||
<div class="col-xl-6">
|
||||
<div class="card-surface h-100">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
|
||||
<div>
|
||||
<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 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 class="row g-4 mb-4">
|
||||
<div class="col-xl-5">
|
||||
|
||||
@ -2,6 +2,7 @@ from datetime import datetime
|
||||
import json
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@ -47,6 +48,7 @@ class CalendarViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Manage your public event calendar securely')
|
||||
self.assertContains(response, 'Generate a copy-ready widget snippet')
|
||||
self.assertContains(response, 'Import a previous backup')
|
||||
|
||||
def test_embed_page_can_hide_header(self):
|
||||
response = self.client.get(reverse('calendar_embed') + '?header=0')
|
||||
@ -65,6 +67,13 @@ class EventDashboardMutationTests(TestCase):
|
||||
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):
|
||||
self.client.login(username='staffer', password='pass12345')
|
||||
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]['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):
|
||||
self.client.login(username='staffer', password='pass12345')
|
||||
response = self.client.get(reverse('event_edit', args=[self.event.slug]))
|
||||
|
||||
@ -9,6 +9,8 @@ from .views import (
|
||||
event_dashboard_detail,
|
||||
event_delete,
|
||||
event_export,
|
||||
event_import_preview,
|
||||
event_import_restore,
|
||||
event_detail,
|
||||
event_edit,
|
||||
event_list,
|
||||
@ -23,6 +25,8 @@ urlpatterns = [
|
||||
path('events/<slug:slug>/', event_detail, name='event_detail'),
|
||||
path('dashboard/events/', event_dashboard, name='event_dashboard'),
|
||||
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/<slug:slug>/edit/', event_edit, name='event_edit'),
|
||||
path('dashboard/events/<slug:slug>/delete/', event_delete, name='event_delete'),
|
||||
|
||||
218
core/views.py
218
core/views.py
@ -5,8 +5,9 @@ import json
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
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.urls import reverse
|
||||
from django.utils import timezone
|
||||
@ -17,6 +18,7 @@ from .models import Event
|
||||
|
||||
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.'
|
||||
BACKUP_IMPORT_SESSION_KEY = 'event_backup_import_payload'
|
||||
|
||||
|
||||
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')
|
||||
def event_dashboard(request):
|
||||
if not request.user.is_staff:
|
||||
raise PermissionDenied
|
||||
events = Event.objects.all().order_by('start', 'name')
|
||||
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'),
|
||||
),
|
||||
)
|
||||
return render(request, 'core/event_dashboard.html', _build_dashboard_context(request))
|
||||
|
||||
|
||||
@login_required(login_url='login')
|
||||
@ -174,6 +312,60 @@ def event_export(request):
|
||||
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')
|
||||
def event_create(request):
|
||||
if not request.user.is_staff:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user