Merge branch 'phase/68-class-teacher'

This commit is contained in:
Leonid Pershin
2026-08-21 09:54:44 +03:00
23 changed files with 993 additions and 23 deletions
+13 -13
View File
@@ -12,22 +12,22 @@
## Задачи ## Задачи
- [ ] У `SchoolClass` опциональный `classTeacherId` (или эквивалент); сейв в ростере - [x] У `SchoolClass` опциональный `classTeacherId` (или эквивалент); сейв в ростере
- [ ] Назначить / снять — HTTP через мейлбокс школы; только хозяин; гость `403` - [x] Назначить / снять — HTTP через мейлбокс школы; только хозяин; гость `403`
- [ ] Кандидат — нанятый с должностью `Teacher`; иначе отказ с текстом, не молчание - [x] Кандидат — нанятый с должностью `Teacher`; иначе отказ с текстом, не молчание
- [ ] Один учитель может быть классным у нескольких классов - [x] Один учитель может быть классным у нескольких классов
- [ ] UI в «Управлении»: список классов со слотом классного (выбор / снять) - [x] UI в «Управлении»: список классов со слотом классного (выбор / снять)
- [ ] Имя классного видно там, где уже показывают класс (карточка ученика / управление) - [x] Имя классного видно там, где уже показывают класс (карточка ученика / управление)
- [ ] Строки через `t(...)`, обе локали - [x] Строки через `t(...)`, обе локали
- [ ] Пустой слот — школа живёт как сейчас; собраний ещё нет - [x] Пустой слот — школа живёт как сейчас; собраний ещё нет
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Назначение учителя класса пишет id в ростер и переживает сейв / reload - [x] Назначение учителя класса пишет id в ростер и переживает сейв / reload
- [ ] Не-учитель и чужой id отклоняются - [x] Не-учитель и чужой id отклоняются
- [ ] Гость не назначает (API) - [x] Гость не назначает (API)
- [ ] Снятие очищает слот; класс без классного валиден - [x] Снятие очищает слот; класс без классного валиден
- [ ] Два класса могут указать одного и того же учителя - [x] Два класса могут указать одного и того же учителя
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -13,7 +13,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [68. Классный руководитель](68-class-teacher.md) | 🔄 | Назначение в Управлении, только учителя | | [68. Классный руководитель](68-class-teacher.md) | | Назначение в Управлении, только учителя |
**Этап B — проступки и кабинет.** Память, авто-вызов, экран. **Этап B — проступки и кабинет.** Память, авто-вызов, экран.
+39 -1
View File
@@ -333,7 +333,9 @@ tick with the clock. Unknown school is `404` `unknown-school`; unknown person is
Family links are other members of the same family: parents and siblings for a child, children Family links are other members of the same family: parents and siblings for a child, children
and partners for a parent. The client opens another card by id; this response does not nest and partners for a parent. The client opens another card by id; this response does not nest
cards. `classId` is the homeroom class for a pupil and `null` otherwise — the personal timetable cards. `classId` is the homeroom class for a pupil and `null` otherwise — the personal timetable
grid fetches `GET .../timetable?classId=` with it. grid fetches `GET .../timetable?classId=` with it. `classTeacherId` / `classTeacherName` come from
that class's slot when a class teacher is assigned; both are null when the slot is empty or the
person has no class.
`activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel` `activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel`
is that def in the request locale. HTTP JSON is additive — no protocol version bump. is that def in the request locale. HTTP JSON is additive — no protocol version bump.
@@ -376,6 +378,8 @@ compute thresholds. There is no school-wide opinions endpoint.
"classYear": 5, "classYear": 5,
"classLetter": "А", "classLetter": "А",
"classId": "class-classroom-105", "classId": "class-classroom-105",
"classTeacherId": "f3.p1",
"classTeacherName": "Иванова Ольга Михайловна",
"body": [{ "id": "Height", "label": "Рост", "value": "164" }], "body": [{ "id": "Height", "label": "Рост", "value": "164" }],
"skills": [{ "id": "Math", "label": "Математика", "value": "62" }], "skills": [{ "id": "Math", "label": "Математика", "value": "62" }],
"traits": [{ "defName": "Diligent", "label": "Усидчивый" }], "traits": [{ "defName": "Diligent", "label": "Усидчивый" }],
@@ -694,10 +698,23 @@ one.
"gradeMax": 11, "gradeMax": 11,
"hoursPerWeek": 5 "hoursPerWeek": 5
} }
],
"classes": [
{
"id": "c5A",
"year": 5,
"letter": "А",
"classTeacherId": "f3.p1",
"classTeacherName": "Иванова Ольга Михайловна"
}
] ]
} }
``` ```
`classes` lists every homeroom with an optional class-teacher slot. `classTeacherId` /
`classTeacherName` are null when the slot is empty. Assign and clear (below) return this same
payload so the Management panel can refresh the slots without a second GET.
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
id on the roster; hiring them sets `isStaff` on that person and does not create a second id on the roster; hiring them sets `isStaff` on that person and does not create a second
entity. Generated candidates (`aN.p0`) join the roster only when hired. `skills` on an entity. Generated candidates (`aN.p0`) join the roster only when hired. `skills` on an
@@ -747,6 +764,27 @@ and the same `?lang=ru|en`.
Removes one assignment. Payroll drops when the subject was not the only one. Same success Removes one assignment. Payroll drops when the subject was not the only one. Same success
payload and `?lang=ru|en` as the other two. Unknown assignment is `404` `unknown-assignment`. payload and `?lang=ru|en` as the other two. Unknown assignment is `404` `unknown-assignment`.
### `PUT /api/schools/{id}/classes/{classId}/class-teacher`
Body: `{ "personId": "f3.p1" }`. Sets the class-teacher slot on that class. Only a hired
person with position `Teacher` is accepted — not a new `PositionDef`. One teacher may cover
several classes. Goes through the school's mailbox; only the owner may call it (`403`
`not-owner`). Success returns the same payload as `GET .../staffing` (including `classes`),
so it takes the same `?lang=ru|en`.
| Status | `code` | When |
| --- | --- | --- |
| `403` | `not-owner` | The session is not the school's owner. |
| `404` | `unknown-school` | No school with that id. |
| `404` | `unknown-class` | `classId` is not in the roster. |
| `404` | `unknown-person` | `personId` is not on the roster. |
| `400` | `not-teacher` | Person is not hired staff with position `Teacher`. |
### `DELETE /api/schools/{id}/classes/{classId}/class-teacher`
Clears the class-teacher slot. An empty slot is valid — the class lives as before; parent
meetings (later) simply do not start. Same success payload and owner gate as the PUT.
### `GET /api/schools/{id}/timetable` ### `GET /api/schools/{id}/timetable`
The published lesson table and uncovered hours. Optional `?classId=` or `?personId=` filter The published lesson table and uncovered hours. Optional `?classId=` or `?personId=` filter
+18
View File
@@ -311,6 +311,15 @@ const ru = {
staffErrorNotTeacher: 'Предмет можно назначить только учителю.', staffErrorNotTeacher: 'Предмет можно назначить только учителю.',
staffErrorAssigned: 'Этот предмет уже назначен.', staffErrorAssigned: 'Этот предмет уже назначен.',
staffErrorSubject: 'Такого предмета нет.', staffErrorSubject: 'Такого предмета нет.',
classTeachersTitle: 'Классные руководители',
classTeacherLabel: 'Классный',
classTeacherNone: 'Не назначен',
classTeacherClear: 'Снять',
classTeacherEmpty: 'Нет классов.',
classTeacherErrorNotTeacher: 'Классным можно назначить только нанятого учителя.',
classTeacherErrorUnknownClass: 'Такого класса нет.',
classTeacherErrorUnknownPerson: 'Этого человека нет в школе.',
classTeacherWithName: 'классный: {name}',
manageTabStaff: 'Штат', manageTabStaff: 'Штат',
manageTabRules: 'Правила', manageTabRules: 'Правила',
@@ -705,6 +714,15 @@ const en: Messages = {
staffErrorNotTeacher: 'Only a teacher can be assigned a subject.', staffErrorNotTeacher: 'Only a teacher can be assigned a subject.',
staffErrorAssigned: 'That subject is already assigned.', staffErrorAssigned: 'That subject is already assigned.',
staffErrorSubject: 'That subject is not in the catalog.', staffErrorSubject: 'That subject is not in the catalog.',
classTeachersTitle: 'Class teachers',
classTeacherLabel: 'Class teacher',
classTeacherNone: 'None',
classTeacherClear: 'Clear',
classTeacherEmpty: 'No classes.',
classTeacherErrorNotTeacher: 'Only a hired teacher can be the class teacher.',
classTeacherErrorUnknownClass: 'That class is not in this school.',
classTeacherErrorUnknownPerson: 'That person is not on the roster.',
classTeacherWithName: 'class teacher: {name}',
manageTabStaff: 'Staff', manageTabStaff: 'Staff',
manageTabRules: 'Rules', manageTabRules: 'Rules',
+40
View File
@@ -327,6 +327,8 @@ export interface PersonCard {
readonly classYear: number | null; readonly classYear: number | null;
readonly classLetter: string | null; readonly classLetter: string | null;
readonly classId: string | null; readonly classId: string | null;
readonly classTeacherId: string | null;
readonly classTeacherName: string | null;
readonly position: string | null; readonly position: string | null;
readonly positionLabel: string | null; readonly positionLabel: string | null;
readonly body: readonly LabeledStat[]; readonly body: readonly LabeledStat[];
@@ -713,6 +715,14 @@ export interface StaffMember {
readonly subjects: readonly DefLabel[]; readonly subjects: readonly DefLabel[];
} }
export interface ClassTeacherSlot {
readonly id: string;
readonly year: number;
readonly letter: string;
readonly classTeacherId: string | null;
readonly classTeacherName: string | null;
}
export interface Staffing { export interface Staffing {
readonly allocated: number; readonly allocated: number;
readonly payroll: number; readonly payroll: number;
@@ -722,6 +732,7 @@ export interface Staffing {
readonly staff: readonly StaffMember[]; readonly staff: readonly StaffMember[];
readonly positions: readonly DefLabel[]; readonly positions: readonly DefLabel[];
readonly subjects: readonly StaffingSubject[]; readonly subjects: readonly StaffingSubject[];
readonly classes: readonly ClassTeacherSlot[];
} }
export async function fetchStaffing(schoolId: number, lang: string): Promise<Staffing> { export async function fetchStaffing(schoolId: number, lang: string): Promise<Staffing> {
@@ -773,6 +784,35 @@ export async function unassignSubject(
); );
} }
export async function assignClassTeacher(
schoolId: number,
classId: string,
personId: string,
lang: string,
): Promise<Staffing> {
const params = new URLSearchParams({ lang });
return request<Staffing>(
`/api/schools/${schoolId}/classes/${encodeURIComponent(classId)}/class-teacher?${params.toString()}`,
{
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ personId }),
},
);
}
export async function clearClassTeacher(
schoolId: number,
classId: string,
lang: string,
): Promise<Staffing> {
const params = new URLSearchParams({ lang });
return request<Staffing>(
`/api/schools/${schoolId}/classes/${encodeURIComponent(classId)}/class-teacher?${params.toString()}`,
{ method: 'DELETE' },
);
}
export interface DressRulePair { export interface DressRulePair {
readonly form: string; readonly form: string;
readonly color: string; readonly color: string;
@@ -4,7 +4,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
ApiError, ApiError,
assignClassTeacher,
assignSubject, assignSubject,
clearClassTeacher,
fetchPerson, fetchPerson,
fetchGameStatus, fetchGameStatus,
fetchDressRules, fetchDressRules,
@@ -45,6 +47,8 @@ vi.mock('../net/api.ts', async (importOriginal) => {
hireStaff: vi.fn(), hireStaff: vi.fn(),
assignSubject: vi.fn(), assignSubject: vi.fn(),
unassignSubject: vi.fn(), unassignSubject: vi.fn(),
assignClassTeacher: vi.fn(),
clearClassTeacher: vi.fn(),
}; };
}); });
@@ -64,6 +68,8 @@ function personCard(): PersonCard {
classYear: null, classYear: null,
classLetter: null, classLetter: null,
classId: null, classId: null,
classTeacherId: null,
classTeacherName: null,
position: null, position: null,
positionLabel: null, positionLabel: null,
body: [], body: [],
@@ -111,6 +117,7 @@ function staffing(): Staffing {
staff: [], staff: [],
positions: [{ defName: 'Teacher', label: 'Teacher' }], positions: [{ defName: 'Teacher', label: 'Teacher' }],
subjects: [], subjects: [],
classes: [],
}; };
} }
@@ -392,6 +399,7 @@ function hiredTeacher(): Staffing {
teachersShort: 1, teachersShort: 1,
}, },
], ],
classes: [{ id: 'c1', year: 5, letter: 'A', classTeacherId: null, classTeacherName: null }],
}; };
} }
@@ -404,6 +412,8 @@ describe('ManagementPanel money and subjects', () => {
vi.mocked(fetchPerson).mockReset(); vi.mocked(fetchPerson).mockReset();
vi.mocked(assignSubject).mockReset(); vi.mocked(assignSubject).mockReset();
vi.mocked(unassignSubject).mockReset(); vi.mocked(unassignSubject).mockReset();
vi.mocked(assignClassTeacher).mockReset();
vi.mocked(clearClassTeacher).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue(hiredTeacher()); vi.mocked(fetchStaffing).mockResolvedValue(hiredTeacher());
vi.mocked(fetchTimetable).mockResolvedValue(timetable()); vi.mocked(fetchTimetable).mockResolvedValue(timetable());
vi.mocked(fetchPerson).mockResolvedValue({ vi.mocked(fetchPerson).mockResolvedValue({
@@ -505,4 +515,43 @@ describe('ManagementPanel money and subjects', () => {
); );
await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(6_000))); await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(6_000)));
}); });
it('assigns and clears a class teacher from the class list', async () => {
const withTeacher: Staffing = {
...hiredTeacher(),
classes: [
{
id: 'c1',
year: 5,
letter: 'A',
classTeacherId: 't1',
classTeacherName: 'Petrov Ivan',
},
],
};
vi.mocked(assignClassTeacher).mockResolvedValue(withTeacher);
vi.mocked(clearClassTeacher).mockResolvedValue(hiredTeacher());
const panel = new ManagementPanel();
document.body.append(panel.listElement, panel.cardElement);
panel.show(2);
const select = await vi.waitFor(() => {
const node = panel.listElement.querySelector('.staffing__class-teachers select');
if (!(node instanceof HTMLSelectElement)) {
throw new Error('class teacher select is missing');
}
return node;
});
expect(panel.listElement.textContent).toContain(t('classTeachersTitle'));
select.value = 't1';
select.dispatchEvent(new Event('change'));
await vi.waitFor(() => expect(assignClassTeacher).toHaveBeenCalledWith(2, 'c1', 't1', 'en'));
await vi.waitFor(() => expect(select.value).toBe('t1'));
select.value = '';
select.dispatchEvent(new Event('change'));
await vi.waitFor(() => expect(clearClassTeacher).toHaveBeenCalledWith(2, 'c1', 'en'));
});
}); });
+90 -1
View File
@@ -1,5 +1,7 @@
import { import {
assignClassTeacher,
assignSubject, assignSubject,
clearClassTeacher,
fetchPerson, fetchPerson,
fetchStaffing, fetchStaffing,
fetchTimetable, fetchTimetable,
@@ -16,11 +18,12 @@ import { DressRulesPanel } from './dressRulesPanel.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
import { formatPersonPlace } from './personCard.ts'; import { formatPersonPlace } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts'; import { PersonCardHost } from './personCardHost.ts';
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts'; import { actionError, classTeacherError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
import { loadChrome, patchChrome } from './viewState.ts'; import { loadChrome, patchChrome } from './viewState.ts';
const TEACHER = 'Teacher'; const TEACHER = 'Teacher';
const CLEAR_VALUE = '';
/** /**
* Left: money, coverage, a door into the applicant window, staff. Right: the person card plus * Left: money, coverage, a door into the applicant window, staff. Right: the person card plus
@@ -45,6 +48,9 @@ export class ManagementPanel {
private readonly staffTitle = el('h3', { class: 'panel__section-title' }); private readonly staffTitle = el('h3', { class: 'panel__section-title' });
private readonly staffTable = el('table', { class: 'people__table' }); private readonly staffTable = el('table', { class: 'people__table' });
private readonly staffEmpty = el('p', { class: 'panel__empty' }); private readonly staffEmpty = el('p', { class: 'panel__empty' });
private readonly classTeachersTitle = el('h3', { class: 'panel__section-title' });
private readonly classTeachersList = el('div', { class: 'staffing__class-teachers' });
private readonly classTeachersEmpty = el('p', { class: 'panel__empty' });
private readonly timetableTitle = el('h3', { class: 'panel__section-title' }); private readonly timetableTitle = el('h3', { class: 'panel__section-title' });
private readonly classLabel = el('span', { class: 'people__label' }); private readonly classLabel = el('span', { class: 'people__label' });
private readonly classSelect = el('select', { class: 'input people__input' }); private readonly classSelect = el('select', { class: 'input people__input' });
@@ -95,6 +101,13 @@ export class ManagementPanel {
this.staffTitle, this.staffTitle,
el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty), el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty),
), ),
el(
'div',
{ class: 'panel__section' },
this.classTeachersTitle,
this.classTeachersEmpty,
this.classTeachersList,
),
el( el(
'div', 'div',
{ class: 'panel__section' }, { class: 'panel__section' },
@@ -126,6 +139,8 @@ export class ManagementPanel {
this.applicantsTitle.textContent = t('staffApplicants'); this.applicantsTitle.textContent = t('staffApplicants');
this.applicantsButton.textContent = t('staffApplicantsOpen'); this.applicantsButton.textContent = t('staffApplicantsOpen');
this.staffTitle.textContent = t('staffHired'); this.staffTitle.textContent = t('staffHired');
this.classTeachersTitle.textContent = t('classTeachersTitle');
this.classTeachersEmpty.textContent = t('classTeacherEmpty');
this.timetableTitle.textContent = t('timetableTitle'); this.timetableTitle.textContent = t('timetableTitle');
this.classLabel.textContent = t('timetableClass'); this.classLabel.textContent = t('timetableClass');
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty'); this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
@@ -283,6 +298,7 @@ export class ManagementPanel {
this.paintUncovered(staffing); this.paintUncovered(staffing);
this.paintApplicants(staffing); this.paintApplicants(staffing);
this.paintStaff(staffing); this.paintStaff(staffing);
this.paintClassTeachers(staffing);
this.paintClasses(this.timetable); this.paintClasses(this.timetable);
} }
@@ -390,6 +406,79 @@ export class ManagementPanel {
} }
} }
private paintClassTeachers(staffing: Staffing | null): void {
const classes = staffing?.classes ?? [];
this.classTeachersEmpty.hidden = classes.length > 0;
this.classTeachersList.hidden = classes.length === 0;
clear(this.classTeachersList);
if (classes.length === 0) {
return;
}
const teachers = (staffing?.staff ?? []).filter((member) => member.position === TEACHER);
for (const schoolClass of classes) {
const select = el('select', {
class: 'input people__input',
disabled: this.busy,
}) as HTMLSelectElement;
const none = el('option', { text: t('classTeacherNone') });
none.value = CLEAR_VALUE;
select.append(none);
for (const teacher of teachers) {
const option = el('option', { text: teacher.fullName });
option.value = teacher.id;
select.append(option);
}
const current = schoolClass.classTeacherId ?? CLEAR_VALUE;
if (current !== CLEAR_VALUE && ![...select.options].some((option) => option.value === current)) {
const missing = el('option', {
text: schoolClass.classTeacherName ?? schoolClass.classTeacherId ?? current,
});
missing.value = current;
select.append(missing);
}
select.value = [...select.options].some((option) => option.value === current) ? current : CLEAR_VALUE;
select.addEventListener('change', () => {
void this.setClassTeacher(schoolClass.id, select.value === CLEAR_VALUE ? null : select.value);
});
this.classTeachersList.append(
el(
'label',
{ class: 'people__field staffing__class-teacher' },
el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }),
select,
),
);
}
}
private async setClassTeacher(classId: string, personId: string | null): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null || this.busy) {
return;
}
this.busy = true;
this.clearError();
this.paint();
try {
this.staffing =
personId === null
? await clearClassTeacher(schoolId, classId, getLocale())
: await assignClassTeacher(schoolId, classId, personId, getLocale());
this.paint();
} catch (error) {
this.showError(classTeacherError(error));
this.paint();
} finally {
this.busy = false;
this.paint();
}
}
private async select(personId: string): Promise<void> { private async select(personId: string): Promise<void> {
this.selectedId = personId; this.selectedId = personId;
this.persist(); this.persist();
@@ -25,6 +25,8 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
classYear: 5, classYear: 5,
classLetter: 'А', classLetter: 'А',
classId: 'class-1', classId: 'class-1',
classTeacherId: null,
classTeacherName: null,
position: null, position: null,
positionLabel: null, positionLabel: null,
body: [{ id: 'Height', label: 'Рост', value: '164' }], body: [{ id: 'Height', label: 'Рост', value: '164' }],
@@ -120,6 +122,19 @@ function tabButton(root: HTMLElement, tab: PersonCardTab): HTMLButtonElement {
} }
describe('renderPersonCard', () => { describe('renderPersonCard', () => {
it('shows the class teacher name next to the class', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(
root,
card({ classTeacherId: 't1', classTeacherName: 'Петров Иван' }),
() => {},
options(),
);
expect(root.textContent).toContain(t('classTeacherWithName', { name: 'Петров Иван' }));
});
it('switches overview, clothes, carried and now', () => { it('switches overview, clothes, carried and now', () => {
setLocale('ru'); setLocale('ru');
const root = document.createElement('div'); const root = document.createElement('div');
+11 -2
View File
@@ -56,10 +56,19 @@ export function roleLabels(roles: readonly string[]): string {
.join(', '); .join(', ');
} }
export function placement(person: Pick<PersonListItem, 'classYear' | 'classLetter' | 'positionLabel'>): string { export function placement(
person: Pick<PersonListItem, 'classYear' | 'classLetter' | 'positionLabel'> & {
readonly classTeacherName?: string | null;
},
): string {
const parts: string[] = []; const parts: string[] = [];
if (person.classYear !== null && person.classLetter !== null) { if (person.classYear !== null && person.classLetter !== null) {
parts.push(`${person.classYear}${person.classLetter}`); let classBit = `${person.classYear}${person.classLetter}`;
if (person.classTeacherName !== null && person.classTeacherName !== undefined && person.classTeacherName.length > 0) {
classBit += ` · ${t('classTeacherWithName', { name: person.classTeacherName })}`;
}
parts.push(classBit);
} }
if (person.positionLabel !== null && person.positionLabel.length > 0) { if (person.positionLabel !== null && person.positionLabel.length > 0) {
+17
View File
@@ -71,6 +71,23 @@ export function actionError(error: unknown): string {
} }
} }
export function classTeacherError(error: unknown): string {
if (!(error instanceof ApiError)) {
return t('staffActionFailed');
}
switch (error.code) {
case 'not-teacher':
return t('classTeacherErrorNotTeacher');
case 'unknown-class':
return t('classTeacherErrorUnknownClass');
case 'unknown-person':
return t('classTeacherErrorUnknownPerson');
default:
return error.message.length > 0 ? error.message : t('staffActionFailed');
}
}
export function parseOptionalInt(value: string): number | undefined { export function parseOptionalInt(value: string): number | undefined {
if (value === '') { if (value === '') {
return undefined; return undefined;
@@ -144,6 +144,8 @@ function card(overrides: Partial<PersonCard>): PersonCard {
classYear: 5, classYear: 5,
classLetter: 'A', classLetter: 'A',
classId: 'c1', classId: 'c1',
classTeacherId: null,
classTeacherName: null,
position: null, position: null,
positionLabel: null, positionLabel: null,
body: [], body: [],
+82
View File
@@ -0,0 +1,82 @@
namespace HSchool.People;
public enum ClassTeacherError
{
None,
UnknownSchool,
UnknownClass,
UnknownPerson,
NotTeacher,
}
public sealed record ClassTeacherOutcome(ClassTeacherError Error, Roster Roster);
/// <summary>
/// Assigns or clears the class-teacher slot on a <see cref="SchoolClass"/>. Not a
/// <see cref="PositionDef"/> — any hired teacher may cover several classes.
/// </summary>
public static class ClassTeachers
{
public static ClassTeacherOutcome UnknownSchool() =>
new(ClassTeacherError.UnknownSchool, new Roster([], [], []));
public static ClassTeacherOutcome Assign(Roster roster, string classId, string personId)
{
ArgumentNullException.ThrowIfNull(roster);
ArgumentNullException.ThrowIfNull(classId);
ArgumentNullException.ThrowIfNull(personId);
var index = IndexOf(roster.Classes, classId);
if (index < 0)
{
return new ClassTeacherOutcome(ClassTeacherError.UnknownClass, roster);
}
var person = roster.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
if (person is null)
{
return new ClassTeacherOutcome(ClassTeacherError.UnknownPerson, roster);
}
if (!person.IsStaff || !string.Equals(person.Position, Staffing.TeacherPosition, StringComparison.Ordinal))
{
return new ClassTeacherOutcome(ClassTeacherError.NotTeacher, roster);
}
return Replace(roster, index, personId);
}
public static ClassTeacherOutcome Clear(Roster roster, string classId)
{
ArgumentNullException.ThrowIfNull(roster);
ArgumentNullException.ThrowIfNull(classId);
var index = IndexOf(roster.Classes, classId);
if (index < 0)
{
return new ClassTeacherOutcome(ClassTeacherError.UnknownClass, roster);
}
return Replace(roster, index, null);
}
private static ClassTeacherOutcome Replace(Roster roster, int index, string? teacherId)
{
var classes = roster.Classes.ToArray();
classes[index] = classes[index] with { ClassTeacherId = teacherId };
return new ClassTeacherOutcome(ClassTeacherError.None, roster with { Classes = classes });
}
private static int IndexOf(IReadOnlyList<SchoolClass> classes, string classId)
{
for (var i = 0; i < classes.Count; i++)
{
if (classes[i].Id.Equals(classId, StringComparison.Ordinal))
{
return i;
}
}
return -1;
}
}
+3 -1
View File
@@ -142,4 +142,6 @@ public sealed record SchoolClass(
string Letter, string Letter,
string RoomId, string RoomId,
int Capacity, int Capacity,
IReadOnlyList<string> PupilIds); IReadOnlyList<string> PupilIds,
/// <summary>Hired teacher who is the class teacher, or null when the slot is empty.</summary>
string? ClassTeacherId = null);
+46 -3
View File
@@ -85,7 +85,9 @@ internal sealed record PersonCardResponse(
PersonConnectionsResponse? Connections = null, PersonConnectionsResponse? Connections = null,
DefLabelResponse? Orientation = null, DefLabelResponse? Orientation = null,
IReadOnlyList<string>? TalkCircleMemberIds = null, IReadOnlyList<string>? TalkCircleMemberIds = null,
string? TalkTopicId = null); string? TalkTopicId = null,
string? ClassTeacherId = null,
string? ClassTeacherName = null);
internal sealed record WornItemResponse( internal sealed record WornItemResponse(
@@ -246,6 +248,8 @@ internal sealed record HireStaffRequest(string? PersonId, string? Position);
internal sealed record AssignSubjectRequest(string? Subject); internal sealed record AssignSubjectRequest(string? Subject);
internal sealed record AssignClassTeacherRequest(string? PersonId);
internal sealed record StaffingResponse( internal sealed record StaffingResponse(
float Allocated, float Allocated,
float Payroll, float Payroll,
@@ -254,7 +258,15 @@ internal sealed record StaffingResponse(
IReadOnlyList<ApplicantResponse> Applicants, IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff, IReadOnlyList<StaffMemberResponse> Staff,
IReadOnlyList<DefLabelResponse> Positions, IReadOnlyList<DefLabelResponse> Positions,
IReadOnlyList<UncoveredSubjectResponse> Subjects); IReadOnlyList<UncoveredSubjectResponse> Subjects,
IReadOnlyList<ClassTeacherSlotResponse> Classes);
internal sealed record ClassTeacherSlotResponse(
string Id,
int Year,
string Letter,
string? ClassTeacherId,
string? ClassTeacherName);
internal sealed record UncoveredSubjectResponse( internal sealed record UncoveredSubjectResponse(
string DefName, string DefName,
@@ -357,7 +369,38 @@ internal static class StaffingMapper
0)) 0))
.ToArray(); .ToArray();
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects); var peopleById = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var classes = roster.Classes
.OrderBy(schoolClass => schoolClass.Year)
.ThenBy(schoolClass => schoolClass.Letter, StringComparer.Ordinal)
.Select(schoolClass =>
{
string? teacherName = null;
if (schoolClass.ClassTeacherId is { } teacherId
&& peopleById.TryGetValue(teacherId, out var teacher))
{
teacherName = teacher.Name.Full;
}
return new ClassTeacherSlotResponse(
schoolClass.Id,
schoolClass.Year,
schoolClass.Letter,
schoolClass.ClassTeacherId,
teacherName);
})
.ToArray();
return new StaffingResponse(
allocated,
payroll,
remaining,
uncovered,
applicants,
staff,
positions,
subjects,
classes);
} }
private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale) private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale)
+107
View File
@@ -682,6 +682,78 @@ internal static class SchoolEndpoints
}) })
.WithName("UnassignSchoolSubject"); .WithName("UnassignSchoolSubject");
schools.MapPut("/{id:int}/classes/{classId}/class-teacher", async (
int id,
string classId,
AssignClassTeacherRequest request,
string? lang,
HttpContext context,
SessionService sessions,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
{
return Results.Unauthorized();
}
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
if (denied is not null)
{
return denied;
}
if (!TryDefName(classId, "classId", out var classIdValue, out var error)
|| !TryPersonId(request.PersonId, out var personId, out error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.AssignClassTeacher(
id,
classIdValue,
personId,
NewCompletion<ClassTeacherOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return ClassTeacherResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("AssignSchoolClassTeacher");
schools.MapDelete("/{id:int}/classes/{classId}/class-teacher", async (
int id,
string classId,
string? lang,
HttpContext context,
SessionService sessions,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
{
return Results.Unauthorized();
}
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
if (denied is not null)
{
return denied;
}
if (!TryDefName(classId, "classId", out var classIdValue, out var error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.ClearClassTeacher(id, classIdValue, NewCompletion<ClassTeacherOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return ClassTeacherResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("ClearSchoolClassTeacher");
schools.MapPost("/{id:int}/notices/{noticeId:long}/dismiss", async ( schools.MapPost("/{id:int}/notices/{noticeId:long}/dismiss", async (
int id, int id,
long noticeId, long noticeId,
@@ -976,6 +1048,41 @@ internal static class SchoolEndpoints
return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale)); return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale));
} }
private static IResult ClassTeacherResult(
int schoolId,
ClassTeacherOutcome outcome,
GameLoopService loop,
string locale)
{
if (outcome.Error != ClassTeacherError.None)
{
return ClassTeacherProblem(outcome);
}
var published = loop.FindPeople(schoolId);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale));
}
private static IResult ClassTeacherProblem(ClassTeacherOutcome outcome) =>
outcome.Error switch
{
ClassTeacherError.UnknownClass =>
Problem(StatusCodes.Status404NotFound, "unknown-class", "That class is not in this school."),
ClassTeacherError.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not on the roster."),
ClassTeacherError.NotTeacher =>
Problem(
StatusCodes.Status400BadRequest,
"not-teacher",
"Only a hired teacher can be the class teacher."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
private static IResult StaffingProblem(StaffingOutcome outcome) => private static IResult StaffingProblem(StaffingOutcome outcome) =>
outcome.Error switch outcome.Error switch
{ {
+11
View File
@@ -110,6 +110,17 @@ internal abstract record GameCommand
string Subject, string Subject,
TaskCompletionSource<StaffingOutcome> Result) : GameCommand; TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
internal sealed record AssignClassTeacher(
int SchoolId,
string ClassId,
string PersonId,
TaskCompletionSource<ClassTeacherOutcome> Result) : GameCommand;
internal sealed record ClearClassTeacher(
int SchoolId,
string ClassId,
TaskCompletionSource<ClassTeacherOutcome> Result) : GameCommand;
internal sealed record PinLesson( internal sealed record PinLesson(
int SchoolId, int SchoolId,
string ClassId, string ClassId,
@@ -286,6 +286,23 @@ internal sealed class GameLoopService(
unassign.Result); unassign.Result);
break; break;
case GameCommand.AssignClassTeacher assignClassTeacher:
HandleClassTeacher(
assignClassTeacher.SchoolId,
new WorkerCommand.AssignClassTeacher(
assignClassTeacher.ClassId,
assignClassTeacher.PersonId,
assignClassTeacher.Result),
assignClassTeacher.Result);
break;
case GameCommand.ClearClassTeacher clearClassTeacher:
HandleClassTeacher(
clearClassTeacher.SchoolId,
new WorkerCommand.ClearClassTeacher(clearClassTeacher.ClassId, clearClassTeacher.Result),
clearClassTeacher.Result);
break;
case GameCommand.PinLesson pin: case GameCommand.PinLesson pin:
HandleTimetable( HandleTimetable(
pin.SchoolId, pin.SchoolId,
@@ -385,6 +402,14 @@ internal sealed class GameLoopService(
} }
} }
private void HandleClassTeacher(int schoolId, WorkerCommand command, TaskCompletionSource<ClassTeacherOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(ClassTeachers.UnknownSchool());
}
}
private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource<TimetableOutcome> result) private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource<TimetableOutcome> result)
{ {
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
+11 -1
View File
@@ -43,12 +43,20 @@ internal static partial class PersonCardReader
var catalog = school.Catalog; var catalog = school.Catalog;
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
int? year = null; int? year = null;
string? letter = null; string? letter = null;
string? classTeacherId = null;
string? classTeacherName = null;
if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)) if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass))
{ {
year = schoolClass.Year; year = schoolClass.Year;
letter = schoolClass.Letter; letter = schoolClass.Letter;
classTeacherId = schoolClass.ClassTeacherId;
if (classTeacherId is { } teacherId && people.TryGetValue(teacherId, out var teacher))
{
classTeacherName = teacher.Name.Full;
}
} }
var needs = LiveNeeds(school.World, personId) ?? person.Needs; var needs = LiveNeeds(school.World, personId) ?? person.Needs;
@@ -98,7 +106,9 @@ internal static partial class PersonCardReader
Connections: Connections(roster, person, catalog, locale), Connections: Connections(roster, person, catalog, locale),
Orientation: OrientationOf(person, catalog, locale), Orientation: OrientationOf(person, catalog, locale),
TalkCircleMemberIds: circle?.MemberIds ?? [], TalkCircleMemberIds: circle?.MemberIds ?? [],
TalkTopicId: circle?.TopicId); TalkTopicId: circle?.TopicId,
ClassTeacherId: classTeacherId,
ClassTeacherName: classTeacherName);
} }
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId) private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
@@ -141,6 +141,15 @@ internal sealed partial class SchoolWorker
unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject)); unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
break; break;
case WorkerCommand.AssignClassTeacher assignClassTeacher:
assignClassTeacher.Result.TrySetResult(
ApplyAssignClassTeacher(school, assignClassTeacher.ClassId, assignClassTeacher.PersonId));
break;
case WorkerCommand.ClearClassTeacher clearClassTeacher:
clearClassTeacher.Result.TrySetResult(ApplyClearClassTeacher(school, clearClassTeacher.ClassId));
break;
case WorkerCommand.PinLesson pin: case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult( pin.Result.TrySetResult(
ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period)); ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period));
@@ -256,6 +265,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.UnassignSubject unassign: case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(Staffing.UnknownSchool()); unassign.Result.TrySetResult(Staffing.UnknownSchool());
break; break;
case WorkerCommand.AssignClassTeacher assignClassTeacher:
assignClassTeacher.Result.TrySetResult(ClassTeachers.UnknownSchool());
break;
case WorkerCommand.ClearClassTeacher clearClassTeacher:
clearClassTeacher.Result.TrySetResult(ClassTeachers.UnknownSchool());
break;
case WorkerCommand.PinLesson pin: case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break; break;
@@ -311,6 +326,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.UnassignSubject unassign: case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetException(exception); unassign.Result.TrySetException(exception);
break; break;
case WorkerCommand.AssignClassTeacher assignClassTeacher:
assignClassTeacher.Result.TrySetException(exception);
break;
case WorkerCommand.ClearClassTeacher clearClassTeacher:
clearClassTeacher.Result.TrySetException(exception);
break;
case WorkerCommand.PinLesson pin: case WorkerCommand.PinLesson pin:
pin.Result.TrySetException(exception); pin.Result.TrySetException(exception);
break; break;
@@ -344,6 +365,32 @@ internal sealed partial class SchoolWorker
ApplyStaffingChange(school, (catalog, roster, pool) => ApplyStaffingChange(school, (catalog, roster, pool) =>
Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
private ClassTeacherOutcome ApplyAssignClassTeacher(School school, string classId, string personId) =>
ApplyClassTeacherChange(school, roster => ClassTeachers.Assign(roster, classId, personId));
private ClassTeacherOutcome ApplyClearClassTeacher(School school, string classId) =>
ApplyClassTeacherChange(school, roster => ClassTeachers.Clear(roster, classId));
private ClassTeacherOutcome ApplyClassTeacherChange(
School school,
Func<Roster, ClassTeacherOutcome> apply)
{
if (school.Roster is null)
{
return ClassTeachers.UnknownSchool();
}
var outcome = apply(school.Roster);
if (outcome.Error == ClassTeacherError.None)
{
school.ApplyRosterData(outcome.Roster);
PersistPeople();
PublishSnapshot();
}
return outcome;
}
private StaffingOutcome ApplyStaffingChange( private StaffingOutcome ApplyStaffingChange(
School school, School school,
Func<DefCatalog, Roster, ApplicantPool, StaffingOutcome> apply) Func<DefCatalog, Roster, ApplicantPool, StaffingOutcome> apply)
+9
View File
@@ -70,6 +70,15 @@ internal abstract record WorkerCommand
string Subject, string Subject,
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand; TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
internal sealed record AssignClassTeacher(
string ClassId,
string PersonId,
TaskCompletionSource<ClassTeacherOutcome> Result) : WorkerCommand;
internal sealed record ClearClassTeacher(
string ClassId,
TaskCompletionSource<ClassTeacherOutcome> Result) : WorkerCommand;
internal sealed record PinLesson( internal sealed record PinLesson(
string ClassId, string ClassId,
string Subject, string Subject,
+11
View File
@@ -320,6 +320,17 @@ public sealed class School : IDisposable
TimetableDirty = true; TimetableDirty = true;
} }
/// <summary>
/// Writes roster fields that do not change who is in the World (class-teacher slots). No
/// respawn and no timetable dirty — unlike <see cref="ApplyStaffing"/>.
/// </summary>
public void ApplyRosterData(Roster roster)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(roster);
Roster = roster;
}
public void SetTimetable(Timetable timetable) public void SetTimetable(Timetable timetable)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
@@ -0,0 +1,251 @@
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class ClassTeacherApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Assign_WritesId_AndSurvivesReload()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Классный сейв", Start);
var staffing = await GetStaffingAsync(client, school.Id);
var teacher = staffing.Applicants[0];
await HireAsync(client, school.Id, teacher.Id, "Teacher");
var schoolClass = Assert.Single((await GetStaffingAsync(client, school.Id)).Classes.Take(1));
var assigned = await AssignClassTeacherAsync(client, school.Id, schoolClass.Id, teacher.Id);
var slot = Assert.Single(assigned.Classes, row => row.Id == schoolClass.Id);
Assert.Equal(teacher.Id, slot.ClassTeacherId);
Assert.False(string.IsNullOrWhiteSpace(slot.ClassTeacherName));
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var after = await GetStaffingAsync(client, school.Id);
var reloaded = Assert.Single(after.Classes, row => row.Id == schoolClass.Id);
Assert.Equal(teacher.Id, reloaded.ClassTeacherId);
var pupil = await FirstPupilOfClassAsync(client, school.Id, schoolClass.Id);
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(pupil)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(card);
Assert.Equal(teacher.Id, card.ClassTeacherId);
Assert.Equal(reloaded.ClassTeacherName, card.ClassTeacherName);
}
[Fact]
public async Task Assign_RejectsNonTeacherAndUnknownPerson()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Классный отказ", Start);
var staffing = await GetStaffingAsync(client, school.Id);
var schoolClass = staffing.Classes[0];
var pupil = await FirstPupilOfClassAsync(client, school.Id, schoolClass.Id);
using var notTeacher = await client.PutAsJsonAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher",
new { personId = pupil },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, notTeacher.StatusCode);
Assert.Equal("not-teacher", await ProblemCodeAsync(notTeacher));
using var unknown = await client.PutAsJsonAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher",
new { personId = "nobody" },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
Assert.Equal("unknown-person", await ProblemCodeAsync(unknown));
}
[Fact]
public async Task Guest_CannotAssign()
{
using var wipe = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(wipe);
var owner = await CreateUserAsync("ClassTeacherOwner");
var guest = await CreateUserAsync("ClassTeacherGuest");
await ResetUserAsync(owner);
var school = await SchoolApiTests.CreateAsync(owner, "Чужой классный", Start);
var staffing = await GetStaffingAsync(owner, school.Id);
var schoolClass = staffing.Classes[0];
using var response = await guest.PutAsJsonAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher",
new { personId = "a0.p0" },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal("not-owner", await ProblemCodeAsync(response));
owner.Dispose();
guest.Dispose();
using var cleanup = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(cleanup);
}
[Fact]
public async Task Clear_EmptiesSlot_AndClassWithoutTeacherIsValid()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Классный снять", Start);
var staffing = await GetStaffingAsync(client, school.Id);
var teacher = staffing.Applicants[0];
await HireAsync(client, school.Id, teacher.Id, "Teacher");
var schoolClass = (await GetStaffingAsync(client, school.Id)).Classes[0];
await AssignClassTeacherAsync(client, school.Id, schoolClass.Id, teacher.Id);
using var clear = await client.DeleteAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher?lang=ru",
TestContext.Current.CancellationToken);
clear.EnsureSuccessStatusCode();
var after = await clear.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(after);
var slot = Assert.Single(after.Classes, row => row.Id == schoolClass.Id);
Assert.Null(slot.ClassTeacherId);
Assert.Null(slot.ClassTeacherName);
Assert.NotEmpty(after.Classes);
}
[Fact]
public async Task TwoClasses_MayShareTheSameTeacher()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Классный двое", Start);
var staffing = await GetStaffingAsync(client, school.Id);
Assert.True(staffing.Classes.Count >= 2);
var teacher = staffing.Applicants[0];
await HireAsync(client, school.Id, teacher.Id, "Teacher");
var first = staffing.Classes[0];
var second = staffing.Classes[1];
await AssignClassTeacherAsync(client, school.Id, first.Id, teacher.Id);
var after = await AssignClassTeacherAsync(client, school.Id, second.Id, teacher.Id);
Assert.Equal(teacher.Id, Assert.Single(after.Classes, row => row.Id == first.Id).ClassTeacherId);
Assert.Equal(teacher.Id, Assert.Single(after.Classes, row => row.Id == second.Id).ClassTeacherId);
}
private static async Task<StaffingResponse> GetStaffingAsync(HttpClient client, int schoolId)
{
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
$"/api/schools/{schoolId}/staffing?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task HireAsync(HttpClient client, int schoolId, string personId, string position)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/staff/hire",
new { personId, position },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
private static async Task<StaffingResponse> AssignClassTeacherAsync(
HttpClient client,
int schoolId,
string classId,
string personId)
{
using var response = await client.PutAsJsonAsync(
$"/api/schools/{schoolId}/classes/{Uri.EscapeDataString(classId)}/class-teacher?lang=ru",
new { personId },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<string> FirstPupilOfClassAsync(HttpClient client, int schoolId, string classId)
{
var people = await client.GetFromJsonAsync<PeopleListResponse>(
$"/api/schools/{schoolId}/people?role=student&pageSize=50",
TestContext.Current.CancellationToken);
Assert.NotNull(people);
foreach (var person in people.People)
{
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{schoolId}/people/{Uri.EscapeDataString(person.Id)}",
TestContext.Current.CancellationToken);
if (card?.ClassId == classId)
{
return person.Id;
}
}
Assert.Fail($"No pupil found for class {classId}.");
return string.Empty;
}
private async Task<HttpClient> CreateUserAsync(string userName) =>
await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, userName);
private static async Task ResetUserAsync(HttpClient client)
{
await SchoolApiTests.LoginAsync(client);
var state = await SchoolApiTests.GetSchoolsAsync(client);
foreach (var school in state.Schools)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
foreach (var other in state.Others)
{
using var response = await client.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Forbidden)
{
continue;
}
response.EnsureSuccessStatusCode();
}
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
}
private sealed record ProblemResponse(string? Code);
private sealed record StaffingResponse(
float Allocated,
float Payroll,
float Remaining,
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff,
IReadOnlyList<ClassTeacherSlotResponse> Classes);
private sealed record ApplicantResponse(string Id, string FullName);
private sealed record StaffMemberResponse(string Id, string FullName, string Position);
private sealed record ClassTeacherSlotResponse(
string Id,
int Year,
string Letter,
string? ClassTeacherId,
string? ClassTeacherName);
private sealed record PeopleListResponse(IReadOnlyList<PersonListItemResponse> People);
private sealed record PersonListItemResponse(string Id);
private sealed record PersonCardResponse(string Id, string? ClassId, string? ClassTeacherId, string? ClassTeacherName);
}
@@ -0,0 +1,95 @@
using HSchool.Content;
namespace HSchool.People.Tests;
public class ClassTeacherTests
{
private const float Cap = 100_000f;
[Fact]
public void Assign_WritesIdIntoRoster_AndSurvivesPeopleJsonRoundTrip()
{
var (catalog, map, roster, pool) = Fresh();
var hired = HireTeacher(catalog, map, roster, pool);
var schoolClass = hired.Roster.Classes[0];
var assigned = ClassTeachers.Assign(hired.Roster, schoolClass.Id, hired.TeacherId);
Assert.Equal(ClassTeacherError.None, assigned.Error);
Assert.Equal(hired.TeacherId, assigned.Roster.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId);
var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, hired.Pool));
var loaded = RosterJson.Parse(json);
Assert.Equal(hired.TeacherId, loaded.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId);
}
[Fact]
public void Assign_RejectsNonTeacherAndUnknownPerson()
{
var (catalog, map, roster, pool) = Fresh();
var hired = HireTeacher(catalog, map, roster, pool);
var schoolClass = hired.Roster.Classes[0];
var pupil = Assert.Single(hired.Roster.People.Where(person => person.IsStudent).Take(1));
Assert.Equal(
ClassTeacherError.NotTeacher,
ClassTeachers.Assign(hired.Roster, schoolClass.Id, pupil.Id).Error);
Assert.Equal(
ClassTeacherError.UnknownPerson,
ClassTeachers.Assign(hired.Roster, schoolClass.Id, "nobody").Error);
Assert.Null(hired.Roster.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId);
}
[Fact]
public void Clear_EmptiesSlot_AndClassWithoutTeacherIsValid()
{
var (catalog, map, roster, pool) = Fresh();
var hired = HireTeacher(catalog, map, roster, pool);
var schoolClass = hired.Roster.Classes[0];
var assigned = ClassTeachers.Assign(hired.Roster, schoolClass.Id, hired.TeacherId);
Assert.Equal(ClassTeacherError.None, assigned.Error);
var cleared = ClassTeachers.Clear(assigned.Roster, schoolClass.Id);
Assert.Equal(ClassTeacherError.None, cleared.Error);
Assert.Null(cleared.Roster.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId);
Assert.Contains(cleared.Roster.Classes, row => row.Id == schoolClass.Id);
}
[Fact]
public void TwoClasses_MayShareTheSameTeacher()
{
var (catalog, map, roster, pool) = Fresh();
var hired = HireTeacher(catalog, map, roster, pool);
Assert.True(hired.Roster.Classes.Count >= 2);
var first = hired.Roster.Classes[0];
var second = hired.Roster.Classes[1];
var one = ClassTeachers.Assign(hired.Roster, first.Id, hired.TeacherId);
Assert.Equal(ClassTeacherError.None, one.Error);
var two = ClassTeachers.Assign(one.Roster, second.Id, hired.TeacherId);
Assert.Equal(ClassTeacherError.None, two.Error);
Assert.Equal(hired.TeacherId, two.Roster.Classes.Single(row => row.Id == first.Id).ClassTeacherId);
Assert.Equal(hired.TeacherId, two.Roster.Classes.Single(row => row.Id == second.Id).ClassTeacherId);
}
private static (DefCatalog Catalog, MapLayout Map, Roster Roster, ApplicantPool Pool) Fresh()
{
var catalog = Fixtures.Catalog();
var map = Fixtures.VanillaMap();
var roster = Fixtures.Generate(map);
var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Russia", Fixtures.AsOf);
return (catalog, map, roster, pool);
}
private static (Roster Roster, ApplicantPool Pool, string TeacherId) HireTeacher(
DefCatalog catalog,
MapLayout map,
Roster roster,
ApplicantPool pool)
{
var applicant = pool.Applicants[0];
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
Assert.Equal(StaffingError.None, hired.Error);
return (hired.Roster, hired.Pool, applicant.Person.Id);
}
}