294 lines
13 KiB
C#
294 lines
13 KiB
C#
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 « <p>Chasse au trésor</p> ». 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);
|
||
}
|
||
}
|
||
}
|