Misc update ressource to mediatheque !
This commit is contained in:
parent
ea237d2913
commit
3d23e05351
@ -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]
|
||||||
|
|||||||
207
ManagerService.Tests/Services/ResourceUsageServiceTests.cs
Normal file
207
ManagerService.Tests/Services/ResourceUsageServiceTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
28
ManagerService/DTOs/ResourceBulkDeleteDTO.cs
Normal file
28
ManagerService/DTOs/ResourceBulkDeleteDTO.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
ManagerService/DTOs/ResourceUsageDTO.cs
Normal file
46
ManagerService/DTOs/ResourceUsageDTO.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1922
ManagerService/Migrations/20260902135901_AddResourceDimensions.Designer.cs
generated
Normal file
1922
ManagerService/Migrations/20260902135901_AddResourceDimensions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|||||||
293
ManagerService/Services/ResourceUsageService.cs
Normal file
293
ManagerService/Services/ResourceUsageService.cs
Normal 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 « <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>();
|
||||||
|
|||||||
@ -29,3 +29,4 @@
|
|||||||
"Landing": "http://localhost:3000"
|
"Landing": "http://localhost:3000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user