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
53 changes: 49 additions & 4 deletions app/api/v1/exercise.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import datetime
from datetime import timedelta, datetime, timezone

import sqlalchemy as sa
from fastapi import APIRouter, HTTPException, status, Response
Expand All @@ -10,6 +10,7 @@
from app.db.database import get_session
from app.db.model import Tan
from app.db.model.exercise import Exercise, ExerciseProgress, Competition, TestCase
from app.util import get_datetime_now

router = APIRouter(
prefix="/exercises",
Expand All @@ -20,7 +21,8 @@
@router.get("/current",
response_model=ExerciseRead,
status_code=status.HTTP_200_OK)
async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(get_session)) -> ExerciseRead | Response:
async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(get_session),
now: datetime = Depends(get_datetime_now)) -> ExerciseRead | Response:
statement = select(Tan).where(Tan.code == tan_code)
result = await session.execute(statement)
tan = result.scalars().first()
Expand Down Expand Up @@ -56,7 +58,7 @@ async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(ge
ep = ExerciseProgress(
tan_code=tan_code,
exercise_id=last_exercise.next_exercise_id,
start_time=datetime.datetime.now(),
start_time=now,
skipped=False
)
session.add(ep)
Expand All @@ -78,7 +80,7 @@ async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(ge
ep = ExerciseProgress(
tan_code=tan_code,
exercise_id=first_exercise_id,
start_time=datetime.datetime.now(),
start_time=now,
skipped=False
)

Expand All @@ -93,6 +95,49 @@ async def get_current_exercise(tan_code: str, session: AsyncSession = Depends(ge
return ExerciseRead(**exercise.to_dict())


@router.post("/current/skip", status_code=status.HTTP_204_NO_CONTENT)
async def post_skip_current_exercise(tan_code: str, session: AsyncSession = Depends(get_session),
now: datetime = Depends(get_datetime_now)) -> None:
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(ExerciseProgress).where(
sa.and_(ExerciseProgress.tan_code == tan_code,
ExerciseProgress.end_time.is_(None)))
result = await session.execute(statement)
exercise_progress: ExerciseProgress = result.scalars().first()

if exercise_progress:
statement = select(Exercise).where(Exercise.id == exercise_progress.exercise_id)
result = await session.execute(statement)
current_exercise: Exercise = result.scalars().first()

allow_skip_after_date = (exercise_progress.start_time
+ timedelta(minutes=current_exercise.allow_skip_after))

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

if now < allow_skip_after_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Skipping is not allowed before {allow_skip_after_date.isoformat()}.")

exercise_progress.end_time = now
exercise_progress.skipped = True

session.add(exercise_progress)
await session.commit()
await session.refresh(exercise_progress)

else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="User is not currently working on an exercise.")


@router.get("/{exercise_id}",
response_model=ExerciseRead,
status_code=status.HTTP_200_OK)
Expand Down
13 changes: 8 additions & 5 deletions app/api/v1/grading_job.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime
import json
import logging
from datetime import datetime
from uuid import uuid4

from aio_pika import Message
Expand All @@ -16,20 +16,22 @@
from app.db.model.exercise import ExerciseProgress
from app.db.model.grading_job import GradingJob
from app.mq.message_queue import get_mq_channel
from app.util import get_datetime_now

router = APIRouter(
prefix="/grading-jobs",
tags=["grading jobs"],
)


async def submit_grading_job(job_msg: dict, session: AsyncSession, ch: AbstractRobustChannel):
async def submit_grading_job(job_msg: dict, session: AsyncSession, ch: AbstractRobustChannel,
now: datetime):
session.add(GradingJob(
id=job_msg["job_id"],
tan_code=job_msg["tan_code"],
exercise_id=job_msg["exercise_id"],
status="pending",
started=datetime.datetime.now()
started=now
))

job_msg["job_id"] = str(job_msg["job_id"])
Expand All @@ -40,7 +42,8 @@ async def submit_grading_job(job_msg: dict, session: AsyncSession, ch: AbstractR

@router.post("/", status_code=status.HTTP_201_CREATED, response_model=str)
async def create_submission(new_submission: ExerciseSubmission, session: AsyncSession = Depends(get_session),
mq_channel: AbstractRobustChannel = Depends(get_mq_channel)) -> str:
mq_channel: AbstractRobustChannel = Depends(get_mq_channel),
now: datetime = Depends(get_datetime_now)) -> str:
stmt = select(ExerciseProgress).where(ExerciseProgress.exercise_id == new_submission.exercise_id,
ExerciseProgress.tan_code == new_submission.tan_code,
ExerciseProgress.end_time.is_(None))
Expand All @@ -58,7 +61,7 @@ async def create_submission(new_submission: ExerciseSubmission, session: AsyncSe
"solution_code": new_submission.solution_code
}

await submit_grading_job(job_msg, session, mq_channel)
await submit_grading_job(job_msg, session, mq_channel, now)
await session.commit()

return str(job_msg["job_id"])
Expand Down
5 changes: 5 additions & 0 deletions app/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from datetime import datetime, timezone


def get_datetime_now():
yield datetime.now(timezone.utc)
45 changes: 45 additions & 0 deletions tests/test_exercise.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import asyncio
from datetime import datetime, timezone

from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from app.db.database import get_session
from app.main import app
from app.util import get_datetime_now
from tests.util.db_util import create_test_tables, get_override_dependency, insert_demo_data, DB_URI
from tests.util.demo_data import EXERCISES

Expand Down Expand Up @@ -157,3 +159,46 @@ def test_get_test_case(self):
assert result_test_cases[0]["user_input"] == expected_test_case["user_input"]
assert result_test_cases[0]["expected_output"] == expected_test_case["expected_output"]
assert "id" in result_test_cases[0]

def test_post_skip_exercise_with_invalid_tan(self):
app.dependency_overrides[get_session] = get_override_dependency(self.engine)
client = TestClient(app)

response = client.post("/exercises/current/skip", params={"tan_code": "non-existing-tan"})

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_session] = get_override_dependency(self.engine)

client = TestClient(app)

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

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"detail": "Skipping is not allowed before 2025-10-07T19:35:00+00:00."
}

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_session] = get_override_dependency(self.engine)

client = TestClient(app)

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

assert response.status_code == status.HTTP_204_NO_CONTENT
9 changes: 9 additions & 0 deletions tests/test_grading_job.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
from datetime import timezone, datetime
from unittest.mock import MagicMock, ANY, AsyncMock

from aio_pika.abc import AbstractRobustChannel
Expand All @@ -10,6 +11,7 @@
from app.db.database import get_session
from app.main import app
from app.mq.message_queue import get_mq_channel
from app.util import get_datetime_now
from tests.util.db_util import create_test_tables, get_override_dependency, insert_demo_data, DB_URI


Expand All @@ -35,6 +37,13 @@ def test_post_submission(self):
async def get_mq_connection_override() -> AbstractRobustChannel:
yield channel_mock # noqa

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_session] = get_override_dependency(self.engine)
app.dependency_overrides[get_mq_channel] = get_mq_connection_override

Expand Down
9 changes: 6 additions & 3 deletions tests/test_logging_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ def test_get_logging_events(self):
assert response.status_code == 200
assert len(response.json()) == 2

assert response.json()[0] == LoggingEventRead(**LOGGING_EVENTS[0]).model_dump(mode='json')
assert response.json()[1] == LoggingEventRead(**LOGGING_EVENTS[1]).model_dump(mode='json')

logging_event_1 = LoggingEventRead(**LOGGING_EVENTS[0]).model_dump(mode='json')
logging_event_1['timestamp'] = logging_event_1['timestamp'][:-1]

logging_event_2 = LoggingEventRead(**LOGGING_EVENTS[1]).model_dump(mode='json')
logging_event_2['timestamp'] = logging_event_2['timestamp'][:-1]

assert response.json()[0] == logging_event_1
assert response.json()[1] == logging_event_2
34 changes: 17 additions & 17 deletions tests/util/demo_data.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone

COMPETITIONS = [
{
Expand All @@ -12,30 +12,30 @@
{
"code": "test-tan-1",
"competition_id": 1,
"valid_from": datetime(2025, 10, 7, 18, 0, 0),
"valid_from": datetime(2025, 10, 7, 18, 0, 0, tzinfo=timezone.utc),
},
{
"code": "test-tan-2",
"competition_id": 1,
"valid_from": datetime(2025, 10, 7, 18, 0, 0),
"valid_from": datetime(2025, 10, 7, 18, 0, 0, tzinfo=timezone.utc),
},
{
"code": "test-tan-3",
"competition_id": 1,
"valid_from": datetime(2025, 10, 7, 18, 0, 0),
"valid_from": datetime(2025, 10, 7, 18, 0, 0, tzinfo=timezone.utc),
},
{
"code": "logging-test-tan",
"competition_id": 1,
"valid_from": datetime(2025, 10, 7, 18, 0, 0),
"valid_from": datetime(2025, 10, 7, 18, 0, 0, tzinfo=timezone.utc),
}
]

LOGGING_EVENTS = [
{
"id": 1,
"tan_code": "logging-test-tan",
"timestamp": datetime(2025, 10, 7, 18, 0, 1),
"timestamp": datetime(2025, 10, 7, 18, 0, 1, tzinfo=timezone.utc),
"source": "button",
"type": "click",
"payload": {"msg": "first logging message", "data": [1, 2, 3, 4, 5]},
Expand All @@ -44,7 +44,7 @@
{
"id": 2,
"tan_code": "logging-test-tan",
"timestamp": datetime(2025, 10, 7, 18, 0, 2),
"timestamp": datetime(2025, 10, 7, 18, 0, 2, tzinfo=timezone.utc),
"source": "button",
"type": "click",
"payload": {"msg": "second logging message", "data": [5, 4, 3, 2, 1]},
Expand All @@ -58,23 +58,23 @@
"title": "Demo Exercise 1",
"markdown": "",
"coding_mode": "bbp",
"allow_skip_after": 0,
"allow_skip_after": 5,
"next_exercise_id": 2,
},
{
"id": 2,
"title": "Demo exercise 2",
"markdown": "",
"coding_mode": "bbp",
"allow_skip_after": 0,
"allow_skip_after": 5,
"next_exercise_id": 3,
},
{
"id": 3,
"title": "Demo exercise 3",
"markdown": "",
"coding_mode": "bbp",
"allow_skip_after": 0,
"allow_skip_after": 5,
"next_exercise_id": None,
}
]
Expand All @@ -84,32 +84,32 @@
"id": 1,
"tan_code": "test-tan-1",
"exercise_id": 1,
"start_time": datetime(2025, 10, 7, 18, 30, 0),
"end_time": datetime(2025, 10, 7, 19, 30, 0),
"start_time": datetime(2025, 10, 7, 18, 30, 0, tzinfo=timezone.utc),
"end_time": datetime(2025, 10, 7, 19, 30, 0, tzinfo=timezone.utc),
"skipped": False
},
{
"id": 2,
"tan_code": "test-tan-1",
"exercise_id": 2,
"start_time": datetime(2025, 10, 7, 19, 30, 0),
"start_time": datetime(2025, 10, 7, 19, 30, 0, tzinfo=timezone.utc),
"end_time": None,
"skipped": False
},
{
"id": 3,
"tan_code": "test-tan-2",
"exercise_id": 1,
"start_time": datetime(2025, 10, 7, 18, 0, 0),
"end_time": datetime(2025, 10, 7, 19, 0, 0),
"start_time": datetime(2025, 10, 7, 18, 0, 0, tzinfo=timezone.utc),
"end_time": datetime(2025, 10, 7, 19, 0, 0, tzinfo=timezone.utc),
"skipped": False
},
{
"id": 4,
"tan_code": "test-tan-2",
"exercise_id": 2,
"start_time": datetime(2025, 10, 7, 19, 0, 0),
"end_time": datetime(2025, 10, 7, 20, 0, 0),
"start_time": datetime(2025, 10, 7, 19, 0, 0, tzinfo=timezone.utc),
"end_time": datetime(2025, 10, 7, 20, 0, 0, tzinfo=timezone.utc),
"skipped": False
},
]
Expand Down