Track real first attempt question stats
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
@@ -11,10 +12,12 @@ namespace QuestionsApi.Controllers;
|
||||
public class QuestionsController : ControllerBase
|
||||
{
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
|
||||
public QuestionsController(QuestionStore questions)
|
||||
public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -30,7 +33,12 @@ public class QuestionsController : ControllerBase
|
||||
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
|
||||
{
|
||||
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
|
||||
return Ok(questions.Select(QuestionResponse.FromModel).ToList());
|
||||
var percentages = await _attempts.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
|
||||
return Ok(questions.Select(q =>
|
||||
{
|
||||
percentages.TryGetValue(q.ExternalId, out var questionPercentages);
|
||||
return QuestionResponse.FromModel(q, questionPercentages);
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -41,7 +49,43 @@ public class QuestionsController : ControllerBase
|
||||
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||
return NotFound();
|
||||
|
||||
return Ok(QuestionResponse.FromModel(question));
|
||||
var percentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId);
|
||||
return Ok(QuestionResponse.FromModel(question, percentages));
|
||||
}
|
||||
|
||||
[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 || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||
return NotFound();
|
||||
if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim()))
|
||||
return BadRequest("choiceId must match a choice id");
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
req.ChoiceId.Trim(),
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId);
|
||||
|
||||
return Ok(new FirstQuestionAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
ChoiceSelectionPercentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public class QuestionAttempt
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public bool IsCorrect { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -28,3 +28,9 @@ public class UpsertQuestionRequest
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public bool IsPublished { get; set; } = true;
|
||||
}
|
||||
|
||||
public class FirstQuestionAttemptRequest
|
||||
{
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class QuestionResponse
|
||||
public List<string> Media { get; set; } = [];
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
public static QuestionResponse FromModel(Question question) => new()
|
||||
public static QuestionResponse FromModel(Question question, Dictionary<string, int>? liveChoiceSelectionPercentages = null) => new()
|
||||
{
|
||||
Id = question.Id ?? "",
|
||||
ExternalId = question.ExternalId,
|
||||
@@ -28,7 +28,9 @@ public class QuestionResponse
|
||||
CorrectChoiceId = question.CorrectChoiceId,
|
||||
Explanation = question.Explanation,
|
||||
ChoiceExplanations = question.ChoiceExplanations,
|
||||
ChoiceSelectionPercentages = question.ChoiceSelectionPercentages,
|
||||
ChoiceSelectionPercentages = liveChoiceSelectionPercentages is { Count: > 0 }
|
||||
? liveChoiceSelectionPercentages
|
||||
: question.ChoiceSelectionPercentages,
|
||||
Discipline = question.Discipline,
|
||||
System = question.System,
|
||||
Difficulty = question.Difficulty,
|
||||
@@ -37,3 +39,9 @@ public class QuestionResponse
|
||||
Tags = question.Tags
|
||||
};
|
||||
}
|
||||
|
||||
public class FirstQuestionAttemptResponse
|
||||
{
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public class QuestionAttemptStore
|
||||
{
|
||||
private readonly IMongoCollection<QuestionAttempt> _col;
|
||||
|
||||
public QuestionAttemptStore(IConfiguration cfg)
|
||||
{
|
||||
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var client = new MongoClient(cs);
|
||||
var db = client.GetDatabase(dbName);
|
||||
_col = db.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
}
|
||||
|
||||
public async Task RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
ChoiceId = choiceId,
|
||||
IsCorrect = isCorrect,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _col.InsertOneAsync(attempt);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, int>> GetPercentagesForQuestionAsync(string questionExternalId)
|
||||
{
|
||||
var results = await GetPercentagesByQuestionAsync([questionExternalId]);
|
||||
return results.GetValueOrDefault(questionExternalId, []);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, Dictionary<string, int>>> GetPercentagesByQuestionAsync(IEnumerable<string> questionExternalIds)
|
||||
{
|
||||
var ids = questionExternalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return [];
|
||||
|
||||
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId)).ToListAsync();
|
||||
var output = new Dictionary<string, Dictionary<string, int>>();
|
||||
|
||||
foreach (var group in attempts.GroupBy(a => a.QuestionExternalId))
|
||||
{
|
||||
var total = group.Count();
|
||||
if (total == 0)
|
||||
continue;
|
||||
|
||||
output[group.Key] = group
|
||||
.GroupBy(a => a.ChoiceId)
|
||||
.ToDictionary(
|
||||
choiceGroup => choiceGroup.Key,
|
||||
choiceGroup => (int)Math.Round(choiceGroup.Count() * 100.0 / total));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,9 @@ public class QuestionStore
|
||||
public async Task<Question?> GetByIdAsync(string id) =>
|
||||
await _col.Find(q => q.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Question?> GetByExternalIdAsync(string externalId) =>
|
||||
await _col.Find(q => q.ExternalId == externalId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Question?> CreateAsync(UpsertQuestionRequest req)
|
||||
{
|
||||
var question = FromRequest(req);
|
||||
|
||||
Reference in New Issue
Block a user