from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession import asyncio from app.core.deps import ( ensure_class_access, ensure_class_permission, get_effective_class_permissions, require_role, resolve_class_id_for_user, ) from app.db.database import get_db from app.db.models import User from app.schemas.timeline import ( TimelineCreate, TimelineUpdate, TimelineOut, TimelineCommentCreate, TimelineCommentOut, ) from app.schemas.common import PageResponse from app.services.timeline_service import ( create_timeline, update_timeline, delete_timeline, get_timeline_by_id, list_timelines, add_images_to_timeline, toggle_like, create_comment, delete_comment, get_comment_by_id, list_comments, ) from app.services.cos_service import upload_image router = APIRouter(prefix="/api/timeline", tags=["timeline"]) def _build_timeline_out(p, user_id: int, include_comments: bool = False) -> TimelineOut: like_count = len(p.likes) if p.likes else 0 has_liked = any(l.user_id == user_id for l in (p.likes or [])) comment_count = len(p.comments) if p.comments else 0 comments_out = None if include_comments and p.comments: comments_out = [ TimelineCommentOut( id=c.id, post_id=c.post_id, author_id=c.author_id, author_name=c.author.name if c.author else "Unknown", content=c.content, created_at=c.created_at, updated_at=c.updated_at, ) for c in p.comments ] return TimelineOut( id=p.id, class_id=p.class_id, author_id=p.author_id, author_name=p.author.name if p.author else "Unknown", title=p.title, content=p.content, image_urls=p.get_image_urls_list(), like_count=like_count, has_liked=has_liked, comment_count=comment_count, comments=comments_out, created_at=p.created_at, updated_at=p.updated_at, ) @router.get("/", response_model=PageResponse[TimelineOut]) async def get_timelines( page: int = 1, page_size: int = 20, class_id: int | None = None, user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) ensure_class_access(user, effective_class_id) posts, total = await list_timelines(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size items = [_build_timeline_out(p, user.id) for p in posts] return PageResponse( items=items, total=total, page=page, page_size=page_size, total_pages=total_pages ) @router.post("/", response_model=TimelineOut) async def create_new_timeline( title: str = Form(...), content: str | None = Form(None), class_id: int | None = Form(None), files: list[UploadFile] = File(default=[]), user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") ensure_class_access(user, effective_class_id) data = TimelineCreate(title=title, content=content) post = await create_timeline(db, effective_class_id, user.id, data) # Upload images in parallel using thread pool image_urls: list[str] = [] if files: async def _upload_one(f: UploadFile) -> str: contents = await f.read() if len(contents) > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)") if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type") return await asyncio.to_thread(upload_image, f"timeline/{post.id}", f.filename or "image.jpg", contents, f.content_type) image_urls = await asyncio.gather(*[_upload_one(f) for f in files]) await add_images_to_timeline(db, post, image_urls) return TimelineOut( id=post.id, class_id=post.class_id, author_id=post.author_id, author_name=user.name, title=post.title, content=post.content, image_urls=image_urls, like_count=0, has_liked=False, comment_count=0, created_at=post.created_at, updated_at=post.updated_at, ) @router.post("/{post_id}/images") async def upload_timeline_images( post_id: int, files: list[UploadFile] = File(...), user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") # Student can only upload to own post; admin can upload to any in their class can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") ensure_class_access(user, post.class_id) urls = [] for f in files: contents = await f.read() if len(contents) > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)") if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type") url = await asyncio.to_thread(upload_image, f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type) urls.append(url) await add_images_to_timeline(db, post, urls) return {"image_urls": urls} @router.put("/{post_id}", response_model=TimelineOut) async def update_existing_timeline( post_id: int, data: TimelineUpdate, user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") ensure_class_access(user, post.class_id) updated = await update_timeline(db, post, data) return _build_timeline_out(updated, user.id) @router.delete("/{post_id}") async def delete_existing_timeline( post_id: int, user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") ensure_class_access(user, post.class_id) await delete_timeline(db, post) return {"message": "Timeline post deleted"} # --- Like & Comment endpoints --- @router.post("/{post_id}/like") async def like_timeline_post( post_id: int, user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") ensure_class_access(user, post.class_id) return await toggle_like(db, post_id, user.id) @router.get("/{post_id}/comments", response_model=PageResponse[TimelineCommentOut]) async def get_post_comments( post_id: int, page: int = 1, page_size: int = 50, user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") ensure_class_access(user, post.class_id) comments, total = await list_comments(db, post_id, page, page_size) total_pages = (total + page_size - 1) // page_size items = [ TimelineCommentOut( id=c.id, post_id=c.post_id, author_id=c.author_id, author_name=c.author.name if c.author else "Unknown", content=c.content, created_at=c.created_at, updated_at=c.updated_at, ) for c in comments ] return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) @router.post("/{post_id}/comments", response_model=TimelineCommentOut) async def add_post_comment( post_id: int, data: TimelineCommentCreate, user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") ensure_class_access(user, post.class_id) comment = await create_comment(db, post_id, user.id, data) return TimelineCommentOut( id=comment.id, post_id=comment.post_id, author_id=comment.author_id, author_name=comment.author.name if comment.author else "Unknown", content=comment.content, created_at=comment.created_at, updated_at=comment.updated_at, ) @router.delete("/comments/{comment_id}") async def delete_timeline_comment( comment_id: int, user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): comment = await get_comment_by_id(db, comment_id) if comment is None: raise HTTPException(status_code=404, detail="Comment not found") post = await get_timeline_by_id(db, comment.post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) if not can_manage and comment.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") ensure_class_access(user, post.class_id) await delete_comment(db, comment) return {"message": "Comment deleted"}