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

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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Role
from app.domain.models.users import User from app.domain.models.users import User
@ -31,6 +32,15 @@ class UsersRepository:
result = await self.db.execute(query) result = await self.db.execute(query)
return result.scalars().first() 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: async def create(self, user: User) -> User:
self.db.add(user) self.db.add(user)
await self.db.commit() await self.db.commit()

View File

@ -86,3 +86,18 @@ async def create_user(
): ):
register_service = RegisterService(db) register_service = RegisterService(db)
return await register_service.create_user(user) 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) user_model = await self.users_repository.update(user_model)
return UserRead.model_validate(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"], invalidatesTags: ["user"],
}), }),
getUsersByRoleName: builder.query({
query: (roleName) => ({
url: `/users/role/${roleName}/`,
method: "GET",
}),
providesTags: ["user"],
}),
}), }),
}); });
@ -48,4 +55,5 @@ export const {
useUpdateUserMutation, useUpdateUserMutation,
useUpdateUserPasswordMutation, useUpdateUserPasswordMutation,
useCreateUserMutation, useCreateUserMutation,
useGetUsersByRoleNameQuery,
} = usersApi; } = 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 {ControlOutlined, PlusOutlined} from "@ant-design/icons";
import useAdminPage from "./useAdminPage.js"; import useAdminPage from "./useAdminPage.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateUserModalForm from "./CreateUserModalForm/CreateUserModalForm.jsx"; import CreateUserModalForm from "./Components/CreateUserModalForm/CreateUserModalForm.jsx";
import UpdateUserModalForm from "./UpdateUserModalForm/UpdateUserModalForm.jsx"; import UpdateUserModalForm from "./Components/UpdateUserModalForm/UpdateUserModalForm.jsx";
const {Title} = Typography; const {Title} = Typography;

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import {useDispatch, useSelector} from "react-redux"; import {useDispatch, useSelector} from "react-redux";
import {Form, notification} from "antd"; import {Form, notification} from "antd";
import {setSelectedUserToUpdate} from "../../../../Redux/Slices/usersSlice.js"; import {setSelectedUserToUpdate} from "../../../../../Redux/Slices/usersSlice.js";
import {useGetAllRolesQuery} from "../../../../Api/rolesApi.js"; import {useGetAllRolesQuery} from "../../../../../Api/rolesApi.js";
import {useEffect} from "react"; import {useEffect} from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import {useUpdateUserMutation, useUpdateUserPasswordMutation} from "../../../../Api/usersApi.js"; import {useUpdateUserMutation, useUpdateUserPasswordMutation} from "../../../../../Api/usersApi.js";
import {useGetStatusesQuery} from "../../../../Api/statusesApi.js"; import {useGetStatusesQuery} from "../../../../../Api/statusesApi.js";
const useUpdateUserModalForm = () => { 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 CoursesPage = () => {
const {
courses,
isLoading,
isError,
isAdmin,
isTeacher,
openCreateModal,
openEditModal,
} = useCoursesPage();
if (isLoading) {
return ( return (
<div> <LoadingIndicator/>
CoursesPage );
}
if (isError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке курсов"/>
}
return (
<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> </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 {configureStore} from "@reduxjs/toolkit";
import authReducer from "./Slices/authSlice.js"; import authReducer from "./Slices/authSlice.js";
import usersReducer from "./Slices/usersSlice.js"; import usersReducer from "./Slices/usersSlice.js";
import coursesReducer from "./Slices/coursesSlice.js";
import {authApi} from "../Api/authApi.js"; import {authApi} from "../Api/authApi.js";
import {usersApi} from "../Api/usersApi.js"; import {usersApi} from "../Api/usersApi.js";
import {rolesApi} from "../Api/rolesApi.js"; import {rolesApi} from "../Api/rolesApi.js";
import {statusesApi} from "../Api/statusesApi.js"; import {statusesApi} from "../Api/statusesApi.js";
import {coursesApi} from "../Api/coursesApi.js";
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
@ -16,7 +18,10 @@ export const store = configureStore({
[rolesApi.reducerPath]: rolesApi.reducer, [rolesApi.reducerPath]: rolesApi.reducer,
[statusesApi.reducerPath]: statusesApi.reducer [statusesApi.reducerPath]: statusesApi.reducer,
courses: coursesReducer,
[coursesApi.reducerPath]: coursesApi.reducer
}, },
middleware: (getDefaultMiddleware) => ( middleware: (getDefaultMiddleware) => (
getDefaultMiddleware().concat( getDefaultMiddleware().concat(
@ -24,6 +29,7 @@ export const store = configureStore({
usersApi.middleware, usersApi.middleware,
rolesApi.middleware, rolesApi.middleware,
statusesApi.middleware, statusesApi.middleware,
coursesApi.middleware
) )
), ),
}); });