Compare commits
2 Commits
a80096d72b
...
149ebc0cf1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149ebc0cf1 | ||
|
|
934d71c2ee |
131
ManagerService.Tests/Controllers/MigrationDryRunTests.cs
Normal file
131
ManagerService.Tests/Controllers/MigrationDryRunTests.cs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
using ManagerService.Controllers;
|
||||||
|
using ManagerService.Tests.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace ManagerService.Tests.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dry run de la bascule Mongo → Postgres, et comparaison des comptages par entité.
|
||||||
|
/// C'est la seule preuve que les écarts (b), (c) et (e) sont fermés : la lecture de
|
||||||
|
/// code ne dit pas ce qui arrive vraiment dans la base.
|
||||||
|
///
|
||||||
|
/// Se saute si aucun Mongo n'écoute sur MIGRATION_TEST_MONGO (défaut
|
||||||
|
/// mongodb://localhost:27018) — la suite reste donc verte sans dépendance.
|
||||||
|
/// Pour l'exécuter : importer `migration-data/` dans un Mongo jetable, cf. STATUS.md §1quinquies.
|
||||||
|
/// </summary>
|
||||||
|
public class MigrationDryRunTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public MigrationDryRunTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
private static string MongoUri =>
|
||||||
|
Environment.GetEnvironmentVariable("MIGRATION_TEST_MONGO") ?? "mongodb://localhost:27018";
|
||||||
|
|
||||||
|
private static bool MongoIsReachable(out Dictionary<string, long> counts)
|
||||||
|
{
|
||||||
|
counts = new Dictionary<string, long>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = MongoClientSettings.FromConnectionString(MongoUri);
|
||||||
|
settings.ServerSelectionTimeout = TimeSpan.FromSeconds(2);
|
||||||
|
var db = new MongoClient(settings).GetDatabase("TabletDb");
|
||||||
|
|
||||||
|
foreach (var name in new[] { "Instances", "Users", "Configurations", "Resources", "Sections", "Devices" })
|
||||||
|
counts[name] = db.GetCollection<MongoDB.Bson.BsonDocument>(name)
|
||||||
|
.CountDocuments(MongoDB.Bson.BsonDocument.Parse("{}"));
|
||||||
|
|
||||||
|
return counts["Instances"] > 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Les tailles de fichier passent par un HEAD sur chaque URL. Sur 2374 ressources
|
||||||
|
/// cela interrogerait Firebase pendant plusieurs minutes, pour une information que
|
||||||
|
/// ce test ne vérifie pas : il compare des comptages. On coupe court.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class UnreachableHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private sealed class Handler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient CreateClient(string name) => new HttpClient(new Handler());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DryRun_MatchesMongoCounts()
|
||||||
|
{
|
||||||
|
if (!MongoIsReachable(out var mongo))
|
||||||
|
{
|
||||||
|
_output.WriteLine($"Mongo injoignable sur {MongoUri} — test sauté.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string> { ["ConnectionStrings:TabletDb"] = MongoUri })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using var db = DbContextFactory.Create();
|
||||||
|
var controller = new MigrationController(
|
||||||
|
db,
|
||||||
|
new Manager.Services.InstanceDatabaseService(config),
|
||||||
|
new Manager.Services.ConfigurationDatabaseService(config),
|
||||||
|
new Manager.Services.SectionDatabaseService(config),
|
||||||
|
new Manager.Services.ResourceDatabaseService(config),
|
||||||
|
new Manager.Services.UserDatabaseService(config),
|
||||||
|
new Manager.Services.DeviceDatabaseService(config),
|
||||||
|
new UnreachableHttpClientFactory());
|
||||||
|
|
||||||
|
var result = await controller.Run(dryRun: true);
|
||||||
|
var report = Assert.IsType<MigrationReportDTO>(result.Value);
|
||||||
|
|
||||||
|
_output.WriteLine($"FatalError : {report.FatalError ?? "(aucun)"}");
|
||||||
|
_output.WriteLine($"Erreurs : {report.Errors.Count}");
|
||||||
|
foreach (var e in report.Errors.Take(15)) _output.WriteLine(" " + e);
|
||||||
|
_output.WriteLine("");
|
||||||
|
_output.WriteLine($"{"Entité",-18}{"Mongo",8}{"Migré",8}");
|
||||||
|
_output.WriteLine($"{"Instances",-18}{mongo["Instances"],8}{report.Migrated.Instances,8}");
|
||||||
|
_output.WriteLine($"{"Users",-18}{mongo["Users"],8}{report.Migrated.Users,8}");
|
||||||
|
_output.WriteLine($"{"Configurations",-18}{mongo["Configurations"],8}{report.Migrated.Configurations,8}");
|
||||||
|
_output.WriteLine($"{"Resources",-18}{mongo["Resources"],8}{report.Migrated.Resources,8}");
|
||||||
|
_output.WriteLine($"{"Sections",-18}{mongo["Sections"],8}{report.Migrated.Sections,8}");
|
||||||
|
_output.WriteLine($"{"Devices",-18}{mongo["Devices"],8}{report.Migrated.Devices,8}");
|
||||||
|
_output.WriteLine($"{"ApplicationInstances",-18}{"—",8}{report.Migrated.ApplicationInstances,8}");
|
||||||
|
_output.WriteLine($"{"AppConfigLinks",-18}{"—",8}{report.Migrated.AppConfigurationLinks,8}");
|
||||||
|
|
||||||
|
Assert.Null(report.FatalError);
|
||||||
|
|
||||||
|
// Une entité migrée sur deux, c'est une bascule qui se déclare réussie en
|
||||||
|
// ayant perdu la moitié du contenu. C'est exactement ce que le dry run doit
|
||||||
|
// attraper avant le jour J.
|
||||||
|
Assert.Equal(mongo["Instances"], report.Migrated.Instances);
|
||||||
|
Assert.Equal(mongo["Users"], report.Migrated.Users);
|
||||||
|
Assert.Equal(mongo["Configurations"], report.Migrated.Configurations);
|
||||||
|
// Les sections dont la configuration a été supprimée dans Mongo ne peuvent pas
|
||||||
|
// être migrées, mais elles doivent être **signalées**. L'invariant qui compte
|
||||||
|
// n'est donc pas « tout est migré » : c'est « rien ne disparaît en silence ».
|
||||||
|
var skippedSections = report.Skipped.Count(s => s.StartsWith("Section "));
|
||||||
|
_output.WriteLine($"{" dont orphelines",-18}{"",8}{skippedSections,8}");
|
||||||
|
Assert.Equal(mongo["Sections"], report.Migrated.Sections + skippedSections);
|
||||||
|
Assert.Equal(mongo["Devices"], report.Migrated.Devices);
|
||||||
|
Assert.Equal(mongo["Resources"], report.Migrated.Resources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ using Manager.Services;
|
|||||||
using ManagerService.Data;
|
using ManagerService.Data;
|
||||||
using ManagerService.Data.SubSection;
|
using ManagerService.Data.SubSection;
|
||||||
using ManagerService.DTOs;
|
using ManagerService.DTOs;
|
||||||
|
using ManagerService.Helpers;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -12,6 +13,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@ -65,6 +67,13 @@ namespace ManagerService.Controllers
|
|||||||
{
|
{
|
||||||
var report = new MigrationReportDTO { DryRun = dryRun };
|
var report = new MigrationReportDTO { DryRun = dryRun };
|
||||||
|
|
||||||
|
// Écart (h). Neuf étapes, un SaveChanges chacune : un échec au milieu
|
||||||
|
// laissait la base à moitié remplie — des sections sans configuration, des
|
||||||
|
// configurations sans instance. Une transaction unique rend l'échec propre :
|
||||||
|
// soit tout est là, soit rien ne l'est, et on relance après correction.
|
||||||
|
// En dryRun rien n'est écrit, donc pas de transaction à ouvrir.
|
||||||
|
var transaction = dryRun ? null : await _db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await MigrateInstancesAsync(report, dryRun, instanceId);
|
await MigrateInstancesAsync(report, dryRun, instanceId);
|
||||||
@ -76,10 +85,26 @@ namespace ManagerService.Controllers
|
|||||||
await MigrateDevicesAsync(report, dryRun, instanceId);
|
await MigrateDevicesAsync(report, dryRun, instanceId);
|
||||||
await LinkMenuSectionsAsync(report, dryRun);
|
await LinkMenuSectionsAsync(report, dryRun);
|
||||||
await EnrichPdfResourcesAsync(report, dryRun);
|
await EnrichPdfResourcesAsync(report, dryRun);
|
||||||
|
|
||||||
|
if (transaction != null)
|
||||||
|
await transaction.CommitAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
if (transaction != null)
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
|
||||||
report.FatalError = ex.Message;
|
report.FatalError = ex.Message;
|
||||||
|
|
||||||
|
// Renvoyer 200 OK sur un échec fatal était le piège : un script de
|
||||||
|
// bascule qui teste le code HTTP concluait que tout s'était bien passé,
|
||||||
|
// l'erreur n'étant que dans le corps de la réponse.
|
||||||
|
return new ObjectResult(report) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (transaction != null)
|
||||||
|
await transaction.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OkObjectResult(report);
|
return new OkObjectResult(report);
|
||||||
@ -103,14 +128,36 @@ namespace ManagerService.Controllers
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mongo ne contient que ces 4 champs — vérifié dans l'export : les ~31
|
||||||
|
// autres colonnes sont nées avec Postgres v3. Il n'y a donc rien à
|
||||||
|
// mapper de plus, il faut générer, dériver et affecter. Sans ça
|
||||||
|
// l'instance arrive muette : PublicApiKey nul = les apps visiteur ne
|
||||||
|
// s'authentifient plus, plan nul = quotas à 0 = rien ne s'indexe.
|
||||||
var entity = new Instance
|
var entity = new Instance
|
||||||
{
|
{
|
||||||
Id = old.Id,
|
Id = old.Id,
|
||||||
Name = old.Name,
|
Name = old.Name,
|
||||||
DateCreation = old.DateCreation,
|
DateCreation = old.DateCreation,
|
||||||
PinCode = old.PinCode?.ToString(),
|
PinCode = old.PinCode?.ToString(),
|
||||||
|
WebSlug = SlugHelper.GenerateUniqueSlug(_db, old.Name),
|
||||||
|
PublicApiKey = GeneratePublicApiKey(),
|
||||||
|
SubscriptionPlanId = PlanForInstance(old.Name),
|
||||||
|
IsActive = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Les drapeaux de plateforme se déduisent des configurations, comme le
|
||||||
|
// fait déjà MigrateApplicationInstancesAsync pour créer les app
|
||||||
|
// instances. IsWeb reste faux : le canal web n'existait pas avant la
|
||||||
|
// bascule, c'est un réglage d'après.
|
||||||
|
var configs = _configSvc.GetAll(old.Id);
|
||||||
|
entity.IsTablet = configs.Any(c => c.IsTablet);
|
||||||
|
entity.IsMobile = configs.Any(c => c.IsMobile);
|
||||||
|
|
||||||
|
ApplyPlanQuotas(entity);
|
||||||
|
// L'assistant ne s'allume que si le plan lui donne des jetons —
|
||||||
|
// sinon le client verrait une entrée de menu qui ne répond jamais.
|
||||||
|
entity.IsAssistant = entity.AiTokensPerMonth > 0;
|
||||||
|
|
||||||
if (!dryRun)
|
if (!dryRun)
|
||||||
_db.Instances.Add(entity);
|
_db.Instances.Add(entity);
|
||||||
|
|
||||||
@ -126,6 +173,50 @@ namespace ManagerService.Controllers
|
|||||||
if (!dryRun) await _db.SaveChangesAsync();
|
if (!dryRun) await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Même schéma que l'inscription self-service (`OnboardingController`) : préfixe
|
||||||
|
/// `ap_`, 32 octets aléatoires cryptographiques, encodés en base64 URL-safe.
|
||||||
|
/// </summary>
|
||||||
|
private static string GeneratePublicApiKey() =>
|
||||||
|
"ap_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||||
|
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Affectation décidée le 2026-08-11 pour les 4 clients existants — aucun n'est
|
||||||
|
/// sur Essentiel. Le rapprochement se fait par nom parce que c'est la seule
|
||||||
|
/// donnée lisible de l'export Mongo ; un id inconnu retombe sur Pro, le plan
|
||||||
|
/// des clients avec app mobile, jamais sur un plan qui offrirait l'IA.
|
||||||
|
/// ⚠️ Pro n'inclut pas l'assistant : MDLF et le Fort n'auront pas d'IA. Pour les
|
||||||
|
/// tests, soit les passer en Premium, soit leur poser un quota à la main.
|
||||||
|
/// </summary>
|
||||||
|
private static string PlanForInstance(string name)
|
||||||
|
{
|
||||||
|
if (name == null) return "plan-pro";
|
||||||
|
if (name.Contains("MyInfoMate", StringComparison.OrdinalIgnoreCase)) return "plan-premium";
|
||||||
|
if (name.Contains("VisitNamur", StringComparison.OrdinalIgnoreCase)) return "plan-premium";
|
||||||
|
return "plan-pro";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recopie les quotas du plan sur l'instance. Duplique volontairement la logique
|
||||||
|
/// d'`InstanceController.ApplyPlanQuotas` plutôt que d'en dépendre : la migration
|
||||||
|
/// ne doit pas casser si ce contrôleur change de forme. Les deux doivent rester
|
||||||
|
/// alignées — voir l'écart (c) du §1quinquies.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyPlanQuotas(Instance instance)
|
||||||
|
{
|
||||||
|
if (instance.SubscriptionPlanId == null) return;
|
||||||
|
|
||||||
|
var plan = _db.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
|
||||||
|
if (plan == null) return;
|
||||||
|
|
||||||
|
instance.StorageQuotaBytes = plan.StorageQuotaBytes;
|
||||||
|
instance.AiTokensPerMonth = plan.AiTokensPerMonth;
|
||||||
|
instance.HasStats = plan.HasStats;
|
||||||
|
instance.StatsHistoryDays = plan.StatsHistoryDays;
|
||||||
|
instance.HasAdvancedStats = plan.HasAdvancedStats;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Resources ────────────────────────────────────────────────────────
|
// ─── Resources ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task MigrateResourcesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
private async Task MigrateResourcesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
||||||
@ -140,6 +231,7 @@ namespace ManagerService.Controllers
|
|||||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
var sizemap = new System.Collections.Concurrent.ConcurrentDictionary<string, long>();
|
var sizemap = new System.Collections.Concurrent.ConcurrentDictionary<string, long>();
|
||||||
|
var unsized = new System.Collections.Concurrent.ConcurrentDictionary<string, bool>();
|
||||||
var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList();
|
var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList();
|
||||||
|
|
||||||
const int batchSize = 30;
|
const int batchSize = 30;
|
||||||
@ -155,7 +247,13 @@ namespace ManagerService.Controllers
|
|||||||
if (resp.Content.Headers.ContentLength.HasValue)
|
if (resp.Content.Headers.ContentLength.HasValue)
|
||||||
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
|
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
|
||||||
}
|
}
|
||||||
catch { /* URL inaccessible ou expirée → SizeBytes restera 0 */ }
|
catch
|
||||||
|
{
|
||||||
|
// L'échec était avalé sans laisser de trace : la ressource
|
||||||
|
// arrivait à 0 octet et le quota de stockage la comptait pour
|
||||||
|
// rien, sans que personne puisse savoir lesquelles. On le note.
|
||||||
|
unsized[r.Id] = true;
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,12 +275,20 @@ namespace ManagerService.Controllers
|
|||||||
DateCreation = old.DateCreation,
|
DateCreation = old.DateCreation,
|
||||||
InstanceId = old.InstanceId,
|
InstanceId = old.InstanceId,
|
||||||
Url = old.Url,
|
Url = old.Url,
|
||||||
SizeBytes = sizemap.TryGetValue(old.Id, out var size) ? size : 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Écart (e) : StoragePath n'était pas renseigné du tout, et SizeBytes
|
||||||
|
// l'était à la main. Les deux passent par le même calculateur que la
|
||||||
|
// création et le backfill (lien L5) — sinon les trois divergent sur
|
||||||
|
// les types URL, qui n'ont ni blob ni poids à compter dans le quota.
|
||||||
|
ResourceStorage.Apply(entity, sizemap.TryGetValue(old.Id, out var size) ? size : 0);
|
||||||
|
|
||||||
if (!dryRun)
|
if (!dryRun)
|
||||||
_db.Resources.Add(entity);
|
_db.Resources.Add(entity);
|
||||||
|
|
||||||
|
if (unsized.ContainsKey(old.Id) && ResourceStorage.HasBlob(entity.Type))
|
||||||
|
report.Errors.Add($"Resource {old.Id} ({old.Label}) : taille inconnue (HEAD en échec), SizeBytes à 0 — le quota de stockage la comptera pour rien");
|
||||||
|
|
||||||
report.Migrated.Resources++;
|
report.Migrated.Resources++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -222,7 +328,15 @@ namespace ManagerService.Controllers
|
|||||||
Token = old.Token,
|
Token = old.Token,
|
||||||
DateCreation = old.DateCreation,
|
DateCreation = old.DateCreation,
|
||||||
InstanceId = old.InstanceId,
|
InstanceId = old.InstanceId,
|
||||||
Role = UserRole.ContentEditor,
|
// Mongo n'a pas de champ Role : le rôle est une notion née avec
|
||||||
|
// Postgres v3. Tous les utilisateurs y avaient donc un accès
|
||||||
|
// complet. `ContentEditor` était un choix par défaut qui les
|
||||||
|
// dégradait tous en silence — plus personne n'aurait pu gérer les
|
||||||
|
// utilisateurs de son instance après la bascule.
|
||||||
|
// `InstanceAdmin` préserve ce qu'ils pouvaient faire hier, et
|
||||||
|
// c'est aussi le rôle que l'inscription self-service donne au
|
||||||
|
// premier utilisateur d'une instance.
|
||||||
|
Role = UserRole.InstanceAdmin,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!dryRun)
|
if (!dryRun)
|
||||||
@ -400,6 +514,28 @@ namespace ManagerService.Controllers
|
|||||||
.GroupBy(s => s.Id).Select(g => g.First())
|
.GroupBy(s => s.Id).Select(g => g.First())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Les sections sont collectées configuration par configuration : celles dont
|
||||||
|
// la configuration a été supprimée dans Mongo ne sont jamais énumérées, donc
|
||||||
|
// ne partaient ni dans Migrated, ni dans Skipped, ni dans Errors. Le rapport
|
||||||
|
// affichait un compte inférieur à Mongo sans dire pourquoi — mesuré au dry
|
||||||
|
// run du 2026-08-11 : 327 sections en base, 307 reprises, 20 muettes.
|
||||||
|
// Elles ne peuvent pas être migrées (la FK ConfigurationId les refuserait),
|
||||||
|
// mais leur disparition doit être un constat, pas une surprise du jour J.
|
||||||
|
var knownConfigIds = configIds.ToHashSet();
|
||||||
|
var orphans = instanceIds
|
||||||
|
.SelectMany(id => _sectionSvc.GetAll(id))
|
||||||
|
.GroupBy(s => s.Id).Select(g => g.First())
|
||||||
|
.Where(s => !knownConfigIds.Contains(s.ConfigurationId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var orphan in orphans)
|
||||||
|
report.Skipped.Add(
|
||||||
|
$"Section {orphan.Id} ({orphan.Label}) — configuration {orphan.ConfigurationId} introuvable, section orpheline dans Mongo");
|
||||||
|
|
||||||
|
if (orphans.Count > 0)
|
||||||
|
report.Details.Add(
|
||||||
|
$"{orphans.Count} section(s) orpheline(s) non migrée(s) : leur configuration n'existe plus dans Mongo");
|
||||||
|
|
||||||
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
foreach (var old in source)
|
foreach (var old in source)
|
||||||
@ -523,7 +659,13 @@ namespace ManagerService.Controllers
|
|||||||
var dto = ParseData<OldQuizzDTO>(old, opts, report);
|
var dto = ParseData<OldQuizzDTO>(old, opts, report);
|
||||||
var s = new SectionQuiz
|
var s = new SectionQuiz
|
||||||
{
|
{
|
||||||
QuizQuestions = new List<QuizQuestion>(),
|
// Écart (b). `new List<QuizQuestion>()` jetait les questions :
|
||||||
|
// mesuré sur l'export, 5 sections quiz en portent **41**. Un quiz
|
||||||
|
// arrivait donc sans une seule question, et rien ne le signalait.
|
||||||
|
// Les agendas, eux, n'ont pas d'événements dans Mongo et les
|
||||||
|
// parcours guidés n'y existent pas : leurs collections filles
|
||||||
|
// vides sont correctes, celle-ci ne l'était pas.
|
||||||
|
QuizQuestions = BuildQuizQuestions(dto?.questions),
|
||||||
QuizBadLevel = dto?.bad_level?.label ?? new List<TranslationAndResourceDTO>(),
|
QuizBadLevel = dto?.bad_level?.label ?? new List<TranslationAndResourceDTO>(),
|
||||||
QuizMediumLevel = dto?.medium_level?.label ?? new List<TranslationAndResourceDTO>(),
|
QuizMediumLevel = dto?.medium_level?.label ?? new List<TranslationAndResourceDTO>(),
|
||||||
QuizGoodLevel = dto?.good_level?.label ?? new List<TranslationAndResourceDTO>(),
|
QuizGoodLevel = dto?.good_level?.label ?? new List<TranslationAndResourceDTO>(),
|
||||||
@ -613,6 +755,39 @@ namespace ManagerService.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Écart (b) : les questions de quiz de Mongo, jusqu'ici perdues. Le type de
|
||||||
|
/// validation n'existait pas dans l'ancien modèle — toutes les questions y étaient
|
||||||
|
/// à choix multiples — d'où `MultipleChoice`, qui préserve le comportement d'hier
|
||||||
|
/// plutôt que de retomber sur le défaut `Simple` du modèle.
|
||||||
|
/// </summary>
|
||||||
|
private static List<QuizQuestion> BuildQuizQuestions(List<OldQuestionDTO> old)
|
||||||
|
{
|
||||||
|
var questions = new List<QuizQuestion>();
|
||||||
|
if (old == null) return questions;
|
||||||
|
|
||||||
|
foreach (var q in old)
|
||||||
|
{
|
||||||
|
questions.Add(new QuizQuestion
|
||||||
|
{
|
||||||
|
Label = q.label ?? new List<TranslationAndResourceDTO>(),
|
||||||
|
ResourceId = q.imageBackgroundResourceId,
|
||||||
|
Order = q.order,
|
||||||
|
ValidationQuestionType = QuestionType.MultipleChoice,
|
||||||
|
Responses = (q.responses ?? new List<OldResponseDTO>())
|
||||||
|
.Select(r => new ResponseDTO
|
||||||
|
{
|
||||||
|
label = r.label ?? new List<TranslationAndResourceDTO>(),
|
||||||
|
isGood = r.isGood,
|
||||||
|
order = r.order,
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
|
|
||||||
private GeoPoint BuildGeoPoint(OldGeoPointDTO p, string sectionMapId)
|
private GeoPoint BuildGeoPoint(OldGeoPointDTO p, string sectionMapId)
|
||||||
{
|
{
|
||||||
Geometry geometry = null;
|
Geometry geometry = null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user