diff --git a/alembic/versions/4baf7c606f77_testcase_table_added.py b/alembic/versions/4baf7c606f77_testcase_table_added.py new file mode 100644 index 0000000..2b41aeb --- /dev/null +++ b/alembic/versions/4baf7c606f77_testcase_table_added.py @@ -0,0 +1,51 @@ +"""testcase table added + +Revision ID: 4baf7c606f77 +Revises: 9a72ad7167bf +Create Date: 2025-11-06 14:21:18.550847 + +""" +from typing import Sequence, Union + +from alembic import op + +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4baf7c606f77' +down_revision: Union[str, Sequence[str], None] = '9a72ad7167bf' +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('test_case', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('exercise_id', sa.INTEGER(), nullable=False), + sa.Column('title', sa.TEXT(), nullable=False), + sa.Column('precondition', sa.JSON(), nullable=False), + sa.Column('postcondition', sa.JSON(), nullable=False), + sa.Column('user_input', sa.JSON(), nullable=False), + sa.Column('expected_output', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['exercise_id'], ['exercise.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('exercise', sa.Column('skip_delay', sa.Integer(), nullable=False)) + op.drop_column('exercise', 'allow_skip_after') + op.add_column('grading_job', sa.Column('passed', sa.BOOLEAN(), nullable=True)) + op.add_column('grading_job', sa.Column('feedback', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('grading_job', 'feedback') + op.drop_column('grading_job', 'passed') + op.add_column('exercise', sa.Column('allow_skip_after', sa.INTEGER(), autoincrement=False, nullable=True)) + op.drop_column('exercise', 'skip_delay') + op.drop_table('test_case') + # ### end Alembic commands ### diff --git a/alembic/versions/53a4dee6ef34_add_passed_and_feedback_column_to_.py b/alembic/versions/53a4dee6ef34_add_passed_and_feedback_column_to_.py deleted file mode 100644 index b57db2f..0000000 --- a/alembic/versions/53a4dee6ef34_add_passed_and_feedback_column_to_.py +++ /dev/null @@ -1,33 +0,0 @@ -"""add passed and feedback column to GradingJob + add title to TestCase - -Revision ID: 53a4dee6ef34 -Revises: 9a72ad7167bf -Create Date: 2025-10-09 14:36:41.006723 - -""" -from typing import Sequence, Union - -from alembic import op - -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '53a4dee6ef34' -down_revision: Union[str, Sequence[str], None] = '9a72ad7167bf' -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.add_column('test_case', sa.Column('title', sa.TEXT(), nullable=False)) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('test_case', 'title') - # ### end Alembic commands ### diff --git a/app/api/schema/exercise.py b/app/api/schema/exercise.py index 2fec47a..4295a16 100644 --- a/app/api/schema/exercise.py +++ b/app/api/schema/exercise.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from pydantic import BaseModel @@ -7,7 +8,7 @@ class ExerciseCreate(BaseModel): title: str markdown: str coding_mode: str - allow_skip_after: Optional[int] + skip_delay: int next_exercise_id: Optional[int] @@ -15,6 +16,10 @@ class ExerciseRead(ExerciseCreate): id: int +class ExerciseWithSkipUnlockTime(ExerciseRead): + skip_unlock_time: datetime + + class SystemState(BaseModel): registers: dict[str, int] memory: dict[int, int] diff --git a/app/api/v1/exercise.py b/app/api/v1/exercise.py index 5d0ea36..d49dbc3 100644 --- a/app/api/v1/exercise.py +++ b/app/api/v1/exercise.py @@ -1,3 +1,4 @@ +import logging from datetime import timedelta, datetime, timezone import sqlalchemy as sa @@ -6,7 +7,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.schema.exercise import ExerciseRead, ExerciseCreate, TestCaseRead, TestCaseCreate +from app.api.schema.exercise import ExerciseRead, ExerciseCreate, TestCaseRead, TestCaseCreate, \ + ExerciseWithSkipUnlockTime from app.db.database import get_session from app.db.model import Tan from app.db.model.exercise import Exercise, ExerciseProgress, Competition, TestCase @@ -19,10 +21,10 @@ @router.get("/current", - response_model=ExerciseRead, + response_model=ExerciseWithSkipUnlockTime, status_code=status.HTTP_200_OK) async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(get_session), - now: datetime = Depends(get_datetime_now)) -> ExerciseRead | Response: + now: datetime = Depends(get_datetime_now)) -> ExerciseWithSkipUnlockTime | Response: statement = select(Tan).where(Tan.code == tan_code) result = await session.execute(statement) tan = result.scalars().first() @@ -30,17 +32,15 @@ async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(ge if not tan: raise HTTPException(status_code=404, detail="TAN code not found") - statement = (select(Exercise) - .where(Exercise.id == (select(ExerciseProgress.exercise_id) - .where(sa.and_(ExerciseProgress.tan_code == tan_code, - ExerciseProgress.end_time.is_(None))) - .scalar_subquery()))) + statement = (select(Exercise, ExerciseProgress) + .join(ExerciseProgress, ExerciseProgress.exercise_id == Exercise.id) + .where(sa.and_(ExerciseProgress.tan_code == tan_code, + ExerciseProgress.end_time.is_(None)))) result = await session.execute(statement) - exercise = result.scalars().first() - - if not exercise: + exercise_and_progress = result.first() + if not exercise_and_progress: stmt = ( select(Exercise) .join(ExerciseProgress, ExerciseProgress.exercise_id == Exercise.id) @@ -68,18 +68,21 @@ async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(ge stmt = select(Exercise).where(Exercise.id == last_exercise.next_exercise_id) result = await session.execute(stmt) exercise = result.scalars().first() - return ExerciseRead(**exercise.to_dict()) + return ExerciseWithSkipUnlockTime(**exercise.to_dict(), + skip_unlock_time=(now + timedelta(minutes=exercise.skip_delay))) else: - stmt = (select(Competition) + stmt = (select(Exercise) + .join(Competition, Competition.first_exercise_id == Exercise.id) .join(Tan, Tan.competition_id == Competition.id) .where(Tan.code == tan_code)) + result = await session.execute(stmt) - first_exercise_id = result.scalars().first().first_exercise_id + first_exercise = result.scalars().first() ep = ExerciseProgress( tan_code=tan_code, - exercise_id=first_exercise_id, + exercise_id=first_exercise.id, start_time=now, skipped=False ) @@ -87,12 +90,17 @@ async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(ge session.add(ep) await session.commit() - stmt = select(Exercise).where(Exercise.id == first_exercise_id) - result = await session.execute(stmt) - exercise = result.scalars().first() - return ExerciseRead(**exercise.to_dict()) + await session.refresh(first_exercise) - return ExerciseRead(**exercise.to_dict()) + return ExerciseWithSkipUnlockTime(**first_exercise.to_dict(), + skip_unlock_time=(now + timedelta(minutes=first_exercise.skip_delay))) + + exercise, progress = exercise_and_progress + + logging.info(progress.start_time.tzinfo) + + return ExerciseWithSkipUnlockTime(**exercise.to_dict(), + skip_unlock_time=(progress.start_time + timedelta(minutes=exercise.skip_delay))) @router.post("/current/skip", status_code=status.HTTP_204_NO_CONTENT) @@ -117,7 +125,7 @@ async def post_skip_current_exercise(tan_code: str, session: AsyncSession = Depe current_exercise: Exercise = result.scalars().first() allow_skip_after_date = (exercise_progress.start_time - + timedelta(minutes=current_exercise.allow_skip_after)) + + timedelta(minutes=current_exercise.skip_delay)) if allow_skip_after_date.tzinfo is None: allow_skip_after_date = allow_skip_after_date.replace(tzinfo=timezone.utc) diff --git a/app/db/model/exercise.py b/app/db/model/exercise.py index ee57451..7324bcd 100644 --- a/app/db/model/exercise.py +++ b/app/db/model/exercise.py @@ -11,7 +11,7 @@ class Exercise(Base): markdown = sa.Column(sa.TEXT, nullable=False) coding_mode = sa.Column(sa.VARCHAR(3), nullable=False) next_exercise_id = sa.Column(sa.Integer, sa.ForeignKey("exercise.id"), nullable=True) - allow_skip_after = sa.Column(sa.Integer, nullable=True) + skip_delay = sa.Column(sa.Integer, nullable=False) def to_dict(self): return { @@ -20,7 +20,7 @@ def to_dict(self): "markdown": self.markdown, "coding_mode": self.coding_mode, "next_exercise_id": self.next_exercise_id, - "allow_skip_after": self.allow_skip_after, + "skip_delay": self.skip_delay, } diff --git a/tests/test_exercise.py b/tests/test_exercise.py index 9db20d0..61ecd8e 100644 --- a/tests/test_exercise.py +++ b/tests/test_exercise.py @@ -12,6 +12,13 @@ from tests.util.demo_data import EXERCISES +def get_datetime_now_override(datetime_now): + def now(): + yield datetime_now + + return now + + class TestExercise: def setup_class(self): @@ -38,7 +45,7 @@ def test_post_exercise(self): "title": "posted exercise", "markdown": "", "coding_mode": "bbp", - "allow_skip_after": None, + "skip_delay": 10, "next_exercise_id": None, } @@ -59,8 +66,11 @@ def test_get_current_exercise(self): response = client.get("/exercises/current", params={"tan_code": "test-tan-1"}) + exercise_1 = dict(EXERCISES[1]) + exercise_1["skip_unlock_time"] = datetime(year=2025, month=10, day=7, hour=19, minute=35, second=0).isoformat() + assert response.status_code == 200 - assert response.json() == EXERCISES[1] + assert response.json() == exercise_1 def test_get_current_exercise_with_none_existing_tan(self): app.dependency_overrides[get_session] = get_override_dependency(self.engine) @@ -71,22 +81,32 @@ def test_get_current_exercise_with_none_existing_tan(self): assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_current_exercise_with_missing_current_progress_entry_1(self): + app.dependency_overrides[get_datetime_now] = get_datetime_now_override( + datetime(year=2025, month=10, day=7, hour=19, minute=35, second=0, tzinfo=timezone.utc)) app.dependency_overrides[get_session] = get_override_dependency(self.engine) client = TestClient(app) response = client.get("/exercises/current", params={"tan_code": "test-tan-2"}) + exercise_2 = dict(EXERCISES[2]) + exercise_2["skip_unlock_time"] = "2025-10-07T19:40:00Z" + assert response.status_code == 200 - assert response.json() == EXERCISES[2] + assert response.json() == exercise_2 def test_get_current_exercise_with_missing_current_progress_entry_2(self): + app.dependency_overrides[get_datetime_now] = get_datetime_now_override( + datetime(year=2025, month=10, day=7, hour=19, minute=35, second=0, tzinfo=timezone.utc)) app.dependency_overrides[get_session] = get_override_dependency(self.engine) client = TestClient(app) response = client.get("/exercises/current", params={"tan_code": "test-tan-3"}) + exercise_0 = dict(EXERCISES[0]) + exercise_0["skip_unlock_time"] = "2025-10-07T19:40:00Z" + assert response.status_code == 200 - assert response.json() == EXERCISES[0] + assert response.json() == exercise_0 def test_post_test_case(self): app.dependency_overrides[get_session] = get_override_dependency(self.engine) @@ -169,13 +189,8 @@ def test_post_skip_exercise_with_invalid_tan(self): assert response.status_code == status.HTTP_404_NOT_FOUND def test_post_skip_exercise_before_deadline(self): - def get_datetime_now_override(): - def now(): - yield datetime(2025, 10, 7, 19, 31, 0, tzinfo=timezone.utc) - - return now - - app.dependency_overrides[get_datetime_now] = get_datetime_now_override() + app.dependency_overrides[get_datetime_now] = get_datetime_now_override( + datetime(2025, 10, 7, 19, 31, 0, tzinfo=timezone.utc)) app.dependency_overrides[get_session] = get_override_dependency(self.engine) client = TestClient(app) @@ -188,13 +203,8 @@ def now(): } def test_post_skip_exercise_after_deadline(self): - def get_datetime_now_override(): - def now(): - yield datetime(2026, 10, 7, 19, 31, 0, tzinfo=timezone.utc) - - return now - - app.dependency_overrides[get_datetime_now] = get_datetime_now_override() + app.dependency_overrides[get_datetime_now] = get_datetime_now_override( + datetime(2026, 10, 7, 19, 31, 0, tzinfo=timezone.utc)) app.dependency_overrides[get_session] = get_override_dependency(self.engine) client = TestClient(app) diff --git a/tests/util/demo_data.py b/tests/util/demo_data.py index 554772d..ca730c7 100644 --- a/tests/util/demo_data.py +++ b/tests/util/demo_data.py @@ -58,7 +58,7 @@ "title": "Demo Exercise 1", "markdown": "", "coding_mode": "bbp", - "allow_skip_after": 5, + "skip_delay": 5, "next_exercise_id": 2, }, { @@ -66,7 +66,7 @@ "title": "Demo exercise 2", "markdown": "", "coding_mode": "bbp", - "allow_skip_after": 5, + "skip_delay": 5, "next_exercise_id": 3, }, { @@ -74,7 +74,7 @@ "title": "Demo exercise 3", "markdown": "", "coding_mode": "bbp", - "allow_skip_after": 5, + "skip_delay": 5, "next_exercise_id": None, } ]