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:
@@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user