This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutoMapper;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.DTO;
|
||||
using LaDOSE.Entity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LaDOSE.Api.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes ranking tables into the club's Google Spreadsheet.
|
||||
///
|
||||
/// The tables arrive already built: the browser reuses the same buildRanking() that produces
|
||||
/// the CSV export, so the sheet and the CSV cannot drift. This controller resolves the target
|
||||
/// from configuration, validates, and relays — see SheetsExportService.
|
||||
///
|
||||
/// Returns IActionResult with explicit ProducesResponseType, like UsersController: the API has
|
||||
/// no exception middleware, so an escaping throw reaches the browser as an HTML developer page
|
||||
/// that the client can only report as a bare status.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
[Route("api/[controller]")]
|
||||
public class SheetsController : ControllerBase
|
||||
{
|
||||
private readonly ISheetsExportService _service;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<SheetsController> _logger;
|
||||
|
||||
public SheetsController(IMapper mapper, ISheetsExportService service, ILogger<SheetsController> logger)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the export is usable and where it points, so the panel can render itself and
|
||||
/// disable the button when the server is not set up. Carries no secret and no key path.
|
||||
/// </summary>
|
||||
[HttpGet("Config")]
|
||||
[ProducesResponseType(typeof(SheetsConfigDTO), StatusCodes.Status200OK)]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
return Ok(_mapper.Map<SheetsConfigDTO>(_service.GetConfig()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one tab per table, in the order given — oldest event first, each tab holding the
|
||||
/// cumulative result up to and including its own event.
|
||||
///
|
||||
/// Existing tabs of the same name are cleared and rewritten in place, keeping their
|
||||
/// formatting; anything typed into them by hand is lost. Tabs not named in the request are
|
||||
/// never touched or deleted.
|
||||
/// </summary>
|
||||
[HttpPost("Export")]
|
||||
[ProducesResponseType(typeof(SheetExportResultDTO), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IActionResult> Export([FromBody] SheetExportRequestDTO dto, CancellationToken ct)
|
||||
{
|
||||
if (dto?.Tabs == null || dto.Tabs.Count == 0)
|
||||
return BadRequest(new { message = "No table to write." });
|
||||
|
||||
var request = _mapper.Map<SheetExportRequest>(dto);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _service.ExportAsync(request, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sheets export by user {User}: {TabCount} tabs to spreadsheet {SpreadsheetId} via {Writer}",
|
||||
User?.Identity?.Name, result.Tabs?.Count ?? 0, result.SpreadsheetId, result.Writer);
|
||||
|
||||
return Ok(_mapper.Map<SheetExportResultDTO>(result));
|
||||
}
|
||||
catch (SheetsExportException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Sheets export refused ({StatusCode})", ex.StatusCode);
|
||||
return StatusCode(ex.StatusCode, new { message = ex.Message });
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The caller navigated away or hit cancel; nothing to report to them.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Anything unforeseen still has to arrive as JSON with a message.
|
||||
_logger.LogError(ex, "Sheets export failed");
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,29 @@ namespace LaDOSE.Api.Controllers
|
||||
var stats = await _service.GetMatchStats(ids);
|
||||
return _mapper.Map<MatchStatsDTO>(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The players a versus lookup can report on: everyone with at least one set in a
|
||||
/// bracket whose game is known, by display name. Not the application users —
|
||||
/// these are tournament players, the entities set rows point at.
|
||||
/// </summary>
|
||||
[HttpGet("Players")]
|
||||
public async Task<List<PlayerOptionDTO>> GetVersusPlayers()
|
||||
{
|
||||
var players = await _service.GetVersusPlayers();
|
||||
return _mapper.Map<List<PlayerOptionDTO>>(players);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every recorded meeting between two players, across all events, split per game.
|
||||
/// Sets played in a bracket with no game attached are excluded and only counted in
|
||||
/// UnknownGameSets. Two identical or unknown ids return an empty breakdown, not an error.
|
||||
/// </summary>
|
||||
[HttpGet("Versus/{playerAId}/{playerBId}")]
|
||||
public async Task<PlayerVersusDTO> GetVersus(int playerAId, int playerBId)
|
||||
{
|
||||
var versus = await _service.GetVersus(playerAId, playerBId);
|
||||
return _mapper.Map<PlayerVersusDTO>(versus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ using AutoMapper;
|
||||
using LaDOSE.Api.Helpers;
|
||||
using LaDOSE.Business.Helper;
|
||||
using LaDOSE.Business.Provider.ChallongProvider;
|
||||
using LaDOSE.Business.Provider.SheetsProvider;
|
||||
using LaDOSE.Business.Provider.SmashProvider;
|
||||
using LaDOSE.Entity.Challonge;
|
||||
using LaDOSE.Entity.Wordpress;
|
||||
@@ -69,7 +70,11 @@ namespace LaDOSE.Api
|
||||
}).AddNewtonsoftJson(x =>
|
||||
{
|
||||
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
|
||||
x.SerializerSettings.MaxDepth= 4;
|
||||
// MaxDepth governs *reading*, so it caps how deep an inbound body may nest.
|
||||
// ReferenceLoopHandling above is what tames the outbound Entity graph.
|
||||
// The Sheets export body is 7 deep (root > tabs > tab > rows > row > points >
|
||||
// number), so the previous value of 4 rejected it outright.
|
||||
x.SerializerSettings.MaxDepth = 32;
|
||||
});
|
||||
#if DEBUG
|
||||
services.AddOpenApi();
|
||||
@@ -162,6 +167,19 @@ namespace LaDOSE.Api
|
||||
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
|
||||
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
|
||||
cfg.CreateMap<HeadToHead, LaDOSE.DTO.HeadToHeadDTO>();
|
||||
cfg.CreateMap<PlayerVersus, LaDOSE.DTO.PlayerVersusDTO>();
|
||||
cfg.CreateMap<VersusGameStats, LaDOSE.DTO.VersusGameStatsDTO>();
|
||||
cfg.CreateMap<PlayerOption, LaDOSE.DTO.PlayerOptionDTO>();
|
||||
|
||||
// Sheets export. Two-way: the request arrives as a DTO and has to become a POCO,
|
||||
// which plain CreateMap does not give. SheetExportRequest.SpreadsheetId has no DTO
|
||||
// counterpart on purpose — SheetsExportService fills it from configuration.
|
||||
cfg.CreateMapTwoWay<SheetExportRequest, LaDOSE.DTO.SheetExportRequestDTO>();
|
||||
cfg.CreateMapTwoWay<SheetTable, LaDOSE.DTO.SheetTableDTO>();
|
||||
cfg.CreateMapTwoWay<SheetRow, LaDOSE.DTO.SheetRowDTO>();
|
||||
cfg.CreateMap<SheetExportResult, LaDOSE.DTO.SheetExportResultDTO>();
|
||||
cfg.CreateMap<SheetTabResult, LaDOSE.DTO.SheetTabResultDTO>();
|
||||
cfg.CreateMap<SheetsConfig, LaDOSE.DTO.SheetsConfigDTO>();
|
||||
|
||||
});
|
||||
IMapper mapper = mapperConfig.CreateMapper();
|
||||
@@ -194,6 +212,45 @@ namespace LaDOSE.Api
|
||||
this.Configuration["ApiKey:SmashApiKey"]));
|
||||
services.AddScoped<IExternalProviderService, ExternalProviderService>();
|
||||
|
||||
#region Google Sheets export
|
||||
|
||||
// Limits and the target spreadsheet. The spreadsheet is reset every year, so it lives
|
||||
// in configuration rather than in code — see .env.example.
|
||||
services.AddSingleton(new SheetsSettings
|
||||
{
|
||||
SpreadsheetId = this.Configuration["GoogleSheets:SpreadsheetId"],
|
||||
MaxTabs = ReadInt("GoogleSheets:MaxTabs", 60),
|
||||
MaxRowsPerTab = ReadInt("GoogleSheets:MaxRowsPerTab", 5000),
|
||||
MaxColumns = ReadInt("GoogleSheets:MaxColumns", 200)
|
||||
});
|
||||
|
||||
// One writer, chosen by configuration. "Logging" exercises the whole feature without
|
||||
// touching Google; anything unrecognised disables the export with a message rather
|
||||
// than a null reference.
|
||||
services.AddScoped<ISheetsWriter>(p =>
|
||||
{
|
||||
switch (this.Configuration["GoogleSheets:Writer"])
|
||||
{
|
||||
case "ServiceAccount":
|
||||
return new GoogleApiSheetsWriter(
|
||||
this.Configuration["GoogleSheets:ServiceAccount:CredentialsPath"]);
|
||||
case "Logging":
|
||||
return new LoggingSheetsWriter(
|
||||
p.GetRequiredService<ILogger<LoggingSheetsWriter>>());
|
||||
default:
|
||||
return new DisabledSheetsWriter();
|
||||
}
|
||||
});
|
||||
|
||||
services.AddScoped<ISheetsExportService, SheetsExportService>();
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>Configuration is all strings; a missing or unparsable value takes the default.</summary>
|
||||
private int ReadInt(string key, int fallback)
|
||||
{
|
||||
return int.TryParse(this.Configuration[key], out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning"
|
||||
"Default": "Warning",
|
||||
"LaDOSE": "Information"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
@@ -13,7 +14,17 @@
|
||||
},
|
||||
"ApiKey": {
|
||||
"ChallongeApiKey": "Challonge ApiKey",
|
||||
"SmashApiKey": "Smash"
|
||||
"SmashApiKey": "SmashApiKey"
|
||||
},
|
||||
"GoogleSheets": {
|
||||
"Writer": "ServiceAccount",
|
||||
"SpreadsheetId": "1FMS3ZesZC7yBNsJG3CpB5c8qzF95JHUpnsj2q45BSs4",
|
||||
"MaxTabs": 60,
|
||||
"MaxRowsPerTab": 5000,
|
||||
"MaxColumns": 200,
|
||||
"ServiceAccount": {
|
||||
"CredentialsPath": "/home/tom/test-agent/LaDOSE/secrets/test.json"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "0.0.0.0",
|
||||
"Port": 5000,
|
||||
|
||||
Reference in New Issue
Block a user