manager-service/ManagerService/Services/ResourceUsageService.cs
2026-09-04 16:49:26 +02:00

294 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using Manager.DTOs;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace ManagerService.Services
{
/// <summary>
/// Index inverse des usages : quelle ressource est employée par quel contenu.
/// Parcourt <see cref="Section.GetReferencedResourceIds"/> dans l'autre sens — la même
/// méthode qui fait marcher l'export hors ligne, donc le même périmètre de champs.
/// </summary>
/// <remarks>
/// Quatre points de vigilance, tous traités ici :
/// 1. Langue nulle = toutes les langues (voir SectionText.ResourceIds). Passer une langue
/// ferait passer pour orpheline une image posée uniquement en NL.
/// 2. Configuration.ImageId et LoaderImageId vivent hors des sections : ajoutés à part,
/// comme le fait ConfigurationController.Export.
/// 3. GuidedStep.ImageUrl est une URL absolue, pas un id : une image posée là est comptée
/// orpheline. Limite connue, migrée en V2, signalée dans l'UI.
/// 4. Un GuidedPath peut pendre d'un SectionEvent, dont GetReferencedResourceIds ne
/// descend pas dans les parcours. Les parcours sont donc parcourus séparément, et les
/// GuidedPaths ne sont volontairement pas chargés sur SectionParcours (pas de doublon).
/// </remarks>
public class ResourceUsageService
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30);
private readonly MyInfoMateDbContext _db;
private readonly IMemoryCache _cache;
public ResourceUsageService(MyInfoMateDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
/// <summary>Usages de toutes les ressources d'une instance, indexés par id de ressource.</summary>
public Dictionary<string, List<ResourceUsageDTO>> GetUsageMap(string instanceId)
{
return _cache.GetOrCreate($"resource-usages:{instanceId}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return Build(instanceId);
});
}
/// <summary>
/// Ce que le rail de facettes consomme : un résumé par ressource — zéro compris, sans
/// quoi « Jamais utilisées » n'aurait rien à lire — et le libellé des configurations.
/// </summary>
public ResourceUsageMapDTO GetUsageSummary(string instanceId)
{
var usages = GetUsageMap(instanceId);
var summary = new ResourceUsageMapDTO();
foreach (var id in _db.Resources.AsNoTracking()
.Where(r => r.InstanceId == instanceId)
.Select(r => r.Id)
.ToList())
{
usages.TryGetValue(id, out var list);
summary.usages[id] = new ResourceUsageSummaryDTO
{
count = list?.Count ?? 0,
configurationIds = (list ?? new List<ResourceUsageDTO>())
.Where(u => u.configurationId != null)
.Select(u => u.configurationId)
.Distinct()
.ToList()
};
}
foreach (var configuration in _db.Configurations.AsNoTracking()
.Where(c => c.InstanceId == instanceId)
.ToList())
summary.configurations[configuration.Id] = configuration.Label;
return summary;
}
/// <summary>Usages d'une ressource. Liste vide si la ressource n'existe pas.</summary>
public List<ResourceUsageDTO> GetUsages(string resourceId)
{
var instanceId = _db.Resources.AsNoTracking()
.Where(r => r.Id == resourceId)
.Select(r => r.InstanceId)
.FirstOrDefault();
if (instanceId == null)
return new List<ResourceUsageDTO>();
return GetUsageMap(instanceId).TryGetValue(resourceId, out var usages)
? usages
: new List<ResourceUsageDTO>();
}
public void Invalidate(string instanceId) => _cache.Remove($"resource-usages:{instanceId}");
private Dictionary<string, List<ResourceUsageDTO>> Build(string instanceId)
{
var map = new Dictionary<string, List<ResourceUsageDTO>>();
var configurations = _db.Configurations.AsNoTracking()
.Where(c => c.InstanceId == instanceId)
.ToList();
var configurationLabels = configurations.ToDictionary(c => c.Id, c => c.Label);
foreach (var configuration in configurations)
{
Add(map, configuration.ImageId, new ResourceUsageDTO
{
kind = "Configuration",
id = configuration.Id,
label = configuration.Label,
configurationId = configuration.Id,
configurationLabel = configuration.Label,
field = "image",
path = $"{configuration.Label} image d'accueil"
});
Add(map, configuration.LoaderImageId, new ResourceUsageDTO
{
kind = "Configuration",
id = configuration.Id,
label = configuration.Label,
configurationId = configuration.Id,
configurationLabel = configuration.Label,
field = "loaderImage",
path = $"{configuration.Label} écran de chargement"
});
}
var sections = LoadSections(instanceId);
foreach (var section in sections)
{
var configurationLabel = Label(configurationLabels, section.ConfigurationId);
foreach (var resourceId in section.GetReferencedResourceIds().Distinct())
{
var field = resourceId == section.ImageId ? "image" : "contenu";
Add(map, resourceId, new ResourceUsageDTO
{
kind = "Section",
id = section.Id,
label = section.Label,
configurationId = section.ConfigurationId,
configurationLabel = configurationLabel,
sectionId = section.Id,
field = field,
path = $"{configurationLabel} {section.Label} {field}"
});
}
}
var sectionsById = sections.ToDictionary(s => s.Id, s => s);
var guidedPaths = _db.GuidedPaths
.Where(p => p.InstanceId == instanceId)
.Include(p => p.Steps).ThenInclude(s => s.QuizQuestions)
.ToList();
foreach (var guidedPath in guidedPaths)
{
var parentId = guidedPath.SectionParcoursId ?? guidedPath.SectionEventId;
var parent = parentId != null && sectionsById.TryGetValue(parentId, out var s) ? s : null;
var configurationId = parent?.ConfigurationId;
var configurationLabel = Label(configurationLabels, configurationId);
var pathLabel = FirstTranslation(guidedPath.Title) ?? "Parcours";
var prefix = parent != null
? $"{configurationLabel} {parent.Label} {pathLabel}"
: $"{configurationLabel} {pathLabel}";
var steps = guidedPath.Steps ?? new List<GuidedStep>();
var stepResourceIds = steps.SelectMany(step => step.GetReferencedResourceIds()).ToHashSet();
// Les ids propres au parcours : ceux de l'agrégat moins ceux de ses étapes,
// pour ne pas attribuer au parcours ce qui appartient à une étape.
foreach (var resourceId in guidedPath.GetReferencedResourceIds().Distinct()
.Where(id => !stepResourceIds.Contains(id)))
{
Add(map, resourceId, new ResourceUsageDTO
{
kind = "GuidedPath",
id = guidedPath.Id,
label = pathLabel,
configurationId = configurationId,
configurationLabel = configurationLabel,
sectionId = parent?.Id,
field = resourceId == guidedPath.ImageResourceId ? "image" : "contenu",
path = prefix
});
}
foreach (var step in steps)
{
var stepLabel = FirstTranslation(step.Title) ?? $"Étape {step.Order + 1}";
foreach (var resourceId in step.GetReferencedResourceIds().Distinct())
{
Add(map, resourceId, new ResourceUsageDTO
{
kind = "GuidedStep",
id = step.Id,
label = stepLabel,
configurationId = configurationId,
configurationLabel = configurationLabel,
sectionId = parent?.Id,
field = "contenu",
path = $"{prefix} {stepLabel}"
});
}
}
}
return map;
}
/// <summary>
/// Pas d'AsNoTracking : les colonnes jsonb passent par un convertisseur, et le
/// provider InMemory des tests rend des collections vides sur une requête détachée.
/// Le service ne modifie rien, le suivi ne coûte donc que de la mémoire.
///
/// Les sous-types dont le contenu vit dans des tables liées ont besoin d'un Include :
/// sans lui, la collection est vide et la section paraît ne référencer que sa vignette.
/// SectionParcours en est volontairement exclu — ses parcours sont traités à part.
/// </summary>
private List<Section> LoadSections(string instanceId)
{
var sections = new List<Section>();
sections.AddRange(_db.Sections.OfType<SectionQuiz>()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.QuizQuestions));
sections.AddRange(_db.Sections.OfType<SectionAgenda>()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.EventAgendas));
sections.AddRange(_db.Sections.OfType<SectionEvent>()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.Programme).ThenInclude(b => b.MapAnnotations)
.Include(s => s.GlobalMapAnnotations));
sections.AddRange(_db.Sections.OfType<SectionMap>()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.MapPoints));
var loaded = new[] { SectionType.Quiz, SectionType.Agenda, SectionType.Event, SectionType.Map };
sections.AddRange(_db.Sections
.Where(s => s.InstanceId == instanceId && !loaded.Contains(s.Type)));
return sections;
}
private static string Label(Dictionary<string, string> labels, string configurationId) =>
configurationId != null && labels.TryGetValue(configurationId, out var label) ? label : "Sans configuration";
/// <summary>
/// Les traductions sont stockées en HTML (l'éditeur Quill de manager-app les écrit
/// ainsi) : un titre de parcours vaut « &lt;p&gt;Chasse au trésor&lt;/p&gt; ». Le chemin
/// affiché dans « Utilisée dans » est un fil d'Ariane sur une ligne, pas du contenu
/// riche — on en retire donc les balises plutôt que de les rendre.
/// </summary>
private static string FirstTranslation(IEnumerable<TranslationDTO> translations)
{
var value = translations?.FirstOrDefault(t => !string.IsNullOrWhiteSpace(StripHtml(t.value)))?.value;
return value == null ? null : StripHtml(value);
}
private static string StripHtml(string text) =>
text == null
? null
: Regex.Replace(WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " ")), @"\s+", " ").Trim();
private static void Add(Dictionary<string, List<ResourceUsageDTO>> map, string resourceId, ResourceUsageDTO usage)
{
if (string.IsNullOrWhiteSpace(resourceId))
return;
if (!map.TryGetValue(resourceId, out var usages))
map[resourceId] = usages = new List<ResourceUsageDTO>();
usages.Add(usage);
}
}
}