This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/content_service/main.py
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
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>
2026-02-09 09:51:32 +01:00

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)