diff --git a/TODO.md b/TODO.md index 3abe54f..df1b2b1 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,6 @@ _⚠️ Da implementare solo dopo conferma che tutto funziona su HF Spaces._ -- [ ] Endpoint `GET /api/v1/lessons/export` — esporta tutte le lezioni di un utente in JSON -- [ ] Endpoint `POST /api/v1/lessons/import` — importa lezioni da JSON -- [ ] UI nel frontend: pulsanti Export/Import nella Dashboard o nel profilo \ No newline at end of file +- [x] Endpoint `GET /api/v1/export-import/export` — esporta tutte le lezioni + laboratori + Q&A + stato in JSON +- [x] Endpoint `POST /api/v1/export-import/import` — importa dati da JSON +- [x] UI nel frontend: pulsanti Export/Import nella pagina Profilo \ No newline at end of file diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 04d11d2..df70d6d 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.api_v1.endpoints import auth, courses, lessons, tavily, users, hands_on +from app.api.api_v1.endpoints import auth, courses, lessons, tavily, users, hands_on, export_import api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -8,3 +8,4 @@ api_router.include_router(lessons.router, prefix="/lessons", tags=["lessons"]) api_router.include_router(tavily.router, prefix="/tavily", tags=["tavily"]) api_router.include_router(hands_on.router, prefix="/hands-on", tags=["hands-on"]) +api_router.include_router(export_import.router, prefix="/export-import", tags=["export-import"]) diff --git a/backend/app/api/api_v1/endpoints/export_import.py b/backend/app/api/api_v1/endpoints/export_import.py new file mode 100644 index 0000000..7c8125b --- /dev/null +++ b/backend/app/api/api_v1/endpoints/export_import.py @@ -0,0 +1,300 @@ +import json +import re +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.api import deps +from app.core.db import get_db +from app.models.base import ( + User, Course, Lesson, LessonQuestion, + HandsOnCourse, Lab, LabQuestion, +) +from app.schemas.export_import import UserExport, ImportResult + +router = APIRouter() + + +@router.get("/export") +async def export_user_data( + current_user: User = Depends(deps.get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """Export all user data (courses, lessons, labs, Q&A) as JSON.""" + + courses_result = await db.execute( + select(Course).where(Course.user_id == current_user.id) + ) + courses = courses_result.scalars().all() + + hands_on_result = await db.execute( + select(HandsOnCourse).where(HandsOnCourse.user_id == current_user.id) + ) + hands_on_courses = hands_on_result.scalars().all() + + course_ids = [c.id for c in courses] + hands_on_ids = [h.id for h in hands_on_courses] + + # Fetch lessons and their questions + lessons_result = await db.execute( + select(Lesson).where(Lesson.course_id.in_(course_ids)) + if course_ids else select(Lesson).where(False) + ) + lessons = lessons_result.scalars().all() if course_ids else [] + + lesson_ids = [l.id for l in lessons] + if lesson_ids: + questions_result = await db.execute( + select(LessonQuestion).where(LessonQuestion.lesson_id.in_(lesson_ids)) + ) + lesson_questions = questions_result.scalars().all() + else: + lesson_questions = [] + + # Fetch labs and their questions + labs_result = await db.execute( + select(Lab).where(Lab.hands_on_course_id.in_(hands_on_ids)) + if hands_on_ids else select(Lab).where(False) + ) + labs = labs_result.scalars().all() if hands_on_ids else [] + + lab_ids = [l.id for l in labs] + if lab_ids: + lab_questions_result = await db.execute( + select(LabQuestion).where(LabQuestion.lab_id.in_(lab_ids)) + ) + lab_questions = lab_questions_result.scalars().all() + else: + lab_questions = [] + + # Build lookup maps + questions_by_lesson: dict[int, list[LessonQuestion]] = {} + for q in lesson_questions: + questions_by_lesson.setdefault(q.lesson_id, []).append(q) + + questions_by_lab: dict[int, list[LabQuestion]] = {} + for q in lab_questions: + questions_by_lab.setdefault(q.lab_id, []).append(q) + + lessons_by_course: dict[int, list[Lesson]] = {} + for l in lessons: + lessons_by_course.setdefault(l.course_id, []).append(l) + + labs_by_hands_on: dict[int, list[Lab]] = {} + for l in labs: + labs_by_hands_on.setdefault(l.hands_on_course_id, []).append(l) + + now = datetime.now(timezone.utc) + + export_data = UserExport( + exported_at=now, + username=current_user.username, + courses=[ + { + "title": c.title, + "description": c.description, + "language": c.language, + "index_json": c.index_json, + "created_at": c.created_at, + "lessons": [ + { + "title": l.title, + "path_in_index": l.path_in_index, + "content_markdown": l.content_markdown, + "is_completed": l.is_completed, + "is_favorite": l.is_favorite, + "user_notes": l.user_notes, + "created_at": l.created_at, + "questions": [ + { + "question": q.question, + "answer": q.answer, + "created_at": q.created_at, + } + for q in questions_by_lesson.get(l.id, []) + ], + } + for l in lessons_by_course.get(c.id, []) + ], + } + for c in courses + ], + hands_on_courses=[ + { + "topic": h.topic, + "title": h.title, + "description": h.description, + "language": h.language, + "custom_instructions": h.custom_instructions, + "index_json": h.index_json, + "created_at": h.created_at, + "labs": [ + { + "title": lab.title, + "path_in_index": lab.path_in_index, + "theory_content": lab.theory_content, + "steps_json": lab.steps_json, + "is_completed": lab.is_completed, + "is_favorite": lab.is_favorite, + "user_notes": lab.user_notes, + "created_at": lab.created_at, + "questions": [ + { + "question": q.question, + "answer": q.answer, + "created_at": q.created_at, + } + for q in questions_by_lab.get(lab.id, []) + ], + } + for lab in labs_by_hands_on.get(h.id, []) + ], + } + for h in hands_on_courses + ], + ) + + json_bytes = export_data.model_dump_json(indent=2).encode("utf-8") + safe_username = re.sub(r'[^\w.-]', '_', current_user.username) + filename = f"ceppa_export_{safe_username}.json" + + return Response( + content=json_bytes, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("/import") +async def import_user_data( + file: UploadFile = File(...), + current_user: User = Depends(deps.get_current_user), + db: AsyncSession = Depends(get_db), +) -> ImportResult: + """Import user data from a previously exported JSON file.""" + + if not file.filename or not file.filename.endswith(".json"): + raise HTTPException(status_code=400, detail="File must be a .json file") + + content = await file.read() + try: + data = json.loads(content) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON file") + + if "version" not in data or "courses" not in data: + raise HTTPException( + status_code=400, + detail="Invalid export file: missing 'version' or 'courses' field", + ) + + courses_data = data.get("courses", []) + hands_on_data = data.get("hands_on_courses", []) + + courses_imported = 0 + lessons_imported = 0 + hands_on_imported = 0 + labs_imported = 0 + + for course_data in courses_data: + course = Course( + user_id=current_user.id, + title=course_data["title"], + description=course_data.get("description"), + language=course_data.get("language", "en"), + index_json=course_data["index_json"], + created_at=_parse_datetime(course_data.get("created_at")), + ) + db.add(course) + await db.flush() + courses_imported += 1 + + for lesson_data in course_data.get("lessons", []): + lesson = Lesson( + course_id=course.id, + title=lesson_data["title"], + path_in_index=lesson_data["path_in_index"], + content_markdown=lesson_data["content_markdown"], + is_completed=lesson_data.get("is_completed", False), + is_favorite=lesson_data.get("is_favorite", False), + user_notes=lesson_data.get("user_notes"), + created_at=_parse_datetime(lesson_data.get("created_at")), + ) + db.add(lesson) + await db.flush() + lessons_imported += 1 + + for q_data in lesson_data.get("questions", []): + question = LessonQuestion( + lesson_id=lesson.id, + question=q_data["question"], + answer=q_data["answer"], + created_at=_parse_datetime(q_data.get("created_at")), + ) + db.add(question) + + for hoc_data in hands_on_data: + hoc = HandsOnCourse( + user_id=current_user.id, + topic=hoc_data["topic"], + title=hoc_data["title"], + description=hoc_data.get("description"), + language=hoc_data.get("language", "en"), + custom_instructions=hoc_data.get("custom_instructions"), + index_json=hoc_data["index_json"], + created_at=_parse_datetime(hoc_data.get("created_at")), + ) + db.add(hoc) + await db.flush() + hands_on_imported += 1 + + for lab_data in hoc_data.get("labs", []): + lab = Lab( + hands_on_course_id=hoc.id, + title=lab_data["title"], + path_in_index=lab_data["path_in_index"], + theory_content=lab_data["theory_content"], + steps_json=lab_data["steps_json"], + is_completed=lab_data.get("is_completed", False), + is_favorite=lab_data.get("is_favorite", False), + user_notes=lab_data.get("user_notes"), + created_at=_parse_datetime(lab_data.get("created_at")), + ) + db.add(lab) + await db.flush() + labs_imported += 1 + + for q_data in lab_data.get("questions", []): + question = LabQuestion( + lab_id=lab.id, + question=q_data["question"], + answer=q_data["answer"], + created_at=_parse_datetime(q_data.get("created_at")), + ) + db.add(question) + + await db.commit() + + return ImportResult( + courses_imported=courses_imported, + lessons_imported=lessons_imported, + hands_on_courses_imported=hands_on_imported, + labs_imported=labs_imported, + ) + + +def _parse_datetime(value: Any) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, TypeError): + pass + return None diff --git a/backend/app/schemas/export_import.py b/backend/app/schemas/export_import.py new file mode 100644 index 0000000..e787ffc --- /dev/null +++ b/backend/app/schemas/export_import.py @@ -0,0 +1,72 @@ +from datetime import datetime +from pydantic import BaseModel + + +class LessonQuestionExport(BaseModel): + question: str + answer: str + created_at: datetime + + +class LessonExport(BaseModel): + title: str + path_in_index: str + content_markdown: str + is_completed: bool + is_favorite: bool + user_notes: str | None = None + created_at: datetime + questions: list[LessonQuestionExport] = [] + + +class CourseExport(BaseModel): + title: str + description: str | None = None + language: str = "en" + index_json: str + created_at: datetime + lessons: list[LessonExport] = [] + + +class LabQuestionExport(BaseModel): + question: str + answer: str + created_at: datetime + + +class LabExport(BaseModel): + title: str + path_in_index: str + theory_content: str + steps_json: str + is_completed: bool + is_favorite: bool + user_notes: str | None = None + created_at: datetime + questions: list[LabQuestionExport] = [] + + +class HandsOnCourseExport(BaseModel): + topic: str + title: str + description: str | None = None + language: str = "en" + custom_instructions: str | None = None + index_json: str + created_at: datetime + labs: list[LabExport] = [] + + +class UserExport(BaseModel): + version: str = "1.0" + exported_at: datetime + username: str + courses: list[CourseExport] = [] + hands_on_courses: list[HandsOnCourseExport] = [] + + +class ImportResult(BaseModel): + courses_imported: int + lessons_imported: int + hands_on_courses_imported: int + labs_imported: int diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 652b02d..d57e692 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -28,4 +28,31 @@ client.interceptors.response.use( } ); +export async function exportUserData() { + const response = await client.get('/export-import/export', { responseType: 'blob' }); + const disposition = response.headers['content-disposition']; + let filename = 'ceppa_export.json'; + if (disposition) { + const match = disposition.match(/filename="?(.+?)"?$/); + if (match) filename = match[1]; + } + const url = URL.createObjectURL(response.data); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export async function importUserData(file) { + const formData = new FormData(); + formData.append('file', file); + const response = await client.post('/export-import/import', formData, { + headers: { 'Content-Type': undefined }, + }); + return response.data; +} + export default client; diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index 452458e..24c1a40 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import client from '../api/client'; -import { Save, ArrowLeft, Key, Server, Bot, Search, Loader2, Eye, EyeOff } from 'lucide-react'; +import client, { exportUserData, importUserData } from '../api/client'; +import { Save, ArrowLeft, Key, Server, Bot, Search, Loader2, Eye, EyeOff, Download, Upload } from 'lucide-react'; export default function Profile() { const navigate = useNavigate(); @@ -21,6 +21,12 @@ export default function Profile() { const [showApiKey, setShowApiKey] = useState(false); const [showTavilyKey, setShowTavilyKey] = useState(false); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const [importFile, setImportFile] = useState(null); + const [importResult, setImportResult] = useState(null); + const fileInputRef = useRef(null); + useEffect(() => { fetchSettings(); }, []); @@ -198,6 +204,107 @@ export default function Profile() { + {/* Data Export/Import */} +
+ Export all your courses, labs, Q&A history, and progress as a JSON file for backup or migration. +
+ ++ Import data from a previous export file. This will add courses and labs to your existing data. +
++ Import completed: {importResult.courses_imported} course{importResult.courses_imported !== 1 ? 's' : ''},{' '} + {importResult.lessons_imported} lesson{importResult.lessons_imported !== 1 ? 's' : ''},{' '} + {importResult.hands_on_courses_imported} lab course{importResult.hands_on_courses_imported !== 1 ? 's' : ''},{' '} + {importResult.labs_imported} lab{importResult.labs_imported !== 1 ? 's' : ''} imported. +
+