Files
step3/api/QuestionsApi/Controllers/QuestionsController.cs
T

484 lines
18 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class QuestionsController : ControllerBase
{
private readonly QuestionStore _questions;
private readonly QuestionAttemptStore _attempts;
private readonly RatingStore _ratings;
private readonly QuestionMediaStore _media;
private readonly AnswerStatisticsStore _statistics;
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
private readonly MasteryAwardService _masteryAwards;
private readonly StudySessionStore _studySessions;
public QuestionsController(
QuestionStore questions,
QuestionAttemptStore attempts,
RatingStore ratings,
QuestionMediaStore media,
AnswerStatisticsStore statistics,
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions)
{
_questions = questions;
_attempts = attempts;
_ratings = ratings;
_media = media;
_statistics = statistics;
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
_masteryAwards = masteryAwards;
_studySessions = studySessions;
}
[HttpGet("systems")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Systems()
{
var systems = await _questions.GetSystemsAsync(User.IsInRole("SUPER"));
return Ok(systems);
}
[HttpPost("search")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
{
List<Question> questions;
try { questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
var bookmarkedIds = await _bookmarks.GetBookmarkedIdsAsync(UserId(), questions.Select(q => q.ExternalId));
return Ok(questions.Select(q =>
{
percentages.TryGetValue(q.ExternalId, out var questionPercentages);
var response = QuestionResponse.FromModel(
q,
questionPercentages,
questionElos.GetValueOrDefault(q.ExternalId, 1000),
req.SupportsChoicePools);
response.IsBookmarked = bookmarkedIds.Contains(q.ExternalId);
return response;
}).ToList());
}
[HttpGet("admin/export")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> AdminExport([FromQuery] string? after = null, [FromQuery] int limit = 100)
{
try
{
var page = await _questions.ExportPageAsync(after, limit);
return Ok(new QuestionAdminExportResponse
{
Items = page.Items.Select(question => QuestionResponse.FromModel(question)).ToList(),
NextCursor = page.NextCursor,
HasMore = page.HasMore
});
}
catch (ArgumentException exception)
{
return BadRequest(exception.Message);
}
}
[HttpGet("{id}")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Get(string id)
{
var question = await _questions.GetByIdAsync(id);
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
return NotFound();
var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId);
var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId);
var response = QuestionResponse.FromModel(question, percentages, questionElo);
response.IsBookmarked = await _bookmarks.IsBookmarkedAsync(UserId(), question.ExternalId);
return Ok(response);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
[HttpPost("attempts/first")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> RecordFirstAttempt([FromBody] FirstQuestionAttemptRequest req)
{
if (string.IsNullOrWhiteSpace(req.QuestionExternalId))
return BadRequest("questionExternalId required");
if (string.IsNullOrWhiteSpace(req.ChoiceId))
return BadRequest("choiceId required");
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
return NotFound();
if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim()))
return BadRequest("choiceId must match a choice id");
var presentedChoiceIds = req.PresentedChoiceIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.ToList();
if (presentedChoiceIds.Count > 0)
{
var validChoiceIds = question.Choices.Select(choice => choice.Id).ToHashSet(StringComparer.Ordinal);
if (presentedChoiceIds.Count != 5 || presentedChoiceIds.Distinct(StringComparer.Ordinal).Count() != 5)
return BadRequest("presentedChoiceIds must contain exactly five unique choice ids");
if (presentedChoiceIds.Any(id => !validChoiceIds.Contains(id)))
return BadRequest("presentedChoiceIds must match question choice ids");
if (!presentedChoiceIds.Contains(req.ChoiceId.Trim(), StringComparer.Ordinal)
|| !presentedChoiceIds.Contains(question.CorrectChoiceId, StringComparer.Ordinal))
return BadRequest("presentedChoiceIds must include the selected and correct choices");
}
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await _masteryAwards.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
req.ChoiceId.Trim(),
req.ChoiceId.Trim() == question.CorrectChoiceId,
false,
presentedChoiceIds,
req.DurationSeconds,
await _credits.GetBankCycleAsync(userId));
if (record.Inserted)
{
_metrics.AttemptCreated();
try
{
await _statistics.IncrementAsync(record.Attempt);
}
catch
{
_metrics.CounterFailure();
}
}
else
{
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
var creditAwards = new List<CreditAward>();
if (record.Inserted && record.Attempt.IsCorrect)
{
var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty);
var difficult = question.Difficulty >= 4;
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"answer:{record.Attempt.Id}", amount,
difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect,
difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer",
"question_attempt", record.Attempt.Id ?? ""));
if (award.Award is not null) creditAwards.Add(award.Award);
}
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
record.Attempt.Id ?? "",
ratingOwner,
TimeSpan.FromSeconds(30));
RatingChangeResponse rating;
try
{
rating = await _ratings.ApplyFirstResultAsync(
userId,
question,
record.Attempt.IsCorrect,
shouldRate);
if (shouldRate)
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
}
catch
{
_metrics.RatingFailure();
if (shouldRate)
await _attempts.ReleaseRatingLeaseAsync(record.Attempt.Id ?? "", ratingOwner);
throw;
}
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
StudySessionEnvelope? studySession = null;
if (!string.IsNullOrWhiteSpace(req.StudySessionId))
studySession = await _studySessions.RecordQuestionAsync(
req.StudySessionId, userId, record.Attempt, question, creditAwards);
if (studySession?.Summary is not null)
creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value =>
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
return Ok(new FirstQuestionAttemptResponse
{
QuestionExternalId = question.ExternalId,
ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages(
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating,
Credits = await _credits.GetStatusAsync(userId),
CreditAwards = creditAwards,
StudySessionProgress = studySession?.Progress,
SessionSummary = studySession?.Summary
});
}
[HttpPost("media")]
[Authorize(Roles = "SUPER")]
[RequestSizeLimit(5_000_000)]
public async Task<IActionResult> UploadMedia(IFormFile file)
{
if (file is null || file.Length == 0)
return BadRequest("file required");
if (file.Length > 5_000_000)
return BadRequest("file must be 5 MB or smaller");
var contentType = file.ContentType.Trim().ToLowerInvariant();
var allowedTypes = new HashSet<string>
{
"image/png",
"image/jpeg",
"image/webp"
};
if (!allowedTypes.Contains(contentType))
return BadRequest("file must be a png, jpeg, or webp image");
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var asset = await _media.CreateAsync(file.FileName, contentType, memory.ToArray());
return Ok(new
{
id = asset.Id ?? "",
url = $"/api/questions/media/{asset.Id}"
});
}
[HttpGet("media/{id}")]
public async Task<IActionResult> GetMedia(string id)
{
var asset = await _media.GetAsync(id.Trim());
if (asset is null)
return NotFound();
return File(asset.Bytes, asset.ContentType);
}
[HttpPost("{externalId}/skip")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Skip(string externalId)
{
if (string.IsNullOrWhiteSpace(externalId))
return BadRequest("externalId required");
var question = await _questions.GetByExternalIdAsync(externalId.Trim());
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
return NotFound();
var userId = CurrentUserId();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
"",
false,
true,
bankCycle: await _credits.GetBankCycleAsync(userId));
if (record.Inserted)
_metrics.AttemptCreated();
else
_metrics.AttemptDuplicate();
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
record.Attempt.Id ?? "",
ratingOwner,
TimeSpan.FromSeconds(30));
RatingChangeResponse rating;
try
{
rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate);
if (shouldRate)
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
}
catch
{
_metrics.RatingFailure();
if (shouldRate)
await _attempts.ReleaseRatingLeaseAsync(record.Attempt.Id ?? "", ratingOwner);
throw;
}
return Ok(new SkipQuestionResponse
{
QuestionExternalId = question.ExternalId,
CorrectChoiceId = question.CorrectChoiceId,
ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages(
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
});
}
[HttpPost]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Create([FromBody] UpsertQuestionRequest req)
{
var validation = QuestionValidator.Validate(req);
if (validation is not null)
return BadRequest(validation);
var created = await _questions.CreateAsync(req);
if (created is null)
return Conflict("Question externalId already exists");
return Ok(QuestionResponse.FromModel(created));
}
[HttpPost("bulk")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> BulkUpsert([FromBody] BulkUpsertQuestionsRequest req)
{
if (req.Questions.Count == 0)
return BadRequest("questions required");
foreach (var question in req.Questions)
{
var validation = QuestionValidator.Validate(question);
if (validation is not null)
return BadRequest($"{question.ExternalId}: {validation}");
}
var upserted = await _questions.UpsertManyByExternalIdAsync(req.Questions);
return Ok(new { upserted });
}
[HttpPost("admin/references/bulk")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> BulkUpdateReferences([FromBody] BulkQuestionReferencesRequest req)
{
if (req.Questions.Count == 0)
return BadRequest("questions required");
var externalIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var update in req.Questions)
{
if (string.IsNullOrWhiteSpace(update.ExternalId))
return BadRequest("externalId required");
var externalId = update.ExternalId.Trim();
if (!externalIds.Add(externalId))
return BadRequest($"duplicate externalId: {externalId}");
var validation = QuestionValidator.ValidateReferences(update.References, required: true);
if (validation is not null)
return BadRequest($"{externalId}: {validation}");
}
var missing = await _questions.GetMissingExternalIdsAsync(externalIds);
if (missing.Count > 0)
return BadRequest($"unknown externalIds: {string.Join(", ", missing)}");
var updated = await _questions.UpdateReferencesByExternalIdAsync(req.Questions);
if (updated != req.Questions.Count)
return Conflict("one or more questions changed before references could be updated");
return Ok(new { updated });
}
[HttpPut("{id}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Update(string id, [FromBody] UpsertQuestionRequest req)
{
var validation = QuestionValidator.Validate(req);
if (validation is not null)
return BadRequest(validation);
try
{
var updated = await _questions.UpdateAsync(id, req);
if (updated is null)
return NotFound();
return Ok(QuestionResponse.FromModel(updated));
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Conflict("Question externalId already exists");
}
}
[HttpDelete("{id}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Delete(string id)
{
var deleted = await _questions.DeleteAsync(id);
if (!deleted)
return NotFound();
return Ok("Deleted");
}
[HttpPost("{id}/flag")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Flag(string id, [FromBody] FlagQuestionRequest req)
{
if (string.IsNullOrWhiteSpace(req.Reason))
return BadRequest("reason required");
var flagged = await _questions.FlagAsync(id, req.Reason.Trim(), CurrentUserId());
if (flagged is null)
return NotFound();
return Ok(QuestionFlagResponse.FromModel(flagged));
}
[HttpPost("{id}/unflag")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Unflag(string id)
{
var unflagged = await _questions.UnflagAsync(id);
if (unflagged is null)
return NotFound();
return Ok(QuestionFlagResponse.FromModel(unflagged));
}
[HttpGet("admin/flagged")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Flagged()
{
var flagged = await _questions.GetFlaggedAsync();
return Ok(flagged.Select(QuestionFlagResponse.FromModel));
}
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}