Update wire protocol to version 6 and enhance timetable functionality
- Bumped the wire protocol version to 6, reflecting changes in the communication structure.
- Expanded the timetable API with new endpoints for fetching and managing lesson schedules, including `GET /api/schools/{id}/timetable` and `POST /api/schools/{id}/timetable/pin`.
- Updated the protocol documentation to include detailed descriptions of the new timetable features and message structures.
- Enhanced the client-side implementation to support the new timetable functionalities, including lesson pinning and unpinning.
- Revised server-side logic to handle timetable operations and ensure proper integration with existing school management features.
- Added tests to validate the new timetable functionalities and ensure robustness in handling lesson data.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using HSchool.People;
|
||||
using HSchool.Schedule;
|
||||
using HSchool.Server.Game;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
internal static class TimetableEndpoints
|
||||
{
|
||||
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public static void MapTimetableEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var schools = builder.MapGroup("/api/schools");
|
||||
|
||||
schools.MapGet("/{id:int}/timetable", (
|
||||
int id,
|
||||
string? classId,
|
||||
string? personId,
|
||||
string? lang,
|
||||
GameLoopService loop) =>
|
||||
{
|
||||
var published = loop.FindPeople(id);
|
||||
if (published is null)
|
||||
{
|
||||
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||
}
|
||||
|
||||
return Results.Ok(MapTimetable(published, loop.Options.SchoolWeekDays, ParseLocale(lang), classId, personId));
|
||||
})
|
||||
.WithName("GetSchoolTimetable");
|
||||
|
||||
schools.MapPost("/{id:int}/timetable/pin", async (
|
||||
int id,
|
||||
PinLessonRequest request,
|
||||
string? lang,
|
||||
GameCommandQueue commands,
|
||||
GameLoopService loop,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryDefName(request.ClassId, "classId", out var classId, out var error)
|
||||
|| !TryDefName(request.Subject, "subject", out var subject, out error)
|
||||
|| !TryDefName(request.RoomId, "roomId", out var roomId, out error))
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
|
||||
}
|
||||
|
||||
var command = new GameCommand.PinLesson(
|
||||
id,
|
||||
classId,
|
||||
subject,
|
||||
roomId,
|
||||
request.Day,
|
||||
request.Period,
|
||||
NewCompletion<TimetableOutcome>());
|
||||
commands.Enqueue(command);
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
return TimetableResult(id, outcome, loop, ParseLocale(lang), classId: null, personId: null);
|
||||
})
|
||||
.WithName("PinSchoolLesson");
|
||||
|
||||
schools.MapDelete("/{id:int}/timetable/pin", async (
|
||||
int id,
|
||||
string? classId,
|
||||
string? subject,
|
||||
int? day,
|
||||
int? period,
|
||||
string? lang,
|
||||
GameCommandQueue commands,
|
||||
GameLoopService loop,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryDefName(classId, "classId", out var classValue, out var error)
|
||||
|| !TryDefName(subject, "subject", out var subjectValue, out error)
|
||||
|| day is null
|
||||
|| period is null)
|
||||
{
|
||||
return Problem(
|
||||
StatusCodes.Status400BadRequest,
|
||||
"invalid-query",
|
||||
error.Length > 0 ? error : "classId, subject, day and period are required.");
|
||||
}
|
||||
|
||||
var command = new GameCommand.UnpinLesson(
|
||||
id,
|
||||
classValue,
|
||||
subjectValue,
|
||||
day.Value,
|
||||
period.Value,
|
||||
NewCompletion<TimetableOutcome>());
|
||||
commands.Enqueue(command);
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
return TimetableResult(id, outcome, loop, ParseLocale(lang), classId: null, personId: null);
|
||||
})
|
||||
.WithName("UnpinSchoolLesson");
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<T> NewCompletion<T>() =>
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private static string ParseLocale(string? lang) =>
|
||||
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
|
||||
|
||||
private static TimetableResponse MapTimetable(
|
||||
PublishedSchoolPeople published,
|
||||
int weekDays,
|
||||
string locale,
|
||||
string? classId,
|
||||
string? personId)
|
||||
{
|
||||
var table = published.Timetable ?? new Timetable([], []);
|
||||
var roster = published.Roster ?? new Roster([], [], []);
|
||||
if (published.Catalog is null)
|
||||
{
|
||||
return new TimetableResponse(weekDays, 0, [], []);
|
||||
}
|
||||
|
||||
return TimetableMapper.From(table, roster, published.Catalog, weekDays, locale, classId, personId);
|
||||
}
|
||||
|
||||
private static IResult TimetableResult(
|
||||
int schoolId,
|
||||
TimetableOutcome outcome,
|
||||
GameLoopService loop,
|
||||
string locale,
|
||||
string? classId,
|
||||
string? personId)
|
||||
{
|
||||
if (outcome.Error != TimetableError.None)
|
||||
{
|
||||
return TimetableProblem(outcome.Error);
|
||||
}
|
||||
|
||||
var published = loop.FindPeople(schoolId);
|
||||
if (published is null)
|
||||
{
|
||||
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||
}
|
||||
|
||||
return Results.Ok(MapTimetable(published, loop.Options.SchoolWeekDays, locale, classId, personId));
|
||||
}
|
||||
|
||||
private static IResult TimetableProblem(TimetableError error) =>
|
||||
error switch
|
||||
{
|
||||
TimetableError.UnknownClass =>
|
||||
Problem(StatusCodes.Status400BadRequest, "unknown-class", "That class is not in the school."),
|
||||
TimetableError.UnknownSubject =>
|
||||
Problem(StatusCodes.Status400BadRequest, "unknown-subject", "That subject is not in the catalog."),
|
||||
TimetableError.UnknownRoom =>
|
||||
Problem(StatusCodes.Status400BadRequest, "unknown-room", "That room is not on the map."),
|
||||
TimetableError.NoTeacher =>
|
||||
Problem(StatusCodes.Status409Conflict, "no-teacher", "Nobody is assigned that subject."),
|
||||
TimetableError.PinRejected =>
|
||||
Problem(StatusCodes.Status409Conflict, "pin-rejected", "That slot or room violates the timetable constraints."),
|
||||
TimetableError.UnknownLesson =>
|
||||
Problem(StatusCodes.Status404NotFound, "unknown-lesson", "That locked lesson is not on the timetable."),
|
||||
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
|
||||
};
|
||||
|
||||
private static bool TryDefName(string? value, string field, out string name, out string error)
|
||||
{
|
||||
name = value?.Trim() ?? string.Empty;
|
||||
if (name.Length is < 1 or > 64)
|
||||
{
|
||||
error = $"{field} is not valid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Problem(int statusCode, string code, string detail)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: detail,
|
||||
statusCode: statusCode,
|
||||
title: code,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user