""" 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)