A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
545 lines
16 KiB
Python
545 lines
16 KiB
Python
"""
|
|
BreakPilot Content Service - FastAPI Application
|
|
Educational Content Management & Publishing
|
|
"""
|
|
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Form, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import or_, and_, func, desc, asc
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
import os
|
|
|
|
from database import get_db, engine
|
|
from models import Base, Content, Rating, Tag, Download, ContentStatus
|
|
from schemas import (
|
|
ContentCreate, ContentUpdate, ContentResponse, ContentListItem,
|
|
RatingCreate, RatingResponse, ContentFilter, ContentStats,
|
|
CreatorStats, FileUploadResponse, ContentWithRatings
|
|
)
|
|
from storage import storage
|
|
|
|
# Create tables
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
# FastAPI app
|
|
app = FastAPI(
|
|
title="BreakPilot Content Service",
|
|
description="Educational Content Management API with Creative Commons",
|
|
version="1.0.0"
|
|
)
|
|
|
|
# CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # TODO: Restrict in production
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# ============= AUTHENTICATION =============
|
|
|
|
# TODO: Integration mit consent-service OAuth2
|
|
async def get_current_user(token: str = None):
|
|
"""Get current user from JWT token (consent-service)"""
|
|
# Placeholder - TODO: Implement JWT validation
|
|
return {
|
|
"id": "test-user-123",
|
|
"name": "Test Creator",
|
|
"email": "test@breakpilot.app",
|
|
"role": "creator"
|
|
}
|
|
|
|
# ============= FILE UPLOAD ENDPOINTS =============
|
|
|
|
@app.post("/api/v1/upload", response_model=FileUploadResponse)
|
|
async def upload_file(
|
|
file: UploadFile = File(...),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Upload content file (Video, PDF, Image)"""
|
|
# Validate file size (max 100MB)
|
|
MAX_SIZE = 100 * 1024 * 1024
|
|
file.file.seek(0, os.SEEK_END)
|
|
file_size = file.file.tell()
|
|
file.file.seek(0)
|
|
|
|
if file_size > MAX_SIZE:
|
|
raise HTTPException(400, f"File too large (max 100MB)")
|
|
|
|
# Validate file type
|
|
allowed_types = [
|
|
"video/mp4", "video/webm",
|
|
"application/pdf",
|
|
"image/jpeg", "image/png", "image/gif",
|
|
"audio/mpeg", "audio/wav"
|
|
]
|
|
|
|
if file.content_type not in allowed_types:
|
|
raise HTTPException(400, f"File type not allowed: {file.content_type}")
|
|
|
|
# Upload to MinIO
|
|
result = await storage.upload_file(
|
|
file_data=file.file,
|
|
file_name=file.filename,
|
|
content_type=file.content_type,
|
|
creator_id=user["id"]
|
|
)
|
|
|
|
return FileUploadResponse(**result)
|
|
|
|
@app.post("/api/v1/upload/thumbnail", response_model=FileUploadResponse)
|
|
async def upload_thumbnail(
|
|
file: UploadFile = File(...),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Upload content thumbnail"""
|
|
if not file.content_type.startswith("image/"):
|
|
raise HTTPException(400, "Thumbnail must be an image")
|
|
|
|
result = await storage.upload_file(
|
|
file_data=file.file,
|
|
file_name=file.filename,
|
|
content_type=file.content_type,
|
|
creator_id=f"{user['id']}/thumbnails"
|
|
)
|
|
|
|
return FileUploadResponse(**result)
|
|
|
|
# ============= CONTENT CRUD ENDPOINTS =============
|
|
|
|
@app.post("/api/v1/content", response_model=ContentResponse)
|
|
async def create_content(
|
|
content_data: ContentCreate,
|
|
db: Session = Depends(get_db),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Create new educational content"""
|
|
# Create content
|
|
content = Content(
|
|
creator_id=user["id"],
|
|
creator_name=user["name"],
|
|
creator_email=user.get("email"),
|
|
title=content_data.title,
|
|
description=content_data.description,
|
|
content_type=content_data.content_type,
|
|
category=content_data.category,
|
|
license=content_data.license,
|
|
age_min=content_data.age_min,
|
|
age_max=content_data.age_max,
|
|
embed_url=content_data.embed_url,
|
|
status=ContentStatus.DRAFT
|
|
)
|
|
|
|
# Add tags
|
|
for tag_name in content_data.tags:
|
|
tag = db.query(Tag).filter(Tag.name == tag_name).first()
|
|
if not tag:
|
|
tag = Tag(name=tag_name)
|
|
db.add(tag)
|
|
content.tags.append(tag)
|
|
|
|
db.add(content)
|
|
db.commit()
|
|
db.refresh(content)
|
|
|
|
return content
|
|
|
|
@app.get("/api/v1/content/{content_id}", response_model=ContentWithRatings)
|
|
async def get_content(
|
|
content_id: str,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get content by ID (with ratings)"""
|
|
content = db.query(Content).filter(Content.id == content_id).first()
|
|
if not content:
|
|
raise HTTPException(404, "Content not found")
|
|
|
|
# Increment view count
|
|
content.views += 1
|
|
db.commit()
|
|
|
|
return content
|
|
|
|
@app.put("/api/v1/content/{content_id}", response_model=ContentResponse)
|
|
async def update_content(
|
|
content_id: str,
|
|
content_data: ContentUpdate,
|
|
db: Session = Depends(get_db),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Update content"""
|
|
content = db.query(Content).filter(Content.id == content_id).first()
|
|
if not content:
|
|
raise HTTPException(404, "Content not found")
|
|
|
|
# Check ownership
|
|
if content.creator_id != user["id"]:
|
|
raise HTTPException(403, "Not authorized to update this content")
|
|
|
|
# Update fields
|
|
update_data = content_data.dict(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
if field != "tags":
|
|
setattr(content, field, value)
|
|
|
|
# Update tags
|
|
if content_data.tags is not None:
|
|
content.tags.clear()
|
|
for tag_name in content_data.tags:
|
|
tag = db.query(Tag).filter(Tag.name == tag_name).first()
|
|
if not tag:
|
|
tag = Tag(name=tag_name)
|
|
db.add(tag)
|
|
content.tags.append(tag)
|
|
|
|
db.commit()
|
|
db.refresh(content)
|
|
|
|
return content
|
|
|
|
@app.delete("/api/v1/content/{content_id}")
|
|
async def delete_content(
|
|
content_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Delete content"""
|
|
content = db.query(Content).filter(Content.id == content_id).first()
|
|
if not content:
|
|
raise HTTPException(404, "Content not found")
|
|
|
|
# Check ownership or admin
|
|
if content.creator_id != user["id"] and user["role"] != "admin":
|
|
raise HTTPException(403, "Not authorized")
|
|
|
|
# Delete files from MinIO
|
|
for file_url in content.files:
|
|
# Extract object name from URL
|
|
object_name = "/".join(file_url.split("/")[-2:])
|
|
await storage.delete_file(object_name)
|
|
|
|
db.delete(content)
|
|
db.commit()
|
|
|
|
return {"message": "Content deleted"}
|
|
|
|
@app.post("/api/v1/content/{content_id}/files")
|
|
async def add_content_files(
|
|
content_id: str,
|
|
file_urls: List[str],
|
|
db: Session = Depends(get_db),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Add files to content (after upload)"""
|
|
content = db.query(Content).filter(Content.id == content_id).first()
|
|
if not content:
|
|
raise HTTPException(404, "Content not found")
|
|
|
|
if content.creator_id != user["id"]:
|
|
raise HTTPException(403, "Not authorized")
|
|
|
|
# Add file URLs
|
|
if not content.files:
|
|
content.files = []
|
|
content.files.extend(file_urls)
|
|
|
|
db.commit()
|
|
db.refresh(content)
|
|
|
|
return content
|
|
|
|
@app.post("/api/v1/content/{content_id}/publish")
|
|
async def publish_content(
|
|
content_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Publish content (make public)"""
|
|
content = db.query(Content).filter(Content.id == content_id).first()
|
|
if not content:
|
|
raise HTTPException(404, "Content not found")
|
|
|
|
if content.creator_id != user["id"]:
|
|
raise HTTPException(403, "Not authorized")
|
|
|
|
# Validate content is ready
|
|
if not content.files and not content.embed_url and not content.h5p_content_id:
|
|
raise HTTPException(400, "Content has no files/media")
|
|
|
|
content.status = ContentStatus.PUBLISHED
|
|
content.published_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
db.refresh(content)
|
|
|
|
# TODO: Publish to Matrix Feed (Sprint 3-4)
|
|
|
|
return content
|
|
|
|
# ============= SEARCH & FILTER ENDPOINTS =============
|
|
|
|
@app.get("/api/v1/content", response_model=List[ContentListItem])
|
|
async def list_content(
|
|
search: Optional[str] = None,
|
|
category: Optional[str] = None,
|
|
content_type: Optional[str] = None,
|
|
license: Optional[str] = None,
|
|
age_min: Optional[int] = None,
|
|
age_max: Optional[int] = None,
|
|
min_rating: Optional[float] = None,
|
|
status: str = "published",
|
|
skip: int = 0,
|
|
limit: int = 20,
|
|
sort_by: str = "created_at",
|
|
sort_desc: bool = True,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""List/Search content with filters"""
|
|
query = db.query(Content)
|
|
|
|
# Status filter
|
|
query = query.filter(Content.status == status)
|
|
|
|
# Search
|
|
if search:
|
|
query = query.filter(
|
|
or_(
|
|
Content.title.ilike(f"%{search}%"),
|
|
Content.description.ilike(f"%{search}%")
|
|
)
|
|
)
|
|
|
|
# Category filter
|
|
if category:
|
|
query = query.filter(Content.category == category)
|
|
|
|
# Content type filter
|
|
if content_type:
|
|
query = query.filter(Content.content_type == content_type)
|
|
|
|
# License filter
|
|
if license:
|
|
query = query.filter(Content.license == license)
|
|
|
|
# Age range filter
|
|
if age_min:
|
|
query = query.filter(Content.age_max >= age_min)
|
|
if age_max:
|
|
query = query.filter(Content.age_min <= age_max)
|
|
|
|
# Rating filter
|
|
if min_rating:
|
|
query = query.filter(Content.avg_rating >= min_rating)
|
|
|
|
# Sorting
|
|
sort_column = getattr(Content, sort_by, Content.created_at)
|
|
if sort_desc:
|
|
query = query.order_by(desc(sort_column))
|
|
else:
|
|
query = query.order_by(asc(sort_column))
|
|
|
|
# Pagination
|
|
total = query.count()
|
|
contents = query.offset(skip).limit(limit).all()
|
|
|
|
return contents
|
|
|
|
@app.get("/api/v1/content/creator/{creator_id}", response_model=List[ContentListItem])
|
|
async def get_creator_content(
|
|
creator_id: str,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all content by a creator"""
|
|
contents = db.query(Content).filter(
|
|
Content.creator_id == creator_id
|
|
).order_by(desc(Content.created_at)).all()
|
|
|
|
return contents
|
|
|
|
# ============= RATING ENDPOINTS =============
|
|
|
|
@app.post("/api/v1/content/{content_id}/rate", response_model=RatingResponse)
|
|
async def rate_content(
|
|
content_id: str,
|
|
rating_data: RatingCreate,
|
|
db: Session = Depends(get_db),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Rate content (teachers only)"""
|
|
content = db.query(Content).filter(Content.id == content_id).first()
|
|
if not content:
|
|
raise HTTPException(404, "Content not found")
|
|
|
|
# Check if user already rated
|
|
existing = db.query(Rating).filter(
|
|
Rating.content_id == content_id,
|
|
Rating.user_id == user["id"]
|
|
).first()
|
|
|
|
if existing:
|
|
# Update existing rating
|
|
existing.stars = rating_data.stars
|
|
existing.comment = rating_data.comment
|
|
rating = existing
|
|
else:
|
|
# Create new rating
|
|
rating = Rating(
|
|
content_id=content_id,
|
|
user_id=user["id"],
|
|
user_name=user.get("name"),
|
|
stars=rating_data.stars,
|
|
comment=rating_data.comment
|
|
)
|
|
db.add(rating)
|
|
|
|
# Recalculate avg rating
|
|
all_ratings = db.query(Rating).filter(Rating.content_id == content_id).all()
|
|
if all_ratings:
|
|
content.avg_rating = sum(r.stars for r in all_ratings) / len(all_ratings)
|
|
content.rating_count = len(all_ratings)
|
|
|
|
db.commit()
|
|
db.refresh(rating)
|
|
|
|
return rating
|
|
|
|
@app.get("/api/v1/content/{content_id}/ratings", response_model=List[RatingResponse])
|
|
async def get_content_ratings(
|
|
content_id: str,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all ratings for content"""
|
|
ratings = db.query(Rating).filter(
|
|
Rating.content_id == content_id
|
|
).order_by(desc(Rating.created_at)).all()
|
|
|
|
return ratings
|
|
|
|
# ============= DOWNLOAD TRACKING =============
|
|
|
|
@app.post("/api/v1/content/{content_id}/download")
|
|
async def track_download(
|
|
content_id: str,
|
|
db: Session = Depends(get_db),
|
|
user: dict = Depends(get_current_user)
|
|
):
|
|
"""Track content download"""
|
|
content = db.query(Content).filter(Content.id == content_id).first()
|
|
if not content:
|
|
raise HTTPException(404, "Content not found")
|
|
|
|
# Increment download count
|
|
content.downloads += 1
|
|
|
|
# Track download
|
|
download = Download(
|
|
content_id=content_id,
|
|
user_id=user["id"]
|
|
)
|
|
db.add(download)
|
|
db.commit()
|
|
|
|
return {"message": "Download tracked"}
|
|
|
|
# ============= ANALYTICS ENDPOINTS =============
|
|
|
|
@app.get("/api/v1/stats/platform", response_model=ContentStats)
|
|
async def get_platform_stats(db: Session = Depends(get_db)):
|
|
"""Get platform-wide statistics"""
|
|
from .models import ContentCategory, ContentType, CCLicense
|
|
|
|
total_contents = db.query(Content).filter(Content.status == "published").count()
|
|
total_downloads = db.query(func.sum(Content.downloads)).scalar() or 0
|
|
total_views = db.query(func.sum(Content.views)).scalar() or 0
|
|
avg_rating = db.query(func.avg(Content.avg_rating)).scalar() or 0.0
|
|
|
|
# By category
|
|
by_category = {}
|
|
for cat in ContentCategory:
|
|
count = db.query(Content).filter(
|
|
Content.category == cat,
|
|
Content.status == "published"
|
|
).count()
|
|
by_category[cat.value] = count
|
|
|
|
# By type
|
|
by_type = {}
|
|
for ctype in ContentType:
|
|
count = db.query(Content).filter(
|
|
Content.content_type == ctype,
|
|
Content.status == "published"
|
|
).count()
|
|
by_type[ctype.value] = count
|
|
|
|
# By license
|
|
by_license = {}
|
|
for lic in CCLicense:
|
|
count = db.query(Content).filter(
|
|
Content.license == lic,
|
|
Content.status == "published"
|
|
).count()
|
|
by_license[lic.value] = count
|
|
|
|
return ContentStats(
|
|
total_contents=total_contents,
|
|
total_downloads=total_downloads,
|
|
total_views=total_views,
|
|
avg_rating=float(avg_rating),
|
|
by_category=by_category,
|
|
by_type=by_type,
|
|
by_license=by_license
|
|
)
|
|
|
|
@app.get("/api/v1/stats/creator/{creator_id}", response_model=CreatorStats)
|
|
async def get_creator_stats(
|
|
creator_id: str,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get creator statistics"""
|
|
contents = db.query(Content).filter(
|
|
Content.creator_id == creator_id,
|
|
Content.status == "published"
|
|
).all()
|
|
|
|
if not contents:
|
|
raise HTTPException(404, "Creator not found")
|
|
|
|
total_contents = len(contents)
|
|
total_downloads = sum(c.downloads for c in contents)
|
|
total_views = sum(c.views for c in contents)
|
|
avg_rating = sum(c.avg_rating for c in contents) / total_contents if total_contents > 0 else 0.0
|
|
|
|
# Content breakdown
|
|
content_breakdown = {}
|
|
for content in contents:
|
|
cat = content.category.value
|
|
content_breakdown[cat] = content_breakdown.get(cat, 0) + 1
|
|
|
|
return CreatorStats(
|
|
creator_id=creator_id,
|
|
creator_name=contents[0].creator_name,
|
|
total_contents=total_contents,
|
|
total_downloads=total_downloads,
|
|
total_views=total_views,
|
|
avg_rating=avg_rating,
|
|
impact_score=0.0, # TODO: Calculate impact score
|
|
content_breakdown=content_breakdown
|
|
)
|
|
|
|
# ============= HEALTH CHECK =============
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
"""Health check endpoint"""
|
|
return {
|
|
"status": "healthy",
|
|
"service": "content-service",
|
|
"version": "1.0.0"
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8002)
|