diff --git a/api/app/application/solution_files_repository.py b/api/app/application/solution_files_repository.py new file mode 100644 index 0000000..f62f26a --- /dev/null +++ b/api/app/application/solution_files_repository.py @@ -0,0 +1,38 @@ +from typing import Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import SolutionFile + + +class SolutionFilesRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, file_id: int) -> Optional[SolutionFile]: + query = ( + select(SolutionFile) + .filter_by(id=file_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_solution_id(self, solution_id: int) -> Sequence[Optional[SolutionFile]]: + query = ( + select(SolutionFile) + .filter_by(solution_id=solution_id) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, solution_file: SolutionFile) -> SolutionFile: + self.db.add(solution_file) + await self.db.commit() + await self.db.refresh(solution_file) + return solution_file + + async def delete(self, solution_file: SolutionFile) -> SolutionFile: + await self.db.delete(solution_file) + await self.db.commit() + return solution_file diff --git a/api/app/application/solutions_repository.py b/api/app/application/solutions_repository.py new file mode 100644 index 0000000..d768965 --- /dev/null +++ b/api/app/application/solutions_repository.py @@ -0,0 +1,50 @@ +from typing import Optional, List + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.domain.models import Solution + + +class SolutionsRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, solution_id: int) -> Optional[Solution]: + query = ( + select(Solution) + .filter_by(id=solution_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_task_id(self, task_id: int) -> Optional[List[Solution]]: + query = ( + select(Solution) + .filter_by(task_id=task_id) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def get_by_task_id_and_student_id(self, task_id: int, student_id: int) -> Optional[List[Solution]]: + query = ( + select(Solution) + .filter_by(task_id=task_id, student_id=student_id) + .options( + selectinload(Solution.files), + ) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, solution: Solution) -> Solution: + self.db.add(solution) + await self.db.commit() + await self.db.refresh(solution) + return solution + + async def delete(self, solution: Solution) -> Solution: + await self.db.delete(solution) + await self.db.commit() + return solution diff --git a/api/app/controllers/solutions_router.py b/api/app/controllers/solutions_router.py new file mode 100644 index 0000000..8ef7550 --- /dev/null +++ b/api/app/controllers/solutions_router.py @@ -0,0 +1,125 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Response, File, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse + +from app.database.session import get_db +from app.domain.entities.solution_files import ReadSolutionFile +from app.domain.entities.solutions import SolutionCreate, SolutionRead, SolutionAfterCreate +from app.domain.models import User +from app.infrastructure.dependencies import require_auth_user +from app.infrastructure.solution_files_service import SolutionFilesService +from app.infrastructure.solutions_service import SolutionsService + +solution_router = APIRouter() + + +@solution_router.get( + '/task/{task_id}/', + response_model=List[SolutionRead], + summary='Get all solutions for task', + description='Get all solutions for task', +) +async def get_solutions_by_task( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + solutions = await service.get_by_task_id(task_id) + return solutions + + +@solution_router.get( + '/task/{task_id}/student/{student_id}/', + response_model=List[SolutionRead], + summary='Get all solutions for task', + description='Get all solutions for task', +) +async def get_solutions_by_task_and_student( + task_id: int, + student_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + solutions = await service.get_by_task_id_and_student_id(task_id, student_id) + return solutions + + +@solution_router.post( + '/{task_id}/', + response_model=SolutionAfterCreate, + status_code=status.HTTP_201_CREATED, + summary='Send solution', + description='Send solution', +) +async def create_solution( + task_id: int, + solution_data: SolutionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + return await service.create(solution_data, current_user, task_id) + + +@solution_router.delete( + '/{solution_id}/', + status_code=status.HTTP_204_NO_CONTENT, + summary='Delete my solution', + description='Delete my solution', +) +async def delete_solution( + solution_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + service = SolutionsService(db) + await service.delete(solution_id, current_user) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@solution_router.get( + '/file/{file_id}/', + response_class=FileResponse, +) +async def get_file( + file_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + task_files_service = SolutionFilesService(db) + return await task_files_service.get_file_by_id(file_id) + + +@solution_router.get( + '/files/{solution_id}/', + response_model=Optional[List[ReadSolutionFile]], + summary='Get a files list by task ID', + description='Get a files list by task ID', +) +async def get_files( + solution_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + task_files_service = SolutionFilesService(db) + return await task_files_service.get_files_list_by_solution(solution_id) + + +@solution_router.post( + '/files/{task_id}/upload/', + response_model=ReadSolutionFile, + summary='Upload a file', + description='Upload a file', +) +async def upload_file( + task_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + task_files_service = SolutionFilesService(db) + return await task_files_service.upload_file(task_id, file) diff --git a/api/app/database/alembic/versions/f8fd9a27eaa7_0003_добавил_таблицу_с_комментариями_к_.py b/api/app/database/alembic/versions/f8fd9a27eaa7_0003_добавил_таблицу_с_комментариями_к_.py new file mode 100644 index 0000000..d6ba196 --- /dev/null +++ b/api/app/database/alembic/versions/f8fd9a27eaa7_0003_добавил_таблицу_с_комментариями_к_.py @@ -0,0 +1,107 @@ +"""0003 добавил таблицу с комментариями к решению + +Revision ID: f8fd9a27eaa7 +Revises: 33d77ac5ed79 +Create Date: 2025-11-29 16:43:22.620248 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f8fd9a27eaa7' +down_revision: Union[str, Sequence[str], None] = '33d77ac5ed79' +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('solution_comments', + sa.Column('comment_text', sa.String(), nullable=False), + sa.Column('comment_autor_id', sa.Integer(), nullable=False), + sa.Column('solution_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(['comment_autor_id'], ['public.users.id'], ), + sa.ForeignKeyConstraint(['solution_id'], ['public.solutions.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_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_student_id_fkey'), 'solutions', type_='foreignkey') + 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.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.create_foreign_key(None, 'solutions', 'users', ['student_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', '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', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'users', 'statuses', ['status_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_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.create_foreign_key(op.f('solutions_student_id_fkey'), 'solutions', 'users', ['student_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_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('solution_comments', schema='public') + # ### end Alembic commands ### diff --git a/api/app/domain/entities/solution_files.py b/api/app/domain/entities/solution_files.py new file mode 100644 index 0000000..a311028 --- /dev/null +++ b/api/app/domain/entities/solution_files.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class ReadSolutionFile(BaseModel): + id: int + filename: str + file_path: str + solution_id: int + + class Config: + from_attributes = True diff --git a/api/app/domain/entities/solutions.py b/api/app/domain/entities/solutions.py new file mode 100644 index 0000000..d11620c --- /dev/null +++ b/api/app/domain/entities/solutions.py @@ -0,0 +1,36 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + +from app.domain.entities.solution_files import ReadSolutionFile +from app.domain.entities.users import UserRead + + +class SolutionBase(BaseModel): + answer_text: str = Field(...) + assessment: Optional[int] = Field(default=None) + + +class SolutionCreate(SolutionBase): + pass + + +class SolutionAfterCreate(SolutionBase): + id: int + + assessment_autor_id: Optional[int] = None + assessment_autor: Optional[UserRead] = None + student_id: int + task_id: int = Field(...) + + class Config: + from_attributes = True + + +class SolutionRead(SolutionAfterCreate): + created_at: datetime + + files: Optional[List[ReadSolutionFile]] = [] + + class Config: + from_attributes = True diff --git a/api/app/domain/models/__init__.py b/api/app/domain/models/__init__.py index 9fc41d2..39ba238 100644 --- a/api/app/domain/models/__init__.py +++ b/api/app/domain/models/__init__.py @@ -15,6 +15,7 @@ from app.domain.models.enrollments import Enrollment from app.domain.models.lesson_files import LessonFile from app.domain.models.lessons import Lesson from app.domain.models.roles import Role +from app.domain.models.solution_comments import SolutionComment from app.domain.models.solution_files import SolutionFile from app.domain.models.solutions import Solution from app.domain.models.statuses import Status diff --git a/api/app/domain/models/solution_comments.py b/api/app/domain/models/solution_comments.py new file mode 100644 index 0000000..9bec13e --- /dev/null +++ b/api/app/domain/models/solution_comments.py @@ -0,0 +1,19 @@ +from typing import List + +from sqlalchemy import String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + + +from app.domain.models.base import RootTable + + +class SolutionComment(RootTable): + __tablename__ = 'solution_comments' + + comment_text: Mapped[str] = mapped_column(nullable=False) + + comment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False) + solution_id: Mapped[int] = mapped_column(ForeignKey('solutions.id'), nullable=False) + + comment_autor: Mapped['User'] = relationship('User', back_populates='solution_comments') + solution: Mapped['Solution'] = relationship('Solution', back_populates='solution_comments') \ No newline at end of file diff --git a/api/app/domain/models/solutions.py b/api/app/domain/models/solutions.py index 6dbafe8..85f44f6 100644 --- a/api/app/domain/models/solutions.py +++ b/api/app/domain/models/solutions.py @@ -10,14 +10,18 @@ class Solution(RootTable): __tablename__ = 'solutions' answer_text: Mapped[str] = mapped_column(nullable=True) - assessment_text: Mapped[str] = mapped_column(String(50), nullable=True) + assessment: Mapped[int] = mapped_column(nullable=True) assessment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=True) task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id'), nullable=False) student_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False) assessment_autor: Mapped['User'] = relationship('User', back_populates='assessments', - foreign_keys=[assessment_autor_id]) + foreign_keys=[assessment_autor_id], lazy='joined') student: Mapped['User'] = relationship('User', back_populates='my_solutions', foreign_keys=[student_id]) files: Mapped[List['SolutionFile']] = relationship('SolutionFile', back_populates='solution') + solution_comments: Mapped[List['SolutionComment']] = relationship( + 'SolutionComment', + back_populates='solution', + ) diff --git a/api/app/domain/models/users.py b/api/app/domain/models/users.py index d80214c..32c3091 100644 --- a/api/app/domain/models/users.py +++ b/api/app/domain/models/users.py @@ -46,6 +46,11 @@ class User(PhotoAbstract): back_populates='student', foreign_keys=[Solution.student_id], ) + from app.domain.models.solution_comments import SolutionComment + solution_comments: Mapped[List['SolutionComment']] = relationship( + 'SolutionComment', + back_populates='comment_autor', + ) def check_password(self, password): return check_password_hash(self.password_hash, password) diff --git a/api/app/infrastructure/solution_files_service.py b/api/app/infrastructure/solution_files_service.py new file mode 100644 index 0000000..0452ff2 --- /dev/null +++ b/api/app/infrastructure/solution_files_service.py @@ -0,0 +1,118 @@ +import os +import uuid +from typing import List + +import aiofiles +from fastapi import UploadFile, HTTPException +from starlette.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from werkzeug.utils import secure_filename + +from app.application.solution_files_repository import SolutionFilesRepository +from app.application.solutions_repository import SolutionsRepository +from app.domain.entities.solution_files import ReadSolutionFile +from app.domain.models import SolutionFile + + +class SolutionFilesService: + def __init__(self, db: AsyncSession): + self.solution_files_repository = SolutionFilesRepository(db) + self.solutions_repository = SolutionsRepository(db) + + async def get_file_by_id(self, file_id: int) -> FileResponse: + solution_file = await self.solution_files_repository.get_by_id(file_id) + + if not solution_file: + raise HTTPException(404, "Файл с таким ID не найден") + + return FileResponse( + solution_file.file_path, + media_type=self.get_media_type(solution_file.filename), + filename=os.path.basename(solution_file.filename), + ) + + async def get_files_list_by_solution(self, solution_id: int) -> List[ReadSolutionFile]: + solution = await self.solutions_repository.get_by_id(solution_id) + + if solution is None: + raise HTTPException(404, "Лекционный материал не найден") + + solution_files = await self.solution_files_repository.get_by_solution_id(solution_id) + + response = [] + for solution_file in solution_files: + response.append( + ReadSolutionFile.model_validate( + solution_file + ) + ) + + return response + + async def upload_file(self, solution_id: int, file: UploadFile) -> ReadSolutionFile: + solution = await self.solutions_repository.get_by_id(solution_id) + + if solution is None: + raise HTTPException(404, "Лекционный материал не найден") + + file_path = await self.save_file(file, f'uploads/solutions/{solution.id}') + + solution_file_model = SolutionFile( + filename=file.filename, + file_path=file_path, + solution_id=solution.id, + ) + + solution_file_model = await self.solution_files_repository.create(solution_file_model) + + return ReadSolutionFile.model_validate(solution_file_model) + + async def delete_file(self, file_id: int) -> ReadSolutionFile: + solution_file = await self.solution_files_repository.get_by_id(file_id) + + if solution_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(solution_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(solution_file.file_path): + os.remove(solution_file.file_path) + + solution_file = await self.solution_files_repository.delete(solution_file) + + return ReadSolutionFile.model_validate(solution_file) + + async def save_file(self, file: UploadFile, upload_dir: str = 'uploads/solutions') -> str: + os.makedirs(upload_dir, exist_ok=True) + filename = self.generate_filename(file) + file_path = os.path.join(upload_dir, filename) + + async with aiofiles.open(file_path, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + return file_path + + @staticmethod + def generate_filename(file: UploadFile) -> str: + return secure_filename(f"{uuid.uuid4()}_{file.filename}") + + @staticmethod + def get_media_type(filename: str) -> str: + extension = filename.split('.')[-1].lower() + if extension in ['jpeg', 'jpg', 'png']: + return f"image/{extension}" + if extension == 'pdf': + return "application/pdf" + if extension in ['zip']: + return "application/zip" + if extension in ['doc', 'docx']: + return "application/msword" + if extension in ['xls', 'xlsx']: + return "application/vnd.ms-excel" + if extension in ['ppt', 'pptx']: + return "application/vnd.ms-powerpoint" + if extension in ['txt']: + return "text/plain" + + return "application/octet-stream" diff --git a/api/app/infrastructure/solutions_service.py b/api/app/infrastructure/solutions_service.py new file mode 100644 index 0000000..3ae9ad8 --- /dev/null +++ b/api/app/infrastructure/solutions_service.py @@ -0,0 +1,118 @@ +import os +from typing import Optional, List + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.solution_files_repository import SolutionFilesRepository +from app.application.solutions_repository import SolutionsRepository +from app.application.tasks_repository import TasksRepository +from app.application.users_repository import UsersRepository +from app.domain.entities.solutions import SolutionRead, SolutionCreate, SolutionAfterCreate +from app.domain.models import User, Solution +from app.settings import Settings + + +class SolutionsService: + def __init__(self, db: AsyncSession): + self.solutions_repository = SolutionsRepository(db) + self.tasks_repository = TasksRepository(db) + self.users_repository = UsersRepository(db) + self.solution_files_repository = SolutionFilesRepository(db) + self.settings = Settings() + + async def get_by_task_id(self, task_id: int) -> Optional[List[SolutionRead]]: + task_model = await self.tasks_repository.get_by_id(task_id) + + if task_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + solutions = await self.solutions_repository.get_by_task_id(task_id) + + response = [] + for solution in solutions: + response.append( + SolutionRead.model_validate(solution) + ) + + return response + + async def get_by_task_id_and_student_id(self, task_id: int, student_id: int) -> Optional[List[SolutionRead]]: + task_model = await self.tasks_repository.get_by_id(task_id) + + if task_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + student_model = await self.users_repository.get_by_id(student_id) + + if student_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Такой пользователь не найден" + ) + + solutions = await self.solutions_repository.get_by_task_id_and_student_id(task_id, student_id) + + response = [] + for solution in solutions: + response.append( + SolutionRead.model_validate(solution) + ) + + return response + + async def create(self, solution: SolutionCreate, creator: User, task_id: int) -> SolutionAfterCreate: + task_model = await self.tasks_repository.get_by_id(task_id) + + if task_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Задание не найдено" + ) + + model_solution = Solution( + answer_text=solution.answer_text, + assessment=solution.assessment, + task_id=task_id, + student_id=creator.id, + ) + created_solution = await self.solutions_repository.create(model_solution) + return SolutionAfterCreate.model_validate(created_solution) + + async def delete(self, solution_id: int, current_user: User) -> Optional[SolutionRead]: + solution = await self.solutions_repository.get_by_id(solution_id) + if solution is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Такое решение не найдено' + ) + + is_admin = current_user.role.title == self.settings.root_role_name + if solution.student_id != current_user.id and not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ запрещён" + ) + + solution_files = await self.solution_files_repository.get_by_solution_id(solution.id) + for file in solution_files: + solution_file = await self.solution_files_repository.get_by_id(file.id) + + if solution_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(solution_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(solution_file.file_path): + os.remove(solution_file.file_path) + + await self.solution_files_repository.delete(solution_file) + + await self.solutions_repository.delete(solution) diff --git a/api/app/main.py b/api/app/main.py index b7ff192..ec663bb 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -6,6 +6,7 @@ from app.controllers.courses_router import courses_router from app.controllers.lessons_router import lessons_router from app.controllers.register_router import register_router from app.controllers.roles_router import roles_router +from app.controllers.solutions_router import solution_router from app.controllers.statuses_router import statuses_router from app.controllers.tasks_router import tasks_router from app.controllers.users_router import users_router @@ -29,6 +30,7 @@ def start_app(): api_app.include_router(lessons_router, prefix=f'{settings.prefix}/lessons', tags=['lessons']) api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register']) api_app.include_router(roles_router, prefix=f'{settings.prefix}/roles', tags=['roles']) + api_app.include_router(solution_router, prefix=f'{settings.prefix}/solutions', tags=['solutions']) api_app.include_router(statuses_router, prefix=f'{settings.prefix}/statuses', tags=['statuses']) api_app.include_router(tasks_router, prefix=f'{settings.prefix}/tasks', tags=['tasks']) api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users']) diff --git a/web/src/Api/solutionsApi.js b/web/src/Api/solutionsApi.js new file mode 100644 index 0000000..9dc96b2 --- /dev/null +++ b/web/src/Api/solutionsApi.js @@ -0,0 +1,73 @@ +import {createApi} from "@reduxjs/toolkit/query/react"; +import {baseQueryWithAuth} from "./baseQuery.js"; + + +export const solutionsApi = createApi({ + reducerPath: "solutionsApi", + baseQuery: baseQueryWithAuth, + tagTypes: ["lesson"], + endpoints: (builder) => ({ + getTaskSolutions: builder.query({ + query: (taskId) => ({ + url: `/solutions/task/${taskId}/`, + method: "GET" + }), + providesTags: ["lesson"], + }), + getTaskStudentSolutions: builder.query({ + query: ({taskId, studentId}) => ({ + url: `/solutions/task/${taskId}/student/${studentId}/`, + method: "GET" + }), + providesTags: ["lesson"], + }), + createSolution: builder.mutation({ + query: ({taskId, solution}) => ({ + url: `/solutions/${taskId}/`, + method: "POST", + body: solution, + }), + invalidatesTags: ["lesson"], + }), + deleteSolution: builder.mutation({ + query: (solutionId) => ({ + url: `/solutions/${solutionId}/`, + method: "DELETE", + }), + invalidatesTags: ["lesson"], + }), + getSolutionFilesList: builder.query({ + query: (solutionId) => ({ + url: `/solutions/files/${solutionId}/`, + method: "GET" + }), + providesTags: ["lesson"], + }), + uploadFile: builder.mutation({ + query: ({solutionId, fileData}) => { + if (!(fileData instanceof File)) { + throw new Error('Invalid file object'); + } + const formData = new FormData(); + formData.append('file', fileData); + return { + url: `/solutions/files/${solutionId}/upload/`, + method: 'POST', + formData: true, + body: formData, + }; + }, + invalidatesTags: ["task"], + }), + }), +}); + +export const { + useGetTaskSolutionsQuery, + useGetTaskStudentSolutionsQuery, + useCreateSolutionMutation, + useDeleteSolutionMutation, + useGetSolutionFilesListQuery, + useUploadFileMutation, +} = solutionsApi; + diff --git a/web/src/Components/Pages/CourseDetailPage/Components/CreateTaskModalForm/useCreateTaskModalForm.js b/web/src/Components/Pages/CourseDetailPage/Components/CreateTaskModalForm/useCreateTaskModalForm.js index 4597ee3..d627473 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/CreateTaskModalForm/useCreateTaskModalForm.js +++ b/web/src/Components/Pages/CourseDetailPage/Components/CreateTaskModalForm/useCreateTaskModalForm.js @@ -12,7 +12,7 @@ const useCreateTaskModalForm = ({courseId}) => { } = useSelector((state) => state.tasks); const [form] = Form.useForm(); - const [createtask, {isLoading}] = useCreateTaskMutation(); + const [createTask, {isLoading}] = useCreateTaskMutation(); const [draftFiles, setDraftFiles] = useState([]); const [uploadFile] = useUploadFileMutation(); @@ -57,7 +57,7 @@ const useCreateTaskModalForm = ({courseId}) => { number: values.number || 1, }; - const response = await createtask({ + const response = await createTask({ courseId, taskData, }).unwrap(); diff --git a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx index db0c940..43c0ad6 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx +++ b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx @@ -1,8 +1,32 @@ -import {Avatar, Button, Col, Divider, Modal, Popconfirm, Row, Space, Spin, Typography} from "antd"; -import {CloseOutlined, UserOutlined} from "@ant-design/icons"; +import { + Avatar, + Button, + Col, Collapse, + Divider, + Empty, Flex, + Form, + Modal, + Popconfirm, + Row, + Space, + Spin, + Tag, + Typography, + Upload +} from "antd"; +import { + CloseOutlined, DeleteOutlined, + DownloadOutlined, + FileOutlined, + PlusOutlined, + UploadOutlined, + UserOutlined +} from "@ant-design/icons"; import useViewTaskModal from "./useTaskLessonModal.js"; +import {ROLES} from "../../../../../Core/constants.js"; +import JoditEditor from "jodit-react"; - +const {Panel} = Collapse; const {Title, Text, Paragraph} = Typography; const ViewTaskModal = () => { @@ -14,7 +38,16 @@ const ViewTaskModal = () => { isCurrentTaskFilesLoading, isCurrentTaskFilesError, downloadFile, - downloadingFiles + downloadingFiles, + currentUser, + mySolutions, + editorRef, + joditConfig, + handleAddFile, + handleRemoveFile, + handleOk, + draftFiles, + handleDeleSolution } = useViewTaskModal(); return ( @@ -102,6 +135,133 @@ const ViewTaskModal = () => {
Файлы отсутствуют
)} + {currentUser?.role?.title === ROLES.STUDENT ? ( +