Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions Sunrise.API/Controllers/ScoreProcessingController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Sunrise.API.Attributes;
using Sunrise.API.Extensions;
using Sunrise.API.Serializable.Request;
using Sunrise.API.Serializable.Response;
using Sunrise.API.Services;
using Sunrise.Shared.Attributes;
using Sunrise.Shared.Enums.Scores;

namespace Sunrise.API.Controllers;

[ApiController]
[ApiHttpTrace]
[Route("score-processing")]
[Subdomain("api")]
[Authorize("RequireSuperUser")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status403Forbidden)]
public class ScoreProcessingController(ScoreProcessingService scoreProcessingService) : ControllerBase
{
[HttpGet("")]
[EndpointDescription("List score processing tasks (filterable, paginated)")]
[ProducesResponseType(typeof(ScoreProcessingTasksResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetTasks(
[Range(1, int.MaxValue)] [FromQuery(Name = "page")]
int page = 1,
[Range(1, 100)] [FromQuery(Name = "limit")]
int limit = 25,
[FromQuery(Name = "status")] ScoreProcessingStatus? status = null,
[FromQuery(Name = "task_type")] ScoreTaskType? taskType = null,
[FromQuery(Name = "score_id")] int? scoreId = null,
[FromQuery(Name = "task_id")] int? taskId = null,
CancellationToken ct = default)
{
return await scoreProcessingService.GetTasks(page, limit, status, taskType, scoreId, taskId, ct);
}

[HttpGet("stats")]
[EndpointDescription("Score processing queue stats (pending/processing/failed counts + raw ETA)")]
[ProducesResponseType(typeof(ScoreProcessingStatsResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetStats(CancellationToken ct = default)
{
return await scoreProcessingService.GetStats(ct);
}

[HttpGet("{id:int}")]
[EndpointDescription("Get a single score processing task")]
[ProducesResponseType(typeof(ScoreProcessingTaskResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetTask([Range(1, int.MaxValue)] int id, CancellationToken ct = default)
{
return await scoreProcessingService.GetTask(id, ct);
}

[HttpGet("score/{scoreId:int}")]
[EndpointDescription("Preview a score and its active processing task")]
[ProducesResponseType(typeof(ScoreProcessingPreviewResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPreview([Range(1, int.MaxValue)] int scoreId, CancellationToken ct = default)
{
return await scoreProcessingService.GetPreview(scoreId, ct);
}

[HttpPost("")]
[EndpointDescription("Queue a recalculate/restore/delete action for a score")]
[ProducesResponseType(typeof(ScoreProcessingTaskResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateTask([FromBody] CreateScoreProcessingTaskRequest request, CancellationToken ct = default)
{
var executorId = HttpContext.GetCurrentUserOrThrow().Id;
return await scoreProcessingService.CreateTask(executorId, request.ScoreId, request.Action, ct);
}

[HttpPost("{id:int}/cancel")]
[EndpointDescription("Stop a pending score processing task")]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status409Conflict)]
public async Task<IActionResult> CancelTask([Range(1, int.MaxValue)] int id, CancellationToken ct = default)
{
var executorId = HttpContext.GetCurrentUserOrThrow().Id;
return await scoreProcessingService.CancelTask(executorId, id, ct);
}

[HttpPost("{id:int}/requeue")]
[EndpointDescription("Requeue a failed score processing task")]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status409Conflict)]
public async Task<IActionResult> RequeueTask([Range(1, int.MaxValue)] int id, CancellationToken ct = default)
{
var executorId = HttpContext.GetCurrentUserOrThrow().Id;
return await scoreProcessingService.Requeue(executorId, id, ct);
}

[HttpPost("bulk")]
[EndpointDescription("Queue an action for an explicit list of score ids (max 100)")]
[ProducesResponseType(typeof(BulkScoreProcessingResultResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> BulkByIds([FromBody] BulkScoreProcessingRequest request, CancellationToken ct = default)
{
var executorId = HttpContext.GetCurrentUserOrThrow().Id;
return await scoreProcessingService.BulkByIds(executorId, request.ScoreIds, request.Action, ct);
}

[HttpPost("bulk-by-filter")]
[EndpointDescription("Queue an action for every score matching the filter for a user (runs in background)")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult BulkByFilter([FromBody] BulkScoreProcessingByFilterRequest request)
{
var executorId = HttpContext.GetCurrentUserOrThrow().Id;
return scoreProcessingService.BulkByFilter(executorId, request);
}

[HttpGet("events")]
[EndpointDescription("List score processing audit events (filterable, paginated)")]
[ProducesResponseType(typeof(EventScoreProcessingListResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetEvents(
[Range(1, int.MaxValue)] [FromQuery(Name = "page")]
int page = 1,
[Range(1, 100)] [FromQuery(Name = "limit")]
int limit = 25,
[FromQuery(Name = "types")] List<ScoreProcessingEventType>? types = null,
[FromQuery(Name = "score_id")] int? scoreId = null,
CancellationToken ct = default)
{
return await scoreProcessingService.GetEvents(page, limit, types, scoreId, ct);
}
}
54 changes: 54 additions & 0 deletions Sunrise.API/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using osu.Shared;
using Sunrise.API.Attributes;
using Sunrise.API.Extensions;
using Sunrise.API.Objects.Keys;
Expand All @@ -18,13 +19,16 @@
using Sunrise.Shared.Database.Objects;
using Sunrise.Shared.Enums;
using Sunrise.Shared.Enums.Leaderboards;
using Sunrise.Shared.Enums.Scores;
using Sunrise.Shared.Enums.Users;
using Sunrise.Shared.Objects;
using Sunrise.Shared.Objects.Keys;
using Sunrise.Shared.Objects.Serializable.Events;
using Sunrise.Shared.Repositories;
using Sunrise.Shared.Services;
using BeatmapStatus = Sunrise.Shared.Enums.Beatmaps.BeatmapStatus;
using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode;
using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus;

namespace Sunrise.API.Controllers;

Expand Down Expand Up @@ -383,6 +387,56 @@ public async Task<IActionResult> GetUserScores(
return Ok(new ScoresResponse(parsedScores, totalScores));
}

[HttpGet]
[Authorize("RequireSuperUser")]
[Route("{id:int}/scores/admin")]
[EndpointDescription("Admin: list all raw user scores (no dedup, includes deleted/failed)")]
[ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(AdminScoresResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetUserScoresAdmin(
[Range(1, int.MaxValue)] int id,
[FromQuery(Name = "mode")] GameMode? mode = null,
[FromQuery(Name = "mods")] IEnumerable<Mods>? mods = null,
[FromQuery(Name = "submission_status")]
SubmissionStatus? submissionStatus = null,
[FromQuery(Name = "beatmap_status")] BeatmapStatus? beatmapStatus = null,
[FromQuery(Name = "submitted_from")] DateTime? submittedFrom = null,
[FromQuery(Name = "submitted_to")] DateTime? submittedTo = null,
[FromQuery(Name = "sort")] ScoreSortType sort = ScoreSortType.Date,
[Range(1, 100)] [FromQuery(Name = "limit")]
int limit = 25,
[Range(1, int.MaxValue)] [FromQuery(Name = "page")]
int page = 1,
CancellationToken ct = default)
{
var user = await database.Users.GetUser(id, options: new QueryOptions(true), ct: ct);
if (user == null)
return Problem(ApiErrorResponse.Detail.UserNotFound, statusCode: StatusCodes.Status404NotFound);

var modsEnum = (mods ?? Array.Empty<Mods>()).Aggregate(Mods.None, (current, mod) => current | mod);

var (scores, totalCount) = await database.Scores.GetScores(
mode,
new QueryOptions(true, new Pagination(page, limit))
{
QueryModifier = query => query.Cast<Score>().IncludeUser()
},
null,
user.Id,
mods != null ? modsEnum : null,
submissionStatus,
beatmapStatus,
submittedFrom,
submittedTo,
sort,
false,
ct);

var parsedScores = scores.Select(score => new AdminScoreResponse(sessions, score)).ToList();

return Ok(new AdminScoresResponse(parsedScores, totalCount));
}

[HttpGet]
[Route("{id:int}/mostplayed")]
[EndpointDescription("Get user most played beatmaps")]
Expand Down
20 changes: 20 additions & 0 deletions Sunrise.API/Extensions/ProblemResultExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;

namespace Sunrise.API.Extensions;

public static class ProblemResultExtensions
{
public static IActionResult ToProblemResult(this string detail, HttpStatusCode statusCode, string? title = null)
{
return new ObjectResult(new ProblemDetails
{
Title = title,
Detail = detail,
Status = (int)statusCode
})
{
StatusCode = (int)statusCode
};
}
}
9 changes: 9 additions & 0 deletions Sunrise.API/Objects/Keys/ApiErrorResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ public static class Title
public const string UnableToRegisterUser = "Unable to register user.";
public const string UnableToAuthenticate = "Unable to authenticate.";
public const string UnableToRefreshAuthToken = "Unable to refresh auth token.";

public const string UnableToQueueScoreProcessing = "Unable to queue score processing.";
public const string UnableToCancelScoreTask = "Unable to cancel score task.";
public const string UnableToRequeueScoreTask = "Unable to requeue score task.";
}

public static class Detail
Expand All @@ -35,6 +39,11 @@ public static class Detail
public const string ReplayNotFound = "Replay not found.";
public const string BeatmapNotFound = "Beatmap not found.";

public const string ScoreAlreadyQueued = "Score already has an active queued task.";
public const string ScoreTaskNotFound = "Score processing task not found.";
public const string InvalidScoreProcessingAction = "Invalid score processing action. Allowed actions are recalculation, restore and delete.";
public const string TooManyScoreIds = "Too many score ids provided in a single bulk request.";

public const string InsufficientPrivileges = "Insufficient privileges to perform this action.";

public const string RestrictionReasonMustBeProvided = "Restriction reason must be provided when restricting a user.";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using osu.Shared;
using Sunrise.Shared.Enums.Scores;
using BeatmapStatus = Sunrise.Shared.Enums.Beatmaps.BeatmapStatus;
using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode;
using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus;

namespace Sunrise.API.Serializable.Request;

public class BulkScoreProcessingByFilterRequest
{
[JsonPropertyName("action")]
[Required]
public ScoreTaskType Action { get; set; }

[JsonPropertyName("user_id")]
[Required]
[Range(1, int.MaxValue)]
public int UserId { get; set; }

[JsonPropertyName("mode")]
public GameMode? Mode { get; set; }

[JsonPropertyName("mods")]
public IEnumerable<Mods>? Mods { get; set; }

[JsonPropertyName("submission_status")]
public SubmissionStatus? SubmissionStatus { get; set; }

[JsonPropertyName("beatmap_status")]
public BeatmapStatus? BeatmapStatus { get; set; }

[JsonPropertyName("submitted_from")]
public DateTime? SubmittedFrom { get; set; }

[JsonPropertyName("submitted_to")]
public DateTime? SubmittedTo { get; set; }
}
17 changes: 17 additions & 0 deletions Sunrise.API/Serializable/Request/BulkScoreProcessingRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Sunrise.Shared.Enums.Scores;

namespace Sunrise.API.Serializable.Request;

public class BulkScoreProcessingRequest
{
[JsonPropertyName("score_ids")]
[Required]
[MinLength(1)]
public List<int> ScoreIds { get; set; } = [];

[JsonPropertyName("action")]
[Required]
public ScoreTaskType Action { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Sunrise.Shared.Enums.Scores;

namespace Sunrise.API.Serializable.Request;

public class CreateScoreProcessingTaskRequest
{
[JsonPropertyName("score_id")]
[Required]
[Range(1, int.MaxValue)]
public int ScoreId { get; set; }

[JsonPropertyName("action")]
[Required]
public ScoreTaskType Action { get; set; }
}
39 changes: 39 additions & 0 deletions Sunrise.API/Serializable/Response/AdminScoreResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
using Sunrise.Shared.Database.Models;
using Sunrise.Shared.Enums.Beatmaps;
using Sunrise.Shared.Enums.Scores;
using Sunrise.Shared.Repositories;

namespace Sunrise.API.Serializable.Response;

public class AdminScoreResponse
{
[JsonConstructor]
public AdminScoreResponse()
{
}

public AdminScoreResponse(SessionRepository sessionRepository, Score score)
{
Score = new ScoreResponse(sessionRepository, score);
SubmissionStatus = score.SubmissionStatus;
BeatmapStatus = score.BeatmapStatus;
IsScoreable = score.IsScoreable;
ScoreHash = score.ScoreHash;
}

[JsonPropertyName("score")]
public ScoreResponse Score { get; set; }

[JsonPropertyName("submission_status")]
public SubmissionStatus SubmissionStatus { get; set; }

[JsonPropertyName("beatmap_status")]
public BeatmapStatus BeatmapStatus { get; set; }

[JsonPropertyName("is_scoreable")]
public bool IsScoreable { get; set; }

[JsonPropertyName("score_hash")]
public string? ScoreHash { get; set; }
}
12 changes: 12 additions & 0 deletions Sunrise.API/Serializable/Response/AdminScoresResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;

namespace Sunrise.API.Serializable.Response;

public class AdminScoresResponse(List<AdminScoreResponse> scores, int totalCount)
{
[JsonPropertyName("scores")]
public List<AdminScoreResponse> Scores { get; set; } = scores;

[JsonPropertyName("total_count")]
public int TotalCount { get; set; } = totalCount;
}
Loading
Loading