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>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,32 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create directory for uploads
RUN mkdir -p /app/uploads
# Expose port
EXPOSE 8002
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1
# Run application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]

View File

@@ -0,0 +1,5 @@
"""
BreakPilot Content Service
Educational Content Management & Publishing Platform
"""
__version__ = "1.0.0"

View File

@@ -0,0 +1,38 @@
"""
Database Configuration für Content Service
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import os
try:
from secret_store import get_secret
DATABASE_URL = get_secret("CONTENT_DB_URL", default=os.getenv("DATABASE_URL", ""))
except ImportError:
# Fallback wenn secret_store nicht verfügbar
DATABASE_URL = os.getenv("CONTENT_DB_URL", os.getenv("DATABASE_URL", ""))
if not DATABASE_URL:
raise ValueError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen")
# Engine configuration
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=os.getenv("SQL_ECHO", "false").lower() == "true"
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency für FastAPI
def get_db():
"""Database dependency for FastAPI endpoints"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,544 @@
"""
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)

View File

@@ -0,0 +1,249 @@
"""
Matrix Client für Content Feed Publishing
Publish Educational Content to Matrix Spaces/Rooms
"""
from matrix_nio import AsyncClient, MatrixRoom, RoomMessageText
from typing import Optional, List, Dict
import os
import logging
import json
logger = logging.getLogger(__name__)
class MatrixContentClient:
"""Matrix Client for Content Publishing"""
def __init__(self):
self.homeserver = os.getenv("MATRIX_HOMESERVER", "http://localhost:8008")
self.access_token = os.getenv("MATRIX_ACCESS_TOKEN")
self.bot_user_id = os.getenv("MATRIX_BOT_USER", "@breakpilot-bot:localhost")
# Feed Rooms
self.feed_room_id = os.getenv("MATRIX_FEED_ROOM", "!breakpilot-feed:localhost")
self.movement_room = os.getenv("MATRIX_MOVEMENT_ROOM", "!movement:localhost")
self.math_room = os.getenv("MATRIX_MATH_ROOM", "!math:localhost")
self.steam_room = os.getenv("MATRIX_STEAM_ROOM", "!steam:localhost")
self.client: Optional[AsyncClient] = None
async def connect(self):
"""Connect to Matrix homeserver"""
if not self.access_token:
logger.warning("No Matrix access token configured - Matrix integration disabled")
return False
try:
self.client = AsyncClient(self.homeserver, self.bot_user_id)
self.client.access_token = self.access_token
# Test connection
whoami = await self.client.whoami()
logger.info(f"✅ Matrix connected as {whoami.user_id}")
return True
except Exception as e:
logger.error(f"❌ Matrix connection failed: {e}")
return False
async def disconnect(self):
"""Disconnect from Matrix"""
if self.client:
await self.client.close()
async def publish_content(
self,
content_id: str,
title: str,
description: str,
content_type: str,
category: str,
license: str,
creator_name: str,
thumbnail_url: Optional[str] = None,
download_url: Optional[str] = None,
age_range: tuple = (6, 18),
tags: List[str] = []
) -> Optional[str]:
"""
Publish content to Matrix feed
Returns:
Matrix event_id if successful, None otherwise
"""
if not self.client:
logger.warning("Matrix client not connected")
return None
try:
# Select room based on category
room_id = self._get_room_for_category(category)
# Format message
message = self._format_content_message(
content_id=content_id,
title=title,
description=description,
content_type=content_type,
category=category,
license=license,
creator_name=creator_name,
thumbnail_url=thumbnail_url,
download_url=download_url,
age_range=age_range,
tags=tags
)
# Send message
response = await self.client.room_send(
room_id=room_id,
message_type="m.educational.content",
content={
"msgtype": "m.text",
"body": message["plain"],
"format": "org.matrix.custom.html",
"formatted_body": message["html"],
"info": message["metadata"]
}
)
logger.info(f"✅ Content published to Matrix: {content_id}{room_id}")
return response.event_id
except Exception as e:
logger.error(f"❌ Failed to publish content to Matrix: {e}")
return None
def _get_room_for_category(self, category: str) -> str:
"""Map content category to Matrix room"""
category_map = {
"movement": self.movement_room,
"math": self.math_room,
"steam": self.steam_room,
}
return category_map.get(category, self.feed_room_id)
def _format_content_message(
self,
content_id: str,
title: str,
description: str,
content_type: str,
category: str,
license: str,
creator_name: str,
thumbnail_url: Optional[str],
download_url: Optional[str],
age_range: tuple,
tags: List[str]
) -> Dict[str, any]:
"""Format content for Matrix message"""
# Content type emoji
type_emoji = {
"video": "📹",
"pdf": "📄",
"image_gallery": "🖼️",
"markdown": "📝",
"audio": "🎵",
"h5p": "🎓"
}.get(content_type, "📦")
# Category emoji
category_emoji = {
"movement": "🏃",
"math": "🔢",
"steam": "🔬",
"language": "📖",
"arts": "🎨",
"social": "🤝",
"mindfulness": "🧘"
}.get(category, "📚")
# Plain text version
plain = f"""
{type_emoji} {title}
{description}
📝 Von: {creator_name}
{category_emoji} Kategorie: {category}
👥 Alter: {age_range[0]}-{age_range[1]} Jahre
⚖️ Lizenz: {license}
🏷️ Tags: {', '.join(tags) if tags else 'Keine'}
{download_url or f'https://breakpilot.app/content/{content_id}'}
""".strip()
# HTML version with better formatting
html = f"""
<div class="breakpilot-content">
<h3>{type_emoji} {title}</h3>
<p>{description}</p>
{f'<img src="{thumbnail_url}" alt="{title}" style="max-width: 400px; border-radius: 8px;" />' if thumbnail_url else ''}
<div class="metadata">
<p><strong>📝 Creator:</strong> {creator_name}</p>
<p><strong>{category_emoji} Kategorie:</strong> {category}</p>
<p><strong>👥 Altersgruppe:</strong> {age_range[0]}-{age_range[1]} Jahre</p>
<p><strong>⚖️ Lizenz:</strong> <a href="https://creativecommons.org/licenses/{license.lower().replace('cc-', '').replace('-', '/')}/4.0/">{license}</a></p>
{f'<p><strong>🏷️ Tags:</strong> {", ".join(tags)}</p>' if tags else ''}
</div>
<p>
<a href="{download_url or f'https://breakpilot.app/content/{content_id}'}" style="background: #3498db; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
📥 Inhalt ansehen/herunterladen
</a>
</p>
</div>
""".strip()
# Metadata for indexing & reactions
metadata = {
"content_id": content_id,
"creator": creator_name,
"license": license,
"category": category,
"content_type": content_type,
"age_range": list(age_range),
"tags": tags,
"download_url": download_url,
"thumbnail_url": thumbnail_url
}
return {
"plain": plain,
"html": html,
"metadata": metadata
}
async def update_content_announcement(
self,
event_id: str,
room_id: str,
new_stats: Dict[str, any]
):
"""Update content message with new stats (downloads, ratings)"""
try:
# TODO: Edit message to add stats
# Matrix nio doesn't support edit yet easily - would need to send m.room.message with m.replace
pass
except Exception as e:
logger.error(f"Failed to update content announcement: {e}")
async def create_discussion_thread(
self,
content_id: str,
room_id: str,
event_id: str
) -> Optional[str]:
"""Create threaded discussion for content"""
try:
# TODO: Create thread (Matrix threading)
pass
except Exception as e:
logger.error(f"Failed to create discussion thread: {e}")
return None
# Global instance
matrix_client = MatrixContentClient()

View File

@@ -0,0 +1,164 @@
"""
BreakPilot Content Service - Database Models
Educational Content Management mit Creative Commons Lizenzen
"""
from sqlalchemy import Column, String, Integer, Float, DateTime, JSON, ForeignKey, Enum, Table
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
import uuid
Base = declarative_base()
# Content Type Enum
class ContentType(str, enum.Enum):
VIDEO = "video"
PDF = "pdf"
IMAGE_GALLERY = "image_gallery"
MARKDOWN = "markdown"
AUDIO = "audio"
H5P = "h5p"
# CC License Enum
class CCLicense(str, enum.Enum):
CC_BY = "CC-BY-4.0"
CC_BY_SA = "CC-BY-SA-4.0"
CC_BY_NC = "CC-BY-NC-4.0"
CC_BY_NC_SA = "CC-BY-NC-SA-4.0"
CC0 = "CC0-1.0"
# Category Enum
class ContentCategory(str, enum.Enum):
MOVEMENT = "movement"
MATH = "math"
STEAM = "steam"
LANGUAGE = "language"
ARTS = "arts"
SOCIAL = "social"
MINDFULNESS = "mindfulness"
# Content Status
class ContentStatus(str, enum.Enum):
DRAFT = "draft"
REVIEW = "review"
PUBLISHED = "published"
ARCHIVED = "archived"
# Many-to-Many: Content <-> Tags
content_tags = Table(
'content_tags',
Base.metadata,
Column('content_id', String, ForeignKey('contents.id')),
Column('tag_id', String, ForeignKey('tags.id'))
)
class Content(Base):
"""Educational Content Model"""
__tablename__ = 'contents'
# Primary Key
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
# Creator Info
creator_id = Column(String, nullable=False, index=True) # User ID from consent-service
creator_name = Column(String, nullable=False)
creator_email = Column(String)
# Content Metadata
title = Column(String(500), nullable=False, index=True)
description = Column(String(5000))
content_type = Column(Enum(ContentType), nullable=False)
category = Column(Enum(ContentCategory), nullable=False, index=True)
# License
license = Column(Enum(CCLicense), nullable=False, default=CCLicense.CC_BY_SA)
# Age Range
age_min = Column(Integer, default=6)
age_max = Column(Integer, default=18)
# Files & URLs
files = Column(JSON, default=list) # List of file URLs in MinIO
thumbnail_url = Column(String)
embed_url = Column(String) # For YouTube, Vimeo, etc.
# H5P Specific
h5p_content_id = Column(String) # H5P content ID if type=h5p
# Matrix Integration
matrix_room_id = Column(String) # Associated Matrix room for discussion
matrix_event_id = Column(String) # Matrix message event ID
# Status
status = Column(Enum(ContentStatus), default=ContentStatus.DRAFT, index=True)
# Analytics
downloads = Column(Integer, default=0)
views = Column(Integer, default=0)
avg_rating = Column(Float, default=0.0)
rating_count = Column(Integer, default=0)
impact_score = Column(Float, default=0.0) # Future: Impact-Scoring
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
published_at = Column(DateTime)
# Relationships
ratings = relationship("Rating", back_populates="content", cascade="all, delete-orphan")
tags = relationship("Tag", secondary=content_tags, back_populates="contents")
def __repr__(self):
return f"<Content {self.title} ({self.content_type})>"
class Rating(Base):
"""Content Ratings by Teachers"""
__tablename__ = 'ratings'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
content_id = Column(String, ForeignKey('contents.id'), nullable=False)
user_id = Column(String, nullable=False, index=True) # Teacher ID
user_name = Column(String)
stars = Column(Integer, nullable=False) # 1-5
comment = Column(String(2000))
created_at = Column(DateTime, default=datetime.utcnow)
# Relationship
content = relationship("Content", back_populates="ratings")
def __repr__(self):
return f"<Rating {self.stars}★ for Content {self.content_id}>"
class Tag(Base):
"""Content Tags for Search/Filter"""
__tablename__ = 'tags'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), unique=True, nullable=False, index=True)
category = Column(String(100)) # Optional grouping
# Relationship
contents = relationship("Content", secondary=content_tags, back_populates="tags")
def __repr__(self):
return f"<Tag {self.name}>"
class Download(Base):
"""Download Tracking (für Impact-Scoring)"""
__tablename__ = 'downloads'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
content_id = Column(String, ForeignKey('contents.id'), nullable=False, index=True)
user_id = Column(String, nullable=False, index=True) # Teacher ID
downloaded_at = Column(DateTime, default=datetime.utcnow, index=True)
ip_address = Column(String) # Optional, anonymisiert nach 7 Tagen
def __repr__(self):
return f"<Download {self.content_id} by {self.user_id}>"

View File

@@ -0,0 +1,35 @@
# BreakPilot Content Service Dependencies
# FastAPI & Web Framework
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database
sqlalchemy==2.0.25
psycopg2-binary==2.9.9
alembic==1.13.1
# Storage
minio==7.2.3
# Validation
pydantic==2.5.3
email-validator==2.1.0
# Matrix SDK (für Sprint 3-4)
matrix-nio==0.24.0
# Authentication (JWT)
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
# HTTP Client
httpx==0.26.0
# Image Processing (für Thumbnails)
pillow==10.2.0
# Utils
python-dateutil==2.8.2

View File

@@ -0,0 +1,191 @@
"""
Pydantic Schemas für Content Service API
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, List
from datetime import datetime
from models import ContentType, CCLicense, ContentCategory, ContentStatus
# ============= REQUEST SCHEMAS =============
class ContentCreate(BaseModel):
"""Schema für Content Creation"""
title: str = Field(..., min_length=3, max_length=500)
description: Optional[str] = Field(None, max_length=5000)
content_type: ContentType
category: ContentCategory
license: CCLicense = CCLicense.CC_BY_SA
age_min: int = Field(6, ge=3, le=18)
age_max: int = Field(18, ge=3, le=18)
embed_url: Optional[str] = None
tags: List[str] = []
@validator('age_max')
def age_max_must_be_greater(cls, v, values):
if 'age_min' in values and v < values['age_min']:
raise ValueError('age_max must be >= age_min')
return v
class ContentUpdate(BaseModel):
"""Schema für Content Update"""
title: Optional[str] = Field(None, min_length=3, max_length=500)
description: Optional[str] = Field(None, max_length=5000)
category: Optional[ContentCategory] = None
license: Optional[CCLicense] = None
age_min: Optional[int] = Field(None, ge=3, le=18)
age_max: Optional[int] = Field(None, ge=3, le=18)
embed_url: Optional[str] = None
tags: Optional[List[str]] = None
status: Optional[ContentStatus] = None
class RatingCreate(BaseModel):
"""Schema für Rating Creation"""
stars: int = Field(..., ge=1, le=5)
comment: Optional[str] = Field(None, max_length=2000)
# ============= RESPONSE SCHEMAS =============
class TagResponse(BaseModel):
"""Tag Response"""
id: str
name: str
category: Optional[str]
class Config:
from_attributes = True
class RatingResponse(BaseModel):
"""Rating Response"""
id: str
user_id: str
user_name: Optional[str]
stars: int
comment: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class ContentResponse(BaseModel):
"""Content Response (Full)"""
id: str
creator_id: str
creator_name: str
creator_email: Optional[str]
title: str
description: Optional[str]
content_type: ContentType
category: ContentCategory
license: CCLicense
age_min: int
age_max: int
files: List[str]
thumbnail_url: Optional[str]
embed_url: Optional[str]
h5p_content_id: Optional[str]
matrix_room_id: Optional[str]
matrix_event_id: Optional[str]
status: ContentStatus
downloads: int
views: int
avg_rating: float
rating_count: int
impact_score: float
created_at: datetime
updated_at: datetime
published_at: Optional[datetime]
tags: List[TagResponse] = []
class Config:
from_attributes = True
class ContentListItem(BaseModel):
"""Content Response (List View - simplified)"""
id: str
creator_name: str
title: str
description: Optional[str]
content_type: ContentType
category: ContentCategory
license: CCLicense
thumbnail_url: Optional[str]
avg_rating: float
rating_count: int
downloads: int
created_at: datetime
tags: List[str] = []
class Config:
from_attributes = True
class ContentWithRatings(ContentResponse):
"""Content with ratings included"""
ratings: List[RatingResponse] = []
class Config:
from_attributes = True
# ============= FILTER SCHEMAS =============
class ContentFilter(BaseModel):
"""Search/Filter Parameters"""
search: Optional[str] = None
category: Optional[ContentCategory] = None
content_type: Optional[ContentType] = None
license: Optional[CCLicense] = None
age_min: Optional[int] = None
age_max: Optional[int] = None
tags: Optional[List[str]] = None
min_rating: Optional[float] = None
status: Optional[ContentStatus] = ContentStatus.PUBLISHED
creator_id: Optional[str] = None
# Pagination
skip: int = Field(0, ge=0)
limit: int = Field(20, ge=1, le=100)
# Sorting
sort_by: str = Field("created_at", pattern="^(created_at|avg_rating|downloads|title)$")
sort_desc: bool = True
# ============= ANALYTICS SCHEMAS =============
class ContentStats(BaseModel):
"""Content Statistics"""
total_contents: int
total_downloads: int
total_views: int
avg_rating: float
by_category: dict
by_type: dict
by_license: dict
class CreatorStats(BaseModel):
"""Creator Statistics"""
creator_id: str
creator_name: str
total_contents: int
total_downloads: int
total_views: int
avg_rating: float
impact_score: float
content_breakdown: dict
# ============= UPLOAD SCHEMAS =============
class FileUploadResponse(BaseModel):
"""File Upload Response"""
file_url: str
file_name: str
file_size: int
content_type: str

View File

@@ -0,0 +1,161 @@
"""
MinIO/S3 Storage Integration für Content Files
"""
from minio import Minio
from minio.error import S3Error
import os
import io
import uuid
from typing import BinaryIO, Optional
from pathlib import Path
import mimetypes
class StorageService:
"""MinIO/S3 Storage Service"""
def __init__(self):
self.endpoint = os.getenv("MINIO_ENDPOINT", "localhost:9000")
self.access_key = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
self.secret_key = os.getenv("MINIO_SECRET_KEY", "minioadmin")
self.secure = os.getenv("MINIO_SECURE", "false").lower() == "true"
self.bucket_name = os.getenv("MINIO_BUCKET", "breakpilot-content")
# Initialize MinIO client
self.client = Minio(
self.endpoint,
access_key=self.access_key,
secret_key=self.secret_key,
secure=self.secure
)
# Ensure bucket exists
self._ensure_bucket()
def _ensure_bucket(self):
"""Create bucket if it doesn't exist"""
try:
if not self.client.bucket_exists(self.bucket_name):
self.client.make_bucket(self.bucket_name)
print(f"✅ Created MinIO bucket: {self.bucket_name}")
# Set public read policy for content
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{self.bucket_name}/*"]
}
]
}
import json
self.client.set_bucket_policy(self.bucket_name, json.dumps(policy))
except S3Error as e:
print(f"⚠️ MinIO bucket check failed: {e}")
async def upload_file(
self,
file_data: BinaryIO,
file_name: str,
content_type: Optional[str] = None,
creator_id: str = "unknown"
) -> dict:
"""
Upload file to MinIO
Args:
file_data: File binary data
file_name: Original filename
content_type: MIME type
creator_id: Creator ID for folder organization
Returns:
dict with file_url, file_name, file_size
"""
try:
# Generate unique filename
file_ext = Path(file_name).suffix
unique_name = f"{uuid.uuid4()}{file_ext}"
object_name = f"{creator_id}/{unique_name}"
# Detect content type if not provided
if not content_type:
content_type, _ = mimetypes.guess_type(file_name)
content_type = content_type or "application/octet-stream"
# Get file size
file_data.seek(0, os.SEEK_END)
file_size = file_data.tell()
file_data.seek(0)
# Upload to MinIO
self.client.put_object(
bucket_name=self.bucket_name,
object_name=object_name,
data=file_data,
length=file_size,
content_type=content_type
)
# Generate public URL
file_url = f"http://{self.endpoint}/{self.bucket_name}/{object_name}"
return {
"file_url": file_url,
"file_name": file_name,
"file_size": file_size,
"content_type": content_type,
"object_name": object_name
}
except S3Error as e:
raise Exception(f"MinIO upload failed: {e}")
async def delete_file(self, object_name: str):
"""Delete file from MinIO"""
try:
self.client.remove_object(self.bucket_name, object_name)
except S3Error as e:
raise Exception(f"MinIO delete failed: {e}")
async def get_presigned_url(self, object_name: str, expires_in: int = 3600) -> str:
"""
Generate presigned URL for private files
Args:
object_name: Object path in bucket
expires_in: URL expiry in seconds (default 1 hour)
Returns:
Presigned URL
"""
try:
from datetime import timedelta
url = self.client.presigned_get_object(
self.bucket_name,
object_name,
expires=timedelta(seconds=expires_in)
)
return url
except S3Error as e:
raise Exception(f"MinIO presigned URL failed: {e}")
async def upload_thumbnail(
self,
file_data: BinaryIO,
file_name: str,
creator_id: str
) -> str:
"""Upload thumbnail image"""
result = await self.upload_file(
file_data=file_data,
file_name=file_name,
content_type="image/jpeg",
creator_id=f"{creator_id}/thumbnails"
)
return result["file_url"]
# Global storage instance
storage = StorageService()