Commit 15c499c7 authored by Sevastian Slavov's avatar Sevastian Slavov Committed by Martin Juhás
Browse files

feat: refine exercise loop terminal state handling

### Additions

* `ExerciseStateEnum`: new values `PAUSED`, `EXPIRED`, `STOPPED`
* `PAUSED` - exercise paused by instructor
* `STOPPED` - exercise stopped permanently by instructor
* `EXPIRED` - exercise ended because time ran out
* `FINISHED` - exercise ended after reaching the final milestone

Closes #539
parent 4c4bea6a
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
@@ -31,9 +31,11 @@ class EventType(models.TextChoices):

class ExerciseStateEnum(graphene.Enum):
    NOT_STARTED = 0
    STOPPED = 1
    PAUSED = 1
    RUNNING = 2
    FINISHED = 3
    STOPPED = 3
    EXPIRED = 4
    FINISHED = 5


QuestionnaireStateEnum = graphene.Enum.from_enum(TeamQuestionnaireState.Status)
+18 −0
Original line number Diff line number Diff line
# Generated by Django 3.2.25 on 2025-12-18 11:20

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('exercise', '0024_exerciseaccess_entered_at'),
    ]

    operations = [
        migrations.AlterField(
            model_name='exercisestate',
            name='status',
            field=models.IntegerField(choices=[(0, 'Not Started'), (1, 'Paused'), (2, 'Running'), (3, 'Stopped'), (4, 'Expired'), (5, 'Finished')], default=0),
        ),
    ]
+4 −2
Original line number Diff line number Diff line
@@ -84,9 +84,11 @@ class TeamState(models.Model):
class ExerciseState(models.Model):
    class Status(models.IntegerChoices):
        NOT_STARTED = 0
        STOPPED = 1
        PAUSED = 1
        RUNNING = 2
        FINISHED = 3
        STOPPED = 3
        EXPIRED = 4
        FINISHED = 5

    elapsed_s = models.IntegerField(default=0)
    start_time = models.DateTimeField(default=None, null=True, blank=True)
+19 −18
Original line number Diff line number Diff line
@@ -20,27 +20,29 @@ from running_exercise.lib.exercise_updater import (
from running_exercise.lib.loop_thread import LoopThread


def stop(state: ExerciseState) -> ExerciseState:
    state.status = state.Status.STOPPED
def pause(state: ExerciseState) -> ExerciseState:
    # stopped by instructor
    state.status = state.Status.PAUSED
    return state


def finish(state: ExerciseState, timestamp: datetime) -> ExerciseState:
    state.status = ExerciseState.Status.FINISHED
def stop_permanently(
    state: ExerciseState, timestamp: datetime
) -> ExerciseState:
    state.status = ExerciseState.Status.STOPPED
    state.finish_time = timestamp
    return state


def start_exercise(state: ExerciseState, config: Config):
    if state.status == ExerciseState.Status.FINISHED:
        raise RunningExerciseOperationException(
            "Cannot start a finished exercise"
        )

    if state.status == ExerciseState.Status.RUNNING:
        raise RunningExerciseOperationException(
            "Cannot start a running exercise"
        )
    if state.status > ExerciseState.Status.RUNNING:
        raise RunningExerciseOperationException(
            "Cannot start an exercise that has ended"
        )

    if state.status == state.Status.NOT_STARTED:
        state.start_time = timezone.now()
@@ -134,7 +136,7 @@ class ExerciseLoop:
                continue

            # The exercise will stop during the next update interval for the given state
            states.append(stop(state))
            states.append(pause(state))

        ExerciseState.objects.bulk_update(states, ["status"])
        return exercise
@@ -158,7 +160,7 @@ class ExerciseLoop:
            )

        ExerciseState.objects.bulk_update(
            [stop(team.exercise_state)], ["status"]
            [pause(team.exercise_state)], ["status"]
        )
        return team.exercise

@@ -169,11 +171,10 @@ class ExerciseLoop:
        states: List[ExerciseState] = []

        for state in exercise.states.all():
            if state.status == ExerciseState.Status.FINISHED:
                logger.info(f"skipping state {state.id}, already finished")
            if state.status >= ExerciseState.Status.STOPPED:
                logger.info(f"skipping state {state.id}, already ended")
                continue

            states.append(finish(state, timestamp))
            states.append(stop_permanently(state, timestamp))

        ExerciseState.objects.bulk_update(states, ["status", "finish_time"])

@@ -192,13 +193,13 @@ class ExerciseLoop:
                f"This exercise cannot be stopped individually."
            )

        if team.exercise_state.status == ExerciseState.Status.FINISHED:
        if team.exercise_state.status >= ExerciseState.Status.STOPPED:
            raise RunningExerciseOperationException(
                f"Cannot finish a finished exercise again"
                f"Cannot finish an exercise that has already ended"
            )

        ExerciseState.objects.bulk_update(
            [finish(team.exercise_state, timezone.now())],
            [stop_permanently(team.exercise_state, timezone.now())],
            ["status", "finish_time"],
        )

+10 −3
Original line number Diff line number Diff line
@@ -383,17 +383,24 @@ class ExerciseStateUpdater:

        return update_time, should_stop

    def finish(self):
    def finish(self, final_status: ExerciseState.Status):
        if final_status not in (
            ExerciseState.Status.EXPIRED,
            ExerciseState.Status.FINISHED,
        ):
            raise ValueError("finish expects EXPIRED or FINISHED")

        if self.state.status != ExerciseState.Status.RUNNING:
            raise RunningExerciseOperationException(
                "Cannot finish a non-running exercise."
            )

        self.state.status = ExerciseState.Status.FINISHED
        self.state.status = final_status
        self.state.finish_time = timezone.now()
        self.state.save()

        logger.info(
            f"exercise id: {self.state.exercise_id}, state id: {self.state.id} has finished"
            f"exercise id: {self.state.exercise_id}, state id: {self.state.id} ended as {final_status}"
        )

        exercise = self.state.exercise
Loading