Metadata-Version: 2.4
Name: escola
Version: 0.1.0
Summary: Desktop app for Catalan FP teachers: Esfera (bfgh) and Moodle integration, LOE catalogue and roster, RA-weighted grading
Project-URL: Homepage, https://github.com/ddemingo/college
Project-URL: Repository, https://github.com/ddemingo/college
Project-URL: Issues, https://github.com/ddemingo/college/issues
Author-email: David de Mingo <david@optersoft.com>
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Keywords: bfgh,catalan,catalonia,desktop,education,esfera,formacio-professional,fp,gencat,gicar,gradebook,grading,loe,moodle,pyside6,rubric,sqlcipher,teaching,xtec
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: MacOS X
Classifier: Environment :: Win32 (MS Windows)
Classifier: Environment :: X11 Applications :: Qt
Classifier: Intended Audience :: Education
Classifier: Natural Language :: Catalan
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Education
Classifier: Topic :: Education :: Computer Aided Instruction (CAI)
Requires-Python: >=3.12
Requires-Dist: anthropic>=0.69
Requires-Dist: httpx>=0.28
Requires-Dist: keyring>=25
Requires-Dist: openpyxl>=3.1
Requires-Dist: pyside6>=6.8
Requires-Dist: reportlab>=4.2
Requires-Dist: sqlcipher3>=0.6.2
Provides-Extra: dev
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# college

A desktop app for teaching in a Catalan FP (vocational training) centre. Four
things, in one window, on one machine:

- **Esfera** — log in to the Generalitat's Esfer@ (bfgh) through GICAR, sync
  your groups and students, read a group's qualifications grid, export it to
  XLSX or PDF.
- **School** — the LOE catalogue (cycles, modules, learning outcomes), the
  roster, and the classes that connect them.
- **Moodle** — pair a Moodle course with a class and pull its assignments and
  marks in, so the coursework the final grade is computed from is not retyped.
- **Grade** — RA-weighted marks and the computed final grade, plus an
  LLM autograder that clones each student's GitLab repository into an isolated
  container, builds and runs it, and grades it against a rubric.

It is a reimplementation of the corresponding parts of
[`academy`](https://github.com/optersoft/academy) — a Rust + Dioxus fullstack
web app — as a single-user desktop application. See
[Relationship to academy](#relationship-to-academy) for what changed and why.

> Renamed from **school** on 2026-08-09. An install from before the rename is
> adopted automatically at first launch: the data directory moves and the
> keyring entries are copied, so an existing database and the key that opens it
> come with you. Nothing is deleted.

**The database is encrypted.** Everything the app stores — including student
names, documents and grades — lives in one SQLCipher file that is unreadable
without the key. That is what makes it defensible for the roster to hold real
PII at all.

## Install

Requires Python 3.12+ and — for the autograder only — Docker.

```bash
uv tool install escola    # or: pip install escola
college                   # opens the app
```

> **The package is `escola`; everything else is `college`.** PyPI would not
> take `college`, so the distribution is published under the Catalan name.
> The command, the importable package, the database and the keychain entries
> are all unchanged — only what you type after `pip install` differs.

From a checkout, with [uv](https://docs.astral.sh/uv/):

```bash
git clone <this repo> && cd college
uv sync
uv run college            # opens the app
```

`sqlcipher3` builds against SQLCipher; on macOS `brew install sqlcipher` first
if the wheel does not resolve. Verify with:

```bash
uv run python -c "import sqlcipher3; print(sqlcipher3.connect(':memory:').execute('PRAGMA cipher_version').fetchone())"
```

### Commands

```bash
uv run college            # the desktop app (default)
uv run college init       # create or migrate the database without a window
uv run college where      # where the database, cache, log and key live
uv run pytest            # the test suite
uv run ruff check src tests
```

## How it is put together

```
src/college/
  paths.py        where things live; keyring service names
  secrets.py      credentials, in the OS keychain
  db/             SQLCipher connection, numbered migrations, all the SQL
  domain/         pure logic — no I/O, no Qt, fully unit-tested
    esfera_codes  bfgh code parsers (module/RA column codes, grade scales)
    grading       RA weights → per-RA scores → final grade
    rounding      the three rounding rules
    grid_model    qualification-grid column order and cell rendering
    project_regex practice code → student-repo path regex
  catalogue.py    the LOE catalogue file: load, import, export
  data/
    catalogue.json  144 cycles, 955 modules, 2851 learning outcomes
  esfera/         the bfgh client (SAML + AOP token), sync, XLSX/PDF export
  grade/          the autograder: GitLab discovery, sandbox, tools, agent loop
  ui/             PySide6 — one window, six sections, work on a thread pool
```

`domain/` is the layer worth reading first: it is a direct port of the Rust
modules that carried the subtle behaviour, and its tests are ports of the Rust
tests case-for-case.

### The three subsystems

**Esfera** (`esfera/client.py`) walks the real login: `GET /bfgh` redirects to
GICAR5 (Keycloak), the credentials are posted, the intermediate POST-redirect
forms are walked until one carries a `SAMLResponse`, and that is posted to
bfgh's Shibboleth ACS. Success is a `_shibsession_*` cookie. Each functional
area then needs its own short-lived **AOP token**, passed in a `token` header
next to `usr_centre`/`usr_rol`. From there: the teacher's groups, a group's
matrícules, and the per-student qualifications matrix (fetched three at a time
— higher concurrency makes bfgh emit spurious 401s).

**School** is the catalogue and roster in `db/migrations/0001_schema.sql`, with
two guard triggers that are the only thing preventing a plausible-but-wrong
grade: an assignment's RA weight must belong to the assignment's own module,
and a student may only be enrolled in a class of their own cycle.

A class is *a module you teach to a group*, and you declare it on the
**Qualificacions** page: pick the group, tick the modules that are yours, and
its students are enrolled for you. That is what fills the class picker — until
a class exists there is nothing to weight, mark or compute, and the page says
so rather than showing three empty tabs.

**Grade** has two halves. The manual half computes per-RA scores from
assignment weights — an unsubmitted assignment still dilutes its RA, and
repeated attempts count once (the best), both deliberate. The automatic half
discovers each student's repo on GitLab, clones it into a container, and runs a
bounded tool-using agent (`read_file`, `list_dir`, `grep`, `run_bash`) that must
finish by calling `emit_grade`.

### The catalogue

The professional families, cycles, modules and learning outcomes are the
Generalitat's published curriculum — identical for every centre in Catalonia,
and changing when a decree changes rather than when anyone uses the app. So it
ships as a plain JSON file, and **the app imports it for you at launch**: on a
fresh database, and again whenever a release carries a newer edition. There is
nothing to fetch, so there is no button to fetch it. Seeding costs about 16 ms.

What you do instead is **choose**. **Cicles i mòduls** is a family → cycle tree
with a checkbox on each cycle meaning *I teach this*; perfil variants nest
under the cycle they vary. Tick two or six, and **Només els meus** turns 144
cycles into yours — it switches itself on the first time you have a selection.
The search box reaches cycle names, codes, acronyms, families **and module
codes**, so typing `0484` finds the cycles that teach Bases de dades. Selecting
a cycle shows its modules; selecting a module shows its RA statements, which
are what you later weight.

The same `offered` flag is what the Esfera sync sets when it finds one of your
groups, so choosing by hand and syncing agree rather than compete.

Correcting the curriculum is a maintainer's job, not a teacher's: edit
`src/college/data/catalogue.json` and ship it. `college.catalogue` is the library
behind that — `load()` reads a file or a URL, `export_catalogue()` writes the
database's copy back in the shipped file's exact formatting, and a test asserts
that round trip is byte-stable, so regenerating the data file never produces a
25,000-line reflow.

Importing **merges**: a cycle you marked as offered stays offered, a module you added by
hand survives an import that does not mention it, and a record referencing
something absent is skipped and reported rather than failing the other 954.
Nothing is written unless the whole file validates, and the write is one
transaction. `--prune` additionally drops modules and RAs the file omits;
cycles are never deleted even then, because that would cascade into your own
groups and students.

Exports use the same formatting as the shipped file, so a correction can be
diffed and committed. A test asserts the bundled file re-serialises byte-for-byte
— otherwise every export would look like a 25,000-line diff.

Esfera feeds the catalogue back. A qualifications grid is the authoritative
list of what a group is graded on this year at this centre — it carries
centre-defined modules the LOE catalogue cannot have (`C043 Disseny de Nous
Fàrmacs`) and RAs the catalogue may be behind on. Fetching a grid adds whatever
it names and we lack.

It adds only. A learning outcome's name comes back from bfgh in a fixed-width
field — measured at exactly 32 latin-1 bytes, cut mid-word — so its text is a
label, not a statement:

| source | text | latin-1 bytes |
|---|---|---|
| Esfera | `Descriu la interacció entre fàrm` | 32 |
| catalogue | `Descriu la interacció entre fàrmacs i receptors, reconeixent les diverses formes d'obtenció…` | 177 |

Esfera decides what exists; the catalogue keeps the wording it has.

## Moodle

Esfera holds the qualification; Moodle holds the coursework it is computed
from. So the sync runs one way — assignments and marks come **in**, and nothing
is ever written back to either system.

It hangs off one decision the teacher makes explicitly and once: *this Moodle
course is that class*. Nothing infers it. A teacher's Moodle carries last
year's courses, courses they only observe, and shortnames that resemble a
module code without being one, so a guess here would silently import a term of
marks into the wrong group. The pairing is stored on `classes.moodle_course_id`
and is unique — two classes pulling from one course would import the same
assignments twice and then disagree about them.

Two rules make a re-sync safe, which matters because re-syncing is the normal
case (after another submission is marked, after a late enrolment, after a
rename):

- **An assignment imported from Moodle is owned by Moodle.** It carries
  `moodle_id`, and a later sync updates it. An assignment you created by hand
  has none and is never touched.
- **A mark is only written for an assignment Moodle owns.** A mark you typed
  cannot be clobbered, because the sync never looks at that assignment.

Students are matched by email, then by name across the two orders Esfera and
Moodle write them in (`Puig, Anna` ↔ `Anna Puig`, accents and case ignored). A
match is then **stored** on `students.moodle_id`, so a later name change cannot
break the link — matching by name every time is how a student who marries
silently becomes a second student. Anyone still unmatched is listed by name;
their marks are skipped, never guessed.

Grades are rescaled to ten using the course's own maximum, so 72.5 out of 100
is a 7.25. An ungraded column stays *absent* rather than becoming a zero — the
RA weighting treats those differently, and a zero would drag the average down
for work nobody has marked yet. The course total is skipped: Moodle returns it
carrying the same `iteminstance` as the first assignment, so a client keying on
that alone files the course total as a mark.

A whole course import is one transaction. Half a term's marks is worse than
none — the RA computation would run over a partial set and produce a plausible,
wrong final grade rather than an obviously missing one.

The connection needs web services enabled on the site and the
`moodle_mobile_app` service available (Moodle's standard mobile token). Watch
out for two things the wire protocol does not make obvious: Moodle answers a
**rejected token with HTTP 200** and an exception object, so the status code
proves nothing and every response body has to be inspected; and it silently
ignores repeated keys and JSON arrays, so list parameters must be sent as
`courseids[0]`, `courseids[1]`.

### Credentials and the encryption key

| What | Where |
|---|---|
| Database key | OS keychain (`com.optersoft.college`), or a passphrase you type |
| Esfera password | OS keychain, only if you tick "remember" |
| Moodle token | OS keychain, one entry per site — the password is never stored |
| GitLab token | OS keychain |
| Model API key | OS keychain, or `ANTHROPIC_API_KEY` / an `ant auth login` profile |

Credentials deliberately do **not** live in the database: the database is the
thing you back up and copy to another machine, and credentials should not
travel with it. On first run a random 256-bit key is generated and stored in
the keychain, so unlocking the app is unlocking the keychain. Where there is no
usable keychain, the app asks for a passphrase instead and SQLCipher derives
the key from it — nothing is stored, so a forgotten passphrase is a lost
database.

## The autograder, and what it runs

`run_bash` gives the model arbitrary code execution. This is not incidental:
grading "does it build and run" cannot be done without running it. The control
is the container — `college/grade/sandbox.py` — which is the boundary between a
student's `Makefile` and your laptop. The app refuses to start a run when
Docker is not available rather than falling back to running anything locally.

A run costs API tokens and takes minutes per student, so it is never implicit:
the discovery preview is always available and free, and the run is a separate,
explicitly-confirmed, cancellable button. Each student is committed as they
finish, so cancelling keeps every grade already produced.

Defaults: `claude-opus-5`, adaptive thinking at `high` effort, 40 turns,
600 s wall-clock, 32 KB per tool output. Server-side refusal fallback is on —
grading runs read and execute student code, which occasionally looks adjacent
to the safety classifiers' territory, and losing a student's grade to a false
positive is worse than the request being served by another model. An
OpenAI-compatible endpoint (Ollama, vLLM, a gateway) is supported as an
alternative backend, so the app can be run without a paid API at all.

## Why PySide6 and not PyQt6

Both bind the same Qt 6, and the code would look nearly identical either way
(`Signal`/`Slot` vs `pyqtSignal`/`pyqtSlot`, and little else). The choice is
about who ships the binding and what it obliges downstream — not about
capability, and **not** about a licence conflict:

**There is no legal blocker either way.** PyQt6 is GPL-3.0 (or a paid Riverbank
commercial licence). An AGPL-3.0 application *may* use it: AGPLv3 §13 and GPLv3
§13 explicitly permit combining works under the two licences. Anyone telling you
PyQt cannot be used in an AGPL app has it wrong.

What actually decided it:

- **PySide6 is Qt's own binding.** The Qt Company releases it in lockstep with
  Qt itself, so a Qt version and its Python binding are the same release train.
  PyQt6 is a third party tracking Qt on their own schedule.
- **LGPL keeps the binding out of the licensing conversation.** This app is
  AGPL because *this app* should stay open, not because its GUI toolkit should
  force that on anyone reusing a piece of it. Under PyQt6, the binding itself
  imposes GPL-or-pay on every derivative; under PySide6's LGPL, a centre that
  wants to fork a page into an internal tool is constrained by our licence
  alone. Fewer licences in the chain, fewer surprises for the next teacher.
- **No per-seat escape hatch to buy.** If a fork ever needed a non-copyleft
  licence, PySide6's LGPL terms allow it with no purchase; PyQt6's route is a
  commercial licence priced per developer.
- **Packaging.** `pyside6` ships official wheels for macOS (Intel + arm64),
  Windows and Linux, with type stubs, from the same vendor as Qt.

The honest counterpoint: PyQt6's `sip` bindings are older and its type stubs
were better for years. That gap has closed, and it would not have outweighed the
release-cadence and licence-chain arguments here anyway.

## Relationship to academy

This is not a port of the whole of `academy`, and where it diverges the reason
is that a desktop app is a different machine, not a different opinion.

| academy | here | why |
|---|---|---|
| Server-side session, JWT carrying Shibboleth cookies | One `EsferaClient` per process, jar in the encrypted DB | No browser to hand a cookie to |
| Students stored as opaque ids; names never persisted | Names, documents and emails stored | The file is encrypted and never leaves the machine; that is the point |
| Google OAuth, `users`, consent records, admin tier | None | One operator, one machine |
| Grading agent in a disposable Isard GPU VM: SFTP a musl binary over an SSH bastion, WebSocket proxy holding the cloud key, in-memory job registry rehydrated at boot | `docker run`, key in the keyring, jobs in the encrypted DB | Every one of those existed because a shared server could not be the sandbox |
| turso engine, MVCC, `BEGIN CONCURRENT`, off-box replication | SQLCipher, WAL | One writer |
| Ollama Cloud through a proxy with a model whitelist | Anthropic SDK directly, OpenAI-compatible endpoint optional | No proxy to protect a key from a VM |

What was ported rather than reimagined: the bfgh code parsers, the
qualifications-grid layout, the RA-weight computation and its two deliberate
behaviours, the rounding rules, the practice-code regex, and the agent loop's
budget and stop-reason semantics. Their Rust tests came along with them.

## Language

The interface is in Catalan, like the original and like the system it talks to.
Code — identifiers, comments, docstrings, commit messages — is in English.
Strings that match official DOGC or bfgh text are not translated at all: they
are matched against, not read.

## Licence

GNU Affero General Public License v3.0 or later. See [LICENSE](LICENSE).

Copyright © David de Mingo <david@optersoft.com>.
