заготовка под управление курсами

This commit is contained in:
Андрей Дувакин 2025-11-28 17:25:29 +05:00
parent bb6537533b
commit 44ceb93729
17 changed files with 500 additions and 14 deletions

View File

@ -3,6 +3,7 @@ from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Role
from app.domain.models.users import User
@ -31,6 +32,15 @@ class UsersRepository:
result = await self.db.execute(query)
return result.scalars().first()
async def get_by_role_name(self, role_name: str) -> List[User]:
query = (
select(User)
.join(User.role)
.filter(Role.title == role_name)
)
result = await self.db.execute(query)
return result.scalars().all()
async def create(self, user: User) -> User:
self.db.add(user)
await self.db.commit()

View File

@ -86,3 +86,18 @@ async def create_user(
):
register_service = RegisterService(db)
return await register_service.create_user(user)
@users_router.get(
'/role/{role_name}/',
response_model=List[UserRead],
summary='Return all users with given role',
description='Return all users with given role',
)
async def get_users_by_role_name(
role_name: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_auth_user),
):
users_service = UsersService(db)
return await users_service.get_by_role_name(role_name)

View File

@ -95,3 +95,11 @@ class UsersService:
user_model = await self.users_repository.update(user_model)
return UserRead.model_validate(user_model)
async def get_by_role_name(self, role_name: str) -> List[UserRead]:
users = await self.users_repository.get_by_role_name(role_name)
response = []
for user in users:
response.append(UserRead.model_validate(user))
return response

74
web/src/Api/coursesApi.js Normal file
View File

@ -0,0 +1,74 @@
import CONFIG from "../Core/сonfig.js";
import {baseQueryWithAuth} from "./baseQuery.js";
import {createApi} from "@reduxjs/toolkit/query/react";
export const coursesApi = createApi({
reducerPath: 'coursesApi',
baseQuery: baseQueryWithAuth,
endpoints: (builder) => ({
getAllCourses: builder.query({
query: () => ({
url: "/courses/",
method: "GET",
}),
providesTags: ['course'],
}),
createCourse: builder.mutation({
query: (data) => ({
url: "/courses/",
method: "POST",
body: data,
}),
invalidatesTags: ['course'],
}),
updateCourse: builder.mutation({
query: ({courseId, ...data}) => ({
url: `/courses/${courseId}/`,
method: "PUT",
body: data,
}),
invalidatesTags: ['course'],
}),
getCourseTeachers: builder.query({
query: (courseId) => ({
url: `/courses/${courseId}/teachers/`,
method: "GET",
}),
providesTags: ['teacher'],
}),
replaceCourseTeachers: builder.mutation({
query: ({courseId, ...data}) => ({
url: `/courses/${courseId}/teachers/`,
method: "PUT",
body: data,
}),
invalidatesTags: ['teacher'],
}),
getCourseStudents: builder.query({
query: (courseId) => ({
url: `/courses/${courseId}/students/`,
method: "GET",
}),
providesTags: ['student'],
}),
replaceCourseStudents: builder.mutation({
query: ({courseId, ...data}) => ({
url: `/courses/${courseId}/students/`,
method: "PUT",
body: data,
}),
invalidatesTags: ['student'],
}),
}),
});
export const {
useGetAllCoursesQuery,
useCreateCourseMutation,
useUpdateCourseMutation,
useGetCourseTeachersQuery,
useReplaceCourseTeachersMutation,
useGetCourseStudentsQuery,
useReplaceCourseStudentsMutation,
} = coursesApi;

View File

@ -39,6 +39,13 @@ export const usersApi = createApi({
}),
invalidatesTags: ["user"],
}),
getUsersByRoleName: builder.query({
query: (roleName) => ({
url: `/users/role/${roleName}/`,
method: "GET",
}),
providesTags: ["user"],
}),
}),
});
@ -48,4 +55,5 @@ export const {
useUpdateUserMutation,
useUpdateUserPasswordMutation,
useCreateUserMutation,
useGetUsersByRoleNameQuery,
} = usersApi;

View File

@ -2,8 +2,8 @@ import {Button, Col, Flex, FloatButton, Input, Result, Row, Table, Tooltip, Typo
import {ControlOutlined, PlusOutlined} from "@ant-design/icons";
import useAdminPage from "./useAdminPage.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateUserModalForm from "./CreateUserModalForm/CreateUserModalForm.jsx";
import UpdateUserModalForm from "./UpdateUserModalForm/UpdateUserModalForm.jsx";
import CreateUserModalForm from "./Components/CreateUserModalForm/CreateUserModalForm.jsx";
import UpdateUserModalForm from "./Components/UpdateUserModalForm/UpdateUserModalForm.jsx";
const {Title} = Typography;

View File

@ -1,8 +1,8 @@
import {useDispatch, useSelector} from "react-redux";
import {Form, notification} from "antd";
import {setOpenModalCreateUser, setSelectedUserToUpdate} from "../../../../Redux/Slices/usersSlice.js";
import {useGetAllRolesQuery} from "../../../../Api/rolesApi.js";
import {useCreateUserMutation} from "../../../../Api/usersApi.js";
import {setOpenModalCreateUser, setSelectedUserToUpdate} from "../../../../../Redux/Slices/usersSlice.js";
import {useGetAllRolesQuery} from "../../../../../Api/rolesApi.js";
import {useCreateUserMutation} from "../../../../../Api/usersApi.js";
const useCreateUserModalForm = () => {

View File

@ -23,7 +23,7 @@ import {
TagOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import LoadingIndicator from "../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import useUpdateUserModalForm from "./useUpdateUserModalForm.js";
const {Title, Text} = Typography;

View File

@ -1,11 +1,11 @@
import {useDispatch, useSelector} from "react-redux";
import {Form, notification} from "antd";
import {setSelectedUserToUpdate} from "../../../../Redux/Slices/usersSlice.js";
import {useGetAllRolesQuery} from "../../../../Api/rolesApi.js";
import {setSelectedUserToUpdate} from "../../../../../Redux/Slices/usersSlice.js";
import {useGetAllRolesQuery} from "../../../../../Api/rolesApi.js";
import {useEffect} from "react";
import dayjs from "dayjs";
import {useUpdateUserMutation, useUpdateUserPasswordMutation} from "../../../../Api/usersApi.js";
import {useGetStatusesQuery} from "../../../../Api/statusesApi.js";
import {useUpdateUserMutation, useUpdateUserPasswordMutation} from "../../../../../Api/usersApi.js";
import {useGetStatusesQuery} from "../../../../../Api/statusesApi.js";
const useUpdateUserModalForm = () => {

View File

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

View File

@ -0,0 +1,60 @@
import {useDispatch, useSelector} from "react-redux";
import {setOpenCreateCourseModal} from "../../../../../Redux/Slices/coursesSlice.js";
import {Form} from "antd";
import {useGetUsersByRoleNameQuery} from "../../../../../Api/usersApi.js";
import {
useCreateCourseMutation,
useReplaceCourseStudentsMutation,
useReplaceCourseTeachersMutation
} from "../../../../../Api/coursesApi.js";
const useCreateCourseModalForm = () => {
const dispatch = useDispatch();
const [form] = Form.useForm();
const {
data: teachers = [],
isLoading: isTeachersLoading,
isError: isTeachersError,
} = useGetUsersByRoleNameQuery("teacher", {
pollingInterval: 20000,
});
const {
data: students = [],
isLoading: isStudentsLoading,
isError: isStudentsError,
} = useGetUsersByRoleNameQuery("student", {
pollingInterval: 20000,
});
const [createCourse, {isLoading: creatingCourse}] = useCreateCourseMutation();
const [replaceTeachers, {isLoading: replacingTeachers}] = useReplaceCourseTeachersMutation();
const [replaceStudents, {isLoading: replacingStudents}] = useReplaceCourseStudentsMutation();
const isLoading = isTeachersLoading || isStudentsLoading || creatingCourse || replacingTeachers || replacingStudents;
const isError = isTeachersError || isStudentsError;
const {
openCreateCourseModal
} = useSelector((state) => state.courses);
const handleCancel = () => {
form.resetFields();
dispatch(setOpenCreateCourseModal(false));
}
return {
openCreateCourseModal,
handleCancel,
handleOk,
form,
teachers,
students,
isLoading,
isError,
}
};
export default useCreateCourseModalForm;

View File

@ -1,10 +1,152 @@
import {
Button,
Card,
Col,
Empty,
Row,
Space,
Spin,
Tag,
Typography,
Avatar, Result, FloatButton, Tooltip,
} from "antd";
import {
PlusOutlined,
UserOutlined,
TeamOutlined, BookOutlined,
} from "@ant-design/icons";
import useCoursesPage from "./useCoursesPage.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateCourseModalForm from "./Components/CreateCourseModalForm/CreateCourseModalForm.jsx";
const {Title, Text} = Typography;
const CoursesPage = () => {
const {
courses,
isLoading,
isError,
isAdmin,
isTeacher,
openCreateModal,
openEditModal,
} = useCoursesPage();
if (isLoading) {
return (
<LoadingIndicator/>
);
}
if (isError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке курсов"/>
}
return (
<div>
CoursesPage
<div style={{minHeight: "100vh"}}>
<Row justify="space-between" align="middle" style={{marginBottom: 24}}>
<Col>
<Title level={2} style={{margin: 0}}>
<BookOutlined/> Курсы
</Title>
</Col>
</Row>
{courses.length === 0 ? (
<Empty
description="Курсов пока нет"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
{(isAdmin || isTeacher) && (
<Button type="primary" onClick={openCreateModal}>
Создать первый курс
</Button>
)}
</Empty>
) : (
<Row gutter={[24, 24]}>
{courses.map((course) => (
<Col xs={24} sm={12} lg={8} xl={6} key={course.id}>
<Card
hoverable
cover={
<div
style={{
height: 160,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 48,
}}
>
{course.title[0].toUpperCase()}
</div>
}
actions={
isAdmin || isTeacher
? [
<Button
type="link"
onClick={() => openEditModal(course)}
>
Редактировать
</Button>,
]
: []
}
>
<Card.Meta
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}}>
{course.teachers.map((t) => (
<Avatar key={t.teacher_id} style={{backgroundColor: "#1890ff"}}>
{t.teacher?.first_name?.[0] || "У"}
</Avatar>
))}
</Avatar.Group>
</div>
)}
</Card>
</Col>
))}
</Row>
)}
<Tooltip title="Создать курс">
<FloatButton
icon={<PlusOutlined/>}
onClick={openCreateModal}
type="primary"
/>
</Tooltip>
<CreateCourseModalForm/>
</div>
);
};

View File

@ -0,0 +1,37 @@
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
import {useGetAllCoursesQuery} 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";
const useCoursesPage = () => {
const dispatch = useDispatch();
const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery();
const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllCoursesQuery();
const isAdmin = userData?.role?.title === CONFIG.ROOT_ROLE_NAME;
const isTeacher = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
const openCreateModal = () => {
dispatch(setOpenCreateCourseModal(true));
};
const closeCreateModal = () => {
dispatch(setOpenCreateCourseModal(false));
};
return {
courses,
isLoading: isCoursesLoading || isUserLoading || isLoading,
isError,
isAdmin,
isTeacher,
openCreateModal,
closeModal: closeCreateModal,
};
};
export default useCoursesPage;

View File

@ -0,0 +1,5 @@
export const ROLES = {
ADMIN: "root",
TEACHER: "teacher",
STUDENT: "student",
}

View File

@ -0,0 +1,23 @@
import {createSlice} from "@reduxjs/toolkit";
const initialState = {
selectedCourseToUpdate: null,
openCreateCourseModal: false,
};
export const coursesSlice = createSlice({
name: "courses",
initialState,
reducers: {
setSelectedCourseToUpdate(state, action) {
state.selectedCourseToUpdate = action.payload;
},
setOpenCreateCourseModal(state, action) {
state.openCreateCourseModal = action.payload;
},
},
});
export const {setSelectedCourseToUpdate, setOpenCreateCourseModal} = coursesSlice.actions;
export default coursesSlice.reducer;

View File

@ -1,10 +1,12 @@
import {configureStore} from "@reduxjs/toolkit";
import authReducer from "./Slices/authSlice.js";
import usersReducer from "./Slices/usersSlice.js";
import coursesReducer from "./Slices/coursesSlice.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";
export const store = configureStore({
reducer: {
@ -16,7 +18,10 @@ export const store = configureStore({
[rolesApi.reducerPath]: rolesApi.reducer,
[statusesApi.reducerPath]: statusesApi.reducer
[statusesApi.reducerPath]: statusesApi.reducer,
courses: coursesReducer,
[coursesApi.reducerPath]: coursesApi.reducer
},
middleware: (getDefaultMiddleware) => (
getDefaultMiddleware().concat(
@ -24,6 +29,7 @@ export const store = configureStore({
usersApi.middleware,
rolesApi.middleware,
statusesApi.middleware,
coursesApi.middleware
)
),
});