Skip to content

Gradebook

SQLite-backed gradebook for storing and querying grades.

mograder.grading.gradebook

SQLite gradebook for persistent grade storage.

Gradebook

SQLite-backed gradebook for storing grades and feedback.

Uses WAL mode for safe concurrent access from multiple SSH sessions.

Source code in src/mograder/grading/gradebook.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
class Gradebook:
    """SQLite-backed gradebook for storing grades and feedback.

    Uses WAL mode for safe concurrent access from multiple SSH sessions.
    """

    def __init__(self, db_path: Path):
        self.db_path = Path(db_path)
        self._is_new = not self.db_path.exists()
        self._conn = sqlite3.connect(
            str(self.db_path),
            check_same_thread=False,
        )
        self._conn.row_factory = sqlite3.Row
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._conn.execute("PRAGMA foreign_keys=ON")
        self._conn.execute("PRAGMA busy_timeout=5000")
        self._create_tables()
        self._migrate()

    @property
    def is_new(self) -> bool:
        """True if the database was freshly created (no prior file)."""
        return self._is_new

    def _create_tables(self) -> None:
        self._conn.executescript(
            """
            CREATE TABLE IF NOT EXISTS assignments (
                name            TEXT PRIMARY KEY,
                max_mark        REAL NOT NULL DEFAULT 100,
                marks_metadata  TEXT,
                auto_check_keys TEXT
            );
            CREATE TABLE IF NOT EXISTS students (
                username  TEXT PRIMARY KEY,
                full_name TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS submissions (
                assignment      TEXT NOT NULL REFERENCES assignments(name),
                student         TEXT NOT NULL,
                auto_mark       REAL,
                manual_mark     REAL,
                total_mark      REAL,
                feedback        TEXT NOT NULL DEFAULT '',
                cell_errors     INTEGER NOT NULL DEFAULT 0,
                tampered        TEXT NOT NULL DEFAULT '[]',
                check_results   TEXT NOT NULL DEFAULT '[]',
                graded_at       TEXT,
                autograded_at   TEXT NOT NULL DEFAULT (datetime('now')),
                updated_at      TEXT,
                PRIMARY KEY (assignment, student)
            );
            """
        )

    def _migrate(self) -> None:
        """Add columns that may be missing in existing databases."""
        for stmt in [
            "ALTER TABLE submissions ADD COLUMN updated_at TEXT",
            "ALTER TABLE assignments ADD COLUMN auto_check_keys TEXT",
            "ALTER TABLE submissions ADD COLUMN penalty_pct REAL",
            "ALTER TABLE submissions ADD COLUMN penalised_mark REAL",
            "ALTER TABLE submissions ADD COLUMN submitted_at TEXT",
        ]:
            try:
                self._conn.execute(stmt)
                self._conn.commit()
            except sqlite3.OperationalError:
                pass  # column already exists

    def close(self) -> None:
        self._conn.close()

    def __enter__(self) -> "Gradebook":
        return self

    def __exit__(self, *exc) -> None:
        self.close()

    @contextmanager
    def write_lock(self, timeout: float = 10.0):
        """Acquire an exclusive file lock on ``{db_path}.lock``.

        Uses ``fcntl.flock`` (Unix) so that concurrent writers (e.g. autograde
        and grader) are serialised.  On Windows the lock is a no-op (SQLite
        WAL + busy_timeout provide basic protection).
        """
        if os.name == "nt":
            yield
            return

        import fcntl
        import time

        lock_path = Path(str(self.db_path) + ".lock")
        fd = lock_path.open("w")
        deadline = time.monotonic() + timeout
        while True:
            try:
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except OSError:
                if time.monotonic() > deadline:
                    fd.close()
                    raise TimeoutError(
                        f"Could not acquire write lock on {lock_path} within {timeout}s"
                    )
                time.sleep(0.1)
        try:
            yield
        finally:
            fcntl.flock(fd, fcntl.LOCK_UN)
            fd.close()

    # --- Assignments ---

    def upsert_assignment(
        self,
        name: str,
        max_mark: float = 100,
        marks_metadata: dict | None = None,
        auto_check_keys: list[str] | None = None,
    ) -> None:
        meta_json = json.dumps(marks_metadata) if marks_metadata else None
        keys_json = json.dumps(sorted(auto_check_keys)) if auto_check_keys else None
        with self._conn:
            self._conn.execute(
                """
                INSERT INTO assignments (name, max_mark, marks_metadata, auto_check_keys)
                VALUES (?, ?, ?, ?)
                ON CONFLICT(name) DO UPDATE SET
                    max_mark = excluded.max_mark,
                    marks_metadata = excluded.marks_metadata,
                    auto_check_keys = excluded.auto_check_keys
                """,
                (name, max_mark, meta_json, keys_json),
            )

    def get_assignment(self, name: str) -> dict | None:
        row = self._conn.execute(
            "SELECT * FROM assignments WHERE name = ?", (name,)
        ).fetchone()
        if row is None:
            return None
        d = dict(row)
        if d["marks_metadata"]:
            d["marks_metadata"] = json.loads(d["marks_metadata"])
        if d.get("auto_check_keys"):
            d["auto_check_keys"] = json.loads(d["auto_check_keys"])
        return d

    # --- Submissions ---

    def save_autograde_result(
        self,
        assignment: str,
        student: str,
        check_results: list,
        cell_errors: int = 0,
        auto_mark: float | None = None,
        tampered: list[str] | None = None,
    ) -> None:
        """Upsert autograde results, preserving existing manual_mark/feedback."""
        checks_json = json.dumps(
            [
                {"label": c.label, "status": c.status, "hidden": c.hidden}
                for c in check_results
            ]
        )
        tampered_json = json.dumps(tampered or [])
        now = datetime.now().isoformat()

        with self._conn:
            self._conn.execute(
                """
                INSERT INTO submissions
                    (assignment, student, auto_mark, cell_errors, tampered,
                     check_results, autograded_at, updated_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(assignment, student) DO UPDATE SET
                    auto_mark = excluded.auto_mark,
                    cell_errors = excluded.cell_errors,
                    tampered = excluded.tampered,
                    check_results = excluded.check_results,
                    autograded_at = excluded.autograded_at,
                    updated_at = excluded.updated_at,
                    total_mark = CASE
                        WHEN submissions.manual_mark IS NOT NULL
                             AND excluded.auto_mark IS NOT NULL
                        THEN excluded.auto_mark + submissions.manual_mark
                        WHEN submissions.manual_mark IS NOT NULL
                        THEN submissions.manual_mark
                        ELSE NULL
                    END
                """,
                (
                    assignment,
                    student,
                    auto_mark,
                    cell_errors,
                    tampered_json,
                    checks_json,
                    now,
                    now,
                ),
            )

    def save_manual_grade(
        self,
        assignment: str,
        student: str,
        manual_mark: float | None,
        feedback: str = "",
        total_mark: float | None = None,
        expected_updated_at: str | None = None,
    ) -> bool:
        """Save manual grade and feedback.

        If *total_mark* is provided it is used directly (e.g. after scaling
        a 0-100 slider to the manual portion). Otherwise total is computed
        as ``auto_mark + manual_mark``.

        If *expected_updated_at* is given and the current ``updated_at`` in the
        database differs, the write is skipped and ``False`` is returned
        (optimistic locking — the caller should warn about a conflict).
        Returns ``True`` on successful save.
        """
        now = datetime.now().isoformat()

        # Get current auto_mark (and check for stale data)
        row = self._conn.execute(
            "SELECT auto_mark, updated_at FROM submissions WHERE assignment = ? AND student = ?",
            (assignment, student),
        ).fetchone()

        if row is not None:
            if (
                expected_updated_at is not None
                and row["updated_at"] != expected_updated_at
            ):
                return False  # stale — conflict detected

            if total_mark is not None:
                total = total_mark
            else:
                auto_mark = row["auto_mark"]
                if manual_mark is not None and auto_mark is not None:
                    total = auto_mark + manual_mark
                elif manual_mark is not None:
                    total = manual_mark
                else:
                    total = None

            with self._conn:
                self._conn.execute(
                    """
                    UPDATE submissions
                    SET manual_mark = ?, feedback = ?, total_mark = ?,
                        graded_at = ?, updated_at = ?
                    WHERE assignment = ? AND student = ?
                    """,
                    (manual_mark, feedback, total, now, now, assignment, student),
                )
        else:
            total = total_mark if total_mark is not None else manual_mark
            with self._conn:
                self._conn.execute(
                    """
                    INSERT INTO submissions
                        (assignment, student, manual_mark, feedback,
                         total_mark, graded_at, updated_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?)
                    """,
                    (assignment, student, manual_mark, feedback, total, now, now),
                )
        return True

    def save_penalty(
        self,
        assignment: str,
        student: str,
        penalty_pct: float,
        penalised_mark: float,
        submitted_at: str | None = None,
    ) -> None:
        """Save late penalty data for a submission."""
        now = datetime.now().isoformat()
        with self._conn:
            self._conn.execute(
                """
                UPDATE submissions
                SET penalty_pct = ?, penalised_mark = ?, submitted_at = ?,
                    updated_at = ?
                WHERE assignment = ? AND student = ?
                """,
                (penalty_pct, penalised_mark, submitted_at, now, assignment, student),
            )

    def get_submission(self, assignment: str, student: str) -> dict | None:
        row = self._conn.execute(
            "SELECT * FROM submissions WHERE assignment = ? AND student = ?",
            (assignment, student),
        ).fetchone()
        if row is None:
            return None
        d = dict(row)
        d["check_results"] = json.loads(d["check_results"])
        d["tampered"] = json.loads(d["tampered"])
        return d

    def list_submissions(self, assignment: str) -> list[dict]:
        rows = self._conn.execute(
            "SELECT * FROM submissions WHERE assignment = ? ORDER BY student",
            (assignment,),
        ).fetchall()
        result = []
        for row in rows:
            d = dict(row)
            d["check_results"] = json.loads(d["check_results"])
            d["tampered"] = json.loads(d["tampered"])
            result.append(d)
        return result

    # --- Grade collection ---

    def collect_grades(self, assignment: str) -> list[dict]:
        """Returns [{student, mark, auto_mark, feedback, ...}, ...] for feedback/CSV."""
        rows = self._conn.execute(
            """
            SELECT student, total_mark, auto_mark, feedback,
                   penalty_pct, penalised_mark, submitted_at
            FROM submissions WHERE assignment = ?
            ORDER BY student
            """,
            (assignment,),
        ).fetchall()
        result = []
        for r in rows:
            d = {
                "student": r["student"],
                "mark": int(r["total_mark"]) if r["total_mark"] is not None else None,
                "auto_mark": int(r["auto_mark"])
                if r["auto_mark"] is not None
                else None,
                "feedback": r["feedback"] or "",
            }
            if r["penalty_pct"] is not None:
                d["penalty_pct"] = r["penalty_pct"]
                d["penalised_mark"] = (
                    int(r["penalised_mark"])
                    if r["penalised_mark"] is not None
                    else None
                )
            result.append(d)
        return result

    def collect_student_marks(
        self, assignment_names: list[str]
    ) -> dict[str, dict[str, int | None]]:
        """Returns {student: {assignment: total_mark}} across assignments."""
        result: dict[str, dict[str, int | None]] = {}
        for aname in assignment_names:
            rows = self._conn.execute(
                "SELECT student, total_mark FROM submissions WHERE assignment = ?",
                (aname,),
            ).fetchall()
            for r in rows:
                student = r["student"]
                if student not in result:
                    result[student] = {}
                mark = int(r["total_mark"]) if r["total_mark"] is not None else None
                result[student][aname] = mark
        return result

    def count_graded(self, assignment: str) -> int:
        """Count submissions with a manual grade set."""
        row = self._conn.execute(
            "SELECT COUNT(*) as c FROM submissions WHERE assignment = ? AND graded_at IS NOT NULL",
            (assignment,),
        ).fetchone()
        return row["c"]

    # --- Students ---

    def upsert_students(self, mapping: dict[str, str]) -> None:
        """Bulk insert or replace student name mappings."""
        with self._conn:
            self._conn.executemany(
                "INSERT OR REPLACE INTO students (username, full_name) VALUES (?, ?)",
                mapping.items(),
            )

    def get_name_lookup(self) -> dict[str, str]:
        """Return {username: full_name} for all students."""
        rows = self._conn.execute("SELECT username, full_name FROM students").fetchall()
        return {r["username"]: r["full_name"] for r in rows}

    # --- Migration ---

    def import_from_py(
        self,
        assignment: str,
        autograded_dir: Path,
        marks_metadata: dict | None = None,
    ) -> int:
        """Import existing grades from .py files into the DB.

        Returns count of submissions imported.
        """
        autograded_dir = Path(autograded_dir)
        if not autograded_dir.is_dir():
            return 0

        now = datetime.now().isoformat()
        count = 0
        with self._conn:
            for f in sorted(autograded_dir.iterdir()):
                if f.suffix != ".py":
                    continue
                student = f.stem
                lines = f.read_text().splitlines(keepends=True)
                manual_mark, feedback_text = parse_marker_feedback(lines)
                auto_mark = parse_auto_marks(lines)

                if manual_mark is not None and auto_mark is not None:
                    total = auto_mark + manual_mark
                elif manual_mark is not None:
                    total = manual_mark
                else:
                    total = None

                self._conn.execute(
                    """
                    INSERT INTO submissions
                        (assignment, student, auto_mark, manual_mark,
                         total_mark, feedback, updated_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?)
                    ON CONFLICT(assignment, student) DO UPDATE SET
                        auto_mark = COALESCE(submissions.auto_mark, excluded.auto_mark),
                        manual_mark = COALESCE(submissions.manual_mark, excluded.manual_mark),
                        total_mark = COALESCE(submissions.total_mark, excluded.total_mark),
                        feedback = CASE
                            WHEN submissions.feedback != '' THEN submissions.feedback
                            ELSE excluded.feedback
                        END,
                        updated_at = excluded.updated_at
                    """,
                    (
                        assignment,
                        student,
                        auto_mark,
                        manual_mark,
                        total,
                        feedback_text,
                        now,
                    ),
                )
                count += 1

        return count

is_new property

True if the database was freshly created (no prior file).

write_lock(timeout=10.0)

Acquire an exclusive file lock on {db_path}.lock.

Uses fcntl.flock (Unix) so that concurrent writers (e.g. autograde and grader) are serialised. On Windows the lock is a no-op (SQLite WAL + busy_timeout provide basic protection).

Source code in src/mograder/grading/gradebook.py
@contextmanager
def write_lock(self, timeout: float = 10.0):
    """Acquire an exclusive file lock on ``{db_path}.lock``.

    Uses ``fcntl.flock`` (Unix) so that concurrent writers (e.g. autograde
    and grader) are serialised.  On Windows the lock is a no-op (SQLite
    WAL + busy_timeout provide basic protection).
    """
    if os.name == "nt":
        yield
        return

    import fcntl
    import time

    lock_path = Path(str(self.db_path) + ".lock")
    fd = lock_path.open("w")
    deadline = time.monotonic() + timeout
    while True:
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            break
        except OSError:
            if time.monotonic() > deadline:
                fd.close()
                raise TimeoutError(
                    f"Could not acquire write lock on {lock_path} within {timeout}s"
                )
            time.sleep(0.1)
    try:
        yield
    finally:
        fcntl.flock(fd, fcntl.LOCK_UN)
        fd.close()

save_autograde_result(assignment, student, check_results, cell_errors=0, auto_mark=None, tampered=None)

Upsert autograde results, preserving existing manual_mark/feedback.

Source code in src/mograder/grading/gradebook.py
def save_autograde_result(
    self,
    assignment: str,
    student: str,
    check_results: list,
    cell_errors: int = 0,
    auto_mark: float | None = None,
    tampered: list[str] | None = None,
) -> None:
    """Upsert autograde results, preserving existing manual_mark/feedback."""
    checks_json = json.dumps(
        [
            {"label": c.label, "status": c.status, "hidden": c.hidden}
            for c in check_results
        ]
    )
    tampered_json = json.dumps(tampered or [])
    now = datetime.now().isoformat()

    with self._conn:
        self._conn.execute(
            """
            INSERT INTO submissions
                (assignment, student, auto_mark, cell_errors, tampered,
                 check_results, autograded_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(assignment, student) DO UPDATE SET
                auto_mark = excluded.auto_mark,
                cell_errors = excluded.cell_errors,
                tampered = excluded.tampered,
                check_results = excluded.check_results,
                autograded_at = excluded.autograded_at,
                updated_at = excluded.updated_at,
                total_mark = CASE
                    WHEN submissions.manual_mark IS NOT NULL
                         AND excluded.auto_mark IS NOT NULL
                    THEN excluded.auto_mark + submissions.manual_mark
                    WHEN submissions.manual_mark IS NOT NULL
                    THEN submissions.manual_mark
                    ELSE NULL
                END
            """,
            (
                assignment,
                student,
                auto_mark,
                cell_errors,
                tampered_json,
                checks_json,
                now,
                now,
            ),
        )

save_manual_grade(assignment, student, manual_mark, feedback='', total_mark=None, expected_updated_at=None)

Save manual grade and feedback.

If total_mark is provided it is used directly (e.g. after scaling a 0-100 slider to the manual portion). Otherwise total is computed as auto_mark + manual_mark.

If expected_updated_at is given and the current updated_at in the database differs, the write is skipped and False is returned (optimistic locking — the caller should warn about a conflict). Returns True on successful save.

Source code in src/mograder/grading/gradebook.py
def save_manual_grade(
    self,
    assignment: str,
    student: str,
    manual_mark: float | None,
    feedback: str = "",
    total_mark: float | None = None,
    expected_updated_at: str | None = None,
) -> bool:
    """Save manual grade and feedback.

    If *total_mark* is provided it is used directly (e.g. after scaling
    a 0-100 slider to the manual portion). Otherwise total is computed
    as ``auto_mark + manual_mark``.

    If *expected_updated_at* is given and the current ``updated_at`` in the
    database differs, the write is skipped and ``False`` is returned
    (optimistic locking — the caller should warn about a conflict).
    Returns ``True`` on successful save.
    """
    now = datetime.now().isoformat()

    # Get current auto_mark (and check for stale data)
    row = self._conn.execute(
        "SELECT auto_mark, updated_at FROM submissions WHERE assignment = ? AND student = ?",
        (assignment, student),
    ).fetchone()

    if row is not None:
        if (
            expected_updated_at is not None
            and row["updated_at"] != expected_updated_at
        ):
            return False  # stale — conflict detected

        if total_mark is not None:
            total = total_mark
        else:
            auto_mark = row["auto_mark"]
            if manual_mark is not None and auto_mark is not None:
                total = auto_mark + manual_mark
            elif manual_mark is not None:
                total = manual_mark
            else:
                total = None

        with self._conn:
            self._conn.execute(
                """
                UPDATE submissions
                SET manual_mark = ?, feedback = ?, total_mark = ?,
                    graded_at = ?, updated_at = ?
                WHERE assignment = ? AND student = ?
                """,
                (manual_mark, feedback, total, now, now, assignment, student),
            )
    else:
        total = total_mark if total_mark is not None else manual_mark
        with self._conn:
            self._conn.execute(
                """
                INSERT INTO submissions
                    (assignment, student, manual_mark, feedback,
                     total_mark, graded_at, updated_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                """,
                (assignment, student, manual_mark, feedback, total, now, now),
            )
    return True

save_penalty(assignment, student, penalty_pct, penalised_mark, submitted_at=None)

Save late penalty data for a submission.

Source code in src/mograder/grading/gradebook.py
def save_penalty(
    self,
    assignment: str,
    student: str,
    penalty_pct: float,
    penalised_mark: float,
    submitted_at: str | None = None,
) -> None:
    """Save late penalty data for a submission."""
    now = datetime.now().isoformat()
    with self._conn:
        self._conn.execute(
            """
            UPDATE submissions
            SET penalty_pct = ?, penalised_mark = ?, submitted_at = ?,
                updated_at = ?
            WHERE assignment = ? AND student = ?
            """,
            (penalty_pct, penalised_mark, submitted_at, now, assignment, student),
        )

collect_grades(assignment)

Returns [{student, mark, auto_mark, feedback, ...}, ...] for feedback/CSV.

Source code in src/mograder/grading/gradebook.py
def collect_grades(self, assignment: str) -> list[dict]:
    """Returns [{student, mark, auto_mark, feedback, ...}, ...] for feedback/CSV."""
    rows = self._conn.execute(
        """
        SELECT student, total_mark, auto_mark, feedback,
               penalty_pct, penalised_mark, submitted_at
        FROM submissions WHERE assignment = ?
        ORDER BY student
        """,
        (assignment,),
    ).fetchall()
    result = []
    for r in rows:
        d = {
            "student": r["student"],
            "mark": int(r["total_mark"]) if r["total_mark"] is not None else None,
            "auto_mark": int(r["auto_mark"])
            if r["auto_mark"] is not None
            else None,
            "feedback": r["feedback"] or "",
        }
        if r["penalty_pct"] is not None:
            d["penalty_pct"] = r["penalty_pct"]
            d["penalised_mark"] = (
                int(r["penalised_mark"])
                if r["penalised_mark"] is not None
                else None
            )
        result.append(d)
    return result

collect_student_marks(assignment_names)

Returns {student: {assignment: total_mark}} across assignments.

Source code in src/mograder/grading/gradebook.py
def collect_student_marks(
    self, assignment_names: list[str]
) -> dict[str, dict[str, int | None]]:
    """Returns {student: {assignment: total_mark}} across assignments."""
    result: dict[str, dict[str, int | None]] = {}
    for aname in assignment_names:
        rows = self._conn.execute(
            "SELECT student, total_mark FROM submissions WHERE assignment = ?",
            (aname,),
        ).fetchall()
        for r in rows:
            student = r["student"]
            if student not in result:
                result[student] = {}
            mark = int(r["total_mark"]) if r["total_mark"] is not None else None
            result[student][aname] = mark
    return result

count_graded(assignment)

Count submissions with a manual grade set.

Source code in src/mograder/grading/gradebook.py
def count_graded(self, assignment: str) -> int:
    """Count submissions with a manual grade set."""
    row = self._conn.execute(
        "SELECT COUNT(*) as c FROM submissions WHERE assignment = ? AND graded_at IS NOT NULL",
        (assignment,),
    ).fetchone()
    return row["c"]

upsert_students(mapping)

Bulk insert or replace student name mappings.

Source code in src/mograder/grading/gradebook.py
def upsert_students(self, mapping: dict[str, str]) -> None:
    """Bulk insert or replace student name mappings."""
    with self._conn:
        self._conn.executemany(
            "INSERT OR REPLACE INTO students (username, full_name) VALUES (?, ?)",
            mapping.items(),
        )

get_name_lookup()

Return {username: full_name} for all students.

Source code in src/mograder/grading/gradebook.py
def get_name_lookup(self) -> dict[str, str]:
    """Return {username: full_name} for all students."""
    rows = self._conn.execute("SELECT username, full_name FROM students").fetchall()
    return {r["username"]: r["full_name"] for r in rows}

import_from_py(assignment, autograded_dir, marks_metadata=None)

Import existing grades from .py files into the DB.

Returns count of submissions imported.

Source code in src/mograder/grading/gradebook.py
def import_from_py(
    self,
    assignment: str,
    autograded_dir: Path,
    marks_metadata: dict | None = None,
) -> int:
    """Import existing grades from .py files into the DB.

    Returns count of submissions imported.
    """
    autograded_dir = Path(autograded_dir)
    if not autograded_dir.is_dir():
        return 0

    now = datetime.now().isoformat()
    count = 0
    with self._conn:
        for f in sorted(autograded_dir.iterdir()):
            if f.suffix != ".py":
                continue
            student = f.stem
            lines = f.read_text().splitlines(keepends=True)
            manual_mark, feedback_text = parse_marker_feedback(lines)
            auto_mark = parse_auto_marks(lines)

            if manual_mark is not None and auto_mark is not None:
                total = auto_mark + manual_mark
            elif manual_mark is not None:
                total = manual_mark
            else:
                total = None

            self._conn.execute(
                """
                INSERT INTO submissions
                    (assignment, student, auto_mark, manual_mark,
                     total_mark, feedback, updated_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(assignment, student) DO UPDATE SET
                    auto_mark = COALESCE(submissions.auto_mark, excluded.auto_mark),
                    manual_mark = COALESCE(submissions.manual_mark, excluded.manual_mark),
                    total_mark = COALESCE(submissions.total_mark, excluded.total_mark),
                    feedback = CASE
                        WHEN submissions.feedback != '' THEN submissions.feedback
                        ELSE excluded.feedback
                    END,
                    updated_at = excluded.updated_at
                """,
                (
                    assignment,
                    student,
                    auto_mark,
                    manual_mark,
                    total,
                    feedback_text,
                    now,
                ),
            )
            count += 1

    return count