Require production approval for question media

This commit is contained in:
hz
2026-07-30 04:16:18 -05:00
parent 3fd670d111
commit 09502944ec
4 changed files with 70 additions and 6 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ The hostname change creates a new browser origin, so testers should expect to si
## Question promotion behavior
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
@@ -49,6 +49,44 @@ public sealed class ContentPromotionTests
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
}
[Fact]
public void ProductionPromotionFlagsMediaForSeparateApproval()
{
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
var source = new BsonDocument
{
["ExternalId"] = "media-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["UpdatedUtc"] = updated,
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
}
[Fact]
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
{
var source = new BsonDocument
{
["ExternalId"] = "text-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["Media"] = new BsonArray()
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
Assert.False(prepared.Contains("FlagReason"));
}
[Fact]
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
{
@@ -25,8 +25,11 @@ public static class ContentPromotionCommand
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questions = await source.GetCollection<BsonDocument>("Questions")
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var questions = sourceQuestions
.Select(QuestionPromotionCommand.PrepareForProduction)
.ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
@@ -9,6 +9,8 @@ namespace QuestionsApi.Services;
public static class QuestionPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
internal const string ProductionMediaReviewReason = "Contains media - pending production review";
internal const string ProductionMediaReviewActor = "system:production-promotion";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
@@ -28,9 +30,9 @@ public static class QuestionPromotionCommand
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
var questions = await source.GetCollection<BsonDocument>("Questions")
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
.Find(questionFilter).ToListAsync();
var duplicateIds = questions
var duplicateIds = sourceQuestions
.GroupBy(ExternalId, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
@@ -38,6 +40,7 @@ public static class QuestionPromotionCommand
if (duplicateIds.Count > 0)
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
var questions = sourceQuestions.Select(PrepareForProduction).ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
@@ -95,7 +98,7 @@ public static class QuestionPromotionCommand
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Scope"] = "questions",
["Eligibility"] = "published_unflagged",
["Eligibility"] = "published_unflagged; media_requires_production_review",
["Questions"] = summary.EligibleQuestions,
["Inserted"] = summary.Inserted,
["Updated"] = summary.Updated,
@@ -118,6 +121,21 @@ public static class QuestionPromotionCommand
question.GetValue("IsPublished", false).ToBoolean()
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
internal static BsonDocument PrepareForProduction(BsonDocument source)
{
var prepared = new BsonDocument(source);
if (!HasMedia(prepared))
return prepared;
var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime
? updated
: new BsonDateTime(DateTime.UnixEpoch);
prepared["FlaggedForReviewUtc"] = reviewUtc;
prepared["FlagReason"] = ProductionMediaReviewReason;
prepared["FlaggedByUserId"] = ProductionMediaReviewActor;
return prepared;
}
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
@@ -192,6 +210,11 @@ public static class QuestionPromotionCommand
private static string ExternalId(BsonDocument document) =>
document.GetValue("ExternalId", "").AsString;
private static bool HasMedia(BsonDocument question) =>
question.TryGetValue("Media", out var media)
&& media.IsBsonArray
&& media.AsBsonArray.Count > 0;
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
{
var collection = target.GetCollection<BsonDocument>("Questions");
@@ -224,7 +247,7 @@ public static class QuestionPromotionCommand
{
mode,
scope = "questions",
eligibility = "published_unflagged",
eligibility = "published_unflagged; media_requires_production_review",
source = options.SourceDatabase,
target = options.TargetDatabase,
eligibleQuestions = summary.EligibleQuestions,