Close the gaps an audit of courses, tasks, files and grades turned up

Every area — courses, rooms, boards, topics, tasks, files, quizzes, teams,
groups, submissions, grades — was checked for data the instance has and the
tools did not show.

Grades and feedback. A teacher's /homework page is a different page from a
student's: grade and comment live in the grading form, one block per
submission, so a teacher account reported every graded submission as having
neither. parseTeacherGrading reads the form, and list_submissions can now
include the written feedback and who handed the work in.

Names. /api/v1 is partly served: courses, users and classes survive in the
deployment's ingress table, and users/{id} is the only route from an id to a
name. Submitters, file creators and course teachers resolve through it, and
degrade to "not visible to this account" where a student may not read them.

Courses, rooms and classes. get_course adds the description, teachers,
member count and weekly timetable from /api/v1/courses. list_classes is new.
get_room reports what the account may do — allowedOperations is an object of
booleans, not the list it was typed as — and applicants and invitation links
where it may manage them.

Board and topic content. Link descriptions, image alt text, drawing and
video-conference titles, the ids behind external tools and H5P content (the
only thing resembling a quiz), and what a deleted element used to be. Topic
Etherpad pads are read like board pads, and htmlToText keeps table columns
apart and drops template indentation.

Files. A scan with no text layer falls back to the preview endpoint, whose
width and outputFormat are undocumented enums, so Claude gets a picture of
the page; list_files reports counts and sizes. Teams stay documented as
unreadable at any API version; their files come later.

What the crawl missed. Tasks attached to topics (18 of 60 on the live
account), each course's own file area, and — behind INDEX_PERSONAL_FILES —
personal files and submissions with their grade comments, so search and
what_changed cover grading. A submission hit points at get_task.

The local instance's preview profile gets an ImageMagick policy that allows
the coders its 7.1.2 build needs; the image's own denies them all.

110 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent a3b17a680c
commit 5ae2210459
25 changed files with 1462 additions and 89 deletions

View File

@@ -3,19 +3,27 @@ import type {
BoardContext,
BoardSkeleton,
CardResponse,
ClassItem,
CourseBoardResponse,
CourseMetadata,
DashboardResponse,
FileParentType,
FileRecord,
GroupItem,
LegacyCourse,
LegacyUser,
LessonResponse,
MeResponse,
NewsResponse,
Paginated,
ParentFileStats,
PreviewWidth,
SubmissionStatus,
LessonLinkedTask,
RoomApplicant,
RoomBoardItem,
RoomDetails,
RoomInvitationLink,
RoomItem,
RoomMember,
TaskContent,
@@ -27,6 +35,9 @@ import type {
*/
export const MAX_IDS_PER_QUERY = 20;
/** The only output format the preview endpoint accepts; anything else is a 400. */
const PREVIEW_OUTPUT_FORMAT = 'image/webp';
/** Statuses worth retrying: transient by definition, and every call here is a GET. */
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 3;
@@ -365,6 +376,27 @@ export class SchulcloudClient {
return body.data ?? [];
}
/**
* People waiting to be let into a room, and the room's invitation links.
*
* Both are room-admin surface: a viewer gets 403, which is ordinary rather
* than exceptional. `allowedOperations` on the room says which of these the
* account may ask for, so callers can skip the ones that would be refused.
*/
async listRoomApplicants(roomId: string): Promise<RoomApplicant[]> {
const body = await this.getJson<{ data?: RoomApplicant[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/applicants`,
);
return body.data ?? [];
}
async listRoomInvitationLinks(roomId: string): Promise<RoomInvitationLink[]> {
const body = await this.getJson<{ data?: RoomInvitationLink[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/room-invitation-links`,
);
return body.data ?? [];
}
// --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {
@@ -441,6 +473,83 @@ export class SchulcloudClient {
limit: clampPageSize(params.limit),
});
}
/** File count and total bytes under one parent, without listing the records. */
getParentFileStats(parentType: FileParentType, parentId: string): Promise<ParentFileStats> {
return this.getJson<ParentFileStats>(
`/api/v3/file/stats/${parentType}/${encodeURIComponent(parentId)}`,
);
}
/**
* A rasterised preview of one file.
*
* The reason this exists: many course PDFs are image-only scans, so text
* extraction legitimately yields nothing and their contents are otherwise
* unreadable. A preview is a picture of the page, which Claude can read
* directly. Only meaningful when the record's `previewStatus` is
* `preview_possible`; anything else 404s or returns the placeholder.
*/
async getFilePreview(
record: Pick<FileRecord, 'id' | 'name'>,
width?: PreviewWidth,
): Promise<DownloadedFile> {
// Two traps here, both of which answer with a 400 that names the value but
// not the permitted set:
// - `width` is an enum (50 | 150 | 500), not a free number;
// - `outputFormat` accepts only `image/webp`. Omitting it is worse than
// wrong: the preview is then rendered in the *source* format, so a PDF
// comes back as a PDF and the whole point — a picture of the page — is
// lost.
const query = new URLSearchParams({ outputFormat: PREVIEW_OUTPUT_FORMAT });
if (width) query.set('width', String(width));
const path =
`/api/v3/file/preview/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}` +
`?${query.toString()}`;
const file = await this.getBytes(path, record.name);
// The response labels itself `webp` rather than `image/webp`, which no
// image consumer would accept. We asked for the format, so we know it.
return file.mimeType.startsWith('image/') ? file : { ...file, mimeType: PREVIEW_OUTPUT_FORMAT };
}
// --- legacy /api/v1 ---------------------------------------------------
//
// Exactly three legacy routes survive in the deployment's ingress table
// (dof_app_deploy .../all/x_ingress.yml): courses, users and classes. They
// are production surface, not a leftover — the table even notes why each
// one is still needed. Everything else under /api/v1 is unrouted and 404s,
// so do not reach for it.
/** One course with the fields v3 drops: description, members, timetable. */
getLegacyCourse(courseId: string): Promise<LegacyCourse> {
return this.getJson<LegacyCourse>(`/api/v1/courses/${encodeURIComponent(courseId)}`);
}
/**
* One user's name.
*
* The only id-to-name mapping available: submission `submitters`, file
* `creatorId` and course `teacherIds` are all bare ids, and no v3 route
* resolves them for a non-admin.
*/
getLegacyUser(userId: string): Promise<LegacyUser> {
return this.getJson<LegacyUser>(`/api/v1/users/${encodeURIComponent(userId)}`);
}
// --- groups and classes ------------------------------------------------
/** Classes ("Klassen") this account belongs to, with teacher names. */
async listClasses(): Promise<ClassItem[]> {
const body = await this.getJson<Paginated<ClassItem>>('/api/v3/groups/class', { limit: MAX_PAGE_SIZE });
return body.data ?? [];
}
/** Groups this account belongs to — room membership groups, classes, courses. */
async listGroups(): Promise<GroupItem[]> {
const body = await this.getJson<Paginated<GroupItem>>('/api/v3/groups', { limit: MAX_PAGE_SIZE });
return body.data ?? [];
}
}
/**