Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions alembic/versions/4baf7c606f77_testcase_table_added.py
Original file line number Diff line number Diff line change
@@ -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 ###

This file was deleted.

7 changes: 6 additions & 1 deletion app/api/schema/exercise.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional

from pydantic import BaseModel
Expand All @@ -7,14 +8,18 @@ class ExerciseCreate(BaseModel):
title: str
markdown: str
coding_mode: str
allow_skip_after: Optional[int]
skip_delay: int
next_exercise_id: Optional[int]


class ExerciseRead(ExerciseCreate):
id: int


class ExerciseWithSkipUnlockTime(ExerciseRead):
skip_unlock_time: datetime


class SystemState(BaseModel):
registers: dict[str, int]
memory: dict[int, int]
Expand Down
50 changes: 29 additions & 21 deletions app/api/v1/exercise.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from datetime import timedelta, datetime, timezone

import sqlalchemy as sa
Expand All @@ -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
Expand All @@ -19,28 +21,26 @@


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

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)
Expand Down Expand Up @@ -68,31 +68,39 @@ 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
)

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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/db/model/exercise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}


Expand Down
46 changes: 28 additions & 18 deletions tests/test_exercise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
}

Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions tests/util/demo_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,23 @@
"title": "Demo Exercise 1",
"markdown": "",
"coding_mode": "bbp",
"allow_skip_after": 5,
"skip_delay": 5,
"next_exercise_id": 2,
},
{
"id": 2,
"title": "Demo exercise 2",
"markdown": "",
"coding_mode": "bbp",
"allow_skip_after": 5,
"skip_delay": 5,
"next_exercise_id": 3,
},
{
"id": 3,
"title": "Demo exercise 3",
"markdown": "",
"coding_mode": "bbp",
"allow_skip_after": 5,
"skip_delay": 5,
"next_exercise_id": None,
}
]
Expand Down