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:
32
backend/content_service/Dockerfile
Normal file
32
backend/content_service/Dockerfile
Normal 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"]
|
||||
5
backend/content_service/__init__.py
Normal file
5
backend/content_service/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
BreakPilot Content Service
|
||||
Educational Content Management & Publishing Platform
|
||||
"""
|
||||
__version__ = "1.0.0"
|
||||
38
backend/content_service/database.py
Normal file
38
backend/content_service/database.py
Normal 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()
|
||||
544
backend/content_service/main.py
Normal file
544
backend/content_service/main.py
Normal 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)
|
||||
249
backend/content_service/matrix_client.py
Normal file
249
backend/content_service/matrix_client.py
Normal 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()
|
||||
164
backend/content_service/models.py
Normal file
164
backend/content_service/models.py
Normal 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}>"
|
||||
35
backend/content_service/requirements.txt
Normal file
35
backend/content_service/requirements.txt
Normal 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
|
||||
191
backend/content_service/schemas.py
Normal file
191
backend/content_service/schemas.py
Normal 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
|
||||
161
backend/content_service/storage.py
Normal file
161
backend/content_service/storage.py
Normal 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()
|
||||
Reference in New Issue
Block a user