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.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.domain.models import Course
|
||||
from app.domain.models import Course, CourseTeacher, Enrollment
|
||||
|
||||
|
||||
class CoursesRepository:
|
||||
@ -11,14 +12,50 @@ class CoursesRepository:
|
||||
self.db = db
|
||||
|
||||
async def get_all(self) -> List[Course]:
|
||||
query = select(Course)
|
||||
query = (
|
||||
select(Course)
|
||||
.options(
|
||||
selectinload(Course.teachers),
|
||||
selectinload(Course.enrollments)
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_all_for_teacher(self, user_id: int) -> Optional[Sequence[Course]]:
|
||||
query = (
|
||||
select(Course)
|
||||
.options(
|
||||
selectinload(Course.teachers),
|
||||
selectinload(Course.enrollments)
|
||||
)
|
||||
.join(Course.teachers)
|
||||
.filter(CourseTeacher.teacher_id == user_id)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_all_for_enrollment(self, user_id: int) -> Optional[Sequence[Course]]:
|
||||
query = (
|
||||
select(Course)
|
||||
.options(
|
||||
selectinload(Course.teachers),
|
||||
selectinload(Course.enrollments)
|
||||
)
|
||||
.join(Course.enrollments)
|
||||
.filter(Enrollment.student_id == user_id)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_by_id(self, course_id: int) -> Optional[Course]:
|
||||
query = (
|
||||
select(Course)
|
||||
.order_by(id=course_id)
|
||||
.options(
|
||||
selectinload(Course.teachers),
|
||||
selectinload(Course.enrollments)
|
||||
)
|
||||
.filter_by(id=course_id)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
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.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate
|
||||
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate
|
||||
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated
|
||||
from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate
|
||||
from app.domain.models import User
|
||||
from app.infrastructure.course_teachers_service import CourseTeachersService
|
||||
from app.infrastructure.courses_service import CoursesService
|
||||
from app.infrastructure.dependencies import require_auth_user, require_teacher
|
||||
from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin
|
||||
from app.infrastructure.enrollments_service import EnrollmentsService
|
||||
|
||||
courses_router = APIRouter()
|
||||
@ -24,15 +24,44 @@ courses_router = APIRouter()
|
||||
)
|
||||
async def get_all_courses(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_auth_user),
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
courses_service = CoursesService(db)
|
||||
return await courses_service.get_all()
|
||||
|
||||
|
||||
@courses_router.get(
|
||||
'/for-me/',
|
||||
response_model=Optional[List[CourseRead]],
|
||||
summary='Return all for current user',
|
||||
description='Return all current user',
|
||||
)
|
||||
async def get_fors_for_teacher(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_auth_user),
|
||||
):
|
||||
courses_service = CoursesService(db)
|
||||
return await courses_service.get_all_for_me(user)
|
||||
|
||||
|
||||
@courses_router.get(
|
||||
'/{course_id}/',
|
||||
response_model=Optional[CourseRead],
|
||||
summary='Return a specific course',
|
||||
description='Return a specific course',
|
||||
)
|
||||
async def get_course(
|
||||
course_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_auth_user),
|
||||
):
|
||||
courses_service = CoursesService(db)
|
||||
return await courses_service.get_by_id(course_id)
|
||||
|
||||
|
||||
@courses_router.post(
|
||||
'/',
|
||||
response_model=Optional[CourseRead],
|
||||
response_model=Optional[CourseCreated],
|
||||
summary='Create a new course',
|
||||
description='Create a new course',
|
||||
)
|
||||
|
||||
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 fastapi import APIRouter, Depends, Response
|
||||
from fastapi import APIRouter, Depends, status, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_db
|
||||
from app.domain.entities.users import UserRead, UserUpdate, PasswordChangeRequest, UserCreate
|
||||
from app.domain.entities.users import UserRead, UserUpdate, PasswordChangeRequest, UserCreate, UserCheckLessonRead
|
||||
from app.domain.models import User
|
||||
from app.infrastructure.dependencies import require_auth_user, require_admin
|
||||
from app.infrastructure.register_service import RegisterService
|
||||
from app.infrastructure.user_check_lessons_service import UserCheckLessonsService
|
||||
from app.infrastructure.users_service import UsersService
|
||||
|
||||
users_router = APIRouter()
|
||||
@ -101,3 +102,47 @@ async def get_users_by_role_name(
|
||||
):
|
||||
users_service = UsersService(db)
|
||||
return await users_service.get_by_role_name(role_name)
|
||||
|
||||
|
||||
@users_router.get(
|
||||
'/check-my-lessons/{course_id}/',
|
||||
response_model=Optional[List[UserCheckLessonRead]],
|
||||
summary='Return all users with given lessons',
|
||||
description='Return all users with given lessons',
|
||||
)
|
||||
async def get_all_users_in_lessons(
|
||||
course_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_auth_user)
|
||||
):
|
||||
user_check_lessons_service = UserCheckLessonsService(db)
|
||||
return await user_check_lessons_service.get_by_course_id_and_user_id(course_id, user.id)
|
||||
|
||||
|
||||
@users_router.post(
|
||||
'/check-lesson/{lesson_id}/',
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary='Mark lesson as checked',
|
||||
description='Mark lesson as checked',
|
||||
)
|
||||
async def mark_lesson_checked(
|
||||
lesson_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_auth_user)
|
||||
):
|
||||
user_check_lessons_service = UserCheckLessonsService(db)
|
||||
await user_check_lessons_service.create(lesson_id, user.id)
|
||||
|
||||
|
||||
@users_router.get(
|
||||
'/my-progress/{course_id}/',
|
||||
summary='Return user progress',
|
||||
description='Return user progress',
|
||||
)
|
||||
async def get_user_progress(
|
||||
course_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(require_auth_user),
|
||||
):
|
||||
user_check_lessons_service = UserCheckLessonsService(db)
|
||||
return await user_check_lessons_service.calculate_user_progress(course_id, user.id)
|
||||
|
||||
@ -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):
|
||||
course_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
|
||||
|
||||
|
||||
class CourseBase(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
photo_filename: Optional[str] = None
|
||||
photo_path: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CourseCreate(BaseModel):
|
||||
title: str = Field(max_length=250)
|
||||
description: Optional[str] = Field(default=None, max_length=1000)
|
||||
@ -15,13 +26,14 @@ class CourseUpdate(CourseCreate):
|
||||
pass
|
||||
|
||||
|
||||
class CourseRead(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
|
||||
teachers: List[CourseTeacherRead]
|
||||
enrollments: List[EnrollmentRead]
|
||||
class CourseRead(CourseBase):
|
||||
teachers: List[CourseTeacherRead] = []
|
||||
enrollments: List[EnrollmentRead] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CourseCreated(CourseBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class EnrollmentCreate(BaseModel):
|
||||
course_id: int = Field()
|
||||
student_id: int = Field()
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ class EnrollmentRead(BaseModel):
|
||||
id: int
|
||||
course_id: int
|
||||
student_id: int
|
||||
enrollment_date: datetime
|
||||
enrollment_date: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
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:
|
||||
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.lessons import Lesson
|
||||
from app.domain.models.roles import Role
|
||||
from app.domain.models.solution_comments import SolutionComment
|
||||
from app.domain.models.solution_files import SolutionFile
|
||||
from app.domain.models.solutions import Solution
|
||||
from app.domain.models.statuses import Status
|
||||
from app.domain.models.task_files import TaskFile
|
||||
from app.domain.models.tasks import Task
|
||||
from app.domain.models.user_check_lessons import UserCheckLessons
|
||||
from app.domain.models.users import User
|
||||
|
||||
@ -17,8 +17,8 @@ class RootTable(Base):
|
||||
class PhotoAbstract(RootTable):
|
||||
__abstract__ = True
|
||||
|
||||
photo_filename: Mapped[str] = mapped_column()
|
||||
photo_path: Mapped[str] = mapped_column()
|
||||
photo_filename: Mapped[str] = mapped_column(nullable=True)
|
||||
photo_path: Mapped[str] = mapped_column(nullable=True)
|
||||
|
||||
|
||||
class FileAbstract(RootTable):
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.domain.models.base import RootTable
|
||||
|
||||
@ -18,6 +18,7 @@ class Lesson(RootTable):
|
||||
creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||
|
||||
course: Mapped['Course'] = relationship('Course', back_populates='lessons')
|
||||
creator: Mapped['User'] = relationship('User', back_populates='created_lessons')
|
||||
creator: Mapped['User'] = relationship('User', back_populates='created_lessons', lazy='joined')
|
||||
|
||||
files: Mapped[List['LessonFile']] = relationship('LessonFile', back_populates='lesson')
|
||||
user_check_lessons: Mapped[List['UserCheckLessons']] = relationship('UserCheckLessons', back_populates='lesson')
|
||||
|
||||
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'
|
||||
|
||||
answer_text: Mapped[str] = mapped_column(nullable=True)
|
||||
assessment_text: Mapped[str] = mapped_column(String(50), nullable=True)
|
||||
assessment: Mapped[int] = mapped_column(nullable=True)
|
||||
|
||||
assessment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=True)
|
||||
task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id'), nullable=False)
|
||||
student_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||
|
||||
assessment_autor: Mapped['User'] = relationship('User', back_populates='assessments',
|
||||
foreign_keys=[assessment_autor_id])
|
||||
foreign_keys=[assessment_autor_id], lazy='joined')
|
||||
student: Mapped['User'] = relationship('User', back_populates='my_solutions', foreign_keys=[student_id])
|
||||
|
||||
files: Mapped[List['SolutionFile']] = relationship('SolutionFile', back_populates='solution')
|
||||
solution_comments: Mapped[List['SolutionComment']] = relationship(
|
||||
'SolutionComment',
|
||||
back_populates='solution',
|
||||
)
|
||||
|
||||
@ -18,6 +18,6 @@ class Task(RootTable):
|
||||
creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||
|
||||
course: Mapped['Course'] = relationship('Course', back_populates='tasks')
|
||||
creator: Mapped['User'] = relationship('User', back_populates='created_tasks')
|
||||
creator: Mapped['User'] = relationship('User', back_populates='created_tasks', lazy='joined')
|
||||
|
||||
files: Mapped[List['TaskFile']] = relationship('TaskFile', back_populates='task')
|
||||
|
||||
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',
|
||||
foreign_keys=[Solution.student_id],
|
||||
)
|
||||
from app.domain.models.solution_comments import SolutionComment
|
||||
solution_comments: Mapped[List['SolutionComment']] = relationship(
|
||||
'SolutionComment',
|
||||
back_populates='comment_autor',
|
||||
)
|
||||
from app.domain.models.user_check_lessons import UserCheckLessons
|
||||
user_check_lessons: Mapped[List['UserCheckLessons']] = relationship(
|
||||
'UserCheckLessons',
|
||||
back_populates='user',
|
||||
)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
@ -4,13 +4,16 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.courses_repository import CoursesRepository
|
||||
from app.domain.entities.courses import CourseRead, CourseCreate
|
||||
from app.domain.models import Course
|
||||
from app.core.constants import UserRoles
|
||||
from app.domain.entities.courses import CourseRead, CourseCreate, CourseCreated
|
||||
from app.domain.models import Course, User
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
class CoursesService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.courses_repository = CoursesRepository(db)
|
||||
self.settings = Settings()
|
||||
|
||||
async def get_all(self) -> List[CourseRead]:
|
||||
courses = await self.courses_repository.get_all()
|
||||
@ -22,7 +25,35 @@ class CoursesService:
|
||||
|
||||
return response
|
||||
|
||||
async def create(self, course: CourseCreate) -> Optional[CourseRead]:
|
||||
async def get_all_for_me(self, user: User) -> Optional[List[CourseRead]]:
|
||||
if user.role.title == self.settings.root_role_name:
|
||||
courses = await self.courses_repository.get_all()
|
||||
|
||||
elif user.role.title == UserRoles.TEACHER:
|
||||
courses = await self.courses_repository.get_all_for_teacher(user.id)
|
||||
|
||||
elif user.role.title == UserRoles.STUDENT:
|
||||
courses = await self.courses_repository.get_all_for_enrollment(user.id)
|
||||
|
||||
else:
|
||||
courses = []
|
||||
|
||||
response = []
|
||||
for course in courses:
|
||||
response.append(
|
||||
CourseRead.model_validate(course)
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
async def get_by_id(self, course_id: int) -> Optional[CourseRead]:
|
||||
course = await self.courses_repository.get_by_id(course_id)
|
||||
if course is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Курс с таким Id не найден')
|
||||
|
||||
return CourseRead.model_validate(course)
|
||||
|
||||
async def create(self, course: CourseCreate) -> CourseCreated: # ← возвращаем CourseCreated
|
||||
course_model = Course(
|
||||
title=course.title,
|
||||
description=course.description,
|
||||
@ -30,7 +61,7 @@ class CoursesService:
|
||||
|
||||
course_model = await self.courses_repository.create(course_model)
|
||||
|
||||
return CourseRead.model_validate(course_model)
|
||||
return CourseCreated.model_validate(course_model)
|
||||
|
||||
async def update(self, course_id: int, course: CourseCreate) -> Optional[CourseRead]:
|
||||
course_model = await self.courses_repository.get_by_id(course_id)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
@ -52,7 +53,7 @@ class EnrollmentsService:
|
||||
enrollments_models.append(Enrollment(
|
||||
course_id=course_id,
|
||||
student_id=enrollment.student_id,
|
||||
enrollment_date=enrollment.enrollment_date,
|
||||
enrollment_date=datetime.datetime.now(),
|
||||
))
|
||||
|
||||
enrollments_models = await self.enrollments_repository.create_list(enrollments_models)
|
||||
|
||||
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.courses_router import courses_router
|
||||
from app.controllers.lessons_router import lessons_router
|
||||
from app.controllers.register_router import register_router
|
||||
from app.controllers.roles_router import roles_router
|
||||
from app.controllers.solution_comments_router import solution_comments_router
|
||||
from app.controllers.solutions_router import solution_router
|
||||
from app.controllers.statuses_router import statuses_router
|
||||
from app.controllers.tasks_router import tasks_router
|
||||
from app.controllers.users_router import users_router
|
||||
from app.settings import Settings
|
||||
|
||||
@ -24,9 +28,13 @@ def start_app():
|
||||
|
||||
api_app.include_router(auth_router, prefix=f'{settings.prefix}/auth', tags=['auth'])
|
||||
api_app.include_router(courses_router, prefix=f'{settings.prefix}/courses', tags=['courses'])
|
||||
api_app.include_router(lessons_router, prefix=f'{settings.prefix}/lessons', tags=['lessons'])
|
||||
api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register'])
|
||||
api_app.include_router(roles_router, prefix=f'{settings.prefix}/roles', tags=['roles'])
|
||||
api_app.include_router(solution_comments_router, prefix=f'{settings.prefix}/comments', tags=['comments'])
|
||||
api_app.include_router(solution_router, prefix=f'{settings.prefix}/solutions', tags=['solutions'])
|
||||
api_app.include_router(statuses_router, prefix=f'{settings.prefix}/statuses', tags=['statuses'])
|
||||
api_app.include_router(tasks_router, prefix=f'{settings.prefix}/tasks', tags=['tasks'])
|
||||
api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users'])
|
||||
|
||||
return api_app
|
||||
|
||||
@ -7,3 +7,4 @@ werkzeug==3.1.3
|
||||
pyjwt==2.9.0
|
||||
fastapi==0.115.0
|
||||
pydantic[email]==2.11.4
|
||||
aiofiles==25.1.0
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/rounded_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
<title>Система обучения lectio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
20
web/package-lock.json
generated
20
web/package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@reduxjs/toolkit": "^2.11.0",
|
||||
"antd": "^6.0.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"jodit-react": "^5.2.38",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@ -3134,6 +3135,25 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jodit": {
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/jodit/-/jodit-4.7.9.tgz",
|
||||
"integrity": "sha512-dPlewnu2+vVXELh9BEJTNQoSBL3Cf2E0fNh30yjSEgUtJHYSUYToDjMDEqr0T/L9iNpqPTODy2b4pzyGpPRUog==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jodit-react": {
|
||||
"version": "5.2.38",
|
||||
"resolved": "https://registry.npmjs.org/jodit-react/-/jodit-react-5.2.38.tgz",
|
||||
"integrity": "sha512-k98vjch0JWX13Dlf7tWv3wPo6dgO9bpi0NUBC6YrpGLYtRJx8d5Ttz5TLWuydEReedWWyNKobSGIGYQDihP8Vw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jodit": "^4.7.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "~0.14 || ^15 || ^16 || ^17 || ^18 || ^19",
|
||||
"react-dom": "~0.14 || ^15 || ^16 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"@reduxjs/toolkit": "^2.11.0",
|
||||
"antd": "^6.0.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"jodit-react": "^5.2.38",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-redux": "^9.2.0",
|
||||
|
||||
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'],
|
||||
}),
|
||||
getAllMyCourses: builder.query({
|
||||
query: () => ({
|
||||
url: "/courses/for-me/",
|
||||
method: "GET",
|
||||
}),
|
||||
providesTags: ['course'],
|
||||
}),
|
||||
getCourseById: builder.query({
|
||||
query: (courseId) => ({
|
||||
url: `/courses/${courseId}/`,
|
||||
method: "GET",
|
||||
}),
|
||||
providesTags: ['course'],
|
||||
}),
|
||||
createCourse: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: "/courses/",
|
||||
@ -38,10 +52,10 @@ export const coursesApi = createApi({
|
||||
providesTags: ['teacher'],
|
||||
}),
|
||||
replaceCourseTeachers: builder.mutation({
|
||||
query: ({courseId, ...data}) => ({
|
||||
query: ({courseId, teachers}) => ({
|
||||
url: `/courses/${courseId}/teachers/`,
|
||||
method: "PUT",
|
||||
body: data,
|
||||
body: teachers,
|
||||
}),
|
||||
invalidatesTags: ['teacher'],
|
||||
}),
|
||||
@ -53,10 +67,10 @@ export const coursesApi = createApi({
|
||||
providesTags: ['student'],
|
||||
}),
|
||||
replaceCourseStudents: builder.mutation({
|
||||
query: ({courseId, ...data}) => ({
|
||||
query: ({courseId, students}) => ({
|
||||
url: `/courses/${courseId}/students/`,
|
||||
method: "PUT",
|
||||
body: data,
|
||||
body: students,
|
||||
}),
|
||||
invalidatesTags: ['student'],
|
||||
}),
|
||||
@ -65,6 +79,8 @@ export const coursesApi = createApi({
|
||||
|
||||
export const {
|
||||
useGetAllCoursesQuery,
|
||||
useGetAllMyCoursesQuery,
|
||||
useGetCourseByIdQuery,
|
||||
useCreateCourseMutation,
|
||||
useUpdateCourseMutation,
|
||||
useGetCourseTeachersQuery,
|
||||
|
||||
104
web/src/Api/lessonsApi.js
Normal file
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"],
|
||||
}),
|
||||
getReadedLessonsByCourse: builder.query({
|
||||
query: (courseId) => ({
|
||||
url: `/users/check-my-lessons/${courseId}/`,
|
||||
method: "GET",
|
||||
}),
|
||||
providesTags: ["user"],
|
||||
}),
|
||||
setLessonAsReaded: builder.mutation({
|
||||
query: (lessonId) => ({
|
||||
url: `/users/check-lesson/${lessonId}/`,
|
||||
method: "POST",
|
||||
}),
|
||||
invalidatesTags: ["user"],
|
||||
}),
|
||||
getMyCourseProgress: builder.query({
|
||||
query: (courseId) => ({
|
||||
url: `/users/my-progress/${courseId}/`,
|
||||
method: "GET",
|
||||
}),
|
||||
providesTags: ["user"],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -56,4 +77,7 @@ export const {
|
||||
useUpdateUserPasswordMutation,
|
||||
useCreateUserMutation,
|
||||
useGetUsersByRoleNameQuery,
|
||||
useGetReadedLessonsByCourseQuery,
|
||||
useSetLessonAsReadedMutation,
|
||||
useGetMyCourseProgressQuery,
|
||||
} = usersApi;
|
||||
@ -7,6 +7,7 @@ import CoursesPage from "../Components/Pages/Courses/CoursesPage.jsx";
|
||||
import MainLayout from "../Components/Layouts/MainLayout.jsx";
|
||||
import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx";
|
||||
import AdminPage from "../Components/Pages/AdminPage/AdminPage.jsx";
|
||||
import CourseDetailPage from "../Components/Pages/CourseDetailPage/CourseDetailPage.jsx";
|
||||
|
||||
|
||||
const AppRouter = () => (
|
||||
@ -18,6 +19,7 @@ const AppRouter = () => (
|
||||
<Route element={<MainLayout/>}>
|
||||
<Route path={"/courses"} element={<CoursesPage/>}/>
|
||||
<Route path={"/profile"} element={<ProfilePage/>}/>
|
||||
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/courses"}/>}/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import useMainLayout from "./useMainLayout.js";
|
||||
import {Layout, Menu} from "antd";
|
||||
import CoursesPage from "../Pages/Courses/CoursesPage.jsx";
|
||||
import CoursesPage from "../Pages/CoursesPage/CoursesPage.jsx";
|
||||
import LoadingIndicator from "../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
||||
import {Outlet} from "react-router-dom";
|
||||
import {BookOutlined, ControlOutlined, LogoutOutlined, UserOutlined} from "@ant-design/icons";
|
||||
@ -79,7 +79,7 @@ const MainLayout = () => {
|
||||
<Outlet/>
|
||||
)}
|
||||
</Content>
|
||||
<Footer style={{textAlign: "center"}}>{new Date().getFullYear()}</Footer>
|
||||
<Footer style={{textAlign: "center"}}>lectio © {new Date().getFullYear()}</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useGetAllUsersQuery, useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
|
||||
import {useGetAllRolesQuery} from "../../../Api/rolesApi.js";
|
||||
import {useMemo, useState} from "react";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {useDispatch} from "react-redux";
|
||||
import {setOpenModalCreateUser, setSelectedUserToUpdate} from "../../../Redux/Slices/usersSlice.js";
|
||||
|
||||
@ -18,6 +18,10 @@ const useAdminPage = () => {
|
||||
setSearchString,
|
||||
] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
window.document.title = "Система обучения lectio - Панель администратора";
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: usersData = [],
|
||||
isLoading: usersIsLoading,
|
||||
|
||||
@ -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 {setOpenCreateCourseModal} from "../../../../../Redux/Slices/coursesSlice.js";
|
||||
import {Form} from "antd";
|
||||
import {Form, notification} from "antd";
|
||||
import {useGetUsersByRoleNameQuery} from "../../../../../Api/usersApi.js";
|
||||
import {
|
||||
useCreateCourseMutation,
|
||||
@ -43,8 +43,55 @@ const useCreateCourseModalForm = () => {
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
dispatch(setOpenCreateCourseModal(false));
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
const newCourse = await createCourse({
|
||||
title: values.title,
|
||||
description: values.description || null,
|
||||
}).unwrap();
|
||||
|
||||
if (values.teacher_ids?.length > 0) {
|
||||
const teachersPayload = values.teacher_ids.map(id => ({
|
||||
teacher_id: id
|
||||
}));
|
||||
|
||||
await replaceTeachers({
|
||||
courseId: newCourse.id,
|
||||
teachers: teachersPayload
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
if (values.student_ids?.length > 0) {
|
||||
const studentsPayload = values.student_ids.map(id => ({
|
||||
student_id: id,
|
||||
}));
|
||||
await replaceStudents({
|
||||
courseId: newCourse.id,
|
||||
students: studentsPayload
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
notification.success({
|
||||
title: "Успешно",
|
||||
description: "Курс успешно создан!",
|
||||
placement: "topRight",
|
||||
});
|
||||
|
||||
form.resetFields();
|
||||
dispatch(setOpenCreateCourseModal(false));
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
title: "Ошибка",
|
||||
description: error?.data?.detail || "Не удалось создать курс.",
|
||||
placement: "topRight",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
openCreateCourseModal,
|
||||
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,
|
||||
Tag,
|
||||
Typography,
|
||||
Avatar, Result, FloatButton, Tooltip,
|
||||
Avatar, Result, FloatButton, Tooltip, Progress, Divider,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
@ -18,10 +18,15 @@ import {
|
||||
import useCoursesPage from "./useCoursesPage.js";
|
||||
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
||||
import CreateCourseModalForm from "./Components/CreateCourseModalForm/CreateCourseModalForm.jsx";
|
||||
import UpdateCourseModalForm from "./Components/UpdateCourseModalForm/UpdateCourseModalForm.jsx";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {useEffect, useState} from "react";
|
||||
import CONFIG from "../../../Core/сonfig.js";
|
||||
|
||||
const {Title, Text} = Typography;
|
||||
|
||||
const CoursesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
courses,
|
||||
isLoading,
|
||||
@ -32,6 +37,38 @@ const CoursesPage = () => {
|
||||
openEditModal,
|
||||
} = useCoursesPage();
|
||||
|
||||
|
||||
const [courseProgress, setCourseProgress] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (courses.length === 0) return;
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (!token) return;
|
||||
|
||||
const fetchProgress = async () => {
|
||||
const progress = {};
|
||||
for (const course of courses) {
|
||||
try {
|
||||
const res = await fetch(`${CONFIG.BASE_URL}/users/my-progress/${course.id}/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
progress[course.id] = data; // data — это число, например 87.5
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Progress fetch error for course", course.id);
|
||||
}
|
||||
}
|
||||
setCourseProgress(progress);
|
||||
};
|
||||
|
||||
fetchProgress();
|
||||
}, [courses]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoadingIndicator/>
|
||||
@ -80,6 +117,7 @@ const CoursesPage = () => {
|
||||
color: "white",
|
||||
fontSize: 48,
|
||||
}}
|
||||
onClick={() => navigate(`/courses/${course.id}`)}
|
||||
>
|
||||
{course.title[0].toUpperCase()}
|
||||
</div>
|
||||
@ -98,38 +136,28 @@ const CoursesPage = () => {
|
||||
}
|
||||
>
|
||||
<Card.Meta
|
||||
onClick={() => navigate(`/courses/${course.id}`)}
|
||||
title={<Title level={4}>{course.title}</Title>}
|
||||
description={
|
||||
course.description || <Text type="secondary">Без описания</Text>
|
||||
}
|
||||
/>
|
||||
<Space direction="vertical" style={{width: "100%", marginTop: 16}}>
|
||||
<Space>
|
||||
<TeamOutlined/>
|
||||
<Text>
|
||||
{course.teachers?.length || 0} учител
|
||||
{course.teachers?.length === 1 ? "ь" : "ей"}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<UserOutlined/>
|
||||
<Text>
|
||||
{course.enrollments?.length || 0} студент
|
||||
{course.enrollments?.length === 1 ? "" : "ов"}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{course.teachers?.length > 0 && (
|
||||
<div style={{marginTop: 16}}>
|
||||
<Text type="secondary">Преподаватели:</Text>
|
||||
<Avatar.Group maxCount={3} style={{marginTop: 8}}>
|
||||
<Avatar.Group max={{count: 3}} style={{marginTop: 8}}>
|
||||
{course.teachers.map((t) => (
|
||||
<Avatar key={t.teacher_id} style={{backgroundColor: "#1890ff"}}>
|
||||
{t.teacher?.first_name?.[0] || "У"}
|
||||
</Avatar>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
<Divider/>
|
||||
<Text type="secondary">Прогресс:</Text>
|
||||
{courseProgress[course.id] !== undefined && (
|
||||
<Progress percent={courseProgress[course.id]} size={[300, 20]}/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@ -147,6 +175,7 @@ const CoursesPage = () => {
|
||||
</Tooltip>
|
||||
|
||||
<CreateCourseModalForm/>
|
||||
<UpdateCourseModalForm/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,16 +1,19 @@
|
||||
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
|
||||
import {useGetAllCoursesQuery} from "../../../Api/coursesApi.js";
|
||||
import {useGetAuthenticatedUserDataQuery, useGetMyCourseProgressQuery} from "../../../Api/usersApi.js";
|
||||
import {useGetAllCoursesQuery, useGetAllMyCoursesQuery} from "../../../Api/coursesApi.js";
|
||||
import CONFIG from "../../../Core/сonfig.js";
|
||||
import {ROLES} from "../../../Core/constants.js";
|
||||
import {useDispatch} from "react-redux";
|
||||
import {setOpenCreateCourseModal} from "../../../Redux/Slices/coursesSlice.js";
|
||||
import {setOpenCreateCourseModal, setSelectedCourseToUpdate} from "../../../Redux/Slices/coursesSlice.js";
|
||||
import {useEffect} from "react";
|
||||
|
||||
|
||||
const useCoursesPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery();
|
||||
const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllCoursesQuery();
|
||||
const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllMyCoursesQuery(undefined, {
|
||||
pollingInterval: 20000,
|
||||
});
|
||||
|
||||
const isAdmin = userData?.role?.title === CONFIG.ROOT_ROLE_NAME;
|
||||
const isTeacher = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
|
||||
@ -19,10 +22,14 @@ const useCoursesPage = () => {
|
||||
dispatch(setOpenCreateCourseModal(true));
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
dispatch(setOpenCreateCourseModal(false));
|
||||
const openEditModal = (course) => {
|
||||
dispatch(setSelectedCourseToUpdate(course));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.document.title = "Система обучения lectio - Курсы";
|
||||
}, []);
|
||||
|
||||
return {
|
||||
courses,
|
||||
isLoading: isCoursesLoading || isUserLoading || isLoading,
|
||||
@ -30,7 +37,7 @@ const useCoursesPage = () => {
|
||||
isAdmin,
|
||||
isTeacher,
|
||||
openCreateModal,
|
||||
closeModal: closeCreateModal,
|
||||
openEditModal,
|
||||
};
|
||||
};
|
||||
|
||||
@ -26,7 +26,7 @@ const LoginPage = () => {
|
||||
hasRedirected.current = true;
|
||||
navigate("/");
|
||||
}
|
||||
document.title = "Аутентификация";
|
||||
document.title = "Система обучения lectio - Аутентификация";
|
||||
}, [user, userData, isLoading, navigate]);
|
||||
|
||||
const onFinish = async (loginData) => {
|
||||
|
||||
@ -13,7 +13,7 @@ const useProfilePage = () => {
|
||||
const [passwordForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
window.document.title = "Профиль";
|
||||
window.document.title = "Система обучения lectio - Профиль";
|
||||
}, []);
|
||||
|
||||
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 usersReducer from "./Slices/usersSlice.js";
|
||||
import coursesReducer from "./Slices/coursesSlice.js";
|
||||
import lessonReducer from "./Slices/lessonsSlice.js";
|
||||
import tasksReducer from "./Slices/tasksSlice.js";
|
||||
import {authApi} from "../Api/authApi.js";
|
||||
import {usersApi} from "../Api/usersApi.js";
|
||||
import {rolesApi} from "../Api/rolesApi.js";
|
||||
import {statusesApi} from "../Api/statusesApi.js";
|
||||
import {coursesApi} from "../Api/coursesApi.js";
|
||||
import {lessonsApi} from "../Api/lessonsApi.js";
|
||||
import {tasksApi} from "../Api/tasksApi.js";
|
||||
import {solutionsApi} from "../Api/solutionsApi.js";
|
||||
import {commentsApi} from "../Api/commentsApi.js";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@ -21,7 +27,17 @@ export const store = configureStore({
|
||||
[statusesApi.reducerPath]: statusesApi.reducer,
|
||||
|
||||
courses: coursesReducer,
|
||||
[coursesApi.reducerPath]: coursesApi.reducer
|
||||
[coursesApi.reducerPath]: coursesApi.reducer,
|
||||
|
||||
lessons: lessonReducer,
|
||||
[lessonsApi.reducerPath]: lessonsApi.reducer,
|
||||
|
||||
tasks: tasksReducer,
|
||||
[tasksApi.reducerPath]: tasksApi.reducer,
|
||||
|
||||
[solutionsApi.reducerPath]: solutionsApi.reducer,
|
||||
|
||||
[commentsApi.reducerPath]: commentsApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => (
|
||||
getDefaultMiddleware().concat(
|
||||
@ -29,7 +45,11 @@ export const store = configureStore({
|
||||
usersApi.middleware,
|
||||
rolesApi.middleware,
|
||||
statusesApi.middleware,
|
||||
coursesApi.middleware
|
||||
coursesApi.middleware,
|
||||
lessonsApi.middleware,
|
||||
tasksApi.middleware,
|
||||
solutionsApi.middleware,
|
||||
commentsApi.middleware,
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user