diff --git a/api/app/controllers/courses_router.py b/api/app/controllers/courses_router.py index 49f55ea..4a9ba5d 100644 --- a/api/app/controllers/courses_router.py +++ b/api/app/controllers/courses_router.py @@ -7,11 +7,13 @@ 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, CourseCreated from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate +from app.domain.entities.gradebook import GradeBookRead 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, require_admin from app.infrastructure.enrollments_service import EnrollmentsService +from app.infrastructure.gradebook_service import GradeBookService courses_router = APIRouter() @@ -152,3 +154,13 @@ async def replace_course_students( ): service = EnrollmentsService(db) return await service.replace_course_students_list(students, course_id) + + +@courses_router.get("/gradebook/{course_id}/", response_model=GradeBookRead) +async def get_gradebook( + course_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user) +): + service = GradeBookService(db) + return await service.get_gradebook_by_course_id(course_id) diff --git a/api/app/domain/entities/gradebook.py b/api/app/domain/entities/gradebook.py new file mode 100644 index 0000000..8e8844c --- /dev/null +++ b/api/app/domain/entities/gradebook.py @@ -0,0 +1,44 @@ +from typing import List, Optional, Dict +from pydantic import BaseModel + + +class LessonInfo(BaseModel): + id: int + title: str + number: int + + class Config: + from_attributes = True + + +class TaskInfo(BaseModel): + id: int + title: str + number: int + max_points: int = 100 + + class Config: + from_attributes = True + + +class StudentProgress(BaseModel): + student_id: int + first_name: str + last_name: str + patronymic: str = "" + read_lesson_ids: List[int] = [] + task_grades: Dict[int, Optional[int]] + + class Config: + from_attributes = True + + +class GradeBookRead(BaseModel): + course_id: int + course_title: str + lessons: List[LessonInfo] + tasks: List[TaskInfo] + students: List[StudentProgress] + + class Config: + from_attributes = True \ No newline at end of file diff --git a/api/app/infrastructure/gradebook_service.py b/api/app/infrastructure/gradebook_service.py new file mode 100644 index 0000000..967fa26 --- /dev/null +++ b/api/app/infrastructure/gradebook_service.py @@ -0,0 +1,97 @@ +from typing import Dict + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.entities.gradebook import GradeBookRead, StudentProgress, LessonInfo, TaskInfo +from app.domain.models import Course, Lesson, Task, User, Enrollment, UserCheckLessons, Solution + + +class GradeBookService: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_gradebook_by_course_id(self, course_id: int) -> GradeBookRead: + # 1. Курс + course = await self.db.scalar(select(Course).filter_by(id=course_id)) + if not course: + raise ValueError("Курс не найден") + + # 2. Лекции + lessons = (await self.db.execute( + select(Lesson).filter_by(course_id=course_id).order_by(Lesson.number) + )).scalars().all() + + # 3. Задания + tasks = (await self.db.execute( + select(Task).filter_by(course_id=course_id).order_by(Task.number) + )).scalars().all() + + # 4. Студенты + students = (await self.db.execute( + select(User) + .join(Enrollment, User.id == Enrollment.student_id) + .filter(Enrollment.course_id == course_id) + .order_by(User.last_name, User.first_name) + )).scalars().all() + + student_progress_map: dict[int, StudentProgress] = {} + + if students: + student_ids = [s.id for s in students] + + # --- Прочитанные лекции --- + read_result = await self.db.execute( + select(UserCheckLessons.user_id, UserCheckLessons.lesson_id) + .filter(UserCheckLessons.user_id.in_(student_ids)) + ) + read_map: dict[int, set[int]] = {} + for user_id, lesson_id in read_result: + read_map.setdefault(user_id, set()).add(lesson_id) + + # --- МАКСИМАЛЬНЫЕ ОЦЕНКИ (ТОЛЬКО ИЗ ОЦЕНЕННЫХ РЕШЕНИЙ!) --- + grades_result = await self.db.execute( + select( + Solution.student_id, + Solution.task_id, + func.max(Solution.assessment) + ) + .where( + Solution.student_id.in_(student_ids), + Solution.assessment.is_not(None) # ← Вот это главное! + ) + .group_by(Solution.student_id, Solution.task_id) + ) + + grade_map: dict[tuple[int, int], int] = { + (student_id, task_id): best_grade + for student_id, task_id, best_grade in grades_result + } + + # --- Собираем прогресс --- + for student in students: + student_progress_map[student.id] = StudentProgress( + student_id=student.id, + first_name=student.first_name, + last_name=student.last_name, + patronymic=student.patronymic or "", + read_lesson_ids=sorted(read_map.get(student.id, set())), + task_grades={ + task.id: grade_map.get((student.id, task.id)) + for task in tasks + } + ) + + return GradeBookRead( + course_id=course_id, + course_title=course.title, + lessons=[ + LessonInfo(id=l.id, title=l.title, number=l.number) + for l in lessons + ], + tasks=[ + TaskInfo(id=t.id, title=t.title, number=t.number) + for t in tasks + ], + students=list(student_progress_map.values()) + ) \ No newline at end of file diff --git a/web/src/Api/coursesApi.js b/web/src/Api/coursesApi.js index 91d57da..3de7b6d 100644 --- a/web/src/Api/coursesApi.js +++ b/web/src/Api/coursesApi.js @@ -74,6 +74,13 @@ export const coursesApi = createApi({ }), invalidatesTags: ['student'], }), + getGradebookByCourse: builder.query({ + query: (courseId) => ({ + url: `/courses/gradebook/${courseId}/`, + method: "GET", + }), + providesTags: ['gradebook'], + }), }), }); @@ -87,4 +94,5 @@ export const { useReplaceCourseTeachersMutation, useGetCourseStudentsQuery, useReplaceCourseStudentsMutation, + useGetGradebookByCourseQuery, } = coursesApi; \ No newline at end of file diff --git a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js index 42ac609..1db6f67 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js +++ b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js @@ -214,7 +214,7 @@ const useViewTaskModal = () => { } - const response = await fetch(`${CONFIG.BASE_URL}/solutions/file/${fileId}/`, { + const response = await fetch(`${CONFIG.BASE_URL}/tasks/file/${fileId}/`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, diff --git a/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx b/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx index 15de2b7..63474db 100644 --- a/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx +++ b/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx @@ -5,7 +5,7 @@ import { Card, Col, Empty, - FloatButton, + FloatButton, Grid, Popconfirm, Result, Row, @@ -19,8 +19,8 @@ import { BookOutlined, CheckCircleFilled, ClockCircleOutlined, DeleteOutlined, EditOutlined, - FormOutlined, - PlusOutlined + FormOutlined, MenuFoldOutlined, MenuUnfoldOutlined, + PlusOutlined, TableOutlined } from "@ant-design/icons"; import {useNavigate, useParams} from "react-router-dom"; import {ROLES} from "../../../Core/constants.js"; @@ -32,12 +32,17 @@ import UpdateLessonModalForm from "./Components/UpdateLessonModalForm/UpdateLess import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx"; import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx"; import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx"; +import {useState} from "react"; const {Title, Text} = Typography; +const {useBreakpoint} = Grid; const CourseDetailPage = () => { const navigate = useNavigate(); + const screens = useBreakpoint(); + const [hovered, setHovered] = useState(false); + const {courseId} = useParams(); const { tasksData, @@ -242,6 +247,39 @@ const CourseDetailPage = () => { +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + +
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && ( { onClick={handleCreateTask} /> - )} + + ) + } ) }; diff --git a/web/src/Components/Pages/GradebookPage/GradebookPage.jsx b/web/src/Components/Pages/GradebookPage/GradebookPage.jsx index c4dd14c..584e096 100644 --- a/web/src/Components/Pages/GradebookPage/GradebookPage.jsx +++ b/web/src/Components/Pages/GradebookPage/GradebookPage.jsx @@ -1,723 +1,217 @@ -import { useState, useMemo } from "react"; -import { Avatar, Progress, Tag, Tooltip, Space, Button, Table, Card, Row, Col, Statistic, Select, Typography, Input, Modal, Badge } from "antd"; -import { UserOutlined, SearchOutlined, SortAscendingOutlined, SortDescendingOutlined, FilterOutlined } from "@ant-design/icons"; +import {useParams, useNavigate} from "react-router-dom"; +import { + Table, + Card, + Typography, + Spin, + Button, + Space, + Tag, + Tooltip, + Empty, + Result, + Avatar, Statistic, +} from "antd"; +import { + ArrowLeftOutlined, + CheckCircleFilled, + ClockCircleOutlined, + MinusOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { + useGetCourseByIdQuery, + useGetGradebookByCourseQuery, +} from "../../../Api/coursesApi.js"; +import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js"; -const { Title } = Typography; -const { Search } = Input; +const {Title, Text} = Typography; -const GradebookPage = ({ onLogout }) => { - const [selectedCourse, setSelectedCourse] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [filters, setFilters] = useState({ - groups: [], - searchText: "", - progressRange: [0, 100] - }); - const [sortConfig, setSortConfig] = useState({ - key: null, - direction: 'ascend' - }); - const [isSearchModalVisible, setIsSearchModalVisible] = useState(false); +const GradebookPage = () => { + const navigate = useNavigate(); + const {courseId} = useParams(); - // Заглушки для данных - const courses = [ - { id: 1, title: "Основы программирования" }, - { id: 2, title: "Веб-разработка" }, - { id: 3, title: "Базы данных" }, - { id: 4, title: "Алгоритмы и структуры данных" }, - ]; + // Данные текущего пользователя + const {data: userData, isLoading: userLoading} = useGetAuthenticatedUserDataQuery(); - const assignments = [ - { id: 1, name: "ДЗ №1", maxScore: 100 }, - { id: 2, name: "ДЗ №2", maxScore: 100 }, - { id: 3, name: "ДЗ №3", maxScore: 100 }, - { id: 4, name: "Проект", maxScore: 200 }, - ]; + const { + data: courseData, + isLoading: courseLoading, + } = useGetCourseByIdQuery(courseId); - const students = [ - { - id: 1, - firstName: "Иван", - lastName: "Иванов", - email: "ivanov@mail.com", - group: "ПИ-201", - grades: { 1: 85, 2: 92, 3: 78, 4: 180 }, - progress: 85 - }, - { - id: 2, - firstName: "Мария", - lastName: "Петрова", - email: "petrova@mail.com", - group: "ПИ-201", - grades: { 1: 95, 2: 88, 3: 91, 4: 190 }, - progress: 92 - }, - { - id: 3, - firstName: "Алексей", - lastName: "Сидоров", - email: "sidorov@mail.com", - group: "ПИ-202", - grades: { 1: 72, 2: null, 3: 85, 4: null }, - progress: 52 - }, - { - id: 4, - firstName: "Екатерина", - lastName: "Смирнова", - email: "smirnova@mail.com", - group: "ПИ-202", - grades: { 1: 88, 2: 90, 3: 87, 4: 175 }, - progress: 88 - }, - { - id: 5, - firstName: "Дмитрий", - lastName: "Козлов", - email: "kozlov@mail.com", - group: "ПИ-203", - grades: { 1: 65, 2: 70, 3: null, 4: null }, - progress: 45 - }, - { - id: 6, - firstName: "Анна", - lastName: "Волкова", - email: "volkova@mail.com", - group: "ПИ-201", - grades: { 1: 100, 2: 98, 3: 95, 4: 195 }, - progress: 97 - }, - { - id: 7, - firstName: "Сергей", - lastName: "Орлов", - email: "orlov@mail.com", - group: "ПИ-203", - grades: { 1: 80, 2: 85, 3: 82, 4: 170 }, - progress: 79 - }, - { - id: 8, - firstName: "Ольга", - lastName: "Лебедева", - email: "lebedeva@mail.com", - group: "ПИ-202", - grades: { 1: 90, 2: 92, 3: 88, 4: 185 }, - progress: 89 - }, - ]; + const { + data: gradebook, + isLoading: gradebookLoading, + isError: gradebookError, + } = useGetGradebookByCourseQuery(courseId, {pollingInterval: 15000}); - // Получение уникальных групп для фильтра - const uniqueGroups = useMemo(() => { - return [...new Set(students.map(student => student.group))]; - }, [students]); - - // Фильтрация и сортировка данных - const filteredAndSortedStudents = useMemo(() => { - let filtered = students.filter(student => { - // Фильтр по группам - if (filters.groups.length > 0 && !filters.groups.includes(student.group)) { - return false; - } - - // Поиск по тексту (ФИО, email, группа) - if (filters.searchText) { - const searchLower = filters.searchText.toLowerCase(); - const fullName = `${student.firstName} ${student.lastName}`.toLowerCase(); - if (!fullName.includes(searchLower) && - !student.email.toLowerCase().includes(searchLower) && - !student.group.toLowerCase().includes(searchLower)) { - return false; - } - } - - // Фильтр по прогрессу - if (student.progress < filters.progressRange[0] || student.progress > filters.progressRange[1]) { - return false; - } - - return true; - }); - - // Сортировка - if (sortConfig.key) { - filtered.sort((a, b) => { - let aValue, bValue; - - switch (sortConfig.key) { - case 'student': - aValue = `${a.firstName} ${a.lastName}`; - bValue = `${b.firstName} ${b.lastName}`; - break; - case 'group': - aValue = a.group; - bValue = b.group; - break; - case 'progress': - aValue = a.progress; - bValue = b.progress; - break; - case 'average': - const aGrades = Object.values(a.grades).filter(g => g !== null); - const bGrades = Object.values(b.grades).filter(g => g !== null); - aValue = aGrades.length > 0 ? aGrades.reduce((sum, grade) => sum + grade, 0) / aGrades.length : 0; - bValue = bGrades.length > 0 ? bGrades.reduce((sum, grade) => sum + grade, 0) / bGrades.length : 0; - break; - default: - // Сортировка по заданиям - if (sortConfig.key.startsWith('assignment_')) { - const assignmentId = parseInt(sortConfig.key.split('_')[1]); - aValue = a.grades[assignmentId] || 0; - bValue = b.grades[assignmentId] || 0; - } else { - aValue = a[sortConfig.key]; - bValue = b[sortConfig.key]; - } - } - - if (aValue < bValue) { - return sortConfig.direction === 'ascend' ? -1 : 1; - } - if (aValue > bValue) { - return sortConfig.direction === 'ascend' ? 1 : -1; - } - return 0; - }); - } - - return filtered; - }, [students, filters, sortConfig]); - - // Обработчики фильтров - const handleGroupFilter = (selectedGroups) => { - setFilters(prev => ({ ...prev, groups: selectedGroups })); - }; - - const handleSearch = (value) => { - setFilters(prev => ({ ...prev, searchText: value })); - setIsSearchModalVisible(false); - }; - - const handleSort = (key) => { - setSortConfig(prev => ({ - key, - direction: prev.key === key && prev.direction === 'ascend' ? 'descend' : 'ascend' - })); - }; - - // Колонки таблицы с фильтрацией и сортировкой - const columns = useMemo(() => { - const baseColumns = [ - { - title: ( - - Студент - + } + /> + + ); + } + + // === Колонки === + const baseColumns = [ + { + title: "№", + width: 60, + fixed: "left", + render: (_, __, index) => index + 1, + }, + { + title: "Студент", + fixed: "left", + width: isStudent ? 300 : 240, + render: (record) => { + const fullName = `${record.last_name} ${record.first_name} ${ + record.patronymic ? record.patronymic[0] + "." : "" + }`; + + if (isStudent) { + return ( + + } style={{backgroundColor: "#1890ff"}}/> +
+ {fullName} +
+ Ваша успеваемость +
+
+ ); + } + return {fullName}; + }, + }, ]; - return [...baseColumns, ...assignmentColumns, ...summaryColumns]; - }, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]); + const lessonColumns = gradebook.lessons.map((lesson) => ({ + title: ( + +
Л{lesson.number}
+
+ ), + width: 70, + align: "center", + render: (record) => + record.read_lesson_ids.includes(lesson.id) ? ( + + ) : ( + + ), + })); - // Статистика с учетом фильтров - const statistics = useMemo(() => { - if (!selectedCourse) { - return { - totalStudents: 0, - averageGrade: 0, - completedAssignments: 0, - totalAssignments: 0, - pendingReview: 0 - }; - } + const taskColumns = gradebook.tasks.map((task) => ({ + title: ( + +
З{task.number}
+
+ ), + width: 90, + align: "center", + render: (record) => { + const grade = record.task_grades[task.id]; + if (grade === null || grade === undefined) { + return ; + } + const color = grade >= 90 ? "green" : grade >= 70 ? "orange" : "red"; + return {grade}; + }, + })); - const totalStudents = filteredAndSortedStudents.length; - const totalAssignments = assignments.length * filteredAndSortedStudents.length; - - let completedCount = 0; - let totalGradeSum = 0; - let gradedCount = 0; + const columns = [...baseColumns, ...lessonColumns, ...taskColumns]; - filteredAndSortedStudents.forEach(student => { - Object.values(student.grades).forEach(grade => { - if (grade !== null) { - completedCount++; - totalGradeSum += grade; - gradedCount++; - } - }); - }); + const dataSource = visibleStudents.map((student) => ({ + key: student.student_id, + ...student, + })); - const averageGrade = gradedCount > 0 ? (totalGradeSum / gradedCount).toFixed(1) : 0; - const pendingReview = filteredAndSortedStudents.reduce((count, student) => { - const nullGrades = Object.values(student.grades).filter(grade => grade === null).length; - return count + nullGrades; - }, 0); + return ( +
+ + + - return { - totalStudents, - averageGrade, - completedAssignments: completedCount, - totalAssignments, - pendingReview - }; - }, [selectedCourse, filteredAndSortedStudents, assignments]); + + {isStudent ? "Моя успеваемость" : "Журнал успеваемости"} — {courseData?.title} + - const handleCourseChange = (courseId) => { - setIsLoading(true); - setSelectedCourse(courseId); - - // Сброс фильтров при смене курса - setFilters({ - groups: [], - searchText: "", - progressRange: [0, 100] - }); - setSortConfig({ - key: null, - direction: 'ascend' - }); + {!isStudent && ( + + + + + + )} + + - // Имитация загрузки данных - setTimeout(() => { - setIsLoading(false); - }, 500); - }; + + + - // Модальное окно поиска - const SearchModal = () => ( - setIsSearchModalVisible(false)} - footer={[ - , - , - ]} - width={400} - > - -
-

- Поиск по ФИО, email или группе: -

- + {!isStudent && ( + + Легенда: + 90–100 + 70–89 + 0–69 + + — прочитано + + + — не прочитано + + + — не сдано / не оценено + + + )}
- {filters.searchText && ( -
- - Активный поиск: "{filters.searchText}" - -
- )} -
-
- ); - - return ( -
- {/* Шапка с навигацией */} - -
- Электронный журнал - - - - {/* Выбор курса */} - - - Выберите курс: -
({ ...student, key: student.id }))} - loading={isLoading} - scroll={{ x: 1200 }} - pagination={{ - pageSize: 10, - showSizeChanger: true, - showQuickJumper: true, - showTotal: (total, range) => - `Показано ${range[0]}-${range[1]} из ${total} студентов`, - pageSizeOptions: ['10', '20', '50'], - style: { marginTop: 16, marginRight: 16 } - }} - size="middle" - /> - - )} - - {/* Сообщение при невыбранном курсе */} - {!selectedCourse && ( - - - Выберите курс для просмотра журнала - -

- Пожалуйста, выберите курс из выпадающего списка выше чтобы увидеть успеваемость студентов -

-
- )} - - {/* Модальное окно поиска */} - - - ); + ); }; export default GradebookPage; \ No newline at end of file