feat: Tutorial-Fortschritt serverseitig persistieren (Resume/Restart)
- Neuer Router /api/tutorial mit GET/PUT/DELETE für Fortschritt pro User - DB-Migration: tutorial_step + tutorial_completed in users-Tabelle - Resume-Dialog bei abgebrochenem Tutorial (Fortsetzen/Neu starten) - Chat-Hinweis passt sich dem Tutorial-Status dynamisch an - API-Methoden: getTutorialState, saveTutorialState, resetTutorialState Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -464,6 +464,13 @@ async def init_db():
|
|||||||
await db.execute("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1")
|
await db.execute("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Migration: Tutorial-Fortschritt pro User
|
||||||
|
if "tutorial_step" not in user_columns:
|
||||||
|
await db.execute("ALTER TABLE users ADD COLUMN tutorial_step INTEGER DEFAULT NULL")
|
||||||
|
await db.execute("ALTER TABLE users ADD COLUMN tutorial_completed INTEGER DEFAULT 0")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: tutorial_step + tutorial_completed zu users hinzugefuegt")
|
||||||
|
|
||||||
if "last_login_at" not in user_columns:
|
if "last_login_at" not in user_columns:
|
||||||
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
|
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ from routers.feedback import router as feedback_router
|
|||||||
from routers.public_api import router as public_api_router
|
from routers.public_api import router as public_api_router
|
||||||
from routers.chat import router as chat_router
|
from routers.chat import router as chat_router
|
||||||
from routers.network_analysis import router as network_analysis_router
|
from routers.network_analysis import router as network_analysis_router
|
||||||
|
from routers.tutorial import router as tutorial_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(incidents_router)
|
app.include_router(incidents_router)
|
||||||
@@ -342,6 +343,7 @@ app.include_router(feedback_router)
|
|||||||
app.include_router(public_api_router)
|
app.include_router(public_api_router)
|
||||||
app.include_router(chat_router, prefix="/api/chat")
|
app.include_router(chat_router, prefix="/api/chat")
|
||||||
app.include_router(network_analysis_router)
|
app.include_router(network_analysis_router)
|
||||||
|
app.include_router(tutorial_router)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/ws")
|
@app.websocket("/api/ws")
|
||||||
|
|||||||
77
src/routers/tutorial.py
Normale Datei
77
src/routers/tutorial.py
Normale Datei
@@ -0,0 +1,77 @@
|
|||||||
|
"""Tutorial-Router: Fortschritt serverseitig pro User speichern."""
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from auth import get_current_user
|
||||||
|
from database import db_dependency
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.tutorial")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tutorial", tags=["tutorial"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
async def get_tutorial_state(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Tutorial-Fortschritt des aktuellen Nutzers abrufen."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT tutorial_step, tutorial_completed FROM users WHERE id = ?",
|
||||||
|
(current_user["id"],),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"current_step": None, "completed": False}
|
||||||
|
return {
|
||||||
|
"current_step": row["tutorial_step"],
|
||||||
|
"completed": bool(row["tutorial_completed"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/state")
|
||||||
|
async def save_tutorial_state(
|
||||||
|
body: dict,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Tutorial-Fortschritt speichern (current_step und/oder completed)."""
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if "current_step" in body:
|
||||||
|
step = body["current_step"]
|
||||||
|
if step is not None and (not isinstance(step, int) or step < 0 or step > 31):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=422, detail="current_step muss 0-31 oder null sein")
|
||||||
|
updates.append("tutorial_step = ?")
|
||||||
|
params.append(step)
|
||||||
|
|
||||||
|
if "completed" in body:
|
||||||
|
updates.append("tutorial_completed = ?")
|
||||||
|
params.append(1 if body["completed"] else 0)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
params.append(current_user["id"])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE users SET {', '.join(updates)} WHERE id = ?",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/state")
|
||||||
|
async def reset_tutorial_state(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Tutorial-Fortschritt zuruecksetzen (fuer Neustart)."""
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE users SET tutorial_step = NULL, tutorial_completed = 0 WHERE id = ?",
|
||||||
|
(current_user["id"],),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -215,6 +215,19 @@ const API = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
|
|
||||||
|
// Tutorial-Fortschritt
|
||||||
|
getTutorialState() {
|
||||||
|
return this._request('GET', '/tutorial/state');
|
||||||
|
},
|
||||||
|
|
||||||
|
saveTutorialState(data) {
|
||||||
|
return this._request('PUT', '/tutorial/state', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetTutorialState() {
|
||||||
|
return this._request('DELETE', '/tutorial/state');
|
||||||
|
},
|
||||||
exportIncident(id, format, scope) {
|
exportIncident(id, format, scope) {
|
||||||
const token = localStorage.getItem('osint_token');
|
const token = localStorage.getItem('osint_token');
|
||||||
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
|
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const Chat = {
|
|||||||
_isOpen: false,
|
_isOpen: false,
|
||||||
_isLoading: false,
|
_isLoading: false,
|
||||||
_hasGreeted: false,
|
_hasGreeted: false,
|
||||||
|
_tutorialHintDismissed: false,
|
||||||
_isFullscreen: false,
|
_isFullscreen: false,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -64,10 +65,13 @@ const Chat = {
|
|||||||
if (!this._hasGreeted) {
|
if (!this._hasGreeted) {
|
||||||
this._hasGreeted = true;
|
this._hasGreeted = true;
|
||||||
this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.');
|
this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.');
|
||||||
// Tutorial-Hinweis beim ersten Chat-Oeffnen der Session
|
|
||||||
if (typeof Tutorial !== 'undefined' && !sessionStorage.getItem('osint_tutorial_hint_dismissed')) {
|
|
||||||
this._showTutorialHint();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tutorial-Hinweis bei jedem Oeffnen aktualisieren (wenn nicht dismissed)
|
||||||
|
if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
|
||||||
|
var oldHint = document.getElementById('chat-tutorial-hint');
|
||||||
|
if (oldHint) oldHint.remove();
|
||||||
|
this._showTutorialHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus auf Input
|
// Focus auf Input
|
||||||
@@ -288,29 +292,57 @@ const Chat = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_showTutorialHint() {
|
async _showTutorialHint() {
|
||||||
var container = document.getElementById('chat-messages');
|
var container = document.getElementById('chat-messages');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
// API-State laden (Fallback: Standard-Hint)
|
||||||
|
var state = null;
|
||||||
|
try { state = await API.getTutorialState(); } catch(e) {}
|
||||||
|
|
||||||
var hint = document.createElement('div');
|
var hint = document.createElement('div');
|
||||||
hint.className = 'chat-tutorial-hint';
|
hint.className = 'chat-tutorial-hint';
|
||||||
hint.id = 'chat-tutorial-hint';
|
hint.id = 'chat-tutorial-hint';
|
||||||
var textDiv = document.createElement('div');
|
var textDiv = document.createElement('div');
|
||||||
textDiv.className = 'chat-tutorial-hint-text';
|
textDiv.className = 'chat-tutorial-hint-text';
|
||||||
textDiv.innerHTML = '<strong>Tipp:</strong> Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt f\u00fcr Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
|
|
||||||
textDiv.style.cursor = 'pointer';
|
textDiv.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
if (state && !state.completed && state.current_step !== null && state.current_step > 0) {
|
||||||
|
// Mittendrin abgebrochen
|
||||||
|
var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32;
|
||||||
|
textDiv.innerHTML = '<strong>Tipp:</strong> Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.';
|
||||||
textDiv.addEventListener('click', function() {
|
textDiv.addEventListener('click', function() {
|
||||||
Chat.close();
|
Chat.close();
|
||||||
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
|
Chat._tutorialHintDismissed = true;
|
||||||
if (typeof Tutorial !== 'undefined') Tutorial.start();
|
if (typeof Tutorial !== 'undefined') Tutorial.start();
|
||||||
});
|
});
|
||||||
|
} else if (state && state.completed) {
|
||||||
|
// Bereits abgeschlossen
|
||||||
|
textDiv.innerHTML = '<strong>Tipp:</strong> Sie haben den Rundgang bereits abgeschlossen. <span style="text-decoration:underline;">Erneut starten?</span>';
|
||||||
|
textDiv.addEventListener('click', async function() {
|
||||||
|
Chat.close();
|
||||||
|
Chat._tutorialHintDismissed = true;
|
||||||
|
try { await API.resetTutorialState(); } catch(e) {}
|
||||||
|
if (typeof Tutorial !== 'undefined') Tutorial.start(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Nie gestartet
|
||||||
|
textDiv.innerHTML = '<strong>Tipp:</strong> Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
|
||||||
|
textDiv.addEventListener('click', function() {
|
||||||
|
Chat.close();
|
||||||
|
Chat._tutorialHintDismissed = true;
|
||||||
|
if (typeof Tutorial !== 'undefined') Tutorial.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var closeBtn = document.createElement('button');
|
var closeBtn = document.createElement('button');
|
||||||
closeBtn.className = 'chat-tutorial-hint-close';
|
closeBtn.className = 'chat-tutorial-hint-close';
|
||||||
closeBtn.title = 'Schlie\u00dfen';
|
closeBtn.title = 'Schließen';
|
||||||
closeBtn.innerHTML = '×';
|
closeBtn.innerHTML = '×';
|
||||||
closeBtn.addEventListener('click', function(e) {
|
closeBtn.addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
hint.remove();
|
hint.remove();
|
||||||
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
|
Chat._tutorialHintDismissed = true;
|
||||||
});
|
});
|
||||||
hint.appendChild(textDiv);
|
hint.appendChild(textDiv);
|
||||||
hint.appendChild(closeBtn);
|
hint.appendChild(closeBtn);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const Tutorial = {
|
|||||||
_resizeHandler: null,
|
_resizeHandler: null,
|
||||||
_demoRunning: false,
|
_demoRunning: false,
|
||||||
_lastExitedStep: -1,
|
_lastExitedStep: -1,
|
||||||
|
_highestStep: -1,
|
||||||
_stepTimers: [], // setTimeout-IDs fuer den aktuellen Step
|
_stepTimers: [], // setTimeout-IDs fuer den aktuellen Step
|
||||||
_savedState: null, // Dashboard-Zustand vor dem Tutorial
|
_savedState: null, // Dashboard-Zustand vor dem Tutorial
|
||||||
|
|
||||||
@@ -1152,14 +1153,52 @@ const Tutorial = {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
start() {
|
async start(forceRestart) {
|
||||||
if (this._isActive) return;
|
if (this._isActive) return;
|
||||||
this._isActive = true;
|
|
||||||
this._currentStep = -1;
|
|
||||||
|
|
||||||
// Chat schließen falls offen
|
// Chat schliessen falls offen
|
||||||
if (typeof Chat !== 'undefined' && Chat._isOpen) Chat.close();
|
if (typeof Chat !== 'undefined' && Chat._isOpen) Chat.close();
|
||||||
|
|
||||||
|
// Server-State laden (Fallback: direkt starten)
|
||||||
|
var state = null;
|
||||||
|
try { state = await API.getTutorialState(); } catch(e) {}
|
||||||
|
|
||||||
|
// Resume-Dialog wenn mittendrin abgebrochen
|
||||||
|
if (!forceRestart && state && !state.completed && state.current_step !== null && state.current_step > 0) {
|
||||||
|
this._showResumeDialog(state.current_step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._startInternal(forceRestart ? 0 : null);
|
||||||
|
},
|
||||||
|
|
||||||
|
_showResumeDialog(step) {
|
||||||
|
var self = this;
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.className = 'tutorial-resume-overlay';
|
||||||
|
overlay.innerHTML = '<div class=tutorial-resume-dialog>'
|
||||||
|
+ '<p>Sie haben den Rundgang bei <strong>Schritt ' + (step + 1) + '/' + this._steps.length + '</strong> unterbrochen.</p>'
|
||||||
|
+ '<div class=tutorial-resume-actions>'
|
||||||
|
+ '<button class=tutorial-btn tutorial-btn-next id=tutorial-resume-btn>Fortsetzen</button>'
|
||||||
|
+ '<button class=tutorial-btn tutorial-btn-secondary id=tutorial-restart-btn>Neu starten</button>'
|
||||||
|
+ '</div></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.getElementById('tutorial-resume-btn').addEventListener('click', function() {
|
||||||
|
overlay.remove();
|
||||||
|
self._startInternal(step);
|
||||||
|
});
|
||||||
|
document.getElementById('tutorial-restart-btn').addEventListener('click', async function() {
|
||||||
|
overlay.remove();
|
||||||
|
try { await API.resetTutorialState(); } catch(e) {}
|
||||||
|
self._startInternal(0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_startInternal(resumeStep) {
|
||||||
|
this._isActive = true;
|
||||||
|
this._highestStep = -1;
|
||||||
|
this._currentStep = -1;
|
||||||
|
|
||||||
// Overlay einblenden + Klicks blockieren
|
// Overlay einblenden + Klicks blockieren
|
||||||
this._els.overlay.classList.add('active');
|
this._els.overlay.classList.add('active');
|
||||||
document.body.classList.add('tutorial-active');
|
document.body.classList.add('tutorial-active');
|
||||||
@@ -1172,7 +1211,11 @@ const Tutorial = {
|
|||||||
this._resizeHandler = this._onResize.bind(this);
|
this._resizeHandler = this._onResize.bind(this);
|
||||||
window.addEventListener('resize', this._resizeHandler);
|
window.addEventListener('resize', this._resizeHandler);
|
||||||
|
|
||||||
|
if (resumeStep && resumeStep > 0) {
|
||||||
|
this.goToStep(resumeStep);
|
||||||
|
} else {
|
||||||
this.next();
|
this.next();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -1230,7 +1273,14 @@ const Tutorial = {
|
|||||||
this._resizeHandler = null;
|
this._resizeHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fortschritt serverseitig speichern
|
||||||
|
if (this._lastExitedStep >= 0 && this._lastExitedStep < this._steps.length - 1) {
|
||||||
|
// Mittendrin abgebrochen — Schritt speichern
|
||||||
|
API.saveTutorialState({ current_step: this._lastExitedStep }).catch(function() {});
|
||||||
|
} else {
|
||||||
|
// Komplett durchlaufen oder letzter Schritt
|
||||||
this._markSeen();
|
this._markSeen();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1258,6 +1308,7 @@ const Tutorial = {
|
|||||||
|
|
||||||
if (this._currentStep >= 0) this._exitStep(this._currentStep);
|
if (this._currentStep >= 0) this._exitStep(this._currentStep);
|
||||||
this._currentStep = index;
|
this._currentStep = index;
|
||||||
|
if (index > this._highestStep) this._highestStep = index;
|
||||||
this._enterStep(index);
|
this._enterStep(index);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2231,6 +2282,7 @@ const Tutorial = {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
_markSeen() {
|
_markSeen() {
|
||||||
try { localStorage.setItem('osint_tutorial_seen', '1'); } catch(e) {}
|
try { localStorage.setItem('osint_tutorial_seen', '1'); } catch(e) {}
|
||||||
|
API.saveTutorialState({ completed: true, current_step: null }).catch(function() {});
|
||||||
},
|
},
|
||||||
|
|
||||||
_hasSeen() {
|
_hasSeen() {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren