Add question reporting and reference reviews
@@ -32,6 +32,94 @@ The Godot client reads this from `project.godot`:
|
|||||||
step3/questions_api_url
|
step3/questions_api_url
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Question Image Copyright Review
|
||||||
|
|
||||||
|
All questions containing images must receive a copyright and licensing review
|
||||||
|
before publication or distribution. This applies both to newly submitted images
|
||||||
|
and to every image already stored in the question bank.
|
||||||
|
|
||||||
|
For each image-bearing question, document:
|
||||||
|
|
||||||
|
- the original source and creator, when known;
|
||||||
|
- the applicable license or written permission;
|
||||||
|
- whether attribution is required and where it is displayed;
|
||||||
|
- whether the image was modified or generated;
|
||||||
|
- the reviewer and review date.
|
||||||
|
|
||||||
|
Do not publish an image when its source or reuse rights cannot be established.
|
||||||
|
Remove or replace images that fail review. Images added for development or
|
||||||
|
manual testing are not exempt from this requirement.
|
||||||
|
|
||||||
|
Outstanding action: audit every existing question with a non-empty `media`
|
||||||
|
field before treating the current question bank as cleared for release.
|
||||||
|
|
||||||
|
## Question Text Originality Review
|
||||||
|
|
||||||
|
Every question must also receive an originality and copyright review before
|
||||||
|
publication or distribution. Review the stem, lead-in, answer choices,
|
||||||
|
explanations, follow-up questions, teaching points, and distinctive combinations
|
||||||
|
of patient details for substantial overlap with copyrighted question banks,
|
||||||
|
books, articles, screenshots, or other source material.
|
||||||
|
|
||||||
|
Changing only names, ages, individual values, or a few words is not sufficient
|
||||||
|
when the structure, sequence, and expression remain recognizably derived from a
|
||||||
|
copyrighted source. Clinical facts and concepts may be used, but each question
|
||||||
|
must express them independently with original organization and wording.
|
||||||
|
|
||||||
|
For each reviewed question, document the reviewer, review date, any reference
|
||||||
|
materials consulted, and whether revisions were required. Do not publish a
|
||||||
|
question when substantial textual or structural overlap remains unresolved.
|
||||||
|
|
||||||
|
Outstanding action: perform this review for every existing question in the
|
||||||
|
question bank, not only newly submitted questions, before considering the bank
|
||||||
|
cleared for release.
|
||||||
|
|
||||||
|
## Question Verification References
|
||||||
|
|
||||||
|
Every question must provide direct reference links to authoritative sources
|
||||||
|
that allow reviewers to verify the clinical, scientific, statistical, or
|
||||||
|
ethical information used in the stem, answer key, and explanation. References
|
||||||
|
are required before publication, even when the tested fact is considered
|
||||||
|
standard knowledge.
|
||||||
|
|
||||||
|
Prefer current primary or authoritative sources, including professional society
|
||||||
|
guidelines, government health agencies, original research, and official drug or
|
||||||
|
product labeling. Use secondary sources only when a suitable primary or
|
||||||
|
authoritative source is unavailable. Link to the specific guideline, article,
|
||||||
|
or document rather than a search-results page or an organization's home page.
|
||||||
|
|
||||||
|
For each question, document:
|
||||||
|
|
||||||
|
- at least one direct reference URL;
|
||||||
|
- the title and publishing organization for each reference;
|
||||||
|
- which key claim or teaching point the reference supports;
|
||||||
|
- the date the source was accessed and the reviewer who verified it.
|
||||||
|
|
||||||
|
References must support the keyed answer and any management recommendations,
|
||||||
|
diagnostic thresholds, formulas, or numerical claims. A citation does not grant
|
||||||
|
permission to copy wording or media; the separate text-originality and image-
|
||||||
|
copyright requirements still apply. If the question schema does not expose a
|
||||||
|
reference field, retain this information in the associated authoring or review
|
||||||
|
record until it can be stored with the question.
|
||||||
|
|
||||||
|
## Duplicate Question Review
|
||||||
|
|
||||||
|
Every question must be checked for duplicates before publication. This review
|
||||||
|
must detect both exact duplicates and near-duplicates that test the same concept
|
||||||
|
with substantially equivalent stems, decisive findings, lead-ins, answer sets,
|
||||||
|
or teaching objectives despite different wording or external IDs.
|
||||||
|
|
||||||
|
Repeated coverage of a learning objective is acceptable when the presentation,
|
||||||
|
reasoning path, or learner task is meaningfully different. Reject exact copies
|
||||||
|
and near-duplicates that merely change a patient age, reorder answers, or make
|
||||||
|
minor value and wording changes. When substantial duplication remains, retain
|
||||||
|
the stronger question, merge any useful unique teaching material, and remove or
|
||||||
|
substantially differentiate the other version.
|
||||||
|
|
||||||
|
Document the reviewer, review date, compared question IDs, and disposition of
|
||||||
|
each potential duplicate. Outstanding action: run this review across the entire
|
||||||
|
existing question bank before considering it cleared for release.
|
||||||
|
|
||||||
## Push Build
|
## Push Build
|
||||||
|
|
||||||
`.github/workflows/questions-api.yml` builds and publishes the Questions API Docker image to GitHub Container Registry on pushes to `main`.
|
`.github/workflows/questions-api.yml` builds and publishes the Questions API Docker image to GitHub Container Registry on pushes to `main`.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
using QuestionsApi.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/question-reports")]
|
||||||
|
public class QuestionReportsController : ControllerBase
|
||||||
|
{
|
||||||
|
private const int MinDetailsLength = 10;
|
||||||
|
private const int MaxDetailsLength = 2000;
|
||||||
|
private const int MaxReviewerNoteLength = 2000;
|
||||||
|
|
||||||
|
private readonly QuestionStore _questions;
|
||||||
|
private readonly QuestionReportStore _reports;
|
||||||
|
|
||||||
|
public QuestionReportsController(QuestionStore questions, QuestionReportStore reports)
|
||||||
|
{
|
||||||
|
_questions = questions;
|
||||||
|
_reports = reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateQuestionReportRequest request)
|
||||||
|
{
|
||||||
|
var questionExternalId = (request.QuestionExternalId ?? "").Trim();
|
||||||
|
var category = (request.Category ?? "").Trim().ToLowerInvariant();
|
||||||
|
var details = (request.Details ?? "").Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(questionExternalId))
|
||||||
|
return BadRequest("questionExternalId required");
|
||||||
|
if (!QuestionReportCategory.All.Contains(category))
|
||||||
|
return BadRequest("category is invalid");
|
||||||
|
if (details.Length < MinDetailsLength || details.Length > MaxDetailsLength)
|
||||||
|
return BadRequest($"details must be between {MinDetailsLength} and {MaxDetailsLength} characters");
|
||||||
|
|
||||||
|
var question = await _questions.GetByExternalIdAsync(questionExternalId);
|
||||||
|
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var userId = CurrentUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var report = await _reports.CreateAsync(question, userId, category, details);
|
||||||
|
return StatusCode(StatusCodes.Status201Created, new CreateQuestionReportResponse
|
||||||
|
{
|
||||||
|
Id = report.Id ?? "",
|
||||||
|
QuestionExternalId = report.QuestionExternalId,
|
||||||
|
Category = report.Category,
|
||||||
|
Status = report.Status,
|
||||||
|
CreatedUtc = report.CreatedUtc
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Roles = "SUPER")]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] string status = QuestionReportStatus.Open,
|
||||||
|
[FromQuery] string category = "",
|
||||||
|
[FromQuery] string questionExternalId = "",
|
||||||
|
[FromQuery] string userId = "",
|
||||||
|
[FromQuery] int skip = 0,
|
||||||
|
[FromQuery] int limit = 50)
|
||||||
|
{
|
||||||
|
status = status.Trim().ToLowerInvariant();
|
||||||
|
category = category.Trim().ToLowerInvariant();
|
||||||
|
questionExternalId = questionExternalId.Trim();
|
||||||
|
userId = userId.Trim();
|
||||||
|
|
||||||
|
if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status))
|
||||||
|
return BadRequest("status is invalid");
|
||||||
|
if (!string.IsNullOrWhiteSpace(category) && !QuestionReportCategory.All.Contains(category))
|
||||||
|
return BadRequest("category is invalid");
|
||||||
|
if (skip < 0)
|
||||||
|
return BadRequest("skip must be zero or greater");
|
||||||
|
if (limit is < 1 or > 100)
|
||||||
|
return BadRequest("limit must be between 1 and 100");
|
||||||
|
|
||||||
|
return Ok(await _reports.SearchAsync(status, category, questionExternalId, userId, skip, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
[Authorize(Roles = "SUPER")]
|
||||||
|
public async Task<IActionResult> Get(string id)
|
||||||
|
{
|
||||||
|
var report = await _reports.GetByIdAsync(id.Trim());
|
||||||
|
return report is null ? NotFound() : Ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id}/status")]
|
||||||
|
[Authorize(Roles = "SUPER")]
|
||||||
|
public async Task<IActionResult> UpdateStatus(string id, [FromBody] UpdateQuestionReportStatusRequest request)
|
||||||
|
{
|
||||||
|
var status = (request.Status ?? "").Trim().ToLowerInvariant();
|
||||||
|
var reviewerNote = (request.ReviewerNote ?? "").Trim();
|
||||||
|
if (!QuestionReportStatus.Terminal.Contains(status))
|
||||||
|
return BadRequest("status must be resolved or dismissed");
|
||||||
|
if (reviewerNote.Length > MaxReviewerNoteLength)
|
||||||
|
return BadRequest($"reviewerNote must be {MaxReviewerNoteLength} characters or fewer");
|
||||||
|
|
||||||
|
var existing = await _reports.GetByIdAsync(id.Trim());
|
||||||
|
if (existing is null)
|
||||||
|
return NotFound();
|
||||||
|
if (existing.Status != QuestionReportStatus.Open)
|
||||||
|
return Conflict("report is already closed");
|
||||||
|
|
||||||
|
var reviewerUserId = CurrentUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(reviewerUserId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var updated = await _reports.CloseAsync(id.Trim(), status, reviewerUserId, reviewerNote);
|
||||||
|
return updated is null ? Conflict("report is already closed") : Ok(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CurrentUserId() =>
|
||||||
|
User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.Name)
|
||||||
|
?? User.Identity?.Name
|
||||||
|
?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Models;
|
||||||
|
|
||||||
|
public static class QuestionReportCategory
|
||||||
|
{
|
||||||
|
public const string Clarity = "clarity";
|
||||||
|
public const string MedicalAccuracy = "medical_accuracy";
|
||||||
|
public const string AnswerExplanation = "answer_explanation";
|
||||||
|
public const string FormattingMedia = "formatting_media";
|
||||||
|
public const string TechnicalIssue = "technical_issue";
|
||||||
|
public const string Other = "other";
|
||||||
|
|
||||||
|
public static readonly HashSet<string> All =
|
||||||
|
[
|
||||||
|
Clarity,
|
||||||
|
MedicalAccuracy,
|
||||||
|
AnswerExplanation,
|
||||||
|
FormattingMedia,
|
||||||
|
TechnicalIssue,
|
||||||
|
Other
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class QuestionReportStatus
|
||||||
|
{
|
||||||
|
public const string Open = "open";
|
||||||
|
public const string Resolved = "resolved";
|
||||||
|
public const string Dismissed = "dismissed";
|
||||||
|
|
||||||
|
public static readonly HashSet<string> Terminal = [Resolved, Dismissed];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuestionReport
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
public string QuestionExternalId { get; set; } = "";
|
||||||
|
public string QuestionMongoId { get; set; } = "";
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string Details { get; set; } = "";
|
||||||
|
public string Status { get; set; } = QuestionReportStatus.Open;
|
||||||
|
public QuestionReportSnapshot QuestionSnapshot { get; set; } = new();
|
||||||
|
public string ReviewedByUserId { get; set; } = "";
|
||||||
|
public string ReviewerNote { get; set; } = "";
|
||||||
|
public DateTime? ReviewedUtc { get; set; }
|
||||||
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuestionReportSnapshot
|
||||||
|
{
|
||||||
|
public string Stem { get; set; } = "";
|
||||||
|
public string LeadIn { get; set; } = "";
|
||||||
|
public List<QuestionChoice> Choices { get; set; } = [];
|
||||||
|
public string CorrectChoiceId { get; set; } = "";
|
||||||
|
public string Explanation { get; set; } = "";
|
||||||
|
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||||
|
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
|
||||||
|
public List<QuestionLab> Labs { get; set; } = [];
|
||||||
|
public List<string> Media { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateQuestionReportRequest
|
||||||
|
{
|
||||||
|
public string QuestionExternalId { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string Details { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateQuestionReportResponse
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string QuestionExternalId { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string Status { get; set; } = QuestionReportStatus.Open;
|
||||||
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateQuestionReportStatusRequest
|
||||||
|
{
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public string ReviewerNote { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuestionReportListResponse
|
||||||
|
{
|
||||||
|
public List<QuestionReport> Items { get; set; } = [];
|
||||||
|
public long Total { get; set; }
|
||||||
|
public int Skip { get; set; }
|
||||||
|
public int Limit { get; set; }
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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.AddSingleton<QuestionMediaStore>();
|
||||||
|
builder.Services.AddSingleton<QuestionReportStore>();
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c =>
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
|||||||
@@ -12,5 +12,15 @@ STEP-style question bank service for study games and clients.
|
|||||||
- `POST /api/questions/bulk` Upsert questions by `externalId` (SUPER only).
|
- `POST /api/questions/bulk` Upsert questions by `externalId` (SUPER only).
|
||||||
- `PUT /api/questions/{id}` Update question (SUPER only).
|
- `PUT /api/questions/{id}` Update question (SUPER only).
|
||||||
- `DELETE /api/questions/{id}` Delete question (SUPER only).
|
- `DELETE /api/questions/{id}` Delete question (SUPER only).
|
||||||
|
- `POST /api/question-reports` Submit a categorized question report (USER or SUPER).
|
||||||
|
- `GET /api/question-reports` List and filter reports, newest first (SUPER only).
|
||||||
|
- `GET /api/question-reports/{id}` Get a report and its question snapshot (SUPER only).
|
||||||
|
- `PATCH /api/question-reports/{id}/status` Resolve or dismiss an open report (SUPER only).
|
||||||
|
|
||||||
Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`.
|
Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`.
|
||||||
|
|
||||||
|
Questions submitted through authoring endpoints must have authoritative direct
|
||||||
|
reference links recorded as described in the repository's **Question
|
||||||
|
Verification References** policy. The references must verify the keyed answer
|
||||||
|
and the material claims in the explanation; search-result links are not
|
||||||
|
acceptable.
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
|
public class QuestionReportStore
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<QuestionReport> _reports;
|
||||||
|
|
||||||
|
public QuestionReportStore(IConfiguration cfg)
|
||||||
|
{
|
||||||
|
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||||
|
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||||
|
var client = new MongoClient(connectionString);
|
||||||
|
var database = client.GetDatabase(databaseName);
|
||||||
|
_reports = database.GetCollection<QuestionReport>("QuestionReports");
|
||||||
|
|
||||||
|
_reports.Indexes.CreateMany(
|
||||||
|
[
|
||||||
|
new CreateIndexModel<QuestionReport>(
|
||||||
|
Builders<QuestionReport>.IndexKeys
|
||||||
|
.Ascending(r => r.Status)
|
||||||
|
.Descending(r => r.CreatedUtc)),
|
||||||
|
new CreateIndexModel<QuestionReport>(
|
||||||
|
Builders<QuestionReport>.IndexKeys.Ascending(r => r.QuestionExternalId)),
|
||||||
|
new CreateIndexModel<QuestionReport>(
|
||||||
|
Builders<QuestionReport>.IndexKeys.Ascending(r => r.UserId))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuestionReport> CreateAsync(Question question, string userId, string category, string details)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var report = new QuestionReport
|
||||||
|
{
|
||||||
|
QuestionExternalId = question.ExternalId,
|
||||||
|
QuestionMongoId = question.Id ?? "",
|
||||||
|
UserId = userId,
|
||||||
|
Category = category,
|
||||||
|
Details = details,
|
||||||
|
Status = QuestionReportStatus.Open,
|
||||||
|
QuestionSnapshot = new QuestionReportSnapshot
|
||||||
|
{
|
||||||
|
Stem = question.Stem,
|
||||||
|
LeadIn = question.LeadIn,
|
||||||
|
Choices = question.Choices
|
||||||
|
.Select(choice => new QuestionChoice { Id = choice.Id, Text = choice.Text })
|
||||||
|
.ToList(),
|
||||||
|
CorrectChoiceId = question.CorrectChoiceId,
|
||||||
|
Explanation = question.Explanation,
|
||||||
|
ChoiceExplanations = new Dictionary<string, string>(question.ChoiceExplanations),
|
||||||
|
WrongAnswerFollowups = question.WrongAnswerFollowups.ToDictionary(
|
||||||
|
item => item.Key,
|
||||||
|
item => new WrongAnswerFollowup
|
||||||
|
{
|
||||||
|
Prompt = item.Value.Prompt,
|
||||||
|
Choices = item.Value.Choices
|
||||||
|
.Select(choice => new QuestionChoice { Id = choice.Id, Text = choice.Text })
|
||||||
|
.ToList(),
|
||||||
|
CorrectChoiceId = item.Value.CorrectChoiceId,
|
||||||
|
TeachingPoint = item.Value.TeachingPoint
|
||||||
|
}),
|
||||||
|
Labs = question.Labs
|
||||||
|
.Select(lab => new QuestionLab
|
||||||
|
{
|
||||||
|
Name = lab.Name,
|
||||||
|
Value = lab.Value,
|
||||||
|
Unit = lab.Unit,
|
||||||
|
Reference = lab.Reference
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
Media = [.. question.Media]
|
||||||
|
},
|
||||||
|
CreatedUtc = now,
|
||||||
|
UpdatedUtc = now
|
||||||
|
};
|
||||||
|
|
||||||
|
await _reports.InsertOneAsync(report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuestionReport?> GetByIdAsync(string id)
|
||||||
|
{
|
||||||
|
if (!ObjectId.TryParse(id, out _))
|
||||||
|
return null;
|
||||||
|
return await _reports.Find(r => r.Id == id).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuestionReportListResponse> SearchAsync(
|
||||||
|
string status,
|
||||||
|
string category,
|
||||||
|
string questionExternalId,
|
||||||
|
string userId,
|
||||||
|
int skip,
|
||||||
|
int limit)
|
||||||
|
{
|
||||||
|
var filter = Builders<QuestionReport>.Filter.Empty;
|
||||||
|
if (!string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
|
||||||
|
filter &= Builders<QuestionReport>.Filter.Eq(r => r.Status, status);
|
||||||
|
if (!string.IsNullOrWhiteSpace(category))
|
||||||
|
filter &= Builders<QuestionReport>.Filter.Eq(r => r.Category, category);
|
||||||
|
if (!string.IsNullOrWhiteSpace(questionExternalId))
|
||||||
|
filter &= Builders<QuestionReport>.Filter.Eq(r => r.QuestionExternalId, questionExternalId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(userId))
|
||||||
|
filter &= Builders<QuestionReport>.Filter.Eq(r => r.UserId, userId);
|
||||||
|
|
||||||
|
var total = await _reports.CountDocumentsAsync(filter);
|
||||||
|
var items = await _reports
|
||||||
|
.Find(filter)
|
||||||
|
.SortByDescending(r => r.CreatedUtc)
|
||||||
|
.Skip(skip)
|
||||||
|
.Limit(limit)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new QuestionReportListResponse
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Total = total,
|
||||||
|
Skip = skip,
|
||||||
|
Limit = limit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuestionReport?> CloseAsync(string id, string status, string reviewerUserId, string reviewerNote)
|
||||||
|
{
|
||||||
|
if (!ObjectId.TryParse(id, out _))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var filter = Builders<QuestionReport>.Filter.And(
|
||||||
|
Builders<QuestionReport>.Filter.Eq(r => r.Id, id),
|
||||||
|
Builders<QuestionReport>.Filter.Eq(r => r.Status, QuestionReportStatus.Open));
|
||||||
|
var update = Builders<QuestionReport>.Update
|
||||||
|
.Set(r => r.Status, status)
|
||||||
|
.Set(r => r.ReviewerNote, reviewerNote)
|
||||||
|
.Set(r => r.ReviewedByUserId, reviewerUserId)
|
||||||
|
.Set(r => r.ReviewedUtc, now)
|
||||||
|
.Set(r => r.UpdatedUtc, now);
|
||||||
|
|
||||||
|
return await _reports.FindOneAndUpdateAsync(
|
||||||
|
filter,
|
||||||
|
update,
|
||||||
|
new FindOneAndUpdateOptions<QuestionReport> { ReturnDocument = ReturnDocument.After });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.1 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://b1ul1abd3ancy"
|
||||||
|
path="res://.godot/imported/alopecia-areata-occipital-001.png-cac14e23748bed69672554273e867cf9.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/alopecia-areata-occipital-001.png"
|
||||||
|
dest_files=["res://.godot/imported/alopecia-areata-occipital-001.png-cac14e23748bed69672554273e867cf9.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cf3a0cl2atyw8"
|
||||||
|
path="res://.godot/imported/asbestos-ferruginous-body-528.png-e5de794a289107157349231884cf169c.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/asbestos-ferruginous-body-528.png"
|
||||||
|
dest_files=["res://.godot/imported/asbestos-ferruginous-body-528.png-e5de794a289107157349231884cf169c.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://2phukqqwkr0j"
|
||||||
|
path="res://.godot/imported/atrial-fibrillation-controlled-response-5172.png-9fe67e541e5dbd55542ed0f497a51d66.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/atrial-fibrillation-controlled-response-5172.png"
|
||||||
|
dest_files=["res://.godot/imported/atrial-fibrillation-controlled-response-5172.png-9fe67e541e5dbd55542ed0f497a51d66.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cgaj6mqqk8txf"
|
||||||
|
path="res://.godot/imported/hsv-grouped-vesicles-mucocutaneous-3041.png-4f4d86d3e278d5b18e7cf944720c4403.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/hsv-grouped-vesicles-mucocutaneous-3041.png"
|
||||||
|
dest_files=["res://.godot/imported/hsv-grouped-vesicles-mucocutaneous-3041.png-4f4d86d3e278d5b18e7cf944720c4403.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://c12lrclgs8xtp"
|
||||||
|
path="res://.godot/imported/huntington-pedigree-3528.png-ef596230c5e63c2dbbdf5e9df3061b8f.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/huntington-pedigree-3528.png"
|
||||||
|
dest_files=["res://.godot/imported/huntington-pedigree-3528.png-ef596230c5e63c2dbbdf5e9df3061b8f.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720">
|
||||||
|
<rect width="1200" height="720" fill="#f8fafc"/>
|
||||||
|
<g stroke="#172033" stroke-width="8" fill="none" stroke-linecap="square">
|
||||||
|
<line x1="420" y1="135" x2="780" y2="135"/>
|
||||||
|
<line x1="600" y1="135" x2="600" y2="245"/>
|
||||||
|
<line x1="375" y1="245" x2="825" y2="245"/>
|
||||||
|
<line x1="375" y1="245" x2="375" y2="330"/>
|
||||||
|
<line x1="825" y1="245" x2="825" y2="330"/>
|
||||||
|
<line x1="425" y1="385" x2="560" y2="385"/>
|
||||||
|
<line x1="492" y1="385" x2="492" y2="500"/>
|
||||||
|
<line x1="467" y1="500" x2="720" y2="500"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#172033" stroke-width="8">
|
||||||
|
<rect x="370" y="85" width="100" height="100" rx="2" fill="#172033"/>
|
||||||
|
<circle cx="830" cy="135" r="50" fill="#f8fafc"/>
|
||||||
|
<circle cx="375" cy="385" r="50" fill="#172033"/>
|
||||||
|
<rect x="560" y="335" width="100" height="100" rx="2" fill="#f8fafc"/>
|
||||||
|
<rect x="775" y="335" width="100" height="100" rx="2" fill="#172033"/>
|
||||||
|
<rect x="417" y="500" width="100" height="100" rx="2" fill="#f8fafc"/>
|
||||||
|
<circle cx="770" cy="550" r="50" fill="#f8fafc"/>
|
||||||
|
</g>
|
||||||
|
<g font-family="Arial, sans-serif" font-size="30" fill="#4b5563">
|
||||||
|
<text x="70" y="145">I</text>
|
||||||
|
<text x="70" y="395">II</text>
|
||||||
|
<text x="70" y="565">III</text>
|
||||||
|
<text x="370" y="660">At-risk husband</text>
|
||||||
|
<text x="700" y="660">Wife</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dvprnn2mwq7h0"
|
||||||
|
path="res://.godot/imported/huntington-pedigree-3528.svg-b6915e7f1c86a790ca2b1d9134425a60.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/huntington-pedigree-3528.svg"
|
||||||
|
dest_files=["res://.godot/imported/huntington-pedigree-3528.svg-b6915e7f1c86a790ca2b1d9134425a60.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
svg/scale=1.0
|
||||||
|
editor/scale_with_editor_scale=false
|
||||||
|
editor/convert_colors_with_editor_theme=false
|
||||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cg0f4dp5l5puy"
|
||||||
|
path="res://.godot/imported/papilledema-fundus-original.png-e6cfd47dd210cfce0b1895feccd9bdf7.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/papilledema-fundus-original.png"
|
||||||
|
dest_files=["res://.godot/imported/papilledema-fundus-original.png-e6cfd47dd210cfce0b1895feccd9bdf7.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dq7r8yq65kutq"
|
||||||
|
path="res://.godot/imported/pcp-diffuse-bilateral-interstitial-cxr-2801.png-9b10bd4d25c9c74ad7b984ef2a458d32.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/pcp-diffuse-bilateral-interstitial-cxr-2801.png"
|
||||||
|
dest_files=["res://.godot/imported/pcp-diffuse-bilateral-interstitial-cxr-2801.png-9b10bd4d25c9c74ad7b984ef2a458d32.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://blladq71aek48"
|
||||||
|
path="res://.godot/imported/pediatric-left-lower-lobe-postobstructive-pneumonia.png-65c5f5042ce198b18c2a8356a93ca274.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/pediatric-left-lower-lobe-postobstructive-pneumonia.png"
|
||||||
|
dest_files=["res://.godot/imported/pediatric-left-lower-lobe-postobstructive-pneumonia.png-65c5f5042ce198b18c2a8356a93ca274.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dpbme1e646oto"
|
||||||
|
path="res://.godot/imported/step3-cardiology-preop-ecg-incidental-552.png-98ee131255d583fc3b714377bb7f05e2.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/step3-cardiology-preop-ecg-incidental-552.png"
|
||||||
|
dest_files=["res://.godot/imported/step3-cardiology-preop-ecg-incidental-552.png-98ee131255d583fc3b714377bb7f05e2.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://daws5c8gngey"
|
||||||
|
path="res://.godot/imported/step3-infectious-bacterial-meningitis-3001.png-d415a86d7d931dce1df2789095c995e1.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/question_images/step3-infectious-bacterial-meningitis-3001.png"
|
||||||
|
dest_files=["res://.godot/imported/step3-infectious-bacterial-meningitis-3001.png-d415a86d7d931dce1df2789095c995e1.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
@@ -40,3 +40,4 @@ rendering_device/driver.windows="d3d12"
|
|||||||
[step3]
|
[step3]
|
||||||
|
|
||||||
questions_api_url="https://s3q.ranaze.com/api/questions/search"
|
questions_api_url="https://s3q.ranaze.com/api/questions/search"
|
||||||
|
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search"
|
|||||||
const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first"
|
const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first"
|
||||||
const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions"
|
const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions"
|
||||||
const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me"
|
const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me"
|
||||||
|
const DEFAULT_QUESTION_REPORTS_API_URL := "https://s3q.ranaze.com/api/question-reports"
|
||||||
const RETRY_DELAYS := [2, 5, 10, 20, 30]
|
const RETRY_DELAYS := [2, 5, 10, 20, 30]
|
||||||
|
|
||||||
signal questions_changed
|
signal questions_changed
|
||||||
@@ -12,17 +13,21 @@ signal choice_percentages_updated(question_id: String, percentages: Dictionary)
|
|||||||
signal ratings_changed
|
signal ratings_changed
|
||||||
signal question_rating_updated(question_id: String, question_elo: int, rating: Dictionary)
|
signal question_rating_updated(question_id: String, question_elo: int, rating: Dictionary)
|
||||||
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
|
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
|
||||||
|
signal question_report_submitted(question_id: String, report_id: String)
|
||||||
|
signal question_report_failed(question_id: String, message: String)
|
||||||
|
|
||||||
var questions: Array = []
|
var questions: Array = []
|
||||||
var request: HTTPRequest
|
var request: HTTPRequest
|
||||||
var attempt_request: HTTPRequest
|
var attempt_request: HTTPRequest
|
||||||
var rating_request: HTTPRequest
|
var rating_request: HTTPRequest
|
||||||
var skip_request: HTTPRequest
|
var skip_request: HTTPRequest
|
||||||
|
var report_request: HTTPRequest
|
||||||
var retry_timer: Timer
|
var retry_timer: Timer
|
||||||
var questions_api_url := DEFAULT_QUESTIONS_API_URL
|
var questions_api_url := DEFAULT_QUESTIONS_API_URL
|
||||||
var question_attempt_api_url := DEFAULT_QUESTION_ATTEMPT_API_URL
|
var question_attempt_api_url := DEFAULT_QUESTION_ATTEMPT_API_URL
|
||||||
var question_api_base_url := DEFAULT_QUESTION_API_BASE_URL
|
var question_api_base_url := DEFAULT_QUESTION_API_BASE_URL
|
||||||
var ratings_api_url := DEFAULT_RATINGS_API_URL
|
var ratings_api_url := DEFAULT_RATINGS_API_URL
|
||||||
|
var question_reports_api_url := DEFAULT_QUESTION_REPORTS_API_URL
|
||||||
var is_loading := false
|
var is_loading := false
|
||||||
var last_error := ""
|
var last_error := ""
|
||||||
var load_state := "idle"
|
var load_state := "idle"
|
||||||
@@ -32,12 +37,14 @@ var overall_elo := 1000
|
|||||||
var rated_question_count := 0
|
var rated_question_count := 0
|
||||||
var category_elos := {}
|
var category_elos := {}
|
||||||
var last_rating_change := {}
|
var last_rating_change := {}
|
||||||
|
var pending_report_question_id := ""
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
|
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
|
||||||
question_attempt_api_url = str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL))
|
question_attempt_api_url = str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL))
|
||||||
question_api_base_url = str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL))
|
question_api_base_url = str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL))
|
||||||
ratings_api_url = str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL))
|
ratings_api_url = str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL))
|
||||||
|
question_reports_api_url = str(ProjectSettings.get_setting("step3/question_reports_api_url", DEFAULT_QUESTION_REPORTS_API_URL))
|
||||||
request = HTTPRequest.new()
|
request = HTTPRequest.new()
|
||||||
request.request_completed.connect(_on_api_questions_loaded)
|
request.request_completed.connect(_on_api_questions_loaded)
|
||||||
add_child(request)
|
add_child(request)
|
||||||
@@ -54,6 +61,10 @@ func _ready() -> void:
|
|||||||
skip_request.request_completed.connect(_on_question_skipped)
|
skip_request.request_completed.connect(_on_question_skipped)
|
||||||
add_child(skip_request)
|
add_child(skip_request)
|
||||||
|
|
||||||
|
report_request = HTTPRequest.new()
|
||||||
|
report_request.request_completed.connect(_on_question_report_submitted)
|
||||||
|
add_child(report_request)
|
||||||
|
|
||||||
retry_timer = Timer.new()
|
retry_timer = Timer.new()
|
||||||
retry_timer.one_shot = true
|
retry_timer.one_shot = true
|
||||||
retry_timer.timeout.connect(_on_retry_timer_timeout)
|
retry_timer.timeout.connect(_on_retry_timer_timeout)
|
||||||
@@ -135,6 +146,8 @@ func clear_questions() -> void:
|
|||||||
rating_request.cancel_request()
|
rating_request.cancel_request()
|
||||||
if skip_request != null:
|
if skip_request != null:
|
||||||
skip_request.cancel_request()
|
skip_request.cancel_request()
|
||||||
|
if report_request != null:
|
||||||
|
report_request.cancel_request()
|
||||||
if retry_timer != null:
|
if retry_timer != null:
|
||||||
retry_timer.stop()
|
retry_timer.stop()
|
||||||
questions.clear()
|
questions.clear()
|
||||||
@@ -147,6 +160,7 @@ func clear_questions() -> void:
|
|||||||
rated_question_count = 0
|
rated_question_count = 0
|
||||||
category_elos = {}
|
category_elos = {}
|
||||||
last_rating_change = {}
|
last_rating_change = {}
|
||||||
|
pending_report_question_id = ""
|
||||||
questions_changed.emit()
|
questions_changed.emit()
|
||||||
ratings_changed.emit()
|
ratings_changed.emit()
|
||||||
|
|
||||||
@@ -182,6 +196,37 @@ func skip_question(question_id: String) -> void:
|
|||||||
push_warning("Failed to record skipped question.")
|
push_warning("Failed to record skipped question.")
|
||||||
|
|
||||||
|
|
||||||
|
func submit_question_report(question_id: String, category: String, details: String) -> bool:
|
||||||
|
if AuthState.access_token.is_empty() or question_id == "":
|
||||||
|
question_report_failed.emit(question_id, "Login is required to submit a report.")
|
||||||
|
return false
|
||||||
|
if report_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
question_report_failed.emit(question_id, "Another report is still being submitted.")
|
||||||
|
return false
|
||||||
|
|
||||||
|
var headers := PackedStringArray([
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"Authorization: Bearer %s" % AuthState.access_token
|
||||||
|
])
|
||||||
|
var payload := {
|
||||||
|
"questionExternalId": question_id,
|
||||||
|
"category": category,
|
||||||
|
"details": details
|
||||||
|
}
|
||||||
|
pending_report_question_id = question_id
|
||||||
|
var err := report_request.request(
|
||||||
|
question_reports_api_url,
|
||||||
|
headers,
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
JSON.stringify(payload)
|
||||||
|
)
|
||||||
|
if err != OK:
|
||||||
|
pending_report_question_id = ""
|
||||||
|
question_report_failed.emit(question_id, "The report could not be submitted. Please try again.")
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
func load_ratings_from_api() -> void:
|
func load_ratings_from_api() -> void:
|
||||||
if AuthState.access_token.is_empty():
|
if AuthState.access_token.is_empty():
|
||||||
return
|
return
|
||||||
@@ -305,6 +350,35 @@ func _on_question_skipped(result: int, response_code: int, _headers: PackedStrin
|
|||||||
question_skipped.emit(question_id, correct_choice_id, question_elo, rating)
|
question_skipped.emit(question_id, correct_choice_id, question_elo, rating)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_question_report_submitted(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||||
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||||
|
var question_id := ""
|
||||||
|
if typeof(parsed) == TYPE_DICTIONARY:
|
||||||
|
question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||||
|
if question_id == "":
|
||||||
|
question_id = pending_report_question_id
|
||||||
|
pending_report_question_id = ""
|
||||||
|
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
|
var message := "The report could not be submitted. Please try again."
|
||||||
|
if typeof(parsed) == TYPE_DICTIONARY:
|
||||||
|
message = str(parsed.get("detail", parsed.get("title", message)))
|
||||||
|
elif typeof(parsed) == TYPE_STRING and not str(parsed).strip_edges().is_empty():
|
||||||
|
message = str(parsed).strip_edges()
|
||||||
|
question_report_failed.emit(question_id, message)
|
||||||
|
return
|
||||||
|
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
question_report_failed.emit(question_id, "The report API returned an invalid response.")
|
||||||
|
return
|
||||||
|
|
||||||
|
var report_id := str(parsed.get("id", ""))
|
||||||
|
if question_id == "" or report_id == "":
|
||||||
|
question_report_failed.emit(question_id, "The report API returned an incomplete response.")
|
||||||
|
return
|
||||||
|
question_report_submitted.emit(question_id, report_id)
|
||||||
|
|
||||||
|
|
||||||
func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
push_warning("Ratings API request failed with status %d." % response_code)
|
push_warning("Ratings API request failed with status %d." % response_code)
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
|||||||
const TEXT_SCALE_MIN := 0.85
|
const TEXT_SCALE_MIN := 0.85
|
||||||
const TEXT_SCALE_MAX := 1.35
|
const TEXT_SCALE_MAX := 1.35
|
||||||
const TEXT_SCALE_STEP := 0.1
|
const TEXT_SCALE_STEP := 0.1
|
||||||
|
const REPORT_DETAILS_MIN := 10
|
||||||
|
const REPORT_DETAILS_MAX := 2000
|
||||||
|
const REPORT_CATEGORIES := [
|
||||||
|
{"id": "clarity", "label": "Clarity"},
|
||||||
|
{"id": "medical_accuracy", "label": "Medical accuracy"},
|
||||||
|
{"id": "answer_explanation", "label": "Answer / explanation"},
|
||||||
|
{"id": "formatting_media", "label": "Formatting / media"},
|
||||||
|
{"id": "technical_issue", "label": "Technical issue"},
|
||||||
|
{"id": "other", "label": "Other"}
|
||||||
|
]
|
||||||
|
|
||||||
var main: Control
|
var main: Control
|
||||||
var choice_buttons: Array[Button] = []
|
var choice_buttons: Array[Button] = []
|
||||||
@@ -54,6 +64,20 @@ 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 report_overlay: Control
|
||||||
|
var report_panel: PanelContainer
|
||||||
|
var report_form_content: VBoxContainer
|
||||||
|
var report_success_content: VBoxContainer
|
||||||
|
var report_category_group: ButtonGroup
|
||||||
|
var report_category_buttons: Array[CheckBox] = []
|
||||||
|
var report_details: TextEdit
|
||||||
|
var report_char_count: Label
|
||||||
|
var report_error_label: Label
|
||||||
|
var report_submit_button: Button
|
||||||
|
var report_cancel_button: Button
|
||||||
|
var report_success_label: Label
|
||||||
|
var report_is_submitting := false
|
||||||
|
var report_question_id := ""
|
||||||
var media_requests: Array[HTTPRequest] = []
|
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
|
||||||
@@ -76,6 +100,10 @@ func _ready() -> void:
|
|||||||
QuestionBankService.ratings_changed.connect(_refresh_player_stats)
|
QuestionBankService.ratings_changed.connect(_refresh_player_stats)
|
||||||
if not AuthState.auth_changed.is_connected(_refresh_player_stats):
|
if not AuthState.auth_changed.is_connected(_refresh_player_stats):
|
||||||
AuthState.auth_changed.connect(_refresh_player_stats)
|
AuthState.auth_changed.connect(_refresh_player_stats)
|
||||||
|
if not QuestionBankService.question_report_submitted.is_connected(_on_question_report_submitted):
|
||||||
|
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
|
||||||
|
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
|
||||||
|
QuestionBankService.question_report_failed.connect(_on_question_report_failed)
|
||||||
|
|
||||||
var top := HBoxContainer.new()
|
var top := HBoxContainer.new()
|
||||||
top.add_theme_constant_override("separation", 12)
|
top.add_theme_constant_override("separation", 12)
|
||||||
@@ -156,6 +184,8 @@ func _ready() -> void:
|
|||||||
report_button.tooltip_text = "Report question"
|
report_button.tooltip_text = "Report question"
|
||||||
report_button.icon = _make_report_icon()
|
report_button.icon = _make_report_icon()
|
||||||
report_button.custom_minimum_size = Vector2(44, 44)
|
report_button.custom_minimum_size = Vector2(44, 44)
|
||||||
|
Style.style_chip(report_button, Style.CYAN)
|
||||||
|
UISoundService.set_button_sound(report_button, "open")
|
||||||
report_button.pressed.connect(_toggle_report_current_question)
|
report_button.pressed.connect(_toggle_report_current_question)
|
||||||
question_toolbar.add_child(report_button)
|
question_toolbar.add_child(report_button)
|
||||||
|
|
||||||
@@ -326,17 +356,23 @@ func _ready() -> void:
|
|||||||
add_child(feedback_fog)
|
add_child(feedback_fog)
|
||||||
|
|
||||||
_build_labs_modal()
|
_build_labs_modal()
|
||||||
|
_build_report_modal()
|
||||||
|
|
||||||
|
|
||||||
func on_show(_payload: Variant = null) -> void:
|
func on_show(_payload: Variant = null) -> void:
|
||||||
_load_question()
|
_load_question()
|
||||||
|
|
||||||
|
|
||||||
|
func on_hide() -> void:
|
||||||
|
_hide_report_modal(true)
|
||||||
|
_hide_labs_modal()
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
_update_answer_feedback(delta)
|
_update_answer_feedback(delta)
|
||||||
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
|
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
|
||||||
_update_timer_bar()
|
_update_timer_bar()
|
||||||
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed":
|
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible):
|
||||||
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
|
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
|
||||||
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
|
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
|
||||||
var minutes := int(floor(float(shown_seconds) / 60.0))
|
var minutes := int(floor(float(shown_seconds) / 60.0))
|
||||||
@@ -346,6 +382,10 @@ func _process(delta: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
|
if report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"):
|
||||||
|
_hide_report_modal()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
if labs_overlay != null and labs_overlay.visible and event.is_action_pressed("ui_cancel"):
|
if labs_overlay != null and labs_overlay.visible and event.is_action_pressed("ui_cancel"):
|
||||||
_hide_labs_modal()
|
_hide_labs_modal()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
@@ -367,6 +407,7 @@ func _input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _load_question() -> void:
|
func _load_question() -> void:
|
||||||
|
_hide_report_modal(true)
|
||||||
_reset_wrong_feedback()
|
_reset_wrong_feedback()
|
||||||
_hide_labs_modal()
|
_hide_labs_modal()
|
||||||
eliminated.clear()
|
eliminated.clear()
|
||||||
@@ -785,11 +826,7 @@ func _toggle_report_current_question() -> void:
|
|||||||
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
||||||
if question_id == "":
|
if question_id == "":
|
||||||
return
|
return
|
||||||
if reported_question_ids.has(question_id):
|
_show_report_modal(question_id)
|
||||||
reported_question_ids.erase(question_id)
|
|
||||||
else:
|
|
||||||
reported_question_ids.append(question_id)
|
|
||||||
_refresh_toolbar_state()
|
|
||||||
|
|
||||||
|
|
||||||
func _refresh_toolbar_state() -> void:
|
func _refresh_toolbar_state() -> void:
|
||||||
@@ -799,6 +836,7 @@ func _refresh_toolbar_state() -> void:
|
|||||||
if report_button != null:
|
if report_button != null:
|
||||||
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
||||||
Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN)
|
Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN)
|
||||||
|
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
|
||||||
|
|
||||||
|
|
||||||
func _refresh_player_stats() -> void:
|
func _refresh_player_stats() -> void:
|
||||||
@@ -976,6 +1014,280 @@ func _on_media_loaded(
|
|||||||
status.visible = false
|
status.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _build_report_modal() -> void:
|
||||||
|
report_overlay = Control.new()
|
||||||
|
report_overlay.visible = false
|
||||||
|
report_overlay.top_level = true
|
||||||
|
report_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
report_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
report_overlay.gui_input.connect(_report_overlay_gui_input)
|
||||||
|
add_child(report_overlay)
|
||||||
|
|
||||||
|
var dim := ColorRect.new()
|
||||||
|
dim.color = Color(0.02, 0.03, 0.05, 0.78)
|
||||||
|
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
report_overlay.add_child(dim)
|
||||||
|
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
report_overlay.add_child(center)
|
||||||
|
|
||||||
|
report_panel = PanelContainer.new()
|
||||||
|
report_panel.custom_minimum_size = Vector2(700, 600)
|
||||||
|
report_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
report_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.RED))
|
||||||
|
center.add_child(report_panel)
|
||||||
|
|
||||||
|
var modal_body := VBoxContainer.new()
|
||||||
|
modal_body.add_theme_constant_override("separation", 14)
|
||||||
|
report_panel.add_child(modal_body)
|
||||||
|
|
||||||
|
var header := HBoxContainer.new()
|
||||||
|
header.add_theme_constant_override("separation", 10)
|
||||||
|
modal_body.add_child(header)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "Report question"
|
||||||
|
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_set_scaled_label(title, 26, Style.TEXT)
|
||||||
|
header.add_child(title)
|
||||||
|
|
||||||
|
var close_button := Button.new()
|
||||||
|
close_button.text = "X"
|
||||||
|
close_button.tooltip_text = "Close report form"
|
||||||
|
close_button.custom_minimum_size = Vector2(42, 38)
|
||||||
|
Style.style_chip(close_button, Style.RED)
|
||||||
|
UISoundService.set_button_sound(close_button, "close")
|
||||||
|
close_button.pressed.connect(_hide_report_modal)
|
||||||
|
header.add_child(close_button)
|
||||||
|
|
||||||
|
report_form_content = VBoxContainer.new()
|
||||||
|
report_form_content.add_theme_constant_override("separation", 12)
|
||||||
|
modal_body.add_child(report_form_content)
|
||||||
|
|
||||||
|
var category_label := Label.new()
|
||||||
|
category_label.text = "What is the problem?"
|
||||||
|
_set_scaled_label(category_label, 18, Style.CYAN)
|
||||||
|
report_form_content.add_child(category_label)
|
||||||
|
|
||||||
|
var category_grid := GridContainer.new()
|
||||||
|
category_grid.columns = 2
|
||||||
|
category_grid.add_theme_constant_override("h_separation", 18)
|
||||||
|
category_grid.add_theme_constant_override("v_separation", 8)
|
||||||
|
report_form_content.add_child(category_grid)
|
||||||
|
|
||||||
|
report_category_group = ButtonGroup.new()
|
||||||
|
report_category_group.allow_unpress = false
|
||||||
|
for category in REPORT_CATEGORIES:
|
||||||
|
var category_button := CheckBox.new()
|
||||||
|
category_button.text = str(category["label"])
|
||||||
|
category_button.set_meta("report_category_id", str(category["id"]))
|
||||||
|
category_button.button_group = report_category_group
|
||||||
|
category_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
category_button.custom_minimum_size = Vector2(0, 38)
|
||||||
|
_set_scaled_button_font(category_button, 16)
|
||||||
|
category_button.toggled.connect(_on_report_category_toggled)
|
||||||
|
category_grid.add_child(category_button)
|
||||||
|
report_category_buttons.append(category_button)
|
||||||
|
|
||||||
|
var details_header := HBoxContainer.new()
|
||||||
|
report_form_content.add_child(details_header)
|
||||||
|
|
||||||
|
var details_label := Label.new()
|
||||||
|
details_label.text = "Details"
|
||||||
|
details_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_set_scaled_label(details_label, 18, Style.CYAN)
|
||||||
|
details_header.add_child(details_label)
|
||||||
|
|
||||||
|
report_char_count = Label.new()
|
||||||
|
report_char_count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||||
|
_set_scaled_label(report_char_count, 14, Style.MUTED)
|
||||||
|
details_header.add_child(report_char_count)
|
||||||
|
|
||||||
|
report_details = TextEdit.new()
|
||||||
|
report_details.placeholder_text = "Describe what is wrong and what should be reviewed."
|
||||||
|
report_details.custom_minimum_size = Vector2(0, 190)
|
||||||
|
report_details.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
report_details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||||
|
report_details.add_theme_font_size_override("font_size", 16)
|
||||||
|
report_details.add_theme_color_override("font_color", Style.TEXT)
|
||||||
|
report_details.add_theme_color_override("font_placeholder_color", Style.MUTED)
|
||||||
|
report_details.add_theme_stylebox_override("normal", Style.panel_box(Style.SURFACE_DARK, Color("3b4964")))
|
||||||
|
report_details.text_changed.connect(_on_report_details_changed)
|
||||||
|
report_form_content.add_child(report_details)
|
||||||
|
|
||||||
|
report_error_label = Label.new()
|
||||||
|
report_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
report_error_label.custom_minimum_size = Vector2(0, 28)
|
||||||
|
_set_scaled_label(report_error_label, 15, Style.RED)
|
||||||
|
report_form_content.add_child(report_error_label)
|
||||||
|
|
||||||
|
var actions := HBoxContainer.new()
|
||||||
|
actions.add_theme_constant_override("separation", 10)
|
||||||
|
report_form_content.add_child(actions)
|
||||||
|
|
||||||
|
report_cancel_button = Button.new()
|
||||||
|
report_cancel_button.text = "Cancel"
|
||||||
|
report_cancel_button.custom_minimum_size = Vector2(130, 46)
|
||||||
|
Style.style_chip(report_cancel_button, Style.MUTED)
|
||||||
|
UISoundService.set_button_sound(report_cancel_button, "close")
|
||||||
|
report_cancel_button.pressed.connect(_hide_report_modal)
|
||||||
|
actions.add_child(report_cancel_button)
|
||||||
|
|
||||||
|
report_submit_button = Button.new()
|
||||||
|
report_submit_button.text = "Submit report"
|
||||||
|
report_submit_button.custom_minimum_size = Vector2(170, 46)
|
||||||
|
report_submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
Style.style_primary_button(report_submit_button)
|
||||||
|
UISoundService.set_button_sound(report_submit_button, "confirmation")
|
||||||
|
report_submit_button.pressed.connect(_submit_question_report)
|
||||||
|
actions.add_child(report_submit_button)
|
||||||
|
|
||||||
|
report_success_content = VBoxContainer.new()
|
||||||
|
report_success_content.visible = false
|
||||||
|
report_success_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
report_success_content.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
report_success_content.add_theme_constant_override("separation", 18)
|
||||||
|
modal_body.add_child(report_success_content)
|
||||||
|
|
||||||
|
report_success_label = Label.new()
|
||||||
|
report_success_label.text = "Report submitted"
|
||||||
|
report_success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
report_success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_set_scaled_label(report_success_label, 24, Style.LIME)
|
||||||
|
report_success_content.add_child(report_success_label)
|
||||||
|
|
||||||
|
var success_close_button := Button.new()
|
||||||
|
success_close_button.text = "Close"
|
||||||
|
success_close_button.custom_minimum_size = Vector2(180, 48)
|
||||||
|
Style.style_primary_button(success_close_button)
|
||||||
|
UISoundService.set_button_sound(success_close_button, "close")
|
||||||
|
success_close_button.pressed.connect(_hide_report_modal)
|
||||||
|
report_success_content.add_child(success_close_button)
|
||||||
|
|
||||||
|
_update_report_form_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _report_overlay_gui_input(event: InputEvent) -> void:
|
||||||
|
var mouse_event := event as InputEventMouseButton
|
||||||
|
if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
|
||||||
|
return
|
||||||
|
if report_panel != null and not report_panel.get_global_rect().has_point(mouse_event.global_position):
|
||||||
|
_hide_report_modal()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _show_report_modal(question_id: String) -> void:
|
||||||
|
if report_overlay == null or report_is_submitting:
|
||||||
|
return
|
||||||
|
_hide_labs_modal()
|
||||||
|
report_question_id = question_id
|
||||||
|
report_form_content.visible = true
|
||||||
|
report_success_content.visible = false
|
||||||
|
report_details.text = ""
|
||||||
|
report_error_label.text = ""
|
||||||
|
for category_button in report_category_buttons:
|
||||||
|
category_button.button_pressed = false
|
||||||
|
_set_report_submitting(false)
|
||||||
|
_update_report_form_state()
|
||||||
|
report_overlay.visible = true
|
||||||
|
report_overlay.move_to_front()
|
||||||
|
BlockSessionService.pause_current_question_timer()
|
||||||
|
|
||||||
|
|
||||||
|
func _hide_report_modal(force: bool = false) -> void:
|
||||||
|
if report_overlay == null or (report_is_submitting and not force):
|
||||||
|
return
|
||||||
|
if report_overlay.visible:
|
||||||
|
report_overlay.visible = false
|
||||||
|
BlockSessionService.resume_current_question_timer()
|
||||||
|
if not report_is_submitting:
|
||||||
|
report_question_id = ""
|
||||||
|
|
||||||
|
|
||||||
|
func _on_report_category_toggled(pressed: bool) -> void:
|
||||||
|
if pressed:
|
||||||
|
_update_report_form_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_report_details_changed() -> void:
|
||||||
|
_update_report_form_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _selected_report_category() -> String:
|
||||||
|
if report_category_group == null:
|
||||||
|
return ""
|
||||||
|
var selected := report_category_group.get_pressed_button()
|
||||||
|
if selected == null:
|
||||||
|
return ""
|
||||||
|
return str(selected.get_meta("report_category_id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
func _update_report_form_state() -> void:
|
||||||
|
if report_details == null or report_submit_button == null:
|
||||||
|
return
|
||||||
|
var details_length := report_details.text.strip_edges().length()
|
||||||
|
report_char_count.text = "%d / %d" % [details_length, REPORT_DETAILS_MAX]
|
||||||
|
report_char_count.add_theme_color_override("font_color", Style.RED if details_length > REPORT_DETAILS_MAX else Style.MUTED)
|
||||||
|
if not report_is_submitting:
|
||||||
|
report_error_label.text = ""
|
||||||
|
report_submit_button.disabled = (
|
||||||
|
report_is_submitting
|
||||||
|
or _selected_report_category() == ""
|
||||||
|
or details_length < REPORT_DETAILS_MIN
|
||||||
|
or details_length > REPORT_DETAILS_MAX
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _set_report_submitting(value: bool) -> void:
|
||||||
|
report_is_submitting = value
|
||||||
|
if report_details != null:
|
||||||
|
report_details.editable = not value
|
||||||
|
for category_button in report_category_buttons:
|
||||||
|
category_button.disabled = value
|
||||||
|
if report_cancel_button != null:
|
||||||
|
report_cancel_button.disabled = value
|
||||||
|
if report_submit_button != null:
|
||||||
|
report_submit_button.text = "Submitting..." if value else "Submit report"
|
||||||
|
_update_report_form_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _submit_question_report() -> void:
|
||||||
|
if report_is_submitting or report_question_id == "":
|
||||||
|
return
|
||||||
|
var category := _selected_report_category()
|
||||||
|
var details := report_details.text.strip_edges()
|
||||||
|
if category == "" or details.length() < REPORT_DETAILS_MIN or details.length() > REPORT_DETAILS_MAX:
|
||||||
|
_update_report_form_state()
|
||||||
|
return
|
||||||
|
report_error_label.text = ""
|
||||||
|
_set_report_submitting(true)
|
||||||
|
QuestionBankService.submit_question_report(report_question_id, category, details)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
|
||||||
|
if question_id != report_question_id:
|
||||||
|
return
|
||||||
|
_set_report_submitting(false)
|
||||||
|
if not reported_question_ids.has(question_id):
|
||||||
|
reported_question_ids.append(question_id)
|
||||||
|
_refresh_toolbar_state()
|
||||||
|
report_form_content.visible = false
|
||||||
|
report_success_content.visible = true
|
||||||
|
report_success_label.text = "Report submitted\nThank you for helping improve this question."
|
||||||
|
UISoundService.play_sound("confirmation")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_question_report_failed(question_id: String, message: String) -> void:
|
||||||
|
if question_id != report_question_id:
|
||||||
|
return
|
||||||
|
_set_report_submitting(false)
|
||||||
|
report_error_label.text = message
|
||||||
|
UISoundService.play_sound("error")
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||