From 8443b777b16f164522507a8d18954607dc1da7b9 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 2 Apr 2026 16:25:16 +0000 Subject: [PATCH] v3 with import/export --- core/__pycache__/tests.cpython-311.pyc | Bin 7933 -> 14053 bytes core/__pycache__/urls.cpython-311.pyc | Bin 1900 -> 2126 bytes core/__pycache__/views.cpython-311.pyc | Bin 16406 -> 28115 bytes core/templates/core/event_dashboard.html | 127 +++++++++++-- core/tests.py | 73 ++++++++ core/urls.py | 4 + core/views.py | 218 +++++++++++++++++++++-- 7 files changed, 399 insertions(+), 23 deletions(-) diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc index 1c713401b4b56800b2f35bf2c7a6ffbac56c27c5..df2cb3b378b3780f4535fe1c72a02938a27851e3 100644 GIT binary patch literal 14053 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 Vw~=AkaWH<`wjm{(Pms&0@jtS3uaE!$ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f89650d22a25c626753fb85f3ab2a854b576e596..94d9acd9515014c8ab980ae2a55b85678e07ce51 100644 GIT binary patch delta 696 zcmaFEcTPZkIWI340}%X}elAmyje+4Yhyw#6P{wDsiR%7BxdKrFxq?xGj0{W+sVph1 zse%(5d>I8Mo|9zeOW~h*L3HAOGiII?Q6OuwFQW`Idx|8G$tsn~pCS!n$fRTZlX-=wLkup#T$Unt4Op~)&b?f;UnLaQK z93hU~cBRQuY%|rhFX%d6@QAy>5`U2;{t8R{4Hk|DmkXTe!jspqZRA%M;b&^#2Eigl Gp!WbhQL-uk delta 539 zcmX>n@P%a|A#Rs%5vq$q%N^D6>%!<@pfKyb1Nld^@<8un$(K(#;&0qML^!YRtZ44Nu0 zK?0hLw^++EQ_G8i43>h#l8l#(AW`ng{Y=G^^_cY-1t!NbTTAl;1*?Qp%Tn`7iuG-Z zb4t^#K!lxs5%1*9%+`}_SR5GnCfBm;VHBBc#hO2PGpnhyj($ddZmNE1QfXdEseWoj zYI12wevy88eo=ODL1J>MesX>hP`W6mSg)Y+7l%!5eoARhs$G#H&@PZ8ihnUq=4R8a m=V4^}z%XzGGt&pT$>-Q+PM*rXieFiXpQ(Wx1dHT>P6Pl=2zM_4 diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index b5887f3b630a102ef1a94166b7f378f4093030f7..cabbde8bb02a5f494e1574ff154bd546f8988813 100644 GIT binary patch literal 28115 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 GI{y#BFLu=c delta 3765 zcmai0eQZhMq!y`#+ih%WySAcq8L)L^OqDh^*jBCmgB4h$W^YAprL9|4O#+pF$|iN^+~*h) z8qKnQKKI;n@AN$EV07iJhzZ1Fsj4Ub7KnMZQLqmrYzb4-~>wDD0!r6rIUt{ z)H9oCCP_8EyXd15e_GF&gYlzkDo6P%?wZM*vxt-jK-C1XiZu1O;$h|dyRNnCDuHLY zOdWWZJF7B-YDN+8WLbXD79G^nG@9$rWz1ZZ4%0-=)LHbH&W;#aH9@2N16RFE2I-p1 z?oLu$1Jox@08rwD4Go4G6$h*1dprw5jWDqM@eCa{|kW_hIfaDv~xMo;kLKs`vZ0U zT9C4el7`Mgd^GS?QqA8CtRj{C-+^U*8RmpB5R{hSCQ`u%gVERuv4UnaCe{|i+5w%V zRnu~-*(~zgaotxCRwJxIC`0H#Ko=};LQR3F$}DeJo?>BsFSwM1czs1<1sbnZGfV?p zjIfDstr%%)Kym|sZ4#(z^+vG7~O#69z$6OvYB24_Z+q&ZKJ6NF!Xs)k=%Q06eJ z`SnC?nTd6i{|uGz?fx3a%ccCfySmbK(LKBAzFda+A#0^s&7+`p7J&Z1U8iyjf7#@ZtZMhOUG$~P@BHS#DQ=)(L0X5-Z z!^psN8HKEVv7y>`7C6r#cjC;xFh9`QIvSkzmQQBh&TGKkoif_YZqs z=($t3^e1a>)pbnOb=;AcOvx*6$*ZR1Rqx52x8=@j!JBgDExBt-?s`w|zAbmZz4w;9 zdrIDY*Y7U&-ID_TUp+{n zORN~cat;}K#&T)7bk8Y7}bl-RkU!&#k~>{VF!s>Cy0r{HL= zNJJ*bdyf;h{ET-n zKmXi$1*MJW;@0F zI3-T+&yXtk#(s{&HxSS(hIV|JN1|QX(QnUti+%|+3~W4bndUDqUp^CB(qzAanHv)S z@7~WL4DKjnvwH!PH2pd`vZDji#tcwdL$A*JDxJKipj@{XcHBgOKI6m(R8T zVKVHRu~UTGT6qgIYMRcm9WzsvG2{6ye+|-~!q2#LX5Si4`$mKpzjK_I_$vom_r3{J z(DUaFb-@G?y@`BLh*Ej1D457F0^tn@`=JEJ4qhS+dw{VE-U@B-&!Py0p2VKyo8uEu z!}sIsdQZZXQYNlM_H7gsH`ZpP3O$>3;}E+ldj?@E0^WA)p`qB!6&D-kp1s& zgg*cn&>BUW^LcjS%Za@O%mBZ7=wk@YIrReB!+ZN1`msbu%6oJDsboT)c^)ufq-(B` z1?4jx?2o8yrh@@(mCo!C$5zVT23`w)xBrkh+wXwN2Z_}Xhjy)LgHYj~R|Uf>oBe5y z0+vHALX@A;vas!cx+1)uUh#{t+Y#sQ)3$E(%f1lZbK@$aS@4DZV2+NNqDv(Wlih&H zb^O3U0#scacquMQ0(FhlJbKSiQ%$DCCBsCixgI&j4>jQ-U{_JyqfLgbL^i@V2)}x` z*N;6|Aw9}Uwu6TUTgi)j{owjCzc!?14(p)A%(7xWJXk-9-Pj7``Nkl$Q+N{_wrbJq zP_;F)5ME4%co148`6W3s2`nr#%Y~0gLv$Nxt2&%2!w#ExGw3K%1mR#ZQ1;_=nFJZK z(`O)4l&47AI1W!C;C&`KJiCk(_85jI)jmR4kfKEFtYWC`SYT<0n@BX_2i{IFD3_w^ znFSLBe#U1|ba(lu$yCe2o6@ZBwA68P_IH{;J5 -
-
-
- Backup -

Download a full event backup

-

Export every event into one JSON file so you can keep an offline backup before larger edits or restore the calendar later if needed.

+
+
+
+
+
+ Backup +

Download a full event backup

+

Export every event into one JSON file so you can keep an offline backup before larger edits or restore the calendar later if needed.

+
+ +
+
+
+ +
+
+ Restore +

Import a previous backup

+

Upload an exported JSON file, review the preview, then confirm before anything in the live calendar is replaced.

+ +
+ {% csrf_token %} +
+ + +
Uploading only prepares a preview. The actual restore is a separate confirmation step.
+
+
+ + Recommended flow: export a fresh backup first, then test your restore file. +
+
+
+
-
- Download backup JSON + + {% if pending_import %} +
+
+
+ Restore preview +

Ready to replace the current calendar

+

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.

+
+
+ {% csrf_token %} + +
+
+ +
+
+
+ Backup events + {{ pending_import.event_count }} +
+
+
+
+ Published + {{ pending_import.published_count }} +
+
+
+
+ Drafts + {{ pending_import.draft_count }} +
+
+
+
+ Exported + {{ pending_import.exported_at_display }} +
+
+
+ +
+
+

First events in this backup

+
+ {% for event in pending_import.preview_events %} +
+
+
+ {{ event.name }} +
{{ event.start|date:"M j, Y g:i A" }} – {{ event.end|date:"M j, Y g:i A" }}
+
+
+
{{ event.location|default:"—" }}
+
{% if event.is_published %}Published{% else %}Draft{% endif %}
+
+
+
+ {% empty %} +

This backup has no events. Confirming the restore would clear the current calendar.

+ {% endfor %} +
+ {% if pending_import.remaining_count %} +

Plus {{ pending_import.remaining_count }} more event{{ pending_import.remaining_count|pluralize }} in the same backup.

+ {% endif %} +
+
+

Restore source

+
+
Project
+
{{ pending_import.project }}
+
Version
+
{{ pending_import.version }}
+
Action
+
Replace all current events
+
+

Safety note: the restore deletes the current event table contents and recreates them from this uploaded backup inside one database transaction.

+
+
-
-
+ {% endif %}
diff --git a/core/tests.py b/core/tests.py index 75c4e28..77aa620 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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])) diff --git a/core/urls.py b/core/urls.py index fd44bcf..0b6544c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -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//', 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//edit/', event_edit, name='event_edit'), path('dashboard/events//delete/', event_delete, name='event_delete'), diff --git a/core/views.py b/core/views.py index 615a255..c136494 100644 --- a/core/views.py +++ b/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: