Merge branch 'phase/68-class-teacher'
This commit is contained in:
@@ -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] Два класса могут указать одного и того же учителя
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [68. Классный руководитель](68-class-teacher.md) | 🔄 | Назначение в Управлении, только учителя |
|
| [68. Классный руководитель](68-class-teacher.md) | ✅ | Назначение в Управлении, только учителя |
|
||||||
|
|
||||||
**Этап B — проступки и кабинет.** Память, авто-вызов, экран.
|
**Этап B — проступки и кабинет.** Память, авто-вызов, экран.
|
||||||
|
|
||||||
|
|||||||
+39
-1
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user