LOT B — une seule migration EF (LotB_FreezeSchema) : - SectionMap.MapResourceId → IconResourceId. L'écart (g) de la bascule tombe avec. Il fallait renommer aussi la propriété de navigation MapResource : la convention EF l'appariait au FK, la laisser aurait fabriqué un FK fantôme. Elle n'était utilisée nulle part ailleurs. - SectionEvent.ParcoursIds supprimé (champ, DTO, SectionFactory, et une initialisation dans un montage de test). - Instance.IsImageWatermark remplace le `instanceId == "633ee379…"` en dur de ResourceController. EF a généré un RenameColumn, pas un drop+add : les icônes déjà configurées survivent. L'avertissement de perte de données ne porte que sur le DropColumn de ParcoursIds, ce qui est l'intention. Non fait, et c'était une erreur de doc : « supprimer SectionEvent.IconResourceId ». Ce champ n'existe pas — la ligne visée appartient à la classe imbriquée MapAnnotation, partagée par SectionEvent, SectionAgenda et SectionMap, lue par cinq contrôleurs et par GetReferencedResourceIds. La supprimer aurait cassé les icônes d'annotation des trois types et la collecte offline. SÉCURITÉ (lot A, même repo) : - AuthenticationController.Authenticate : un bloc #if DEBUG écrasait l'email et le mot de passe reçus par un compte de test, donc toute compilation en Debug authentifiait n'importe quelle saisie. Retiré. - EnableSensitiveDataLogging (qui écrit les valeurs des paramètres dans les logs) passe sous #if DEBUG, l'idiome déjà employé dans Startup.cs pour le CORS et Hangfire. Le Dockerfile publiant en -c Release, c'est un verrou réel. LOT C1 : - Calculateur StoragePath/SizeBytes extrait dans Helpers/ResourceStorage.cs, avec 13 tests fixant l'invariant des types URL. Il ferme le lien L5 : le backfill (C2) et l'écart (e) de la migration appelleront le même code. - L'extraction a révélé la divergence qu'elle devait empêcher : des deux chemins de création de ResourceController, le chemin multipart écrivait SizeBytes mais laissait StoragePath nul. dotnet build Debug et Release verts, dotnet test 143/143 (130 + 13). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
832 lines
35 KiB
C#
832 lines
35 KiB
C#
using Manager.DTOs;
|
|
using Manager.Services;
|
|
using ManagerService.Data;
|
|
using ManagerService.Data.SubSection;
|
|
using ManagerService.DTOs;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NetTopologySuite.Geometries;
|
|
using NSwag.Annotations;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace ManagerService.Controllers
|
|
{
|
|
[Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)]
|
|
[ApiController, Route("api/[controller]")]
|
|
[OpenApiTag("Migration", Description = "MongoDB → PostgreSQL data migration")]
|
|
public class MigrationController : ControllerBase
|
|
{
|
|
private readonly MyInfoMateDbContext _db;
|
|
private readonly InstanceDatabaseService _instanceSvc;
|
|
private readonly ConfigurationDatabaseService _configSvc;
|
|
private readonly SectionDatabaseService _sectionSvc;
|
|
private readonly ResourceDatabaseService _resourceSvc;
|
|
private readonly UserDatabaseService _userSvc;
|
|
private readonly DeviceDatabaseService _deviceSvc;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
|
|
public MigrationController(
|
|
MyInfoMateDbContext db,
|
|
InstanceDatabaseService instanceSvc,
|
|
ConfigurationDatabaseService configSvc,
|
|
SectionDatabaseService sectionSvc,
|
|
ResourceDatabaseService resourceSvc,
|
|
UserDatabaseService userSvc,
|
|
DeviceDatabaseService deviceSvc,
|
|
IHttpClientFactory httpClientFactory)
|
|
{
|
|
_db = db;
|
|
_instanceSvc = instanceSvc;
|
|
_configSvc = configSvc;
|
|
_sectionSvc = sectionSvc;
|
|
_resourceSvc = resourceSvc;
|
|
_userSvc = userSvc;
|
|
_deviceSvc = deviceSvc;
|
|
_httpClientFactory = httpClientFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run (or simulate) the MongoDB → PostgreSQL migration.
|
|
/// Use dryRun=true to validate without writing anything to the database.
|
|
/// Use instanceId to restrict to a single instance.
|
|
/// </summary>
|
|
[HttpPost("run")]
|
|
[ProducesResponseType(typeof(MigrationReportDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
public async Task<ObjectResult> Run(
|
|
[FromQuery] bool dryRun = false,
|
|
[FromQuery] string instanceId = null)
|
|
{
|
|
var report = new MigrationReportDTO { DryRun = dryRun };
|
|
|
|
try
|
|
{
|
|
await MigrateInstancesAsync(report, dryRun, instanceId);
|
|
await MigrateResourcesAsync(report, dryRun, instanceId);
|
|
await MigrateUsersAsync(report, dryRun, instanceId);
|
|
await MigrateConfigurationsAsync(report, dryRun, instanceId);
|
|
await MigrateApplicationInstancesAsync(report, dryRun, instanceId);
|
|
await MigrateSectionsAsync(report, dryRun, instanceId);
|
|
await MigrateDevicesAsync(report, dryRun, instanceId);
|
|
await LinkMenuSectionsAsync(report, dryRun);
|
|
await EnrichPdfResourcesAsync(report, dryRun);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.FatalError = ex.Message;
|
|
}
|
|
|
|
return new OkObjectResult(report);
|
|
}
|
|
|
|
// ─── Instances ────────────────────────────────────────────────────────
|
|
|
|
private async Task MigrateInstancesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
|
{
|
|
var source = _instanceSvc.GetAll();
|
|
if (filterInstanceId != null)
|
|
source = source.Where(i => i.Id == filterInstanceId).ToList();
|
|
|
|
foreach (var old in source)
|
|
{
|
|
try
|
|
{
|
|
if (await _db.Instances.AnyAsync(x => x.Id == old.Id))
|
|
{
|
|
report.Skipped.Add($"Instance {old.Id} ({old.Name}) — already exists");
|
|
continue;
|
|
}
|
|
|
|
var entity = new Instance
|
|
{
|
|
Id = old.Id,
|
|
Name = old.Name,
|
|
DateCreation = old.DateCreation,
|
|
PinCode = old.PinCode?.ToString(),
|
|
};
|
|
|
|
if (!dryRun)
|
|
_db.Instances.Add(entity);
|
|
|
|
report.Migrated.Instances++;
|
|
report.Details.Add($"Instance {old.Id} ({old.Name})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"Instance {old.Id}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── Resources ────────────────────────────────────────────────────────
|
|
|
|
private async Task MigrateResourcesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
|
{
|
|
var instanceIds = filterInstanceId != null
|
|
? new List<string> { filterInstanceId }
|
|
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
|
|
var source = instanceIds.SelectMany(id => _resourceSvc.GetAll(id)).ToList();
|
|
|
|
// Fetch sizes en parallèle par batches de 30
|
|
var httpClient = _httpClientFactory.CreateClient();
|
|
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
|
|
|
var sizemap = new System.Collections.Concurrent.ConcurrentDictionary<string, long>();
|
|
var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList();
|
|
|
|
const int batchSize = 30;
|
|
for (int i = 0; i < urlSource.Count; i += batchSize)
|
|
{
|
|
var batch = urlSource.Skip(i).Take(batchSize);
|
|
await Task.WhenAll(batch.Select(async r =>
|
|
{
|
|
try
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Head, r.Url);
|
|
var resp = await httpClient.SendAsync(req);
|
|
if (resp.Content.Headers.ContentLength.HasValue)
|
|
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
|
|
}
|
|
catch { /* URL inaccessible ou expirée → SizeBytes restera 0 */ }
|
|
}));
|
|
}
|
|
|
|
foreach (var old in source)
|
|
{
|
|
try
|
|
{
|
|
if (await _db.Resources.AnyAsync(x => x.Id == old.Id))
|
|
{
|
|
report.Skipped.Add($"Resource {old.Id} ({old.Label}) — already exists");
|
|
continue;
|
|
}
|
|
|
|
var entity = new Resource
|
|
{
|
|
Id = old.Id,
|
|
Type = old.Type,
|
|
Label = old.Label,
|
|
DateCreation = old.DateCreation,
|
|
InstanceId = old.InstanceId,
|
|
Url = old.Url,
|
|
SizeBytes = sizemap.TryGetValue(old.Id, out var size) ? size : 0,
|
|
};
|
|
|
|
if (!dryRun)
|
|
_db.Resources.Add(entity);
|
|
|
|
report.Migrated.Resources++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"Resource {old.Id}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── Users ────────────────────────────────────────────────────────────
|
|
|
|
private async Task MigrateUsersAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
|
{
|
|
var source = _userSvc.GetAll();
|
|
if (filterInstanceId != null)
|
|
source = source.Where(u => u.InstanceId == filterInstanceId).ToList();
|
|
|
|
foreach (var old in source)
|
|
{
|
|
try
|
|
{
|
|
if (await _db.Users.AnyAsync(x => x.Id == old.Id))
|
|
{
|
|
report.Skipped.Add($"User {old.Id} ({old.Email}) — already exists");
|
|
continue;
|
|
}
|
|
|
|
var entity = new User
|
|
{
|
|
Id = old.Id,
|
|
Email = old.Email,
|
|
Password = old.Password,
|
|
FirstName = old.FirstName,
|
|
LastName = old.LastName,
|
|
Token = old.Token,
|
|
DateCreation = old.DateCreation,
|
|
InstanceId = old.InstanceId,
|
|
Role = UserRole.ContentEditor,
|
|
};
|
|
|
|
if (!dryRun)
|
|
_db.Users.Add(entity);
|
|
|
|
report.Migrated.Users++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"User {old.Id}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── Configurations ───────────────────────────────────────────────────
|
|
|
|
private async Task MigrateConfigurationsAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
|
{
|
|
var instanceIds = filterInstanceId != null
|
|
? new List<string> { filterInstanceId }
|
|
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
|
|
var source = instanceIds.SelectMany(id => _configSvc.GetAll(id)).ToList();
|
|
|
|
foreach (var old in source)
|
|
{
|
|
try
|
|
{
|
|
if (await _db.Configurations.AnyAsync(x => x.Id == old.Id))
|
|
{
|
|
report.Skipped.Add($"Configuration {old.Id} ({old.Label}) — already exists");
|
|
continue;
|
|
}
|
|
|
|
var entity = new Configuration
|
|
{
|
|
Id = old.Id,
|
|
InstanceId = old.InstanceId,
|
|
Label = old.Label,
|
|
Title = old.Title ?? new List<TranslationDTO>(),
|
|
ImageId = old.ImageId,
|
|
ImageSource = old.ImageSource,
|
|
PrimaryColor = old.PrimaryColor,
|
|
SecondaryColor = old.SecondaryColor,
|
|
Languages = old.Languages ?? new List<string>(),
|
|
DateCreation = old.DateCreation,
|
|
IsOffline = old.IsOffline,
|
|
LoaderImageId = old.LoaderImageId,
|
|
LoaderImageUrl = old.LoaderImageUrl,
|
|
IsQRCode = false,
|
|
IsSearchText = false,
|
|
IsSearchNumber = false,
|
|
};
|
|
|
|
if (!dryRun)
|
|
_db.Configurations.Add(entity);
|
|
|
|
report.Migrated.Configurations++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"Configuration {old.Id}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── ApplicationInstances + AppConfigurationLinks ────────────────────
|
|
|
|
private async Task MigrateApplicationInstancesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
|
{
|
|
var instanceIds = filterInstanceId != null
|
|
? new List<string> { filterInstanceId }
|
|
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
|
|
|
|
foreach (var iid in instanceIds)
|
|
{
|
|
var configs = _configSvc.GetAll(iid);
|
|
|
|
foreach (var appType in new[] { AppType.Tablet, AppType.Mobile })
|
|
{
|
|
var relevant = appType == AppType.Tablet
|
|
? configs.Where(c => c.IsTablet).ToList()
|
|
: configs.Where(c => c.IsMobile).ToList();
|
|
|
|
if (!relevant.Any()) continue;
|
|
|
|
var appInstanceId = $"migration-{appType.ToString().ToLower()}-{iid}";
|
|
|
|
try
|
|
{
|
|
if (!await _db.ApplicationInstances.AnyAsync(x => x.Id == appInstanceId))
|
|
{
|
|
var firstConfig = relevant.First();
|
|
var allLanguages = relevant
|
|
.Where(c => c.Languages != null)
|
|
.SelectMany(c => c.Languages)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
var appInstance = new ApplicationInstance
|
|
{
|
|
Id = appInstanceId,
|
|
InstanceId = iid,
|
|
AppType = appType,
|
|
Languages = allLanguages,
|
|
LoaderImageId = firstConfig.LoaderImageId,
|
|
LoaderImageUrl = firstConfig.LoaderImageUrl,
|
|
PrimaryColor = firstConfig.PrimaryColor,
|
|
SecondaryColor = firstConfig.SecondaryColor,
|
|
Configurations = new List<AppConfigurationLink>(),
|
|
};
|
|
if (!dryRun) _db.ApplicationInstances.Add(appInstance);
|
|
report.Migrated.ApplicationInstances++;
|
|
report.Details.Add($"ApplicationInstance {appType} for instance {iid}");
|
|
}
|
|
|
|
foreach (var config in relevant)
|
|
{
|
|
var linkId = $"migration-{appType.ToString().ToLower()}-link-{config.Id}";
|
|
if (await _db.AppConfigurationLinks.AnyAsync(x => x.Id == linkId))
|
|
{
|
|
report.Skipped.Add($"AppConfigurationLink {linkId} — already exists");
|
|
continue;
|
|
}
|
|
|
|
var link = new AppConfigurationLink
|
|
{
|
|
Id = linkId,
|
|
ConfigurationId = config.Id,
|
|
ApplicationInstanceId = appInstanceId,
|
|
IsActive = true,
|
|
IsDate = appType == AppType.Tablet && config.IsDate,
|
|
IsHour = appType == AppType.Tablet && config.IsHour,
|
|
IsSectionImageBackground = appType == AppType.Tablet && config.IsSectionImageBackground,
|
|
RoundedValue = appType == AppType.Tablet ? config.RoundedValue : null,
|
|
ScreenPercentageSectionsMainPage = appType == AppType.Tablet ? config.ScreenPercentageSectionsMainPage : null,
|
|
LoaderImageId = config.LoaderImageId,
|
|
LoaderImageUrl = config.LoaderImageUrl,
|
|
PrimaryColor = config.PrimaryColor,
|
|
SecondaryColor = config.SecondaryColor,
|
|
};
|
|
if (!dryRun) _db.AppConfigurationLinks.Add(link);
|
|
report.Migrated.AppConfigurationLinks++;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"ApplicationInstance {appType} {iid}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── Sections ─────────────────────────────────────────────────────────
|
|
|
|
private async Task MigrateSectionsAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
|
{
|
|
var instanceIds = filterInstanceId != null
|
|
? new List<string> { filterInstanceId }
|
|
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
|
|
|
|
// GetAllFromConfigurationEvenSubsection inclut sections ET sous-sections
|
|
var configIds = instanceIds
|
|
.SelectMany(id => _configSvc.GetAll(id))
|
|
.Select(c => c.Id)
|
|
.ToList();
|
|
|
|
var source = configIds
|
|
.SelectMany(cid => _sectionSvc.GetAllFromConfigurationEvenSubsection(cid))
|
|
.GroupBy(s => s.Id).Select(g => g.First())
|
|
.ToList();
|
|
|
|
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
|
|
foreach (var old in source)
|
|
{
|
|
try
|
|
{
|
|
if (await _db.Sections.AnyAsync(x => x.Id == old.Id))
|
|
{
|
|
report.Skipped.Add($"Section {old.Id} ({old.Label}, type {old.Type}) — already exists");
|
|
continue;
|
|
}
|
|
|
|
Section entity = BuildSection(old, opts, report);
|
|
if (entity == null) continue;
|
|
|
|
if (!dryRun)
|
|
_db.Sections.Add(entity);
|
|
|
|
report.Migrated.Sections++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"Section {old.Id} ({old.Label}, type {old.Type}): {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
private Section BuildSection(OldSection old, JsonSerializerOptions opts, MigrationReportDTO report)
|
|
{
|
|
// Champs communs à tous les sous-types
|
|
void FillBase(Section s)
|
|
{
|
|
s.Id = old.Id;
|
|
s.Label = old.Label ?? "";
|
|
s.Title = old.Title ?? new List<TranslationDTO>();
|
|
s.Description = old.Description ?? new List<TranslationDTO>();
|
|
s.Order = old.Order;
|
|
s.ConfigurationId = old.ConfigurationId;
|
|
s.ImageId = old.ImageId;
|
|
s.ImageSource = old.ImageSource;
|
|
s.Type = old.Type;
|
|
s.IsSubSection = old.IsSubSection;
|
|
s.ParentId = old.ParentId;
|
|
s.DateCreation = old.DateCreation;
|
|
s.InstanceId = old.InstanceId;
|
|
s.IsBeacon = old.IsBeacon;
|
|
s.BeaconId = old.BeaconId;
|
|
s.Latitude = old.Latitude;
|
|
s.Longitude = old.Longitude;
|
|
s.MeterZoneGPS = old.MeterZoneGPS;
|
|
s.IsActive = true;
|
|
}
|
|
|
|
switch (old.Type)
|
|
{
|
|
case SectionType.Map:
|
|
{
|
|
var dto = ParseData<OldMapDTO>(old, opts, report);
|
|
var s = new SectionMap
|
|
{
|
|
MapZoom = dto?.zoom ?? 18,
|
|
MapMapType = dto?.mapType,
|
|
MapTypeMapbox = dto?.mapTypeMapbox,
|
|
MapMapProvider = dto?.mapProvider,
|
|
IconResourceId = dto?.iconResourceId,
|
|
MapCenterLatitude = dto?.latitude,
|
|
MapCenterLongitude = dto?.longitude,
|
|
MapCategories = dto?.categories?.Select(c => new CategorieDTO
|
|
{
|
|
id = c.id,
|
|
label = c.label,
|
|
icon = c.icon,
|
|
order = c.order,
|
|
}).ToList() ?? new List<CategorieDTO>(),
|
|
MapPoints = dto?.points?.Select(p => BuildGeoPoint(p, old.Id)).ToList()
|
|
?? new List<GeoPoint>(),
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Slider:
|
|
{
|
|
var dto = ParseData<OldSliderDTO>(old, opts, report);
|
|
var s = new SectionSlider
|
|
{
|
|
SliderContents = dto?.contents ?? new List<ContentDTO>(),
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Video:
|
|
{
|
|
var dto = ParseData<OldVideoDTO>(old, opts, report);
|
|
var s = new SectionVideo { VideoSource = dto?.source ?? "" };
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Web:
|
|
{
|
|
var dto = ParseData<OldWebDTO>(old, opts, report);
|
|
var s = new SectionWeb { WebSource = dto?.source ?? "" };
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Menu:
|
|
{
|
|
// Les relations MenuSections sont gérées dans LinkMenuSectionsAsync
|
|
var s = new SectionMenu { MenuSections = new List<Section>() };
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Quiz:
|
|
{
|
|
var dto = ParseData<OldQuizzDTO>(old, opts, report);
|
|
var s = new SectionQuiz
|
|
{
|
|
QuizQuestions = new List<QuizQuestion>(),
|
|
QuizBadLevel = dto?.bad_level?.label ?? new List<TranslationAndResourceDTO>(),
|
|
QuizMediumLevel = dto?.medium_level?.label ?? new List<TranslationAndResourceDTO>(),
|
|
QuizGoodLevel = dto?.good_level?.label ?? new List<TranslationAndResourceDTO>(),
|
|
QuizGreatLevel = dto?.great_level?.label ?? new List<TranslationAndResourceDTO>(),
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Article:
|
|
{
|
|
var dto = ParseData<OldArticleDTO>(old, opts, report);
|
|
var s = new SectionArticle
|
|
{
|
|
ArticleContent = dto?.content ?? new List<TranslationDTO>(),
|
|
ArticleIsContentTop = dto?.isContentTop ?? false,
|
|
ArticleAudioIds = dto?.audioIds ?? new List<TranslationDTO>(),
|
|
ArticleIsReadAudioAuto = dto?.isReadAudioAuto ?? false,
|
|
ArticleContents = dto?.contents ?? new List<ContentDTO>(),
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.PDF:
|
|
{
|
|
var dto = ParseData<OldPdfDTO>(old, opts, report);
|
|
var s = new SectionPdf
|
|
{
|
|
PDFOrderedTranslationAndResources = dto?.pdfs?.Select(p =>
|
|
new OrderedTranslationAndResourceDTO
|
|
{
|
|
translationAndResourceDTOs = p.pdfFilesAndTitles ?? new List<TranslationAndResourceDTO>(),
|
|
order = p.order,
|
|
}).ToList() ?? new List<OrderedTranslationAndResourceDTO>(),
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Game:
|
|
{
|
|
var dto = ParseData<OldPuzzleDTO>(old, opts, report);
|
|
var s = new SectionGame
|
|
{
|
|
GameMessageDebut = ToTranslationAndResourceList(dto?.messageDebut),
|
|
GameMessageFin = ToTranslationAndResourceList(dto?.messageFin),
|
|
GamePuzzleImageId = dto?.image?.resourceId,
|
|
GamePuzzleRows = dto?.rows ?? 3,
|
|
GamePuzzleCols = dto?.cols ?? 3,
|
|
GameType = SectionGame.GameTypes.Puzzle,
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Agenda:
|
|
{
|
|
var dto = ParseData<OldAgendaDTO>(old, opts, report);
|
|
var s = new SectionAgenda
|
|
{
|
|
IsOnlineAgenda = true,
|
|
AgendaResourceIds = dto?.resourceIds ?? new List<TranslationDTO>(),
|
|
AgendaMapProvider = dto?.mapProvider,
|
|
EventAgendas = new List<EventAgenda>(),
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
case SectionType.Weather:
|
|
{
|
|
var dto = ParseData<OldWeatherDTO>(old, opts, report);
|
|
var s = new SectionWeather
|
|
{
|
|
WeatherCity = dto?.city,
|
|
WeatherUpdatedDate = dto?.updatedDate,
|
|
WeatherResult = dto?.result,
|
|
};
|
|
FillBase(s);
|
|
return s;
|
|
}
|
|
|
|
default:
|
|
report.Errors.Add($"Section {old.Id}: unknown SectionType {old.Type} — skipped");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private GeoPoint BuildGeoPoint(OldGeoPointDTO p, string sectionMapId)
|
|
{
|
|
Geometry geometry = null;
|
|
if (p.latitude != null && p.longitude != null
|
|
&& double.TryParse(p.latitude, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var lat)
|
|
&& double.TryParse(p.longitude, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var lon))
|
|
{
|
|
geometry = new Point(lon, lat) { SRID = 4326 };
|
|
}
|
|
|
|
return new GeoPoint
|
|
{
|
|
Title = p.title ?? new List<TranslationDTO>(),
|
|
Description = p.description ?? new List<TranslationDTO>(),
|
|
Contents = p.contents?.Select(c => new ContentDTO
|
|
{
|
|
resourceId = c.resourceId,
|
|
}).ToList() ?? new List<ContentDTO>(),
|
|
CategorieId = p.categorieId ?? p.categorie?.id,
|
|
Geometry = geometry,
|
|
ImageResourceId = p.imageResourceId,
|
|
ImageUrl = p.imageUrl,
|
|
Schedules = p.schedules ?? new List<TranslationDTO>(),
|
|
Prices = p.prices ?? new List<TranslationDTO>(),
|
|
Phone = p.phone ?? new List<TranslationDTO>(),
|
|
Email = p.email ?? new List<TranslationDTO>(),
|
|
Site = p.site ?? new List<TranslationDTO>(),
|
|
SectionMapId = sectionMapId,
|
|
};
|
|
}
|
|
|
|
// ─── Devices ──────────────────────────────────────────────────────────
|
|
|
|
private async Task MigrateDevicesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
|
|
{
|
|
var instanceIds = filterInstanceId != null
|
|
? new List<string> { filterInstanceId }
|
|
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
|
|
var source = instanceIds.SelectMany(id => _deviceSvc.GetAll(id)).ToList();
|
|
|
|
foreach (var old in source)
|
|
{
|
|
try
|
|
{
|
|
if (await _db.Devices.AnyAsync(x => x.Id == old.Id))
|
|
{
|
|
report.Skipped.Add($"Device {old.Id} ({old.Name}) — already exists");
|
|
continue;
|
|
}
|
|
|
|
var entity = new Device
|
|
{
|
|
Id = old.Id,
|
|
Identifier = old.Identifier,
|
|
Name = old.Name,
|
|
IpAddressWLAN = old.IpAddressWLAN,
|
|
IpAddressETH = old.IpAddressETH,
|
|
ConfigurationId = old.ConfigurationId,
|
|
Connected = old.Connected,
|
|
DateCreation = old.DateCreation,
|
|
DateUpdate = old.DateUpdate < new DateTime(2000, 1, 1) ? DateTime.MinValue : old.DateUpdate,
|
|
BatteryLevel = old.BatteryLevel?.ToString(),
|
|
LastBatteryLevel = old.LastBatteryLevel,
|
|
ConnectionLevel = old.ConnectionLevel?.ToString(),
|
|
LastConnectionLevel = old.LastConnectionLevel,
|
|
InstanceId = old.InstanceId,
|
|
};
|
|
|
|
if (!dryRun)
|
|
_db.Devices.Add(entity);
|
|
|
|
report.Migrated.Devices++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"Device {old.Id}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── Post-processing : relier SectionMenu.MenuSections ───────────────
|
|
|
|
private async Task LinkMenuSectionsAsync(MigrationReportDTO report, bool dryRun)
|
|
{
|
|
var menus = await _db.Sections
|
|
.OfType<SectionMenu>()
|
|
.Include(m => m.MenuSections)
|
|
.ToListAsync();
|
|
|
|
foreach (var menu in menus)
|
|
{
|
|
if (menu.MenuSections.Count > 0)
|
|
{
|
|
report.Skipped.Add($"MenuLink {menu.Id} — already linked ({menu.MenuSections.Count} children)");
|
|
continue;
|
|
}
|
|
|
|
var oldSection = _sectionSvc.GetById(menu.Id);
|
|
if (oldSection?.Data == null) continue;
|
|
|
|
try
|
|
{
|
|
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
var dto = JsonSerializer.Deserialize<OldMenuDTO>(oldSection.Data, opts);
|
|
if (dto?.sections == null) continue;
|
|
|
|
var childIds = dto.sections.Select(s => s.id).Where(id => id != null).ToList();
|
|
var children = await _db.Sections.Where(s => childIds.Contains(s.Id)).ToListAsync();
|
|
|
|
if (!dryRun)
|
|
{
|
|
menu.MenuSections = children;
|
|
_db.Sections.Update(menu);
|
|
}
|
|
|
|
report.Migrated.MenuLinks += children.Count;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"MenuLink {menu.Id}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── Post-processing : enrichir les resource dans les JSONB PDF ─────────
|
|
|
|
private async Task EnrichPdfResourcesAsync(MigrationReportDTO report, bool dryRun)
|
|
{
|
|
var pdfSections = await _db.Sections.OfType<SectionPdf>().ToListAsync();
|
|
|
|
foreach (var pdf in pdfSections)
|
|
{
|
|
try
|
|
{
|
|
bool modified = false;
|
|
foreach (var ordered in pdf.PDFOrderedTranslationAndResources ?? new List<OrderedTranslationAndResourceDTO>())
|
|
{
|
|
foreach (var tar in ordered.translationAndResourceDTOs ?? new List<TranslationAndResourceDTO>())
|
|
{
|
|
if (tar.resource == null && tar.resourceId != null)
|
|
{
|
|
var resource = await _db.Resources.FindAsync(tar.resourceId);
|
|
if (resource != null)
|
|
{
|
|
tar.resource = resource.ToDTO();
|
|
modified = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (modified)
|
|
{
|
|
if (!dryRun) _db.Sections.Update(pdf);
|
|
report.Migrated.PdfResourcesEnriched++;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"EnrichPdf {pdf.Id}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
if (!dryRun) await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
private T ParseData<T>(OldSection old, JsonSerializerOptions opts, MigrationReportDTO report) where T : class
|
|
{
|
|
if (string.IsNullOrEmpty(old.Data)) return null;
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<T>(old.Data, opts);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
report.Errors.Add($"Section {old.Id} — failed to parse Data as {typeof(T).Name}: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private List<TranslationAndResourceDTO> ToTranslationAndResourceList(List<TranslationAndResourceDTO> source)
|
|
=> source ?? new List<TranslationAndResourceDTO>();
|
|
}
|
|
|
|
// ─── DTO du rapport ───────────────────────────────────────────────────────
|
|
|
|
public class MigrationReportDTO
|
|
{
|
|
public bool DryRun { get; set; }
|
|
public MigrationCountsDTO Migrated { get; set; } = new();
|
|
public List<string> Skipped { get; set; } = new();
|
|
public List<string> Errors { get; set; } = new();
|
|
public List<string> Details { get; set; } = new();
|
|
public string FatalError { get; set; }
|
|
}
|
|
|
|
public class MigrationCountsDTO
|
|
{
|
|
public int Instances { get; set; }
|
|
public int Resources { get; set; }
|
|
public int Users { get; set; }
|
|
public int Configurations { get; set; }
|
|
public int ApplicationInstances { get; set; }
|
|
public int AppConfigurationLinks { get; set; }
|
|
public int Sections { get; set; }
|
|
public int Devices { get; set; }
|
|
public int MenuLinks { get; set; }
|
|
public int PdfResourcesEnriched { get; set; }
|
|
}
|
|
}
|