Merge branch 'andrei' into lev

# Conflicts:
#	web/src/App/AppRouter.jsx
This commit is contained in:
Андрей Дувакин 2025-11-29 21:09:39 +05:00
commit eaeff43494
87 changed files with 6495 additions and 170 deletions

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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',
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -2,7 +2,6 @@ from pydantic import BaseModel, EmailStr, Field
class CourseTeacherCreate(BaseModel):
course_id: int = Field()
teacher_id: int = Field()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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')

View File

@ -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')

View File

@ -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',
)

View File

@ -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')

View File

@ -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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -7,3 +7,4 @@ werkzeug==3.1.3
pyjwt==2.9.0
fastapi==0.115.0
pydantic[email]==2.11.4
aiofiles==25.1.0

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/rounded_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<title>Система обучения lectio</title>
</head>
<body>
<div id="root"></div>

20
web/package-lock.json generated
View File

@ -12,6 +12,7 @@
"@reduxjs/toolkit": "^2.11.0",
"antd": "^6.0.0",
"dayjs": "^1.11.19",
"jodit-react": "^5.2.38",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-redux": "^9.2.0",
@ -3134,6 +3135,25 @@
"dev": true,
"license": "ISC"
},
"node_modules/jodit": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/jodit/-/jodit-4.7.9.tgz",
"integrity": "sha512-dPlewnu2+vVXELh9BEJTNQoSBL3Cf2E0fNh30yjSEgUtJHYSUYToDjMDEqr0T/L9iNpqPTODy2b4pzyGpPRUog==",
"license": "MIT"
},
"node_modules/jodit-react": {
"version": "5.2.38",
"resolved": "https://registry.npmjs.org/jodit-react/-/jodit-react-5.2.38.tgz",
"integrity": "sha512-k98vjch0JWX13Dlf7tWv3wPo6dgO9bpi0NUBC6YrpGLYtRJx8d5Ttz5TLWuydEReedWWyNKobSGIGYQDihP8Vw==",
"license": "MIT",
"dependencies": {
"jodit": "^4.7.9"
},
"peerDependencies": {
"react": "~0.14 || ^15 || ^16 || ^17 || ^18 || ^19",
"react-dom": "~0.14 || ^15 || ^16 || ^17 || ^18 || ^19"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -14,6 +14,7 @@
"@reduxjs/toolkit": "^2.11.0",
"antd": "^6.0.0",
"dayjs": "^1.11.19",
"jodit-react": "^5.2.38",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-redux": "^9.2.0",

View File

@ -0,0 +1,39 @@
import {createApi} from "@reduxjs/toolkit/query/react";
import {baseQueryWithAuth} from "./baseQuery.js";
export const commentsApi = createApi({
reducerPath: "commentsApi",
baseQuery: baseQueryWithAuth,
tagTypes: ["comments"],
endpoints: (builder) => ({
getAllCommentsBySolutionId: builder.query({
query: (solutionId) => ({
url: `/comments/solution/${solutionId}/`,
method: "GET"
}),
providesTags: ["comments"],
}),
createComment: builder.mutation({
query: ({solutionId, comment}) => ({
url: `/comments/solution/${solutionId}/`,
method: "POST",
body: comment,
}),
invalidatesTags: ["comments"],
}),
deleteComment: builder.mutation({
query: (commentId) => ({
url: `/comments/${commentId}/`,
method: "DELETE",
}),
invalidatesTags: ["comments"],
}),
})
});
export const {
useGetAllCommentsBySolutionIdQuery,
useCreateCommentMutation,
useDeleteCommentMutation,
} = commentsApi;

View File

@ -14,6 +14,20 @@ export const coursesApi = createApi({
}),
providesTags: ['course'],
}),
getAllMyCourses: builder.query({
query: () => ({
url: "/courses/for-me/",
method: "GET",
}),
providesTags: ['course'],
}),
getCourseById: builder.query({
query: (courseId) => ({
url: `/courses/${courseId}/`,
method: "GET",
}),
providesTags: ['course'],
}),
createCourse: builder.mutation({
query: (data) => ({
url: "/courses/",
@ -38,10 +52,10 @@ export const coursesApi = createApi({
providesTags: ['teacher'],
}),
replaceCourseTeachers: builder.mutation({
query: ({courseId, ...data}) => ({
query: ({courseId, teachers}) => ({
url: `/courses/${courseId}/teachers/`,
method: "PUT",
body: data,
body: teachers,
}),
invalidatesTags: ['teacher'],
}),
@ -53,10 +67,10 @@ export const coursesApi = createApi({
providesTags: ['student'],
}),
replaceCourseStudents: builder.mutation({
query: ({courseId, ...data}) => ({
query: ({courseId, students}) => ({
url: `/courses/${courseId}/students/`,
method: "PUT",
body: data,
body: students,
}),
invalidatesTags: ['student'],
}),
@ -65,6 +79,8 @@ export const coursesApi = createApi({
export const {
useGetAllCoursesQuery,
useGetAllMyCoursesQuery,
useGetCourseByIdQuery,
useCreateCourseMutation,
useUpdateCourseMutation,
useGetCourseTeachersQuery,

104
web/src/Api/lessonsApi.js Normal file
View File

@ -0,0 +1,104 @@
import {createApi} from "@reduxjs/toolkit/query/react";
import {baseQueryWithAuth} from "./baseQuery.js";
export const lessonsApi = createApi({
reducerPath: "lessonsApi",
baseQuery: baseQueryWithAuth,
tagTypes: ["lesson"],
endpoints: (builder) => ({
getLessonsByCourseId: builder.query({
query: (courseId) => ({
url: `/lessons/course/${courseId}/`,
method: "GET",
}),
providesTags: ["lesson"],
transformResponse: (response) => {
return response.map(lesson => ({
...lesson,
contentType: "lesson",
__typename: "Lesson"
}));
},
}),
getLessonById: builder.query({
query: (lessonId) => ({
url: `/lessons/${lessonId}/`,
method: "GET",
}),
providesTags: ["lesson"],
}),
createLesson: builder.mutation({
query: ({courseId, lessonData}) => ({
url: `/lessons/${courseId}/`,
method: "POST",
body: lessonData,
}),
invalidatesTags: ["lesson"],
}),
updateLesson: builder.mutation({
query: ({lessonId, lessonData}) => ({
url: `/lessons/${lessonId}/`,
method: "PUT",
body: lessonData,
}),
invalidatesTags: ["lesson"],
}),
deleteLesson: builder.mutation({
query: (lessonId) => ({
url: `/lessons/${lessonId}/`,
method: "DELETE",
}),
invalidatesTags: ["lesson"],
}),
getLessonFilesList: builder.query({
query: (lessonId) => ({
url: `/lessons/files/${lessonId}/`,
method: "GET",
}),
providesTags: ["lesson"],
}),
getDownloadFile: builder.query({
query: (fileId) => ({
url: `/lessons/file/${fileId}/`,
method: "GET",
}),
providesTags: ["lesson"],
}),
uploadFile: builder.mutation({
query: ({lesson_id, fileData}) => {
if (!(fileData instanceof File)) {
throw new Error('Invalid file object');
}
const formData = new FormData();
formData.append('file', fileData);
return {
url: `/lessons/files/${lesson_id}/upload/`,
method: 'POST',
formData: true,
body: formData,
};
},
invalidatesTags: ["lesson"],
}),
deleteFile: builder.mutation({
query: (fileId) => ({
url: `/lessons/files/${fileId}/`,
method: "DELETE",
}),
invalidatesTags: ["lesson"],
}),
}),
});
export const {
useGetLessonsByCourseIdQuery,
useGetLessonByIdQuery,
useCreateLessonMutation,
useUpdateLessonMutation,
useDeleteLessonMutation,
useGetLessonFilesListQuery,
useGetDownloadFileQuery,
useUploadFileMutation,
useDeleteFileMutation,
} = lessonsApi;

View File

@ -0,0 +1,82 @@
import {createApi} from "@reduxjs/toolkit/query/react";
import {baseQueryWithAuth} from "./baseQuery.js";
export const solutionsApi = createApi({
reducerPath: "solutionsApi",
baseQuery: baseQueryWithAuth,
tagTypes: ["solution"],
endpoints: (builder) => ({
getTaskSolutions: builder.query({
query: (taskId) => ({
url: `/solutions/task/${taskId}/`,
method: "GET"
}),
providesTags: ["solution"],
}),
getTaskStudentSolutions: builder.query({
query: ({taskId, studentId}) => ({
url: `/solutions/task/${taskId}/student/${studentId}/`,
method: "GET"
}),
providesTags: ["solution"],
}),
createSolution: builder.mutation({
query: ({taskId, solution}) => ({
url: `/solutions/${taskId}/`,
method: "POST",
body: solution,
}),
invalidatesTags: ["solution"],
}),
deleteSolution: builder.mutation({
query: (solutionId) => ({
url: `/solutions/${solutionId}/`,
method: "DELETE",
}),
invalidatesTags: ["solution"],
}),
getSolutionFilesList: builder.query({
query: (solutionId) => ({
url: `/solutions/files/${solutionId}/`,
method: "GET"
}),
providesTags: ["solution"],
}),
uploadFile: builder.mutation({
query: ({solutionId, fileData}) => {
if (!(fileData instanceof File)) {
throw new Error('Invalid file object');
}
const formData = new FormData();
formData.append('file', fileData);
return {
url: `/solutions/files/${solutionId}/upload/`,
method: 'POST',
formData: true,
body: formData,
};
},
invalidatesTags: ["solution"],
}),
createAssessment: builder.mutation({
query: ({solutionId, assessment}) => ({
url: `/solutions/assessment/${solutionId}/`,
method: "POST",
body: assessment,
}),
invalidatesTags: ["solution"],
}),
}),
});
export const {
useGetTaskSolutionsQuery,
useGetTaskStudentSolutionsQuery,
useCreateSolutionMutation,
useDeleteSolutionMutation,
useGetSolutionFilesListQuery,
useUploadFileMutation,
useCreateAssessmentMutation,
} = solutionsApi;

104
web/src/Api/tasksApi.js Normal file
View File

@ -0,0 +1,104 @@
import {createApi} from "@reduxjs/toolkit/query/react";
import {baseQueryWithAuth} from "./baseQuery.js";
export const tasksApi = createApi({
reducerPath: "tasksApi",
baseQuery: baseQueryWithAuth,
tagTypes: ["task"],
endpoints: (builder) => ({
getTasksByCourseId: builder.query({
query: (courseId) => ({
url: `/tasks/course/${courseId}/`,
method: "GET",
}),
providesTags: ["task"],
transformResponse: (response) => {
return response.map(task => ({
...task,
contentType: "task",
__typename: "Task"
}));
},
}),
getTaskById: builder.query({
query: (taskId) => ({
url: `/tasks/${taskId}/`,
method: "GET",
}),
providesTags: ["task"],
}),
createTask: builder.mutation({
query: ({courseId, taskData}) => ({
url: `/tasks/${courseId}/`,
method: "POST",
body: taskData,
}),
invalidatesTags: ["task"],
}),
updateTask: builder.mutation({
query: ({taskId, taskData}) => ({
url: `/tasks/${taskId}/`,
method: "PUT",
body: taskData,
}),
invalidatesTags: ["task"],
}),
deleteTask: builder.mutation({
query: (taskId) => ({
url: `/tasks/${taskId}/`,
method: "DELETE",
}),
invalidatesTags: ["task"],
}),
getTaskFilesList: builder.query({
query: (taskId) => ({
url: `/tasks/files/${taskId}/`,
method: "GET",
}),
providesTags: ["task"],
}),
getDownloadFile: builder.query({
query: (fileId) => ({
url: `/tasks/file/${fileId}/`,
method: "GET",
}),
providesTags: ["task"],
}),
uploadFile: builder.mutation({
query: ({task_id, fileData}) => {
if (!(fileData instanceof File)) {
throw new Error('Invalid file object');
}
const formData = new FormData();
formData.append('file', fileData);
return {
url: `/tasks/files/${task_id}/upload/`,
method: 'POST',
formData: true,
body: formData,
};
},
invalidatesTags: ["task"],
}),
deleteFile: builder.mutation({
query: (fileId) => ({
url: `/tasks/files/${fileId}/`,
method: "DELETE",
}),
invalidatesTags: ["task"],
}),
}),
});
export const {
useGetTasksByCourseIdQuery,
useGetTaskByIdQuery,
useCreateTaskMutation,
useUpdateTaskMutation,
useDeleteTaskMutation,
useGetTaskFilesListQuery,
useGetDownloadFileQuery,
useUploadFileMutation,
useDeleteFileMutation,
} = tasksApi;

View File

@ -46,6 +46,27 @@ export const usersApi = createApi({
}),
providesTags: ["user"],
}),
getReadedLessonsByCourse: builder.query({
query: (courseId) => ({
url: `/users/check-my-lessons/${courseId}/`,
method: "GET",
}),
providesTags: ["user"],
}),
setLessonAsReaded: builder.mutation({
query: (lessonId) => ({
url: `/users/check-lesson/${lessonId}/`,
method: "POST",
}),
invalidatesTags: ["user"],
}),
getMyCourseProgress: builder.query({
query: (courseId) => ({
url: `/users/my-progress/${courseId}/`,
method: "GET",
}),
providesTags: ["user"],
}),
}),
});
@ -56,4 +77,7 @@ export const {
useUpdateUserPasswordMutation,
useCreateUserMutation,
useGetUsersByRoleNameQuery,
useGetReadedLessonsByCourseQuery,
useSetLessonAsReadedMutation,
useGetMyCourseProgressQuery,
} = usersApi;

View File

@ -7,6 +7,7 @@ import CoursesPage from "../Components/Pages/Courses/CoursesPage.jsx";
import MainLayout from "../Components/Layouts/MainLayout.jsx";
import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx";
import AdminPage from "../Components/Pages/AdminPage/AdminPage.jsx";
import CourseDetailPage from "../Components/Pages/CourseDetailPage/CourseDetailPage.jsx";
const AppRouter = () => (
@ -18,6 +19,7 @@ const AppRouter = () => (
<Route element={<MainLayout/>}>
<Route path={"/courses"} element={<CoursesPage/>}/>
<Route path={"/profile"} element={<ProfilePage/>}/>
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path={"*"} element={<Navigate to={"/courses"}/>}/>
</Route>
</Route>

View File

@ -1,6 +1,6 @@
import useMainLayout from "./useMainLayout.js";
import {Layout, Menu} from "antd";
import CoursesPage from "../Pages/Courses/CoursesPage.jsx";
import CoursesPage from "../Pages/CoursesPage/CoursesPage.jsx";
import LoadingIndicator from "../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import {Outlet} from "react-router-dom";
import {BookOutlined, ControlOutlined, LogoutOutlined, UserOutlined} from "@ant-design/icons";
@ -79,7 +79,7 @@ const MainLayout = () => {
<Outlet/>
)}
</Content>
<Footer style={{textAlign: "center"}}>{new Date().getFullYear()}</Footer>
<Footer style={{textAlign: "center"}}>lectio © {new Date().getFullYear()}</Footer>
</Layout>
</Layout>
)

View File

@ -1,6 +1,6 @@
import {useGetAllUsersQuery, useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
import {useGetAllRolesQuery} from "../../../Api/rolesApi.js";
import {useMemo, useState} from "react";
import {useEffect, useMemo, useState} from "react";
import {useDispatch} from "react-redux";
import {setOpenModalCreateUser, setSelectedUserToUpdate} from "../../../Redux/Slices/usersSlice.js";
@ -18,6 +18,10 @@ const useAdminPage = () => {
setSearchString,
] = useState("");
useEffect(() => {
window.document.title = "Система обучения lectio - Панель администратора";
}, []);
const {
data: usersData = [],
isLoading: usersIsLoading,

View File

@ -0,0 +1,86 @@
import useCreateLessonModalForm from "./useCreateLessonModalForm.js";
import {Button, Form, Input, InputNumber, Modal, Upload} from "antd";
import JoditEditor from "jodit-react";
import {UploadOutlined} from "@ant-design/icons";
const {TextArea} = Input;
const CreateLessonModalForm = ({courseId}) => {
const {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading,
handleAddFile,
handleRemoveFile,
draftFiles,
} = useCreateLessonModalForm({courseId});
return (
<Modal
title="Создание лекционного материала"
open={isModalOpen}
onCancel={handleCancel}
width={1000}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoading}
onClick={handleOk}
>
Создать лекцию
</Button>,
]}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item
name="title"
label="Название лекции"
rules={[{required: true, message: "Введите название лекции"}]}
>
<Input size="large"/>
</Form.Item>
<Form.Item name="description" label="Краткое описание">
<TextArea rows={2} placeholder="О чём эта лекция..."/>
</Form.Item>
<Form.Item name="number" label="Порядковый номер" initialValue={1}>
<InputNumber min={1} style={{width: "100%"}}/>
</Form.Item>
<Form.Item label="Содержание лекции">
<div style={{border: "1px solid #d9d9d9", borderRadius: 6}}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
</Form.Item>
<Form.Item name="files" label="Прикрепить файлы">
<Upload
fileList={draftFiles}
beforeUpload={(file) => {
handleAddFile(file);
return false;
}}
onRemove={(file) => handleRemoveFile(file)}
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
</Form.Item>
</Form>
</Modal>
);
};
export default CreateLessonModalForm;

View File

@ -0,0 +1,178 @@
import {useDispatch, useSelector} from "react-redux";
import {setOpenModalCreateLesson} from "../../../../../Redux/Slices/lessonsSlice.js";
import {Form, notification} from "antd";
import {useMemo, useRef, useState} from "react";
import {useCreateLessonMutation, useUploadFileMutation} from "../../../../../Api/lessonsApi.js";
const useCreateLessonModalForm = ({courseId}) => {
const dispatch = useDispatch();
const {
openModalCreateLesson
} = useSelector((state) => state.lessons);
const [form] = Form.useForm();
const [createLesson, {isLoading}] = useCreateLessonMutation();
const [draftFiles, setDraftFiles] = useState([]);
const [uploadFile] = useUploadFileMutation();
const isModalOpen = openModalCreateLesson;
const editorRef = useRef(null);
const handleCancel = () => {
form.resetFields();
if (editorRef.current) {
editorRef.current.value = "";
}
dispatch(setOpenModalCreateLesson(false));
};
const handleAddFile = (file) => {
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
if (file.size > maxSize) {
notification.error({
message: "Ошибка вставки",
description: "Файл слишком большой.",
placement: "topRight",
});
return false;
}
setDraftFiles((prev) => [...prev, file]);
return false;
};
const handleRemoveFile = (file) => {
setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid));
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const content = editorRef.current?.value || "";
const lessonData = {
title: values.title,
description: values.description || null,
text: content,
number: values.number || 1,
};
const response = await createLesson({
courseId,
lessonData,
}).unwrap();
for (const file of draftFiles) {
try {
await uploadFile({
lesson_id: response.id,
fileData: file,
}).unwrap();
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
}
notification.success({
title: "Успех",
description: "Лекция успешно создана!",
placement: "topRight",
});
handleCancel();
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось создать лекцию",
placement: "topRight",
});
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
height: 150,
toolbarAdaptive: false,
buttons: [
"bold", "italic", "underline", "strikethrough", "|",
"superscript", "subscript", "|",
"ul", "ol", "outdent", "indent", "|",
"font", "fontsize", "brush", "paragraph", "|",
"align", "hr", "|",
"table", "link", "image", "video", "symbols", "|",
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
"find", "source", "fullsize", "print", "preview",
],
autofocus: false,
preserveSelection: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
spellcheck: true,
placeholder: "Заполните содержимое лекционного материала",
showCharsCounter: true,
showWordsCounter: true,
showXPathInStatusbar: false,
toolbarSticky: true,
toolbarButtonSize: "middle",
cleanHTML: {
removeEmptyElements: true,
replaceNBSP: false,
},
hotkeys: {
"ctrl + shift + f": "find",
"ctrl + b": "bold",
"ctrl + i": "italic",
"ctrl + u": "underline",
},
image: {
editSrc: true,
editTitle: true,
editAlt: true,
openOnDblClick: false,
},
video: {
allowedSources: ["youtube", "vimeo"],
},
uploader: {
insertImageAsBase64URI: true,
},
paste: {
insertAsBase64: true,
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 5 * 1024 * 1024,
error: () => {
notification.error({
title: "Ошибка вставки",
description: "Файл слишком большой или неподдерживаемый формат.",
placement: "topRight",
});
},
},
}),
[]
);
return {
isModalOpen,
handleCancel,
form,
joditConfig,
editorRef,
handleOk,
handleAddFile,
handleRemoveFile,
draftFiles,
}
};
export default useCreateLessonModalForm;

View File

@ -0,0 +1,86 @@
import useCreateTaskModalForm from "./useCreateTaskModalForm.js";
import {Button, Form, Input, InputNumber, Modal, Upload} from "antd";
import JoditEditor from "jodit-react";
import {UploadOutlined} from "@ant-design/icons";
const {TextArea} = Input;
const CreateTaskModalForm = ({courseId}) => {
const {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading,
handleAddFile,
handleRemoveFile,
draftFiles,
} = useCreateTaskModalForm({courseId});
return (
<Modal
title="Создание задания"
open={isModalOpen}
onCancel={handleCancel}
width={1000}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoading}
onClick={handleOk}
>
Создать задание
</Button>,
]}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item
name="title"
label="Название задания"
rules={[{required: true, message: "Введите название задания"}]}
>
<Input size="large"/>
</Form.Item>
<Form.Item name="description" label="Краткое описание заачи">
<TextArea rows={2} placeholder="Что нужно будет сделать..."/>
</Form.Item>
<Form.Item name="number" label="Порядковый номер" initialValue={1}>
<InputNumber min={1} style={{width: "100%"}}/>
</Form.Item>
<Form.Item label="Содержание задания">
<div style={{border: "1px solid #d9d9d9", borderRadius: 6}}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
</Form.Item>
<Form.Item name="files" label="Прикрепить файлы">
<Upload
fileList={draftFiles}
beforeUpload={(file) => {
handleAddFile(file);
return false;
}}
onRemove={(file) => handleRemoveFile(file)}
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
</Form.Item>
</Form>
</Modal>
);
};
export default CreateTaskModalForm;

View File

@ -0,0 +1,178 @@
import {useDispatch, useSelector} from "react-redux";
import {Form, notification} from "antd";
import {useMemo, useRef, useState} from "react";
import {setOpenModalCreateTask} from "../../../../../Redux/Slices/tasksSlice.js";
import {useCreateTaskMutation, useUploadFileMutation} from "../../../../../Api/tasksApi.js";
const useCreateTaskModalForm = ({courseId}) => {
const dispatch = useDispatch();
const {
openModalCreateTask
} = useSelector((state) => state.tasks);
const [form] = Form.useForm();
const [createTask, {isLoading}] = useCreateTaskMutation();
const [draftFiles, setDraftFiles] = useState([]);
const [uploadFile] = useUploadFileMutation();
const isModalOpen = openModalCreateTask;
const editorRef = useRef(null);
const handleCancel = () => {
form.resetFields();
if (editorRef.current) {
editorRef.current.value = "";
}
dispatch(setOpenModalCreateTask(false));
};
const handleAddFile = (file) => {
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
if (file.size > maxSize) {
notification.error({
message: "Ошибка вставки",
description: "Файл слишком большой.",
placement: "topRight",
});
return false;
}
setDraftFiles((prev) => [...prev, file]);
return false;
};
const handleRemoveFile = (file) => {
setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid));
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const content = editorRef.current?.value || "";
const taskData = {
title: values.title,
description: values.description || null,
text: content,
number: values.number || 1,
};
const response = await createTask({
courseId,
taskData,
}).unwrap();
for (const file of draftFiles) {
try {
await uploadFile({
task_id: response.id,
fileData: file,
}).unwrap();
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
}
notification.success({
title: "Успех",
description: "Задание успешно создано!",
placement: "topRight",
});
handleCancel();
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось создать задание",
placement: "topRight",
});
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
height: 150,
toolbarAdaptive: false,
buttons: [
"bold", "italic", "underline", "strikethrough", "|",
"superscript", "subscript", "|",
"ul", "ol", "outdent", "indent", "|",
"font", "fontsize", "brush", "paragraph", "|",
"align", "hr", "|",
"table", "link", "image", "video", "symbols", "|",
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
"find", "source", "fullsize", "print", "preview",
],
autofocus: false,
preserveSelection: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
spellcheck: true,
placeholder: "Заполните содержимое задания",
showCharsCounter: true,
showWordsCounter: true,
showXPathInStatusbar: false,
toolbarSticky: true,
toolbarButtonSize: "middle",
cleanHTML: {
removeEmptyElements: true,
replaceNBSP: false,
},
hotkeys: {
"ctrl + shift + f": "find",
"ctrl + b": "bold",
"ctrl + i": "italic",
"ctrl + u": "underline",
},
image: {
editSrc: true,
editTitle: true,
editAlt: true,
openOnDblClick: false,
},
video: {
allowedSources: ["youtube", "vimeo"],
},
uploader: {
insertImageAsBase64URI: true,
},
paste: {
insertAsBase64: true,
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 5 * 1024 * 1024,
error: () => {
notification.error({
title: "Ошибка вставки",
description: "Файл слишком большой или неподдерживаемый формат.",
placement: "topRight",
});
},
},
}),
[]
);
return {
isModalOpen,
handleCancel,
form,
joditConfig,
editorRef,
handleOk,
handleAddFile,
handleRemoveFile,
draftFiles,
}
};
export default useCreateTaskModalForm;

View File

@ -0,0 +1,139 @@
import useUpdateLessonModalForm from "./useUpdateLessonModalForm.js";
import {Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Row, Spin, Upload} from "antd";
import JoditEditor from "jodit-react";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import {UploadOutlined} from "@ant-design/icons";
const {TextArea} = Input;
const UpdateLessonModalForm = ({courseId}) => {
const {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading,
initialContent,
currentLesson,
isFilesLoading,
downloadFile,
files,
downloadingFiles,
deletingFiles,
deleteFile,
draftFiles,
handleAddFile,
handleRemoveFile,
} = useUpdateLessonModalForm({courseId});
if (isLoading) {
return <Modal>
<LoadingIndicator/>
</Modal>
}
return (
<Modal
title="Редактирование лекции"
open={isModalOpen}
onCancel={handleCancel}
width={1000}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoading}
onClick={handleOk}
>
Сохранить изменения
</Button>,
]}
maskClosable={false}
keyboard={false}
>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Название лекции"
rules={[{ required: true, message: "Введите название лекции" }]}
>
<Input size="large" />
</Form.Item>
<Form.Item name="description" label="Краткое описание">
<TextArea rows={2} placeholder="О чём эта лекция..." />
</Form.Item>
<Form.Item name="number" label="Порядковый номер">
<InputNumber min={1} style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Содержание лекции">
<div style={{ border: "1px solid #d9d9d9", borderRadius: 8, overflow: "hidden" }}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
</Form.Item>
{isFilesLoading ? (
<Spin/>
) : files.length > 0 ? (
files.map((file) => (
<Row key={file.id} align="middle" justify="space-between">
<span>{file.filename || "Не указан"}</span>
<div>
<Button
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id] || false}
disabled={downloadingFiles[file.id] || deletingFiles[file.id] || false}
type={"dashed"}
style={{marginRight: 8}}
>
{downloadingFiles[file.id] ? "Загрузка..." : "Скачать"}
</Button>
<Popconfirm
title={"Вы уверены, что хотите удалить файл?"}
onConfirm={() => deleteFile(file.id, file.filename)}
>
<Button
loading={deletingFiles[file.id] || false}
disabled={deletingFiles[file.id] || downloadingFiles[file.id] || false}
type={"dashed"}
danger
>
{deletingFiles[file.id] ? "Удаление..." : "Удалить"}
</Button>
</Popconfirm>
</div>
<Divider/>
</Row>
))
) : (
<p>Файлы отсутствуют</p>
)}
<Form.Item name="files" label="Прикрепить файлы">
<Upload
fileList={draftFiles}
beforeUpload={(file) => {
handleAddFile(file);
return false;
}}
onRemove={(file) => handleRemoveFile(file)}
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
</Form.Item>
</Form>
</Modal>
);
};
export default UpdateLessonModalForm;

View File

@ -0,0 +1,318 @@
import {useDispatch, useSelector} from "react-redux";
import {Form, notification} from "antd";
import {useEffect, useMemo, useRef, useState} from "react";
import {
useDeleteFileMutation,
useGetLessonFilesListQuery,
useUpdateLessonMutation,
useUploadFileMutation
} from "../../../../../Api/lessonsApi.js";
import {setSelectedLessonToUpdate} from "../../../../../Redux/Slices/lessonsSlice.js";
import CONFIG from "../../../../../Core/сonfig.js";
const useUpdateLessonModalForm = () => {
const dispatch = useDispatch();
const [form] = Form.useForm();
const editorRef = useRef(null);
const {selectedLessonToUpdate} = useSelector((state) => state.lessons);
const [updateLesson, {isLoading: isLoadingUpdateLesson}] = useUpdateLessonMutation();
const isModalOpen = selectedLessonToUpdate !== null;
const [draftFiles, setDraftFiles] = useState([]);
const [uploadFile] = useUploadFileMutation();
const [deleteFileMut] = useDeleteFileMutation();
const [downloadingFiles, setDownloadingFiles] = useState({});
const [deletingFiles, setDeletingFiles] = useState({});
const {
data: currentLessonFiles = [],
isLoading: isCurrentLessonFilesLoading,
isError: isCurrentLessonFilesError
} = useGetLessonFilesListQuery(selectedLessonToUpdate?.id, {
skip: !selectedLessonToUpdate?.id
});
const downloadFile = async (fileId, fileName) => {
try {
setDownloadingFiles((prev) => ({ ...prev, [fileId]: true }));
const token = localStorage.getItem('access_token');
if (!token) {
notification.error({
title: "Ошибка",
description: "Токен не найден",
placement: "topRight",
});
return;
}
const response = await fetch(`${CONFIG.BASE_URL}/lessons/file/${fileId}/`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || contentType.includes('text/html')) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
let safeFileName = fileName || "file";
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
if (contentType.includes('application/pdf')) {
safeFileName += '.pdf';
} else if (contentType.includes('image/jpeg')) {
safeFileName += '.jpg';
} else if (contentType.includes('image/png')) {
safeFileName += '.png';
} else {
safeFileName += '.bin';
}
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", safeFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
notification.error({
title: "Ошибка",
description: error.message || "Не удалось скачать файл",
placement: "topRight",
});
} finally {
setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
}
};
const deleteFile = async (fileId, fileName) => {
try {
setDeletingFiles((prev) => ({...prev, [fileId]: true}));
await deleteFileMut(fileId).unwrap();
notification.success({
title: "Файл удален",
description: `Файл ${fileName || "неизвестный"} успешно удален.`,
placement: "topRight",
});
} catch (error) {
console.error("Error deleting file:", error);
notification.error({
title: "Ошибка при удалении файла",
description: `Не удалось удалить файл ${fileName || "неизвестный"}: ${error.data?.detail || error.message}`,
placement: "topRight",
});
} finally {
setDeletingFiles((prev) => ({...prev, [fileId]: false}));
}
};
useEffect(() => {
if (selectedLessonToUpdate && isModalOpen) {
form.setFieldsValue({
title: selectedLessonToUpdate.title || "",
description: selectedLessonToUpdate.description || "",
number: selectedLessonToUpdate.number || 1,
});
setTimeout(() => {
if (editorRef.current) {
editorRef.current.value = selectedLessonToUpdate.text || "";
}
}, 0);
}
}, [selectedLessonToUpdate, isModalOpen, form]);
const handleCancel = () => {
form.resetFields();
if (editorRef.current) {
editorRef.current.value = "";
}
dispatch(setSelectedLessonToUpdate(null));
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const content = editorRef.current?.value || "";
const lessonData = {
title: values.title,
description: values.description || null,
text: content,
number: values.number,
};
await updateLesson({
lessonId: selectedLessonToUpdate.id,
lessonData,
}).unwrap();
for (const file of draftFiles) {
try {
await uploadFile({
lesson_id: selectedLessonToUpdate.id,
fileData: file,
}).unwrap();
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
}
setDraftFiles([]);
notification.success({
title: "Успех",
description: "Лекция успешно обновлена!",
});
handleCancel();
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось обновить лекцию",
});
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
height: 150,
toolbarAdaptive: false,
buttons: [
"bold", "italic", "underline", "strikethrough", "|",
"superscript", "subscript", "|",
"ul", "ol", "outdent", "indent", "|",
"font", "fontsize", "brush", "paragraph", "|",
"align", "hr", "|",
"table", "link", "image", "video", "symbols", "|",
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
"find", "source", "fullsize", "print", "preview",
],
autofocus: false,
preserveSelection: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
spellcheck: true,
placeholder: "Заполните содержимое лекционного материала",
showCharsCounter: true,
showWordsCounter: true,
showXPathInStatusbar: false,
toolbarSticky: true,
toolbarButtonSize: "middle",
cleanHTML: {
removeEmptyElements: true,
replaceNBSP: false,
},
hotkeys: {
"ctrl + shift + f": "find",
"ctrl + b": "bold",
"ctrl + i": "italic",
"ctrl + u": "underline",
},
image: {
editSrc: true,
editTitle: true,
editAlt: true,
openOnDblClick: false,
},
video: {
allowedSources: ["youtube", "vimeo"],
},
uploader: {
insertImageAsBase64URI: true,
},
paste: {
insertAsBase64: true,
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 5 * 1024 * 1024,
error: () => {
notification.error({
title: "Ошибка вставки",
description: "Файл слишком большой или неподдерживаемый формат.",
placement: "topRight",
});
},
},
}),
[]
);
const handleAddFile = (file) => {
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
if (file.size > maxSize) {
notification.error({
message: "Ошибка вставки",
description: "Файл слишком большой.",
placement: "topRight",
});
return false;
}
setDraftFiles((prev) => [...prev, file]);
return false;
};
const handleRemoveFile = (file) => {
setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid));
};
const initialContent = selectedLessonToUpdate?.text || "";
return {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading: isLoadingUpdateLesson,
initialContent,
currentLesson: selectedLessonToUpdate,
currentLessonFiles,
isFilesLoading: isCurrentLessonFilesLoading,
downloadFile,
files: currentLessonFiles,
downloadingFiles,
deletingFiles,
deleteFile,
draftFiles,
handleAddFile,
handleRemoveFile,
};
};
export default useUpdateLessonModalForm;

View File

@ -0,0 +1,139 @@
import useUpdateTaskModalForm from "./useUpdateTaskModalForm.js";
import {Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Row, Spin, Upload} from "antd";
import JoditEditor from "jodit-react";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import {UploadOutlined} from "@ant-design/icons";
const {TextArea} = Input;
const UpdateTaskModalForm = ({courseId}) => {
const {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading,
initialContent,
currentLesson,
isFilesLoading,
downloadFile,
files,
downloadingFiles,
deletingFiles,
deleteFile,
draftFiles,
handleAddFile,
handleRemoveFile,
} = useUpdateTaskModalForm({courseId});
if (isLoading) {
return <Modal>
<LoadingIndicator/>
</Modal>
}
return (
<Modal
title="Редактирование задания"
open={isModalOpen}
onCancel={handleCancel}
width={1000}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoading}
onClick={handleOk}
>
Сохранить изменения
</Button>,
]}
maskClosable={false}
keyboard={false}
>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Название задания"
rules={[{required: true, message: "Введите название задания"}]}
>
<Input size="large"/>
</Form.Item>
<Form.Item name="description" label="Краткое описание заачи">
<TextArea rows={2} placeholder="Что нужно будет сделать..."/>
</Form.Item>
<Form.Item name="number" label="Порядковый номер" initialValue={1}>
<InputNumber min={1} style={{width: "100%"}}/>
</Form.Item>
<Form.Item label="Содержание задания">
<div style={{border: "1px solid #d9d9d9", borderRadius: 6}}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
</Form.Item>
{isFilesLoading ? (
<Spin/>
) : files.length > 0 ? (
files.map((file) => (
<Row key={file.id} align="middle" justify="space-between">
<span>{file.filename || "Не указан"}</span>
<div>
<Button
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id] || false}
disabled={downloadingFiles[file.id] || deletingFiles[file.id] || false}
type={"dashed"}
style={{marginRight: 8}}
>
{downloadingFiles[file.id] ? "Загрузка..." : "Скачать"}
</Button>
<Popconfirm
title={"Вы уверены, что хотите удалить файл?"}
onConfirm={() => deleteFile(file.id, file.filename)}
>
<Button
loading={deletingFiles[file.id] || false}
disabled={deletingFiles[file.id] || downloadingFiles[file.id] || false}
type={"dashed"}
danger
>
{deletingFiles[file.id] ? "Удаление..." : "Удалить"}
</Button>
</Popconfirm>
</div>
<Divider/>
</Row>
))
) : (
<p>Файлы отсутствуют</p>
)}
<Form.Item name="files" label="Прикрепить файлы">
<Upload
fileList={draftFiles}
beforeUpload={(file) => {
handleAddFile(file);
return false;
}}
onRemove={(file) => handleRemoveFile(file)}
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
</Form.Item>
</Form>
</Modal>
);
};
export default UpdateTaskModalForm;

View File

@ -0,0 +1,318 @@
import {useDispatch, useSelector} from "react-redux";
import {Form, notification} from "antd";
import {useEffect, useMemo, useRef, useState} from "react";
import CONFIG from "../../../../../Core/сonfig.js";
import {
useDeleteFileMutation,
useGetTaskFilesListQuery,
useUpdateTaskMutation,
useUploadFileMutation
} from "../../../../../Api/tasksApi.js";
import {setSelectedTaskToUpdate} from "../../../../../Redux/Slices/tasksSlice.js";
const useUpdateTaskModalForm = () => {
const dispatch = useDispatch();
const [form] = Form.useForm();
const editorRef = useRef(null);
const {selectedTaskToUpdate} = useSelector((state) => state.tasks);
const [updateTask, {isLoading: isLoadingUpdateTask}] = useUpdateTaskMutation();
const isModalOpen = selectedTaskToUpdate !== null;
const [draftFiles, setDraftFiles] = useState([]);
const [uploadFile] = useUploadFileMutation();
const [deleteFileMut] = useDeleteFileMutation();
const [downloadingFiles, setDownloadingFiles] = useState({});
const [deletingFiles, setDeletingFiles] = useState({});
const {
data: currentTaskFiles = [],
isLoading: isCurrentTaskFilesLoading,
isError: isCurrentTaskFilesError
} = useGetTaskFilesListQuery(selectedTaskToUpdate?.id, {
skip: !selectedTaskToUpdate?.id
});
const downloadFile = async (fileId, fileName) => {
try {
setDownloadingFiles((prev) => ({ ...prev, [fileId]: true }));
const token = localStorage.getItem('access_token');
if (!token) {
notification.error({
title: "Ошибка",
description: "Токен не найден",
placement: "topRight",
});
return;
}
const response = await fetch(`${CONFIG.BASE_URL}/tasks/file/${fileId}/`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || contentType.includes('text/html')) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
let safeFileName = fileName || "file";
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
if (contentType.includes('application/pdf')) {
safeFileName += '.pdf';
} else if (contentType.includes('image/jpeg')) {
safeFileName += '.jpg';
} else if (contentType.includes('image/png')) {
safeFileName += '.png';
} else {
safeFileName += '.bin';
}
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", safeFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
notification.error({
title: "Ошибка",
description: error.message || "Не удалось скачать файл",
placement: "topRight",
});
} finally {
setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
}
};
const deleteFile = async (fileId, fileName) => {
try {
setDeletingFiles((prev) => ({...prev, [fileId]: true}));
await deleteFileMut(fileId).unwrap();
notification.success({
title: "Файл удален",
description: `Файл ${fileName || "неизвестный"} успешно удален.`,
placement: "topRight",
});
} catch (error) {
console.error("Error deleting file:", error);
notification.error({
title: "Ошибка при удалении файла",
description: `Не удалось удалить файл ${fileName || "неизвестный"}: ${error.data?.detail || error.message}`,
placement: "topRight",
});
} finally {
setDeletingFiles((prev) => ({...prev, [fileId]: false}));
}
};
useEffect(() => {
if (selectedTaskToUpdate && isModalOpen) {
form.setFieldsValue({
title: selectedTaskToUpdate.title || "",
description: selectedTaskToUpdate.description || "",
number: selectedTaskToUpdate.number || 1,
});
setTimeout(() => {
if (editorRef.current) {
editorRef.current.value = selectedTaskToUpdate.text || "";
}
}, 0);
}
}, [selectedTaskToUpdate, isModalOpen, form]);
const handleCancel = () => {
form.resetFields();
if (editorRef.current) {
editorRef.current.value = "";
}
dispatch(setSelectedTaskToUpdate(null));
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const content = editorRef.current?.value || "";
const taskData = {
title: values.title,
description: values.description || null,
text: content,
number: values.number,
};
await updateTask({
taskId: selectedTaskToUpdate.id,
taskData,
}).unwrap();
for (const file of draftFiles) {
try {
await uploadFile({
task_id: selectedTaskToUpdate.id,
fileData: file,
}).unwrap();
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
}
setDraftFiles([]);
notification.success({
title: "Успех",
description: "Лекция успешно обновлена!",
});
handleCancel();
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось обновить лекцию",
});
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
height: 150,
toolbarAdaptive: false,
buttons: [
"bold", "italic", "underline", "strikethrough", "|",
"superscript", "subscript", "|",
"ul", "ol", "outdent", "indent", "|",
"font", "fontsize", "brush", "paragraph", "|",
"align", "hr", "|",
"table", "link", "image", "video", "symbols", "|",
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
"find", "source", "fullsize", "print", "preview",
],
autofocus: false,
preserveSelection: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
spellcheck: true,
placeholder: "Заполните содержимое лекционного материала",
showCharsCounter: true,
showWordsCounter: true,
showXPathInStatusbar: false,
toolbarSticky: true,
toolbarButtonSize: "middle",
cleanHTML: {
removeEmptyElements: true,
replaceNBSP: false,
},
hotkeys: {
"ctrl + shift + f": "find",
"ctrl + b": "bold",
"ctrl + i": "italic",
"ctrl + u": "underline",
},
image: {
editSrc: true,
editTitle: true,
editAlt: true,
openOnDblClick: false,
},
video: {
allowedSources: ["youtube", "vimeo"],
},
uploader: {
insertImageAsBase64URI: true,
},
paste: {
insertAsBase64: true,
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 5 * 1024 * 1024,
error: () => {
notification.error({
title: "Ошибка вставки",
description: "Файл слишком большой или неподдерживаемый формат.",
placement: "topRight",
});
},
},
}),
[]
);
const handleAddFile = (file) => {
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
if (file.size > maxSize) {
notification.error({
message: "Ошибка вставки",
description: "Файл слишком большой.",
placement: "topRight",
});
return false;
}
setDraftFiles((prev) => [...prev, file]);
return false;
};
const handleRemoveFile = (file) => {
setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid));
};
const initialContent = selectedTaskToUpdate?.text || "";
return {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading: isLoadingUpdateTask,
initialContent,
currenttask: selectedTaskToUpdate,
currentTaskFiles,
isFilesLoading: isCurrentTaskFilesLoading,
downloadFile,
files: currentTaskFiles,
downloadingFiles,
deletingFiles,
deleteFile,
draftFiles,
handleAddFile,
handleRemoveFile,
};
};
export default useUpdateTaskModalForm;

View File

@ -0,0 +1,114 @@
import useViewLessonModal from "./useViewLessonModal.js";
import {Avatar, Button, Col, Divider, Modal, Popconfirm, Row, Space, Spin, Typography} from "antd";
import {CloseOutlined, UserOutlined} from "@ant-design/icons";
const {Title, Text, Paragraph} = Typography;
const ViewLessonModal = () => {
const {
selectedLessonToView,
modalIsOpen,
handleClose,
currentLessonFiles,
isCurrentLessonFilesLoading,
isCurrentLessonFilesError,
downloadFile,
downloadingFiles
} = useViewLessonModal();
return (
<Modal
open={modalIsOpen}
onCancel={handleClose}
footer={null}
width={1000}
closeIcon={<CloseOutlined style={{fontSize: 20}}/>}
title={null}
centered
destroyOnHidden
>
<Space align="start" style={{marginBottom: 16}}>
<Col>
<Title level={2} style={{margin: 0, flex: 1}}>
{selectedLessonToView?.title}
</Title>
<Avatar
size="small"
icon={<UserOutlined/>}
style={{backgroundColor: "#1890ff"}}
>
{selectedLessonToView?.creator?.first_name?.[0] || "У"}
</Avatar>
<Text type="secondary">
Создал: <strong>{selectedLessonToView?.creator?.first_name} {selectedLessonToView?.creator?.last_name}</strong>
{selectedLessonToView?.creator?.patronymic && ` ${selectedLessonToView?.creator.patronymic}`}
</Text>
</Col>
</Space>
{selectedLessonToView?.description && (
<>
<Title level={4} style={{margin: "16px 0 8px"}}>
Описание
</Title>
<Paragraph type="secondary" style={{fontSize: 16, marginBottom: 24}}>
{selectedLessonToView?.description}
</Paragraph>
<Divider style={{margin: "24px 0"}}/>
</>
)}
{selectedLessonToView?.text ? (
<div
className="lesson-content"
dangerouslySetInnerHTML={{__html: selectedLessonToView?.text}}
style={{
fontSize: "16px",
lineHeight: "1.7",
color: "#333",
}}
/>
) : (
<Paragraph italic type="secondary">
Текстовый материал отсутствует
</Paragraph>
)}
<Divider/>
<Title level={3}>Прикрепленные файлы</Title>
{isCurrentLessonFilesLoading ? (
<Spin/>
) : currentLessonFiles.length > 0 ? (
currentLessonFiles.map((file) => (
<Row key={file.id} align="middle" justify="space-between">
<span>{file.filename || "Не указан"}</span>
<div>
<Button
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id] || false}
disabled={downloadingFiles[file.id] || false}
type={"dashed"}
style={{marginRight: 8}}
>
{downloadingFiles[file.id] ? "Загрузка..." : "Скачать"}
</Button>
</div>
<Divider/>
</Row>
))
) : (
<p>Файлы отсутствуют</p>
)}
<div style={{textAlign: "right"}}>
<Button onClick={handleClose}>
Закрыть
</Button>
</div>
</Modal>
);
};
export default ViewLessonModal;

View File

@ -0,0 +1,121 @@
import {useDispatch, useSelector} from "react-redux";
import {setSelectedLessonToView} from "../../../../../Redux/Slices/lessonsSlice.js";
import {useGetLessonFilesListQuery} from "../../../../../Api/lessonsApi.js";
import {notification} from "antd";
import CONFIG from "../../../../../Core/сonfig.js";
import {useState} from "react";
const useViewLessonModal = () => {
const dispatch = useDispatch();
const {
selectedLessonToView
} = useSelector((state) => state.lessons);
const modalIsOpen = selectedLessonToView !== null;
const handleClose = () => {
dispatch(setSelectedLessonToView(null));
};
const {
data: currentLessonFiles = [],
isLoading: isCurrentLessonFilesLoading,
isError: isCurrentLessonFilesError
} = useGetLessonFilesListQuery(selectedLessonToView?.id, {
skip: !selectedLessonToView?.id
});
const [downloadingFiles, setDownloadingFiles] = useState({});
const downloadFile = async (fileId, fileName) => {
try {
setDownloadingFiles((prev) => ({ ...prev, [fileId]: true }));
const token = localStorage.getItem('access_token');
if (!token) {
notification.error({
title: "Ошибка",
description: "Токен не найден",
placement: "topRight",
});
return;
}
const response = await fetch(`${CONFIG.BASE_URL}/lessons/file/${fileId}/`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || contentType.includes('text/html')) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
let safeFileName = fileName || "file";
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
if (contentType.includes('application/pdf')) {
safeFileName += '.pdf';
} else if (contentType.includes('image/jpeg')) {
safeFileName += '.jpg';
} else if (contentType.includes('image/png')) {
safeFileName += '.png';
} else {
safeFileName += '.bin';
}
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", safeFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
notification.error({
title: "Ошибка",
description: error.message || "Не удалось скачать файл",
placement: "topRight",
});
} finally {
setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
}
};
return {
selectedLessonToView,
modalIsOpen,
handleClose,
currentLessonFiles,
isCurrentLessonFilesLoading,
isCurrentLessonFilesError,
downloadFile,
downloadingFiles
}
};
export default useViewLessonModal;

View File

@ -0,0 +1,556 @@
import {
Avatar,
Button,
Col, Collapse,
Divider,
Empty, Flex,
Form, Input, InputNumber, List,
Modal,
Popconfirm,
Row,
Space,
Spin,
Tag,
Typography,
Upload
} from "antd";
import {
CloseOutlined, DeleteOutlined,
DownloadOutlined,
FileOutlined,
PlusOutlined,
UploadOutlined,
UserOutlined
} from "@ant-design/icons";
import useViewTaskModal from "./useTaskLessonModal.js";
import {ROLES} from "../../../../../Core/constants.js";
import JoditEditor from "jodit-react";
const {Panel} = Collapse;
const {Title, Text, Paragraph} = Typography;
const ViewTaskModal = () => {
const {
selectedTaskToView,
modalIsOpen,
handleClose,
currentTaskFiles,
isCurrentTaskFilesLoading,
isCurrentTaskFilesError,
downloadFile,
downloadingFiles,
currentUser,
mySolutions,
editorRef,
joditConfig,
handleAddFile,
handleRemoveFile,
handleOk,
draftFiles,
handleDeleSolution,
allSolutions,
onAssessmentFinish,
assessmentForm,
onCommentSubmit,
commentForm
} = useViewTaskModal();
return (
<Modal
open={modalIsOpen}
onCancel={handleClose}
footer={null}
width={1000}
closeIcon={<CloseOutlined style={{fontSize: 20}}/>}
title={null}
centered
destroyOnHidden
>
<Space align="start" style={{marginBottom: 16}}>
<Col>
<Title level={2} style={{margin: 0, flex: 1}}>
{selectedTaskToView?.title}
</Title>
<Avatar
size="small"
icon={<UserOutlined/>}
style={{backgroundColor: "#1890ff"}}
>
{selectedTaskToView?.creator?.first_name?.[0] || "У"}
</Avatar>
<Text type="secondary">
Создал: <strong>{selectedTaskToView?.creator?.first_name} {selectedTaskToView?.creator?.last_name}</strong>
{selectedTaskToView?.creator?.patronymic && ` ${selectedTaskToView?.creator.patronymic}`}
</Text>
</Col>
</Space>
{selectedTaskToView?.description && (
<>
<Title level={4} style={{margin: "16px 0 8px"}}>
Описание
</Title>
<Paragraph type="secondary" style={{fontSize: 16, marginBottom: 24}}>
{selectedTaskToView?.description}
</Paragraph>
<Divider style={{margin: "24px 0"}}/>
</>
)}
{selectedTaskToView?.text ? (
<div
className="Task-content"
dangerouslySetInnerHTML={{__html: selectedTaskToView?.text}}
style={{
fontSize: "16px",
lineHeight: "1.7",
color: "#333",
}}
/>
) : (
<Paragraph italic type="secondary">
Текстовый материал отсутствует
</Paragraph>
)}
<Divider/>
<Title level={3}>Прикрепленные файлы</Title>
{isCurrentTaskFilesLoading ? (
<Spin/>
) : currentTaskFiles.length > 0 ? (
currentTaskFiles.map((file) => (
<Row key={file.id} align="middle" justify="space-between">
<span>{file.filename || "Не указан"}</span>
<div>
<Button
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id] || false}
disabled={downloadingFiles[file.id] || false}
type={"dashed"}
style={{marginRight: 8}}
>
{downloadingFiles[file.id] ? "Загрузка..." : "Скачать"}
</Button>
</div>
<Divider/>
</Row>
))
) : (
<p>Файлы отсутствуют</p>
)}
{currentUser?.role?.title === ROLES.STUDENT ? (
<Col>
<Title level={3}>Ваши решения</Title>
{mySolutions.length > 0 ? (
<Collapse accordion>
{mySolutions.map((solution) => (
<Panel
key={solution.id}
header={
<Flex justify="space-between" align="center">
<Text strong>Решение
от {new Date(solution.created_at).toLocaleString("ru-RU")}</Text>
{solution.assessment !== null ? (
<Tag
color={solution.assessment >= 80 ? "green" : solution.assessment >= 60 ? "orange" : "red"}>
Оценка: {solution.assessment} / 100
</Tag>
) : (
<Tag color="blue">На проверке</Tag>
)}
<Popconfirm
title={`Удалить ответ на задание?`}
description="Это действие нельзя отменить"
onConfirm={(e) => {
handleDeleSolution(solution.id)
}}
okText="Удалить"
cancelText="Отмена"
>
<Button
type="text"
danger
icon={<DeleteOutlined/>}
/>
</Popconfirm>
</Flex>
}
extra={
solution.assessment !== null && (
<Tag color="purple">
Проверено: {solution.assessment_autor?.first_name} {solution.assessment_autor?.last_name}
</Tag>
)
}
>
<div style={{marginBottom: 16}}>
<Text strong>Ответ:</Text>
<div
style={{
background: "#f9f9f9",
padding: 16,
borderRadius: 8,
margin: "12px 0",
border: "1px solid #f0f0f0",
minHeight: 60,
}}
dangerouslySetInnerHTML={{__html: solution.answer_text || "<em>Текст ответа отсутствует</em>"}}
/>
</div>
{solution.files && solution.files.length > 0 ? (
<div>
<Text strong>Прикреплённые файлы:</Text>
<div style={{
marginTop: 8,
display: "flex",
flexDirection: "column",
gap: 8
}}>
{solution.files.map((file) => (
<Button
key={file.id}
type="dashed"
icon={<FileOutlined/>}
style={{textAlign: "left"}}
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id]}
>
<span style={{marginLeft: 8}}>
{file.filename}
</span>
<DownloadOutlined style={{marginLeft: 8, color: "#1890ff"}}/>
</Button>
))}
</div>
</div>
) : (
<Text type="secondary">Файлы не прикреплены</Text>
)}
<div style={{ marginTop: 32 }}>
<Title level={4}>Комментарии к решению</Title>
<div
style={{
maxHeight: 400,
overflowY: "auto",
padding: "8px 0",
border: "1px solid #f0f0f0",
borderRadius: 8,
background: "#fafafa",
}}
>
{solution.solution_comments && solution.solution_comments.length > 0 ? (
<List
dataSource={solution.solution_comments}
renderItem={(comment) => (
<List.Item style={{ padding: "12px 16px", borderBottom: "1px solid #f0f0f0" }}>
<List.Item.Meta
avatar={
<Avatar style={{ backgroundColor: "#1890ff" }}>
{comment.comment_autor.first_name[0]}
{comment.comment_autor.last_name[0]}
</Avatar>
}
title={
<Space>
<Text strong>
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
</Text>
{comment.comment_autor.role?.title === "teacher" && (
<Tag color="gold" size="small">Преподаватель</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
</Text>
}
/>
<div
style={{
marginTop: 8,
paddingLeft: 56,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Пока нет комментариев"
style={{ margin: "20px 0" }}
/>
)}
</div>
<Form
onFinish={(values) => onCommentSubmit(solution.id, values.comment)}
style={{ marginTop: 16 }}
form={commentForm}
>
<Form.Item
name="comment"
rules={[{ required: true, message: "Напишите комментарий" }]}
>
<Input.TextArea
rows={3}
placeholder="Напишите комментарий к решению..."
allowClear
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit">
Отправить комментарий
</Button>
</Form.Item>
</Form>
</div>
</Panel>
))}
</Collapse>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Решений пока нет"
/>
)}
<Title level={3}>Добавить решение</Title>
<div style={{border: "1px solid #d9d9d9", borderRadius: 8, overflow: "hidden"}}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
<Divider/>
<Upload
fileList={draftFiles}
beforeUpload={(file) => {
handleAddFile(file);
return false;
}}
onRemove={(file) => handleRemoveFile(file)}
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
<Divider/>
<Button type="primary" onClick={handleOk}>
Сохранить решение
</Button>
</Col>
) : [ROLES.ADMIN, ROLES.TEACHER].includes(currentUser?.role?.title) && (
<Col>
<Title level={3}>Присланные решения</Title>
{allSolutions.length > 0 ? (
<Collapse accordion>
{allSolutions.map((solution) => (
<Panel
key={solution.id}
header={
<Flex justify="space-between" align="center">
<Text strong>Решение
от {new Date(solution.created_at).toLocaleString("ru-RU")}</Text>
{solution.assessment !== null ? (
<Tag
color={solution.assessment >= 80 ? "green" : solution.assessment >= 60 ? "orange" : "red"}>
Оценка: {solution.assessment} / 100
</Tag>
) : (
<Tag color="red">Ждет проверки</Tag>
)}
</Flex>
}
extra={
solution.assessment !== null && (
<Tag color="purple">
Проверено: {solution.assessment_autor?.first_name} {solution.assessment_autor?.last_name}
</Tag>
)
}
>
<div style={{marginBottom: 16}}>
<Text strong>Ответ:</Text>
<div
style={{
background: "#f9f9f9",
padding: 16,
borderRadius: 8,
margin: "12px 0",
border: "1px solid #f0f0f0",
minHeight: 60,
}}
dangerouslySetInnerHTML={{__html: solution.answer_text || "<em>Текст ответа отсутствует</em>"}}
/>
</div>
{solution.files && solution.files.length > 0 ? (
<div>
<Text strong>Прикреплённые файлы:</Text>
<div style={{
marginTop: 8,
display: "flex",
flexDirection: "column",
gap: 8
}}>
{solution.files.map((file) => (
<Button
key={file.id}
type="dashed"
icon={<FileOutlined/>}
style={{textAlign: "left"}}
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id]}
>
<span style={{marginLeft: 8}}>
{file.filename}
</span>
<DownloadOutlined style={{marginLeft: 8, color: "#1890ff"}}/>
</Button>
))}
</div>
</div>
) : (
<Text type="secondary">Файлы не прикреплены</Text>
)}
<Title level={3}>Оценка</Title>
<Form form={assessmentForm} onFinish={() => {
onAssessmentFinish(solution.id)
}}>
<Form.Item
name={"assessment"}
rules={[{required: true, message: "Укажите оценку"}]}
>
<InputNumber
min={1}
max={100}
placeholder={"Выставите балл от 1 до 100"}
style={{
minWidth: "230px"
}}
defaultValue={solution.assessment || null}
required
/>
</Form.Item>
<Form.Item>
<Button type={"primary"} htmlType={"submit"}>
Выставить оценку
</Button>
</Form.Item>
</Form>
<div style={{ marginTop: 32 }}>
<Title level={4}>Комментарии к решению</Title>
<div
style={{
maxHeight: 400,
overflowY: "auto",
padding: "8px 0",
border: "1px solid #f0f0f0",
borderRadius: 8,
background: "#fafafa",
}}
>
{solution.solution_comments && solution.solution_comments.length > 0 ? (
<List
dataSource={solution.solution_comments}
renderItem={(comment) => (
<List.Item style={{ padding: "12px 16px", borderBottom: "1px solid #f0f0f0" }}>
<List.Item.Meta
avatar={
<Avatar style={{ backgroundColor: "#1890ff" }}>
{comment.comment_autor.first_name[0]}
{comment.comment_autor.last_name[0]}
</Avatar>
}
title={
<Space>
<Text strong>
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
</Text>
{comment.comment_autor.role?.title === "teacher" && (
<Tag color="gold" size="small">Преподаватель</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
</Text>
}
/>
<div
style={{
marginTop: 8,
paddingLeft: 56,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Пока нет комментариев"
style={{ margin: "20px 0" }}
/>
)}
</div>
<Form
onFinish={(values) => onCommentSubmit(solution.id, values.comment)}
style={{ marginTop: 16 }}
form={commentForm}
>
<Form.Item
name="comment"
rules={[{ required: true, message: "Напишите комментарий" }]}
>
<Input.TextArea
rows={3}
placeholder="Напишите комментарий к решению..."
allowClear
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit">
Отправить комментарий
</Button>
</Form.Item>
</Form>
</div>
</Panel>
))}
</Collapse>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Решений пока нет"
/>
)}
</Col>
)}
<Divider/>
<div style={{textAlign: "right"}}>
<Button onClick={handleClose}>
Закрыть
</Button>
</div>
</Modal>
);
};
export default ViewTaskModal;

View File

@ -0,0 +1,393 @@
import {useDispatch, useSelector} from "react-redux";
import {setSelectedTaskToView} from "../../../../../Redux/Slices/tasksSlice.js";
import {Form, notification} from "antd";
import CONFIG from "../../../../../Core/сonfig.js";
import {useMemo, useRef, useState} from "react";
import {useGetTaskFilesListQuery} from "../../../../../Api/tasksApi.js";
import {useGetAuthenticatedUserDataQuery} from "../../../../../Api/usersApi.js";
import {
useCreateAssessmentMutation,
useCreateSolutionMutation, useDeleteSolutionMutation, useGetTaskSolutionsQuery,
useGetTaskStudentSolutionsQuery,
useUploadFileMutation
} from "../../../../../Api/solutionsApi.js";
import {ROLES} from "../../../../../Core/constants.js";
import {useCreateCommentMutation} from "../../../../../Api/commentsApi.js";
const useViewTaskModal = () => {
const dispatch = useDispatch();
const {
selectedTaskToView
} = useSelector((state) => state.tasks);
const [assessmentForm] = Form.useForm();
const [
createSolution,
{
isLoading: isCreatingSolution,
isError: isErrorCreatingSoltion
}
] = useCreateSolutionMutation();
const modalIsOpen = selectedTaskToView !== null;
const [draftFiles, setDraftFiles] = useState([]);
const [uploadFile] = useUploadFileMutation();
const [deleteSolution] = useDeleteSolutionMutation();
const [createComment, {
isLoading: isCreatingComment,
isError: isErrorCreatingComment
}] = useCreateCommentMutation();
const onCommentSubmit = async (solutionId, commentText) => {
if (!commentText?.trim()) return;
try {
await createComment({
solutionId: solutionId,
comment: {
comment_text: commentText.trim()
}
}).unwrap();
commentForm.resetFields();
notification.success({
message: "Комментарий отправлен",
description: "Ваш комментарий успешно добавлен",
});
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail || "Не удалось отправить комментарий",
});
}
};
const handleAddFile = (file) => {
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
if (file.size > maxSize) {
notification.error({
message: "Ошибка вставки",
description: "Файл слишком большой.",
placement: "topRight",
});
return false;
}
setDraftFiles((prev) => [...prev, file]);
return false;
};
const handleDeleSolution = async (solutionId) => {
try {
await deleteSolution(solutionId);
notification.success({
title: "Успех",
description: "Задание успешно удалено!",
placement: "topRight",
})
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось удалить задание",
placement: "topRight",
})
}
};
const handleRemoveFile = (file) => {
setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid));
};
const editorRef = useRef(null);
const handleOk = async () => {
try {
const content = editorRef.current?.value || "";
const solutionData = {
answer_text: content,
};
const response = await createSolution({
taskId: selectedTaskToView?.id,
solution: solutionData,
}).unwrap();
for (const file of draftFiles) {
try {
await uploadFile({
solutionId: response.id,
fileData: file,
}).unwrap();
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
}
setDraftFiles([]);
notification.success({
title: "Успех",
description: "Задание успешно создано!",
placement: "topRight",
});
if (editorRef.current) {
editorRef.current.value = "";
}
} catch (error) {
console.error(error)
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось создать задание",
placement: "topRight",
});
}
};
const handleClose = () => {
dispatch(setSelectedTaskToView(null));
};
const {
data: currentTaskFiles = [],
isLoading: isCurrentTaskFilesLoading,
isError: isCurrentTaskFilesError
} = useGetTaskFilesListQuery(selectedTaskToView?.id, {
skip: !selectedTaskToView?.id
});
const {
data: currentUser = {},
isLoading: isCurrentUserLoading,
isError: isCurrentUserError,
} = useGetAuthenticatedUserDataQuery();
const {
data: mySolutions = [],
isLoading: isMySolutionsLoading,
isError: isMySolutionsError
} = useGetTaskStudentSolutionsQuery({
taskId: selectedTaskToView?.id,
studentId: currentUser?.id
}, {
skip: !selectedTaskToView?.id || currentUser?.role?.title !== ROLES.STUDENT,
pollingInterval: 5000,
});
const {
data: allSolutions = [],
isLoading: isAllSolutionsLoading,
} = useGetTaskSolutionsQuery(selectedTaskToView?.id, {
skip: !selectedTaskToView?.id || ![ROLES.TEACHER, ROLES.ADMIN].includes(currentUser?.role?.title),
pollingInterval: 5000,
})
const [commentForm] = Form.useForm();
const [downloadingFiles, setDownloadingFiles] = useState({});
const downloadFile = async (fileId, fileName) => {
try {
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
const token = localStorage.getItem('access_token');
if (!token) {
notification.error({
title: "Ошибка",
description: "Токен не найден",
placement: "topRight",
});
return;
}
const response = await fetch(`${CONFIG.BASE_URL}/solutions/file/${fileId}/`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || contentType.includes('text/html')) {
const errorText = await response.text();
notification.error({
title: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
let safeFileName = fileName || "file";
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
if (contentType.includes('application/pdf')) {
safeFileName += '.pdf';
} else if (contentType.includes('image/jpeg')) {
safeFileName += '.jpg';
} else if (contentType.includes('image/png')) {
safeFileName += '.png';
} else {
safeFileName += '.bin';
}
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", safeFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
notification.error({
title: "Ошибка",
description: error.message || "Не удалось скачать файл",
placement: "topRight",
});
} finally {
setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
}
};
const [
createAssessment,
] = useCreateAssessmentMutation();
const onAssessmentFinish = async (solutionId) => {
const values = await assessmentForm.validateFields();
try {
await createAssessment({
solutionId,
assessment: values,
});
notification.success({
title: "Успех",
description: "Оценка выставлена",
placement: "topRight",
});
} catch (e) {
notification.error({
title: "Ошибка",
description: e.message || "Не удалось выставить оценку",
placement: "topRight",
});
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
height: 150,
toolbarAdaptive: false,
buttons: [
"bold", "italic", "underline", "strikethrough", "|",
"superscript", "subscript", "|",
"ul", "ol", "outdent", "indent", "|",
"font", "fontsize", "brush", "paragraph", "|",
"align", "hr", "|",
"table", "link", "image", "video", "symbols", "|",
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
"find", "source", "fullsize", "print", "preview",
],
autofocus: false,
preserveSelection: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
spellcheck: true,
placeholder: "Заполните содержимое задания",
showCharsCounter: true,
showWordsCounter: true,
showXPathInStatusbar: false,
toolbarSticky: true,
toolbarButtonSize: "middle",
cleanHTML: {
removeEmptyElements: true,
replaceNBSP: false,
},
hotkeys: {
"ctrl + shift + f": "find",
"ctrl + b": "bold",
"ctrl + i": "italic",
"ctrl + u": "underline",
},
image: {
editSrc: true,
editTitle: true,
editAlt: true,
openOnDblClick: false,
},
video: {
allowedSources: ["youtube", "vimeo"],
},
uploader: {
insertImageAsBase64URI: true,
},
paste: {
insertAsBase64: true,
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 5 * 1024 * 1024,
error: () => {
notification.error({
title: "Ошибка вставки",
description: "Файл слишком большой или неподдерживаемый формат.",
placement: "topRight",
});
},
},
}),
[]
);
return {
selectedTaskToView,
modalIsOpen,
handleClose,
currentTaskFiles,
isCurrentTaskFilesLoading,
isCurrentTaskFilesError,
downloadFile,
downloadingFiles,
currentUser,
mySolutions,
editorRef,
joditConfig,
handleAddFile,
handleRemoveFile,
handleOk,
draftFiles,
handleDeleSolution,
allSolutions,
onAssessmentFinish,
assessmentForm,
onCommentSubmit,
}
};
export default useViewTaskModal;

View File

@ -0,0 +1,270 @@
import useCourseDetailPage from "./useCourseDetailPage.js";
import {
Avatar,
Button,
Card,
Col,
Empty,
FloatButton,
Popconfirm,
Result,
Row,
Space,
Tag,
Tooltip,
Typography
} from "antd";
import {
ArrowLeftOutlined,
BookOutlined, CheckCircleFilled, ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
FormOutlined,
PlusOutlined
} from "@ant-design/icons";
import {useNavigate, useParams} from "react-router-dom";
import {ROLES} from "../../../Core/constants.js";
import CONFIG from "../../../Core/сonfig.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateLessonModalForm from "./Components/CreateLessonModalForm/CreateLessonModalForm.jsx";
import ViewLessonModal from "./Components/ViewLessonModalForm/ViewLessonModal.jsx";
import UpdateLessonModalForm from "./Components/UpdateLessonModalForm/UpdateLessonModalForm.jsx";
import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx";
import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx";
import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx";
const {Title, Text} = Typography;
const CourseDetailPage = () => {
const navigate = useNavigate();
const {courseId} = useParams();
const {
tasksData,
isTeacherOrAdmin,
lessonsData,
userData,
courseData,
isLoading,
isError,
handleCreateLesson,
handleOpenLesson,
handleEditLesson,
handleDeleteLesson,
handleCreateTask,
handleOpenTask,
handleEditTask,
handleDeleteTask,
listReadLessonsIds,
} = useCourseDetailPage(courseId);
if (isLoading) {
return <LoadingIndicator/>;
}
if (isError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке курса"/>;
}
return (
<div style={{minHeight: "100vh"}}>
<Row justify="space-between" align="middle" style={{marginBottom: 24}}>
<Col>
<Button
icon={<ArrowLeftOutlined/>}
onClick={() => navigate(-1)}
style={{marginBottom: 16}}
>
Назад
</Button>
<Title level={2} style={{margin: 0}}>
<BookOutlined/> Курс - {courseData.title}
</Title>
</Col>
</Row>
{lessonsData.length === 0 ? (
<Empty
description="Пока нет лекций"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{margin: "60px 0"}}
>
{isTeacherOrAdmin && (
<Button type="primary" size="large" onClick={handleCreateLesson}>
Добавить первую лекцию
</Button>
)}
</Empty>
) : (
<Row gutter={[24, 24]}>
{[...lessonsData, ...tasksData]
.sort((a, b) => a.number - b.number)
.map((item) => {
const isLesson = item.__typename === "Lesson";
const isTask = item.__typename === "Task";
return (
<Col xs={24} sm={12} lg={8} xl={6} key={item.id}>
<Card
hoverable
style={{height: "100%", cursor: "pointer"}}
onClick={() =>
isLesson ? handleOpenLesson(item) : handleOpenTask(item)
}
title={
<Space>
<Text strong>
{item.title}
</Text>
</Space>
}
extra={
isTeacherOrAdmin && (
<Space onClick={(e) => e.stopPropagation()}>
<Button
type="text"
icon={<EditOutlined/>}
onClick={(e) => {
e.stopPropagation();
isLesson
? handleEditLesson(item)
: handleEditTask(item);
}}
/>
<Popconfirm
title={`Удалить ${isLesson ? "лекцию" : "задание"}?`}
description="Это действие нельзя отменить"
onConfirm={(e) => {
e?.stopPropagation();
isLesson
? handleDeleteLesson(item.id)
: handleDeleteTask(item.id);
}}
okText="Удалить"
cancelText="Отмена"
>
<Button
type="text"
danger
icon={<DeleteOutlined/>}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</Space>
)
}
>
<div style={{marginBottom: 16}}>
<Space vertical>
{isTask && (
<Tag color="orange" size="small">
Задание
</Tag>
)}
{isLesson && (
<Tag color="blue" size="small">
Лекция
</Tag>
)}
{item.description ? (
<Text type="secondary">
{item.description.slice(0, 100)}
{item.description.length > 100 && "..."}
</Text>
) : (
<Text type="secondary" italic>
Описание отсутствует
</Text>
)}
</Space>
</div>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}>
<Text>
{isLesson ? "Лекционный материал" : "Задание"}
</Text>
{isLesson && (
listReadLessonsIds.includes(item.id) ? (
<Space style={{color: "#52c41a", fontWeight: 500}}>
<CheckCircleFilled style={{fontSize: 18}}/>
<span>Пройдено</span>
</Space>
) : (
<Space style={{color: "#999"}}>
<ClockCircleOutlined style={{fontSize: 16}}/>
<span>Не прочитано</span>
</Space>
)
)}
</div>
<div
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Avatar
size="small"
style={{backgroundColor: "#1890ff"}}
>
{item.creator?.first_name?.[0] || "У"}
</Avatar>
<Text type="secondary" style={{fontSize: 12}}>
Создал: {item.creator?.first_name}{" "}
{item.creator?.last_name}
</Text>
</div>
</Card>
</Col>
);
})}
</Row>
)}
<CreateLessonModalForm
courseId={courseId}
/>
<ViewLessonModal
courseId={courseId}
/>
<UpdateLessonModalForm/>
<CreateTaskModalForm
courseId={courseId}
/>
<ViewTaskModal
courseId={courseId}
/>
<UpdateTaskModalForm/>
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
<FloatButton.Group
placement={"left"}
trigger="hover"
type="primary"
icon={<PlusOutlined/>}
tooltip="Добавить элемент курса"
>
<FloatButton
icon={<PlusOutlined/>}
tooltip="Лекционный материал"
onClick={handleCreateLesson}
/>
<FloatButton
icon={<FormOutlined/>}
tooltip="Задание"
onClick={handleCreateTask}
/>
</FloatButton.Group>
)}
</div>
)
};
export default CourseDetailPage;

View File

@ -0,0 +1,180 @@
import {
useGetAuthenticatedUserDataQuery,
useGetReadedLessonsByCourseQuery,
useSetLessonAsReadedMutation
} from "../../../Api/usersApi.js";
import {useGetCourseByIdQuery} from "../../../Api/coursesApi.js";
import {useEffect} from "react";
import {useDispatch} from "react-redux";
import {
setOpenModalCreateLesson,
setSelectedLessonToUpdate,
setSelectedLessonToView
} from "../../../Redux/Slices/lessonsSlice.js";
import {useDeleteLessonMutation, useGetLessonsByCourseIdQuery} from "../../../Api/lessonsApi.js";
import {ROLES} from "../../../Core/constants.js";
import CONFIG from "../../../Core/сonfig.js";
import {notification} from "antd";
import {
setOpenModalCreateTask,
setSelectedTaskToUpdate,
setSelectedTaskToView
} from "../../../Redux/Slices/tasksSlice.js";
import {useDeleteTaskMutation, useGetTasksByCourseIdQuery} from "../../../Api/tasksApi.js";
const useCourseDetailPage = (courseId) => {
const dispatch = useDispatch();
const {
data: userData,
isLoading: isUserLoading,
isError: isUserError,
} = useGetAuthenticatedUserDataQuery(undefined, {
pollingInterval: 10000,
});
const {
data: courseData,
isLoading: isCourseLoading,
isError: isCourseError,
} = useGetCourseByIdQuery(courseId, {
pollingInterval: 10000,
});
const {
data: lessonsData = [],
isLoading: isLessonsLoading,
isError: isLessonsError,
} = useGetLessonsByCourseIdQuery(courseId, {
pollingInterval: 10000,
});
const {
data: tasksData = [],
isLoading: isTasksLoading,
isError: isTasksError
} = useGetTasksByCourseIdQuery(courseId, {
pollingInterval: 10000,
});
const [
deleteLesson,
] = useDeleteLessonMutation();
const [
deleteTask,
] = useDeleteTaskMutation();
const handleDeleteLesson = async (lessonId) => {
try {
await deleteLesson(lessonId);
notification.success({
title: "Успешно",
description: "Лекция удалена",
placement: "topRight",
});
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Произошла ошибка при удалении лекции",
placement: "topRight",
})
}
};
const handleDeleteTask = async (taskId) => {
try {
await deleteTask(taskId);
notification.success({
title: "Успешно",
description: "Задание удалено",
placement: "topRight",
});
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Произошла ошибка при удалении задания",
placement: "topRight",
})
}
};
useEffect(() => {
window.document.title = `Система обучения lectio - Курс: ${courseData?.title}`;
}, [courseData]);
const handleCreateLesson = () => {
dispatch(setOpenModalCreateLesson(true))
};
const isTeacherOrAdmin = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
const [
markLessonAsRead
] = useSetLessonAsReadedMutation();
const markLesson = async (lessonId) => {
try {
await markLessonAsRead(lessonId);
} catch {
notification.error({
title: "Ошибка",
description: "Не удалось отметить лекцию как прочитанную",
placement: "topRight",
})
}
};
const handleOpenLesson = async (lesson) => {
await markLesson(lesson.id)
dispatch(setSelectedLessonToView(lesson))
};
const handleEditLesson = (lesson) => {
dispatch(setSelectedLessonToUpdate(lesson))
};
const handleCreateTask = () => {
dispatch(setOpenModalCreateTask(true))
};
const handleOpenTask = (task) => {
dispatch(setSelectedTaskToView(task))
};
const handleEditTask = (task) => {
dispatch(setSelectedTaskToUpdate(task))
};
const {
data: readedLessons = []
} = useGetReadedLessonsByCourseQuery(courseData?.id, {
pollingInterval: 10000,
skip: courseData?.id === null || courseData?.id === undefined
});
const listReadLessonsIds = readedLessons.map((data) => data.lesson_id);
return {
tasksData,
isTeacherOrAdmin,
lessonsData,
userData,
courseData,
isLoading: isUserLoading || isCourseLoading || isLessonsLoading || isTasksLoading,
isError: isUserError || isCourseError || isLessonsError || isTasksError,
handleCreateLesson,
handleOpenLesson,
handleEditLesson,
handleDeleteLesson,
handleCreateTask,
handleOpenTask,
handleEditTask,
handleDeleteTask,
listReadLessonsIds,
}
};
export default useCourseDetailPage;

View File

@ -1,98 +0,0 @@
import useCreateCourseModalForm from "./useCreateCourseModalForm.js";
import {Button, Col, Form, Input, Modal, Result, Row, Select} from "antd";
import TextArea from "antd/es/input/TextArea.js";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const CreateCourseModal = () => {
const {
openCreateCourseModal,
handleCancel,
form,
isLoadinging,
isError,
teachers,
students
} = useCreateCourseModalForm();
if (isLoadinging) {
return (
<LoadingIndicator/>
);
}
if (isError) {
return (
<Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных пользователя"/>
);
}
return (
<Modal
title={"Создание курса"}
open={openCreateCourseModal}
onCancel={handleCancel}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoadinging}
// onClick={handleOk}
>
Создать
</Button>,
]}
width={800}
>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Название курса"
rules={[{required: true, message: "Введите название"}]}
>
<Input size="large" placeholder="Введение в Python"/>
</Form.Item>
<Form.Item name="description" label="Описание">
<TextArea rows={4} placeholder="Курс для начинающих..."/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="teacher_ids" label="Преподаватели">
<Select
mode="multiple"
placeholder="Выберите преподавателей"
loading={isLoadinging}
>
{teachers.map((teacher) => (
<Select.Option key={teacher.id}
value={teacher.id}>{teacher.last_name} {teacher.first_name} - {teacher.login}</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="student_ids" label="Студенты">
<Select
mode="multiple"
placeholder="Выберите студентов"
loading={isLoadinging}
>
{students.map((student) => (
<Select.Option key={student.id}
value={student.id}>{student.last_name} {student.first_name} - {student.login}</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
);
}
export default CreateCourseModal;

View File

@ -0,0 +1,110 @@
import {Button, Col, Form, Input, Modal, Result, Row, Select, Spin} from "antd";
import useCreateCourseModalForm from "./useCreateCourseModalForm.js";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const {Option} = Select;
const {TextArea} = Input;
const CreateCourseModal = () => {
const {
openCreateCourseModal,
handleCancel,
handleOk,
form,
teachers,
students,
isLoading,
isError,
} = useCreateCourseModalForm();
if (isError) {
return <Modal visible={openCreateCourseModal} footer={null}>
<Result status="500" title="Ошибка загрузки"/>
</Modal>;
}
const filterOption = (input, option) =>
option.children.toString().toLowerCase().includes(input.toLowerCase());
return (
<Modal
title="Создание курса"
open={openCreateCourseModal}
onCancel={handleCancel}
width={900}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoading}
onClick={handleOk}
>
Создать курс
</Button>,
]}
>
{isLoading ? (
<LoadingIndicator/>
) : (
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Название курса"
rules={[{required: true, message: "Введите название курса"}]}
>
<Input size="large" placeholder="Введение в React"/>
</Form.Item>
<Form.Item name="description" label="Описание (необязательно)">
<TextArea rows={4} placeholder="Курс для начинающих разработчиков..."/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="teacher_ids" label="Преподаватели">
<Select
mode="multiple"
placeholder="Начните вводить ФИО или логин..."
loading={isLoading}
showSearch={{filterOption: filterOption}}
notFoundContent="Преподаватели не найдены"
>
{teachers.map((teacher) => (
<Option key={teacher.id} value={teacher.id}>
{teacher.last_name} {teacher.first_name} ({teacher.login})
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="student_ids" label="Студенты">
<Select
mode="multiple"
showSearch
placeholder="Начните вводить ФИО или логин..."
loading={isLoading}
filterOption={filterOption}
optionFilterProp="children"
notFoundContent="Студенты не найдены"
>
{students.map((student) => (
<Option key={student.id} value={student.id}>
{student.last_name} {student.first_name} ({student.login})
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
</Form>
)}
</Modal>
);
};
export default CreateCourseModal;

View File

@ -1,6 +1,6 @@
import {useDispatch, useSelector} from "react-redux";
import {setOpenCreateCourseModal} from "../../../../../Redux/Slices/coursesSlice.js";
import {Form} from "antd";
import {Form, notification} from "antd";
import {useGetUsersByRoleNameQuery} from "../../../../../Api/usersApi.js";
import {
useCreateCourseMutation,
@ -43,7 +43,54 @@ const useCreateCourseModalForm = () => {
const handleCancel = () => {
form.resetFields();
dispatch(setOpenCreateCourseModal(false));
}
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const newCourse = await createCourse({
title: values.title,
description: values.description || null,
}).unwrap();
if (values.teacher_ids?.length > 0) {
const teachersPayload = values.teacher_ids.map(id => ({
teacher_id: id
}));
await replaceTeachers({
courseId: newCourse.id,
teachers: teachersPayload
}).unwrap();
}
if (values.student_ids?.length > 0) {
const studentsPayload = values.student_ids.map(id => ({
student_id: id,
}));
await replaceStudents({
courseId: newCourse.id,
students: studentsPayload
}).unwrap();
}
notification.success({
title: "Успешно",
description: "Курс успешно создан!",
placement: "topRight",
});
form.resetFields();
dispatch(setOpenCreateCourseModal(false));
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось создать курс.",
placement: "topRight",
});
}
};
return {
openCreateCourseModal,

View File

@ -0,0 +1,106 @@
import {Button, Col, Form, Input, Modal, Result, Row, Select, Spin} from "antd";
import useUpdateCourseModalForm from "./useUpdateCourseModalForm.js";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const {Option} = Select;
const {TextArea} = Input;
const UpdateCourseModal = () => {
const {
isModalOpen,
openUpdateCourseModal,
handleCancel,
handleOk,
form,
teachers,
students,
isLoading,
isError,
} = useUpdateCourseModalForm();
if (isError) {
return (
<Modal open={openUpdateCourseModal} footer={null} onCancel={handleCancel}>
<Result status="500" title="Ошибка загрузки"/>
</Modal>
);
}
const filterOption = (input, option) =>
option.children.toString().toLowerCase().includes(input.toLowerCase());
return (
<Modal
title="Редактирование курса"
open={isModalOpen}
onCancel={handleCancel}
width={900}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button key="submit" type="primary" loading={isLoading} onClick={handleOk}>
Сохранить изменения
</Button>,
]}
>
{isLoading ? (
<LoadingIndicator />
) : (
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Название курса"
rules={[{ required: true, message: "Введите название курса" }]}
>
<Input size="large" />
</Form.Item>
<Form.Item name="description" label="Описание">
<TextArea rows={4} placeholder="Описание курса..." />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="teacher_ids" label="Преподаватели">
<Select
mode="multiple"
placeholder="Выберите преподавателей"
showSearch
filterOption={filterOption}
notFoundContent="Преподаватели не найдены"
>
{teachers.map((teacher) => (
<Option key={teacher.id} value={teacher.id}>
{teacher.last_name} {teacher.first_name} ({teacher.login})
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="student_ids" label="Студенты">
<Select
mode="multiple"
placeholder="Выберите студентов"
showSearch
filterOption={filterOption}
notFoundContent="Студенты не найдены"
>
{students.map((student) => (
<Option key={student.id} value={student.id}>
{student.last_name} {student.first_name} ({student.login})
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
</Form>
)}
</Modal>
);
};
export default UpdateCourseModal;

View File

@ -0,0 +1,116 @@
import {useDispatch, useSelector} from "react-redux";
import {setSelectedCourseToUpdate} from "../../../../../Redux/Slices/coursesSlice.js";
import {Form, notification} from "antd";
import {useGetUsersByRoleNameQuery} from "../../../../../Api/usersApi.js";
import {
useGetCourseStudentsQuery, useGetCourseTeachersQuery,
useReplaceCourseStudentsMutation,
useReplaceCourseTeachersMutation, useUpdateCourseMutation
} from "../../../../../Api/coursesApi.js";
import {useEffect} from "react";
import {ROLES} from "../../../../../Core/constants.js";
const useUpdateCourseModalForm = () => {
const dispatch = useDispatch();
const [form] = Form.useForm();
const {selectedCourseToUpdate} = useSelector((state) => state.courses);
const courseId = selectedCourseToUpdate?.id;
const isModalOpen = selectedCourseToUpdate !== null;
const {
data: allTeachers = [],
isLoading: teachersLoading,
isError: teachersError,
} = useGetUsersByRoleNameQuery(ROLES.TEACHER);
const {
data: allStudents = [],
isLoading: studentsLoading,
isError: studentsError,
} = useGetUsersByRoleNameQuery(ROLES.STUDENT);
const {
data: currentTeachers = [],
isLoading: currentTeachersLoading,
} = useGetCourseTeachersQuery(courseId, {skip: !courseId});
const {
data: currentStudents = [],
isLoading: currentStudentsLoading,
} = useGetCourseStudentsQuery(courseId, {skip: !courseId});
const [updateCourse, {isLoading: updating}] = useUpdateCourseMutation();
const [replaceTeachers] = useReplaceCourseTeachersMutation();
const [replaceStudents] = useReplaceCourseStudentsMutation();
const isLoading = teachersLoading || studentsLoading || updating || currentTeachersLoading || currentStudentsLoading;
const isError = teachersError || studentsError;
useEffect(() => {
if (selectedCourseToUpdate) {
form.setFieldsValue({
title: selectedCourseToUpdate.title,
description: selectedCourseToUpdate.description || "",
teacher_ids: currentTeachers.map(t => t.teacher_id),
student_ids: currentStudents.map(s => s.student_id),
});
}
}, [selectedCourseToUpdate, currentTeachers, currentStudents, form]);
const handleCancel = () => {
form.resetFields();
dispatch(setSelectedCourseToUpdate(null));
};
const handleOk = async () => {
try {
const values = await form.validateFields();
await updateCourse({
courseId: courseId,
title: values.title,
description: values.description || null,
}).unwrap();
const teachersPayload = (values.teacher_ids || []).map(id => ({teacher_id: id}));
await replaceTeachers({
courseId,
teachers: teachersPayload,
}).unwrap();
const studentsPayload = (values.student_ids || []).map(id => ({student_id: id}));
await replaceStudents({
courseId,
students: studentsPayload,
}).unwrap();
notification.success({
title: "Успех",
description: "Курс успешно обновлён!",
});
handleCancel();
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось обновить курс",
});
}
};
return {
isModalOpen,
handleCancel,
handleOk,
form,
teachers: allTeachers,
students: allStudents,
isLoading,
isError,
};
};
export default useUpdateCourseModalForm;

View File

@ -8,7 +8,7 @@ import {
Spin,
Tag,
Typography,
Avatar, Result, FloatButton, Tooltip,
Avatar, Result, FloatButton, Tooltip, Progress, Divider,
} from "antd";
import {
PlusOutlined,
@ -18,10 +18,15 @@ import {
import useCoursesPage from "./useCoursesPage.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateCourseModalForm from "./Components/CreateCourseModalForm/CreateCourseModalForm.jsx";
import UpdateCourseModalForm from "./Components/UpdateCourseModalForm/UpdateCourseModalForm.jsx";
import {useNavigate} from "react-router-dom";
import {useEffect, useState} from "react";
import CONFIG from "../../../Core/сonfig.js";
const {Title, Text} = Typography;
const CoursesPage = () => {
const navigate = useNavigate();
const {
courses,
isLoading,
@ -32,6 +37,38 @@ const CoursesPage = () => {
openEditModal,
} = useCoursesPage();
const [courseProgress, setCourseProgress] = useState({});
useEffect(() => {
if (courses.length === 0) return;
const token = localStorage.getItem("access_token");
if (!token) return;
const fetchProgress = async () => {
const progress = {};
for (const course of courses) {
try {
const res = await fetch(`${CONFIG.BASE_URL}/users/my-progress/${course.id}/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (res.ok) {
const data = await res.json();
progress[course.id] = data; // data это число, например 87.5
}
} catch (err) {
console.error("Progress fetch error for course", course.id);
}
}
setCourseProgress(progress);
};
fetchProgress();
}, [courses]);
if (isLoading) {
return (
<LoadingIndicator/>
@ -80,6 +117,7 @@ const CoursesPage = () => {
color: "white",
fontSize: 48,
}}
onClick={() => navigate(`/courses/${course.id}`)}
>
{course.title[0].toUpperCase()}
</div>
@ -98,38 +136,28 @@ const CoursesPage = () => {
}
>
<Card.Meta
onClick={() => navigate(`/courses/${course.id}`)}
title={<Title level={4}>{course.title}</Title>}
description={
course.description || <Text type="secondary">Без описания</Text>
}
/>
<Space direction="vertical" style={{width: "100%", marginTop: 16}}>
<Space>
<TeamOutlined/>
<Text>
{course.teachers?.length || 0} учител
{course.teachers?.length === 1 ? "ь" : "ей"}
</Text>
</Space>
<Space>
<UserOutlined/>
<Text>
{course.enrollments?.length || 0} студент
{course.enrollments?.length === 1 ? "" : "ов"}
</Text>
</Space>
</Space>
{course.teachers?.length > 0 && (
<div style={{marginTop: 16}}>
<Text type="secondary">Преподаватели:</Text>
<Avatar.Group maxCount={3} style={{marginTop: 8}}>
<Avatar.Group max={{count: 3}} style={{marginTop: 8}}>
{course.teachers.map((t) => (
<Avatar key={t.teacher_id} style={{backgroundColor: "#1890ff"}}>
{t.teacher?.first_name?.[0] || "У"}
</Avatar>
))}
</Avatar.Group>
<Divider/>
<Text type="secondary">Прогресс:</Text>
{courseProgress[course.id] !== undefined && (
<Progress percent={courseProgress[course.id]} size={[300, 20]}/>
)}
</div>
)}
</Card>
@ -147,6 +175,7 @@ const CoursesPage = () => {
</Tooltip>
<CreateCourseModalForm/>
<UpdateCourseModalForm/>
</div>
);
};

View File

@ -1,16 +1,19 @@
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
import {useGetAllCoursesQuery} from "../../../Api/coursesApi.js";
import {useGetAuthenticatedUserDataQuery, useGetMyCourseProgressQuery} from "../../../Api/usersApi.js";
import {useGetAllCoursesQuery, useGetAllMyCoursesQuery} from "../../../Api/coursesApi.js";
import CONFIG from "../../../Core/сonfig.js";
import {ROLES} from "../../../Core/constants.js";
import {useDispatch} from "react-redux";
import {setOpenCreateCourseModal} from "../../../Redux/Slices/coursesSlice.js";
import {setOpenCreateCourseModal, setSelectedCourseToUpdate} from "../../../Redux/Slices/coursesSlice.js";
import {useEffect} from "react";
const useCoursesPage = () => {
const dispatch = useDispatch();
const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery();
const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllCoursesQuery();
const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllMyCoursesQuery(undefined, {
pollingInterval: 20000,
});
const isAdmin = userData?.role?.title === CONFIG.ROOT_ROLE_NAME;
const isTeacher = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
@ -19,10 +22,14 @@ const useCoursesPage = () => {
dispatch(setOpenCreateCourseModal(true));
};
const closeCreateModal = () => {
dispatch(setOpenCreateCourseModal(false));
const openEditModal = (course) => {
dispatch(setSelectedCourseToUpdate(course));
};
useEffect(() => {
window.document.title = "Система обучения lectio - Курсы";
}, []);
return {
courses,
isLoading: isCoursesLoading || isUserLoading || isLoading,
@ -30,7 +37,7 @@ const useCoursesPage = () => {
isAdmin,
isTeacher,
openCreateModal,
closeModal: closeCreateModal,
openEditModal,
};
};

View File

@ -26,7 +26,7 @@ const LoginPage = () => {
hasRedirected.current = true;
navigate("/");
}
document.title = "Аутентификация";
document.title = "Система обучения lectio - Аутентификация";
}, [user, userData, isLoading, navigate]);
const onFinish = async (loginData) => {

View File

@ -13,7 +13,7 @@ const useProfilePage = () => {
const [passwordForm] = Form.useForm();
useEffect(() => {
window.document.title = "Профиль";
window.document.title = "Система обучения lectio - Профиль";
}, []);
const {

View File

@ -0,0 +1,32 @@
import {createSlice} from "@reduxjs/toolkit";
const initialState = {
selectedLessonToUpdate: null,
openModalCreateLesson: false,
selectedLessonToView: null,
};
const lessonSlice = createSlice({
name: "lessons",
initialState,
reducers: {
setSelectedLessonToUpdate(state, action) {
state.selectedLessonToUpdate = action.payload;
},
setOpenModalCreateLesson(state, action) {
state.openModalCreateLesson = action.payload;
},
setSelectedLessonToView(state, action) {
state.selectedLessonToView = action.payload;
}
},
});
export const {
setSelectedLessonToUpdate,
setOpenModalCreateLesson,
setSelectedLessonToView,
} = lessonSlice.actions;
export default lessonSlice.reducer;

View File

@ -0,0 +1,32 @@
import {createSlice} from "@reduxjs/toolkit";
const initialState = {
selectedTaskToUpdate: null,
openModalCreateTask: false,
selectedTaskToView: null,
}
const tasksSlice = createSlice({
name: "tasks",
initialState,
reducers: {
setSelectedTaskToUpdate: (state, action) => {
state.selectedTaskToUpdate = action.payload;
},
setOpenModalCreateTask: (state, action) => {
state.openModalCreateTask = action.payload;
},
setSelectedTaskToView: (state, action) => {
state.selectedTaskToView = action.payload;
},
},
});
export const {
setSelectedTaskToUpdate,
setOpenModalCreateTask,
setSelectedTaskToView,
} = tasksSlice.actions;
export default tasksSlice.reducer;

View File

@ -2,11 +2,17 @@ import {configureStore} from "@reduxjs/toolkit";
import authReducer from "./Slices/authSlice.js";
import usersReducer from "./Slices/usersSlice.js";
import coursesReducer from "./Slices/coursesSlice.js";
import lessonReducer from "./Slices/lessonsSlice.js";
import tasksReducer from "./Slices/tasksSlice.js";
import {authApi} from "../Api/authApi.js";
import {usersApi} from "../Api/usersApi.js";
import {rolesApi} from "../Api/rolesApi.js";
import {statusesApi} from "../Api/statusesApi.js";
import {coursesApi} from "../Api/coursesApi.js";
import {lessonsApi} from "../Api/lessonsApi.js";
import {tasksApi} from "../Api/tasksApi.js";
import {solutionsApi} from "../Api/solutionsApi.js";
import {commentsApi} from "../Api/commentsApi.js";
export const store = configureStore({
reducer: {
@ -21,7 +27,17 @@ export const store = configureStore({
[statusesApi.reducerPath]: statusesApi.reducer,
courses: coursesReducer,
[coursesApi.reducerPath]: coursesApi.reducer
[coursesApi.reducerPath]: coursesApi.reducer,
lessons: lessonReducer,
[lessonsApi.reducerPath]: lessonsApi.reducer,
tasks: tasksReducer,
[tasksApi.reducerPath]: tasksApi.reducer,
[solutionsApi.reducerPath]: solutionsApi.reducer,
[commentsApi.reducerPath]: commentsApi.reducer,
},
middleware: (getDefaultMiddleware) => (
getDefaultMiddleware().concat(
@ -29,7 +45,11 @@ export const store = configureStore({
usersApi.middleware,
rolesApi.middleware,
statusesApi.middleware,
coursesApi.middleware
coursesApi.middleware,
lessonsApi.middleware,
tasksApi.middleware,
solutionsApi.middleware,
commentsApi.middleware,
)
),
});