diff --git a/backend/app/daos/post.py b/backend/app/daos/post.py index 3ffe052c..48124c1d 100644 --- a/backend/app/daos/post.py +++ b/backend/app/daos/post.py @@ -1,14 +1,26 @@ -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union +from fastapi import HTTPException from sqlalchemy import or_ from sqlalchemy.orm import joinedload -from app.models.classroom import Post +from app.models.classroom import Activity, Post, PostAttachment +from app.models.common import Response +from app.schemas.classroom import ActivityPostIn, PostIn, ResponsePostIn from .base import BaseDao class PostDao(BaseDao): + + def get_by_id(self, postId: int) -> Post: + _post = self.session.query(Post).get(postId) + + if not _post: + raise HTTPException(status_code=404, detail="Post não encontrado") + + return _post + def get_posts( self, classroom_ids: List[int], @@ -20,11 +32,11 @@ def get_posts( query = ( self.session.query(Post) .options(joinedload(Post.classroom)) - .filter(Post.classromId.in_(classroom_ids)) + .filter(Post.classroomId.in_(classroom_ids)) ) if subject_id: - query = query.filter(Post.classromId == subject_id) + query = query.filter(Post.classroomId == subject_id) if search_query: query = query.filter( @@ -44,3 +56,148 @@ def get_posts( ) return posts, total + + def _create_post( + self, + post: Union[PostIn, ActivityPostIn, ResponsePostIn], + classroomId: int, + userId: int, + ) -> Post: + _post = Post( + classroomId=classroomId, + addedById=userId, + title=post.title, + content=post.content, + type=post.type, + ) + + if post.attachments: + _post.attachments = ( + self.session.query(PostAttachment) + .filter(PostAttachment.id.in_(post.attachments)) + .all() + ) + + self.session.add(_post) + self.session.commit() + self.session.refresh(_post) + + return _post + + def create_notice(self, post: PostIn, classroomId: int, userId: int) -> Post: + return self._create_post(post, classroomId, userId) + + def create_lecture(self, post: PostIn, classroomId: int, userId: int) -> Post: + return self._create_post(post, classroomId, userId) + + def create_activity( + self, post: ActivityPostIn, classroomId: int, userId: int + ) -> Post: + _post = self._create_post(post, classroomId, userId) + + # TODO: Create or Add (?) Activity Group + + _activity = Activity( + postId=_post.id, + startDate=post.startDate, + endDate=post.endDate, + maxGrade=post.maxGrade, + linearCoefficient=post.linearCoefficient, + angularCoefficient=post.angularCoefficient, + weight=post.weight, + ) + + self.session.add(_activity) + self.session.commit() + + self.session.refresh(_post) + + return _post + + def create_test(self, post: ActivityPostIn, classroomId: int, userId: int) -> Post: + return self.create_activity(post, classroomId, userId) + + def create_response( + self, post: ResponsePostIn, classroomId: int, userId: int + ) -> Post: + _post = self._create_post(post, classroomId, userId) + + _response = Response( + postId=_post.id, activityId=post.activityId, grade=post.grade + ) + + self.session.add(_response) + self.session.commit() + + self.session.refresh(_post) + + return _post + + def _update_post( + self, postId: int, post: Union[PostIn, ActivityPostIn, ResponsePostIn] + ) -> Post: + _post = self.get_by_id(postId) + + _post.title = post.title + _post.content = post.content + + if post.attachments: + _post.attachments = ( + self.session.query(PostAttachment) + .filter(PostAttachment.id.in_(post.attachments)) + .all() + ) + + self.session.commit() + self.session.refresh(_post) + + return _post + + def update_notice(self, postId: int, post: PostIn) -> Post: + return self._update_post(postId, post) + + def update_lecture(self, postId: int, post: PostIn) -> Post: + return self._update_post(postId, post) + + def update_activity(self, postId: int, post: ActivityPostIn) -> Post: + + _post = self._update_post(postId, post) + + ACTIVITY_KEYS = [ + "startDate", + "endDate", + "maxGrade", + "linearCoefficient", + "angularCoefficient", + "weight", + ] + + if _post.activity: + for key, value in post.model_dump().items(): + if key in ACTIVITY_KEYS: + setattr(_post.activity, key, value) + + self.session.commit() + self.session.refresh(_post) + + return _post + + def update_test(self, postId: int, post: ActivityPostIn) -> Post: + return self.update_activity(postId, post) + + def update_response(self, postId: int, post: ResponsePostIn) -> Post: + _post = self._update_post(postId, post) + + if _post.response: + _post.response.activityId = post.activityId + _post.response.grade = post.grade + + self.session.commit() + self.session.refresh(_post) + + return _post + + def delete_by_id(self, postId: int): + _post = self.get_by_id(postId) + self.session.delete(_post) + self.session.commit() diff --git a/backend/app/models/classroom.py b/backend/app/models/classroom.py index 03db1bc9..10e84925 100644 --- a/backend/app/models/classroom.py +++ b/backend/app/models/classroom.py @@ -1,8 +1,8 @@ from datetime import datetime from typing import List, Optional, get_args -from sqlalchemy import JSON import sqlalchemy as sa +from sqlalchemy import JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import BaseTable @@ -17,14 +17,16 @@ class Classroom(BaseTable): name: Mapped[str] thumbnail: Mapped["Attach"] = relationship() - posts: Mapped[List["Post"]] = relationship(back_populates="classroom") + posts: Mapped[List["Post"]] = relationship( + back_populates="classroom", cascade="all, delete" + ) activityGroups: Mapped[List["ActivityGroup"]] = relationship( back_populates="classroom" ) members: Mapped[List["Enrollment"]] = relationship( - back_populates="classroom" + back_populates="classroom", cascade="all, delete" ) - video_conference: Mapped[List[str]] = mapped_column(JSON) + video_conference: Mapped[List[str]] = mapped_column(JSON, default="") @property def teachers(self): @@ -34,9 +36,7 @@ def teachers(self): class Activity(BaseTable): __tablename__ = "activities" - postId: Mapped[Optional[int]] = mapped_column( - sa.Integer, sa.ForeignKey("posts.id") - ) + postId: Mapped[Optional[int]] = mapped_column(sa.Integer, sa.ForeignKey("posts.id")) post: Mapped["Post"] = relationship("Post", back_populates="activity") startDate: Mapped[Optional[datetime]] @@ -56,64 +56,58 @@ class Activity(BaseTable): class Serie(BaseTable): __tablename__ = "series" - student: Mapped[List["Student"]] = relationship( - "Student", back_populates="serie" - ) + student: Mapped[List["Student"]] = relationship("Student", back_populates="serie") name: Mapped[str] class Post(BaseTable): __tablename__ = "posts" - classromId: Mapped[Optional[int]] = mapped_column( + classroomId: Mapped[Optional[int]] = mapped_column( sa.Integer, sa.ForeignKey("classrooms.id") ) - classroom: Mapped["Classroom"] = relationship( - "Classroom", back_populates="posts" - ) + classroom: Mapped["Classroom"] = relationship("Classroom", back_populates="posts") title: Mapped[str] content: Mapped[str] frequency: Mapped[List["Frequency"]] = relationship( - "Frequency", back_populates="lecture" + "Frequency", back_populates="lecture", cascade="all, delete" ) type: Mapped[PostType] = mapped_column(sa.Enum(*get_args(PostType))) attachments: Mapped[List["PostAttachment"]] = relationship( - "PostAttachment", back_populates="post" + "PostAttachment", back_populates="post", cascade="all, delete" ) activity: Mapped[Optional["Activity"]] = relationship( - "Activity", back_populates="post" + "Activity", back_populates="post", cascade="all, delete" ) response: Mapped[Optional["Response"]] = relationship( - "Response", back_populates="post", foreign_keys="Response.postId" + "Response", + back_populates="post", + foreign_keys="Response.postId", + cascade="all, delete", ) responses: Mapped[List["Response"]] = relationship( "Response", back_populates="activity", foreign_keys="Response.activityId", + cascade="all, delete", ) comments: Mapped[List["Comment"]] = relationship( - "Comment", back_populates="post" + "Comment", back_populates="post", cascade="all, delete" ) messages: Mapped[List["Message"]] = relationship( - "Message", back_populates="post" + "Message", back_populates="post", cascade="all, delete" ) class Frequency(BaseTable): __tablename__ = "frequencies" - studentId: Mapped[int] = mapped_column( - sa.Integer, sa.ForeignKey("users.id") - ) + studentId: Mapped[int] = mapped_column(sa.Integer, sa.ForeignKey("users.id")) student: Mapped["User"] = relationship("User", foreign_keys=[studentId]) lecture: Mapped["Post"] = relationship("Post", back_populates="frequency") - postId: Mapped[Optional[int]] = mapped_column( - sa.Integer, sa.ForeignKey("posts.id") - ) - status: Mapped[FrequencyStatus] = mapped_column( - sa.Enum(*get_args(FrequencyStatus)) - ) + postId: Mapped[Optional[int]] = mapped_column(sa.Integer, sa.ForeignKey("posts.id")) + status: Mapped[FrequencyStatus] = mapped_column(sa.Enum(*get_args(FrequencyStatus))) justification: Mapped[Optional[str]] diff --git a/backend/app/models/enum.py b/backend/app/models/enum.py index fab788ab..ff5202ec 100644 --- a/backend/app/models/enum.py +++ b/backend/app/models/enum.py @@ -7,7 +7,7 @@ AchievementType = Literal["olympic medal", "certificate"] AchievementStatus = Literal["ready", "pending", "soft delete"] MedalType = Literal["participation", "bronze", "silver", "gold"] -PostType = Literal["notice", "class material", "activity", "other"] +PostType = Literal["notice", "lecture", "activity", "test", "response"] FrequencyStatus = Literal["present", "missed", "justified"] CommentType = Literal["public", "private"] ExpenseLogType = Literal["deposit", "removal"] diff --git a/backend/app/routers/post.py b/backend/app/routers/post.py index 74dda527..2310fec3 100644 --- a/backend/app/routers/post.py +++ b/backend/app/routers/post.py @@ -1,22 +1,27 @@ -from typing import Optional +from typing import Optional, Union from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session +from starlette.status import HTTP_200_OK, HTTP_201_CREATED from app.daos.admin import AdminDao +from app.daos.classroom import ClassroomDao from app.daos.general import GeneralDao from app.daos.post import PostDao from app.models.user import User from app.schemas.classroom import ( + ActivityPostIn, Author, ClassroomBase, ClassroomOut, CommentInp, CommentOut, + PostIn, PostResponse, + ResponsePostIn, ) from app.services.db import get_session -from app.services.decorators import paginated_response +from app.services.decorators import paginated_response, post_type_response from app.services.user import UserService router = APIRouter(prefix="/post", tags=["post"]) @@ -83,6 +88,52 @@ async def add_comment( return response +@router.post("", status_code=HTTP_201_CREATED) +@post_type_response +async def create_post( + classroomId: int, + post: Union[PostIn, ActivityPostIn, ResponsePostIn], + current_user: User = Depends(UserService.get_current_user), + session: Session = Depends(get_session), +): + + _is_member = ClassroomDao(session).is_member(current_user.id, classroomId) + + if current_user.type not in ["admin", "other"] and not _is_member: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário não autorizado", + ) + + create_post_by_type = getattr(PostDao(session), f"create_{post.type}") + + _post = create_post_by_type(post, classroomId, current_user.id) + + return _post + + +@router.get("/{postId}", status_code=status.HTTP_200_OK) +@post_type_response +async def get_post( + postId: int, + classroomId: int, + current_user: User = Depends(UserService.get_current_user), + session: Session = Depends(get_session), +): + + _is_member = ClassroomDao(session).is_member(current_user.id, classroomId) + + if not _is_member: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário não autorizado", + ) + + _post = PostDao(session).get_by_id(postId) + + return _post + + @router.get("", status_code=status.HTTP_200_OK) @paginated_response async def get_all_posts( @@ -108,9 +159,9 @@ async def get_all_posts( return [PostResponse.model_validate(post) for post in posts] -@router.put("/classrooms/{classroom_id}", status_code=status.HTTP_200_OK) +@router.put("/classrooms/{classroomId}", status_code=status.HTTP_200_OK) async def update_classroom( - classroom_id: int, + classroomId: int, classroom: ClassroomBase, current_user: User = Depends(UserService.get_current_user), session: Session = Depends(get_session), @@ -121,15 +172,54 @@ async def update_classroom( detail="Usuário não autorizado", ) - _classroom = AdminDao(session).update_classroom(classroom, classroom_id) + _classroom = AdminDao(session).update_classroom(classroom, classroomId) if _classroom is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Classroom not found" ) return ClassroomOut.model_validate(_classroom) - _classroom = AdminDao(session).update_classroom(classroom, classroom_id) - if _classroom is None: + + +@router.put("/{postId}", status_code=HTTP_200_OK) +@post_type_response +async def update_post( + postId: int, + classroomId: int, + post: Union[PostIn, ActivityPostIn, ResponsePostIn], + current_user: User = Depends(UserService.get_current_user), + session: Session = Depends(get_session), +): + _is_member = ClassroomDao(session).is_member(current_user.id, classroomId) + + if current_user.type not in ["admin", "other"] and not _is_member: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Classroom not found" + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário não autorizado", ) - return ClassroomOut.model_validate(_classroom) + + update_post_by_type = getattr(PostDao(session), f"update_{post.type}") + + _post = update_post_by_type(postId, post) + + return _post + + +@router.delete("/{postId}", status_code=HTTP_200_OK) +async def delete_post( + postId: int, + classroomId: int, + current_user: User = Depends(UserService.get_current_user), + session: Session = Depends(get_session), +): + + _is_member = ClassroomDao(session).is_member(current_user.id, classroomId) + + if current_user.type not in ["admin", "other"] and not _is_member: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário não autorizado", + ) + + PostDao(session).delete_by_id(postId) + + return {"message": "Post deletado com sucesso"} diff --git a/backend/app/schemas/classroom.py b/backend/app/schemas/classroom.py index 8f8de16e..9d851ba2 100644 --- a/backend/app/schemas/classroom.py +++ b/backend/app/schemas/classroom.py @@ -1,11 +1,10 @@ -from datetime import date, datetime -from typing import Optional, List +from datetime import datetime +from typing import List, Literal, Optional from pydantic import BaseModel -from app.models.enum import PostType, Role - -from .user import UserPoster +from app.models.enum import AttachType, FrequencyStatus, PostType, Role +from app.schemas.user import UserMinimal class EnrollmentBase(BaseModel): @@ -36,32 +35,98 @@ class ClassroomOut(ClassroomBase): teachers: list[TeacherOut] = [] video_conference: List[str] -class Penalty(BaseModel): - angularCoefficient: float - linearCoefficient: float + +class ActivityGroup(BaseModel): + pass + + +class Frequency(BaseModel): + student: UserMinimal + status: FrequencyStatus + justification: Optional[str] + + +class PostAttachment(BaseModel): + id: int + name: str + type: AttachType + metadata: str + + class Config: + from_attributes = True class PostBase(BaseModel): title: str content: str - postBy: UserPoster - type: PostType - endsOn: Optional[date] + attachments: Optional[list[int]] + + class Config: + from_attributes = True + + +class PostIn(PostBase): + type: Literal["notice", "lecture"] + + +class ActivityBase(BaseModel): + startDate: Optional[datetime] + endDate: Optional[datetime] maxGrade: Optional[float] - penalty: Optional[Penalty] + linearCoefficient: Optional[float] + angularCoefficient: Optional[float] weight: float = 1 + +class Activity(ActivityBase): + id: int + activityGroup: Optional[ActivityGroup] + + class Config: + from_attributes = True + + +class ResponseBase(BaseModel): + grade: Optional[float] + + +class Response(ResponseBase): + id: int + class Config: from_attributes = True +class ActivityPostIn(ActivityBase, PostBase): + type: Literal["activity", "test"] + activityGroupId: Optional[int] + + class PostOut(PostBase): id: int + type: PostType createdAt: datetime + addedBy: UserMinimal + # frequency: list[Frequency] + # responses: list[Response] + # comments: list[CommentOut] + + +class ActivityPostOut(PostOut): + activity: Activity + + +class ResponsePostIn(ResponseBase, PostBase): + type: str = "response" + activityId: int + + +class ResponsePostOut(PostOut): + response: Response class PostResponse(PostOut): - subject: ClassroomOut + classroom: ClassroomOut class CommentInp(BaseModel): diff --git a/backend/app/services/decorators.py b/backend/app/services/decorators.py index d7e09d2c..3a93263c 100644 --- a/backend/app/services/decorators.py +++ b/backend/app/services/decorators.py @@ -1,6 +1,8 @@ from functools import wraps from typing import Any, Callable, TypeVar +from app.schemas.classroom import ActivityPostOut, PostOut, ResponsePostOut + F = TypeVar("F", bound=Callable[..., Any]) @@ -27,3 +29,23 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: } return wrapper # type: ignore + + +def post_type_response(function: F) -> F: + @wraps(function) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + response = await function(*args, **kwargs) + + post_out_model = { + "notice": PostOut, + "lecture": PostOut, + "activity": ActivityPostOut, + "test": ActivityPostOut, + "response": ResponsePostOut, + } + + _type = response.type + + return post_out_model.get(_type, PostOut).model_validate(response) + + return wrapper # type: ignore