Add functional simulation question review
This commit is contained in:
@@ -32,6 +32,18 @@ public sealed class ChoicePoolTests
|
|||||||
Assert.Equal("https://example.org/guideline", response.References[0].Url);
|
Assert.Equal("https://example.org/guideline", response.References[0].Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExamReviewContainsOnlyChoicesPresentedDuringTheExam()
|
||||||
|
{
|
||||||
|
var question = BuildQuestion(9);
|
||||||
|
var presented = new[] { "F", "A", "C", "H", "D" };
|
||||||
|
var response = QuestionResponse.FromModelWithChoices(question, presented);
|
||||||
|
|
||||||
|
Assert.Equal(presented, response.Choices.Select(choice => choice.Id));
|
||||||
|
Assert.Equal(presented.Order(), response.ChoiceExplanations.Keys.Order());
|
||||||
|
Assert.DoesNotContain("B", response.Choices.Select(choice => choice.Id));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PublishedQuestionRequiresCompletePerChoiceTeachingContent()
|
public void PublishedQuestionRequiresCompletePerChoiceTeachingContent()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
|||||||
return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation));
|
return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("completed")]
|
||||||
|
public async Task<IActionResult> Completed([FromQuery] int limit = 20, [FromQuery] DateTime? before = null) =>
|
||||||
|
Ok(await simulations.GetCompletedAsync(UserId(), limit, before));
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public async Task<IActionResult> Get(string id)
|
public async Task<IActionResult> Get(string id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ public class ExamSimulationReadinessResponse
|
|||||||
public bool HasActiveSimulation { get; set; }
|
public bool HasActiveSimulation { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class CompletedExamSimulationSummary
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public DateTime CompletedUtc { get; set; }
|
||||||
|
public int PredictedScore { get; set; }
|
||||||
|
public int RangeLow { get; set; }
|
||||||
|
public int RangeHigh { get; set; }
|
||||||
|
public int Correct { get; set; }
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int SeenCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompletedExamSimulationPage
|
||||||
|
{
|
||||||
|
public List<CompletedExamSimulationSummary> Items { get; set; } = [];
|
||||||
|
public DateTime? NextBefore { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class Step3Outcome
|
public class Step3Outcome
|
||||||
{
|
{
|
||||||
[BsonId]
|
[BsonId]
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ public class QuestionResponse
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static QuestionResponse FromModelWithChoices(
|
||||||
|
Question question,
|
||||||
|
IReadOnlyCollection<string> presentedChoiceIds,
|
||||||
|
int questionElo = 1000)
|
||||||
|
{
|
||||||
|
var response = FromModel(question, questionElo: questionElo, includeChoicePool: true);
|
||||||
|
var ids = presentedChoiceIds.ToHashSet(StringComparer.Ordinal);
|
||||||
|
response.Choices = presentedChoiceIds
|
||||||
|
.Select(id => question.Choices.FirstOrDefault(choice => choice.Id == id))
|
||||||
|
.Where(choice => choice is not null)
|
||||||
|
.Cast<QuestionChoice>()
|
||||||
|
.ToList();
|
||||||
|
response.ChoiceExplanations = response.ChoiceExplanations.Where(entry => ids.Contains(entry.Key)).ToDictionary();
|
||||||
|
response.WrongAnswerFollowups = response.WrongAnswerFollowups.Where(entry => ids.Contains(entry.Key)).ToDictionary();
|
||||||
|
response.ChoiceSelectionPercentages = response.ChoiceSelectionPercentages.Where(entry => ids.Contains(entry.Key)).ToDictionary();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<QuestionChoice> SelectLegacyChoices(Question question)
|
private static List<QuestionChoice> SelectLegacyChoices(Question question)
|
||||||
{
|
{
|
||||||
if (question.Choices.Count <= 5)
|
if (question.Choices.Count <= 5)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public class ExamSimulationStore
|
|||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||||
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State)));
|
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State)));
|
||||||
|
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||||
|
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CompletedUtc)));
|
||||||
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||||
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UpdatedUtc)));
|
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UpdatedUtc)));
|
||||||
_outcomes.Indexes.CreateOne(new CreateIndexModel<Step3Outcome>(
|
_outcomes.Indexes.CreateOne(new CreateIndexModel<Step3Outcome>(
|
||||||
@@ -136,8 +138,8 @@ public class ExamSimulationStore
|
|||||||
Score = ExamSimulationScoring.Calculate(items, responseMap, questionMap),
|
Score = ExamSimulationScoring.Calculate(items, responseMap, questionMap),
|
||||||
BlueprintTargets = targets,
|
BlueprintTargets = targets,
|
||||||
BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()),
|
BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()),
|
||||||
ReviewQuestions = items.Select(item => QuestionResponse.FromModel(
|
ReviewQuestions = items.Select(item => QuestionResponse.FromModelWithChoices(
|
||||||
questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList()
|
questionMap[item.QuestionExternalId], item.PresentedChoiceIds, item.QuestionElo)).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +156,33 @@ public class ExamSimulationStore
|
|||||||
return simulation;
|
return simulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CompletedExamSimulationPage> GetCompletedAsync(string userId, int limit, DateTime? before)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 50);
|
||||||
|
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.UserId, userId) &
|
||||||
|
Builders<ExamSimulation>.Filter.Eq(item => item.State, ExamSimulationStates.Completed);
|
||||||
|
if (before is not null)
|
||||||
|
filter &= Builders<ExamSimulation>.Filter.Lt(item => item.CompletedUtc, before.Value);
|
||||||
|
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
|
||||||
|
var hasMore = simulations.Count > limit;
|
||||||
|
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
|
||||||
|
return new CompletedExamSimulationPage
|
||||||
|
{
|
||||||
|
Items = simulations.Select(item => new CompletedExamSimulationSummary
|
||||||
|
{
|
||||||
|
Id = item.Id ?? "",
|
||||||
|
CompletedUtc = item.CompletedUtc ?? item.UpdatedUtc,
|
||||||
|
PredictedScore = item.Score?.PredictedScore ?? 0,
|
||||||
|
RangeLow = item.Score?.RangeLow ?? 0,
|
||||||
|
RangeHigh = item.Score?.RangeHigh ?? 0,
|
||||||
|
Correct = item.Score?.Correct ?? 0,
|
||||||
|
Total = item.Score?.Total ?? item.Items.Count,
|
||||||
|
SeenCount = item.Score?.SeenCount ?? 0
|
||||||
|
}).ToList(),
|
||||||
|
NextBefore = hasMore && simulations.Count > 0 ? simulations[^1].CompletedUtc : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ExamSimulation> StartNextBlockAsync(ExamSimulation simulation)
|
public async Task<ExamSimulation> StartNextBlockAsync(ExamSimulation simulation)
|
||||||
{
|
{
|
||||||
if (simulation.State != ExamSimulationStates.BetweenBlocks)
|
if (simulation.State != ExamSimulationStates.BetweenBlocks)
|
||||||
@@ -244,7 +273,7 @@ public class ExamSimulationStore
|
|||||||
{
|
{
|
||||||
response.ReviewQuestions = simulation.Items.OrderBy(item => item.BlockIndex).ThenBy(item => item.Position)
|
response.ReviewQuestions = simulation.Items.OrderBy(item => item.BlockIndex).ThenBy(item => item.Position)
|
||||||
.Where(item => questionMap.ContainsKey(item.QuestionExternalId))
|
.Where(item => questionMap.ContainsKey(item.QuestionExternalId))
|
||||||
.Select(item => QuestionResponse.FromModel(questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList();
|
.Select(item => QuestionResponse.FromModelWithChoices(questionMap[item.QuestionExternalId], item.PresentedChoiceIds, item.QuestionElo)).ToList();
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ signal readiness_changed
|
|||||||
signal state_changed
|
signal state_changed
|
||||||
signal request_failed(message: String)
|
signal request_failed(message: String)
|
||||||
signal outcome_changed
|
signal outcome_changed
|
||||||
|
signal completed_history_changed
|
||||||
|
|
||||||
var api_url := DEFAULT_API_URL
|
var api_url := DEFAULT_API_URL
|
||||||
var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes"
|
var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes"
|
||||||
@@ -14,6 +15,8 @@ var request: HTTPRequest
|
|||||||
var readiness := {}
|
var readiness := {}
|
||||||
var simulation := {}
|
var simulation := {}
|
||||||
var outcome := {}
|
var outcome := {}
|
||||||
|
var completed_history: Array = []
|
||||||
|
var completed_history_next_before := ""
|
||||||
var is_loading := false
|
var is_loading := false
|
||||||
var pending_action := ""
|
var pending_action := ""
|
||||||
var request_queue: Array = []
|
var request_queue: Array = []
|
||||||
@@ -37,6 +40,17 @@ func load_active() -> void:
|
|||||||
_send("active", HTTPClient.METHOD_GET, "/active")
|
_send("active", HTTPClient.METHOD_GET, "/active")
|
||||||
|
|
||||||
|
|
||||||
|
func load_completed_history(before: String = "") -> void:
|
||||||
|
var suffix := "/completed?limit=20"
|
||||||
|
if not before.is_empty():
|
||||||
|
suffix += "&before=%s" % before.uri_encode()
|
||||||
|
_send("history_append" if not before.is_empty() else "history", HTTPClient.METHOD_GET, suffix)
|
||||||
|
|
||||||
|
|
||||||
|
func load_simulation(simulation_id: String) -> void:
|
||||||
|
_send("completed_get", HTTPClient.METHOD_GET, "/%s" % simulation_id.uri_encode())
|
||||||
|
|
||||||
|
|
||||||
func create_simulation() -> void:
|
func create_simulation() -> void:
|
||||||
_send("create", HTTPClient.METHOD_POST, "")
|
_send("create", HTTPClient.METHOD_POST, "")
|
||||||
|
|
||||||
@@ -156,6 +170,12 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
|||||||
readiness = parsed
|
readiness = parsed
|
||||||
readiness_changed.emit()
|
readiness_changed.emit()
|
||||||
return
|
return
|
||||||
|
if action == "history" or action == "history_append":
|
||||||
|
var items: Array = parsed.get("items", [])
|
||||||
|
completed_history = completed_history + items if action == "history_append" else items
|
||||||
|
completed_history_next_before = str(parsed.get("nextBefore", ""))
|
||||||
|
completed_history_changed.emit()
|
||||||
|
return
|
||||||
if action.begins_with("outcome_"):
|
if action.begins_with("outcome_"):
|
||||||
outcome = parsed
|
outcome = parsed
|
||||||
outcome_changed.emit()
|
outcome_changed.emit()
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ func _build_ui() -> void:
|
|||||||
screens_container.add_child(simulation_results)
|
screens_container.add_child(simulation_results)
|
||||||
screens["simulation_results"] = simulation_results
|
screens["simulation_results"] = simulation_results
|
||||||
|
|
||||||
|
var simulation_review := preload("res://scripts/ui/simulation_review.gd").new()
|
||||||
|
simulation_review.name = "SimulationReview"
|
||||||
|
simulation_review.main = self
|
||||||
|
screens_container.add_child(simulation_review)
|
||||||
|
screens["simulation_review"] = simulation_review
|
||||||
|
|
||||||
var profile := preload("res://scripts/ui/progress_profile.gd").new()
|
var profile := preload("res://scripts/ui/progress_profile.gd").new()
|
||||||
profile.name = "ProgressProfile"
|
profile.name = "ProgressProfile"
|
||||||
profile.main = self
|
profile.main = self
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ var main: Control
|
|||||||
var score_label: Label
|
var score_label: Label
|
||||||
var summary_label: Label
|
var summary_label: Label
|
||||||
var category_list: VBoxContainer
|
var category_list: VBoxContainer
|
||||||
var review_list: VBoxContainer
|
|
||||||
var outcome_score: SpinBox
|
var outcome_score: SpinBox
|
||||||
var outcome_date: LineEdit
|
var outcome_date: LineEdit
|
||||||
var outcome_consent: CheckBox
|
var outcome_consent: CheckBox
|
||||||
var outcome_status: Label
|
var outcome_status: Label
|
||||||
var outcome_delete_button: Button
|
var outcome_delete_button: Button
|
||||||
|
var current_simulation := {}
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -42,13 +42,13 @@ func _ready() -> void:
|
|||||||
content.add_child(category_heading)
|
content.add_child(category_heading)
|
||||||
category_list = VBoxContainer.new()
|
category_list = VBoxContainer.new()
|
||||||
content.add_child(category_list)
|
content.add_child(category_list)
|
||||||
var review_heading := Label.new()
|
var review_button := Button.new()
|
||||||
review_heading.text = "Answer review"
|
review_button.text = "Review Questions"
|
||||||
Style.set_label(review_heading, 22, Style.TEXT)
|
review_button.custom_minimum_size = Vector2(240, 50)
|
||||||
content.add_child(review_heading)
|
review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
review_list = VBoxContainer.new()
|
Style.style_primary_button(review_button)
|
||||||
review_list.add_theme_constant_override("separation", 8)
|
review_button.pressed.connect(func(): main.show_screen("simulation_review", current_simulation))
|
||||||
content.add_child(review_list)
|
content.add_child(review_button)
|
||||||
|
|
||||||
var outcome_heading := Label.new()
|
var outcome_heading := Label.new()
|
||||||
outcome_heading.text = "Help calibrate future predictions"
|
outcome_heading.text = "Help calibrate future predictions"
|
||||||
@@ -102,6 +102,7 @@ func _ready() -> void:
|
|||||||
|
|
||||||
func on_show(payload: Variant = null) -> void:
|
func on_show(payload: Variant = null) -> void:
|
||||||
var simulation: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else ExamSimulationService.simulation
|
var simulation: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else ExamSimulationService.simulation
|
||||||
|
current_simulation = simulation
|
||||||
var score: Dictionary = simulation.get("score", {})
|
var score: Dictionary = simulation.get("score", {})
|
||||||
var predicted := int(score.get("predictedScore", 0))
|
var predicted := int(score.get("predictedScore", 0))
|
||||||
var low := int(score.get("rangeLow", 0))
|
var low := int(score.get("rangeLow", 0))
|
||||||
@@ -117,30 +118,9 @@ func on_show(payload: Variant = null) -> void:
|
|||||||
label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))]
|
label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))]
|
||||||
Style.set_label(label, 16, Style.TEXT)
|
Style.set_label(label, 16, Style.TEXT)
|
||||||
category_list.add_child(label)
|
category_list.add_child(label)
|
||||||
_build_review(simulation)
|
|
||||||
ExamSimulationService.load_outcome()
|
ExamSimulationService.load_outcome()
|
||||||
|
|
||||||
|
|
||||||
func _build_review(simulation: Dictionary) -> void:
|
|
||||||
_clear(review_list)
|
|
||||||
var responses := {}
|
|
||||||
for response in simulation.get("responses", []):
|
|
||||||
responses[str(response.get("questionExternalId", ""))] = response
|
|
||||||
for raw_question in simulation.get("reviewQuestions", []):
|
|
||||||
if typeof(raw_question) != TYPE_DICTIONARY:
|
|
||||||
continue
|
|
||||||
var question: Dictionary = raw_question
|
|
||||||
var id := str(question.get("externalId", ""))
|
|
||||||
var selected := str(responses.get(id, {}).get("choiceId", ""))
|
|
||||||
var correct := str(question.get("correctChoiceId", ""))
|
|
||||||
var item := RichTextLabel.new()
|
|
||||||
item.bbcode_enabled = true
|
|
||||||
item.fit_content = true
|
|
||||||
item.custom_minimum_size = Vector2(0, 84)
|
|
||||||
item.text = "[b]%s[/b] %s\nSelected: %s | Correct: %s\n%s" % ["CORRECT" if selected == correct else "MISS", str(question.get("leadIn", "")), selected if selected != "" else "Unanswered", correct, str(question.get("explanation", ""))]
|
|
||||||
review_list.add_child(item)
|
|
||||||
|
|
||||||
|
|
||||||
func _clear(container: Control) -> void:
|
func _clear(container: Control) -> void:
|
||||||
for child in container.get_children():
|
for child in container.get_children():
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
extends VBoxContainer
|
||||||
|
|
||||||
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||||
|
const BLOCK_SIZES := [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19]
|
||||||
|
|
||||||
|
var main: Control
|
||||||
|
var simulation := {}
|
||||||
|
var questions: Array = []
|
||||||
|
var responses := {}
|
||||||
|
var filtered_indices: Array[int] = []
|
||||||
|
var review_position := 0
|
||||||
|
var active_filter := "all"
|
||||||
|
var progress_label: Label
|
||||||
|
var content: VBoxContainer
|
||||||
|
var previous_button: Button
|
||||||
|
var next_button: Button
|
||||||
|
var empty_label: Label
|
||||||
|
var media_requests: Array[HTTPRequest] = []
|
||||||
|
var image_overlay: Control
|
||||||
|
var image_texture: TextureRect
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_theme_constant_override("separation", 10)
|
||||||
|
var toolbar := HBoxContainer.new()
|
||||||
|
toolbar.add_theme_constant_override("separation", 8)
|
||||||
|
add_child(toolbar)
|
||||||
|
var back := Button.new()
|
||||||
|
back.text = "< Results"
|
||||||
|
Style.style_chip(back, Style.CYAN)
|
||||||
|
back.pressed.connect(func(): main.show_screen("simulation_results", simulation))
|
||||||
|
toolbar.add_child(back)
|
||||||
|
progress_label = Label.new()
|
||||||
|
progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
Style.set_label(progress_label, 17, Style.TEXT)
|
||||||
|
toolbar.add_child(progress_label)
|
||||||
|
var filters := GridContainer.new()
|
||||||
|
filters.columns = 4
|
||||||
|
filters.add_theme_constant_override("h_separation", 8)
|
||||||
|
filters.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
add_child(filters)
|
||||||
|
var group := ButtonGroup.new()
|
||||||
|
for definition in [["All", "all"], ["Incorrect", "incorrect"], ["Unanswered", "unanswered"], ["Flagged", "flagged"]]:
|
||||||
|
var button := Button.new()
|
||||||
|
button.text = definition[0]
|
||||||
|
button.toggle_mode = true
|
||||||
|
button.button_group = group
|
||||||
|
button.button_pressed = definition[1] == "all"
|
||||||
|
Style.style_chip(button, Style.CYAN)
|
||||||
|
button.pressed.connect(_set_filter.bind(definition[1]))
|
||||||
|
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
filters.add_child(button)
|
||||||
|
|
||||||
|
var scroll := ScrollContainer.new()
|
||||||
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
add_child(scroll)
|
||||||
|
content = VBoxContainer.new()
|
||||||
|
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
content.add_theme_constant_override("separation", 10)
|
||||||
|
scroll.add_child(content)
|
||||||
|
empty_label = Label.new()
|
||||||
|
empty_label.visible = false
|
||||||
|
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
Style.set_label(empty_label, 19, Style.MUTED)
|
||||||
|
content.add_child(empty_label)
|
||||||
|
|
||||||
|
var navigation := HBoxContainer.new()
|
||||||
|
navigation.add_theme_constant_override("separation", 10)
|
||||||
|
add_child(navigation)
|
||||||
|
previous_button = Button.new()
|
||||||
|
previous_button.text = "< Previous"
|
||||||
|
previous_button.custom_minimum_size = Vector2(180, 48)
|
||||||
|
Style.style_chip(previous_button, Style.CYAN)
|
||||||
|
previous_button.pressed.connect(_move.bind(-1))
|
||||||
|
navigation.add_child(previous_button)
|
||||||
|
var spacer := Control.new()
|
||||||
|
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
navigation.add_child(spacer)
|
||||||
|
next_button = Button.new()
|
||||||
|
next_button.text = "Next >"
|
||||||
|
next_button.custom_minimum_size = Vector2(180, 48)
|
||||||
|
Style.style_primary_button(next_button)
|
||||||
|
next_button.pressed.connect(_move.bind(1))
|
||||||
|
navigation.add_child(next_button)
|
||||||
|
_build_image_overlay()
|
||||||
|
|
||||||
|
|
||||||
|
func on_show(payload: Variant = null) -> void:
|
||||||
|
if typeof(payload) == TYPE_DICTIONARY:
|
||||||
|
simulation = payload
|
||||||
|
questions = simulation.get("reviewQuestions", [])
|
||||||
|
responses.clear()
|
||||||
|
for response in simulation.get("responses", []):
|
||||||
|
responses[str(response.get("questionExternalId", ""))] = response
|
||||||
|
active_filter = "all"
|
||||||
|
review_position = 0
|
||||||
|
_apply_filter()
|
||||||
|
|
||||||
|
|
||||||
|
func on_hide() -> void:
|
||||||
|
_clear_media_requests()
|
||||||
|
image_overlay.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
|
if not visible or not event.pressed or event.echo:
|
||||||
|
return
|
||||||
|
if event.keycode == KEY_LEFT:
|
||||||
|
_move(-1)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif event.keycode == KEY_RIGHT:
|
||||||
|
_move(1)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif event.keycode == KEY_ESCAPE and image_overlay.visible:
|
||||||
|
image_overlay.visible = false
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_filter(filter_name: String) -> void:
|
||||||
|
active_filter = filter_name
|
||||||
|
review_position = 0
|
||||||
|
_apply_filter()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_filter() -> void:
|
||||||
|
filtered_indices.clear()
|
||||||
|
for index in range(questions.size()):
|
||||||
|
var question: Dictionary = questions[index]
|
||||||
|
var response: Dictionary = responses.get(str(question.get("externalId", "")), {})
|
||||||
|
var selected := str(response.get("choiceId", ""))
|
||||||
|
var correct := str(question.get("correctChoiceId", ""))
|
||||||
|
if _matches_filter(active_filter, selected, correct, bool(response.get("flagged", false))):
|
||||||
|
filtered_indices.append(index)
|
||||||
|
_render_question()
|
||||||
|
|
||||||
|
|
||||||
|
func _move(direction: int) -> void:
|
||||||
|
var target := review_position + direction
|
||||||
|
if target < 0 or target >= filtered_indices.size():
|
||||||
|
return
|
||||||
|
review_position = target
|
||||||
|
_render_question()
|
||||||
|
|
||||||
|
|
||||||
|
func _render_question() -> void:
|
||||||
|
_clear_content()
|
||||||
|
_clear_media_requests()
|
||||||
|
if filtered_indices.is_empty():
|
||||||
|
empty_label = Label.new()
|
||||||
|
empty_label.text = "No questions match this filter."
|
||||||
|
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
Style.set_label(empty_label, 19, Style.MUTED)
|
||||||
|
content.add_child(empty_label)
|
||||||
|
progress_label.text = "0 questions"
|
||||||
|
previous_button.disabled = true
|
||||||
|
next_button.disabled = true
|
||||||
|
return
|
||||||
|
var question_index := filtered_indices[review_position]
|
||||||
|
var question: Dictionary = questions[question_index]
|
||||||
|
var response: Dictionary = responses.get(str(question.get("externalId", "")), {})
|
||||||
|
var selected := str(response.get("choiceId", ""))
|
||||||
|
var correct := str(question.get("correctChoiceId", ""))
|
||||||
|
progress_label.text = "Question %d of %d | Block %d | %s" % [review_position + 1, filtered_indices.size(), _block_for_index(question_index) + 1, "Correct" if selected == correct else "Unanswered" if selected == "" else "Incorrect"]
|
||||||
|
previous_button.disabled = review_position == 0
|
||||||
|
next_button.disabled = review_position + 1 >= filtered_indices.size()
|
||||||
|
_add_text(str(question.get("stem", "")), 18, Style.TEXT)
|
||||||
|
_add_media(question.get("media", []))
|
||||||
|
_add_labs(question.get("labs", []))
|
||||||
|
_add_text(str(question.get("leadIn", "")), 21, Style.TEXT)
|
||||||
|
var explanations: Dictionary = question.get("choiceExplanations", {})
|
||||||
|
for raw_choice in question.get("choices", []):
|
||||||
|
var choice: Dictionary = raw_choice
|
||||||
|
var choice_id := str(choice.get("id", ""))
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
var border := Style.LIME if choice_id == correct else Style.RED if choice_id == selected else Style.BORDER
|
||||||
|
var background := Color("1f7a3a") if choice_id == correct else Color("8f1d2c") if choice_id == selected else Style.SURFACE
|
||||||
|
panel.add_theme_stylebox_override("panel", Style.panel_box(background, border))
|
||||||
|
content.add_child(panel)
|
||||||
|
var choice_content := VBoxContainer.new()
|
||||||
|
panel.add_child(choice_content)
|
||||||
|
var suffix := " [Correct answer]" if choice_id == correct else " [Your answer]" if choice_id == selected else ""
|
||||||
|
var answer := Label.new()
|
||||||
|
answer.text = "%s. %s%s" % [choice_id, str(choice.get("text", "")), suffix]
|
||||||
|
answer.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
Style.set_label(answer, 17, Style.TEXT)
|
||||||
|
choice_content.add_child(answer)
|
||||||
|
var explanation := str(explanations.get(choice_id, ""))
|
||||||
|
if not explanation.is_empty():
|
||||||
|
var detail := Label.new()
|
||||||
|
detail.text = explanation
|
||||||
|
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
Style.set_label(detail, 15, Style.TEXT)
|
||||||
|
choice_content.add_child(detail)
|
||||||
|
_add_heading("Explanation")
|
||||||
|
_add_text(str(question.get("explanation", "")), 17, Style.TEXT)
|
||||||
|
_add_references(question.get("references", []))
|
||||||
|
|
||||||
|
|
||||||
|
func _add_heading(value: String) -> void:
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = value
|
||||||
|
Style.set_label(label, 20, Style.CYAN)
|
||||||
|
content.add_child(label)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_text(value: String, size_value: int, color: Color) -> void:
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = value
|
||||||
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
Style.set_label(label, size_value, color)
|
||||||
|
content.add_child(label)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_labs(labs: Array) -> void:
|
||||||
|
if labs.is_empty():
|
||||||
|
return
|
||||||
|
_add_heading("Laboratory studies")
|
||||||
|
for raw_lab in labs:
|
||||||
|
var lab: Dictionary = raw_lab
|
||||||
|
_add_text("%s: %s" % [str(lab.get("name", lab.get("test", ""))), str(lab.get("value", ""))], 15, Style.TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_references(references: Array) -> void:
|
||||||
|
if references.is_empty():
|
||||||
|
return
|
||||||
|
_add_heading("References")
|
||||||
|
for raw_reference in references:
|
||||||
|
var reference: Dictionary = raw_reference
|
||||||
|
var url := str(reference.get("url", ""))
|
||||||
|
if not url.begins_with("https://"):
|
||||||
|
continue
|
||||||
|
var link := LinkButton.new()
|
||||||
|
link.text = str(reference.get("title", url))
|
||||||
|
link.tooltip_text = url
|
||||||
|
link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
|
link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
|
link.add_theme_color_override("font_color", Style.CYAN)
|
||||||
|
link.pressed.connect(func(target_url: String = url): OS.shell_open(target_url))
|
||||||
|
content.add_child(link)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_media(media: Array) -> void:
|
||||||
|
for raw_url in media:
|
||||||
|
var url := _resolve_media_url(str(raw_url))
|
||||||
|
if url.is_empty():
|
||||||
|
continue
|
||||||
|
var preview := TextureRect.new()
|
||||||
|
preview.custom_minimum_size = Vector2(0, 260)
|
||||||
|
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||||
|
content.add_child(preview)
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
request.request_completed.connect(func(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray): _on_media_loaded(result, code, body, preview))
|
||||||
|
media_requests.append(request)
|
||||||
|
add_child(request)
|
||||||
|
request.request(url)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_media_loaded(result: int, code: int, body: PackedByteArray, preview: TextureRect) -> void:
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300 or not is_instance_valid(preview):
|
||||||
|
return
|
||||||
|
var image := Image.new()
|
||||||
|
var error := image.load_png_from_buffer(body)
|
||||||
|
if error != OK: error = image.load_jpg_from_buffer(body)
|
||||||
|
if error != OK: error = image.load_webp_from_buffer(body)
|
||||||
|
if error != OK: return
|
||||||
|
preview.texture = ImageTexture.create_from_image(image)
|
||||||
|
preview.gui_input.connect(func(event: InputEvent):
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||||
|
image_texture.texture = preview.texture
|
||||||
|
image_overlay.visible = true
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_image_overlay() -> void:
|
||||||
|
image_overlay = Control.new()
|
||||||
|
image_overlay.visible = false
|
||||||
|
image_overlay.top_level = true
|
||||||
|
image_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
image_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(image_overlay)
|
||||||
|
var background := ColorRect.new()
|
||||||
|
background.color = Color(Style.BACKGROUND, 0.97)
|
||||||
|
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
image_overlay.add_child(background)
|
||||||
|
image_texture = TextureRect.new()
|
||||||
|
image_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
image_texture.offset_left = 24
|
||||||
|
image_texture.offset_top = 72
|
||||||
|
image_texture.offset_right = -24
|
||||||
|
image_texture.offset_bottom = -24
|
||||||
|
image_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
image_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
image_overlay.add_child(image_texture)
|
||||||
|
var close := Button.new()
|
||||||
|
close.text = "X"
|
||||||
|
close.position = Vector2(16, 16)
|
||||||
|
close.custom_minimum_size = Vector2(44, 44)
|
||||||
|
Style.style_chip(close, Style.CYAN)
|
||||||
|
close.pressed.connect(func(): image_overlay.visible = false)
|
||||||
|
image_overlay.add_child(close)
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_media_url(value: String) -> String:
|
||||||
|
if value.begins_with("http://") or value.begins_with("https://"):
|
||||||
|
return value
|
||||||
|
var path := value if value.begins_with("/") else "/%s" % value
|
||||||
|
var base := QuestionBankService.question_api_base_url
|
||||||
|
var scheme_end := base.find("://")
|
||||||
|
var path_start := base.find("/", scheme_end + 3)
|
||||||
|
return "%s%s" % [base if path_start < 0 else base.substr(0, path_start), path]
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_content() -> void:
|
||||||
|
for child in content.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_media_requests() -> void:
|
||||||
|
for request in media_requests:
|
||||||
|
if is_instance_valid(request): request.queue_free()
|
||||||
|
media_requests.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _block_for_index(index: int) -> int:
|
||||||
|
var consumed := 0
|
||||||
|
for block in range(BLOCK_SIZES.size()):
|
||||||
|
if index < consumed + BLOCK_SIZES[block]: return block
|
||||||
|
consumed += BLOCK_SIZES[block]
|
||||||
|
return BLOCK_SIZES.size() - 1
|
||||||
|
|
||||||
|
|
||||||
|
static func _matches_filter(filter_name: String, selected: String, correct: String, flagged: bool) -> bool:
|
||||||
|
match filter_name:
|
||||||
|
"incorrect": return selected != "" and selected != correct
|
||||||
|
"unanswered": return selected == ""
|
||||||
|
"flagged": return flagged
|
||||||
|
_: return true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cyt8fuoixldi2
|
||||||
@@ -14,6 +14,8 @@ var retry_button: Button
|
|||||||
var simulation_button: Button
|
var simulation_button: Button
|
||||||
var simulation_status_label: Label
|
var simulation_status_label: Label
|
||||||
var simulation_preview_link: LinkButton
|
var simulation_preview_link: LinkButton
|
||||||
|
var completed_history_list: VBoxContainer
|
||||||
|
var completed_history_more: Button
|
||||||
var pending_simulation_launch := false
|
var pending_simulation_launch := false
|
||||||
var current_stage := "exam"
|
var current_stage := "exam"
|
||||||
var pending_question_bank_launch := false
|
var pending_question_bank_launch := false
|
||||||
@@ -35,6 +37,8 @@ func _ready() -> void:
|
|||||||
ExamSimulationService.request_failed.connect(_on_simulation_failed)
|
ExamSimulationService.request_failed.connect(_on_simulation_failed)
|
||||||
if not AuthState.auth_changed.is_connected(_refresh_simulation_preview_visibility):
|
if not AuthState.auth_changed.is_connected(_refresh_simulation_preview_visibility):
|
||||||
AuthState.auth_changed.connect(_refresh_simulation_preview_visibility)
|
AuthState.auth_changed.connect(_refresh_simulation_preview_visibility)
|
||||||
|
if not ExamSimulationService.completed_history_changed.is_connected(_refresh_completed_history):
|
||||||
|
ExamSimulationService.completed_history_changed.connect(_refresh_completed_history)
|
||||||
|
|
||||||
_build_exam_view()
|
_build_exam_view()
|
||||||
_build_mode_view()
|
_build_mode_view()
|
||||||
@@ -49,6 +53,7 @@ func on_show(payload: Variant = null) -> void:
|
|||||||
_refresh_bank_state()
|
_refresh_bank_state()
|
||||||
_refresh_simulation_preview_visibility()
|
_refresh_simulation_preview_visibility()
|
||||||
ExamSimulationService.load_readiness()
|
ExamSimulationService.load_readiness()
|
||||||
|
ExamSimulationService.load_completed_history()
|
||||||
|
|
||||||
|
|
||||||
func on_hide() -> void:
|
func on_hide() -> void:
|
||||||
@@ -174,6 +179,26 @@ func _build_mode_view() -> void:
|
|||||||
mode_view.add_child(simulation_preview_link)
|
mode_view.add_child(simulation_preview_link)
|
||||||
_refresh_simulation_preview_visibility()
|
_refresh_simulation_preview_visibility()
|
||||||
|
|
||||||
|
var history_heading := Label.new()
|
||||||
|
history_heading.text = "Completed simulations"
|
||||||
|
Style.set_label(history_heading, 20, Style.TEXT)
|
||||||
|
mode_view.add_child(history_heading)
|
||||||
|
completed_history_list = VBoxContainer.new()
|
||||||
|
completed_history_list.add_theme_constant_override("separation", 6)
|
||||||
|
completed_history_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
var history_scroll := ScrollContainer.new()
|
||||||
|
history_scroll.custom_minimum_size = Vector2(0, 150)
|
||||||
|
history_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
history_scroll.add_child(completed_history_list)
|
||||||
|
mode_view.add_child(history_scroll)
|
||||||
|
completed_history_more = Button.new()
|
||||||
|
completed_history_more.text = "Load more"
|
||||||
|
completed_history_more.visible = false
|
||||||
|
completed_history_more.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
|
Style.style_chip(completed_history_more, Style.CYAN)
|
||||||
|
completed_history_more.pressed.connect(func(): ExamSimulationService.load_completed_history(ExamSimulationService.completed_history_next_before))
|
||||||
|
mode_view.add_child(completed_history_more)
|
||||||
|
|
||||||
bank_status_label = Label.new()
|
bank_status_label = Label.new()
|
||||||
bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
Style.set_label(bank_status_label, 16, Style.CYAN)
|
Style.set_label(bank_status_label, 16, Style.CYAN)
|
||||||
@@ -269,6 +294,30 @@ func _refresh_simulation_preview_visibility() -> void:
|
|||||||
simulation_preview_link.visible = AuthState.role.to_upper() == "SUPER"
|
simulation_preview_link.visible = AuthState.role.to_upper() == "SUPER"
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_completed_history() -> void:
|
||||||
|
if completed_history_list == null:
|
||||||
|
return
|
||||||
|
for child in completed_history_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
for raw_summary in ExamSimulationService.completed_history:
|
||||||
|
var summary: Dictionary = raw_summary
|
||||||
|
var button := Button.new()
|
||||||
|
button.text = "%s | Predicted %d (%d-%d) | %d/%d correct" % [
|
||||||
|
str(summary.get("completedUtc", "")).left(10), int(summary.get("predictedScore", 0)),
|
||||||
|
int(summary.get("rangeLow", 0)), int(summary.get("rangeHigh", 0)),
|
||||||
|
int(summary.get("correct", 0)), int(summary.get("total", 0))]
|
||||||
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
Style.style_chip(button, Style.CYAN)
|
||||||
|
button.pressed.connect(_open_completed_simulation.bind(str(summary.get("id", ""))))
|
||||||
|
completed_history_list.add_child(button)
|
||||||
|
completed_history_more.visible = not ExamSimulationService.completed_history_next_before.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func _open_completed_simulation(simulation_id: String) -> void:
|
||||||
|
pending_simulation_launch = true
|
||||||
|
ExamSimulationService.load_simulation(simulation_id)
|
||||||
|
|
||||||
|
|
||||||
func _on_simulation_state_changed() -> void:
|
func _on_simulation_state_changed() -> void:
|
||||||
_refresh_simulation_state()
|
_refresh_simulation_state()
|
||||||
if not pending_simulation_launch or ExamSimulationService.simulation.is_empty():
|
if not pending_simulation_launch or ExamSimulationService.simulation.is_empty():
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var review_script = load("res://scripts/ui/simulation_review.gd")
|
||||||
|
_assert(review_script._matches_filter("all", "A", "B", false), "all should include every response")
|
||||||
|
_assert(review_script._matches_filter("incorrect", "A", "B", false), "incorrect should include wrong answers")
|
||||||
|
_assert(not review_script._matches_filter("incorrect", "", "B", false), "incorrect should exclude unanswered questions")
|
||||||
|
_assert(review_script._matches_filter("unanswered", "", "B", false), "unanswered should include blank responses")
|
||||||
|
_assert(review_script._matches_filter("flagged", "B", "B", true), "flagged should include flagged correct responses")
|
||||||
|
_assert(not review_script._matches_filter("flagged", "A", "B", false), "flagged should exclude unflagged responses")
|
||||||
|
var review = review_script.new()
|
||||||
|
get_root().add_child(review)
|
||||||
|
await process_frame
|
||||||
|
review.on_show({
|
||||||
|
"reviewQuestions": [
|
||||||
|
{"externalId": "q1", "stem": "Stem one", "leadIn": "Lead one", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "A", "choiceExplanations": {"A": "Correct", "B": "Wrong"}, "explanation": "Overall", "labs": [], "media": [], "references": []},
|
||||||
|
{"externalId": "q2", "stem": "Stem two", "leadIn": "Lead two", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "B", "choiceExplanations": {}, "explanation": "Overall", "labs": [], "media": [], "references": []}
|
||||||
|
],
|
||||||
|
"responses": [{"questionExternalId": "q1", "choiceId": "B", "flagged": true}]
|
||||||
|
})
|
||||||
|
_assert(review.filtered_indices.size() == 2, "all filter should build a complete review set")
|
||||||
|
review._set_filter("incorrect")
|
||||||
|
_assert(review.filtered_indices == [0], "incorrect filter should retain only answered misses")
|
||||||
|
review._set_filter("unanswered")
|
||||||
|
_assert(review.filtered_indices == [1], "unanswered filter should retain blank responses")
|
||||||
|
await process_frame
|
||||||
|
review.queue_free()
|
||||||
|
await process_frame
|
||||||
|
quit(1 if failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _assert(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
return
|
||||||
|
failures += 1
|
||||||
|
push_error(message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dn2mvv1kmhup3
|
||||||
Reference in New Issue
Block a user