Misc update ressource to mediatheque !

This commit is contained in:
Thomas Fransolet 2026-09-04 16:49:26 +02:00
parent ea237d2913
commit 3d23e05351
17 changed files with 2768 additions and 23 deletions

View File

@ -1,10 +1,11 @@
using Manager.Services; using Manager.Services;
using ManagerService.Controllers; using ManagerService.Controllers;
using ManagerService.Data; using ManagerService.Data;
using ManagerService.DTOs; using ManagerService.DTOs;
using ManagerService.Services; using ManagerService.Services;
using ManagerService.Tests.Infrastructure; using ManagerService.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -27,7 +28,8 @@ namespace ManagerService.Tests.Controllers
new ConfigurationDatabaseService(cfg), new ConfigurationDatabaseService(cfg),
db, db,
httpClientFactory ?? new FakeHttpClientFactory(), httpClientFactory ?? new FakeHttpClientFactory(),
blobService ?? new FakeBlobService()); blobService ?? new FakeBlobService(),
new ResourceUsageService(db, new MemoryCache(new MemoryCacheOptions())));
FakeUser.SetUser(controller, FakeUser.Create("Manager.contenteditor", "inst-test")); FakeUser.SetUser(controller, FakeUser.Create("Manager.contenteditor", "inst-test"));
return controller; return controller;
} }
@ -166,21 +168,26 @@ namespace ManagerService.Tests.Controllers
// ── DELETE ─────────────────────────────────────────────────────────── // ── DELETE ───────────────────────────────────────────────────────────
// La suppression d'une ressource utilisée est refusée depuis la Médiathèque V1 :
// la suite de Delete déréférence la ressource partout où elle sert, ce qui viderait
// des contenus en silence. Ces deux tests figeaient l'ancien comportement.
[Fact] [Fact]
public void Delete_NullifiesConfigurationImageId() public void Delete_ResourceUsedByConfiguration_Returns409()
{ {
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image });
db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", ImageId = "r1", Title = new List<TranslationDTO>() }); db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", ImageId = "r1", Title = new List<TranslationDTO>() });
db.SaveChanges(); db.SaveChanges();
BuildController(db).Delete("r1"); var result = BuildController(db).Delete("r1").GetAwaiter().GetResult();
Assert.Null(db.Configurations.First().ImageId); Assert.IsType<ConflictObjectResult>(result);
Assert.Equal("r1", db.Configurations.First().ImageId);
Assert.Equal(1, db.Resources.Count());
} }
[Fact] [Fact]
public void Delete_NullifiesSectionImageId() public void Delete_ResourceUsedBySection_Returns409()
{ {
using var db = DbContextFactory.Create(); using var db = DbContextFactory.Create();
db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image });
@ -190,9 +197,30 @@ namespace ManagerService.Tests.Controllers
db.Sections.Add(section); db.Sections.Add(section);
db.SaveChanges(); db.SaveChanges();
BuildController(db).Delete("r1"); var result = BuildController(db).Delete("r1").GetAwaiter().GetResult();
Assert.Null(db.Sections.First().ImageId); Assert.IsType<ConflictObjectResult>(result);
Assert.Equal("r1", db.Sections.First().ImageId);
}
[Fact]
public void DeleteBulk_DeletesFreeResources_AndRefusesUsedOnes()
{
using var db = DbContextFactory.Create();
db.Resources.AddRange(
new Resource { Id = "r-used", InstanceId = "inst-test", Label = "Utilisée", Type = ResourceType.Image },
new Resource { Id = "r-free", InstanceId = "inst-test", Label = "Libre", Type = ResourceType.Image });
db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", ImageId = "r-used", Title = new List<TranslationDTO>() });
db.SaveChanges();
var result = BuildController(db)
.DeleteBulk(new ResourceBulkDeleteRequestDTO { ids = new List<string> { "r-used", "r-free" } })
.GetAwaiter().GetResult();
var report = Assert.IsType<ResourceBulkDeleteReportDTO>(Assert.IsType<OkObjectResult>(result).Value);
Assert.Equal(new[] { "r-free" }, report.deleted);
Assert.Equal("r-used", Assert.Single(report.refused).id);
Assert.Equal(1, Assert.Single(report.refused).usageCount);
} }
[Fact] [Fact]

View File

@ -0,0 +1,207 @@
using Manager.DTOs;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using ManagerService.Services;
using ManagerService.Tests.Infrastructure;
using Microsoft.Extensions.Caching.Memory;
using System.Collections.Generic;
using System.Linq;
using Xunit;
namespace ManagerService.Tests.Services
{
/// <summary>
/// L'index inverse décide de ce que la Médiathèque propose à la suppression.
/// Une ressource comptée orpheline à tort est une image effacée d'un contenu vivant,
/// d'où un test par piège identifié.
/// </summary>
public class ResourceUsageServiceTests
{
private static ResourceUsageService Build(MyInfoMateDbContext db) =>
new ResourceUsageService(db, new MemoryCache(new MemoryCacheOptions()));
private static void SeedInstance(MyInfoMateDbContext db)
{
db.Configurations.Add(new Configuration
{
Id = "cfg-1",
InstanceId = "inst-1",
Label = "Visite",
Title = new List<TranslationDTO>(),
Languages = new List<string> { "FR", "NL" }
});
}
[Fact]
public void ImageUsedOnlyInDutch_IsNotOrphan()
{
using var db = DbContextFactory.Create();
SeedInstance(db);
var article = TestSection.Article("sec-1", "inst-1", "Article", "cfg-1");
article.ArticleAudioIds = new List<TranslationDTO>
{
new TranslationDTO { language = "NL", value = "res-nl" }
};
db.Sections.Add(article);
db.Resources.Add(new Resource { Id = "res-nl", InstanceId = "inst-1", Label = "Audio NL", Type = ResourceType.Audio });
db.SaveChanges();
var usages = Build(db).GetUsages("res-nl");
Assert.Single(usages);
Assert.Equal("Section", usages[0].kind);
}
[Fact]
public void ConfigurationImages_AreCounted()
{
using var db = DbContextFactory.Create();
db.Configurations.Add(new Configuration
{
Id = "cfg-1",
InstanceId = "inst-1",
Label = "Visite",
Title = new List<TranslationDTO>(),
Languages = new List<string> { "FR" },
ImageId = "res-accueil",
LoaderImageId = "res-loader"
});
db.Resources.AddRange(
new Resource { Id = "res-accueil", InstanceId = "inst-1", Label = "Accueil", Type = ResourceType.Image },
new Resource { Id = "res-loader", InstanceId = "inst-1", Label = "Loader", Type = ResourceType.Image });
db.SaveChanges();
var service = Build(db);
Assert.Single(service.GetUsages("res-accueil"));
Assert.Equal("image", service.GetUsages("res-accueil")[0].field);
Assert.Equal("loaderImage", service.GetUsages("res-loader")[0].field);
}
[Fact]
public void GuidedPathOnEvent_IsWalked()
{
using var db = DbContextFactory.Create();
SeedInstance(db);
db.Sections.Add(new SectionEvent
{
Id = "sec-event",
InstanceId = "inst-1",
ConfigurationId = "cfg-1",
Label = "Carnaval",
Type = SectionType.Event,
Title = new List<TranslationDTO>(),
Description = new List<TranslationDTO>()
});
db.GuidedPaths.Add(new GuidedPath
{
Id = "path-1",
InstanceId = "inst-1",
SectionEventId = "sec-event",
Title = new List<TranslationDTO> { new TranslationDTO { language = "FR", value = "Chasse au trésor" } },
Description = new List<TranslationDTO>(),
ImageResourceId = "res-path"
});
db.Resources.Add(new Resource { Id = "res-path", InstanceId = "inst-1", Label = "Vignette", Type = ResourceType.Image });
db.SaveChanges();
var usages = Build(db).GetUsages("res-path");
// SectionEvent.GetReferencedResourceIds ne descend pas dans les parcours :
// sans parcours des GuidedPaths, cette image passerait pour orpheline.
Assert.Single(usages);
Assert.Equal("GuidedPath", usages[0].kind);
Assert.Contains("Chasse au trésor", usages[0].path);
}
[Fact]
public void StepResource_IsAttributedToTheStep_NotThePath()
{
using var db = DbContextFactory.Create();
SeedInstance(db);
db.GuidedPaths.Add(new GuidedPath
{
Id = "path-1",
InstanceId = "inst-1",
Title = new List<TranslationDTO> { new TranslationDTO { language = "FR", value = "Parcours" } },
Description = new List<TranslationDTO>(),
Steps = new List<GuidedStep>
{
new GuidedStep
{
Id = "step-1",
GuidedPathId = "path-1",
Order = 0,
Title = new List<TranslationDTO> { new TranslationDTO { language = "FR", value = "Première étape" } },
Description = new List<TranslationDTO>(),
Contents = new List<ContentDTO> { new ContentDTO { resourceId = "res-step" } },
AudioIds = new List<TranslationDTO>()
}
}
});
db.Resources.Add(new Resource { Id = "res-step", InstanceId = "inst-1", Label = "Photo", Type = ResourceType.Image });
db.SaveChanges();
var usages = Build(db).GetUsages("res-step");
Assert.Single(usages);
Assert.Equal("GuidedStep", usages[0].kind);
}
[Fact]
public void UsageSummary_ReportsZeroForUnusedResources_AndConfigurationLabels()
{
using var db = DbContextFactory.Create();
SeedInstance(db);
var article = TestSection.Article("sec-1", "inst-1", "Article", "cfg-1");
article.ImageId = "res-used";
db.Sections.Add(article);
db.Resources.AddRange(
new Resource { Id = "res-used", InstanceId = "inst-1", Label = "Utilisée", Type = ResourceType.Image },
new Resource { Id = "res-free", InstanceId = "inst-1", Label = "Libre", Type = ResourceType.Image });
db.SaveChanges();
var summary = Build(db).GetUsageSummary("inst-1");
Assert.Equal(1, summary.usages["res-used"].count);
Assert.Equal(new[] { "cfg-1" }, summary.usages["res-used"].configurationIds);
Assert.Equal(0, summary.usages["res-free"].count);
Assert.Equal("Visite", summary.configurations["cfg-1"]);
}
[Fact]
public void GuidedStepImageUrl_IsNotResolved()
{
using var db = DbContextFactory.Create();
SeedInstance(db);
db.GuidedPaths.Add(new GuidedPath
{
Id = "path-1",
InstanceId = "inst-1",
Title = new List<TranslationDTO>(),
Description = new List<TranslationDTO>(),
Steps = new List<GuidedStep>
{
new GuidedStep
{
Id = "step-1",
GuidedPathId = "path-1",
Order = 0,
Title = new List<TranslationDTO>(),
Description = new List<TranslationDTO>(),
Contents = new List<ContentDTO>(),
AudioIds = new List<TranslationDTO>(),
ImageUrl = "https://storage/res-url"
}
}
});
db.Resources.Add(new Resource { Id = "res-url", InstanceId = "inst-1", Label = "Image d'étape", Type = ResourceType.Image });
db.SaveChanges();
// Limite connue et assumée en V1 : ImageUrl est une URL absolue, pas un id.
// Le test la fige pour que sa disparition en V2 soit un choix, pas un accident.
Assert.Empty(Build(db).GetUsages("res-url"));
}
}
}

View File

@ -254,10 +254,18 @@ namespace ManagerService.Controllers
// granularité au jour supposerait de garder les questions, ce que la purge interdit. // granularité au jour supposerait de garder les questions, ce que la purge interdit.
var monthOfSince = new DateTime(since.Year, since.Month, 1, 0, 0, 0, DateTimeKind.Utc); var monthOfSince = new DateTime(since.Year, since.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var citedIds = await scope // `CitedContentIds` est une List<string> sérialisée en jsonb par un convertisseur
.SelectMany(q => q.CitedContentIds) // de valeur : EF ne sait pas traduire un SelectMany dessus et lève à l'exécution.
// On rapatrie les listes, puis on aplatit côté client.
var citedLists = await scope
.Select(q => q.CitedContentIds)
.ToListAsync(); .ToListAsync();
var citedIds = citedLists
.Where(ids => ids != null)
.SelectMany(ids => ids)
.ToList();
// Les titres se résolvent en une seule requête, puis en mémoire : la liste des // Les titres se résolvent en une seule requête, puis en mémoire : la liste des
// contenus cités est courte par nature, elle est déjà tronquée à 6. // contenus cités est courte par nature, elle est déjà tronquée à 6.
var topCited = citedIds var topCited = citedIds

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
@ -579,7 +579,7 @@ namespace ManagerService.Controllers
resource.InstanceId = resourceExport.instanceId; resource.InstanceId = resourceExport.instanceId;
resource.Type = resourceExport.type; resource.Type = resourceExport.type;
resource.Label = resourceExport.label; resource.Label = resourceExport.label;
resource.DateCreation = resourceExport.dateCreation; resource.DateCreation = resourceExport.dateCreation ?? DateTime.Now.ToUniversalTime();
//resource.Data = resourceExport.data; //resource.Data = resourceExport.data;
var resourceInDb = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == resourceExport.id); var resourceInDb = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == resourceExport.id);

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.IO; using System.IO;
@ -34,13 +34,15 @@ namespace ManagerService.Controllers
private readonly ILogger<ResourceController> _logger; private readonly ILogger<ResourceController> _logger;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IResourceBlobService _blobService; private readonly IResourceBlobService _blobService;
private readonly ResourceUsageService _usageService;
IHexIdGeneratorService idService = new HexIdGeneratorService(); IHexIdGeneratorService idService = new HexIdGeneratorService();
private static int MaxWidth = 1024; private static int MaxWidth = 1024;
private static int MaxHeight = 1024; private static int MaxHeight = 1024;
public ResourceController(ILogger<ResourceController> logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService) public ResourceController(ILogger<ResourceController> logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService, ResourceUsageService usageService)
{ {
_usageService = usageService;
_logger = logger; _logger = logger;
_resourceService = resourceService; _resourceService = resourceService;
_sectionService = sectionService; _sectionService = sectionService;
@ -270,6 +272,7 @@ namespace ManagerService.Controllers
// Todo add some verification ? // Todo add some verification ?
Resource resource = new Resource(); Resource resource = new Resource();
resource.Label = label; resource.Label = label;
resource.FileName = file.FileName;
resource.Type = resourceType; resource.Type = resourceType;
resource.DateCreation = DateTime.Now.ToUniversalTime(); resource.DateCreation = DateTime.Now.ToUniversalTime();
resource.InstanceId = instanceId; resource.InstanceId = instanceId;
@ -333,6 +336,9 @@ namespace ManagerService.Controllers
resource.Label = newResource.label; resource.Label = newResource.label;
resource.Type = newResource.type; resource.Type = newResource.type;
resource.Url = newResource.url; resource.Url = newResource.url;
resource.FileName = newResource.fileName;
resource.Width = newResource.width;
resource.Height = newResource.height;
resource.DateCreation = DateTime.Now.ToUniversalTime(); resource.DateCreation = DateTime.Now.ToUniversalTime();
//resource.Data = newResource.data; //resource.Data = newResource.data;
resource.InstanceId = newResource.instanceId; resource.InstanceId = newResource.instanceId;
@ -388,6 +394,9 @@ namespace ManagerService.Controllers
resource.Label = updatedResource.label != null ? updatedResource.label : resource.Label; resource.Label = updatedResource.label != null ? updatedResource.label : resource.Label;
resource.Type = updatedResource.type != null ? updatedResource.type : resource.Type; resource.Type = updatedResource.type != null ? updatedResource.type : resource.Type;
resource.Url = updatedResource.url != null ? updatedResource.url : resource.Url; resource.Url = updatedResource.url != null ? updatedResource.url : resource.Url;
resource.FileName = updatedResource.fileName != null ? updatedResource.fileName : resource.FileName;
resource.Width = updatedResource.width ?? resource.Width;
resource.Height = updatedResource.height ?? resource.Height;
if (updatedResource.sizeBytes > 0) if (updatedResource.sizeBytes > 0)
resource.SizeBytes = updatedResource.sizeBytes; resource.SizeBytes = updatedResource.sizeBytes;
@ -425,6 +434,115 @@ namespace ManagerService.Controllers
} }
/// <summary>
/// Contenus qui utilisent une ressource.
/// </summary>
/// <param name="id">id de la ressource</param>
[ProducesResponseType(typeof(List<ResourceUsageDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{id}/usages")]
public ObjectResult GetUsages(string id)
{
try
{
return new OkObjectResult(_usageService.GetUsages(id));
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Nombre d'usages par ressource pour une instance, zéro compris.
/// </summary>
/// <param name="instanceId">id de l'instance</param>
[ProducesResponseType(typeof(ResourceUsageMapDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("usage-map")]
public ObjectResult GetUsageMap([FromQuery] string instanceId)
{
try
{
if (instanceId == null)
throw new ArgumentNullException("InstanceId needed");
return new OkObjectResult(_usageService.GetUsageSummary(instanceId));
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message) { };
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Suppression en lot. Même règle que la suppression unitaire, appliquée par élément :
/// une ressource utilisée est refusée, les autres partent, et le rapport dit qui est qui.
/// </summary>
[ProducesResponseType(typeof(ResourceBulkDeleteReportDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 500)]
[HttpDelete("bulk")]
public async Task<ObjectResult> DeleteBulk([FromBody] ResourceBulkDeleteRequestDTO request)
{
try
{
if (request?.ids == null)
throw new ArgumentNullException("Ids param is null");
var report = new ResourceBulkDeleteReportDTO();
foreach (var id in request.ids.Distinct())
{
var resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == id);
if (resource == null)
{
report.refused.Add(new ResourceBulkDeleteRefusalDTO { id = id, reason = "Ressource introuvable" });
continue;
}
var usages = _usageService.GetUsages(id);
if (usages.Count > 0)
{
report.refused.Add(new ResourceBulkDeleteRefusalDTO
{
id = id,
label = resource.Label,
usageCount = usages.Count,
reason = "Ressource utilisée"
});
continue;
}
var outcome = await Delete(id);
if (outcome.StatusCode == 202)
report.deleted.Add(id);
else
report.refused.Add(new ResourceBulkDeleteRefusalDTO
{
id = id,
label = resource.Label,
reason = outcome.Value?.ToString()
});
}
return new OkObjectResult(report);
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message) { };
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary> /// <summary>
/// Delete a resource /// Delete a resource
/// </summary> /// </summary>
@ -433,6 +551,7 @@ namespace ManagerService.Controllers
[ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)] [ProducesResponseType(typeof(string), 500)]
[ProducesResponseType(typeof(List<ResourceUsageDTO>), 409)]
[ProducesResponseType(typeof(string), 502)] [ProducesResponseType(typeof(string), 502)]
[HttpDelete("{id}")] [HttpDelete("{id}")]
public async Task<ObjectResult> Delete(string id) public async Task<ObjectResult> Delete(string id)
@ -447,6 +566,13 @@ namespace ManagerService.Controllers
if (resource == null) if (resource == null)
throw new KeyNotFoundException("Resource does not exist"); throw new KeyNotFoundException("Resource does not exist");
// Le front désactive déjà le bouton, mais la règle doit vivre ici : la
// suite de cette méthode déréférence la ressource partout où elle sert,
// donc un appel venu d'ailleurs viderait des contenus en silence.
var usages = _usageService.GetUsages(id);
if (usages.Count > 0)
return new ConflictObjectResult(usages) { };
// Le blob part AVANT la ligne. manager-app faisait l'inverse et avalait // Le blob part AVANT la ligne. manager-app faisait l'inverse et avalait
// l'échec : la ligne disparaissait, le blob restait, et n'ayant plus de // l'échec : la ligne disparaissait, le blob restait, et n'ayant plus de
// ligne il devenait invisible au quota tout en restant facturé. Ici un // ligne il devenait invisible au quota tout en restant facturé. Ici un
@ -502,7 +628,10 @@ namespace ManagerService.Controllers
{ {
foreach (var categorie in map.MapCategories) foreach (var categorie in map.MapCategories)
{ {
if (categorie.resourceDTO.id == id) // Une catégorie sans icône a un `resourceDTO` nul :
// le déréférencer faisait échouer toute suppression
// de ressource dès qu'une carte avait des catégories.
if (categorie.resourceDTO?.id == id)
{ {
categorie.resourceDTO = null; categorie.resourceDTO = null;
_myInfoMateDbContext.Entry(map).Property(p => p.MapCategories).IsModified = true; _myInfoMateDbContext.Entry(map).Property(p => p.MapCategories).IsModified = true;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
@ -76,12 +76,27 @@ namespace ManagerService.Controllers
{ {
try try
{ {
var query = _myInfoMateDbContext.Users.AsQueryable();
if (!IsSuperAdmin()) if (!IsSuperAdmin())
query = query.Where(u => u.InstanceId == GetCallerInstanceId()); {
var callerInstanceId = GetCallerInstanceId();
var users = _myInfoMateDbContext.Users
.Where(u => u.InstanceId == callerInstanceId)
.ToList();
return new OkObjectResult(query.ToList().Select(u => u.ToDTO())); return new OkObjectResult(users.Select(u => u.ToDTO()));
}
// Le SuperAdmin voit toutes les instances : sans le nom de l'instance,
// deux utilisateurs homonymes de clients differents sont indistinguables.
var instanceNames = _myInfoMateDbContext.Instances
.ToDictionary(i => i.Id, i => i.Name);
return new OkObjectResult(_myInfoMateDbContext.Users.ToList().Select(u =>
{
var dto = u.ToDTO();
dto.instanceName = instanceNames.TryGetValue(u.InstanceId, out var name) ? name : null;
return dto;
}));
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
namespace ManagerService.DTOs
{
public class ResourceBulkDeleteRequestDTO
{
public List<string> ids { get; set; }
}
public class ResourceBulkDeleteReportDTO
{
public List<string> deleted { get; set; } = new List<string>();
public List<ResourceBulkDeleteRefusalDTO> refused { get; set; } = new List<ResourceBulkDeleteRefusalDTO>();
}
public class ResourceBulkDeleteRefusalDTO
{
public string id { get; set; }
public string label { get; set; }
/// <summary>Zéro quand le refus vient d'autre chose qu'un usage (introuvable, stockage).</summary>
public int usageCount { get; set; }
public string reason { get; set; }
}
}

View File

@ -9,9 +9,19 @@ namespace ManagerService.DTOs
public ResourceType type { get; set; } public ResourceType type { get; set; }
public string label { get; set; } public string label { get; set; }
public string url { get; set; } // firebase url public string url { get; set; } // firebase url
public DateTime dateCreation { get; set; } public DateTime? dateCreation { get; set; }
public DateTime dateUpdate { get; set; } public DateTime? dateUpdate { get; set; }
public string instanceId { get; set; } public string instanceId { get; set; }
public long sizeBytes { get; set; } public long sizeBytes { get; set; }
/// <summary>Vrai nom de fichier ("brochure-2026.pdf"). Nul sur les ressources anciennes.</summary>
public string fileName { get; set; }
public int? width { get; set; }
public int? height { get; set; }
/// <summary>Nombre d'usages. Rempli seulement par les endpoints qui le calculent.</summary>
public int? usageCount { get; set; }
} }
} }

View File

@ -0,0 +1,46 @@
using System.Collections.Generic;
namespace ManagerService.DTOs
{
public class ResourceUsageDTO
{
/// <summary>Section | GuidedPath | GuidedStep | Configuration</summary>
public string kind { get; set; }
public string id { get; set; }
public string label { get; set; }
public string configurationId { get; set; }
public string configurationLabel { get; set; }
/// <summary>Section porteuse, pour ouvrir le contenu depuis la Médiathèque. Nulle pour kind = Configuration.</summary>
public string sectionId { get; set; }
/// <summary>Champ porteur quand il est identifiable ("image", "loaderImage"), sinon "contenu".</summary>
public string field { get; set; }
/// <summary>Chaîne lisible affichée au front : "Escape game Parcours image".</summary>
public string path { get; set; }
}
/// <summary>
/// Réponse de <c>GET /api/Resource/usage-map</c> : tout ce dont le rail de facettes de la
/// Médiathèque a besoin en une requête — un compteur par ressource, les configurations
/// où elle sert, et le libellé de ces configurations pour l'afficher sans second appel.
/// </summary>
public class ResourceUsageMapDTO
{
public Dictionary<string, ResourceUsageSummaryDTO> usages { get; set; } = new Dictionary<string, ResourceUsageSummaryDTO>();
public Dictionary<string, string> configurations { get; set; } = new Dictionary<string, string>();
}
public class ResourceUsageSummaryDTO
{
public int count { get; set; }
public List<string> configurationIds { get; set; } = new List<string>();
}
}

View File

@ -9,6 +9,9 @@ namespace ManagerService.DTOs
public string firstName { get; set; } public string firstName { get; set; }
public string lastName { get; set; } public string lastName { get; set; }
public string instanceId { get; set; } public string instanceId { get; set; }
/// <summary>Nom de l'instance, renseigne uniquement pour un SuperAdmin qui liste toutes les instances.</summary>
public string? instanceName { get; set; }
public UserRole? role { get; set; } public UserRole? role { get; set; }
public string? password { get; set; } public string? password { get; set; }
} }

View File

@ -54,6 +54,12 @@ namespace ManagerService.Data
/// </summary> /// </summary>
public string FileName { get; set; } public string FileName { get; set; }
/// <summary>Largeur en pixels, connue de manager-app au moment de l'upload. Nulle sur les ressources anciennes.</summary>
public int? Width { get; set; }
/// <summary>Hauteur en pixels, connue de manager-app au moment de l'upload. Nulle sur les ressources anciennes.</summary>
public int? Height { get; set; }
/// <summary>Inclure ce document dans les connaissances du guide IA. Exposé dans l'UI en V2 seulement.</summary> /// <summary>Inclure ce document dans les connaissances du guide IA. Exposé dans l'UI en V2 seulement.</summary>
public bool IncludeInAiKnowledge { get; set; } = false; public bool IncludeInAiKnowledge { get; set; } = false;
@ -77,7 +83,10 @@ namespace ManagerService.Data
dateCreation = DateCreation, dateCreation = DateCreation,
dateUpdate = DateUpdate, dateUpdate = DateUpdate,
instanceId = InstanceId, instanceId = InstanceId,
sizeBytes = SizeBytes sizeBytes = SizeBytes,
fileName = FileName,
width = Width,
height = Height
}; };
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddResourceDimensions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Height",
table: "Resources",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Width",
table: "Resources",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Height",
table: "Resources");
migrationBuilder.DropColumn(
name: "Width",
table: "Resources");
}
}
}

View File

@ -644,6 +644,9 @@ namespace ManagerService.Migrations
b.Property<string>("FileName") b.Property<string>("FileName")
.HasColumnType("text"); .HasColumnType("text");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<bool>("IncludeInAiKnowledge") b.Property<bool>("IncludeInAiKnowledge")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -667,6 +670,9 @@ namespace ManagerService.Migrations
b.Property<string>("Url") b.Property<string>("Url")
.HasColumnType("text"); .HasColumnType("text");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("InstanceId"); b.HasIndex("InstanceId");

View File

@ -0,0 +1,293 @@
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);
}
}
}

View File

@ -173,6 +173,8 @@ namespace ManagerService
services.AddScoped<SectionDatabaseService>(); services.AddScoped<SectionDatabaseService>();
services.AddScoped<ConfigurationDatabaseService>(); services.AddScoped<ConfigurationDatabaseService>();
services.AddScoped<ResourceDatabaseService>(); services.AddScoped<ResourceDatabaseService>();
services.AddMemoryCache();
services.AddScoped<ResourceUsageService>();
services.AddScoped<DeviceDatabaseService>(); services.AddScoped<DeviceDatabaseService>();
services.AddScoped<InstanceDatabaseService>(); services.AddScoped<InstanceDatabaseService>();
services.AddScoped<ApiKeyDatabaseService>(); services.AddScoped<ApiKeyDatabaseService>();

View File

@ -29,3 +29,4 @@
"Landing": "http://localhost:3000" "Landing": "http://localhost:3000"
} }
} }