diff --git a/api/app/application/courses_repository.py b/api/app/application/courses_repository.py index 0fd4494..6dd6964 100644 --- a/api/app/application/courses_repository.py +++ b/api/app/application/courses_repository.py @@ -2,6 +2,7 @@ from typing import List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.domain.models import Course @@ -11,14 +12,24 @@ class CoursesRepository: self.db = db async def get_all(self) -> List[Course]: - query = select(Course) + query = ( + select(Course) + .options( + selectinload(Course.teachers), + selectinload(Course.enrollments) + ) + ) result = await self.db.execute(query) return result.scalars().all() async def get_by_id(self, course_id: int) -> Optional[Course]: query = ( select(Course) - .order_by(id=course_id) + .options( + selectinload(Course.teachers), + selectinload(Course.enrollments) + ) + .filter_by(id=course_id) ) result = await self.db.execute(query) return result.scalars().first() diff --git a/api/app/controllers/courses_router.py b/api/app/controllers/courses_router.py index 8a416b6..3cc3ed6 100644 --- a/api/app/controllers/courses_router.py +++ b/api/app/controllers/courses_router.py @@ -5,12 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_db from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate -from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate +from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate from app.domain.models import User from app.infrastructure.course_teachers_service import CourseTeachersService from app.infrastructure.courses_service import CoursesService -from app.infrastructure.dependencies import require_auth_user, require_teacher +from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin from app.infrastructure.enrollments_service import EnrollmentsService courses_router = APIRouter() @@ -24,7 +24,7 @@ courses_router = APIRouter() ) async def get_all_courses( db: AsyncSession = Depends(get_db), - user: User = Depends(require_auth_user), + user: User = Depends(require_admin), ): courses_service = CoursesService(db) return await courses_service.get_all() @@ -32,7 +32,7 @@ async def get_all_courses( @courses_router.post( '/', - response_model=Optional[CourseRead], + response_model=Optional[CourseCreated], summary='Create a new course', description='Create a new course', ) diff --git a/api/app/database/alembic/versions/33d77ac5ed79_0002_сделал_поле_с_фото_необязательным.py b/api/app/database/alembic/versions/33d77ac5ed79_0002_сделал_поле_с_фото_необязательным.py new file mode 100644 index 0000000..a13123e --- /dev/null +++ b/api/app/database/alembic/versions/33d77ac5ed79_0002_сделал_поле_с_фото_необязательным.py @@ -0,0 +1,106 @@ +"""0002 сделал поле с фото необязательным + +Revision ID: 33d77ac5ed79 +Revises: 6241a16321b4 +Create Date: 2025-11-28 19:23:15.655318 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '33d77ac5ed79' +down_revision: Union[str, Sequence[str], None] = '6241a16321b4' +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.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', 'users', ['teacher_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'course_teachers', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.alter_column('courses', 'photo_filename', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('courses', 'photo_path', + existing_type=sa.VARCHAR(), + nullable=True) + 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', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'enrollments', 'users', ['student_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_course_id_fkey'), 'lessons', type_='foreignkey') + op.drop_constraint(op.f('lessons_creator_id_fkey'), 'lessons', type_='foreignkey') + op.create_foreign_key(None, 'lessons', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public') + op.create_foreign_key(None, 'lessons', 'courses', ['course_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', '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.create_foreign_key(None, 'solutions', 'tasks', ['task_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_creator_id_fkey'), 'tasks', type_='foreignkey') + op.drop_constraint(op.f('tasks_course_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', '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_course_id_fkey'), 'tasks', 'courses', ['course_id'], ['id']) + op.create_foreign_key(op.f('tasks_creator_id_fkey'), 'tasks', 'users', ['creator_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, 'lessons', schema='public', type_='foreignkey') + op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('lessons_creator_id_fkey'), 'lessons', 'users', ['creator_id'], ['id']) + op.create_foreign_key(op.f('lessons_course_id_fkey'), 'lessons', 'courses', ['course_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.alter_column('courses', 'photo_path', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('courses', 'photo_filename', + existing_type=sa.VARCHAR(), + nullable=False) + 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']) + # ### end Alembic commands ### diff --git a/api/app/domain/entities/course_teachers.py b/api/app/domain/entities/course_teachers.py index 17cfeee..e2e9c6d 100644 --- a/api/app/domain/entities/course_teachers.py +++ b/api/app/domain/entities/course_teachers.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, EmailStr, Field class CourseTeacherCreate(BaseModel): - course_id: int = Field() teacher_id: int = Field() diff --git a/api/app/domain/entities/courses.py b/api/app/domain/entities/courses.py index 47934f9..477a778 100644 --- a/api/app/domain/entities/courses.py +++ b/api/app/domain/entities/courses.py @@ -6,6 +6,17 @@ from app.domain.entities.course_teachers import CourseTeacherRead from app.domain.entities.enrollments import EnrollmentRead +class CourseBase(BaseModel): + id: int + title: str + description: Optional[str] = None + photo_filename: Optional[str] = None + photo_path: Optional[str] = None + + class Config: + from_attributes = True + + class CourseCreate(BaseModel): title: str = Field(max_length=250) description: Optional[str] = Field(default=None, max_length=1000) @@ -15,13 +26,14 @@ class CourseUpdate(CourseCreate): pass -class CourseRead(BaseModel): - id: int - title: str - description: str - - teachers: List[CourseTeacherRead] - enrollments: List[EnrollmentRead] +class CourseRead(CourseBase): + teachers: List[CourseTeacherRead] = [] + enrollments: List[EnrollmentRead] = [] class Config: from_attributes = True + + +class CourseCreated(CourseBase): + class Config: + from_attributes = True diff --git a/api/app/domain/entities/enrollments.py b/api/app/domain/entities/enrollments.py index 343ba20..ded455e 100644 --- a/api/app/domain/entities/enrollments.py +++ b/api/app/domain/entities/enrollments.py @@ -1,10 +1,10 @@ from datetime import datetime +from typing import Optional from pydantic import BaseModel, EmailStr, Field class EnrollmentCreate(BaseModel): - course_id: int = Field() student_id: int = Field() @@ -12,7 +12,7 @@ class EnrollmentRead(BaseModel): id: int course_id: int student_id: int - enrollment_date: datetime + enrollment_date: Optional[datetime] = None class Config: from_attributes = True diff --git a/api/app/domain/models/base.py b/api/app/domain/models/base.py index 33b76b9..dea194c 100644 --- a/api/app/domain/models/base.py +++ b/api/app/domain/models/base.py @@ -17,8 +17,8 @@ class RootTable(Base): class PhotoAbstract(RootTable): __abstract__ = True - photo_filename: Mapped[str] = mapped_column() - photo_path: Mapped[str] = mapped_column() + photo_filename: Mapped[str] = mapped_column(nullable=True) + photo_path: Mapped[str] = mapped_column(nullable=True) class FileAbstract(RootTable): diff --git a/api/app/domain/models/enrollments.py b/api/app/domain/models/enrollments.py index 9eb8393..1609a04 100644 --- a/api/app/domain/models/enrollments.py +++ b/api/app/domain/models/enrollments.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import ForeignKey +from sqlalchemy import ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.domain.models.base import RootTable diff --git a/api/app/infrastructure/courses_service.py b/api/app/infrastructure/courses_service.py index db7fffd..798b686 100644 --- a/api/app/infrastructure/courses_service.py +++ b/api/app/infrastructure/courses_service.py @@ -4,7 +4,7 @@ from fastapi import HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.application.courses_repository import CoursesRepository -from app.domain.entities.courses import CourseRead, CourseCreate +from app.domain.entities.courses import CourseRead, CourseCreate, CourseCreated from app.domain.models import Course @@ -22,7 +22,7 @@ class CoursesService: return response - async def create(self, course: CourseCreate) -> Optional[CourseRead]: + async def create(self, course: CourseCreate) -> CourseCreated: # ← возвращаем CourseCreated course_model = Course( title=course.title, description=course.description, @@ -30,7 +30,7 @@ class CoursesService: course_model = await self.courses_repository.create(course_model) - return CourseRead.model_validate(course_model) + return CourseCreated.model_validate(course_model) async def update(self, course_id: int, course: CourseCreate) -> Optional[CourseRead]: course_model = await self.courses_repository.get_by_id(course_id) diff --git a/api/app/infrastructure/enrollments_service.py b/api/app/infrastructure/enrollments_service.py index a835f42..981ddf4 100644 --- a/api/app/infrastructure/enrollments_service.py +++ b/api/app/infrastructure/enrollments_service.py @@ -1,3 +1,4 @@ +import datetime from typing import Optional, List from fastapi import HTTPException, status @@ -52,7 +53,7 @@ class EnrollmentsService: enrollments_models.append(Enrollment( course_id=course_id, student_id=enrollment.student_id, - enrollment_date=enrollment.enrollment_date, + enrollment_date=datetime.datetime.now(), )) enrollments_models = await self.enrollments_repository.create_list(enrollments_models) diff --git a/web/index.html b/web/index.html index 9c049fa..4a6752d 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ -