Add question image media support
This commit is contained in:
@@ -14,12 +14,18 @@ public class QuestionsController : ControllerBase
|
|||||||
private readonly QuestionStore _questions;
|
private readonly QuestionStore _questions;
|
||||||
private readonly QuestionAttemptStore _attempts;
|
private readonly QuestionAttemptStore _attempts;
|
||||||
private readonly RatingStore _ratings;
|
private readonly RatingStore _ratings;
|
||||||
|
private readonly QuestionMediaStore _media;
|
||||||
|
|
||||||
public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings)
|
public QuestionsController(
|
||||||
|
QuestionStore questions,
|
||||||
|
QuestionAttemptStore attempts,
|
||||||
|
RatingStore ratings,
|
||||||
|
QuestionMediaStore media)
|
||||||
{
|
{
|
||||||
_questions = questions;
|
_questions = questions;
|
||||||
_attempts = attempts;
|
_attempts = attempts;
|
||||||
_ratings = ratings;
|
_ratings = ratings;
|
||||||
|
_media = media;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("systems")]
|
[HttpGet("systems")]
|
||||||
@@ -101,6 +107,48 @@ public class QuestionsController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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")]
|
[HttpPost("{externalId}/skip")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public async Task<IActionResult> Skip(string externalId)
|
public async Task<IActionResult> Skip(string externalId)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Models;
|
||||||
|
|
||||||
|
public class QuestionMediaAsset
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
public string FileName { get; set; } = "";
|
||||||
|
public string ContentType { get; set; } = "";
|
||||||
|
public byte[] Bytes { get; set; } = [];
|
||||||
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ builder.Services.AddControllers();
|
|||||||
builder.Services.AddSingleton<QuestionStore>();
|
builder.Services.AddSingleton<QuestionStore>();
|
||||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||||
builder.Services.AddSingleton<RatingStore>();
|
builder.Services.AddSingleton<RatingStore>();
|
||||||
|
builder.Services.AddSingleton<QuestionMediaStore>();
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c =>
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
|
public class QuestionMediaStore
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<QuestionMediaAsset> _col;
|
||||||
|
|
||||||
|
public QuestionMediaStore(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<QuestionMediaAsset>("QuestionMediaAssets");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuestionMediaAsset> CreateAsync(string fileName, string contentType, byte[] bytes)
|
||||||
|
{
|
||||||
|
var asset = new QuestionMediaAsset
|
||||||
|
{
|
||||||
|
FileName = fileName.Trim(),
|
||||||
|
ContentType = contentType.Trim(),
|
||||||
|
Bytes = bytes,
|
||||||
|
CreatedUtc = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _col.InsertOneAsync(asset);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuestionMediaAsset?> GetAsync(string id) =>
|
||||||
|
await _col.Find(asset => asset.Id == id).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
@@ -39,11 +39,13 @@ var flag_button: Button
|
|||||||
var report_button: Button
|
var report_button: Button
|
||||||
var nav_palette: HBoxContainer
|
var nav_palette: HBoxContainer
|
||||||
var question_content: VBoxContainer
|
var question_content: VBoxContainer
|
||||||
|
var media_container: VBoxContainer
|
||||||
var timer_bar: ProgressBar
|
var timer_bar: ProgressBar
|
||||||
var feedback_fog: ColorRect
|
var feedback_fog: ColorRect
|
||||||
var labs_overlay: Control
|
var labs_overlay: Control
|
||||||
var labs_panel: PanelContainer
|
var labs_panel: PanelContainer
|
||||||
var labs_content: VBoxContainer
|
var labs_content: VBoxContainer
|
||||||
|
var media_requests: Array[HTTPRequest] = []
|
||||||
var red_fog_time := 0.0
|
var red_fog_time := 0.0
|
||||||
var green_fog_time := 0.0
|
var green_fog_time := 0.0
|
||||||
var correct_advance_delay := 0.0
|
var correct_advance_delay := 0.0
|
||||||
@@ -151,6 +153,12 @@ func _ready() -> void:
|
|||||||
stem_label.custom_minimum_size = Vector2(0, 96)
|
stem_label.custom_minimum_size = Vector2(0, 96)
|
||||||
question_content.add_child(stem_label)
|
question_content.add_child(stem_label)
|
||||||
|
|
||||||
|
media_container = VBoxContainer.new()
|
||||||
|
media_container.visible = false
|
||||||
|
media_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
media_container.add_theme_constant_override("separation", 10)
|
||||||
|
question_content.add_child(media_container)
|
||||||
|
|
||||||
lead_label = Label.new()
|
lead_label = Label.new()
|
||||||
lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
Style.set_label(lead_label, 21, Style.TEXT)
|
Style.set_label(lead_label, 21, Style.TEXT)
|
||||||
@@ -336,6 +344,7 @@ func _load_question() -> void:
|
|||||||
_update_timer_bar()
|
_update_timer_bar()
|
||||||
stem_plain_text = str(question.get("stem", ""))
|
stem_plain_text = str(question.get("stem", ""))
|
||||||
_render_stem()
|
_render_stem()
|
||||||
|
_load_question_media(question)
|
||||||
lead_label.text = question.get("lead_in", "")
|
lead_label.text = question.get("lead_in", "")
|
||||||
_refresh_labs_button()
|
_refresh_labs_button()
|
||||||
|
|
||||||
@@ -471,8 +480,6 @@ func _submit_or_next() -> void:
|
|||||||
current_answer = {}
|
current_answer = {}
|
||||||
next_button.text = "Submit"
|
next_button.text = "Submit"
|
||||||
next_button.disabled = true
|
next_button.disabled = true
|
||||||
if skip_button != null:
|
|
||||||
skip_button.disabled = true
|
|
||||||
_trigger_wrong_feedback()
|
_trigger_wrong_feedback()
|
||||||
_apply_saved_choice_state()
|
_apply_saved_choice_state()
|
||||||
return
|
return
|
||||||
@@ -697,6 +704,112 @@ func _current_question_has_labs() -> bool:
|
|||||||
return not labs.is_empty()
|
return not labs.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func _load_question_media(question: Dictionary) -> void:
|
||||||
|
_clear_question_media()
|
||||||
|
var media: Array = question.get("media", [])
|
||||||
|
if media.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
media_container.visible = true
|
||||||
|
var question_id := str(question.get("id", ""))
|
||||||
|
for item in media:
|
||||||
|
var media_url := _resolve_media_url(str(item))
|
||||||
|
if media_url == "":
|
||||||
|
continue
|
||||||
|
var frame := PanelContainer.new()
|
||||||
|
frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463")))
|
||||||
|
media_container.add_child(frame)
|
||||||
|
|
||||||
|
var image_box := VBoxContainer.new()
|
||||||
|
image_box.add_theme_constant_override("separation", 8)
|
||||||
|
frame.add_child(image_box)
|
||||||
|
|
||||||
|
var status := Label.new()
|
||||||
|
status.text = "Loading image..."
|
||||||
|
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
Style.set_label(status, 15, Style.MUTED)
|
||||||
|
image_box.add_child(status)
|
||||||
|
|
||||||
|
var texture_rect := TextureRect.new()
|
||||||
|
texture_rect.visible = false
|
||||||
|
texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH
|
||||||
|
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
texture_rect.custom_minimum_size = Vector2(0, 260)
|
||||||
|
texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
image_box.add_child(texture_rect)
|
||||||
|
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
request.request_completed.connect(
|
||||||
|
func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray):
|
||||||
|
_on_media_loaded(result, response_code, body, question_id, texture_rect, status)
|
||||||
|
)
|
||||||
|
media_requests.append(request)
|
||||||
|
add_child(request)
|
||||||
|
var err := request.request(media_url)
|
||||||
|
if err != OK:
|
||||||
|
status.text = "Image unavailable."
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_question_media() -> void:
|
||||||
|
for request in media_requests:
|
||||||
|
if is_instance_valid(request):
|
||||||
|
request.queue_free()
|
||||||
|
media_requests.clear()
|
||||||
|
if media_container == null:
|
||||||
|
return
|
||||||
|
for child in media_container.get_children():
|
||||||
|
media_container.remove_child(child)
|
||||||
|
child.queue_free()
|
||||||
|
media_container.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_media_url(value: String) -> String:
|
||||||
|
var media_url := value.strip_edges()
|
||||||
|
if media_url == "":
|
||||||
|
return ""
|
||||||
|
if media_url.begins_with("http://") or media_url.begins_with("https://"):
|
||||||
|
return media_url
|
||||||
|
if not media_url.begins_with("/"):
|
||||||
|
media_url = "/%s" % media_url
|
||||||
|
|
||||||
|
var base_url := QuestionBankService.question_api_base_url
|
||||||
|
var scheme_end := base_url.find("://")
|
||||||
|
if scheme_end < 0:
|
||||||
|
return media_url
|
||||||
|
var path_start := base_url.find("/", scheme_end + 3)
|
||||||
|
var origin := base_url if path_start < 0 else base_url.substr(0, path_start)
|
||||||
|
return "%s%s" % [origin, media_url]
|
||||||
|
|
||||||
|
|
||||||
|
func _on_media_loaded(
|
||||||
|
result: int,
|
||||||
|
response_code: int,
|
||||||
|
body: PackedByteArray,
|
||||||
|
question_id: String,
|
||||||
|
texture_rect: TextureRect,
|
||||||
|
status: Label) -> void:
|
||||||
|
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
|
||||||
|
return
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
|
status.text = "Image unavailable."
|
||||||
|
return
|
||||||
|
|
||||||
|
var image := Image.new()
|
||||||
|
var err := image.load_png_from_buffer(body)
|
||||||
|
if err != OK:
|
||||||
|
err = image.load_jpg_from_buffer(body)
|
||||||
|
if err != OK:
|
||||||
|
err = image.load_webp_from_buffer(body)
|
||||||
|
if err != OK:
|
||||||
|
status.text = "Image unavailable."
|
||||||
|
return
|
||||||
|
|
||||||
|
texture_rect.texture = ImageTexture.create_from_image(image)
|
||||||
|
texture_rect.visible = true
|
||||||
|
status.visible = false
|
||||||
|
|
||||||
|
|
||||||
func _build_labs_modal() -> void:
|
func _build_labs_modal() -> void:
|
||||||
labs_overlay = Control.new()
|
labs_overlay = Control.new()
|
||||||
labs_overlay.visible = false
|
labs_overlay.visible = false
|
||||||
|
|||||||
Reference in New Issue
Block a user