сделал добавление и удаление ответа на задание

This commit is contained in:
Андрей Дувакин 2025-11-29 16:47:14 +05:00
parent 1fb12bc7e4
commit d57f2de2da
19 changed files with 1102 additions and 16 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 ###

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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',
)

View File

@ -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)

View File

@ -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"

View File

@ -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)

View File

@ -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'])

View File

@ -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;

View File

@ -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();

View File

@ -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 = () => {
<p>Файлы отсутствуют</p>
)}
{currentUser?.role?.title === ROLES.STUDENT ? (
<Col>
<Title level={3}>Ваши решения</Title>
{mySolutions.length > 0 ? (
<Collapse accordion>
{mySolutions.map((solution) => (
<Panel
key={solution.id}
header={
<Flex justify="space-between" align="center">
<Text strong>Решение
от {new Date(solution.created_at).toLocaleString("ru-RU")}</Text>
{solution.assessment !== null ? (
<Tag
color={solution.assessment >= 80 ? "green" : solution.assessment >= 60 ? "orange" : "red"}>
Оценка: {solution.assessment} / 100
</Tag>
) : (
<Tag color="blue">На проверке</Tag>
)}
<Popconfirm
title={`Удалить ответ на задание?`}
description="Это действие нельзя отменить"
onConfirm={(e) => {
handleDeleSolution(solution.id)
}}
okText="Удалить"
cancelText="Отмена"
>
<Button
type="text"
danger
icon={<DeleteOutlined/>}
/>
</Popconfirm>
</Flex>
}
extra={
solution.assessment !== null && (
<Tag color="purple">
Проверено: {solution.assessment_autor?.first_name} {solution.assessment_autor?.last_name}
</Tag>
)
}
>
<div style={{marginBottom: 16}}>
<Text strong>Ответ:</Text>
<Paragraph
style={{
background: "#f9f9f9",
padding: 12,
borderRadius: 8,
margin: "8px 0",
whiteSpace: "pre-wrap",
}}
>
{solution.answer_text}
</Paragraph>
</div>
{solution.files && solution.files.length > 0 ? (
<div>
<Text strong>Прикреплённые файлы:</Text>
<div style={{
marginTop: 8,
display: "flex",
flexDirection: "column",
gap: 8
}}>
{solution.files.map((file) => (
<Button
key={file.id}
type="dashed"
icon={<FileOutlined/>}
style={{textAlign: "left"}}
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id]}
>
<span style={{marginLeft: 8}}>
{file.filename} ({(file.file_size / 1024 / 1024).toFixed(2)} МБ)
</span>
<DownloadOutlined style={{marginLeft: 8, color: "#1890ff"}}/>
</Button>
))}
</div>
</div>
) : (
<Text type="secondary">Файлы не прикреплены</Text>
)}
</Panel>
))}
</Collapse>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Решений пока нет"
/>
)}
<Title level={3}>Добавить решение</Title>
<div style={{border: "1px solid #d9d9d9", borderRadius: 8, overflow: "hidden"}}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
<Divider/>
<Upload
fileList={draftFiles}
beforeUpload={(file) => {
handleAddFile(file);
return false;
}}
onRemove={(file) => handleRemoveFile(file)}
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
<Divider/>
<Button type="primary" onClick={handleOk}>
Сохранить решение
</Button>
</Col>
) : [ROLES.ADMIN, ROLES.TEACHER].includes(currentUser?.role?.title) && (
<></>
)}
<div style={{textAlign: "right"}}>
<Button onClick={handleClose}>
Закрыть

View File

@ -2,8 +2,15 @@ import {useDispatch, useSelector} from "react-redux";
import {setSelectedTaskToView} from "../../../../../Redux/Slices/tasksSlice.js";
import {notification} from "antd";
import CONFIG from "../../../../../Core/сonfig.js";
import {useState} from "react";
import {useMemo, useRef, useState} from "react";
import {useGetTaskFilesListQuery} from "../../../../../Api/tasksApi.js";
import {useGetAuthenticatedUserDataQuery} from "../../../../../Api/usersApi.js";
import {
useCreateSolutionMutation, useDeleteSolutionMutation,
useGetTaskStudentSolutionsQuery,
useUploadFileMutation
} from "../../../../../Api/solutionsApi.js";
import {ROLES} from "../../../../../Core/constants.js";
const useViewTaskModal = () => {
@ -13,12 +20,113 @@ const useViewTaskModal = () => {
selectedTaskToView
} = useSelector((state) => state.tasks);
const [
craeteColution,
{
isLoading: isCreatingSolution,
isError: isErrorCreatingSoltion
}
] = useCreateSolutionMutation();
const modalIsOpen = selectedTaskToView !== null;
const [draftFiles, setDraftFiles] = useState([]);
const [uploadFile] = useUploadFileMutation();
const [deleteSolution] = useDeleteSolutionMutation();
const handleAddFile = (file) => {
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
if (file.size > maxSize) {
notification.error({
message: "Ошибка вставки",
description: "Файл слишком большой.",
placement: "topRight",
});
return false;
}
setDraftFiles((prev) => [...prev, file]);
return false;
};
const handleDeleSolution = async (solutionId) => {
try {
await deleteSolution(solutionId);
notification.success({
title: "Успех",
description: "Задание успешно удалено!",
placement: "topRight",
})
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось удалить задание",
placement: "topRight",
})
}
};
const handleRemoveFile = (file) => {
setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid));
};
const editorRef = useRef(null);
const handleOk = async () => {
try {
const content = editorRef.current?.value || "";
const solutionData = {
answer_text: content,
};
const response = await craeteColution({
taskId: selectedTaskToView?.id,
solution: solutionData,
}).unwrap();
for (const file of draftFiles) {
try {
await uploadFile({
solutionId: response.id,
fileData: file,
}).unwrap();
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
}
notification.success({
title: "Успех",
description: "Задание успешно создано!",
placement: "topRight",
});
if (editorRef.current) {
editorRef.current.value = "";
}
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось создать задание",
placement: "topRight",
});
}
};
const handleClose = () => {
dispatch(setSelectedTaskToView(null));
};
const {
data: currentTaskFiles = [],
isLoading: isCurrentTaskFilesLoading,
@ -27,6 +135,24 @@ const useViewTaskModal = () => {
skip: !selectedTaskToView?.id
});
const {
data: currentUser = {},
isLoading: isCurrentUserLoading,
isError: isCurrentUserError,
} = useGetAuthenticatedUserDataQuery();
const {
data: mySolutions = [],
isLoading: isMySolutionsLoading,
isError: isMySolutionsError
} = useGetTaskStudentSolutionsQuery({
taskId: selectedTaskToView?.id,
studentId: currentUser?.id
}, {
skip: !selectedTaskToView?.id || currentUser?.role?.title !== ROLES.STUDENT,
pollingInterval: 5000,
});
const [downloadingFiles, setDownloadingFiles] = useState({});
const downloadFile = async (fileId, fileName) => {
@ -41,16 +167,15 @@ const useViewTaskModal = () => {
placement: "topRight",
});
return;
}
};
const response = await fetch(`${CONFIG.BASE_URL}/tasks/file/${fileId}/`, {
const response = await fetch(`${CONFIG.BASE_URL}/solutions/file/${fileId}/`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
@ -59,7 +184,7 @@ const useViewTaskModal = () => {
placement: "topRight",
});
return;
}
};
const contentType = response.headers.get('content-type');
if (!contentType || contentType.includes('text/html')) {
@ -105,6 +230,70 @@ const useViewTaskModal = () => {
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
height: 150,
toolbarAdaptive: false,
buttons: [
"bold", "italic", "underline", "strikethrough", "|",
"superscript", "subscript", "|",
"ul", "ol", "outdent", "indent", "|",
"font", "fontsize", "brush", "paragraph", "|",
"align", "hr", "|",
"table", "link", "image", "video", "symbols", "|",
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
"find", "source", "fullsize", "print", "preview",
],
autofocus: false,
preserveSelection: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
spellcheck: true,
placeholder: "Заполните содержимое задания",
showCharsCounter: true,
showWordsCounter: true,
showXPathInStatusbar: false,
toolbarSticky: true,
toolbarButtonSize: "middle",
cleanHTML: {
removeEmptyElements: true,
replaceNBSP: false,
},
hotkeys: {
"ctrl + shift + f": "find",
"ctrl + b": "bold",
"ctrl + i": "italic",
"ctrl + u": "underline",
},
image: {
editSrc: true,
editTitle: true,
editAlt: true,
openOnDblClick: false,
},
video: {
allowedSources: ["youtube", "vimeo"],
},
uploader: {
insertImageAsBase64URI: true,
},
paste: {
insertAsBase64: true,
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 5 * 1024 * 1024,
error: () => {
notification.error({
title: "Ошибка вставки",
description: "Файл слишком большой или неподдерживаемый формат.",
placement: "topRight",
});
},
},
}),
[]
);
return {
selectedTaskToView,
@ -114,7 +303,16 @@ const useViewTaskModal = () => {
isCurrentTaskFilesLoading,
isCurrentTaskFilesError,
downloadFile,
downloadingFiles
downloadingFiles,
currentUser,
mySolutions,
editorRef,
joditConfig,
handleAddFile,
handleRemoveFile,
handleOk,
draftFiles,
handleDeleSolution
}
};

View File

@ -16,7 +16,7 @@ import {
setSelectedTaskToUpdate,
setSelectedTaskToView
} from "../../../Redux/Slices/tasksSlice.js";
import {useGetTasksByCourseIdQuery} from "../../../Api/tasksApi.js";
import {useDeleteTaskMutation, useGetTasksByCourseIdQuery} from "../../../Api/tasksApi.js";
const useCourseDetailPage = (courseId) => {
@ -58,6 +58,10 @@ const useCourseDetailPage = (courseId) => {
deleteLesson,
] = useDeleteLessonMutation();
const [
deleteTask,
] = useDeleteTaskMutation();
const handleDeleteLesson = async (lessonId) => {
try {
await deleteLesson(lessonId);
@ -77,7 +81,20 @@ const useCourseDetailPage = (courseId) => {
};
const handleDeleteTask = async (taskId) => {
try {
await deleteTask(taskId);
notification.success({
title: "Успешно",
description: "Задание удалено",
placement: "topRight",
});
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Произошла ошибка при удалении задания",
placement: "topRight",
})
}
};
useEffect(() => {

View File

@ -11,6 +11,7 @@ import {statusesApi} from "../Api/statusesApi.js";
import {coursesApi} from "../Api/coursesApi.js";
import {lessonsApi} from "../Api/lessonsApi.js";
import {tasksApi} from "../Api/tasksApi.js";
import {solutionsApi} from "../Api/solutionsApi.js";
export const store = configureStore({
reducer: {
@ -32,6 +33,8 @@ export const store = configureStore({
tasks: tasksReducer,
[tasksApi.reducerPath]: tasksApi.reducer,
[solutionsApi.reducerPath]: solutionsApi.reducer,
},
middleware: (getDefaultMiddleware) => (
getDefaultMiddleware().concat(
@ -42,6 +45,7 @@ export const store = configureStore({
coursesApi.middleware,
lessonsApi.middleware,
tasksApi.middleware,
solutionsApi.middleware,
)
),
});