feat(export): JSON full-backup download (pantry + saved recipes)
Adds GET /export/json that bundles inventory and saved recipes into a single timestamped JSON file for data portability. The export envelope includes schema version and export timestamp so future import logic can handle version differences. Frontend: new primary-styled JSON download button in the Export card with a short description of what is included. Closes #62
This commit is contained in:
parent
2ad71f2636
commit
c8fdc21c29
2 changed files with 43 additions and 2 deletions
|
|
@ -1,9 +1,11 @@
|
||||||
"""Export endpoints — CSV/Excel of receipt and inventory data."""
|
"""Export endpoints — CSV and JSON export of user data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
@ -45,3 +47,33 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/json")
|
||||||
|
async def export_full_json(store: Store = Depends(get_store)):
|
||||||
|
"""Export full pantry inventory + saved recipes as a single JSON file.
|
||||||
|
|
||||||
|
Intended for data portability — users can import this into another
|
||||||
|
Kiwi instance or keep it as an offline backup.
|
||||||
|
"""
|
||||||
|
inventory, saved = await asyncio.gather(
|
||||||
|
asyncio.to_thread(store.list_inventory),
|
||||||
|
asyncio.to_thread(store.get_saved_recipes, 1000, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
export_doc = {
|
||||||
|
"kiwi_export": {
|
||||||
|
"version": "1.0",
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"inventory": [dict(row) for row in inventory],
|
||||||
|
"saved_recipes": [dict(row) for row in saved],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body = json.dumps(export_doc, default=str, indent=2)
|
||||||
|
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([body]),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -392,10 +392,14 @@
|
||||||
<!-- Export -->
|
<!-- Export -->
|
||||||
<div class="card export-card">
|
<div class="card export-card">
|
||||||
<h2 class="section-title">Export</h2>
|
<h2 class="section-title">Export</h2>
|
||||||
<div class="flex gap-sm" style="margin-top: var(--spacing-sm)">
|
<div class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
|
||||||
|
<button @click="exportJSON" class="btn btn-primary">Download JSON (full backup)</button>
|
||||||
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
|
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
|
||||||
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
|
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-secondary" style="margin-top: var(--spacing-xs)">
|
||||||
|
JSON includes pantry + saved recipes. Import it into another Kiwi instance any time.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Modal -->
|
<!-- Edit Modal -->
|
||||||
|
|
@ -847,6 +851,11 @@ function exportExcel() {
|
||||||
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
|
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportJSON() {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || '/api/v1'
|
||||||
|
window.open(`${apiUrl}/export/json`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
// Full date string for tooltip (accessible label)
|
// Full date string for tooltip (accessible label)
|
||||||
function formatDateFull(dateStr: string): string {
|
function formatDateFull(dateStr: string): string {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue