diff --git a/alembic/versions/3836d6b8e3f6_failed_submissions_and_next_grading_.py b/alembic/versions/3836d6b8e3f6_failed_submissions_and_next_grading_.py new file mode 100644 index 0000000..8035045 --- /dev/null +++ b/alembic/versions/3836d6b8e3f6_failed_submissions_and_next_grading_.py @@ -0,0 +1,31 @@ +"""failed_submissions and next_grading_allowed_at columns added to grading_job table + +Revision ID: 3836d6b8e3f6 +Revises: 4baf7c606f77 +Create Date: 2025-11-09 13:03:28.701620 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '3836d6b8e3f6' +down_revision: Union[str, Sequence[str], None] = '4baf7c606f77' +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('exercise_progress', sa.Column('skipped', sa.BOOLEAN(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('exercise_progress', 'skipped') + # ### end Alembic commands ### diff --git a/app/api/v1/grading_job.py b/app/api/v1/grading_job.py index 00cc80a..1ae63be 100644 --- a/app/api/v1/grading_job.py +++ b/app/api/v1/grading_job.py @@ -3,6 +3,7 @@ from datetime import datetime from uuid import uuid4 +import sqlalchemy as sa from aio_pika import Message from aio_pika.abc import AbstractRobustChannel from fastapi import APIRouter, Depends, HTTPException @@ -18,6 +19,8 @@ from app.mq.message_queue import get_mq_channel from app.util import get_datetime_now +INITIAL_JOB_STATUS = "pending" + router = APIRouter( prefix="/grading-jobs", tags=["grading jobs"], @@ -30,10 +33,12 @@ async def submit_grading_job(job_msg: dict, session: AsyncSession, ch: AbstractR id=job_msg["job_id"], tan_code=job_msg["tan_code"], exercise_id=job_msg["exercise_id"], - status="pending", + status=INITIAL_JOB_STATUS, started=now )) + await session.commit() + job_msg["job_id"] = str(job_msg["job_id"]) exchange = await ch.get_exchange(MQ_EXCHANGE_NAME) @@ -53,6 +58,21 @@ async def create_submission(new_submission: ExerciseSubmission, session: AsyncSe if not exercise_progress: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tried to submit to an inactive exercise.") + if exercise_progress.next_grading_allowed_at and exercise_progress.next_grading_allowed_at > now: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Next grading allowed at {exercise_progress.next_grading_allowed_at}") + + stmt = select(GradingJob).where(sa.and_(GradingJob.exercise_id == new_submission.exercise_id, + GradingJob.tan_code == new_submission.tan_code, + GradingJob.status == INITIAL_JOB_STATUS)) + + result = await session.execute(stmt) + grading_job = result.scalars().first() + + if grading_job: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Previous grading job still in progress!") + try: job_msg = { "job_id": uuid4(), diff --git a/app/db/model/exercise.py b/app/db/model/exercise.py index 7324bcd..d674eff 100644 --- a/app/db/model/exercise.py +++ b/app/db/model/exercise.py @@ -59,6 +59,8 @@ class ExerciseProgress(Base): start_time = sa.Column(sa.DateTime(timezone=True), nullable=False) end_time = sa.Column(sa.DateTime(timezone=True), nullable=True) skipped = sa.Column(sa.BOOLEAN, nullable=False) + failed_submissions = sa.Column(sa.INTEGER, nullable=False, default=0) + next_grading_allowed_at = sa.Column(sa.DateTime(timezone=True), nullable=True) class Competition(Base): diff --git a/app/mq/message_queue.py b/app/mq/message_queue.py index 81bcd04..2281a10 100644 --- a/app/mq/message_queue.py +++ b/app/mq/message_queue.py @@ -8,7 +8,7 @@ async def get_mq_channel() -> AbstractRobustChannel: uri = f"amqp://{MQ_USER}:{MQ_PWD}@{MQ_URL}:{MQ_PORT}" - logging.debug('connect to: ', uri) + logging.debug(f'connect to: {uri}') connection = await aio_pika.connect_robust(uri) async with connection: diff --git a/tests/test_grading_job.py b/tests/test_grading_job.py index cb2c675..f90a888 100644 --- a/tests/test_grading_job.py +++ b/tests/test_grading_job.py @@ -1,5 +1,5 @@ import asyncio -from datetime import timezone, datetime +from datetime import datetime from unittest.mock import MagicMock, ANY, AsyncMock from aio_pika.abc import AbstractRobustChannel @@ -39,7 +39,7 @@ async def get_mq_connection_override() -> AbstractRobustChannel: def get_datetime_now_override(): def now(): - yield datetime(2026, 10, 7, 19, 31, 0, tzinfo=timezone.utc) + yield datetime(2026, 10, 7, 19, 37, 0) return now @@ -86,3 +86,65 @@ async def get_mq_connection_override() -> AbstractRobustChannel: assert response.status_code == status.HTTP_400_BAD_REQUEST channel_mock.get_exchange.assert_not_awaited() exchange_mock.publish.assert_not_awaited() + + def test_early_grading_job_submission(self): + channel_mock, exchange_mock = setup_mocks() + + async def get_mq_connection_override() -> AbstractRobustChannel: + yield channel_mock # noqa + + def get_datetime_now_override(): + def now(): + yield datetime(2025, 10, 7, 19, 31, 0) + + 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 + + client = TestClient(app) + + exercise_submission = { + "tan_code": "test-tan-1", + "exercise_id": 2, + "solution_code": "addi r0 r0 r0" + } + + response = client.post("/grading-jobs", json=exercise_submission) + + print(response.json()) + + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.json()["detail"] == "Next grading allowed at 2025-10-07 19:35:00" + + def test_post_job_with_pending_job(self): + channel_mock, exchange_mock = setup_mocks() + + async def get_mq_connection_override() -> AbstractRobustChannel: + yield channel_mock # noqa + + def get_datetime_now_override(): + def now(): + yield datetime(2025, 10, 7, 19, 50, 0) + + 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 + + client = TestClient(app) + + exercise_submission = { + "tan_code": "test-tan-4", + "exercise_id": 1, + "solution_code": "addi r0 r0 r0" + } + + response = client.post("/grading-jobs", json=exercise_submission) + + print(response.json()) + + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.json()["detail"] == "Previous grading job still in progress!" diff --git a/tests/util/db_util.py b/tests/util/db_util.py index cf10a03..64c1385 100644 --- a/tests/util/db_util.py +++ b/tests/util/db_util.py @@ -3,10 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, async_sessionmaker from app.db.database import Base -from app.db.model import Exercise, Tan, LoggingEvent +from app.db.model import Exercise, Tan, LoggingEvent, GradingJob from app.db.model.exercise import ExerciseProgress, Competition, TestCase from tests.util.demo_data import COMPETITIONS, TANS, EXERCISES, EXERCISE_PROGRESS_ENTRIES, EXERCISE_TEST_CASES, \ - LOGGING_EVENTS + LOGGING_EVENTS, GRADING_JOBS DB_URI = "sqlite+aiosqlite:///:memory:" @@ -45,6 +45,9 @@ async def insert_demo_data(session_factory: async_sessionmaker): for test_case in EXERCISE_TEST_CASES[exercise_id]: await insert_exercise_test_case(session, exercise_id, test_case) + for grading_job in GRADING_JOBS: + await insert_grading_job(session, grading_job) + async def insert_exercise(session: AsyncSession, exercise: dict) -> None: exercise = Exercise(**exercise) @@ -81,3 +84,9 @@ async def insert_exercise_test_case(session: AsyncSession, exercise_id: int, tes test_case = TestCase(exercise_id=exercise_id, **test_case) session.add(test_case) await session.commit() + + +async def insert_grading_job(session: AsyncSession, grading_job: dict) -> None: + grading_job = GradingJob(**grading_job) + session.add(grading_job) + await session.commit() diff --git a/tests/util/demo_data.py b/tests/util/demo_data.py index ca730c7..dcd7623 100644 --- a/tests/util/demo_data.py +++ b/tests/util/demo_data.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from uuid import uuid4 COMPETITIONS = [ { @@ -24,6 +25,10 @@ "competition_id": 1, "valid_from": datetime(2025, 10, 7, 18, 0, 0, tzinfo=timezone.utc), }, + { + "code": "test-tan-4", + "competition_id": 1, + }, { "code": "logging-test-tan", "competition_id": 1, @@ -94,7 +99,8 @@ "exercise_id": 2, "start_time": datetime(2025, 10, 7, 19, 30, 0, tzinfo=timezone.utc), "end_time": None, - "skipped": False + "skipped": False, + "next_grading_allowed_at": datetime(2025, 10, 7, 19, 35, 0, tzinfo=timezone.utc) }, { "id": 3, @@ -112,6 +118,25 @@ "end_time": datetime(2025, 10, 7, 20, 0, 0, tzinfo=timezone.utc), "skipped": False }, + { + "id": 5, + "tan_code": "test-tan-4", + "exercise_id": 1, + "start_time": datetime(2025, 10, 7, 19, 0, 0, tzinfo=timezone.utc), + "end_time": None, + "skipped": False + }, + +] + +GRADING_JOBS = [ + { + "id": uuid4(), + "tan_code": "test-tan-4", + "exercise_id": 1, + "status": "pending", + "started": datetime(2025, 10, 7, 18, 0, 0, tzinfo=timezone.utc), + } ] EXERCISE_TEST_CASES = {