diff --git a/api/app/application/solutions_repository.py b/api/app/application/solutions_repository.py index 37b79fb..c3f04ad 100644 --- a/api/app/application/solutions_repository.py +++ b/api/app/application/solutions_repository.py @@ -1,10 +1,10 @@ from typing import Optional, List -from sqlalchemy import select +from sqlalchemy import select, func, distinct from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.domain.models import Solution +from app.domain.models import Solution, Task class SolutionsRepository: @@ -19,6 +19,22 @@ class SolutionsRepository: 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) diff --git a/api/app/application/user_check_lessons_repository.py b/api/app/application/user_check_lessons_repository.py new file mode 100644 index 0000000..edc027d --- /dev/null +++ b/api/app/application/user_check_lessons_repository.py @@ -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 diff --git a/api/app/controllers/users_router.py b/api/app/controllers/users_router.py index dfa31ac..8b9a9ae 100644 --- a/api/app/controllers/users_router.py +++ b/api/app/controllers/users_router.py @@ -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) diff --git a/api/app/database/alembic/versions/5664875e4492_0004_добавил_таблицу_для_отслеживания_.py b/api/app/database/alembic/versions/5664875e4492_0004_добавил_таблицу_для_отслеживания_.py new file mode 100644 index 0000000..60db064 --- /dev/null +++ b/api/app/database/alembic/versions/5664875e4492_0004_добавил_таблицу_для_отслеживания_.py @@ -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 ### diff --git a/api/app/domain/entities/users.py b/api/app/domain/entities/users.py index 5e5d910..8242e94 100644 --- a/api/app/domain/entities/users.py +++ b/api/app/domain/entities/users.py @@ -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 diff --git a/api/app/domain/models/__init__.py b/api/app/domain/models/__init__.py index 39ba238..6880dbd 100644 --- a/api/app/domain/models/__init__.py +++ b/api/app/domain/models/__init__.py @@ -21,4 +21,5 @@ 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 diff --git a/api/app/domain/models/lessons.py b/api/app/domain/models/lessons.py index d11ac9d..fb7ca89 100644 --- a/api/app/domain/models/lessons.py +++ b/api/app/domain/models/lessons.py @@ -21,3 +21,4 @@ class Lesson(RootTable): 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') diff --git a/api/app/domain/models/user_check_lessons.py b/api/app/domain/models/user_check_lessons.py new file mode 100644 index 0000000..5505195 --- /dev/null +++ b/api/app/domain/models/user_check_lessons.py @@ -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') diff --git a/api/app/domain/models/users.py b/api/app/domain/models/users.py index 32c3091..464e3a0 100644 --- a/api/app/domain/models/users.py +++ b/api/app/domain/models/users.py @@ -51,6 +51,11 @@ class User(PhotoAbstract): '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) diff --git a/api/app/infrastructure/user_check_lessons_service.py b/api/app/infrastructure/user_check_lessons_service.py new file mode 100644 index 0000000..6050547 --- /dev/null +++ b/api/app/infrastructure/user_check_lessons_service.py @@ -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) \ No newline at end of file diff --git a/web/src/Api/usersApi.js b/web/src/Api/usersApi.js index e1a2333..807f8b8 100644 --- a/web/src/Api/usersApi.js +++ b/web/src/Api/usersApi.js @@ -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; \ No newline at end of file diff --git a/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx b/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx index 5bb7a7d..15de2b7 100644 --- a/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx +++ b/web/src/Components/Pages/CourseDetailPage/CourseDetailPage.jsx @@ -16,7 +16,7 @@ import { } from "antd"; import { ArrowLeftOutlined, - BookOutlined, + BookOutlined, CheckCircleFilled, ClockCircleOutlined, DeleteOutlined, EditOutlined, FormOutlined, @@ -55,6 +55,7 @@ const CourseDetailPage = () => { handleOpenTask, handleEditTask, handleDeleteTask, + listReadLessonsIds, } = useCourseDetailPage(courseId); if (isLoading) { @@ -177,10 +178,29 @@ const CourseDetailPage = () => { )} +