diff --git a/api/app/application/courses_repository.py b/api/app/application/courses_repository.py index 0fd4494..69c9483 100644 --- a/api/app/application/courses_repository.py +++ b/api/app/application/courses_repository.py @@ -1,9 +1,10 @@ -from typing import List, Optional +from typing import List, Optional, Sequence from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload -from app.domain.models import Course +from app.domain.models import Course, CourseTeacher, Enrollment class CoursesRepository: @@ -11,14 +12,50 @@ class CoursesRepository: self.db = db async def get_all(self) -> List[Course]: - query = select(Course) + query = ( + select(Course) + .options( + selectinload(Course.teachers), + selectinload(Course.enrollments) + ) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_all_for_teacher(self, user_id: int) -> Optional[Sequence[Course]]: + query = ( + select(Course) + .options( + selectinload(Course.teachers), + selectinload(Course.enrollments) + ) + .join(Course.teachers) + .filter(CourseTeacher.teacher_id == user_id) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_all_for_enrollment(self, user_id: int) -> Optional[Sequence[Course]]: + query = ( + select(Course) + .options( + selectinload(Course.teachers), + selectinload(Course.enrollments) + ) + .join(Course.enrollments) + .filter(Enrollment.student_id == user_id) + ) result = await self.db.execute(query) return result.scalars().all() async def get_by_id(self, course_id: int) -> Optional[Course]: query = ( select(Course) - .order_by(id=course_id) + .options( + selectinload(Course.teachers), + selectinload(Course.enrollments) + ) + .filter_by(id=course_id) ) result = await self.db.execute(query) return result.scalars().first() diff --git a/api/app/application/lesson_files_repository.py b/api/app/application/lesson_files_repository.py new file mode 100644 index 0000000..8891559 --- /dev/null +++ b/api/app/application/lesson_files_repository.py @@ -0,0 +1,38 @@ +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import LessonFile + + +class LessonFilesRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, file_id: int) -> Optional[LessonFile]: + query = ( + select(LessonFile) + .filter_by(id=file_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_lesson_id(self, lesson_id: int) -> Optional[LessonFile]: + query = ( + select(LessonFile) + .filter_by(lesson_id=lesson_id) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, lesson_file: LessonFile) -> LessonFile: + self.db.add(lesson_file) + await self.db.commit() + await self.db.refresh(lesson_file) + return lesson_file + + async def delete(self, lesson_file: LessonFile) -> LessonFile: + await self.db.delete(lesson_file) + await self.db.commit() + return lesson_file diff --git a/api/app/application/lessons_repository.py b/api/app/application/lessons_repository.py new file mode 100644 index 0000000..5a87bb2 --- /dev/null +++ b/api/app/application/lessons_repository.py @@ -0,0 +1,45 @@ +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import Lesson + + +class LessonsRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_all_by_course(self, course_id: int) -> List[Lesson]: + query = ( + select(Lesson) + .filter_by(course_id=course_id) + .order_by(Lesson.number) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_by_id(self, lesson_id: int) -> Optional[Lesson]: + query = ( + select(Lesson) + .filter_by(id=lesson_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def create(self, lesson: Lesson) -> Lesson: + self.db.add(lesson) + await self.db.commit() + await self.db.refresh(lesson) + return lesson + + async def update(self, lesson: Lesson) -> Lesson: + await self.db.merge(lesson) + await self.db.commit() + await self.db.refresh(lesson) + return lesson + + async def delete(self, lesson: Lesson) -> Lesson: + await self.db.delete(lesson) + await self.db.commit() + return lesson diff --git a/api/app/application/solution_comments_repository.py b/api/app/application/solution_comments_repository.py new file mode 100644 index 0000000..ad9d408 --- /dev/null +++ b/api/app/application/solution_comments_repository.py @@ -0,0 +1,39 @@ +from typing import Optional, List + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import SolutionComment + + +class SolutionCommentsRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, comment_id: int) -> Optional[SolutionComment]: + query = ( + select(SolutionComment) + .filter_by(id=comment_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_solution_id(self, solution_id: int) -> Optional[List[SolutionComment]]: + query = ( + select(SolutionComment) + .filter_by(solution_id=solution_id) + .order_by(SolutionComment.created_at) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, comment: SolutionComment) -> SolutionComment: + self.db.add(comment) + await self.db.commit() + await self.db.refresh(comment) + return comment + + async def delete(self, comment: SolutionComment) -> SolutionComment: + await self.db.delete(comment) + await self.db.commit() + return comment diff --git a/api/app/application/solution_files_repository.py b/api/app/application/solution_files_repository.py new file mode 100644 index 0000000..f62f26a --- /dev/null +++ b/api/app/application/solution_files_repository.py @@ -0,0 +1,38 @@ +from typing import Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import SolutionFile + + +class SolutionFilesRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, file_id: int) -> Optional[SolutionFile]: + query = ( + select(SolutionFile) + .filter_by(id=file_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_solution_id(self, solution_id: int) -> Sequence[Optional[SolutionFile]]: + query = ( + select(SolutionFile) + .filter_by(solution_id=solution_id) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, solution_file: SolutionFile) -> SolutionFile: + self.db.add(solution_file) + await self.db.commit() + await self.db.refresh(solution_file) + return solution_file + + async def delete(self, solution_file: SolutionFile) -> SolutionFile: + await self.db.delete(solution_file) + await self.db.commit() + return solution_file diff --git a/api/app/application/solutions_repository.py b/api/app/application/solutions_repository.py new file mode 100644 index 0000000..c3f04ad --- /dev/null +++ b/api/app/application/solutions_repository.py @@ -0,0 +1,76 @@ +from typing import Optional, List + +from sqlalchemy import select, func, distinct +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.domain.models import Solution, Task + + +class SolutionsRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, solution_id: int) -> Optional[Solution]: + query = ( + select(Solution) + .filter_by(id=solution_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_completed_tasks_by_course_and_user( + self, + course_id: int, + user_id: int + ) -> List[int]: + query = ( + select(distinct(Solution.task_id)) + .join(Task, Solution.task_id == Task.id) + .where( + Solution.student_id == user_id, + Task.course_id == course_id + ) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_by_task_id(self, task_id: int) -> Optional[List[Solution]]: + query = ( + select(Solution) + .filter_by(task_id=task_id) + .options( + selectinload(Solution.files), + selectinload(Solution.solution_comments), + ) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_by_task_id_and_student_id(self, task_id: int, student_id: int) -> Optional[List[Solution]]: + query = ( + select(Solution) + .filter_by(task_id=task_id, student_id=student_id) + .options( + selectinload(Solution.files), + selectinload(Solution.solution_comments), + ) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, solution: Solution) -> Solution: + self.db.add(solution) + await self.db.commit() + await self.db.refresh(solution) + return solution + + async def update(self, solution: Solution) -> Solution: + await self.db.merge(solution) + await self.db.commit() + return solution + + async def delete(self, solution: Solution) -> Solution: + await self.db.delete(solution) + await self.db.commit() + return solution diff --git a/api/app/application/task_files_repository.py b/api/app/application/task_files_repository.py new file mode 100644 index 0000000..9028818 --- /dev/null +++ b/api/app/application/task_files_repository.py @@ -0,0 +1,38 @@ +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import TaskFile + + +class TaskFilesRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, file_id: int) -> Optional[TaskFile]: + query = ( + select(TaskFile) + .filter_by(id=file_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_task_id(self, task_id: int) -> Optional[TaskFile]: + query = ( + select(TaskFile) + .filter_by(task_id=task_id) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, task_file: TaskFile) -> TaskFile: + self.db.add(task_file) + await self.db.commit() + await self.db.refresh(task_file) + return task_file + + async def delete(self, task_file: TaskFile) -> TaskFile: + await self.db.delete(task_file) + await self.db.commit() + return task_file diff --git a/api/app/application/tasks_repository.py b/api/app/application/tasks_repository.py new file mode 100644 index 0000000..caf8698 --- /dev/null +++ b/api/app/application/tasks_repository.py @@ -0,0 +1,45 @@ +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import Task + + +class TasksRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_all_by_course(self, course_id: int) -> List[Task]: + query = ( + select(Task) + .filter_by(course_id=course_id) + .order_by(Task.number) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_by_id(self, task_id: int) -> Optional[Task]: + query = ( + select(Task) + .filter_by(id=task_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def create(self, task: Task) -> Task: + self.db.add(task) + await self.db.commit() + await self.db.refresh(task) + return task + + async def update(self, task: Task) -> Task: + await self.db.merge(task) + await self.db.commit() + await self.db.refresh(task) + return task + + async def delete(self, task: Task) -> Task: + await self.db.delete(task) + await self.db.commit() + return task diff --git a/api/app/application/user_check_lessons_repository.py b/api/app/application/user_check_lessons_repository.py new file mode 100644 index 0000000..edc027d --- /dev/null +++ b/api/app/application/user_check_lessons_repository.py @@ -0,0 +1,36 @@ +from typing import Optional, List + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import Lesson, UserCheckLessons + + +class UserCheckLessonsRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_course_id_and_user_id(self, course_id: int, user_id: int) -> Optional[List[UserCheckLessons]]: + query = ( + select(UserCheckLessons) + .join(Lesson, UserCheckLessons.lesson_id == Lesson.id) # связь с лекцией + .where( + Lesson.course_id == course_id, + UserCheckLessons.user_id == user_id + ) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_by_user_id_and_lesson_id(self, user_id: int, lesson_id: int) -> Optional[UserCheckLessons]: + query = ( + select(UserCheckLessons) + .filter_by(user_id=user_id, lesson_id=lesson_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def create(self, user_check_lessons: UserCheckLessons) -> UserCheckLessons: + self.db.add(user_check_lessons) + await self.db.commit() + return user_check_lessons diff --git a/api/app/controllers/courses_router.py b/api/app/controllers/courses_router.py index 8a416b6..49f55ea 100644 --- a/api/app/controllers/courses_router.py +++ b/api/app/controllers/courses_router.py @@ -5,12 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_db from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate -from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate +from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate from app.domain.models import User from app.infrastructure.course_teachers_service import CourseTeachersService from app.infrastructure.courses_service import CoursesService -from app.infrastructure.dependencies import require_auth_user, require_teacher +from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin from app.infrastructure.enrollments_service import EnrollmentsService courses_router = APIRouter() @@ -24,15 +24,44 @@ courses_router = APIRouter() ) async def get_all_courses( db: AsyncSession = Depends(get_db), - user: User = Depends(require_auth_user), + user: User = Depends(require_admin), ): courses_service = CoursesService(db) return await courses_service.get_all() +@courses_router.get( + '/for-me/', + response_model=Optional[List[CourseRead]], + summary='Return all for current user', + description='Return all current user', +) +async def get_fors_for_teacher( + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + courses_service = CoursesService(db) + return await courses_service.get_all_for_me(user) + + +@courses_router.get( + '/{course_id}/', + response_model=Optional[CourseRead], + summary='Return a specific course', + description='Return a specific course', +) +async def get_course( + course_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + courses_service = CoursesService(db) + return await courses_service.get_by_id(course_id) + + @courses_router.post( '/', - response_model=Optional[CourseRead], + response_model=Optional[CourseCreated], summary='Create a new course', description='Create a new course', ) diff --git a/api/app/controllers/lessons_router.py b/api/app/controllers/lessons_router.py new file mode 100644 index 0000000..0562c65 --- /dev/null +++ b/api/app/controllers/lessons_router.py @@ -0,0 +1,151 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, status, File, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse + +from app.database.session import get_db +from app.domain.entities.lesson_files import ReadLessonFile +from app.domain.entities.lessons import LessonCreate, LessonUpdate, LessonRead +from app.domain.models import User +from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin +from app.infrastructure.lesson_files_service import LessonFilesService +from app.infrastructure.lessons_service import LessonsService + +lessons_router = APIRouter() + + +@lessons_router.get( + '/course/{course_id}/', + response_model=Optional[List[LessonRead]], + summary='Get all lessons by course', + description='Get all lessons by course', +) +async def get_course_lessons( + course_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + lessons_service = LessonsService(db) + return await lessons_service.get_all_by_course(course_id) + + +@lessons_router.get( + '/{lesson_id}/', + response_model=Optional[LessonRead], + summary='Get lesson by lesson ID', + description='Get lesson by lesson ID', +) +async def get_lesson( + lesson_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + lessons_service = LessonsService(db) + return await lessons_service.get_by_id(lesson_id) + + +@lessons_router.post( + '/{course_id}/', + response_model=Optional[LessonRead], + summary='Create a new lesson', + description='Create a new lesson', +) +async def create_lesson( + course_id: int, + lesson_data: LessonCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + lessons_service = LessonsService(db) + return await lessons_service.create(lesson_data, current_user, course_id) + + +@lessons_router.put( + '/{lesson_id}/', + response_model=Optional[LessonRead], + summary='Update a lesson', + description='Update a lesson', +) +async def update_lesson( + lesson_id: int, + lesson_data: LessonUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + lessons_service = LessonsService(db) + return await lessons_service.update(lesson_id, lesson_data, current_user) + + +@lessons_router.delete( + '/{lesson_id}/', + response_model=Optional[LessonRead], + summary='Delete a lesson', + description='Delete a lesson', +) +async def delete_lesson( + lesson_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + lessons_service = LessonsService(db) + return await lessons_service.delete(lesson_id, current_user) + + +@lessons_router.get( + '/files/{lesson_id}/', + response_model=Optional[List[ReadLessonFile]], + summary='Get a files list by lesson ID', + description='Get a files list by lesson ID', +) +async def get_files( + lesson_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.get_files_list_by_lesson(lesson_id) + + +@lessons_router.get( + '/file/{file_id}/', + response_class=FileResponse, +) +async def get_file( + file_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.get_file_by_id(file_id) + + +@lessons_router.post( + '/files/{lesson_id}/upload/', + response_model=ReadLessonFile, + summary='Upload a file', + description='Upload a file', +) +async def upload_file( + lesson_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_teacher), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.upload_file(lesson_id, file) + + +@lessons_router.delete( + '/files/{file_id}/', + response_model=Optional[ReadLessonFile], + summary='Delete a file', + description='Delete a file', +) +async def delete_file( + file_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.delete_file(file_id) diff --git a/api/app/controllers/solution_comments_router.py b/api/app/controllers/solution_comments_router.py new file mode 100644 index 0000000..7d2feb5 --- /dev/null +++ b/api/app/controllers/solution_comments_router.py @@ -0,0 +1,55 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, status, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.domain.entities.solutions import SolutionCommentRead, SolutionCommentCreate +from app.domain.models import SolutionComment, User +from app.infrastructure.dependencies import require_auth_user +from app.infrastructure.solution_comments_service import SolutionCommentsService + +solution_comments_router = APIRouter() + + +@solution_comments_router.get( + '/solution/{solution_id}/', + response_model=List[SolutionCommentRead], + summary='Returns all comments for solution', + description='Returns all comments for solution', +) +async def get_solution_comments( + solution_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + solution_comments_service = SolutionCommentsService(db) + return await solution_comments_service.get_by_solution_id(solution_id) + +@solution_comments_router.post( + '/solution/{solution_id}/', + response_model=SolutionCommentRead, + summary='Creates a new solution comment', + description='Creates a new solution comment', +) +async def create_solution_comment( + solution_id: int, + solution_comment: SolutionCommentCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + solution_comments_service = SolutionCommentsService(db) + return await solution_comments_service.create(solution_comment, user, solution_id) + +@solution_comments_router.delete( + '/{comment_id}/', + summary='Deletes a solution comment', + description='Deletes a solution comment', +) +async def delete_solution_comment( + comment_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + solution_comments_service = SolutionCommentsService(db) + return await solution_comments_service.delete(comment_id) diff --git a/api/app/controllers/solutions_router.py b/api/app/controllers/solutions_router.py new file mode 100644 index 0000000..d1e1eb4 --- /dev/null +++ b/api/app/controllers/solutions_router.py @@ -0,0 +1,142 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Response, File, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse + +from app.database.session import get_db +from app.domain.entities.solution_files import ReadSolutionFile +from app.domain.entities.solutions import SolutionCreate, SolutionRead, SolutionAfterCreate, AssessmentCreate +from app.domain.models import User +from app.infrastructure.dependencies import require_auth_user, require_teacher +from app.infrastructure.solution_files_service import SolutionFilesService +from app.infrastructure.solutions_service import SolutionsService + +solution_router = APIRouter() + + +@solution_router.get( + '/task/{task_id}/', + response_model=List[SolutionRead], + summary='Get all solutions for task', + description='Get all solutions for task', +) +async def get_solutions_by_task( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + solutions = await service.get_by_task_id(task_id) + return solutions + + +@solution_router.get( + '/task/{task_id}/student/{student_id}/', + response_model=List[SolutionRead], + summary='Get all solutions for task', + description='Get all solutions for task', +) +async def get_solutions_by_task_and_student( + task_id: int, + student_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + solutions = await service.get_by_task_id_and_student_id(task_id, student_id) + return solutions + + +@solution_router.post( + '/{task_id}/', + response_model=SolutionAfterCreate, + status_code=status.HTTP_201_CREATED, + summary='Send solution', + description='Send solution', +) +async def create_solution( + task_id: int, + solution_data: SolutionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + return await service.create(solution_data, current_user, task_id) + + +@solution_router.delete( + '/{solution_id}/', + status_code=status.HTTP_204_NO_CONTENT, + summary='Delete my solution', + description='Delete my solution', +) +async def delete_solution( + solution_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + await service.delete(solution_id, current_user) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@solution_router.get( + '/file/{file_id}/', + response_class=FileResponse, +) +async def get_file( + file_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + task_files_service = SolutionFilesService(db) + return await task_files_service.get_file_by_id(file_id) + + +@solution_router.get( + '/files/{solution_id}/', + response_model=Optional[List[ReadSolutionFile]], + summary='Get a files list by task ID', + description='Get a files list by task ID', +) +async def get_files( + solution_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + task_files_service = SolutionFilesService(db) + return await task_files_service.get_files_list_by_solution(solution_id) + + +@solution_router.post( + '/files/{task_id}/upload/', + response_model=ReadSolutionFile, + summary='Upload a file', + description='Upload a file', +) +async def upload_file( + task_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + task_files_service = SolutionFilesService(db) + return await task_files_service.upload_file(task_id, file) + + +@solution_router.post( + '/assessment/{solution_id}/', + status_code=status.HTTP_204_NO_CONTENT, + summary='Set assessment for solution', + description='Set assessment for solution', +) +async def create_assessment( + solution_id: int, + assessment_data: AssessmentCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + solutions_service = SolutionsService(db) + await solutions_service.create_assessment(solution_id, assessment_data, current_user) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/api/app/controllers/tasks_router.py b/api/app/controllers/tasks_router.py new file mode 100644 index 0000000..8a69320 --- /dev/null +++ b/api/app/controllers/tasks_router.py @@ -0,0 +1,151 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, status, File, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse + +from app.database.session import get_db +from app.domain.entities.task_files import ReadTaskFile +from app.domain.entities.tasks import TaskRead, TaskCreate +from app.domain.models import User +from app.infrastructure.dependencies import require_auth_user, require_teacher +from app.infrastructure.task_files_service import TaskFilesService +from app.infrastructure.tasks_service import TasksService + +tasks_router = APIRouter() + + +@tasks_router.get( + '/course/{course_id}/', + response_model=Optional[List[TaskRead]], + summary='Get all tasks by course', + description='Get all tasks by course', +) +async def get_course_tasks( + course_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + tasks_service = TasksService(db) + return await tasks_service.get_all_by_course(course_id) + + +@tasks_router.get( + '/{task_id}/', + response_model=Optional[TaskRead], + summary='Get task by task ID', + description='Get task by task ID', +) +async def get_task( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + tasks_service = TasksService(db) + return await tasks_service.get_by_id(task_id) + + +@tasks_router.post( + '/{course_id}/', + response_model=Optional[TaskRead], + summary='Create a new task', + description='Create a new task', +) +async def create_task( + course_id: int, + task_data: TaskCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + tasks_service = TasksService(db) + return await tasks_service.create(task_data, current_user, course_id) + + +@tasks_router.put( + '/{task_id}/', + response_model=Optional[TaskRead], + summary='Update a task', + description='Update a task', +) +async def update_task( + task_id: int, + task_data: TaskCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + tasks_service = TasksService(db) + return await tasks_service.update(task_id, task_data, current_user) + + +@tasks_router.delete( + '/{task_id}/', + response_model=Optional[TaskRead], + summary='Delete a task', + description='Delete a task', +) +async def delete_task( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + tasks_service = TasksService(db) + return await tasks_service.delete(task_id, current_user) + + +@tasks_router.get( + '/files/{task_id}/', + response_model=Optional[List[ReadTaskFile]], + summary='Get a files list by task ID', + description='Get a files list by task ID', +) +async def get_files( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + task_files_service = TaskFilesService(db) + return await task_files_service.get_files_list_by_task(task_id) + + +@tasks_router.get( + '/file/{file_id}/', + response_class=FileResponse, +) +async def get_file( + file_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + task_files_service = TaskFilesService(db) + return await task_files_service.get_file_by_id(file_id) + + +@tasks_router.post( + '/files/{task_id}/upload/', + response_model=ReadTaskFile, + summary='Upload a file', + description='Upload a file', +) +async def upload_file( + task_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_teacher), +): + task_files_service = TaskFilesService(db) + return await task_files_service.upload_file(task_id, file) + + +@tasks_router.delete( + '/files/{file_id}/', + response_model=Optional[ReadTaskFile], + summary='Delete a file', + description='Delete a file', +) +async def delete_file( + file_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + task_files_service = TaskFilesService(db) + return await task_files_service.delete_file(file_id) diff --git a/api/app/controllers/users_router.py b/api/app/controllers/users_router.py index dfa31ac..8b9a9ae 100644 --- a/api/app/controllers/users_router.py +++ b/api/app/controllers/users_router.py @@ -1,13 +1,14 @@ from typing import List, Optional -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, status, Response from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_db -from app.domain.entities.users import UserRead, UserUpdate, PasswordChangeRequest, UserCreate +from app.domain.entities.users import UserRead, UserUpdate, PasswordChangeRequest, UserCreate, UserCheckLessonRead from app.domain.models import User from app.infrastructure.dependencies import require_auth_user, require_admin from app.infrastructure.register_service import RegisterService +from app.infrastructure.user_check_lessons_service import UserCheckLessonsService from app.infrastructure.users_service import UsersService users_router = APIRouter() @@ -101,3 +102,47 @@ async def get_users_by_role_name( ): users_service = UsersService(db) return await users_service.get_by_role_name(role_name) + + +@users_router.get( + '/check-my-lessons/{course_id}/', + response_model=Optional[List[UserCheckLessonRead]], + summary='Return all users with given lessons', + description='Return all users with given lessons', +) +async def get_all_users_in_lessons( + course_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user) +): + user_check_lessons_service = UserCheckLessonsService(db) + return await user_check_lessons_service.get_by_course_id_and_user_id(course_id, user.id) + + +@users_router.post( + '/check-lesson/{lesson_id}/', + status_code=status.HTTP_201_CREATED, + summary='Mark lesson as checked', + description='Mark lesson as checked', +) +async def mark_lesson_checked( + lesson_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user) +): + user_check_lessons_service = UserCheckLessonsService(db) + await user_check_lessons_service.create(lesson_id, user.id) + + +@users_router.get( + '/my-progress/{course_id}/', + summary='Return user progress', + description='Return user progress', +) +async def get_user_progress( + course_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + user_check_lessons_service = UserCheckLessonsService(db) + return await user_check_lessons_service.calculate_user_progress(course_id, user.id) diff --git a/api/app/database/alembic/versions/33d77ac5ed79_0002_сделал_поле_с_фото_необязательным.py b/api/app/database/alembic/versions/33d77ac5ed79_0002_сделал_поле_с_фото_необязательным.py new file mode 100644 index 0000000..a13123e --- /dev/null +++ b/api/app/database/alembic/versions/33d77ac5ed79_0002_сделал_поле_с_фото_необязательным.py @@ -0,0 +1,106 @@ +"""0002 сделал поле с фото необязательным + +Revision ID: 33d77ac5ed79 +Revises: 6241a16321b4 +Create Date: 2025-11-28 19:23:15.655318 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '33d77ac5ed79' +down_revision: Union[str, Sequence[str], None] = '6241a16321b4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', type_='foreignkey') + op.drop_constraint(op.f('course_teachers_course_id_fkey'), 'course_teachers', type_='foreignkey') + op.create_foreign_key(None, 'course_teachers', 'users', ['teacher_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'course_teachers', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.alter_column('courses', 'photo_filename', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('courses', 'photo_path', + existing_type=sa.VARCHAR(), + nullable=True) + op.drop_constraint(op.f('enrollments_student_id_fkey'), 'enrollments', type_='foreignkey') + op.drop_constraint(op.f('enrollments_course_id_fkey'), 'enrollments', type_='foreignkey') + op.create_foreign_key(None, 'enrollments', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'enrollments', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', type_='foreignkey') + op.create_foreign_key(None, 'lesson_files', 'lessons', ['lesson_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('lessons_course_id_fkey'), 'lessons', type_='foreignkey') + op.drop_constraint(op.f('lessons_creator_id_fkey'), 'lessons', type_='foreignkey') + op.create_foreign_key(None, 'lessons', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'lessons', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('solution_files_solution_id_fkey'), 'solution_files', type_='foreignkey') + op.create_foreign_key(None, 'solution_files', 'solutions', ['solution_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('solutions_task_id_fkey'), 'solutions', type_='foreignkey') + op.drop_constraint(op.f('solutions_assessment_autor_id_fkey'), 'solutions', type_='foreignkey') + op.drop_constraint(op.f('solutions_student_id_fkey'), 'solutions', type_='foreignkey') + op.create_foreign_key(None, 'solutions', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'solutions', 'users', ['assessment_autor_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'solutions', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('task_files_task_id_fkey'), 'task_files', type_='foreignkey') + op.create_foreign_key(None, 'task_files', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('tasks_creator_id_fkey'), 'tasks', type_='foreignkey') + op.drop_constraint(op.f('tasks_course_id_fkey'), 'tasks', type_='foreignkey') + op.create_foreign_key(None, 'tasks', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'tasks', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('users_role_id_fkey'), 'users', type_='foreignkey') + op.drop_constraint(op.f('users_status_id_fkey'), 'users', type_='foreignkey') + op.create_foreign_key(None, 'users', 'statuses', ['status_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'users', schema='public', type_='foreignkey') + op.drop_constraint(None, 'users', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('users_status_id_fkey'), 'users', 'statuses', ['status_id'], ['id']) + op.create_foreign_key(op.f('users_role_id_fkey'), 'users', 'roles', ['role_id'], ['id']) + op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey') + op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('tasks_course_id_fkey'), 'tasks', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('tasks_creator_id_fkey'), 'tasks', 'users', ['creator_id'], ['id']) + op.drop_constraint(None, 'task_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('task_files_task_id_fkey'), 'task_files', 'tasks', ['task_id'], ['id']) + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('solutions_student_id_fkey'), 'solutions', 'users', ['student_id'], ['id']) + op.create_foreign_key(op.f('solutions_assessment_autor_id_fkey'), 'solutions', 'users', ['assessment_autor_id'], ['id']) + op.create_foreign_key(op.f('solutions_task_id_fkey'), 'solutions', 'tasks', ['task_id'], ['id']) + op.drop_constraint(None, 'solution_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('solution_files_solution_id_fkey'), 'solution_files', 'solutions', ['solution_id'], ['id']) + op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey') + op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('lessons_creator_id_fkey'), 'lessons', 'users', ['creator_id'], ['id']) + op.create_foreign_key(op.f('lessons_course_id_fkey'), 'lessons', 'courses', ['course_id'], ['id']) + op.drop_constraint(None, 'lesson_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', 'lessons', ['lesson_id'], ['id']) + op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey') + op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('enrollments_course_id_fkey'), 'enrollments', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('enrollments_student_id_fkey'), 'enrollments', 'users', ['student_id'], ['id']) + op.alter_column('courses', 'photo_path', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('courses', 'photo_filename', + existing_type=sa.VARCHAR(), + nullable=False) + op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey') + op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('course_teachers_course_id_fkey'), 'course_teachers', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', 'users', ['teacher_id'], ['id']) + # ### end Alembic commands ### diff --git a/api/app/database/alembic/versions/5664875e4492_0004_добавил_таблицу_для_отслеживания_.py b/api/app/database/alembic/versions/5664875e4492_0004_добавил_таблицу_для_отслеживания_.py new file mode 100644 index 0000000..60db064 --- /dev/null +++ b/api/app/database/alembic/versions/5664875e4492_0004_добавил_таблицу_для_отслеживания_.py @@ -0,0 +1,114 @@ +"""0004 добавил таблицу для отслеживания прохождения лекций + +Revision ID: 5664875e4492 +Revises: f8fd9a27eaa7 +Create Date: 2025-11-29 19:49:20.397877 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5664875e4492' +down_revision: Union[str, Sequence[str], None] = 'f8fd9a27eaa7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user_check_lessons', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('lesson_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['lesson_id'], ['public.lessons.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['public.users.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='public' + ) + op.drop_constraint(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', type_='foreignkey') + op.drop_constraint(op.f('course_teachers_course_id_fkey'), 'course_teachers', type_='foreignkey') + op.create_foreign_key(None, 'course_teachers', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'course_teachers', 'users', ['teacher_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('enrollments_student_id_fkey'), 'enrollments', type_='foreignkey') + op.drop_constraint(op.f('enrollments_course_id_fkey'), 'enrollments', type_='foreignkey') + op.create_foreign_key(None, 'enrollments', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'enrollments', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', type_='foreignkey') + op.create_foreign_key(None, 'lesson_files', 'lessons', ['lesson_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('lessons_creator_id_fkey'), 'lessons', type_='foreignkey') + op.drop_constraint(op.f('lessons_course_id_fkey'), 'lessons', type_='foreignkey') + op.create_foreign_key(None, 'lessons', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'lessons', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('solution_comments_solution_id_fkey'), 'solution_comments', type_='foreignkey') + op.drop_constraint(op.f('solution_comments_comment_autor_id_fkey'), 'solution_comments', type_='foreignkey') + op.create_foreign_key(None, 'solution_comments', 'solutions', ['solution_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'solution_comments', 'users', ['comment_autor_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('solution_files_solution_id_fkey'), 'solution_files', type_='foreignkey') + op.create_foreign_key(None, 'solution_files', 'solutions', ['solution_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('solutions_task_id_fkey'), 'solutions', type_='foreignkey') + op.drop_constraint(op.f('solutions_assessment_autor_id_fkey'), 'solutions', type_='foreignkey') + op.drop_constraint(op.f('solutions_student_id_fkey'), 'solutions', type_='foreignkey') + op.create_foreign_key(None, 'solutions', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'solutions', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'solutions', 'users', ['assessment_autor_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('task_files_task_id_fkey'), 'task_files', type_='foreignkey') + op.create_foreign_key(None, 'task_files', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('tasks_course_id_fkey'), 'tasks', type_='foreignkey') + op.drop_constraint(op.f('tasks_creator_id_fkey'), 'tasks', type_='foreignkey') + op.create_foreign_key(None, 'tasks', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'tasks', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('users_role_id_fkey'), 'users', type_='foreignkey') + op.drop_constraint(op.f('users_status_id_fkey'), 'users', type_='foreignkey') + op.create_foreign_key(None, 'users', 'statuses', ['status_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'users', schema='public', type_='foreignkey') + op.drop_constraint(None, 'users', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('users_status_id_fkey'), 'users', 'statuses', ['status_id'], ['id']) + op.create_foreign_key(op.f('users_role_id_fkey'), 'users', 'roles', ['role_id'], ['id']) + op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey') + op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('tasks_creator_id_fkey'), 'tasks', 'users', ['creator_id'], ['id']) + op.create_foreign_key(op.f('tasks_course_id_fkey'), 'tasks', 'courses', ['course_id'], ['id']) + op.drop_constraint(None, 'task_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('task_files_task_id_fkey'), 'task_files', 'tasks', ['task_id'], ['id']) + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('solutions_student_id_fkey'), 'solutions', 'users', ['student_id'], ['id']) + op.create_foreign_key(op.f('solutions_assessment_autor_id_fkey'), 'solutions', 'users', ['assessment_autor_id'], ['id']) + op.create_foreign_key(op.f('solutions_task_id_fkey'), 'solutions', 'tasks', ['task_id'], ['id']) + op.drop_constraint(None, 'solution_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('solution_files_solution_id_fkey'), 'solution_files', 'solutions', ['solution_id'], ['id']) + op.drop_constraint(None, 'solution_comments', schema='public', type_='foreignkey') + op.drop_constraint(None, 'solution_comments', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('solution_comments_comment_autor_id_fkey'), 'solution_comments', 'users', ['comment_autor_id'], ['id']) + op.create_foreign_key(op.f('solution_comments_solution_id_fkey'), 'solution_comments', 'solutions', ['solution_id'], ['id']) + op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey') + op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('lessons_course_id_fkey'), 'lessons', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('lessons_creator_id_fkey'), 'lessons', 'users', ['creator_id'], ['id']) + op.drop_constraint(None, 'lesson_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', 'lessons', ['lesson_id'], ['id']) + op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey') + op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('enrollments_course_id_fkey'), 'enrollments', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('enrollments_student_id_fkey'), 'enrollments', 'users', ['student_id'], ['id']) + op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey') + op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('course_teachers_course_id_fkey'), 'course_teachers', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', 'users', ['teacher_id'], ['id']) + op.drop_table('user_check_lessons', schema='public') + # ### end Alembic commands ### diff --git a/api/app/database/alembic/versions/f8fd9a27eaa7_0003_добавил_таблицу_с_комментариями_к_.py b/api/app/database/alembic/versions/f8fd9a27eaa7_0003_добавил_таблицу_с_комментариями_к_.py new file mode 100644 index 0000000..d6ba196 --- /dev/null +++ b/api/app/database/alembic/versions/f8fd9a27eaa7_0003_добавил_таблицу_с_комментариями_к_.py @@ -0,0 +1,107 @@ +"""0003 добавил таблицу с комментариями к решению + +Revision ID: f8fd9a27eaa7 +Revises: 33d77ac5ed79 +Create Date: 2025-11-29 16:43:22.620248 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f8fd9a27eaa7' +down_revision: Union[str, Sequence[str], None] = '33d77ac5ed79' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('solution_comments', + sa.Column('comment_text', sa.String(), nullable=False), + sa.Column('comment_autor_id', sa.Integer(), nullable=False), + sa.Column('solution_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['comment_autor_id'], ['public.users.id'], ), + sa.ForeignKeyConstraint(['solution_id'], ['public.solutions.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='public' + ) + op.drop_constraint(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', type_='foreignkey') + op.drop_constraint(op.f('course_teachers_course_id_fkey'), 'course_teachers', type_='foreignkey') + op.create_foreign_key(None, 'course_teachers', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'course_teachers', 'users', ['teacher_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('enrollments_student_id_fkey'), 'enrollments', type_='foreignkey') + op.drop_constraint(op.f('enrollments_course_id_fkey'), 'enrollments', type_='foreignkey') + op.create_foreign_key(None, 'enrollments', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'enrollments', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', type_='foreignkey') + op.create_foreign_key(None, 'lesson_files', 'lessons', ['lesson_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('lessons_creator_id_fkey'), 'lessons', type_='foreignkey') + op.drop_constraint(op.f('lessons_course_id_fkey'), 'lessons', type_='foreignkey') + op.create_foreign_key(None, 'lessons', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'lessons', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('solution_files_solution_id_fkey'), 'solution_files', type_='foreignkey') + op.create_foreign_key(None, 'solution_files', 'solutions', ['solution_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('solutions_student_id_fkey'), 'solutions', type_='foreignkey') + op.drop_constraint(op.f('solutions_task_id_fkey'), 'solutions', type_='foreignkey') + op.drop_constraint(op.f('solutions_assessment_autor_id_fkey'), 'solutions', type_='foreignkey') + op.create_foreign_key(None, 'solutions', 'users', ['assessment_autor_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'solutions', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'solutions', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('task_files_task_id_fkey'), 'task_files', type_='foreignkey') + op.create_foreign_key(None, 'task_files', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('tasks_course_id_fkey'), 'tasks', type_='foreignkey') + op.drop_constraint(op.f('tasks_creator_id_fkey'), 'tasks', type_='foreignkey') + op.create_foreign_key(None, 'tasks', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'tasks', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.drop_constraint(op.f('users_role_id_fkey'), 'users', type_='foreignkey') + op.drop_constraint(op.f('users_status_id_fkey'), 'users', type_='foreignkey') + op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'users', 'statuses', ['status_id'], ['id'], source_schema='public', referent_schema='public') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'users', schema='public', type_='foreignkey') + op.drop_constraint(None, 'users', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('users_status_id_fkey'), 'users', 'statuses', ['status_id'], ['id']) + op.create_foreign_key(op.f('users_role_id_fkey'), 'users', 'roles', ['role_id'], ['id']) + op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey') + op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('tasks_creator_id_fkey'), 'tasks', 'users', ['creator_id'], ['id']) + op.create_foreign_key(op.f('tasks_course_id_fkey'), 'tasks', 'courses', ['course_id'], ['id']) + op.drop_constraint(None, 'task_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('task_files_task_id_fkey'), 'task_files', 'tasks', ['task_id'], ['id']) + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('solutions_assessment_autor_id_fkey'), 'solutions', 'users', ['assessment_autor_id'], ['id']) + op.create_foreign_key(op.f('solutions_task_id_fkey'), 'solutions', 'tasks', ['task_id'], ['id']) + op.create_foreign_key(op.f('solutions_student_id_fkey'), 'solutions', 'users', ['student_id'], ['id']) + op.drop_constraint(None, 'solution_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('solution_files_solution_id_fkey'), 'solution_files', 'solutions', ['solution_id'], ['id']) + op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey') + op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('lessons_course_id_fkey'), 'lessons', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('lessons_creator_id_fkey'), 'lessons', 'users', ['creator_id'], ['id']) + op.drop_constraint(None, 'lesson_files', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', 'lessons', ['lesson_id'], ['id']) + op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey') + op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('enrollments_course_id_fkey'), 'enrollments', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('enrollments_student_id_fkey'), 'enrollments', 'users', ['student_id'], ['id']) + op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey') + op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('course_teachers_course_id_fkey'), 'course_teachers', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', 'users', ['teacher_id'], ['id']) + op.drop_table('solution_comments', schema='public') + # ### end Alembic commands ### diff --git a/api/app/domain/entities/course_teachers.py b/api/app/domain/entities/course_teachers.py index 17cfeee..e2e9c6d 100644 --- a/api/app/domain/entities/course_teachers.py +++ b/api/app/domain/entities/course_teachers.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, EmailStr, Field class CourseTeacherCreate(BaseModel): - course_id: int = Field() teacher_id: int = Field() diff --git a/api/app/domain/entities/courses.py b/api/app/domain/entities/courses.py index 47934f9..477a778 100644 --- a/api/app/domain/entities/courses.py +++ b/api/app/domain/entities/courses.py @@ -6,6 +6,17 @@ from app.domain.entities.course_teachers import CourseTeacherRead from app.domain.entities.enrollments import EnrollmentRead +class CourseBase(BaseModel): + id: int + title: str + description: Optional[str] = None + photo_filename: Optional[str] = None + photo_path: Optional[str] = None + + class Config: + from_attributes = True + + class CourseCreate(BaseModel): title: str = Field(max_length=250) description: Optional[str] = Field(default=None, max_length=1000) @@ -15,13 +26,14 @@ class CourseUpdate(CourseCreate): pass -class CourseRead(BaseModel): - id: int - title: str - description: str - - teachers: List[CourseTeacherRead] - enrollments: List[EnrollmentRead] +class CourseRead(CourseBase): + teachers: List[CourseTeacherRead] = [] + enrollments: List[EnrollmentRead] = [] class Config: from_attributes = True + + +class CourseCreated(CourseBase): + class Config: + from_attributes = True diff --git a/api/app/domain/entities/enrollments.py b/api/app/domain/entities/enrollments.py index 343ba20..ded455e 100644 --- a/api/app/domain/entities/enrollments.py +++ b/api/app/domain/entities/enrollments.py @@ -1,10 +1,10 @@ from datetime import datetime +from typing import Optional from pydantic import BaseModel, EmailStr, Field class EnrollmentCreate(BaseModel): - course_id: int = Field() student_id: int = Field() @@ -12,7 +12,7 @@ class EnrollmentRead(BaseModel): id: int course_id: int student_id: int - enrollment_date: datetime + enrollment_date: Optional[datetime] = None class Config: from_attributes = True diff --git a/api/app/domain/entities/lesson_files.py b/api/app/domain/entities/lesson_files.py new file mode 100644 index 0000000..e563be4 --- /dev/null +++ b/api/app/domain/entities/lesson_files.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class ReadLessonFile(BaseModel): + id: int + filename: str + file_path: str + lesson_id: int + + class Config: + from_attributes = True diff --git a/api/app/domain/entities/lessons.py b/api/app/domain/entities/lessons.py new file mode 100644 index 0000000..fe5de9f --- /dev/null +++ b/api/app/domain/entities/lessons.py @@ -0,0 +1,33 @@ +from typing import Optional +from pydantic import BaseModel, Field + +from app.domain.entities.users import UserRead + + +class LessonBase(BaseModel): + title: str = Field(..., max_length=250) + description: Optional[str] = None + text: Optional[str] = None + number: int = Field(..., ge=1) + + +class LessonCreate(LessonBase): + pass + + +class LessonUpdate(BaseModel): + title: Optional[str] = Field(None, max_length=250) + description: Optional[str] = None + text: Optional[str] = None + number: Optional[int] = Field(None, ge=1) + + +class LessonRead(LessonBase): + id: int + course_id: int + creator_id: int + + creator: UserRead + + class Config: + from_attributes = True diff --git a/api/app/domain/entities/solution_files.py b/api/app/domain/entities/solution_files.py new file mode 100644 index 0000000..a311028 --- /dev/null +++ b/api/app/domain/entities/solution_files.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class ReadSolutionFile(BaseModel): + id: int + filename: str + file_path: str + solution_id: int + + class Config: + from_attributes = True diff --git a/api/app/domain/entities/solutions.py b/api/app/domain/entities/solutions.py new file mode 100644 index 0000000..e85e7e0 --- /dev/null +++ b/api/app/domain/entities/solutions.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + +from app.domain.entities.solution_files import ReadSolutionFile +from app.domain.entities.users import UserRead + + +class SolutionBase(BaseModel): + answer_text: str = Field(...) + assessment: Optional[int] = Field(default=None) + + +class SolutionCreate(SolutionBase): + pass + + +class SolutionAfterCreate(SolutionBase): + id: int + + assessment_autor_id: Optional[int] = None + assessment_autor: Optional[UserRead] = None + student_id: int + task_id: int = Field(...) + + class Config: + from_attributes = True + + +class AssessmentCreate(BaseModel): + assessment: int = Field(...) + + +class SolutionCommentBase(BaseModel): + comment_text: str = Field(...) + + +class SolutionCommentCreate(SolutionCommentBase): + pass + + +class SolutionCommentRead(SolutionCommentBase): + comment_autor_id: int + solution_id: int + + comment_autor: UserRead + + class Config: + from_attributes = True + + +class SolutionRead(SolutionAfterCreate): + created_at: datetime + + files: Optional[List[ReadSolutionFile]] = [] + solution_comments: Optional[List[SolutionCommentRead]] = [] + + class Config: + from_attributes = True diff --git a/api/app/domain/entities/task_files.py b/api/app/domain/entities/task_files.py new file mode 100644 index 0000000..5102024 --- /dev/null +++ b/api/app/domain/entities/task_files.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class ReadTaskFile(BaseModel): + id: int + filename: str + file_path: str + task_id: int + + class Config: + from_attributes = True diff --git a/api/app/domain/entities/tasks.py b/api/app/domain/entities/tasks.py new file mode 100644 index 0000000..64da1f5 --- /dev/null +++ b/api/app/domain/entities/tasks.py @@ -0,0 +1,30 @@ +from typing import Optional +from pydantic import BaseModel, Field + +from app.domain.entities.users import UserRead + + +class TaskBase(BaseModel): + title: str = Field(..., max_length=250) + description: Optional[str] = None + text: Optional[str] = None + number: int = Field(..., ge=1) + + +class TaskCreate(TaskBase): + pass + + +class TaskUpdate(TaskBase): + pass + + +class TaskRead(TaskBase): + id: int + course_id: int + creator_id: int + + creator: UserRead + + class Config: + from_attributes = True diff --git a/api/app/domain/entities/users.py b/api/app/domain/entities/users.py index 5e5d910..8242e94 100644 --- a/api/app/domain/entities/users.py +++ b/api/app/domain/entities/users.py @@ -62,3 +62,19 @@ class UserRead(BaseModel): class Config: from_attributes = True + + +class UserCheckLessonBase(BaseModel): + lesson_id: int + user_id: int + + +class UserCheckLessonCreate(UserCheckLessonBase): + pass + + +class UserCheckLessonRead(UserCheckLessonBase): + id: int + + class Config: + from_attributes = True diff --git a/api/app/domain/models/__init__.py b/api/app/domain/models/__init__.py index 9fc41d2..6880dbd 100644 --- a/api/app/domain/models/__init__.py +++ b/api/app/domain/models/__init__.py @@ -15,9 +15,11 @@ from app.domain.models.enrollments import Enrollment from app.domain.models.lesson_files import LessonFile from app.domain.models.lessons import Lesson from app.domain.models.roles import Role +from app.domain.models.solution_comments import SolutionComment from app.domain.models.solution_files import SolutionFile from app.domain.models.solutions import Solution from app.domain.models.statuses import Status from app.domain.models.task_files import TaskFile from app.domain.models.tasks import Task +from app.domain.models.user_check_lessons import UserCheckLessons from app.domain.models.users import User diff --git a/api/app/domain/models/base.py b/api/app/domain/models/base.py index 33b76b9..dea194c 100644 --- a/api/app/domain/models/base.py +++ b/api/app/domain/models/base.py @@ -17,8 +17,8 @@ class RootTable(Base): class PhotoAbstract(RootTable): __abstract__ = True - photo_filename: Mapped[str] = mapped_column() - photo_path: Mapped[str] = mapped_column() + photo_filename: Mapped[str] = mapped_column(nullable=True) + photo_path: Mapped[str] = mapped_column(nullable=True) class FileAbstract(RootTable): diff --git a/api/app/domain/models/enrollments.py b/api/app/domain/models/enrollments.py index 9eb8393..1609a04 100644 --- a/api/app/domain/models/enrollments.py +++ b/api/app/domain/models/enrollments.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import ForeignKey +from sqlalchemy import ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.domain.models.base import RootTable diff --git a/api/app/domain/models/lessons.py b/api/app/domain/models/lessons.py index 2393bb1..fb7ca89 100644 --- a/api/app/domain/models/lessons.py +++ b/api/app/domain/models/lessons.py @@ -18,6 +18,7 @@ class Lesson(RootTable): creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False) course: Mapped['Course'] = relationship('Course', back_populates='lessons') - creator: Mapped['User'] = relationship('User', back_populates='created_lessons') + creator: Mapped['User'] = relationship('User', back_populates='created_lessons', lazy='joined') files: Mapped[List['LessonFile']] = relationship('LessonFile', back_populates='lesson') + user_check_lessons: Mapped[List['UserCheckLessons']] = relationship('UserCheckLessons', back_populates='lesson') diff --git a/api/app/domain/models/solution_comments.py b/api/app/domain/models/solution_comments.py new file mode 100644 index 0000000..377cd9f --- /dev/null +++ b/api/app/domain/models/solution_comments.py @@ -0,0 +1,16 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.domain.models.base import RootTable + + +class SolutionComment(RootTable): + __tablename__ = 'solution_comments' + + comment_text: Mapped[str] = mapped_column(nullable=False) + + comment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False) + solution_id: Mapped[int] = mapped_column(ForeignKey('solutions.id'), nullable=False) + + comment_autor: Mapped['User'] = relationship('User', back_populates='solution_comments', lazy='joined') + solution: Mapped['Solution'] = relationship('Solution', back_populates='solution_comments') \ No newline at end of file diff --git a/api/app/domain/models/solutions.py b/api/app/domain/models/solutions.py index 6dbafe8..85f44f6 100644 --- a/api/app/domain/models/solutions.py +++ b/api/app/domain/models/solutions.py @@ -10,14 +10,18 @@ class Solution(RootTable): __tablename__ = 'solutions' answer_text: Mapped[str] = mapped_column(nullable=True) - assessment_text: Mapped[str] = mapped_column(String(50), nullable=True) + assessment: Mapped[int] = mapped_column(nullable=True) assessment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=True) task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id'), nullable=False) student_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False) assessment_autor: Mapped['User'] = relationship('User', back_populates='assessments', - foreign_keys=[assessment_autor_id]) + foreign_keys=[assessment_autor_id], lazy='joined') student: Mapped['User'] = relationship('User', back_populates='my_solutions', foreign_keys=[student_id]) files: Mapped[List['SolutionFile']] = relationship('SolutionFile', back_populates='solution') + solution_comments: Mapped[List['SolutionComment']] = relationship( + 'SolutionComment', + back_populates='solution', + ) diff --git a/api/app/domain/models/tasks.py b/api/app/domain/models/tasks.py index 7f1446d..cefca2c 100644 --- a/api/app/domain/models/tasks.py +++ b/api/app/domain/models/tasks.py @@ -18,6 +18,6 @@ class Task(RootTable): creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False) course: Mapped['Course'] = relationship('Course', back_populates='tasks') - creator: Mapped['User'] = relationship('User', back_populates='created_tasks') + creator: Mapped['User'] = relationship('User', back_populates='created_tasks', lazy='joined') files: Mapped[List['TaskFile']] = relationship('TaskFile', back_populates='task') diff --git a/api/app/domain/models/user_check_lessons.py b/api/app/domain/models/user_check_lessons.py new file mode 100644 index 0000000..5505195 --- /dev/null +++ b/api/app/domain/models/user_check_lessons.py @@ -0,0 +1,14 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.domain.models.base import RootTable + + +class UserCheckLessons(RootTable): + __tablename__ = 'user_check_lessons' + + user_id: Mapped[int] = mapped_column(ForeignKey('users.id')) + lesson_id: Mapped[int] = mapped_column(ForeignKey('lessons.id')) + + user: Mapped['User'] = relationship('User', back_populates='user_check_lessons') + lesson: Mapped['Lesson'] = relationship('Lesson', back_populates='user_check_lessons') diff --git a/api/app/domain/models/users.py b/api/app/domain/models/users.py index d80214c..464e3a0 100644 --- a/api/app/domain/models/users.py +++ b/api/app/domain/models/users.py @@ -46,6 +46,16 @@ class User(PhotoAbstract): back_populates='student', foreign_keys=[Solution.student_id], ) + from app.domain.models.solution_comments import SolutionComment + solution_comments: Mapped[List['SolutionComment']] = relationship( + 'SolutionComment', + back_populates='comment_autor', + ) + from app.domain.models.user_check_lessons import UserCheckLessons + user_check_lessons: Mapped[List['UserCheckLessons']] = relationship( + 'UserCheckLessons', + back_populates='user', + ) def check_password(self, password): return check_password_hash(self.password_hash, password) diff --git a/api/app/infrastructure/courses_service.py b/api/app/infrastructure/courses_service.py index db7fffd..dff2e92 100644 --- a/api/app/infrastructure/courses_service.py +++ b/api/app/infrastructure/courses_service.py @@ -4,13 +4,16 @@ from fastapi import HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.application.courses_repository import CoursesRepository -from app.domain.entities.courses import CourseRead, CourseCreate -from app.domain.models import Course +from app.core.constants import UserRoles +from app.domain.entities.courses import CourseRead, CourseCreate, CourseCreated +from app.domain.models import Course, User +from app.settings import Settings class CoursesService: def __init__(self, db: AsyncSession): self.courses_repository = CoursesRepository(db) + self.settings = Settings() async def get_all(self) -> List[CourseRead]: courses = await self.courses_repository.get_all() @@ -22,7 +25,35 @@ class CoursesService: return response - async def create(self, course: CourseCreate) -> Optional[CourseRead]: + async def get_all_for_me(self, user: User) -> Optional[List[CourseRead]]: + if user.role.title == self.settings.root_role_name: + courses = await self.courses_repository.get_all() + + elif user.role.title == UserRoles.TEACHER: + courses = await self.courses_repository.get_all_for_teacher(user.id) + + elif user.role.title == UserRoles.STUDENT: + courses = await self.courses_repository.get_all_for_enrollment(user.id) + + else: + courses = [] + + response = [] + for course in courses: + response.append( + CourseRead.model_validate(course) + ) + + return response + + async def get_by_id(self, course_id: int) -> Optional[CourseRead]: + course = await self.courses_repository.get_by_id(course_id) + if course is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Курс с таким Id не найден') + + return CourseRead.model_validate(course) + + async def create(self, course: CourseCreate) -> CourseCreated: # ← возвращаем CourseCreated course_model = Course( title=course.title, description=course.description, @@ -30,7 +61,7 @@ class CoursesService: course_model = await self.courses_repository.create(course_model) - return CourseRead.model_validate(course_model) + return CourseCreated.model_validate(course_model) async def update(self, course_id: int, course: CourseCreate) -> Optional[CourseRead]: course_model = await self.courses_repository.get_by_id(course_id) diff --git a/api/app/infrastructure/enrollments_service.py b/api/app/infrastructure/enrollments_service.py index a835f42..981ddf4 100644 --- a/api/app/infrastructure/enrollments_service.py +++ b/api/app/infrastructure/enrollments_service.py @@ -1,3 +1,4 @@ +import datetime from typing import Optional, List from fastapi import HTTPException, status @@ -52,7 +53,7 @@ class EnrollmentsService: enrollments_models.append(Enrollment( course_id=course_id, student_id=enrollment.student_id, - enrollment_date=enrollment.enrollment_date, + enrollment_date=datetime.datetime.now(), )) enrollments_models = await self.enrollments_repository.create_list(enrollments_models) diff --git a/api/app/infrastructure/lesson_files_service.py b/api/app/infrastructure/lesson_files_service.py new file mode 100644 index 0000000..2069ed2 --- /dev/null +++ b/api/app/infrastructure/lesson_files_service.py @@ -0,0 +1,118 @@ +import os +import uuid +from typing import List + +import aiofiles +from fastapi import UploadFile, HTTPException +from starlette.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from werkzeug.utils import secure_filename + +from app.application.lesson_files_repository import LessonFilesRepository +from app.application.lessons_repository import LessonsRepository +from app.domain.entities.lesson_files import ReadLessonFile +from app.domain.models import LessonFile + + +class LessonFilesService: + def __init__(self, db: AsyncSession): + self.lesson_files_repository = LessonFilesRepository(db) + self.lessons_repository = LessonsRepository(db) + + async def get_file_by_id(self, file_id: int) -> FileResponse: + lesson_file = await self.lesson_files_repository.get_by_id(file_id) + + if not lesson_file: + raise HTTPException(404, "Файл с таким ID не найден") + + return FileResponse( + lesson_file.file_path, + media_type=self.get_media_type(lesson_file.filename), + filename=os.path.basename(lesson_file.filename), + ) + + async def get_files_list_by_lesson(self, lesson_id: int) -> List[ReadLessonFile]: + lesson = await self.lessons_repository.get_by_id(lesson_id) + + if lesson is None: + raise HTTPException(404, "Лекционный материал не найден") + + lesson_files = await self.lesson_files_repository.get_by_lesson_id(lesson_id) + + response = [] + for lesson_file in lesson_files: + response.append( + ReadLessonFile.model_validate( + lesson_file + ) + ) + + return response + + async def upload_file(self, lesson_id: int, file: UploadFile) -> ReadLessonFile: + lesson = await self.lessons_repository.get_by_id(lesson_id) + + if lesson is None: + raise HTTPException(404, "Лекционный материал не найден") + + file_path = await self.save_file(file, f'uploads/lessons/{lesson.id}') + + lesson_file_model = LessonFile( + filename=file.filename, + file_path=file_path, + lesson_id=lesson.id, + ) + + lesson_file_model = await self.lesson_files_repository.create(lesson_file_model) + + return ReadLessonFile.model_validate(lesson_file_model) + + async def delete_file(self, file_id: int) -> ReadLessonFile: + lesson_file = await self.lesson_files_repository.get_by_id(file_id) + + if lesson_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(lesson_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(lesson_file.file_path): + os.remove(lesson_file.file_path) + + lesson_file = await self.lesson_files_repository.delete(lesson_file) + + return ReadLessonFile.model_validate(lesson_file) + + async def save_file(self, file: UploadFile, upload_dir: str = 'uploads/lessons') -> str: + os.makedirs(upload_dir, exist_ok=True) + filename = self.generate_filename(file) + file_path = os.path.join(upload_dir, filename) + + async with aiofiles.open(file_path, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + return file_path + + @staticmethod + def generate_filename(file: UploadFile) -> str: + return secure_filename(f"{uuid.uuid4()}_{file.filename}") + + @staticmethod + def get_media_type(filename: str) -> str: + extension = filename.split('.')[-1].lower() + if extension in ['jpeg', 'jpg', 'png']: + return f"image/{extension}" + if extension == 'pdf': + return "application/pdf" + if extension in ['zip']: + return "application/zip" + if extension in ['doc', 'docx']: + return "application/msword" + if extension in ['xls', 'xlsx']: + return "application/vnd.ms-excel" + if extension in ['ppt', 'pptx']: + return "application/vnd.ms-powerpoint" + if extension in ['txt']: + return "text/plain" + + return "application/octet-stream" diff --git a/api/app/infrastructure/lessons_service.py b/api/app/infrastructure/lessons_service.py new file mode 100644 index 0000000..b8e47f8 --- /dev/null +++ b/api/app/infrastructure/lessons_service.py @@ -0,0 +1,114 @@ +import os + +from fastapi import HTTPException, status +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.courses_repository import CoursesRepository +from app.application.lesson_files_repository import LessonFilesRepository +from app.application.lessons_repository import LessonsRepository +from app.domain.entities.lessons import LessonCreate, LessonUpdate, LessonRead +from app.domain.models import Lesson, User +from app.infrastructure.lesson_files_service import LessonFilesService +from app.settings import Settings + + +class LessonsService: + def __init__(self, db: AsyncSession): + self.lessons_repository = LessonsRepository(db) + self.courses_repository = CoursesRepository(db) + self.lesson_files_repository = LessonFilesRepository(db) + self.settings = Settings() + + async def get_all_by_course(self, course_id: int) -> List[LessonRead]: + lessons = await self.lessons_repository.get_all_by_course(course_id) + response = [] + + for lesson in lessons: + response.append(LessonRead.model_validate(lesson)) + + return response + + async def get_by_id(self, lesson_id: int) -> LessonRead: + lesson = await self.lessons_repository.get_by_id(lesson_id) + if not lesson: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Урок не найден" + ) + return LessonRead.model_validate(lesson) + + async def create(self, lesson_data: LessonCreate, creator: User, course_id) -> LessonRead: + course_model = await self.courses_repository.get_by_id(course_id) + if not course_model: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Курс не найден" + ) + + lesson = Lesson( + title=lesson_data.title, + description=lesson_data.description, + text=lesson_data.text, + number=lesson_data.number, + course_id=course_id, + creator_id=creator.id + ) + + created_lesson = await self.lessons_repository.create(lesson) + return LessonRead.model_validate(created_lesson) + + async def update(self, lesson_id: int, lesson_data: LessonUpdate, current_user: User) -> Optional[LessonRead]: + lesson = await self.lessons_repository.get_by_id(lesson_id) + if not lesson: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Урок не найден" + ) + + is_admin = current_user.role.title == self.settings.root_role_name + if lesson.creator_id != current_user.id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ запрещён" + ) + + update_dict = lesson_data.dict(exclude_unset=True) + for key, value in update_dict.items(): + setattr(lesson, key, value) + + updated_lesson = await self.lessons_repository.update(lesson) + return LessonRead.model_validate(updated_lesson) + + async def delete(self, lesson_id: int, current_user: User) -> None: + lesson = await self.lessons_repository.get_by_id(lesson_id) + if not lesson: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Урок не найден" + ) + + is_admin = current_user.role.title == self.settings.root_role_name + if lesson.creator_id != current_user.id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ запрещён" + ) + + lesson_files = await self.lesson_files_repository.get_by_lesson_id(lesson_id) + for file in lesson_files: + lesson_file = await self.lesson_files_repository.get_by_id(file.id) + + if lesson_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(lesson_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(lesson_file.file_path): + os.remove(lesson_file.file_path) + + await self.lesson_files_repository.delete(lesson_file) + + await self.lessons_repository.delete(lesson) diff --git a/api/app/infrastructure/solution_comments_service.py b/api/app/infrastructure/solution_comments_service.py new file mode 100644 index 0000000..85fb08f --- /dev/null +++ b/api/app/infrastructure/solution_comments_service.py @@ -0,0 +1,75 @@ +from http.client import HTTPException +from typing import Optional, List + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.solution_comments_repository import SolutionCommentsRepository +from app.application.solutions_repository import SolutionsRepository +from app.domain.entities.solutions import SolutionCommentRead, SolutionCommentCreate +from app.domain.models import User, SolutionComment + + +class SolutionCommentsService: + def __init__(self, db: AsyncSession): + self.solution_comments_repository = SolutionCommentsRepository(db) + self.solutions_repository = SolutionsRepository(db) + + async def get_by_id(self, comment_id) -> Optional[SolutionCommentRead]: + comment_model = await self.solution_comments_repository.get_by_id(comment_id) + + if comment_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Комментарий не найден" + ) + + return SolutionCommentRead.model_validate(comment_model) + + async def get_by_solution_id(self, solution_id) -> Optional[List[SolutionCommentRead]]: + solution_model = await self.solutions_repository.get_by_id(solution_id) + + if solution_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Решение не найдено" + ) + + comments = await self.solution_comments_repository.get_by_solution_id(solution_id) + response = [] + for comment in comments: + response.append(SolutionCommentRead.model_validate(comment)) + + return response + + async def create( + self, comment: SolutionCommentCreate, user: User, solution_id: int + ) -> Optional[SolutionCommentRead]: + solution_model = await self.solutions_repository.get_by_id(solution_id) + + if solution_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Решение не найдено" + ) + + comment_model = SolutionComment( + comment_text=comment.comment_text, + comment_autor_id=user.id, + solution_id=solution_model.id, + ) + + comment_model = await self.solution_comments_repository.create(comment_model) + + return SolutionCommentRead.model_validate(comment_model) + + async def delete(self, comment_id: int) -> None: + comment_model = await self.solution_comments_repository.get_by_id(comment_id) + + if comment_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Комментарий не найден" + ) + + await self.solution_comments_repository.delete(comment_model) diff --git a/api/app/infrastructure/solution_files_service.py b/api/app/infrastructure/solution_files_service.py new file mode 100644 index 0000000..0452ff2 --- /dev/null +++ b/api/app/infrastructure/solution_files_service.py @@ -0,0 +1,118 @@ +import os +import uuid +from typing import List + +import aiofiles +from fastapi import UploadFile, HTTPException +from starlette.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from werkzeug.utils import secure_filename + +from app.application.solution_files_repository import SolutionFilesRepository +from app.application.solutions_repository import SolutionsRepository +from app.domain.entities.solution_files import ReadSolutionFile +from app.domain.models import SolutionFile + + +class SolutionFilesService: + def __init__(self, db: AsyncSession): + self.solution_files_repository = SolutionFilesRepository(db) + self.solutions_repository = SolutionsRepository(db) + + async def get_file_by_id(self, file_id: int) -> FileResponse: + solution_file = await self.solution_files_repository.get_by_id(file_id) + + if not solution_file: + raise HTTPException(404, "Файл с таким ID не найден") + + return FileResponse( + solution_file.file_path, + media_type=self.get_media_type(solution_file.filename), + filename=os.path.basename(solution_file.filename), + ) + + async def get_files_list_by_solution(self, solution_id: int) -> List[ReadSolutionFile]: + solution = await self.solutions_repository.get_by_id(solution_id) + + if solution is None: + raise HTTPException(404, "Лекционный материал не найден") + + solution_files = await self.solution_files_repository.get_by_solution_id(solution_id) + + response = [] + for solution_file in solution_files: + response.append( + ReadSolutionFile.model_validate( + solution_file + ) + ) + + return response + + async def upload_file(self, solution_id: int, file: UploadFile) -> ReadSolutionFile: + solution = await self.solutions_repository.get_by_id(solution_id) + + if solution is None: + raise HTTPException(404, "Лекционный материал не найден") + + file_path = await self.save_file(file, f'uploads/solutions/{solution.id}') + + solution_file_model = SolutionFile( + filename=file.filename, + file_path=file_path, + solution_id=solution.id, + ) + + solution_file_model = await self.solution_files_repository.create(solution_file_model) + + return ReadSolutionFile.model_validate(solution_file_model) + + async def delete_file(self, file_id: int) -> ReadSolutionFile: + solution_file = await self.solution_files_repository.get_by_id(file_id) + + if solution_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(solution_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(solution_file.file_path): + os.remove(solution_file.file_path) + + solution_file = await self.solution_files_repository.delete(solution_file) + + return ReadSolutionFile.model_validate(solution_file) + + async def save_file(self, file: UploadFile, upload_dir: str = 'uploads/solutions') -> str: + os.makedirs(upload_dir, exist_ok=True) + filename = self.generate_filename(file) + file_path = os.path.join(upload_dir, filename) + + async with aiofiles.open(file_path, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + return file_path + + @staticmethod + def generate_filename(file: UploadFile) -> str: + return secure_filename(f"{uuid.uuid4()}_{file.filename}") + + @staticmethod + def get_media_type(filename: str) -> str: + extension = filename.split('.')[-1].lower() + if extension in ['jpeg', 'jpg', 'png']: + return f"image/{extension}" + if extension == 'pdf': + return "application/pdf" + if extension in ['zip']: + return "application/zip" + if extension in ['doc', 'docx']: + return "application/msword" + if extension in ['xls', 'xlsx']: + return "application/vnd.ms-excel" + if extension in ['ppt', 'pptx']: + return "application/vnd.ms-powerpoint" + if extension in ['txt']: + return "text/plain" + + return "application/octet-stream" diff --git a/api/app/infrastructure/solutions_service.py b/api/app/infrastructure/solutions_service.py new file mode 100644 index 0000000..7a2c9f8 --- /dev/null +++ b/api/app/infrastructure/solutions_service.py @@ -0,0 +1,132 @@ +import os +from typing import Optional, List + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.solution_files_repository import SolutionFilesRepository +from app.application.solutions_repository import SolutionsRepository +from app.application.tasks_repository import TasksRepository +from app.application.users_repository import UsersRepository +from app.domain.entities.solutions import SolutionRead, SolutionCreate, SolutionAfterCreate, AssessmentCreate +from app.domain.models import User, Solution +from app.settings import Settings + + +class SolutionsService: + def __init__(self, db: AsyncSession): + self.solutions_repository = SolutionsRepository(db) + self.tasks_repository = TasksRepository(db) + self.users_repository = UsersRepository(db) + self.solution_files_repository = SolutionFilesRepository(db) + self.settings = Settings() + + async def get_by_task_id(self, task_id: int) -> Optional[List[SolutionRead]]: + task_model = await self.tasks_repository.get_by_id(task_id) + + if task_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + solutions = await self.solutions_repository.get_by_task_id(task_id) + + response = [] + for solution in solutions: + response.append( + SolutionRead.model_validate(solution) + ) + + return response + + async def get_by_task_id_and_student_id(self, task_id: int, student_id: int) -> Optional[List[SolutionRead]]: + task_model = await self.tasks_repository.get_by_id(task_id) + + if task_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + student_model = await self.users_repository.get_by_id(student_id) + + if student_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Такой пользователь не найден" + ) + + solutions = await self.solutions_repository.get_by_task_id_and_student_id(task_id, student_id) + + response = [] + for solution in solutions: + response.append( + SolutionRead.model_validate(solution) + ) + + return response + + async def create_assessment(self, solution_id: int, assessment: AssessmentCreate, user: User) -> None: + solution_model = await self.solutions_repository.get_by_id(solution_id) + + if solution_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Такого решения не найдено" + ) + + solution_model.assessment = assessment.assessment + solution_model.assessment_autor_id = user.id + + await self.solutions_repository.update(solution_model) + + async def create(self, solution: SolutionCreate, creator: User, task_id: int) -> SolutionAfterCreate: + task_model = await self.tasks_repository.get_by_id(task_id) + + if task_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + model_solution = Solution( + answer_text=solution.answer_text, + assessment=solution.assessment, + task_id=task_id, + student_id=creator.id, + ) + created_solution = await self.solutions_repository.create(model_solution) + return SolutionAfterCreate.model_validate(created_solution) + + async def delete(self, solution_id: int, current_user: User) -> Optional[SolutionRead]: + solution = await self.solutions_repository.get_by_id(solution_id) + if solution is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Такое решение не найдено' + ) + + is_admin = current_user.role.title == self.settings.root_role_name + if solution.student_id != current_user.id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ запрещён" + ) + + solution_files = await self.solution_files_repository.get_by_solution_id(solution.id) + for file in solution_files: + solution_file = await self.solution_files_repository.get_by_id(file.id) + + if solution_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(solution_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(solution_file.file_path): + os.remove(solution_file.file_path) + + await self.solution_files_repository.delete(solution_file) + + await self.solutions_repository.delete(solution) diff --git a/api/app/infrastructure/task_files_service.py b/api/app/infrastructure/task_files_service.py new file mode 100644 index 0000000..a8d2466 --- /dev/null +++ b/api/app/infrastructure/task_files_service.py @@ -0,0 +1,118 @@ +import os +import uuid +from typing import List + +import aiofiles +from fastapi import UploadFile, HTTPException +from starlette.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from werkzeug.utils import secure_filename + +from app.application.task_files_repository import TaskFilesRepository +from app.application.tasks_repository import TasksRepository +from app.domain.entities.task_files import ReadTaskFile +from app.domain.models import TaskFile + + +class TaskFilesService: + def __init__(self, db: AsyncSession): + self.task_files_repository = TaskFilesRepository(db) + self.tasks_repository = TasksRepository(db) + + async def get_file_by_id(self, file_id: int) -> FileResponse: + task_file = await self.task_files_repository.get_by_id(file_id) + + if not task_file: + raise HTTPException(404, "Файл с таким ID не найден") + + return FileResponse( + task_file.file_path, + media_type=self.get_media_type(task_file.filename), + filename=os.path.basename(task_file.filename), + ) + + async def get_files_list_by_task(self, task_id: int) -> List[ReadTaskFile]: + task = await self.tasks_repository.get_by_id(task_id) + + if task is None: + raise HTTPException(404, "Лекционный материал не найден") + + task_files = await self.task_files_repository.get_by_task_id(task_id) + + response = [] + for task_file in task_files: + response.append( + ReadTaskFile.model_validate( + task_file + ) + ) + + return response + + async def upload_file(self, task_id: int, file: UploadFile) -> ReadTaskFile: + task = await self.tasks_repository.get_by_id(task_id) + + if task is None: + raise HTTPException(404, "Лекционный материал не найден") + + file_path = await self.save_file(file, f'uploads/tasks/{task.id}') + + task_file_model = TaskFile( + filename=file.filename, + file_path=file_path, + task_id=task.id, + ) + + task_file_model = await self.task_files_repository.create(task_file_model) + + return ReadTaskFile.model_validate(task_file_model) + + async def delete_file(self, file_id: int) -> ReadTaskFile: + task_file = await self.task_files_repository.get_by_id(file_id) + + if task_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(task_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(task_file.file_path): + os.remove(task_file.file_path) + + task_file = await self.task_files_repository.delete(task_file) + + return ReadTaskFile.model_validate(task_file) + + async def save_file(self, file: UploadFile, upload_dir: str = 'uploads/tasks') -> str: + os.makedirs(upload_dir, exist_ok=True) + filename = self.generate_filename(file) + file_path = os.path.join(upload_dir, filename) + + async with aiofiles.open(file_path, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + return file_path + + @staticmethod + def generate_filename(file: UploadFile) -> str: + return secure_filename(f"{uuid.uuid4()}_{file.filename}") + + @staticmethod + def get_media_type(filename: str) -> str: + extension = filename.split('.')[-1].lower() + if extension in ['jpeg', 'jpg', 'png']: + return f"image/{extension}" + if extension == 'pdf': + return "application/pdf" + if extension in ['zip']: + return "application/zip" + if extension in ['doc', 'docx']: + return "application/msword" + if extension in ['xls', 'xlsx']: + return "application/vnd.ms-excel" + if extension in ['ppt', 'pptx']: + return "application/vnd.ms-powerpoint" + if extension in ['txt']: + return "text/plain" + + return "application/octet-stream" diff --git a/api/app/infrastructure/tasks_service.py b/api/app/infrastructure/tasks_service.py new file mode 100644 index 0000000..160071c --- /dev/null +++ b/api/app/infrastructure/tasks_service.py @@ -0,0 +1,113 @@ +import os +from typing import List, Optional + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.courses_repository import CoursesRepository +from app.application.task_files_repository import TaskFilesRepository +from app.application.tasks_repository import TasksRepository +from app.domain.entities.tasks import TaskCreate, TaskUpdate +from app.domain.entities.tasks import TaskRead +from app.domain.models import User, Task +from app.settings import Settings + + +class TasksService: + def __init__(self, db: AsyncSession): + self.tasks_repository = TasksRepository(db) + self.courses_repository = CoursesRepository(db) + self.task_files_repository = TaskFilesRepository(db) + self.settings = Settings() + + async def get_all_by_course(self, course_id: int) -> List[TaskRead]: + tasks = await self.tasks_repository.get_all_by_course(course_id) + response = [] + + for task in tasks: + response.append(TaskRead.model_validate(task)) + + return response + + async def get_by_id(self, task_id: int) -> TaskRead: + task = await self.tasks_repository.get_by_id(task_id) + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + return TaskRead.model_validate(task) + + async def create(self, task_data: TaskCreate, creator: User, course_id) -> TaskRead: + course_model = await self.courses_repository.get_by_id(course_id) + if not course_model: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Курс не найден" + ) + + task = Task( + title=task_data.title, + description=task_data.description, + text=task_data.text, + number=task_data.number, + course_id=course_id, + creator_id=creator.id + ) + + created_task = await self.tasks_repository.create(task) + return TaskRead.model_validate(created_task) + + async def update(self, task_id: int, task_data: TaskUpdate, current_user: User) -> Optional[TaskRead]: + task = await self.tasks_repository.get_by_id(task_id) + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + is_admin = current_user.role.title == self.settings.root_role_name + if task.creator_id != current_user.id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ запрещён" + ) + + update_dict = task_data.dict(exclude_unset=True) + for key, value in update_dict.items(): + setattr(task, key, value) + + updated_task = await self.tasks_repository.update(task) + return TaskRead.model_validate(updated_task) + + async def delete(self, task_id: int, current_user: User) -> None: + task = await self.tasks_repository.get_by_id(task_id) + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + is_admin = current_user.role.title == self.settings.root_role_name + if task.creator_id != current_user.id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ запрещён" + ) + + task_files = await self.task_files_repository.get_by_task_id(task_id) + for file in task_files: + task_file = await self.task_files_repository.get_by_id(file.id) + + if task_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(task_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(task_file.file_path): + os.remove(task_file.file_path) + + await self.task_files_repository.delete(task_file) + + await self.tasks_repository.delete(task) diff --git a/api/app/infrastructure/user_check_lessons_service.py b/api/app/infrastructure/user_check_lessons_service.py new file mode 100644 index 0000000..6050547 --- /dev/null +++ b/api/app/infrastructure/user_check_lessons_service.py @@ -0,0 +1,102 @@ +from typing import Optional, List + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.courses_repository import CoursesRepository +from app.application.lessons_repository import LessonsRepository +from app.application.solutions_repository import SolutionsRepository +from app.application.tasks_repository import TasksRepository +from app.application.user_check_lessons_repository import UserCheckLessonsRepository +from app.application.users_repository import UsersRepository +from app.domain.entities.users import UserCheckLessonRead, UserCheckLessonCreate +from app.domain.models import UserCheckLessons + + +class UserCheckLessonsService: + def __init__(self, db: AsyncSession): + self.user_check_lessons_repository = UserCheckLessonsRepository(db) + self.users_repository = UsersRepository(db) + self.lessons_repository = LessonsRepository(db) + self.courses_repository = CoursesRepository(db) + self.tasks_repository = TasksRepository(db) + self.lessons_repository = LessonsRepository(db) + self.solutions_repository = SolutionsRepository(db) + + async def get_by_course_id_and_user_id(self, course_id: int, user_id: int) -> Optional[List[UserCheckLessonRead]]: + user = await self.users_repository.get_by_id(user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + course = await self.courses_repository.get_by_id(course_id) + if course is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Курс не найден" + ) + + checks = await self.user_check_lessons_repository.get_by_course_id_and_user_id(course_id, user_id) + response = [] + for check in checks: + response.append( + UserCheckLessonRead.model_validate(check) + ) + + return response + + async def create(self, lesson_id: int, user_id: int) -> None: + user = await self.users_repository.get_by_id(user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + lesson = await self.lessons_repository.get_by_id(lesson_id) + if lesson is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Лекция не найдена" + ) + + user_check = await self.user_check_lessons_repository.get_by_user_id_and_lesson_id( + user_id, + lesson_id + ) + + if user_check is not None: + return + + user_check_model = UserCheckLessons( + lesson_id=lesson_id, + user_id=user_id, + ) + user_check_model = await self.user_check_lessons_repository.create(user_check_model) + + async def calculate_user_progress(self, course_id: int, user_id: int) -> float: + total_lessons = await self.lessons_repository.get_all_by_course(course_id) + total_tasks = await self.tasks_repository.get_all_by_course(course_id) + + total_content = len(total_lessons) + len(total_tasks) + if total_content == 0: + return 100.0 + + passed_lessons = await self.user_check_lessons_repository.get_by_course_id_and_user_id( + course_id=course_id, + user_id=user_id + ) + passed_lessons_count = len(passed_lessons) + + completed_tasks = await self.solutions_repository.get_completed_tasks_by_course_and_user( + course_id=course_id, + user_id=user_id + ) + completed_tasks_count = len(completed_tasks) + + completed_total = passed_lessons_count + completed_tasks_count + progress = (completed_total / total_content) * 100 + + return round(progress, 2) \ No newline at end of file diff --git a/api/app/main.py b/api/app/main.py index 1b238a9..57b5862 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -3,9 +3,13 @@ from starlette.middleware.cors import CORSMiddleware from app.controllers.auth_router import auth_router from app.controllers.courses_router import courses_router +from app.controllers.lessons_router import lessons_router from app.controllers.register_router import register_router from app.controllers.roles_router import roles_router +from app.controllers.solution_comments_router import solution_comments_router +from app.controllers.solutions_router import solution_router from app.controllers.statuses_router import statuses_router +from app.controllers.tasks_router import tasks_router from app.controllers.users_router import users_router from app.settings import Settings @@ -24,9 +28,13 @@ def start_app(): api_app.include_router(auth_router, prefix=f'{settings.prefix}/auth', tags=['auth']) api_app.include_router(courses_router, prefix=f'{settings.prefix}/courses', tags=['courses']) + api_app.include_router(lessons_router, prefix=f'{settings.prefix}/lessons', tags=['lessons']) api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register']) api_app.include_router(roles_router, prefix=f'{settings.prefix}/roles', tags=['roles']) + api_app.include_router(solution_comments_router, prefix=f'{settings.prefix}/comments', tags=['comments']) + api_app.include_router(solution_router, prefix=f'{settings.prefix}/solutions', tags=['solutions']) api_app.include_router(statuses_router, prefix=f'{settings.prefix}/statuses', tags=['statuses']) + api_app.include_router(tasks_router, prefix=f'{settings.prefix}/tasks', tags=['tasks']) api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users']) return api_app diff --git a/api/req.txt b/api/req.txt index 1529607..d3a8463 100644 --- a/api/req.txt +++ b/api/req.txt @@ -6,4 +6,5 @@ greenlet==3.2.4 werkzeug==3.1.3 pyjwt==2.9.0 fastapi==0.115.0 -pydantic[email]==2.11.4 \ No newline at end of file +pydantic[email]==2.11.4 +aiofiles==25.1.0 \ No newline at end of file diff --git a/web/index.html b/web/index.html index 9c049fa..4a6752d 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ -
Файлы отсутствуют
+ )} +Файлы отсутствуют
+ )} +Файлы отсутствуют
+ )} + +Файлы отсутствуют
+ )} + + {currentUser?.role?.title === ROLES.STUDENT ? ( +