Skip to content

Commit d4d053c

Browse files
author
Florian Wörister
committed
include skip_unlock_time in /exercises/current response
1 parent 36b29dc commit d4d053c

File tree

7 files changed

+119
-78
lines changed

7 files changed

+119
-78
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""testcase table added
2+
3+
Revision ID: 4baf7c606f77
4+
Revises: 9a72ad7167bf
5+
Create Date: 2025-11-06 14:21:18.550847
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = '4baf7c606f77'
17+
down_revision: Union[str, Sequence[str], None] = '9a72ad7167bf'
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Upgrade schema."""
24+
# ### commands auto generated by Alembic - please adjust! ###
25+
op.create_table('test_case',
26+
sa.Column('id', sa.INTEGER(), nullable=False),
27+
sa.Column('exercise_id', sa.INTEGER(), nullable=False),
28+
sa.Column('title', sa.TEXT(), nullable=False),
29+
sa.Column('precondition', sa.JSON(), nullable=False),
30+
sa.Column('postcondition', sa.JSON(), nullable=False),
31+
sa.Column('user_input', sa.JSON(), nullable=False),
32+
sa.Column('expected_output', sa.JSON(), nullable=False),
33+
sa.ForeignKeyConstraint(['exercise_id'], ['exercise.id'], ),
34+
sa.PrimaryKeyConstraint('id')
35+
)
36+
op.add_column('exercise', sa.Column('skip_delay', sa.Integer(), nullable=False))
37+
op.drop_column('exercise', 'allow_skip_after')
38+
op.add_column('grading_job', sa.Column('passed', sa.BOOLEAN(), nullable=True))
39+
op.add_column('grading_job', sa.Column('feedback', sa.JSON(), nullable=True))
40+
# ### end Alembic commands ###
41+
42+
43+
def downgrade() -> None:
44+
"""Downgrade schema."""
45+
# ### commands auto generated by Alembic - please adjust! ###
46+
op.drop_column('grading_job', 'feedback')
47+
op.drop_column('grading_job', 'passed')
48+
op.add_column('exercise', sa.Column('allow_skip_after', sa.INTEGER(), autoincrement=False, nullable=True))
49+
op.drop_column('exercise', 'skip_delay')
50+
op.drop_table('test_case')
51+
# ### end Alembic commands ###

alembic/versions/53a4dee6ef34_add_passed_and_feedback_column_to_.py

Lines changed: 0 additions & 33 deletions
This file was deleted.

app/api/schema/exercise.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime
12
from typing import Optional
23

34
from pydantic import BaseModel
@@ -7,14 +8,18 @@ class ExerciseCreate(BaseModel):
78
title: str
89
markdown: str
910
coding_mode: str
10-
allow_skip_after: Optional[int]
11+
skip_delay: int
1112
next_exercise_id: Optional[int]
1213

1314

1415
class ExerciseRead(ExerciseCreate):
1516
id: int
1617

1718

19+
class ExerciseWithSkipUnlockTime(ExerciseRead):
20+
skip_unlock_time: datetime
21+
22+
1823
class SystemState(BaseModel):
1924
registers: dict[str, int]
2025
memory: dict[int, int]

app/api/v1/exercise.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from datetime import timedelta, datetime, timezone
23

34
import sqlalchemy as sa
@@ -6,7 +7,8 @@
67
from sqlalchemy import select
78
from sqlalchemy.ext.asyncio import AsyncSession
89

9-
from app.api.schema.exercise import ExerciseRead, ExerciseCreate, TestCaseRead, TestCaseCreate
10+
from app.api.schema.exercise import ExerciseRead, ExerciseCreate, TestCaseRead, TestCaseCreate, \
11+
ExerciseWithSkipUnlockTime
1012
from app.db.database import get_session
1113
from app.db.model import Tan
1214
from app.db.model.exercise import Exercise, ExerciseProgress, Competition, TestCase
@@ -19,28 +21,26 @@
1921

2022

2123
@router.get("/current",
22-
response_model=ExerciseRead,
24+
response_model=ExerciseWithSkipUnlockTime,
2325
status_code=status.HTTP_200_OK)
2426
async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(get_session),
25-
now: datetime = Depends(get_datetime_now)) -> ExerciseRead | Response:
27+
now: datetime = Depends(get_datetime_now)) -> ExerciseWithSkipUnlockTime | Response:
2628
statement = select(Tan).where(Tan.code == tan_code)
2729
result = await session.execute(statement)
2830
tan = result.scalars().first()
2931

3032
if not tan:
3133
raise HTTPException(status_code=404, detail="TAN code not found")
3234

33-
statement = (select(Exercise)
34-
.where(Exercise.id == (select(ExerciseProgress.exercise_id)
35-
.where(sa.and_(ExerciseProgress.tan_code == tan_code,
36-
ExerciseProgress.end_time.is_(None)))
37-
.scalar_subquery())))
35+
statement = (select(Exercise, ExerciseProgress)
36+
.join(ExerciseProgress, ExerciseProgress.exercise_id == Exercise.id)
37+
.where(sa.and_(ExerciseProgress.tan_code == tan_code,
38+
ExerciseProgress.end_time.is_(None))))
3839

3940
result = await session.execute(statement)
40-
exercise = result.scalars().first()
41-
42-
if not exercise:
41+
exercise_and_progress = result.first()
4342

43+
if not exercise_and_progress:
4444
stmt = (
4545
select(Exercise)
4646
.join(ExerciseProgress, ExerciseProgress.exercise_id == Exercise.id)
@@ -68,31 +68,39 @@ async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(ge
6868
stmt = select(Exercise).where(Exercise.id == last_exercise.next_exercise_id)
6969
result = await session.execute(stmt)
7070
exercise = result.scalars().first()
71-
return ExerciseRead(**exercise.to_dict())
7271

72+
return ExerciseWithSkipUnlockTime(**exercise.to_dict(),
73+
skip_unlock_time=(now + timedelta(minutes=exercise.skip_delay)))
7374
else:
74-
stmt = (select(Competition)
75+
stmt = (select(Exercise)
76+
.join(Competition, Competition.first_exercise_id == Exercise.id)
7577
.join(Tan, Tan.competition_id == Competition.id)
7678
.where(Tan.code == tan_code))
79+
7780
result = await session.execute(stmt)
78-
first_exercise_id = result.scalars().first().first_exercise_id
81+
first_exercise = result.scalars().first()
7982

8083
ep = ExerciseProgress(
8184
tan_code=tan_code,
82-
exercise_id=first_exercise_id,
85+
exercise_id=first_exercise.id,
8386
start_time=now,
8487
skipped=False
8588
)
8689

8790
session.add(ep)
8891
await session.commit()
8992

90-
stmt = select(Exercise).where(Exercise.id == first_exercise_id)
91-
result = await session.execute(stmt)
92-
exercise = result.scalars().first()
93-
return ExerciseRead(**exercise.to_dict())
93+
await session.refresh(first_exercise)
9494

95-
return ExerciseRead(**exercise.to_dict())
95+
return ExerciseWithSkipUnlockTime(**first_exercise.to_dict(),
96+
skip_unlock_time=(now + timedelta(minutes=first_exercise.skip_delay)))
97+
98+
exercise, progress = exercise_and_progress
99+
100+
logging.info(progress.start_time.tzinfo)
101+
102+
return ExerciseWithSkipUnlockTime(**exercise.to_dict(),
103+
skip_unlock_time=(progress.start_time + timedelta(minutes=exercise.skip_delay)))
96104

97105

98106
@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
117125
current_exercise: Exercise = result.scalars().first()
118126

119127
allow_skip_after_date = (exercise_progress.start_time
120-
+ timedelta(minutes=current_exercise.allow_skip_after))
128+
+ timedelta(minutes=current_exercise.skip_delay))
121129

122130
if allow_skip_after_date.tzinfo is None:
123131
allow_skip_after_date = allow_skip_after_date.replace(tzinfo=timezone.utc)

app/db/model/exercise.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class Exercise(Base):
1111
markdown = sa.Column(sa.TEXT, nullable=False)
1212
coding_mode = sa.Column(sa.VARCHAR(3), nullable=False)
1313
next_exercise_id = sa.Column(sa.Integer, sa.ForeignKey("exercise.id"), nullable=True)
14-
allow_skip_after = sa.Column(sa.Integer, nullable=True)
14+
skip_delay = sa.Column(sa.Integer, nullable=False)
1515

1616
def to_dict(self):
1717
return {
@@ -20,7 +20,7 @@ def to_dict(self):
2020
"markdown": self.markdown,
2121
"coding_mode": self.coding_mode,
2222
"next_exercise_id": self.next_exercise_id,
23-
"allow_skip_after": self.allow_skip_after,
23+
"skip_delay": self.skip_delay,
2424
}
2525

2626

tests/test_exercise.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
from tests.util.demo_data import EXERCISES
1313

1414

15+
def get_datetime_now_override(datetime_now):
16+
def now():
17+
yield datetime_now
18+
19+
return now
20+
21+
1522
class TestExercise:
1623

1724
def setup_class(self):
@@ -38,7 +45,7 @@ def test_post_exercise(self):
3845
"title": "posted exercise",
3946
"markdown": "",
4047
"coding_mode": "bbp",
41-
"allow_skip_after": None,
48+
"skip_delay": 10,
4249
"next_exercise_id": None,
4350
}
4451

@@ -59,8 +66,11 @@ def test_get_current_exercise(self):
5966

6067
response = client.get("/exercises/current", params={"tan_code": "test-tan-1"})
6168

69+
exercise_1 = dict(EXERCISES[1])
70+
exercise_1["skip_unlock_time"] = datetime(year=2025, month=10, day=7, hour=19, minute=35, second=0).isoformat()
71+
6272
assert response.status_code == 200
63-
assert response.json() == EXERCISES[1]
73+
assert response.json() == exercise_1
6474

6575
def test_get_current_exercise_with_none_existing_tan(self):
6676
app.dependency_overrides[get_session] = get_override_dependency(self.engine)
@@ -71,22 +81,32 @@ def test_get_current_exercise_with_none_existing_tan(self):
7181
assert response.status_code == status.HTTP_404_NOT_FOUND
7282

7383
def test_get_current_exercise_with_missing_current_progress_entry_1(self):
84+
app.dependency_overrides[get_datetime_now] = get_datetime_now_override(
85+
datetime(year=2025, month=10, day=7, hour=19, minute=35, second=0, tzinfo=timezone.utc))
7486
app.dependency_overrides[get_session] = get_override_dependency(self.engine)
7587
client = TestClient(app)
7688

7789
response = client.get("/exercises/current", params={"tan_code": "test-tan-2"})
7890

91+
exercise_2 = dict(EXERCISES[2])
92+
exercise_2["skip_unlock_time"] = "2025-10-07T19:40:00Z"
93+
7994
assert response.status_code == 200
80-
assert response.json() == EXERCISES[2]
95+
assert response.json() == exercise_2
8196

8297
def test_get_current_exercise_with_missing_current_progress_entry_2(self):
98+
app.dependency_overrides[get_datetime_now] = get_datetime_now_override(
99+
datetime(year=2025, month=10, day=7, hour=19, minute=35, second=0, tzinfo=timezone.utc))
83100
app.dependency_overrides[get_session] = get_override_dependency(self.engine)
84101
client = TestClient(app)
85102

86103
response = client.get("/exercises/current", params={"tan_code": "test-tan-3"})
87104

105+
exercise_0 = dict(EXERCISES[0])
106+
exercise_0["skip_unlock_time"] = "2025-10-07T19:40:00Z"
107+
88108
assert response.status_code == 200
89-
assert response.json() == EXERCISES[0]
109+
assert response.json() == exercise_0
90110

91111
def test_post_test_case(self):
92112
app.dependency_overrides[get_session] = get_override_dependency(self.engine)
@@ -169,13 +189,8 @@ def test_post_skip_exercise_with_invalid_tan(self):
169189
assert response.status_code == status.HTTP_404_NOT_FOUND
170190

171191
def test_post_skip_exercise_before_deadline(self):
172-
def get_datetime_now_override():
173-
def now():
174-
yield datetime(2025, 10, 7, 19, 31, 0, tzinfo=timezone.utc)
175-
176-
return now
177-
178-
app.dependency_overrides[get_datetime_now] = get_datetime_now_override()
192+
app.dependency_overrides[get_datetime_now] = get_datetime_now_override(
193+
datetime(2025, 10, 7, 19, 31, 0, tzinfo=timezone.utc))
179194
app.dependency_overrides[get_session] = get_override_dependency(self.engine)
180195

181196
client = TestClient(app)
@@ -188,13 +203,8 @@ def now():
188203
}
189204

190205
def test_post_skip_exercise_after_deadline(self):
191-
def get_datetime_now_override():
192-
def now():
193-
yield datetime(2026, 10, 7, 19, 31, 0, tzinfo=timezone.utc)
194-
195-
return now
196-
197-
app.dependency_overrides[get_datetime_now] = get_datetime_now_override()
206+
app.dependency_overrides[get_datetime_now] = get_datetime_now_override(
207+
datetime(2026, 10, 7, 19, 31, 0, tzinfo=timezone.utc))
198208
app.dependency_overrides[get_session] = get_override_dependency(self.engine)
199209

200210
client = TestClient(app)

tests/util/demo_data.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,23 @@
5858
"title": "Demo Exercise 1",
5959
"markdown": "",
6060
"coding_mode": "bbp",
61-
"allow_skip_after": 5,
61+
"skip_delay": 5,
6262
"next_exercise_id": 2,
6363
},
6464
{
6565
"id": 2,
6666
"title": "Demo exercise 2",
6767
"markdown": "",
6868
"coding_mode": "bbp",
69-
"allow_skip_after": 5,
69+
"skip_delay": 5,
7070
"next_exercise_id": 3,
7171
},
7272
{
7373
"id": 3,
7474
"title": "Demo exercise 3",
7575
"markdown": "",
7676
"coding_mode": "bbp",
77-
"allow_skip_after": 5,
77+
"skip_delay": 5,
7878
"next_exercise_id": None,
7979
}
8080
]

0 commit comments

Comments
 (0)