Skip to content

Commit

Permalink
v3.23.2 (#514)
Browse files Browse the repository at this point in the history
* Fixed an issue that caused user roles and login info to fail to appear on the user list page in admin.

* Fixed an issue that could cause Gameboard to fail to align challenge end time with engine expiration.

* WIP

* Minor cleanup

* Fixed a bug that could cause incorrect resolution of user team membership

* Update topo package version

* Bug fixes and new tags filter for the challenges report

* Add unstarted teams/no launched challenge teams to enrollment report summary

* Game center teams sort fix, score progress widget fix, cleanup

* Fixed bugs: game level feedback, add tags to practice area report, hide invisible tags

* Fix compile error
  • Loading branch information
sei-bstein authored Oct 16, 2024
1 parent f4c70c0 commit 4772a4f
Show file tree
Hide file tree
Showing 9 changed files with 81 additions and 52 deletions.
1 change: 1 addition & 0 deletions src/Gameboard.Api/Common/CommonModels.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Text.RegularExpressions;

namespace Gameboard.Api.Common;

Expand Down
4 changes: 1 addition & 3 deletions src/Gameboard.Api/Data/GameboardDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@

namespace Gameboard.Api.Data;

public class GameboardDbContext : DbContext
public class GameboardDbContext(DbContextOptions options) : DbContext(options)
{
public GameboardDbContext(DbContextOptions options) : base(options) { }

protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
Expand Down
10 changes: 5 additions & 5 deletions src/Gameboard.Api/Features/Feedback/FeedbackService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public async Task<Feedback> Submit(FeedbackSubmission model, string actorId)
{
var valid = await FeedbackMatchesTemplate(model.Questions, model.GameId, model.ChallengeId);
if (!valid)
throw new InvalideFeedbackFormat();
throw new InvalidFeedbackFormat();
}

if (entity is not null)
Expand Down Expand Up @@ -293,7 +293,7 @@ private async Task<bool> FeedbackMatchesTemplate(QuestionSubmission[] feedback,
var feedbackTemplate = GetTemplate(challengeId.IsEmpty(), game);

if (feedbackTemplate.Length != feedback.Length)
throw new InvalideFeedbackFormat();
throw new InvalidFeedbackFormat();

var templateMap = new Dictionary<string, QuestionTemplate>();
foreach (QuestionTemplate q in feedbackTemplate) { templateMap.Add(q.Id, q); }
Expand All @@ -302,19 +302,19 @@ private async Task<bool> FeedbackMatchesTemplate(QuestionSubmission[] feedback,
{
var template = templateMap.GetValueOrDefault(q.Id, null);
if (template == null) // user submitted id that isn't in game template
throw new InvalideFeedbackFormat();
throw new InvalidFeedbackFormat();
if (template.Required && q.Answer.IsEmpty()) // requirement config is not met
throw new MissingRequiredField();
if (q.Answer.IsEmpty()) // don't validate answer is null/empty, if not required
continue;
if (template.Type == "text" && q.Answer.Length > 2000) // universal character limit per text question
throw new InvalideFeedbackFormat();
throw new InvalidFeedbackFormat();
if (template.Type == "likert") // because all likert options are ints, parse and check range with max config
{
int answerInt;
bool isInt = Int32.TryParse(q.Answer, out answerInt);
if (!isInt || answerInt < template.Min || answerInt > template.Max) // parsing failed or outside of range
throw new InvalideFeedbackFormat();
throw new InvalidFeedbackFormat();
}
}
return true;
Expand Down
24 changes: 9 additions & 15 deletions src/Gameboard.Api/Features/Feedback/FeedbackValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@
// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information.

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Gameboard.Api.Data;
using Microsoft.EntityFrameworkCore;

namespace Gameboard.Api.Validators
{
Expand All @@ -28,29 +26,25 @@ private async Task _validate(FeedbackSubmission model)
throw new ResourceNotFound<Data.Game>(model.GameId);

if (model.ChallengeId.IsEmpty() != model.ChallengeSpecId.IsEmpty()) // must specify both or neither
throw new InvalideFeedbackFormat();
throw new InvalidFeedbackFormat();

// if not blank, must exist for challenge and challenge spec
if (model.ChallengeSpecId.IsEmpty())
throw new ArgumentException("ChallengeSpecId is required");

if (!await _store.AnyAsync<Data.ChallengeSpec>(s => s.Id == model.ChallengeSpecId, CancellationToken.None))
throw new ResourceNotFound<ChallengeSpec>(model.ChallengeSpecId);

if (!await _store.AnyAsync<Data.Challenge>(c => c.Id == model.ChallengeId, CancellationToken.None))
if (model.ChallengeId.IsNotEmpty() && !await _store.AnyAsync<Data.Challenge>(c => c.Id == model.ChallengeId, CancellationToken.None))
{
throw new ResourceNotFound<Challenge>(model.ChallengeSpecId);
}

// if specified, this is a challenge-specific feedback response, so validate challenge/spec/game match
if (model.ChallengeSpecId.NotEmpty())
if (model.ChallengeSpecId.IsNotEmpty())
{
if (!await _store.AnyAsync<Data.ChallengeSpec>(s => s.Id == model.ChallengeSpecId, CancellationToken.None))
throw new ResourceNotFound<ChallengeSpec>(model.ChallengeSpecId);

// if specified, this is a challenge-specific feedback response, so validate challenge/spec/game match
if (!await _store.AnyAsync<Data.ChallengeSpec>(s => s.Id == model.ChallengeSpecId && s.GameId == model.GameId, CancellationToken.None))
throw new ActionForbidden();

if (!await _store.AnyAsync<Data.Challenge>(c => c.Id == model.ChallengeId && c.SpecId == model.ChallengeSpecId, CancellationToken.None))
throw new ActionForbidden();
}

await Task.CompletedTask;
}
}
}
55 changes: 33 additions & 22 deletions src/Gameboard.Api/Features/Practice/PracticeService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
Expand All @@ -19,6 +20,8 @@ public interface IPracticeService
Task<DateTimeOffset> GetExtendedSessionEnd(DateTimeOffset currentSessionBegin, DateTimeOffset currentSessionEnd, CancellationToken cancellationToken);
Task<PracticeModeSettingsApiModel> GetSettings(CancellationToken cancellationToken);
Task<Data.Player> GetUserActivePracticeSession(string userId, CancellationToken cancellationToken);
Task<IEnumerable<string>> GetVisibleChallengeTags(CancellationToken cancellationToken);
Task<IEnumerable<string>> GetVisibleChallengeTags(IEnumerable<string> requestedTags, CancellationToken cancellationToken);
IEnumerable<string> UnescapeSuggestedSearches(string input);
}

Expand All @@ -29,38 +32,34 @@ public enum CanPlayPracticeChallengeResult
Yes
}

internal class PracticeService : IPracticeService
internal partial class PracticeService
(
IMapper mapper,
INowService now,
ISlugService slugService,
IStore store
) : IPracticeService
{
private readonly IMapper _mapper;
private readonly INowService _now;
private readonly IStore _store;

public PracticeService
(
IMapper mapper,
INowService now,
IStore store
)
{
_mapper = mapper;
_now = now;
_store = store;
}
private readonly IMapper _mapper = mapper;
private readonly INowService _now = now;
private readonly ISlugService _slugService = slugService;
private readonly IStore _store = store;

// To avoid needing a table that literally just displays a list of strings, we store the list of suggested searches as a
// newline-delimited string in the PracticeModeSettings table (which has only one record).
public string EscapeSuggestedSearches(IEnumerable<string> input)
=> string.Join(Environment.NewLine, input.Select(search => search.Trim()));
=> string.Join(Environment.NewLine, input.Select(search => _slugService.Get(search.Trim())));

// same deal here - split on newline
public IEnumerable<string> UnescapeSuggestedSearches(string input)
{
if (input.IsEmpty())
return Array.Empty<string>();
return [];

return input
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
.Select(search => search.Trim())
return Regex
.Split(input, @"/s+", RegexOptions.Multiline)
.Select(m => m.Trim().ToLower())
.Where(m => m.IsNotEmpty())
.ToArray();
}

Expand Down Expand Up @@ -121,7 +120,7 @@ public async Task<PracticeModeSettingsApiModel> GetSettings(CancellationToken ca
CertificateHtmlTemplate = null,
DefaultPracticeSessionLengthMinutes = 60,
IntroTextMarkdown = null,
SuggestedSearches = Array.Empty<string>()
SuggestedSearches = []
};
}

Expand All @@ -131,6 +130,18 @@ public async Task<PracticeModeSettingsApiModel> GetSettings(CancellationToken ca
return apiModel;
}

public async Task<IEnumerable<string>> GetVisibleChallengeTags(CancellationToken cancellationToken)
{
var settings = await GetSettings(cancellationToken);
return settings.SuggestedSearches;
}

public async Task<IEnumerable<string>> GetVisibleChallengeTags(IEnumerable<string> requestedTags, CancellationToken cancellationToken)
{
var settings = await GetSettings(cancellationToken);
return requestedTags.Select(t => t.ToLower()).Intersect(settings.SuggestedSearches).ToArray();
}

private async Task<IEnumerable<string>> GetActiveSessionUsers()
=> await GetActivePracticeSessionsQueryBase()
.Select(p => p.UserId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
using System.Threading.Tasks;
using Gameboard.Api.Data;
using Gameboard.Api.Features.Challenges;
using Gameboard.Api.Features.Practice;
using Microsoft.EntityFrameworkCore;
using ServiceStack;

namespace Gameboard.Api.Features.Reports;

Expand All @@ -17,8 +17,9 @@ public interface IChallengesReportService
ChallengesReportStatSummary GetStatSummary(IEnumerable<ChallengesReportRecord> records);
}

internal class ChallengesReportService(IReportsService reportsService, IStore store) : IChallengesReportService
internal class ChallengesReportService(IPracticeService practiceService, IReportsService reportsService, IStore store) : IChallengesReportService
{
private readonly IPracticeService _practiceService = practiceService;
private readonly IReportsService _reportsService = reportsService;
private readonly IStore _store = store;

Expand Down Expand Up @@ -135,9 +136,14 @@ public async Task<IEnumerable<ChallengesReportRecord>> GetRawResults(ChallengesR
.Count()
}, cancellationToken);

// we currently restrict tags we show on challenges (to avoid polluting the UI with internal tags).
// the non-awesome part of this is that we do it using the practice settings, because that's where we needed it first
var visibleTags = await _practiceService.GetVisibleChallengeTags(specs.SelectMany(s => s.Tags), cancellationToken);

var preSortResults = specs.Select(cs =>
{
var aggregations = specAggregations.ContainsKey(cs.Id) ? specAggregations[cs.Id] : null;
var tags = (cs.Tags.IsEmpty() ? [] : cs.Tags).Where(visibleTags.Contains).ToArray();
return new ChallengesReportRecord
{
Expand All @@ -153,7 +159,7 @@ public async Task<IEnumerable<ChallengesReportRecord>> GetRawResults(ChallengesR
},
PlayerModeCurrent = cs.PlayerModeCurrent,
Points = cs.Points,
Tags = cs.Tags.IsNotEmpty() ? cs.Tags : [],
Tags = tags,
AvgCompleteSolveTimeMs = aggregations?.AvgCompleteSolveTimeMs,
AvgScore = aggregations?.AvgScore,
DeployCompetitiveCount = aggregations is not null ? aggregations.DeployCompetitiveCount : 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public sealed class PracticeModeByChallengeReportRecord : IPracticeModeReportRec
public required double MaxPossibleScore { get; set; }
public required double AvgScore { get; set; }
public required string Description { get; set; }
public required IEnumerable<string> Tags { get; set; }
public required string Text { get; set; }
public required IEnumerable<ReportSponsorViewModel> SponsorsPlayed { get; set; }
public required PracticeModeReportByChallengePerformance OverallPerformance { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Gameboard.Api.Data;
using Gameboard.Api.Features.Games;
using Gameboard.Api.Features.Practice;
using Microsoft.EntityFrameworkCore;
using ServiceStack;

namespace Gameboard.Api.Features.Reports;

Expand All @@ -19,13 +22,14 @@ public interface IPracticeModeReportService
Task<PracticeModeReportPlayerModeSummary> GetPlayerModePerformanceSummary(string userId, bool isPractice, CancellationToken cancellationToken);
}

internal class PracticeModeReportService : IPracticeModeReportService
internal partial class PracticeModeReportService : IPracticeModeReportService
{
private readonly IPracticeService _practiceService;
private readonly IReportsService _reportsService;
private readonly IStore _store;

public PracticeModeReportService(IReportsService reportsService, IStore store)
=> (_reportsService, _store) = (reportsService, store);
public PracticeModeReportService(IPracticeService practiceService, IReportsService reportsService, IStore store)
=> (_practiceService, _reportsService, _store) = (practiceService, reportsService, store);

private sealed class PracticeModeReportUngroupedResults
{
Expand Down Expand Up @@ -189,6 +193,9 @@ public async Task<PracticeModeReportResults> GetResultsByChallenge(PracticeModeR
// the "false" argument here excludes competitive records (this grouping only looks at practice challenges)
var ungroupedResults = await BuildUngroupedResults(parameters, false, cancellationToken);

// get tags that we want to display
var visibleTags = await _practiceService.GetVisibleChallengeTags(cancellationToken);

var records = ungroupedResults
.Challenges
.GroupBy(c => c.SpecId)
Expand All @@ -212,6 +219,16 @@ public async Task<PracticeModeReportResults> GetResultsByChallenge(PracticeModeR
Performance = BuildChallengePerformance(attempts, s)
});
var tags = Array.Empty<string>();
if (spec.Tags.IsNotEmpty())
{
var specTags = Regex
.Split(spec.Tags, @"\s+", RegexOptions.Multiline)
.Select(m => m.Trim().ToLower())
.ToArray();
tags = visibleTags.Intersect(specTags).ToArray();
}
return new PracticeModeByChallengeReportRecord
{
Id = spec.Id,
Expand All @@ -228,6 +245,7 @@ public async Task<PracticeModeReportResults> GetResultsByChallenge(PracticeModeR
MaxPossibleScore = spec.Points,
AvgScore = attempts.Select(a => a.Score).Average(),
Description = spec.Description,
Tags = tags,
Text = spec.Text,
SponsorsPlayed = sponsorsPlayed,
OverallPerformance = performanceOverall,
Expand Down
2 changes: 1 addition & 1 deletion src/Gameboard.Api/Structure/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public class InvalidConsoleAction : Exception { }
public class AlreadyExists : Exception { }
public class ChallengeLocked : Exception { }
public class ChallengeStartPending : Exception { }
public class InvalideFeedbackFormat : Exception { }
public class InvalidFeedbackFormat : Exception { }
public class PlayerIsntInGame : Exception { }
public class InvalidPlayerMode : Exception { }
public class MissingRequiredField : Exception { }
Expand Down

0 comments on commit 4772a4f

Please sign in to comment.