Implement people management features by adding API endpoints for retrieving school rosters and individual person cards. Enhance the UI to support a people browser with filtering and pagination capabilities. Update localization strings for improved user experience and ensure robust handling of person data. Revise documentation to reflect new API functionalities and update tests to validate the new features.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
internal enum PersonLookupError
|
||||
{
|
||||
None,
|
||||
UnknownSchool,
|
||||
UnknownPerson,
|
||||
}
|
||||
|
||||
internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error);
|
||||
|
||||
internal sealed record PeopleListResponse(
|
||||
int Total,
|
||||
int Page,
|
||||
int PageSize,
|
||||
IReadOnlyList<PersonListItemResponse> People,
|
||||
PeopleFilterOptionsResponse Filters);
|
||||
|
||||
internal sealed record PeopleFilterOptionsResponse(
|
||||
IReadOnlyList<int> Years,
|
||||
IReadOnlyList<string> Letters,
|
||||
IReadOnlyList<DefLabelResponse> Positions);
|
||||
|
||||
internal sealed record DefLabelResponse(string DefName, string Label);
|
||||
|
||||
internal sealed record PersonListItemResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
string Surname,
|
||||
string Given,
|
||||
string Patronymic,
|
||||
bool Female,
|
||||
int Age,
|
||||
IReadOnlyList<string> Roles,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
string? Position,
|
||||
string? PositionLabel);
|
||||
|
||||
internal sealed record PersonCardResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
string Surname,
|
||||
string Given,
|
||||
string Patronymic,
|
||||
bool Female,
|
||||
int Age,
|
||||
DateTime BirthDate,
|
||||
IReadOnlyList<string> Roles,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
string? Position,
|
||||
string? PositionLabel,
|
||||
IReadOnlyList<LabeledStatResponse> Body,
|
||||
IReadOnlyList<LabeledStatResponse> Skills,
|
||||
IReadOnlyList<DefLabelResponse> Traits,
|
||||
IReadOnlyList<NeedStatResponse> Needs,
|
||||
PersonFamilyResponse Family);
|
||||
|
||||
internal sealed record LabeledStatResponse(string Id, string Label, string Value);
|
||||
|
||||
internal sealed record NeedStatResponse(string Id, string Label, float Value);
|
||||
|
||||
internal sealed record PersonFamilyResponse(
|
||||
IReadOnlyList<PersonRelResponse> Parents,
|
||||
IReadOnlyList<PersonRelResponse> Children,
|
||||
IReadOnlyList<PersonRelResponse> Siblings,
|
||||
IReadOnlyList<PersonRelResponse> Partners);
|
||||
|
||||
internal sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
||||
|
||||
internal static class PeopleListMapper
|
||||
{
|
||||
public static PeopleListResponse From(
|
||||
Roster roster,
|
||||
RosterPage page,
|
||||
DateTime asOf,
|
||||
DefCatalog? catalog,
|
||||
string locale,
|
||||
RosterQuery query)
|
||||
{
|
||||
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||
var people = page.People
|
||||
.Select(person => Item(person, classes, asOf, catalog, locale))
|
||||
.ToArray();
|
||||
return new PeopleListResponse(page.Total, query.Page, query.PageSize, people, FilterOptions(roster, catalog, locale));
|
||||
}
|
||||
|
||||
private static PersonListItemResponse Item(
|
||||
Person person,
|
||||
IReadOnlyDictionary<string, SchoolClass> classes,
|
||||
DateTime asOf,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
int? year = null;
|
||||
string? letter = null;
|
||||
if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass))
|
||||
{
|
||||
year = schoolClass.Year;
|
||||
letter = schoolClass.Letter;
|
||||
}
|
||||
|
||||
return new PersonListItemResponse(
|
||||
person.Id,
|
||||
person.Name.Full,
|
||||
person.Name.Surname,
|
||||
person.Name.Given,
|
||||
person.Name.Patronymic,
|
||||
person.Female,
|
||||
person.AgeOn(asOf),
|
||||
RolesOf(person),
|
||||
year,
|
||||
letter,
|
||||
person.Position,
|
||||
PositionLabel(catalog, locale, person.Position));
|
||||
}
|
||||
|
||||
internal static string[] RolesOf(Person person)
|
||||
{
|
||||
var roles = new List<string>(3);
|
||||
if (person.IsStudent)
|
||||
{
|
||||
roles.Add(PersonRoles.Student);
|
||||
}
|
||||
|
||||
if (person.IsStaff)
|
||||
{
|
||||
roles.Add(PersonRoles.Staff);
|
||||
}
|
||||
|
||||
if (person.IsParent)
|
||||
{
|
||||
roles.Add(PersonRoles.Parent);
|
||||
}
|
||||
|
||||
return [.. roles];
|
||||
}
|
||||
|
||||
internal static string? PositionLabel(DefCatalog? catalog, string locale, string? position)
|
||||
{
|
||||
if (position is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (catalog is not null && catalog.Positions.TryGetValue(position, out var def))
|
||||
{
|
||||
return catalog.Label(locale, def);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
private static PeopleFilterOptionsResponse FilterOptions(Roster roster, DefCatalog? catalog, string locale)
|
||||
{
|
||||
var years = roster.Classes.Select(schoolClass => schoolClass.Year).Distinct().OrderBy(year => year).ToArray();
|
||||
var letters = roster.Classes.Select(schoolClass => schoolClass.Letter).Distinct().OrderBy(letter => letter, StringComparer.Ordinal).ToArray();
|
||||
var positions = roster.People
|
||||
.Select(person => person.Position)
|
||||
.Where(position => position is not null)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(position => PositionLabel(catalog, locale, position) ?? position, StringComparer.Ordinal)
|
||||
.Select(position => new DefLabelResponse(position!, PositionLabel(catalog, locale, position) ?? position!))
|
||||
.ToArray();
|
||||
return new PeopleFilterOptionsResponse(years, letters, positions);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to
|
||||
/// the supervisor as a command and awaited, so each school stays on its own worker thread.
|
||||
/// The main menu talks to these: list, create, delete. The people list reads a published roster
|
||||
/// snapshot; the person card goes through the school's mailbox because needs are live.
|
||||
/// </summary>
|
||||
internal static class SchoolEndpoints
|
||||
{
|
||||
@@ -91,6 +92,70 @@ internal static class SchoolEndpoints
|
||||
return deleted ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("DeleteSchool");
|
||||
|
||||
schools.MapGet("/{id:int}/people", (
|
||||
int id,
|
||||
string? role,
|
||||
int? year,
|
||||
string? letter,
|
||||
string? position,
|
||||
string? sex,
|
||||
int? ageMin,
|
||||
int? ageMax,
|
||||
string? sort,
|
||||
string? dir,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
string? lang,
|
||||
GameLoopService loop) =>
|
||||
{
|
||||
var published = loop.FindPeople(id);
|
||||
if (published is null)
|
||||
{
|
||||
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||
}
|
||||
|
||||
if (!TryParsePeopleQuery(role, year, letter, position, sex, ageMin, ageMax, sort, dir, page, pageSize, out var query, out var error))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
|
||||
}
|
||||
|
||||
var roster = published.Roster;
|
||||
if (roster is null)
|
||||
{
|
||||
return Results.Ok(new PeopleListResponse(0, query.Page, query.PageSize, [], new PeopleFilterOptionsResponse([], [], [])));
|
||||
}
|
||||
|
||||
var locale = ParseLocale(lang);
|
||||
var slice = RosterBrowser.Apply(roster, published.School.GameTime, query);
|
||||
return Results.Ok(PeopleListMapper.From(roster, slice, published.School.GameTime, published.Catalog, locale, query));
|
||||
})
|
||||
.WithName("GetSchoolPeople");
|
||||
|
||||
schools.MapGet("/{id:int}/people/{personId}", async (
|
||||
int id,
|
||||
string personId,
|
||||
string? lang,
|
||||
GameCommandQueue commands,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
|
||||
}
|
||||
|
||||
var command = new GameCommand.GetPerson(id, personId, ParseLocale(lang), NewCompletion<PersonCardResult>());
|
||||
commands.Enqueue(command);
|
||||
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
return outcome.Error switch
|
||||
{
|
||||
PersonLookupError.None when outcome.Card is not null => Results.Ok(outcome.Card),
|
||||
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
|
||||
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
|
||||
};
|
||||
})
|
||||
.WithName("GetSchoolPerson");
|
||||
}
|
||||
|
||||
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
|
||||
@@ -102,6 +167,118 @@ internal static class SchoolEndpoints
|
||||
? SchoolNameLanguage.English
|
||||
: SchoolNameLanguage.Russian;
|
||||
|
||||
private static string ParseLocale(string? lang) =>
|
||||
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
|
||||
|
||||
private static bool TryParsePeopleQuery(
|
||||
string? role,
|
||||
int? year,
|
||||
string? letter,
|
||||
string? position,
|
||||
string? sex,
|
||||
int? ageMin,
|
||||
int? ageMax,
|
||||
string? sort,
|
||||
string? dir,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
out RosterQuery query,
|
||||
out string error)
|
||||
{
|
||||
query = default;
|
||||
error = string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(role) && !HSchool.Content.PersonRoles.IsKnown(role))
|
||||
{
|
||||
error = "role must be student, staff or parent.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (year is < 1)
|
||||
{
|
||||
error = "year must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool? female = null;
|
||||
if (!string.IsNullOrWhiteSpace(sex))
|
||||
{
|
||||
if (sex.Equals("female", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
female = true;
|
||||
}
|
||||
else if (sex.Equals("male", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
female = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
error = "sex must be male or female.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (ageMin is < 0 || ageMax is < 0)
|
||||
{
|
||||
error = "ageMin and ageMax must be zero or greater.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ageMin is int min && ageMax is int max && min > max)
|
||||
{
|
||||
error = "ageMin cannot be greater than ageMax.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!RosterBrowser.TryParseSort(sort, out var parsedSort))
|
||||
{
|
||||
error = "sort must be surname, age, year or position.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var descending = false;
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
if (dir.Equals("desc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descending = true;
|
||||
}
|
||||
else if (!dir.Equals("asc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = "dir must be asc or desc.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var parsedPage = page ?? 1;
|
||||
if (parsedPage < 1)
|
||||
{
|
||||
error = "page must be 1 or greater.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsedPageSize = pageSize ?? RosterBrowser.DefaultPageSize;
|
||||
if (parsedPageSize < 1 || parsedPageSize > RosterBrowser.MaxPageSize)
|
||||
{
|
||||
error = $"pageSize must be between 1 and {RosterBrowser.MaxPageSize}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
query = new RosterQuery(
|
||||
string.IsNullOrWhiteSpace(role) ? null : role,
|
||||
year,
|
||||
string.IsNullOrWhiteSpace(letter) ? null : letter,
|
||||
string.IsNullOrWhiteSpace(position) ? null : position,
|
||||
female,
|
||||
ageMin,
|
||||
ageMax,
|
||||
parsedSort,
|
||||
descending,
|
||||
parsedPage,
|
||||
parsedPageSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Problem(int statusCode, string code, string detail) =>
|
||||
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user