From 1934e0c333af15ded3a606134a96bb247159ae17 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 30 Jan 2025 10:05:13 -0500 Subject: [PATCH 1/6] Rename export class files and add all games to export if none are specifically requested --- .../Requests/ExportGame/ExportGames.cs | 84 +++++++++++++++++++ .../Requests/ExportGame/ExportGamesModels.cs | 6 ++ 2 files changed, 90 insertions(+) create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGames.cs create mode 100644 src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGamesModels.cs diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGames.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGames.cs new file mode 100644 index 00000000..6ba63c0e --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGames.cs @@ -0,0 +1,84 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Data; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Games; + +public record ExportGamesCommand(string[] GameIds, bool? IncludePracticeAreaDefaultCertificateTemplate) : IRequest; + +internal sealed class ExportGamesHandler +( + IGameImportExportService importExportService, + IStore store, + IValidatorService validator +) : IRequestHandler +{ + private readonly IGameImportExportService _importExportService = importExportService; + private readonly IStore _store = store; + private readonly IValidatorService _validator = validator; + + public async Task Handle(ExportGamesCommand request, CancellationToken cancellationToken) + { + var finalGameIds = Array.Empty(); + if (request.GameIds.IsNotEmpty()) + { + finalGameIds = [.. request.GameIds.Distinct().Where(gId => gId.IsNotEmpty())]; + } + + await _validator + .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) + .AddValidator(ctx => + { + if (request.GameIds.IsEmpty()) + { + ctx.AddValidationException(new MissingRequiredInput(nameof(request.GameIds))); + } + }) + .AddValidator(async ctx => + { + if ((request?.GameIds?.Length ?? 0) == 0) + { + return; + } + + var gamesExist = await _store + .WithNoTracking() + .Where(g => finalGameIds.Contains(g.Id)) + .Select(g => g.Id) + .ToArrayAsync(cancellationToken); + + if (gamesExist.Length != request.GameIds.Length) + { + foreach (var gameId in request.GameIds.Where(gId => !gamesExist.Contains(gId))) + { + ctx.AddValidationException(new ResourceNotFound(gameId)); + } + } + + }) + .Validate(cancellationToken); + + // if no gameIds have been passed, give them everything + if (finalGameIds.IsEmpty()) + { + finalGameIds = await _store + .WithNoTracking() + .Select(g => g.Id) + .ToArrayAsync(cancellationToken); + } + + var batch = await _importExportService.ExportPackage + ( + request.GameIds, + request.IncludePracticeAreaDefaultCertificateTemplate.GetValueOrDefault(), + cancellationToken + ); + + return new ExportGamesResult { ExportBatch = batch }; + } +} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGamesModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGamesModels.cs new file mode 100644 index 00000000..4bc1ecc1 --- /dev/null +++ b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGamesModels.cs @@ -0,0 +1,6 @@ +namespace Gameboard.Api.Features.Games; + +public sealed class ExportGamesResult +{ + public required GameImportExportBatch ExportBatch { get; set; } +} From 909361258d73077a7fc8365db34760e239a2726d Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 30 Jan 2025 15:21:50 -0500 Subject: [PATCH 2/6] Fix a bug that caused competitive attempts to appear in the practice tab --- .../Requests/GetGameCenterPractice/GetGameCenterPractice.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs index 1c5ff8bc..c6b536ca 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterPractice/GetGameCenterPractice.cs @@ -47,6 +47,7 @@ public async Task Handle(GetGameCenterPracticeContext var users = await _store .WithNoTracking() .Where(c => c.GameId == request.GameId) + .Where(c => c.PlayerMode == PlayerMode.Practice) .Where ( c => From d69efd572448b25109bcbd9359746f9edbc89a8b Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 30 Jan 2025 15:22:27 -0500 Subject: [PATCH 3/6] Game center stats now only include competitive (except for obviously practice-related stats) --- .../GetGameCenterContext.cs | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs index cd011f7e..28fabaad 100644 --- a/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs +++ b/src/Gameboard.Api/Features/Admin/Requests/GetGameCenterContext/GetGameCenterContext.cs @@ -11,6 +11,8 @@ using Gameboard.Api.Structure.MediatR.Validators; using MediatR; using Microsoft.EntityFrameworkCore; +using ServiceStack.Text; +using TopoMojo.Api.Client; namespace Gameboard.Api.Features.Admin; @@ -60,7 +62,7 @@ await _validator g.IsPublished, IsRegistrationActive = g.RegistrationType == GameRegistrationType.Open && g.RegistrationOpen <= nowish && g.RegistrationClose >= nowish, IsTeamGame = g.MaxTeamSize > 1, - RegisteredTeamCount = g.Players.Select(p => p.TeamId).Distinct().Count() + RegisteredTeamCount = g.Players.Where(p => p.Mode == PlayerMode.Competition).Select(p => p.TeamId).Distinct().Count() }) .SingleAsync(g => g.Id == request.GameId, cancellationToken); @@ -101,12 +103,26 @@ await _validator var startedTeamsCount = await _store .WithNoTracking() .Where(p => p.GameId == request.GameId) + .Where(p => p.Mode == PlayerMode.Competition) .SelectedStartedTeamIds() .CountAsync(cancellationToken); - var playerActivity = await _store + var practiceData = await _store + .WithNoTracking() + .Where(p => p.GameId == gameData.Id) + .Where(p => p.Mode == PlayerMode.Practice) + .Select(p => new + { + p.Id, + p.TeamId, + p.UserId + }) + .ToArrayAsync(cancellationToken); + + var competitiveActivity = await _store .WithNoTracking() .Where(p => p.GameId == request.GameId) + .Where(p => p.Mode == PlayerMode.Competition) .Select(p => new { p.GameId, @@ -120,20 +136,15 @@ await _validator .GroupBy(p => p.GameId) .Select(gr => new GameCenterContextStats { - AttemptCountPractice = gr.Where(p => p.Mode == PlayerMode.Practice).Count(), + AttemptCountPractice = practiceData.Count(), PlayerCountActive = gr .Where(p => p.IsActive) .Count(), PlayerCountCompetitive = gr - .Where(p => p.Mode == PlayerMode.Competition) - .Select(p => p.UserId) - .Distinct() - .Count(), - PlayerCountPractice = gr - .Where(p => p.Mode == PlayerMode.Practice) .Select(p => p.UserId) .Distinct() .Count(), + PlayerCountPractice = practiceData.Select(p => p.UserId).Distinct().Count(), PlayerCountTotal = gr .Select(p => p.UserId) .Distinct() @@ -149,15 +160,10 @@ await _validator .Distinct() .Count(), TeamCountCompetitive = gr - .Where(p => p.Mode == PlayerMode.Competition) - .Select(p => p.TeamId) - .Distinct() - .Count(), - TeamCountPractice = gr - .Where(p => p.Mode == PlayerMode.Practice) .Select(p => p.TeamId) .Distinct() .Count(), + TeamCountPractice = practiceData.Select(p => p.TeamId).Distinct().Count(), TeamCountNotStarted = gameData.RegisteredTeamCount - startedTeamsCount, TeamCountTotal = gameData.RegisteredTeamCount, TopScore = topScore == null ? null : topScore.ScoreOverall, @@ -182,7 +188,7 @@ await _validator IsPublished = gameData.IsPublished, IsRegistrationActive = gameData.IsRegistrationActive, IsTeamGame = gameData.IsTeamGame, - Stats = playerActivity ?? new() + Stats = competitiveActivity ?? new() { AttemptCountPractice = gameData.IsPracticeMode ? 0 : null, TopScore = null From f10997d060ec8fb64762d124e0eaa6bdb4a7e4c4 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 30 Jan 2025 15:24:03 -0500 Subject: [PATCH 4/6] refactor graderurl to be constructed lower-level in challenge instantiation (for DRYness) --- .../Challenges/ChallengeServiceTests.cs | 5 ++- .../Features/Challenge/ChallengeController.cs | 32 +++++++++++++++---- .../Challenge/Services/ChallengeService.cs | 28 ++++++++++++---- .../PracticeModeReportSubmissionsExport.cs | 3 +- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs index 9f413127..ff0e8f93 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs @@ -20,7 +20,6 @@ public class ChallengeServiceTests /// /// /// - /// /// /// /// @@ -102,6 +101,7 @@ string userId A.Fake(), A.Fake(), A.Fake(), + A.Fake(), A.Fake(), A.Fake(), A.Fake(), @@ -127,7 +127,6 @@ string userId fakeGame, fakePlayer, userId, - graderUrl, 1, 0 ); @@ -225,6 +224,7 @@ string userId A.Fake(), A.Fake(), A.Fake(), + A.Fake(), A.Fake(), A.Fake(), A.Fake(), @@ -250,7 +250,6 @@ string userId fakeGame, fakePlayer, userId, - graderUrl, 1, 0 ); diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs index 62c649e1..4818d2a2 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs @@ -25,7 +25,6 @@ namespace Gameboard.Api.Controllers; public class ChallengeController ( IActingUserService actingUserService, - IChallengeGraderUrlService challengeGraderUrlService, ILogger logger, IDistributedCache cache, ChallengeValidator validator, @@ -37,7 +36,6 @@ public class ChallengeController ConsoleActorMap actormap ) : GameboardLegacyController(actingUserService, logger, cache, validator) { - private readonly IChallengeGraderUrlService _challengeGraderUrlService = challengeGraderUrlService; private readonly IMediator _mediator = mediator; private readonly IUserRolePermissionsService _permissionsService = permissionsService; @@ -47,12 +45,35 @@ ConsoleActorMap actormap ConsoleActorMap ActorMap { get; } = actormap; /// - /// Create new challenge instance + /// Purge a challenge. This deletes the challenge instance, and all progress on it, and can't be undone. + /// A record of the challenge is stored in ArchivedChallenges. + /// + /// + /// + /// + [HttpDelete("api/challenge/{challengeId}")] + public Task DeleteChallenge(string challengeId, CancellationToken cancellationToken) + => _mediator.Send(new PurgeChallengeCommand(challengeId), cancellationToken); + + /// + /// Create and start an instance of a challenge. + /// + /// + /// + /// + [HttpPost("api/challenge")] + public Task Create([FromBody] StartChallengeCommand request, CancellationToken cancellationToken) + => _mediator.Send(request, cancellationToken); + + /// + /// Create new challenge instance. + /// + /// (This endpoint is deprecated and will be removed in a future release. Instead, use api/challenge and pass teamId + specId) /// /// Idempotent method to retrieve or create challenge state /// NewChallenge /// Challenge - [HttpPost("api/challenge")] + [HttpPost("api/challenge/launch")] public async Task Create([FromBody] NewChallenge model) { await AuthorizeAny @@ -65,8 +86,7 @@ await AuthorizeAny if (!await _permissionsService.Can(PermissionKey.Play_ChooseChallengeVariant)) model.Variant = 0; - var graderUrl = _challengeGraderUrlService.BuildGraderUrl(); - var result = await ChallengeService.GetOrCreate(model, Actor.Id, graderUrl); + var result = await ChallengeService.GetOrCreate(model, Actor.Id); await Hub.Clients.Group(result.TeamId).ChallengeEvent(new HubEvent { diff --git a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs index 319d7352..3e9c5aec 100644 --- a/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/Services/ChallengeService.cs @@ -28,6 +28,7 @@ public partial class ChallengeService IActingUserService actingUserService, ConsoleActorMap actorMap, CoreOptions coreOptions, + IChallengeGraderUrlService challengeGraderUrls, IChallengeStore challengeStore, IChallengeDocsService challengeDocsService, IChallengeSubmissionsService challengeSubmissionsService, @@ -47,6 +48,7 @@ ITeamService teamService { private readonly IActingUserService _actingUserService = actingUserService; private readonly ConsoleActorMap _actorMap = actorMap; + private readonly IChallengeGraderUrlService _challengeGraderUrls = challengeGraderUrls; private readonly IChallengeStore _challengeStore = challengeStore; private readonly IGameEngineService _gameEngine = gameEngine; private readonly IGuidService _guids = guids; @@ -66,12 +68,18 @@ ITeamService teamService public Task Get(string challengeId) => GetDto(_store.WithNoTracking().Where(c => c.Id == challengeId)); - public async Task GetOrCreate(NewChallenge model, string actorId, string graderUrl) + public Task GetOrCreate(NewChallenge model) + { + var actingUser = _actingUserService.Get(); + return GetOrCreate(model, actingUser.Id); + } + + public async Task GetOrCreate(NewChallenge model, string actorId) { var challengeQuery = await ResolveChallenge(model.SpecId, model.PlayerId); var challenge = await GetDto(challengeQuery); - return challenge ?? await Create(model, actorId, graderUrl, CancellationToken.None); + return challenge ?? await Create(model, actorId, CancellationToken.None); } public int GetDeployingChallengeCount(string teamId) @@ -97,7 +105,7 @@ public IEnumerable GetTags(Data.ChallengeSpec spec) .ToArray(); } - public async Task Create(NewChallenge model, string actorId, string graderUrl, CancellationToken cancellationToken) + public async Task Create(NewChallenge model, string actorId, CancellationToken cancellationToken) { var now = _now.Get(); var player = await _store @@ -165,7 +173,7 @@ public async Task Create(NewChallenge model, string actorId, string g .CountAsync(p => p.TeamId == player.TeamId, cancellationToken); } - var challenge = await BuildAndRegisterChallenge(model, spec, player.Game, player, actorId, graderUrl, playerCount, model.Variant); + var challenge = await BuildAndRegisterChallenge(model, spec, player.Game, player, actorId, playerCount, model.Variant); await _store.Create(challenge, cancellationToken); await _challengeStore.UpdateEtd(challenge.SpecId); @@ -524,6 +532,15 @@ await _store.Create(new ChallengeEvent return Mapper.Map(challenge); } + public async Task ArchiveChallenge(string challengeId, CancellationToken cancellationToken) + { + var challenge = await _store + .WithNoTracking() + .SingleOrDefaultAsync(c => c.Id == challengeId, cancellationToken) ?? throw new ResourceNotFound(challengeId); + + await ArchiveChallenges([challenge]); + } + public async Task ArchivePlayerChallenges(Data.Player player) { // for this, we need to make sure that we're not cleaning up any challenges @@ -746,7 +763,6 @@ internal async Task BuildAndRegisterChallenge Data.Game game, Data.Player player, string actorUserId, - string graderUrl, int playerCount, int variant ) @@ -774,7 +790,7 @@ int variant ChallengeSpec = spec, Game = game, GraderKey = graderKey, - GraderUrl = graderUrl, + GraderUrl = _challengeGraderUrls.BuildGraderUrl(), Player = player, PlayerCount = playerCount, StartGamespace = newChallenge.StartGamespace, diff --git a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/Requests/PracticeModeReportSubmissionsExport.cs b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/Requests/PracticeModeReportSubmissionsExport.cs index ddb2b38c..0ebd6b76 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/Requests/PracticeModeReportSubmissionsExport.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/PracticeMode/Requests/PracticeModeReportSubmissionsExport.cs @@ -2,10 +2,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Gameboard.Api.Features.Reports; using MediatR; -namespace Gameboard.Api.Reports; +namespace Gameboard.Api.Features.Reports; // this returns an object array because, regrettably, submission entries have a dynamic number of "answer" fields, so we pull back a strongly-typed // object from the app logic, but then we convert to untyped here to accommodate for arbitray numbers of fields. From 8dba4955e9a1ccf069478ec5e486cd26eb17fc99 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 30 Jan 2025 15:24:51 -0500 Subject: [PATCH 5/6] Added new 'View Challenges' modal in Game Center -> Teams to allow admin start, deploy, undeploy, and purge --- .../Requests/PurgeChallenge/PurgeChallenge.cs | 26 ++++ .../Requests/StartChallenge/StartChallenge.cs | 88 ++++++++++++ .../StartChallenge/StartChallengeModels.cs | 6 + .../Requests/ExportGame/ExportGame.cs | 75 ---------- .../Requests/ExportGame/ExportGameModels.cs | 6 - .../GameResourcesDeployService.cs | 4 - .../Features/Player/PlayerController.cs | 13 -- .../Reports/ReportsExportController.cs | 1 - .../GetTeamChallengeSpecsStatuses.cs | 132 ++++++++++++++++++ .../GetTeamChallengeSpecsStatusesModels.cs | 26 ++++ .../Features/Teams/TeamController.cs | 4 + .../Features/Teams/TeamExceptions.cs | 6 + .../Permissions/UserRolePermissionsModels.cs | 1 + .../Permissions/UserRolePermissionsService.cs | 7 + 14 files changed, 296 insertions(+), 99 deletions(-) create mode 100644 src/Gameboard.Api/Features/Challenge/Requests/PurgeChallenge/PurgeChallenge.cs create mode 100644 src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallenge.cs create mode 100644 src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallengeModels.cs delete mode 100644 src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs delete mode 100644 src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGameModels.cs create mode 100644 src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatuses.cs create mode 100644 src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatusesModels.cs diff --git a/src/Gameboard.Api/Features/Challenge/Requests/PurgeChallenge/PurgeChallenge.cs b/src/Gameboard.Api/Features/Challenge/Requests/PurgeChallenge/PurgeChallenge.cs new file mode 100644 index 00000000..7296cbe9 --- /dev/null +++ b/src/Gameboard.Api/Features/Challenge/Requests/PurgeChallenge/PurgeChallenge.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using MediatR; + +namespace Gameboard.Api.Features.Challenges; + +public record PurgeChallengeCommand(string ChallengeId) : IRequest; + +internal sealed class PurgeChallengeHandler(ChallengeService challenges, IValidatorService validator) : IRequestHandler +{ + private readonly ChallengeService _challenges = challenges; + private readonly IValidatorService _validator = validator; + + public async Task Handle(PurgeChallengeCommand request, CancellationToken cancellationToken) + { + await _validator + .Auth(c => c.RequireOneOf(PermissionKey.Teams_EditSession)) + .AddEntityExistsValidator(request.ChallengeId) + .Validate(cancellationToken); + + await _challenges.ArchiveChallenge(request.ChallengeId, cancellationToken); + } +} diff --git a/src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallenge.cs b/src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallenge.cs new file mode 100644 index 00000000..05530c4a --- /dev/null +++ b/src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallenge.cs @@ -0,0 +1,88 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Teams; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Hubs; +using Gameboard.Api.Services; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Challenges; + +public record StartChallengeCommand(string ChallengeSpecId, string TeamId, int variantIndex) : IRequest; + +internal sealed class StartChallengeHandler +( + IActingUserService actingUser, + ChallengeService challengeService, + IHubContext legacyTeamHub, + IStore store, + ITeamService teamService, + IValidatorService validator +) : IRequestHandler +{ + private readonly IActingUserService _actingUser = actingUser; + private readonly ChallengeService _challengeService = challengeService; + private readonly IHubContext _legacyTeamHub = legacyTeamHub; + private readonly IStore _store = store; + private readonly ITeamService _teamService = teamService; + private readonly IValidatorService _validator = validator; + + public async Task Handle(StartChallengeCommand request, CancellationToken cancellationToken) + { + // resolve the team/challenge/game mess + var captain = await _teamService.ResolveCaptain(request.TeamId, cancellationToken); + var team = await _teamService.GetTeam(request.TeamId); + + var spec = await _store + .WithNoTracking() + .Where(s => s.Id == request.ChallengeSpecId) + .Select(s => new { s.Id, s.GameId, GameName = s.Game.Name }) + .SingleOrDefaultAsync(cancellationToken); + + await _validator + .Auth + ( + c => c + .RequireOneOf(PermissionKey.Teams_EditSession) + .UnlessUserIdIn([.. team.Members.Select(m => m.Id)]) + ) + .AddValidator(spec is null, new ResourceNotFound(request.ChallengeSpecId)) + .AddValidator(captain.GameId != spec.GameId, new TeamIsntPlayingGame + ( + new SimpleEntity + { + Id = captain.TeamId, + Name = captain.ApprovedName + }, + new SimpleEntity + { + Id = captain.GameId, + Name = spec.GameName + } + )) + .Validate(cancellationToken); + + var challenge = await _challengeService.GetOrCreate(new NewChallenge + { + PlayerId = captain.Id, + SpecId = request.ChallengeSpecId, + StartGamespace = true, + Variant = request.variantIndex + }); + + await _legacyTeamHub.Clients.Group(captain.TeamId).ChallengeEvent(new HubEvent + { + Model = challenge, + Action = EventAction.Updated, + ActingUser = _actingUser.Get().ToSimpleEntity() + }); + + return new StartChallengeResponse { Challenge = challenge }; + } +} diff --git a/src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallengeModels.cs b/src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallengeModels.cs new file mode 100644 index 00000000..081d0812 --- /dev/null +++ b/src/Gameboard.Api/Features/Challenge/Requests/StartChallenge/StartChallengeModels.cs @@ -0,0 +1,6 @@ +namespace Gameboard.Api.Features.Challenges; + +public sealed class StartChallengeResponse +{ + public required Challenge Challenge { get; set; } +} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs deleted file mode 100644 index dc80873f..00000000 --- a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGame.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Gameboard.Api.Data; -using Gameboard.Api.Structure.MediatR; -using MediatR; -using Microsoft.EntityFrameworkCore; - -namespace Gameboard.Api.Features.Games; - -public record ExportGamesCommand(string[] GameIds, bool? IncludePracticeAreaDefaultCertificateTemplate) : IRequest; - -internal sealed class ExportGamesHandler -( - IGameImportExportService importExportService, - IStore store, - IValidatorService validator -) : IRequestHandler -{ - private readonly IGameImportExportService _importExportService = importExportService; - private readonly IStore _store = store; - private readonly IValidatorService _validator = validator; - - public async Task Handle(ExportGamesCommand request, CancellationToken cancellationToken) - { - var finalGameIds = Array.Empty(); - if (request.GameIds.IsNotEmpty()) - { - finalGameIds = [.. request.GameIds.Distinct().Where(gId => gId.IsNotEmpty())]; - } - - await _validator - .Auth(c => c.Require(Users.PermissionKey.Games_CreateEditDelete)) - .AddValidator(ctx => - { - if (request.GameIds.IsEmpty()) - { - ctx.AddValidationException(new MissingRequiredInput(nameof(request.GameIds))); - } - }) - .AddValidator(async ctx => - { - if ((request?.GameIds?.Length ?? 0) == 0) - { - return; - } - - var gamesExist = await _store - .WithNoTracking() - .Where(g => finalGameIds.Contains(g.Id)) - .Select(g => g.Id) - .ToArrayAsync(cancellationToken); - - if (gamesExist.Length != request.GameIds.Length) - { - foreach (var gameId in request.GameIds.Where(gId => !gamesExist.Contains(gId))) - { - ctx.AddValidationException(new ResourceNotFound(gameId)); - } - } - - }) - .Validate(cancellationToken); - - var batch = await _importExportService.ExportPackage - ( - request.GameIds, - request.IncludePracticeAreaDefaultCertificateTemplate.GetValueOrDefault(), - cancellationToken - ); - - return new ExportGamesResult { ExportBatch = batch }; - } -} diff --git a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGameModels.cs b/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGameModels.cs deleted file mode 100644 index 4bc1ecc1..00000000 --- a/src/Gameboard.Api/Features/Game/ImportExport/Requests/ExportGame/ExportGameModels.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Gameboard.Api.Features.Games; - -public sealed class ExportGamesResult -{ - public required GameImportExportBatch ExportBatch { get; set; } -} diff --git a/src/Gameboard.Api/Features/Game/ResourceDeployment/GameResourcesDeployService.cs b/src/Gameboard.Api/Features/Game/ResourceDeployment/GameResourcesDeployService.cs index 6d75e26c..d38e9532 100644 --- a/src/Gameboard.Api/Features/Game/ResourceDeployment/GameResourcesDeployService.cs +++ b/src/Gameboard.Api/Features/Game/ResourceDeployment/GameResourcesDeployService.cs @@ -29,7 +29,6 @@ internal class GameResourcesDeployService : IGameResourcesDeployService private readonly ChallengeService _challengeService; private readonly CoreOptions _coreOptions; private readonly IGameEngineService _gameEngineService; - private readonly IChallengeGraderUrlService _graderUrlService; private readonly IJsonService _jsonService; private readonly ILockService _lockService; private readonly ILogger _logger; @@ -45,7 +44,6 @@ public GameResourcesDeployService ChallengeService challengeService, CoreOptions coreOptions, IGameEngineService gameEngineService, - IChallengeGraderUrlService graderUrlService, IJsonService jsonService, ILockService lockService, IMediator mediator, @@ -60,7 +58,6 @@ ITeamService teamService _challengeService = challengeService; _coreOptions = coreOptions; _gameEngineService = gameEngineService; - _graderUrlService = graderUrlService; _jsonService = jsonService; _lockService = lockService; _logger = logger; @@ -184,7 +181,6 @@ private async Task Variant = 0 }, captain.UserId, - _graderUrlService.BuildGraderUrl(), cancellationToken ); } diff --git a/src/Gameboard.Api/Features/Player/PlayerController.cs b/src/Gameboard.Api/Features/Player/PlayerController.cs index e4042c49..ac906b82 100644 --- a/src/Gameboard.Api/Features/Player/PlayerController.cs +++ b/src/Gameboard.Api/Features/Player/PlayerController.cs @@ -159,19 +159,6 @@ public async Task List([FromQuery] PlayerDataFilter model) return await PlayerService.List(model, await _permissionsService.Can(PermissionKey.Admin_View)); } - /// - /// Load active challenge data for a team. - /// - /// The id of the team who owns the challenges - /// An array of challenge entries. - [HttpGet("/api/team/{id}/challenges")] - [Authorize] - public async Task> GetTeamChallenges([FromRoute] string id) - { - await Authorize(_permissionsService.Can(PermissionKey.Teams_Observe)); - return await PlayerService.LoadChallengesForTeam(id); - } - /// /// Get a Game's Teams with Members /// diff --git a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs index 7c55edd3..e1339d02 100644 --- a/src/Gameboard.Api/Features/Reports/ReportsExportController.cs +++ b/src/Gameboard.Api/Features/Reports/ReportsExportController.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Tasks; using Gameboard.Api.Features.Challenges; -using Gameboard.Api.Reports; using Gameboard.Api.Structure; using MediatR; using Microsoft.AspNetCore.Authorization; diff --git a/src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatuses.cs b/src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatuses.cs new file mode 100644 index 00000000..879b42b5 --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatuses.cs @@ -0,0 +1,132 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Gameboard.Api.Common.Services; +using Gameboard.Api.Data; +using Gameboard.Api.Features.Users; +using Gameboard.Api.Structure.MediatR; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Api.Features.Teams; + +public record GetTeamChallengeSpecsStatusesQuery(string TeamId) : IRequest; + +internal sealed class GetTeamChallengeSpecsStatusesHandler +( + INowService now, + IStore store, + ITeamService teamService, + IValidatorService validatorService +) : IRequestHandler +{ + private readonly INowService _now = now; + private readonly IStore _store = store; + private readonly ITeamService _teamService = teamService; + private readonly IValidatorService _validator = validatorService; + + public async Task Handle(GetTeamChallengeSpecsStatusesQuery request, CancellationToken cancellationToken) + { + var team = await _teamService.GetTeam(request.TeamId) ?? throw new ResourceNotFound(request.TeamId); + var game = await _store + .WithNoTracking() + .Where(g => g.Id == team.GameId) + .Select(g => new SimpleEntity { Id = g.Id, Name = g.Name }) + .SingleOrDefaultAsync(cancellationToken); + + var userIds = team.Members.Select(p => p.UserId).ToArray(); + await _validator + .Auth(c => c.RequireOneOf(PermissionKey.Admin_View).UnlessUserIdIn(userIds)) + .Validate(cancellationToken); + + var challengeSpecs = await _store + .WithNoTracking() + .Where(s => s.GameId == team.GameId) + .Where(s => !s.IsHidden && !s.Disabled) + .Select(s => new + { + s.Id, + s.Name, + s.GameId, + GameName = s.Game.Name, + s.Points + }) + .ToArrayAsync(cancellationToken); + var specIds = challengeSpecs.Select(s => s.Id).ToArray(); + + var teamChallenges = await _store + .WithNoTracking() + .Where(c => c.TeamId == request.TeamId) + .Where(c => specIds.Contains(c.SpecId)) + .Select(c => new + { + c.Id, + c.GameId, + c.HasDeployedGamespace, + c.SpecId, + c.StartTime, + c.EndTime, + c.Points, + c.Score + }) + .ToDictionaryAsync + ( + c => c.SpecId, + c => c, + cancellationToken + ); + + return new GetTeamChallengeSpecsStatusesResponse + { + Game = game, + Team = new SimpleEntity { Id = team.TeamId, Name = team.ApprovedName }, + ChallengeSpecStatuses = challengeSpecs.Select(s => + { + teamChallenges.TryGetValue(s.Id, out var challenge); + + // they haven't started + if (challenge is null) + { + return new TeamChallengeSpecStatus + { + AvailabilityRange = null, + ChallengeId = null, + Score = null, + ScoreMax = s.Points, + Spec = new SimpleEntity { Id = s.Id, Name = s.Name }, + State = TeamChallengeSpecStatusState.NotStarted + }; + } + + return new TeamChallengeSpecStatus + { + AvailabilityRange = new DateRange(challenge.StartTime, challenge.EndTime), + ChallengeId = challenge.Id, + Score = challenge.Score, + ScoreMax = challenge.Points, + Spec = new SimpleEntity { Id = s.Id, Name = s.Name }, + State = ResolveStatus(challenge.HasDeployedGamespace, challenge.StartTime, challenge.EndTime) + }; + }) + .ToArray() + }; + } + + private TeamChallengeSpecStatusState ResolveStatus(bool isDeployed, DateTimeOffset startTime, DateTimeOffset endTime) + { + if (startTime.IsEmpty() && endTime.IsEmpty()) + { + return TeamChallengeSpecStatusState.NotStarted; + } + + var nowish = _now.Get(); + + if (endTime.IsNotEmpty() && nowish > endTime) + { + return TeamChallengeSpecStatusState.Ended; + } + + return isDeployed ? TeamChallengeSpecStatusState.Deployed : TeamChallengeSpecStatusState.NotDeployed; + } +} diff --git a/src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatusesModels.cs b/src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatusesModels.cs new file mode 100644 index 00000000..879a80e4 --- /dev/null +++ b/src/Gameboard.Api/Features/Teams/Requests/GetTeamChallenges/GetTeamChallengeSpecsStatusesModels.cs @@ -0,0 +1,26 @@ +namespace Gameboard.Api.Features.Teams; + +public sealed class GetTeamChallengeSpecsStatusesResponse +{ + public required SimpleEntity Team { get; set; } + public required SimpleEntity Game { get; set; } + public required TeamChallengeSpecStatus[] ChallengeSpecStatuses { get; set; } +} + +public enum TeamChallengeSpecStatusState +{ + NotStarted, + NotDeployed, + Deployed, + Ended +} + +public sealed class TeamChallengeSpecStatus +{ + public required DateRange AvailabilityRange { get; set; } + public required string ChallengeId { get; set; } + public required double? Score { get; set; } + public required double ScoreMax { get; set; } + public required SimpleEntity Spec { get; set; } + public required TeamChallengeSpecStatusState State { get; set; } +} diff --git a/src/Gameboard.Api/Features/Teams/TeamController.cs b/src/Gameboard.Api/Features/Teams/TeamController.cs index ae6bd319..e0e2c3a5 100644 --- a/src/Gameboard.Api/Features/Teams/TeamController.cs +++ b/src/Gameboard.Api/Features/Teams/TeamController.cs @@ -27,6 +27,10 @@ ITeamService teamService public Task AdvanceTeams([FromBody] AdvanceTeamsRequest request) => _mediator.Send(new AdvanceTeamsCommand(request.GameId, request.IncludeScores, request.TeamIds)); + [HttpGet("{teamId}/challenges")] + public Task GetChallengeSpecStatuses([FromRoute] string teamId, CancellationToken cancellationToken) + => _mediator.Send(new GetTeamChallengeSpecsStatusesQuery(teamId), cancellationToken); + [HttpDelete("{teamId}/players/{playerId}")] public Task RemovePlayer([FromRoute] string teamId, [FromRoute] string playerId) => _mediator.Send(new RemoveFromTeamCommand(playerId)); diff --git a/src/Gameboard.Api/Features/Teams/TeamExceptions.cs b/src/Gameboard.Api/Features/Teams/TeamExceptions.cs index 15ac6034..5a704caf 100644 --- a/src/Gameboard.Api/Features/Teams/TeamExceptions.cs +++ b/src/Gameboard.Api/Features/Teams/TeamExceptions.cs @@ -91,6 +91,12 @@ internal TeamIsFull(string invitingPlayerId, int teamSize, int maxTeamSize) : base($"Inviting player {invitingPlayerId} has {teamSize} players on their team, and the max team size for this game is {maxTeamSize}.") { } } +internal class TeamIsntPlayingGame : GameboardValidationException +{ + internal TeamIsntPlayingGame(SimpleEntity team, SimpleEntity game) + : base($"""Team {team.Name} isn't playing game {game.Name}.""") { } +} + internal class UserIsntOnTeam : GameboardValidationException { internal UserIsntOnTeam(string userId, string teamId, string message = null) diff --git a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs index cafd25a6..aaa3c41f 100644 --- a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs +++ b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsModels.cs @@ -24,6 +24,7 @@ public enum PermissionKey Support_ViewTickets, SystemNotifications_CreateEdit, Teams_ApproveNameChanges, + Teams_CreateEditDeleteChallenges, Teams_DeployGameResources, Teams_EditSession, Teams_Enroll, diff --git a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs index 0432551f..505b1a48 100644 --- a/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs +++ b/src/Gameboard.Api/Features/User/Permissions/UserRolePermissionsService.cs @@ -154,6 +154,13 @@ internal class UserRolePermissionsService(IActingUserService actingUserService, Description = "Approve name change requests for users and players" }, new() + { + Group = PermissionKeyGroup.Teams, + Key = PermissionKey.Teams_CreateEditDeleteChallenges, + Name = "Create/delete challenge instances", + Description = "Start and purge an instance of a challenge on behalf of any team" + }, + new() { Group = PermissionKeyGroup.Teams, Key = PermissionKey.Teams_DeployGameResources, From 97eca711ae9b9fd01d5ca177a7c557d759512bc8 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 30 Jan 2025 15:50:28 -0500 Subject: [PATCH 6/6] Fix broken test --- .../Features/Challenges/ChallengeControllerCreateTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs b/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs index fdb6e594..c4c9b72e 100644 --- a/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs +++ b/src/Gameboard.Api.Tests.Integration/Tests/Features/Challenges/ChallengeControllerCreateTests.cs @@ -33,6 +33,7 @@ await _testContext.WithDataState(state => g.Players = state.Build(fixture, p => { p.Id = playerId; + p.Role = PlayerRole.Manager; p.User = state.Build(fixture, u => u.Id = userId); p.SessionBegin = DateTimeOffset.UtcNow.AddDays(-1); p.SessionEnd = DateTimeOffset.UtcNow.AddDays(1); @@ -51,7 +52,7 @@ await _testContext.WithDataState(state => // act var challenge = await _testContext .CreateHttpClientWithActingUser(u => u.Id = userId) - .PostAsync("/api/challenge", model.ToJsonBody()) + .PostAsync("/api/challenge/launch", model.ToJsonBody()) .DeserializeResponseAs(); // assert