сделал создание и редактирование мета информации курса, и состава участников и учителей

This commit is contained in:
Андрей Дувакин 2025-11-28 20:37:03 +05:00
parent 44ceb93729
commit 9fe0d1e3e9
19 changed files with 513 additions and 115 deletions

View File

@ -2,6 +2,7 @@ from typing import List, Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.domain.models import Course from app.domain.models import Course
@ -11,14 +12,24 @@ class CoursesRepository:
self.db = db self.db = db
async def get_all(self) -> List[Course]: async def get_all(self) -> List[Course]:
query = select(Course) query = (
select(Course)
.options(
selectinload(Course.teachers),
selectinload(Course.enrollments)
)
)
result = await self.db.execute(query) result = await self.db.execute(query)
return result.scalars().all() return result.scalars().all()
async def get_by_id(self, course_id: int) -> Optional[Course]: async def get_by_id(self, course_id: int) -> Optional[Course]:
query = ( query = (
select(Course) select(Course)
.order_by(id=course_id) .options(
selectinload(Course.teachers),
selectinload(Course.enrollments)
)
.filter_by(id=course_id)
) )
result = await self.db.execute(query) result = await self.db.execute(query)
return result.scalars().first() return result.scalars().first()

View File

@ -5,12 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db from app.database.session import get_db
from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated
from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate
from app.domain.models import User from app.domain.models import User
from app.infrastructure.course_teachers_service import CourseTeachersService from app.infrastructure.course_teachers_service import CourseTeachersService
from app.infrastructure.courses_service import CoursesService from app.infrastructure.courses_service import CoursesService
from app.infrastructure.dependencies import require_auth_user, require_teacher from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin
from app.infrastructure.enrollments_service import EnrollmentsService from app.infrastructure.enrollments_service import EnrollmentsService
courses_router = APIRouter() courses_router = APIRouter()
@ -24,7 +24,7 @@ courses_router = APIRouter()
) )
async def get_all_courses( async def get_all_courses(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth_user), user: User = Depends(require_admin),
): ):
courses_service = CoursesService(db) courses_service = CoursesService(db)
return await courses_service.get_all() return await courses_service.get_all()
@ -32,7 +32,7 @@ async def get_all_courses(
@courses_router.post( @courses_router.post(
'/', '/',
response_model=Optional[CourseRead], response_model=Optional[CourseCreated],
summary='Create a new course', summary='Create a new course',
description='Create a new course', description='Create a new course',
) )

View File

@ -0,0 +1,106 @@
"""0002 сделал поле с фото необязательным
Revision ID: 33d77ac5ed79
Revises: 6241a16321b4
Create Date: 2025-11-28 19:23:15.655318
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '33d77ac5ed79'
down_revision: Union[str, Sequence[str], None] = '6241a16321b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', type_='foreignkey')
op.drop_constraint(op.f('course_teachers_course_id_fkey'), 'course_teachers', type_='foreignkey')
op.create_foreign_key(None, 'course_teachers', 'users', ['teacher_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'course_teachers', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
op.alter_column('courses', 'photo_filename',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('courses', 'photo_path',
existing_type=sa.VARCHAR(),
nullable=True)
op.drop_constraint(op.f('enrollments_student_id_fkey'), 'enrollments', type_='foreignkey')
op.drop_constraint(op.f('enrollments_course_id_fkey'), 'enrollments', type_='foreignkey')
op.create_foreign_key(None, 'enrollments', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'enrollments', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', type_='foreignkey')
op.create_foreign_key(None, 'lesson_files', 'lessons', ['lesson_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('lessons_course_id_fkey'), 'lessons', type_='foreignkey')
op.drop_constraint(op.f('lessons_creator_id_fkey'), 'lessons', type_='foreignkey')
op.create_foreign_key(None, 'lessons', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'lessons', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('solution_files_solution_id_fkey'), 'solution_files', type_='foreignkey')
op.create_foreign_key(None, 'solution_files', 'solutions', ['solution_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('solutions_task_id_fkey'), 'solutions', type_='foreignkey')
op.drop_constraint(op.f('solutions_assessment_autor_id_fkey'), 'solutions', type_='foreignkey')
op.drop_constraint(op.f('solutions_student_id_fkey'), 'solutions', type_='foreignkey')
op.create_foreign_key(None, 'solutions', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'solutions', 'users', ['assessment_autor_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'solutions', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('task_files_task_id_fkey'), 'task_files', type_='foreignkey')
op.create_foreign_key(None, 'task_files', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('tasks_creator_id_fkey'), 'tasks', type_='foreignkey')
op.drop_constraint(op.f('tasks_course_id_fkey'), 'tasks', type_='foreignkey')
op.create_foreign_key(None, 'tasks', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'tasks', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('users_role_id_fkey'), 'users', type_='foreignkey')
op.drop_constraint(op.f('users_status_id_fkey'), 'users', type_='foreignkey')
op.create_foreign_key(None, 'users', 'statuses', ['status_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', schema='public', type_='foreignkey')
op.drop_constraint(None, 'users', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('users_status_id_fkey'), 'users', 'statuses', ['status_id'], ['id'])
op.create_foreign_key(op.f('users_role_id_fkey'), 'users', 'roles', ['role_id'], ['id'])
op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey')
op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('tasks_course_id_fkey'), 'tasks', 'courses', ['course_id'], ['id'])
op.create_foreign_key(op.f('tasks_creator_id_fkey'), 'tasks', 'users', ['creator_id'], ['id'])
op.drop_constraint(None, 'task_files', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('task_files_task_id_fkey'), 'task_files', 'tasks', ['task_id'], ['id'])
op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey')
op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey')
op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('solutions_student_id_fkey'), 'solutions', 'users', ['student_id'], ['id'])
op.create_foreign_key(op.f('solutions_assessment_autor_id_fkey'), 'solutions', 'users', ['assessment_autor_id'], ['id'])
op.create_foreign_key(op.f('solutions_task_id_fkey'), 'solutions', 'tasks', ['task_id'], ['id'])
op.drop_constraint(None, 'solution_files', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('solution_files_solution_id_fkey'), 'solution_files', 'solutions', ['solution_id'], ['id'])
op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey')
op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('lessons_creator_id_fkey'), 'lessons', 'users', ['creator_id'], ['id'])
op.create_foreign_key(op.f('lessons_course_id_fkey'), 'lessons', 'courses', ['course_id'], ['id'])
op.drop_constraint(None, 'lesson_files', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', 'lessons', ['lesson_id'], ['id'])
op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('enrollments_course_id_fkey'), 'enrollments', 'courses', ['course_id'], ['id'])
op.create_foreign_key(op.f('enrollments_student_id_fkey'), 'enrollments', 'users', ['student_id'], ['id'])
op.alter_column('courses', 'photo_path',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('courses', 'photo_filename',
existing_type=sa.VARCHAR(),
nullable=False)
op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey')
op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('course_teachers_course_id_fkey'), 'course_teachers', 'courses', ['course_id'], ['id'])
op.create_foreign_key(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', 'users', ['teacher_id'], ['id'])
# ### end Alembic commands ###

View File

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

View File

@ -6,6 +6,17 @@ from app.domain.entities.course_teachers import CourseTeacherRead
from app.domain.entities.enrollments import EnrollmentRead from app.domain.entities.enrollments import EnrollmentRead
class CourseBase(BaseModel):
id: int
title: str
description: Optional[str] = None
photo_filename: Optional[str] = None
photo_path: Optional[str] = None
class Config:
from_attributes = True
class CourseCreate(BaseModel): class CourseCreate(BaseModel):
title: str = Field(max_length=250) title: str = Field(max_length=250)
description: Optional[str] = Field(default=None, max_length=1000) description: Optional[str] = Field(default=None, max_length=1000)
@ -15,13 +26,14 @@ class CourseUpdate(CourseCreate):
pass pass
class CourseRead(BaseModel): class CourseRead(CourseBase):
id: int teachers: List[CourseTeacherRead] = []
title: str enrollments: List[EnrollmentRead] = []
description: str
teachers: List[CourseTeacherRead]
enrollments: List[EnrollmentRead]
class Config: class Config:
from_attributes = True from_attributes = True
class CourseCreated(CourseBase):
class Config:
from_attributes = True

View File

@ -1,10 +1,10 @@
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
class EnrollmentCreate(BaseModel): class EnrollmentCreate(BaseModel):
course_id: int = Field()
student_id: int = Field() student_id: int = Field()
@ -12,7 +12,7 @@ class EnrollmentRead(BaseModel):
id: int id: int
course_id: int course_id: int
student_id: int student_id: int
enrollment_date: datetime enrollment_date: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -17,8 +17,8 @@ class RootTable(Base):
class PhotoAbstract(RootTable): class PhotoAbstract(RootTable):
__abstract__ = True __abstract__ = True
photo_filename: Mapped[str] = mapped_column() photo_filename: Mapped[str] = mapped_column(nullable=True)
photo_path: Mapped[str] = mapped_column() photo_path: Mapped[str] = mapped_column(nullable=True)
class FileAbstract(RootTable): class FileAbstract(RootTable):

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.domain.models.base import RootTable from app.domain.models.base import RootTable

View File

@ -4,7 +4,7 @@ from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.courses_repository import CoursesRepository from app.application.courses_repository import CoursesRepository
from app.domain.entities.courses import CourseRead, CourseCreate from app.domain.entities.courses import CourseRead, CourseCreate, CourseCreated
from app.domain.models import Course from app.domain.models import Course
@ -22,7 +22,7 @@ class CoursesService:
return response return response
async def create(self, course: CourseCreate) -> Optional[CourseRead]: async def create(self, course: CourseCreate) -> CourseCreated: # ← возвращаем CourseCreated
course_model = Course( course_model = Course(
title=course.title, title=course.title,
description=course.description, description=course.description,
@ -30,7 +30,7 @@ class CoursesService:
course_model = await self.courses_repository.create(course_model) course_model = await self.courses_repository.create(course_model)
return CourseRead.model_validate(course_model) return CourseCreated.model_validate(course_model)
async def update(self, course_id: int, course: CourseCreate) -> Optional[CourseRead]: async def update(self, course_id: int, course: CourseCreate) -> Optional[CourseRead]:
course_model = await self.courses_repository.get_by_id(course_id) course_model = await self.courses_repository.get_by_id(course_id)

View File

@ -1,3 +1,4 @@
import datetime
from typing import Optional, List from typing import Optional, List
from fastapi import HTTPException, status from fastapi import HTTPException, status
@ -52,7 +53,7 @@ class EnrollmentsService:
enrollments_models.append(Enrollment( enrollments_models.append(Enrollment(
course_id=course_id, course_id=course_id,
student_id=enrollment.student_id, student_id=enrollment.student_id,
enrollment_date=enrollment.enrollment_date, enrollment_date=datetime.datetime.now(),
)) ))
enrollments_models = await self.enrollments_repository.create_list(enrollments_models) enrollments_models = await self.enrollments_repository.create_list(enrollments_models)

View File

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

View File

@ -38,10 +38,10 @@ export const coursesApi = createApi({
providesTags: ['teacher'], providesTags: ['teacher'],
}), }),
replaceCourseTeachers: builder.mutation({ replaceCourseTeachers: builder.mutation({
query: ({courseId, ...data}) => ({ query: ({courseId, teachers}) => ({
url: `/courses/${courseId}/teachers/`, url: `/courses/${courseId}/teachers/`,
method: "PUT", method: "PUT",
body: data, body: teachers,
}), }),
invalidatesTags: ['teacher'], invalidatesTags: ['teacher'],
}), }),
@ -53,10 +53,10 @@ export const coursesApi = createApi({
providesTags: ['student'], providesTags: ['student'],
}), }),
replaceCourseStudents: builder.mutation({ replaceCourseStudents: builder.mutation({
query: ({courseId, ...data}) => ({ query: ({courseId, students}) => ({
url: `/courses/${courseId}/students/`, url: `/courses/${courseId}/students/`,
method: "PUT", method: "PUT",
body: data, body: students,
}), }),
invalidatesTags: ['student'], invalidatesTags: ['student'],
}), }),

View File

@ -79,7 +79,7 @@ const MainLayout = () => {
<Outlet/> <Outlet/>
)} )}
</Content> </Content>
<Footer style={{textAlign: "center"}}>{new Date().getFullYear()}</Footer> <Footer style={{textAlign: "center"}}>lectio © {new Date().getFullYear()}</Footer>
</Layout> </Layout>
</Layout> </Layout>
) )

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import {
import useCoursesPage from "./useCoursesPage.js"; import useCoursesPage from "./useCoursesPage.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateCourseModalForm from "./Components/CreateCourseModalForm/CreateCourseModalForm.jsx"; import CreateCourseModalForm from "./Components/CreateCourseModalForm/CreateCourseModalForm.jsx";
import UpdateCourseModalForm from "./Components/UpdateCourseModalForm/UpdateCourseModalForm.jsx";
const {Title, Text} = Typography; const {Title, Text} = Typography;
@ -103,27 +104,11 @@ const CoursesPage = () => {
course.description || <Text type="secondary">Без описания</Text> course.description || <Text type="secondary">Без описания</Text>
} }
/> />
<Space direction="vertical" style={{width: "100%", marginTop: 16}}>
<Space>
<TeamOutlined/>
<Text>
{course.teachers?.length || 0} учител
{course.teachers?.length === 1 ? "ь" : "ей"}
</Text>
</Space>
<Space>
<UserOutlined/>
<Text>
{course.enrollments?.length || 0} студент
{course.enrollments?.length === 1 ? "" : "ов"}
</Text>
</Space>
</Space>
{course.teachers?.length > 0 && ( {course.teachers?.length > 0 && (
<div style={{marginTop: 16}}> <div style={{marginTop: 16}}>
<Text type="secondary">Преподаватели:</Text> <Text type="secondary">Преподаватели:</Text>
<Avatar.Group maxCount={3} style={{marginTop: 8}}> <Avatar.Group max={{count: 3}} style={{marginTop: 8}}>
{course.teachers.map((t) => ( {course.teachers.map((t) => (
<Avatar key={t.teacher_id} style={{backgroundColor: "#1890ff"}}> <Avatar key={t.teacher_id} style={{backgroundColor: "#1890ff"}}>
{t.teacher?.first_name?.[0] || "У"} {t.teacher?.first_name?.[0] || "У"}
@ -147,6 +132,7 @@ const CoursesPage = () => {
</Tooltip> </Tooltip>
<CreateCourseModalForm/> <CreateCourseModalForm/>
<UpdateCourseModalForm/>
</div> </div>
); );
}; };

View File

@ -3,14 +3,16 @@ import {useGetAllCoursesQuery} from "../../../Api/coursesApi.js";
import CONFIG from "../../../Core/сonfig.js"; import CONFIG from "../../../Core/сonfig.js";
import {ROLES} from "../../../Core/constants.js"; import {ROLES} from "../../../Core/constants.js";
import {useDispatch} from "react-redux"; import {useDispatch} from "react-redux";
import {setOpenCreateCourseModal} from "../../../Redux/Slices/coursesSlice.js"; import {setOpenCreateCourseModal, setSelectedCourseToUpdate} from "../../../Redux/Slices/coursesSlice.js";
const useCoursesPage = () => { const useCoursesPage = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery(); const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery();
const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllCoursesQuery(); const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllCoursesQuery(undefined, {
pollingInterval: 20000,
});
const isAdmin = userData?.role?.title === CONFIG.ROOT_ROLE_NAME; const isAdmin = userData?.role?.title === CONFIG.ROOT_ROLE_NAME;
const isTeacher = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title); const isTeacher = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
@ -19,8 +21,8 @@ const useCoursesPage = () => {
dispatch(setOpenCreateCourseModal(true)); dispatch(setOpenCreateCourseModal(true));
}; };
const closeCreateModal = () => { const openEditModal = (course) => {
dispatch(setOpenCreateCourseModal(false)); dispatch(setSelectedCourseToUpdate(course));
}; };
return { return {
@ -30,7 +32,7 @@ const useCoursesPage = () => {
isAdmin, isAdmin,
isTeacher, isTeacher,
openCreateModal, openCreateModal,
closeModal: closeCreateModal, openEditModal,
}; };
}; };