Archived
Initialer Commit: FastAPI Backend
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
ENVIRONMENT=development
|
||||
USERS_FILE_PATH=users.json
|
||||
DATA_DIR_PATH=data
|
||||
@@ -0,0 +1,2 @@
|
||||
venv/
|
||||
__pycache__
|
||||
File diff suppressed because it is too large
Load Diff
+198796
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Depends, HTTPException, status, Request, Form
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env Datei laden
|
||||
load_dotenv()
|
||||
|
||||
# 1. Konstanten und Verzeichnisse initialisieren
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
USERS_FILE = BASE_DIR / os.getenv("USERS_FILE_PATH", "users.json")
|
||||
DATA_DIR = BASE_DIR / os.getenv("DATA_DIR_PATH", "data")
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Templates initialisieren
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
app = FastAPI(title="Screenity Backend")
|
||||
security = HTTPBasic()
|
||||
|
||||
# 2. CORS-Einstellungen
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- Hilfsfunktionen ---
|
||||
|
||||
def load_users() -> dict:
|
||||
if not USERS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(USERS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return {}
|
||||
|
||||
def save_users(users: dict):
|
||||
with open(USERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(users, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def authenticate(credentials: HTTPBasicCredentials = Depends(security)) -> dict:
|
||||
users = load_users()
|
||||
for user_id, user_info in users.items():
|
||||
if user_info.get("email") == credentials.username:
|
||||
# secrets.compare_digest schützt vor Timing-Attacks
|
||||
if secrets.compare_digest(user_info.get("password", ""), credentials.password):
|
||||
# Wir packen die user_id mit in das Dictionary für späteren Zugriff
|
||||
user_info["id"] = user_id
|
||||
return user_info
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Anmeldung fehlgeschlagen",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
|
||||
def authenticate_admin(current_user: dict = Depends(authenticate)) -> dict:
|
||||
if not current_user.get("is_admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Zugriff verweigert: Administrator-Rechte erforderlich"
|
||||
)
|
||||
return current_user
|
||||
|
||||
# --- Endpunkte ---
|
||||
|
||||
# 4. Endpunkt: Daten vom Handy empfangen
|
||||
@app.post("/report", status_code=status.HTTP_201_CREATED)
|
||||
@app.post("/report/", status_code=status.HTTP_201_CREATED)
|
||||
async def receive_report(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
device_id = data.get("device_id")
|
||||
report_date = data.get("report_date")
|
||||
|
||||
if not device_id or not report_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing device_id or report_date"
|
||||
)
|
||||
|
||||
file_path = DATA_DIR / f"{device_id}.json"
|
||||
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
device_data = json.load(f)
|
||||
else:
|
||||
device_data = {
|
||||
"device_id": device_id,
|
||||
"device_name": data.get("device_name", "Unbekanntes Gerät"),
|
||||
"reports": {}
|
||||
}
|
||||
|
||||
# Report hinzufügen oder updaten
|
||||
device_data["reports"][report_date] = {
|
||||
"total_screen_time_ms": data.get("total_screen_time_ms", 0),
|
||||
"apps": data.get("apps", []),
|
||||
"detailed_events": data.get("detailedEvents", [])
|
||||
}
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(device_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
return {"status": "success"}
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
# 5. Endpunkt: Zusammenfassung
|
||||
@app.get("/summary")
|
||||
@app.get("/summary/")
|
||||
def get_summary(current_user: dict = Depends(authenticate)):
|
||||
summary = []
|
||||
today_str = date.today().isoformat()
|
||||
total_today_ms = 0
|
||||
total_all_ms = 0
|
||||
|
||||
allowed_devices = current_user.get("devices", [])
|
||||
|
||||
for device_id in allowed_devices:
|
||||
file_path = DATA_DIR / f"{device_id}.json"
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
reports = data.get("reports", {})
|
||||
dev_total_ms = sum(r.get("total_screen_time_ms", 0) for r in reports.values())
|
||||
today_ms = reports.get(today_str, {}).get("total_screen_time_ms", 0)
|
||||
|
||||
total_today_ms += today_ms
|
||||
total_all_ms += dev_total_ms
|
||||
|
||||
summary.append({
|
||||
"device_id": device_id,
|
||||
"device_name": data.get("device_name", "Unbekannt"),
|
||||
"days_tracked": len(reports),
|
||||
"total_screen_time_hours": round(dev_total_ms / 3600000, 2),
|
||||
"today_ms": today_ms,
|
||||
"reports": reports
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"user_name": current_user.get("name"),
|
||||
"devices": summary,
|
||||
"total_all_devices_hours": round(total_all_ms / 3600000, 2),
|
||||
"today_total_all_devices_ms": total_today_ms
|
||||
}
|
||||
|
||||
# 6. Geräte verwalten
|
||||
@app.put("/device/{device_id}")
|
||||
async def update_device(device_id: str, request: Request, current_user: dict = Depends(authenticate)):
|
||||
# Sicherheitscheck: Darf der User dieses Gerät überhaupt bearbeiten?
|
||||
if device_id not in current_user.get("devices", []) and not current_user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Nicht autorisiert für dieses Gerät")
|
||||
|
||||
file_path = DATA_DIR / f"{device_id}.json"
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
|
||||
|
||||
try:
|
||||
new_data = await request.json()
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
device_data = json.load(f)
|
||||
|
||||
if "device_name" in new_data:
|
||||
device_data["device_name"] = new_data["device_name"]
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(device_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
@app.delete("/device/{device_id}")
|
||||
def delete_device(device_id: str, current_user: dict = Depends(authenticate_admin)):
|
||||
# Nur Admins dürfen Geräte komplett aus dem System löschen
|
||||
file_path = DATA_DIR / f"{device_id}.json"
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return {"status": "success"}
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
|
||||
|
||||
# --- Admin Bereich ---
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_dashboard(request: Request, current_user: dict = Depends(authenticate_admin)):
|
||||
users = load_users()
|
||||
devices = []
|
||||
|
||||
for f in DATA_DIR.glob("*.json"):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as file:
|
||||
devices.append(json.load(file))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return templates.TemplateResponse(
|
||||
name="admin.html",
|
||||
context={
|
||||
"request": request,
|
||||
"users": users,
|
||||
"devices": devices,
|
||||
"current_user": current_user
|
||||
}
|
||||
)
|
||||
|
||||
@app.post("/admin/assign-device")
|
||||
async def assign_device(
|
||||
user_id: str = Form(...),
|
||||
device_id: str = Form(...),
|
||||
current_user: dict = Depends(authenticate_admin)
|
||||
):
|
||||
users = load_users()
|
||||
if user_id in users:
|
||||
if "devices" not in users[user_id]:
|
||||
users[user_id]["devices"] = []
|
||||
|
||||
if device_id not in users[user_id]["devices"]:
|
||||
users[user_id]["devices"].append(device_id)
|
||||
save_users(users)
|
||||
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.post("/admin/remove-device")
|
||||
async def remove_device(
|
||||
user_id: str = Form(...),
|
||||
device_id: str = Form(...),
|
||||
current_user: dict = Depends(authenticate_admin)
|
||||
):
|
||||
users = load_users()
|
||||
if user_id in users and device_id in users[user_id].get("devices", []):
|
||||
users[user_id]["devices"].remove(device_id)
|
||||
save_users(users)
|
||||
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Werte aus der .env mit Fallback-Standards auslesen
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
port = int(os.getenv("PORT", 8000))
|
||||
uvicorn.run("main:app", host=host, port=port, reload=True)
|
||||
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Admin Dashboard - Screen Time</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<span class="navbar-brand">ScreenTime Admin</span>
|
||||
<span class="text-white">Eingeloggt als: {{ current_user.name }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-primary text-white">Benutzer verwalten</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Geräte</th>
|
||||
><th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for id, user in users.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ user.name }}</strong> {% if user.is_admin %}<span class="badge bg-danger">Admin</span>{% endif %}
|
||||
<br><small class="text-muted">{{ user.email }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% for dev_id in user.devices %}
|
||||
<div class="badge bg-primary mb-1 d-block">
|
||||
{{ dev_id }}
|
||||
<form action="/admin/remove-device" method="post" style="display:inline;">
|
||||
<input type="hidden" name="user_id" value="{{ id }}">
|
||||
<input type="hidden" name="device_id" value="{{ dev_id }}">
|
||||
<button type="submit" class="btn-close btn-close-white" style="font-size: 0.5rem;"></button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/assign-device" method="post" class="d-flex gap-1">
|
||||
<input type="hidden" name="user_id" value="{{ id }}">
|
||||
<select name="device_id" class="form-select form-select-sm">
|
||||
<option value="">Gerät wählen...</option>
|
||||
{% for d in devices %}
|
||||
{% if d.device_id not in user.devices %}
|
||||
<option value="{{ d.device_id }}">{{ d.device_name or d.device_id }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">+</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/user/delete/{{ id }}" class="btn btn-sm btn-danger" onclick="return confirm('Löschen?')">X</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<hr>
|
||||
<h5>Neuen Benutzer anlegen</h5>
|
||||
<form action="/admin/user/add" method="post" class="row g-2">
|
||||
<div class="col-md-4"><input type="text" name="name" class="form-control" placeholder="Name" required></div>
|
||||
<div class="col-md-4"><input type="email" name="email" class="form-control" placeholder="Email" required></div>
|
||||
<div class="col-md-3"><input type="password" name="password" class="form-control" placeholder="Passwort" required></div>
|
||||
<div class="col-md-1"><button type="submit" class="btn btn-success">+</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-secondary text-white">Registrierte Geräte</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
{% for dev in devices %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
{{ dev.device_name }} <small class="text-muted">({{ dev.device_id }})</small>
|
||||
<span class="badge bg-info pill">{{ dev.reports|length }} Reports</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"1": {
|
||||
"name": "Robin Schanbacher",
|
||||
"email": "robin@schanbros.de",
|
||||
"password": "EKMYwa9yc&robin2",
|
||||
"is_admin": true,
|
||||
"devices": [
|
||||
"4950b7811d57268d",
|
||||
"d1be2cb6cbd8ef59",
|
||||
"d3e8f3c74675c343",
|
||||
"3e5c73b706b71103"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user