Merge branch 'andrei' into lev
# Conflicts: # web/src/App/AppRouter.jsx
This commit is contained in:
commit
eaeff43494
@ -1,9 +1,10 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional, Sequence
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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:
|
class CoursesRepository:
|
||||||
@ -11,14 +12,50 @@ class CoursesRepository:
|
|||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def get_all(self) -> List[Course]:
|
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)
|
result = await self.db.execute(query)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
async def get_by_id(self, course_id: int) -> Optional[Course]:
|
async def get_by_id(self, course_id: int) -> Optional[Course]:
|
||||||
query = (
|
query = (
|
||||||
select(Course)
|
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)
|
result = await self.db.execute(query)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|||||||
38
api/app/application/lesson_files_repository.py
Normal file
38
api/app/application/lesson_files_repository.py
Normal 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
|
||||||
45
api/app/application/lessons_repository.py
Normal file
45
api/app/application/lessons_repository.py
Normal 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
|
||||||
39
api/app/application/solution_comments_repository.py
Normal file
39
api/app/application/solution_comments_repository.py
Normal 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
|
||||||
38
api/app/application/solution_files_repository.py
Normal file
38
api/app/application/solution_files_repository.py
Normal 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
|
||||||
76
api/app/application/solutions_repository.py
Normal file
76
api/app/application/solutions_repository.py
Normal 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
|
||||||
38
api/app/application/task_files_repository.py
Normal file
38
api/app/application/task_files_repository.py
Normal 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
|
||||||
45
api/app/application/tasks_repository.py
Normal file
45
api/app/application/tasks_repository.py
Normal 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
|
||||||
36
api/app/application/user_check_lessons_repository.py
Normal file
36
api/app/application/user_check_lessons_repository.py
Normal 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
|
||||||
@ -5,12 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.database.session import get_db
|
from app.database.session import get_db
|
||||||
from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate
|
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.entities.enrollments import EnrollmentRead, EnrollmentCreate
|
||||||
from app.domain.models import User
|
from app.domain.models import User
|
||||||
from app.infrastructure.course_teachers_service import CourseTeachersService
|
from app.infrastructure.course_teachers_service import CourseTeachersService
|
||||||
from app.infrastructure.courses_service import CoursesService
|
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
|
from app.infrastructure.enrollments_service import EnrollmentsService
|
||||||
|
|
||||||
courses_router = APIRouter()
|
courses_router = APIRouter()
|
||||||
@ -24,15 +24,44 @@ courses_router = APIRouter()
|
|||||||
)
|
)
|
||||||
async def get_all_courses(
|
async def get_all_courses(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(require_auth_user),
|
user: User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
courses_service = CoursesService(db)
|
courses_service = CoursesService(db)
|
||||||
return await courses_service.get_all()
|
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(
|
@courses_router.post(
|
||||||
'/',
|
'/',
|
||||||
response_model=Optional[CourseRead],
|
response_model=Optional[CourseCreated],
|
||||||
summary='Create a new course',
|
summary='Create a new course',
|
||||||
description='Create a new course',
|
description='Create a new course',
|
||||||
)
|
)
|
||||||
|
|||||||
151
api/app/controllers/lessons_router.py
Normal file
151
api/app/controllers/lessons_router.py
Normal 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)
|
||||||
55
api/app/controllers/solution_comments_router.py
Normal file
55
api/app/controllers/solution_comments_router.py
Normal 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)
|
||||||
142
api/app/controllers/solutions_router.py
Normal file
142
api/app/controllers/solutions_router.py
Normal 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)
|
||||||
151
api/app/controllers/tasks_router.py
Normal file
151
api/app/controllers/tasks_router.py
Normal 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)
|
||||||
@ -1,13 +1,14 @@
|
|||||||
from typing import List, Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_db
|
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.domain.models import User
|
||||||
from app.infrastructure.dependencies import require_auth_user, require_admin
|
from app.infrastructure.dependencies import require_auth_user, require_admin
|
||||||
from app.infrastructure.register_service import RegisterService
|
from app.infrastructure.register_service import RegisterService
|
||||||
|
from app.infrastructure.user_check_lessons_service import UserCheckLessonsService
|
||||||
from app.infrastructure.users_service import UsersService
|
from app.infrastructure.users_service import UsersService
|
||||||
|
|
||||||
users_router = APIRouter()
|
users_router = APIRouter()
|
||||||
@ -101,3 +102,47 @@ async def get_users_by_role_name(
|
|||||||
):
|
):
|
||||||
users_service = UsersService(db)
|
users_service = UsersService(db)
|
||||||
return await users_service.get_by_role_name(role_name)
|
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)
|
||||||
|
|||||||
@ -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 ###
|
||||||
@ -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 ###
|
||||||
@ -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 ###
|
||||||
@ -2,7 +2,6 @@ from pydantic import BaseModel, EmailStr, Field
|
|||||||
|
|
||||||
|
|
||||||
class CourseTeacherCreate(BaseModel):
|
class CourseTeacherCreate(BaseModel):
|
||||||
course_id: int = Field()
|
|
||||||
teacher_id: int = Field()
|
teacher_id: int = Field()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,17 @@ from app.domain.entities.course_teachers import CourseTeacherRead
|
|||||||
from app.domain.entities.enrollments import EnrollmentRead
|
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):
|
class CourseCreate(BaseModel):
|
||||||
title: str = Field(max_length=250)
|
title: str = Field(max_length=250)
|
||||||
description: Optional[str] = Field(default=None, max_length=1000)
|
description: Optional[str] = Field(default=None, max_length=1000)
|
||||||
@ -15,13 +26,14 @@ class CourseUpdate(CourseCreate):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CourseRead(BaseModel):
|
class CourseRead(CourseBase):
|
||||||
id: int
|
teachers: List[CourseTeacherRead] = []
|
||||||
title: str
|
enrollments: List[EnrollmentRead] = []
|
||||||
description: str
|
|
||||||
|
|
||||||
teachers: List[CourseTeacherRead]
|
|
||||||
enrollments: List[EnrollmentRead]
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CourseCreated(CourseBase):
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
class EnrollmentCreate(BaseModel):
|
class EnrollmentCreate(BaseModel):
|
||||||
course_id: int = Field()
|
|
||||||
student_id: int = Field()
|
student_id: int = Field()
|
||||||
|
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ class EnrollmentRead(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
course_id: int
|
course_id: int
|
||||||
student_id: int
|
student_id: int
|
||||||
enrollment_date: datetime
|
enrollment_date: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
11
api/app/domain/entities/lesson_files.py
Normal file
11
api/app/domain/entities/lesson_files.py
Normal 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
|
||||||
33
api/app/domain/entities/lessons.py
Normal file
33
api/app/domain/entities/lessons.py
Normal 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
|
||||||
11
api/app/domain/entities/solution_files.py
Normal file
11
api/app/domain/entities/solution_files.py
Normal 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
|
||||||
59
api/app/domain/entities/solutions.py
Normal file
59
api/app/domain/entities/solutions.py
Normal 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
|
||||||
11
api/app/domain/entities/task_files.py
Normal file
11
api/app/domain/entities/task_files.py
Normal 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
|
||||||
30
api/app/domain/entities/tasks.py
Normal file
30
api/app/domain/entities/tasks.py
Normal 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
|
||||||
@ -62,3 +62,19 @@ class UserRead(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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
|
||||||
|
|||||||
@ -15,9 +15,11 @@ from app.domain.models.enrollments import Enrollment
|
|||||||
from app.domain.models.lesson_files import LessonFile
|
from app.domain.models.lesson_files import LessonFile
|
||||||
from app.domain.models.lessons import Lesson
|
from app.domain.models.lessons import Lesson
|
||||||
from app.domain.models.roles import Role
|
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.solution_files import SolutionFile
|
||||||
from app.domain.models.solutions import Solution
|
from app.domain.models.solutions import Solution
|
||||||
from app.domain.models.statuses import Status
|
from app.domain.models.statuses import Status
|
||||||
from app.domain.models.task_files import TaskFile
|
from app.domain.models.task_files import TaskFile
|
||||||
from app.domain.models.tasks import Task
|
from app.domain.models.tasks import Task
|
||||||
|
from app.domain.models.user_check_lessons import UserCheckLessons
|
||||||
from app.domain.models.users import User
|
from app.domain.models.users import User
|
||||||
|
|||||||
@ -17,8 +17,8 @@ class RootTable(Base):
|
|||||||
class PhotoAbstract(RootTable):
|
class PhotoAbstract(RootTable):
|
||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
|
|
||||||
photo_filename: Mapped[str] = mapped_column()
|
photo_filename: Mapped[str] = mapped_column(nullable=True)
|
||||||
photo_path: Mapped[str] = mapped_column()
|
photo_path: Mapped[str] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class FileAbstract(RootTable):
|
class FileAbstract(RootTable):
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.domain.models.base import RootTable
|
from app.domain.models.base import RootTable
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class Lesson(RootTable):
|
|||||||
creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
course: Mapped['Course'] = relationship('Course', back_populates='lessons')
|
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')
|
files: Mapped[List['LessonFile']] = relationship('LessonFile', back_populates='lesson')
|
||||||
|
user_check_lessons: Mapped[List['UserCheckLessons']] = relationship('UserCheckLessons', back_populates='lesson')
|
||||||
|
|||||||
16
api/app/domain/models/solution_comments.py
Normal file
16
api/app/domain/models/solution_comments.py
Normal 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')
|
||||||
@ -10,14 +10,18 @@ class Solution(RootTable):
|
|||||||
__tablename__ = 'solutions'
|
__tablename__ = 'solutions'
|
||||||
|
|
||||||
answer_text: Mapped[str] = mapped_column(nullable=True)
|
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)
|
assessment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=True)
|
||||||
task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id'), nullable=False)
|
task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id'), nullable=False)
|
||||||
student_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
student_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
assessment_autor: Mapped['User'] = relationship('User', back_populates='assessments',
|
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])
|
student: Mapped['User'] = relationship('User', back_populates='my_solutions', foreign_keys=[student_id])
|
||||||
|
|
||||||
files: Mapped[List['SolutionFile']] = relationship('SolutionFile', back_populates='solution')
|
files: Mapped[List['SolutionFile']] = relationship('SolutionFile', back_populates='solution')
|
||||||
|
solution_comments: Mapped[List['SolutionComment']] = relationship(
|
||||||
|
'SolutionComment',
|
||||||
|
back_populates='solution',
|
||||||
|
)
|
||||||
|
|||||||
@ -18,6 +18,6 @@ class Task(RootTable):
|
|||||||
creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
course: Mapped['Course'] = relationship('Course', back_populates='tasks')
|
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')
|
files: Mapped[List['TaskFile']] = relationship('TaskFile', back_populates='task')
|
||||||
|
|||||||
14
api/app/domain/models/user_check_lessons.py
Normal file
14
api/app/domain/models/user_check_lessons.py
Normal 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')
|
||||||
@ -46,6 +46,16 @@ class User(PhotoAbstract):
|
|||||||
back_populates='student',
|
back_populates='student',
|
||||||
foreign_keys=[Solution.student_id],
|
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):
|
def check_password(self, password):
|
||||||
return check_password_hash(self.password_hash, password)
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|||||||
@ -4,13 +4,16 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.application.courses_repository import CoursesRepository
|
from app.application.courses_repository import CoursesRepository
|
||||||
from app.domain.entities.courses import CourseRead, CourseCreate
|
from app.core.constants import UserRoles
|
||||||
from app.domain.models import Course
|
from app.domain.entities.courses import CourseRead, CourseCreate, CourseCreated
|
||||||
|
from app.domain.models import Course, User
|
||||||
|
from app.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
class CoursesService:
|
class CoursesService:
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.courses_repository = CoursesRepository(db)
|
self.courses_repository = CoursesRepository(db)
|
||||||
|
self.settings = Settings()
|
||||||
|
|
||||||
async def get_all(self) -> List[CourseRead]:
|
async def get_all(self) -> List[CourseRead]:
|
||||||
courses = await self.courses_repository.get_all()
|
courses = await self.courses_repository.get_all()
|
||||||
@ -22,7 +25,35 @@ class CoursesService:
|
|||||||
|
|
||||||
return response
|
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(
|
course_model = Course(
|
||||||
title=course.title,
|
title=course.title,
|
||||||
description=course.description,
|
description=course.description,
|
||||||
@ -30,7 +61,7 @@ class CoursesService:
|
|||||||
|
|
||||||
course_model = await self.courses_repository.create(course_model)
|
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]:
|
async def update(self, course_id: int, course: CourseCreate) -> Optional[CourseRead]:
|
||||||
course_model = await self.courses_repository.get_by_id(course_id)
|
course_model = await self.courses_repository.get_by_id(course_id)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
@ -52,7 +53,7 @@ class EnrollmentsService:
|
|||||||
enrollments_models.append(Enrollment(
|
enrollments_models.append(Enrollment(
|
||||||
course_id=course_id,
|
course_id=course_id,
|
||||||
student_id=enrollment.student_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)
|
enrollments_models = await self.enrollments_repository.create_list(enrollments_models)
|
||||||
|
|||||||
118
api/app/infrastructure/lesson_files_service.py
Normal file
118
api/app/infrastructure/lesson_files_service.py
Normal 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"
|
||||||
114
api/app/infrastructure/lessons_service.py
Normal file
114
api/app/infrastructure/lessons_service.py
Normal 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)
|
||||||
75
api/app/infrastructure/solution_comments_service.py
Normal file
75
api/app/infrastructure/solution_comments_service.py
Normal 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)
|
||||||
118
api/app/infrastructure/solution_files_service.py
Normal file
118
api/app/infrastructure/solution_files_service.py
Normal 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"
|
||||||
132
api/app/infrastructure/solutions_service.py
Normal file
132
api/app/infrastructure/solutions_service.py
Normal 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)
|
||||||
118
api/app/infrastructure/task_files_service.py
Normal file
118
api/app/infrastructure/task_files_service.py
Normal 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"
|
||||||
113
api/app/infrastructure/tasks_service.py
Normal file
113
api/app/infrastructure/tasks_service.py
Normal 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)
|
||||||
102
api/app/infrastructure/user_check_lessons_service.py
Normal file
102
api/app/infrastructure/user_check_lessons_service.py
Normal 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)
|
||||||
@ -3,9 +3,13 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.controllers.auth_router import auth_router
|
from app.controllers.auth_router import auth_router
|
||||||
from app.controllers.courses_router import courses_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.register_router import register_router
|
||||||
from app.controllers.roles_router import roles_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.statuses_router import statuses_router
|
||||||
|
from app.controllers.tasks_router import tasks_router
|
||||||
from app.controllers.users_router import users_router
|
from app.controllers.users_router import users_router
|
||||||
from app.settings import Settings
|
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(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(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(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(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(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'])
|
api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users'])
|
||||||
|
|
||||||
return api_app
|
return api_app
|
||||||
|
|||||||
@ -7,3 +7,4 @@ werkzeug==3.1.3
|
|||||||
pyjwt==2.9.0
|
pyjwt==2.9.0
|
||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
pydantic[email]==2.11.4
|
pydantic[email]==2.11.4
|
||||||
|
aiofiles==25.1.0
|
||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/rounded_logo.png" />
|
<link rel="icon" href="/rounded_logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>web</title>
|
<title>Система обучения lectio</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
20
web/package-lock.json
generated
20
web/package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@reduxjs/toolkit": "^2.11.0",
|
"@reduxjs/toolkit": "^2.11.0",
|
||||||
"antd": "^6.0.0",
|
"antd": "^6.0.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"jodit-react": "^5.2.38",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
@ -3134,6 +3135,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"@reduxjs/toolkit": "^2.11.0",
|
"@reduxjs/toolkit": "^2.11.0",
|
||||||
"antd": "^6.0.0",
|
"antd": "^6.0.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"jodit-react": "^5.2.38",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
|||||||
39
web/src/Api/commentsApi.js
Normal file
39
web/src/Api/commentsApi.js
Normal 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;
|
||||||
@ -14,6 +14,20 @@ export const coursesApi = createApi({
|
|||||||
}),
|
}),
|
||||||
providesTags: ['course'],
|
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({
|
createCourse: builder.mutation({
|
||||||
query: (data) => ({
|
query: (data) => ({
|
||||||
url: "/courses/",
|
url: "/courses/",
|
||||||
@ -38,10 +52,10 @@ export const coursesApi = createApi({
|
|||||||
providesTags: ['teacher'],
|
providesTags: ['teacher'],
|
||||||
}),
|
}),
|
||||||
replaceCourseTeachers: builder.mutation({
|
replaceCourseTeachers: builder.mutation({
|
||||||
query: ({courseId, ...data}) => ({
|
query: ({courseId, teachers}) => ({
|
||||||
url: `/courses/${courseId}/teachers/`,
|
url: `/courses/${courseId}/teachers/`,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: data,
|
body: teachers,
|
||||||
}),
|
}),
|
||||||
invalidatesTags: ['teacher'],
|
invalidatesTags: ['teacher'],
|
||||||
}),
|
}),
|
||||||
@ -53,10 +67,10 @@ export const coursesApi = createApi({
|
|||||||
providesTags: ['student'],
|
providesTags: ['student'],
|
||||||
}),
|
}),
|
||||||
replaceCourseStudents: builder.mutation({
|
replaceCourseStudents: builder.mutation({
|
||||||
query: ({courseId, ...data}) => ({
|
query: ({courseId, students}) => ({
|
||||||
url: `/courses/${courseId}/students/`,
|
url: `/courses/${courseId}/students/`,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: data,
|
body: students,
|
||||||
}),
|
}),
|
||||||
invalidatesTags: ['student'],
|
invalidatesTags: ['student'],
|
||||||
}),
|
}),
|
||||||
@ -65,6 +79,8 @@ export const coursesApi = createApi({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetAllCoursesQuery,
|
useGetAllCoursesQuery,
|
||||||
|
useGetAllMyCoursesQuery,
|
||||||
|
useGetCourseByIdQuery,
|
||||||
useCreateCourseMutation,
|
useCreateCourseMutation,
|
||||||
useUpdateCourseMutation,
|
useUpdateCourseMutation,
|
||||||
useGetCourseTeachersQuery,
|
useGetCourseTeachersQuery,
|
||||||
|
|||||||
104
web/src/Api/lessonsApi.js
Normal file
104
web/src/Api/lessonsApi.js
Normal 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;
|
||||||
82
web/src/Api/solutionsApi.js
Normal file
82
web/src/Api/solutionsApi.js
Normal 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
104
web/src/Api/tasksApi.js
Normal 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;
|
||||||
@ -46,6 +46,27 @@ export const usersApi = createApi({
|
|||||||
}),
|
}),
|
||||||
providesTags: ["user"],
|
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,
|
useUpdateUserPasswordMutation,
|
||||||
useCreateUserMutation,
|
useCreateUserMutation,
|
||||||
useGetUsersByRoleNameQuery,
|
useGetUsersByRoleNameQuery,
|
||||||
|
useGetReadedLessonsByCourseQuery,
|
||||||
|
useSetLessonAsReadedMutation,
|
||||||
|
useGetMyCourseProgressQuery,
|
||||||
} = usersApi;
|
} = usersApi;
|
||||||
@ -7,6 +7,7 @@ import CoursesPage from "../Components/Pages/Courses/CoursesPage.jsx";
|
|||||||
import MainLayout from "../Components/Layouts/MainLayout.jsx";
|
import MainLayout from "../Components/Layouts/MainLayout.jsx";
|
||||||
import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx";
|
import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx";
|
||||||
import AdminPage from "../Components/Pages/AdminPage/AdminPage.jsx";
|
import AdminPage from "../Components/Pages/AdminPage/AdminPage.jsx";
|
||||||
|
import CourseDetailPage from "../Components/Pages/CourseDetailPage/CourseDetailPage.jsx";
|
||||||
|
|
||||||
|
|
||||||
const AppRouter = () => (
|
const AppRouter = () => (
|
||||||
@ -18,6 +19,7 @@ const AppRouter = () => (
|
|||||||
<Route element={<MainLayout/>}>
|
<Route element={<MainLayout/>}>
|
||||||
<Route path={"/courses"} element={<CoursesPage/>}/>
|
<Route path={"/courses"} element={<CoursesPage/>}/>
|
||||||
<Route path={"/profile"} element={<ProfilePage/>}/>
|
<Route path={"/profile"} element={<ProfilePage/>}/>
|
||||||
|
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
||||||
<Route path={"*"} element={<Navigate to={"/courses"}/>}/>
|
<Route path={"*"} element={<Navigate to={"/courses"}/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import useMainLayout from "./useMainLayout.js";
|
import useMainLayout from "./useMainLayout.js";
|
||||||
import {Layout, Menu} from "antd";
|
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 LoadingIndicator from "../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
||||||
import {Outlet} from "react-router-dom";
|
import {Outlet} from "react-router-dom";
|
||||||
import {BookOutlined, ControlOutlined, LogoutOutlined, UserOutlined} from "@ant-design/icons";
|
import {BookOutlined, ControlOutlined, LogoutOutlined, UserOutlined} from "@ant-design/icons";
|
||||||
@ -79,7 +79,7 @@ const MainLayout = () => {
|
|||||||
<Outlet/>
|
<Outlet/>
|
||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
<Footer style={{textAlign: "center"}}>{new Date().getFullYear()}</Footer>
|
<Footer style={{textAlign: "center"}}>lectio © {new Date().getFullYear()}</Footer>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {useGetAllUsersQuery, useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
|
import {useGetAllUsersQuery, useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
|
||||||
import {useGetAllRolesQuery} from "../../../Api/rolesApi.js";
|
import {useGetAllRolesQuery} from "../../../Api/rolesApi.js";
|
||||||
import {useMemo, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {useDispatch} from "react-redux";
|
import {useDispatch} from "react-redux";
|
||||||
import {setOpenModalCreateUser, setSelectedUserToUpdate} from "../../../Redux/Slices/usersSlice.js";
|
import {setOpenModalCreateUser, setSelectedUserToUpdate} from "../../../Redux/Slices/usersSlice.js";
|
||||||
|
|
||||||
@ -18,6 +18,10 @@ const useAdminPage = () => {
|
|||||||
setSearchString,
|
setSearchString,
|
||||||
] = useState("");
|
] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.document.title = "Система обучения lectio - Панель администратора";
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: usersData = [],
|
data: usersData = [],
|
||||||
isLoading: usersIsLoading,
|
isLoading: usersIsLoading,
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
270
web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx
Normal file
270
web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx
Normal 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;
|
||||||
180
web/src/Components/Pages/CourseDetailPage/useCourseDetailPage.js
Normal file
180
web/src/Components/Pages/CourseDetailPage/useCourseDetailPage.js
Normal 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;
|
||||||
@ -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;
|
|
||||||
@ -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;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import {useDispatch, useSelector} from "react-redux";
|
import {useDispatch, useSelector} from "react-redux";
|
||||||
import {setOpenCreateCourseModal} from "../../../../../Redux/Slices/coursesSlice.js";
|
import {setOpenCreateCourseModal} from "../../../../../Redux/Slices/coursesSlice.js";
|
||||||
import {Form} from "antd";
|
import {Form, notification} from "antd";
|
||||||
import {useGetUsersByRoleNameQuery} from "../../../../../Api/usersApi.js";
|
import {useGetUsersByRoleNameQuery} from "../../../../../Api/usersApi.js";
|
||||||
import {
|
import {
|
||||||
useCreateCourseMutation,
|
useCreateCourseMutation,
|
||||||
@ -43,8 +43,55 @@ const useCreateCourseModalForm = () => {
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
dispatch(setOpenCreateCourseModal(false));
|
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 {
|
return {
|
||||||
openCreateCourseModal,
|
openCreateCourseModal,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -8,7 +8,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
Avatar, Result, FloatButton, Tooltip,
|
Avatar, Result, FloatButton, Tooltip, Progress, Divider,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -18,10 +18,15 @@ import {
|
|||||||
import useCoursesPage from "./useCoursesPage.js";
|
import useCoursesPage from "./useCoursesPage.js";
|
||||||
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
||||||
import CreateCourseModalForm from "./Components/CreateCourseModalForm/CreateCourseModalForm.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 {Title, Text} = Typography;
|
||||||
|
|
||||||
const CoursesPage = () => {
|
const CoursesPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
courses,
|
courses,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -32,6 +37,38 @@ const CoursesPage = () => {
|
|||||||
openEditModal,
|
openEditModal,
|
||||||
} = useCoursesPage();
|
} = 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<LoadingIndicator/>
|
<LoadingIndicator/>
|
||||||
@ -80,6 +117,7 @@ const CoursesPage = () => {
|
|||||||
color: "white",
|
color: "white",
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
}}
|
}}
|
||||||
|
onClick={() => navigate(`/courses/${course.id}`)}
|
||||||
>
|
>
|
||||||
{course.title[0].toUpperCase()}
|
{course.title[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
@ -98,38 +136,28 @@ const CoursesPage = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card.Meta
|
<Card.Meta
|
||||||
|
onClick={() => navigate(`/courses/${course.id}`)}
|
||||||
title={<Title level={4}>{course.title}</Title>}
|
title={<Title level={4}>{course.title}</Title>}
|
||||||
description={
|
description={
|
||||||
course.description || <Text type="secondary">Без описания</Text>
|
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 && (
|
{course.teachers?.length > 0 && (
|
||||||
<div style={{marginTop: 16}}>
|
<div style={{marginTop: 16}}>
|
||||||
<Text type="secondary">Преподаватели:</Text>
|
<Text type="secondary">Преподаватели:</Text>
|
||||||
<Avatar.Group maxCount={3} style={{marginTop: 8}}>
|
<Avatar.Group max={{count: 3}} style={{marginTop: 8}}>
|
||||||
{course.teachers.map((t) => (
|
{course.teachers.map((t) => (
|
||||||
<Avatar key={t.teacher_id} style={{backgroundColor: "#1890ff"}}>
|
<Avatar key={t.teacher_id} style={{backgroundColor: "#1890ff"}}>
|
||||||
{t.teacher?.first_name?.[0] || "У"}
|
{t.teacher?.first_name?.[0] || "У"}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
))}
|
))}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
|
<Divider/>
|
||||||
|
<Text type="secondary">Прогресс:</Text>
|
||||||
|
{courseProgress[course.id] !== undefined && (
|
||||||
|
<Progress percent={courseProgress[course.id]} size={[300, 20]}/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@ -147,6 +175,7 @@ const CoursesPage = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<CreateCourseModalForm/>
|
<CreateCourseModalForm/>
|
||||||
|
<UpdateCourseModalForm/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,16 +1,19 @@
|
|||||||
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
|
import {useGetAuthenticatedUserDataQuery, useGetMyCourseProgressQuery} from "../../../Api/usersApi.js";
|
||||||
import {useGetAllCoursesQuery} from "../../../Api/coursesApi.js";
|
import {useGetAllCoursesQuery, useGetAllMyCoursesQuery} from "../../../Api/coursesApi.js";
|
||||||
import CONFIG from "../../../Core/сonfig.js";
|
import CONFIG from "../../../Core/сonfig.js";
|
||||||
import {ROLES} from "../../../Core/constants.js";
|
import {ROLES} from "../../../Core/constants.js";
|
||||||
import {useDispatch} from "react-redux";
|
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 useCoursesPage = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery();
|
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 isAdmin = userData?.role?.title === CONFIG.ROOT_ROLE_NAME;
|
||||||
const isTeacher = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
|
const isTeacher = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
|
||||||
@ -19,10 +22,14 @@ const useCoursesPage = () => {
|
|||||||
dispatch(setOpenCreateCourseModal(true));
|
dispatch(setOpenCreateCourseModal(true));
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCreateModal = () => {
|
const openEditModal = (course) => {
|
||||||
dispatch(setOpenCreateCourseModal(false));
|
dispatch(setSelectedCourseToUpdate(course));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.document.title = "Система обучения lectio - Курсы";
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
courses,
|
courses,
|
||||||
isLoading: isCoursesLoading || isUserLoading || isLoading,
|
isLoading: isCoursesLoading || isUserLoading || isLoading,
|
||||||
@ -30,7 +37,7 @@ const useCoursesPage = () => {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
isTeacher,
|
isTeacher,
|
||||||
openCreateModal,
|
openCreateModal,
|
||||||
closeModal: closeCreateModal,
|
openEditModal,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ const LoginPage = () => {
|
|||||||
hasRedirected.current = true;
|
hasRedirected.current = true;
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
document.title = "Аутентификация";
|
document.title = "Система обучения lectio - Аутентификация";
|
||||||
}, [user, userData, isLoading, navigate]);
|
}, [user, userData, isLoading, navigate]);
|
||||||
|
|
||||||
const onFinish = async (loginData) => {
|
const onFinish = async (loginData) => {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const useProfilePage = () => {
|
|||||||
const [passwordForm] = Form.useForm();
|
const [passwordForm] = Form.useForm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.document.title = "Профиль";
|
window.document.title = "Система обучения lectio - Профиль";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
32
web/src/Redux/Slices/lessonsSlice.js
Normal file
32
web/src/Redux/Slices/lessonsSlice.js
Normal 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;
|
||||||
32
web/src/Redux/Slices/tasksSlice.js
Normal file
32
web/src/Redux/Slices/tasksSlice.js
Normal 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;
|
||||||
@ -2,11 +2,17 @@ import {configureStore} from "@reduxjs/toolkit";
|
|||||||
import authReducer from "./Slices/authSlice.js";
|
import authReducer from "./Slices/authSlice.js";
|
||||||
import usersReducer from "./Slices/usersSlice.js";
|
import usersReducer from "./Slices/usersSlice.js";
|
||||||
import coursesReducer from "./Slices/coursesSlice.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 {authApi} from "../Api/authApi.js";
|
||||||
import {usersApi} from "../Api/usersApi.js";
|
import {usersApi} from "../Api/usersApi.js";
|
||||||
import {rolesApi} from "../Api/rolesApi.js";
|
import {rolesApi} from "../Api/rolesApi.js";
|
||||||
import {statusesApi} from "../Api/statusesApi.js";
|
import {statusesApi} from "../Api/statusesApi.js";
|
||||||
import {coursesApi} from "../Api/coursesApi.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({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@ -21,7 +27,17 @@ export const store = configureStore({
|
|||||||
[statusesApi.reducerPath]: statusesApi.reducer,
|
[statusesApi.reducerPath]: statusesApi.reducer,
|
||||||
|
|
||||||
courses: coursesReducer,
|
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) => (
|
middleware: (getDefaultMiddleware) => (
|
||||||
getDefaultMiddleware().concat(
|
getDefaultMiddleware().concat(
|
||||||
@ -29,7 +45,11 @@ export const store = configureStore({
|
|||||||
usersApi.middleware,
|
usersApi.middleware,
|
||||||
rolesApi.middleware,
|
rolesApi.middleware,
|
||||||
statusesApi.middleware,
|
statusesApi.middleware,
|
||||||
coursesApi.middleware
|
coursesApi.middleware,
|
||||||
|
lessonsApi.middleware,
|
||||||
|
tasksApi.middleware,
|
||||||
|
solutionsApi.middleware,
|
||||||
|
commentsApi.middleware,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user