Update to dotnet 9.0, add user roles, MatchStats and OpenApi/Scalar in dev
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Interface
|
||||
{
|
||||
public interface IStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregate the persisted sets of the given Events into per-player records,
|
||||
/// head-to-head records and a coverage report.
|
||||
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
|
||||
/// </summary>
|
||||
Task<MatchStats> GetMatchStats(List<int> eventIds);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface
|
||||
ApplicationUser Authenticate(string username, string password);
|
||||
IEnumerable<ApplicationUser> GetAll();
|
||||
ApplicationUser GetById(int id);
|
||||
ApplicationUser Create(ApplicationUser user, string password);
|
||||
ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null);
|
||||
void Update(ApplicationUser user, string password = null);
|
||||
void Delete(int id);
|
||||
|
||||
/// <summary>The roles that exist in the database — reference data, not created at runtime.</summary>
|
||||
IEnumerable<ApplicationRole> GetAllRoles();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<AssemblyName>LaDOSE.Business</AssemblyName>
|
||||
<RootNamespace>LaDOSE.Business</RootNamespace>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
using LaDOSE.Entity.Context;
|
||||
|
||||
namespace LaDOSE.Business.Service
|
||||
{
|
||||
/// <summary>
|
||||
/// Match statistics over the persisted Set rows.
|
||||
///
|
||||
/// Two things to know about the data:
|
||||
/// - There is no Set -> Player relationship in the EF model (the navigation properties
|
||||
/// and their configuration are commented out), so player names are resolved with a
|
||||
/// separate query plus an in-memory dictionary. Nothing here Includes a player.
|
||||
/// - There is no winner column. The winner is inferred from the scores, and start.gg
|
||||
/// encodes a DQ as -1, so games are clamped at 0.
|
||||
///
|
||||
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
||||
/// issues one query per table and hands the loaded lists to the pure static
|
||||
/// <see cref="Aggregate"/>, which is unit-testable without a database.
|
||||
/// </summary>
|
||||
public class StatisticsService : IStatisticsService
|
||||
{
|
||||
protected LaDOSEDbContext _context;
|
||||
|
||||
public StatisticsService(LaDOSEDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<MatchStats> GetMatchStats(List<int> eventIds)
|
||||
{
|
||||
var requested = (eventIds ?? new List<int>()).Distinct().ToList();
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
// Well-formed and empty, never null and never a 500.
|
||||
return Task.FromResult(new MatchStats());
|
||||
}
|
||||
|
||||
// One query per table. No query per set and no query per player.
|
||||
var events = _context.Event
|
||||
.Where(e => requested.Contains(e.Id))
|
||||
.ToList();
|
||||
|
||||
var existingEventIds = events.Select(e => e.Id).ToList();
|
||||
|
||||
var tournaments = existingEventIds.Count == 0
|
||||
? new List<Tournament>()
|
||||
: _context.Tournament
|
||||
.Where(t => existingEventIds.Contains(t.EventId))
|
||||
.ToList();
|
||||
|
||||
var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList();
|
||||
|
||||
// _context.Set is the DbSet<Set> property, not DbContext.Set<T>().
|
||||
var sets = tournamentIds.Count == 0
|
||||
? new List<Set>()
|
||||
: _context.Set
|
||||
.Where(s => tournamentIds.Contains(s.TournamentId))
|
||||
.ToList();
|
||||
|
||||
var playerIds = sets.Select(s => s.Player1Id)
|
||||
.Concat(sets.Select(s => s.Player2Id))
|
||||
.Where(id => id != 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var players = playerIds.Count == 0
|
||||
? new List<Player>()
|
||||
: _context.Player
|
||||
.Where(p => playerIds.Contains(p.Id))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
||||
/// </summary>
|
||||
/// <param name="requestedEventIds">The event ids the caller asked for.</param>
|
||||
/// <param name="events">Events that exist (used for the coverage count).</param>
|
||||
/// <param name="tournaments">Candidate tournaments; filtered here on EventId.</param>
|
||||
/// <param name="sets">Candidate sets; filtered here on TournamentId.</param>
|
||||
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||
public static MatchStats Aggregate(
|
||||
IEnumerable<int> requestedEventIds,
|
||||
IEnumerable<Event> events,
|
||||
IEnumerable<Tournament> tournaments,
|
||||
IEnumerable<Set> sets,
|
||||
IEnumerable<Player> players)
|
||||
{
|
||||
var result = new MatchStats();
|
||||
|
||||
var requested = new HashSet<int>((requestedEventIds ?? Enumerable.Empty<int>()));
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Coverage: events -------------------------------------------------------
|
||||
var existingEventIds = new HashSet<int>((events ?? Enumerable.Empty<Event>())
|
||||
.Where(e => e != null && requested.Contains(e.Id))
|
||||
.Select(e => e.Id));
|
||||
result.Coverage.Events = existingEventIds.Count;
|
||||
|
||||
// Coverage: brackets ----------------------------------------------------
|
||||
var scopedTournamentIds = new HashSet<int>((tournaments ?? Enumerable.Empty<Tournament>())
|
||||
.Where(t => t != null && existingEventIds.Contains(t.EventId))
|
||||
.Select(t => t.Id));
|
||||
result.Coverage.Brackets = scopedTournamentIds.Count;
|
||||
|
||||
var scopedSets = (sets ?? Enumerable.Empty<Set>())
|
||||
.Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId))
|
||||
.ToList();
|
||||
result.Coverage.Sets = scopedSets.Count;
|
||||
result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count();
|
||||
|
||||
// Name resolution -------------------------------------------------------
|
||||
var nameById = new Dictionary<int, string>();
|
||||
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||
{
|
||||
nameById[player.Id] = DisplayName(player);
|
||||
}
|
||||
|
||||
var stats = new Dictionary<int, PlayerMatchStats>();
|
||||
var h2h = new Dictionary<(int, int), HeadToHead>();
|
||||
|
||||
foreach (var set in scopedSets)
|
||||
{
|
||||
// Unusable rows still count in Coverage.Sets but nowhere else.
|
||||
if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
|
||||
if (set.Player1Score == set.Player2Score)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Coverage.DecidedSets++;
|
||||
|
||||
var p1 = set.Player1Id;
|
||||
var p2 = set.Player2Id;
|
||||
|
||||
// A DQ is stored as -1. Clamp so it never produces negative games.
|
||||
var games1 = Math.Max(0, set.Player1Score);
|
||||
var games2 = Math.Max(0, set.Player2Score);
|
||||
|
||||
var stat1 = GetOrAdd(stats, p1, nameById);
|
||||
var stat2 = GetOrAdd(stats, p2, nameById);
|
||||
|
||||
stat1.GamesWon += games1;
|
||||
stat1.GamesLost += games2;
|
||||
stat2.GamesWon += games2;
|
||||
stat2.GamesLost += games1;
|
||||
|
||||
var winnerId = set.Player1Score > set.Player2Score ? p1 : p2;
|
||||
if (winnerId == p1)
|
||||
{
|
||||
stat1.Wins++;
|
||||
stat2.Losses++;
|
||||
}
|
||||
else
|
||||
{
|
||||
stat2.Wins++;
|
||||
stat1.Losses++;
|
||||
}
|
||||
|
||||
// Lower PlayerId is always A, so WinsA / WinsB are unambiguous.
|
||||
var aId = Math.Min(p1, p2);
|
||||
var bId = Math.Max(p1, p2);
|
||||
var key = (aId, bId);
|
||||
if (!h2h.TryGetValue(key, out var pair))
|
||||
{
|
||||
pair = new HeadToHead
|
||||
{
|
||||
PlayerAId = aId,
|
||||
PlayerA = ResolveName(aId, nameById),
|
||||
PlayerBId = bId,
|
||||
PlayerB = ResolveName(bId, nameById)
|
||||
};
|
||||
h2h[key] = pair;
|
||||
}
|
||||
|
||||
if (winnerId == aId)
|
||||
{
|
||||
pair.WinsA++;
|
||||
}
|
||||
else
|
||||
{
|
||||
pair.WinsB++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sets is derived, so Sets == Wins + Losses cannot drift.
|
||||
foreach (var stat in stats.Values)
|
||||
{
|
||||
stat.Sets = stat.Wins + stat.Losses;
|
||||
Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses");
|
||||
}
|
||||
|
||||
result.Players = stats.Values
|
||||
.OrderByDescending(p => p.Wins)
|
||||
.ThenByDescending(p => p.Sets)
|
||||
.ThenBy(p => p.Player, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.PlayerId)
|
||||
.ToList();
|
||||
|
||||
// Not capped: bounded by the number of sets in scope.
|
||||
result.HeadToHead = h2h.Values
|
||||
.OrderByDescending(p => p.WinsA + p.WinsB)
|
||||
.ThenBy(p => p.PlayerA, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.PlayerB, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.PlayerAId)
|
||||
.ThenBy(p => p.PlayerBId)
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
|
||||
Dictionary<int, string> nameById)
|
||||
{
|
||||
if (!stats.TryGetValue(playerId, out var stat))
|
||||
{
|
||||
stat = new PlayerMatchStats
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Player = ResolveName(playerId, nameById)
|
||||
};
|
||||
stats[playerId] = stat;
|
||||
}
|
||||
|
||||
return stat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gamertag, falling back to Name, else "#<id>" (a set can reference a player row
|
||||
/// that no longer exists, and Gamertag is nullable in the database).
|
||||
/// </summary>
|
||||
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
|
||||
{
|
||||
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
|
||||
}
|
||||
|
||||
private static string DisplayName(Player player)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(player.Gamertag))
|
||||
{
|
||||
return player.Gamertag;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(player.Name))
|
||||
{
|
||||
return player.Name;
|
||||
}
|
||||
|
||||
return $"#{player.Id}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
using LaDOSE.Entity.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LaDOSE.Business.Service
|
||||
{
|
||||
@@ -20,8 +21,9 @@ namespace LaDOSE.Business.Service
|
||||
{
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
||||
return null;
|
||||
var p = _context.ApplicationUser.ToList();
|
||||
var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username);
|
||||
var user = _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.SingleOrDefault(x => x.Username == username);
|
||||
|
||||
// check if username exists
|
||||
if (user == null)
|
||||
@@ -37,23 +39,44 @@ namespace LaDOSE.Business.Service
|
||||
|
||||
public IEnumerable<ApplicationUser> GetAll()
|
||||
{
|
||||
return _context.ApplicationUser;
|
||||
return _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roles are included because authorization reads them on every authenticated
|
||||
/// request (see the OnTokenValidated handler in Startup).
|
||||
/// </summary>
|
||||
public ApplicationUser GetById(int id)
|
||||
{
|
||||
return _context.ApplicationUser.Find(id);
|
||||
return _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.SingleOrDefault(x => x.Id == id);
|
||||
}
|
||||
|
||||
public ApplicationUser Create(ApplicationUser user, string password)
|
||||
public IEnumerable<ApplicationRole> GetAllRoles()
|
||||
{
|
||||
return _context.ApplicationRole.OrderBy(x => x.Name).ToList();
|
||||
}
|
||||
|
||||
public ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null)
|
||||
{
|
||||
// validation
|
||||
if (string.IsNullOrWhiteSpace(user?.Username))
|
||||
throw new Exception("Username is required");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new Exception("Password is required");
|
||||
|
||||
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
|
||||
throw new Exception("Username \"" + user.Username + "\" is already taken");
|
||||
|
||||
// EF fills userid/roleid on the join rows from these navigations when it saves.
|
||||
user.UserRoles = ResolveRoles(roleNames)
|
||||
.Select(role => new ApplicationUserRole { Role = role })
|
||||
.ToList();
|
||||
|
||||
byte[] passwordHash, passwordSalt;
|
||||
CreatePasswordHash(password, out passwordHash, out passwordSalt);
|
||||
|
||||
@@ -66,6 +89,35 @@ namespace LaDOSE.Business.Service
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns role names into the existing rows of <c>applicationrole</c>. An unknown
|
||||
/// name is an error rather than a new role, so a typo cannot quietly produce an
|
||||
/// account with no privileges — or, worse, a second spelling of "Admin".
|
||||
/// </summary>
|
||||
private List<ApplicationRole> ResolveRoles(IEnumerable<string> roleNames)
|
||||
{
|
||||
var wanted = (roleNames ?? Enumerable.Empty<string>())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (wanted.Count == 0)
|
||||
return new List<ApplicationRole>();
|
||||
|
||||
var known = _context.ApplicationRole.ToList();
|
||||
var resolved = new List<ApplicationRole>();
|
||||
foreach (var name in wanted)
|
||||
{
|
||||
var role = known.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (role == null)
|
||||
throw new Exception($"Unknown role \"{name}\"");
|
||||
resolved.Add(role);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public void Update(ApplicationUser userParam, string password = null)
|
||||
{
|
||||
var user = _context.ApplicationUser.Find(userParam.Id);
|
||||
@@ -101,12 +153,18 @@ namespace LaDOSE.Business.Service
|
||||
|
||||
public void Delete(int id)
|
||||
{
|
||||
var user = _context.ApplicationUser.Find(id);
|
||||
if (user != null)
|
||||
{
|
||||
_context.ApplicationUser.Remove(user);
|
||||
_context.SaveChanges();
|
||||
}
|
||||
// applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows
|
||||
// have to go first — clearing the collection makes EF delete them.
|
||||
var user = _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.SingleOrDefault(x => x.Id == id);
|
||||
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.UserRoles?.Clear();
|
||||
_context.ApplicationUser.Remove(user);
|
||||
_context.SaveChanges();
|
||||
}
|
||||
|
||||
// private helper methods
|
||||
|
||||
Reference in New Issue
Block a user