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.Data;
|
||||
using ManagerService.DTOs;
|
||||
using ManagerService.Services;
|
||||
using ManagerService.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@ -27,7 +28,8 @@ namespace ManagerService.Tests.Controllers
|
||||
new ConfigurationDatabaseService(cfg),
|
||||
db,
|
||||
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"));
|
||||
return controller;
|
||||
}
|
||||
@ -166,21 +168,26 @@ namespace ManagerService.Tests.Controllers
|
||||
|
||||
// ── 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]
|
||||
public void Delete_NullifiesConfigurationImageId()
|
||||
public void Delete_ResourceUsedByConfiguration_Returns409()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
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.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]
|
||||
public void Delete_NullifiesSectionImageId()
|
||||
public void Delete_ResourceUsedBySection_Returns409()
|
||||
{
|
||||
using var db = DbContextFactory.Create();
|
||||
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.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]
|
||||
|
||||
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.
|
||||
var monthOfSince = new DateTime(since.Year, since.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var citedIds = await scope
|
||||
.SelectMany(q => q.CitedContentIds)
|
||||
// `CitedContentIds` est une List<string> sérialisée en jsonb par un convertisseur
|
||||
// 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();
|
||||
|
||||
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
|
||||
// contenus cités est courte par nature, elle est déjà tronquée à 6.
|
||||
var topCited = citedIds
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
@ -579,7 +579,7 @@ namespace ManagerService.Controllers
|
||||
resource.InstanceId = resourceExport.instanceId;
|
||||
resource.Type = resourceExport.type;
|
||||
resource.Label = resourceExport.label;
|
||||
resource.DateCreation = resourceExport.dateCreation;
|
||||
resource.DateCreation = resourceExport.dateCreation ?? DateTime.Now.ToUniversalTime();
|
||||
//resource.Data = resourceExport.data;
|
||||
|
||||
var resourceInDb = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == resourceExport.id);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
@ -34,13 +34,15 @@ namespace ManagerService.Controllers
|
||||
private readonly ILogger<ResourceController> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IResourceBlobService _blobService;
|
||||
private readonly ResourceUsageService _usageService;
|
||||
IHexIdGeneratorService idService = new HexIdGeneratorService();
|
||||
|
||||
private static int MaxWidth = 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;
|
||||
_resourceService = resourceService;
|
||||
_sectionService = sectionService;
|
||||
@ -270,6 +272,7 @@ namespace ManagerService.Controllers
|
||||
// Todo add some verification ?
|
||||
Resource resource = new Resource();
|
||||
resource.Label = label;
|
||||
resource.FileName = file.FileName;
|
||||
resource.Type = resourceType;
|
||||
resource.DateCreation = DateTime.Now.ToUniversalTime();
|
||||
resource.InstanceId = instanceId;
|
||||
@ -333,6 +336,9 @@ namespace ManagerService.Controllers
|
||||
resource.Label = newResource.label;
|
||||
resource.Type = newResource.type;
|
||||
resource.Url = newResource.url;
|
||||
resource.FileName = newResource.fileName;
|
||||
resource.Width = newResource.width;
|
||||
resource.Height = newResource.height;
|
||||
resource.DateCreation = DateTime.Now.ToUniversalTime();
|
||||
//resource.Data = newResource.data;
|
||||
resource.InstanceId = newResource.instanceId;
|
||||
@ -388,6 +394,9 @@ namespace ManagerService.Controllers
|
||||
resource.Label = updatedResource.label != null ? updatedResource.label : resource.Label;
|
||||
resource.Type = updatedResource.type != null ? updatedResource.type : resource.Type;
|
||||
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)
|
||||
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>
|
||||
/// Delete a resource
|
||||
/// </summary>
|
||||
@ -433,6 +551,7 @@ namespace ManagerService.Controllers
|
||||
[ProducesResponseType(typeof(string), 400)]
|
||||
[ProducesResponseType(typeof(string), 404)]
|
||||
[ProducesResponseType(typeof(string), 500)]
|
||||
[ProducesResponseType(typeof(List<ResourceUsageDTO>), 409)]
|
||||
[ProducesResponseType(typeof(string), 502)]
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ObjectResult> Delete(string id)
|
||||
@ -447,6 +566,13 @@ namespace ManagerService.Controllers
|
||||
if (resource == null)
|
||||
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
|
||||
// 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
|
||||
@ -502,7 +628,10 @@ namespace ManagerService.Controllers
|
||||
{
|
||||
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;
|
||||
_myInfoMateDbContext.Entry(map).Property(p => p.MapCategories).IsModified = true;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
@ -76,12 +76,27 @@ namespace ManagerService.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = _myInfoMateDbContext.Users.AsQueryable();
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
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 string label { get; set; }
|
||||
public string url { get; set; } // firebase url
|
||||
public DateTime dateCreation { get; set; }
|
||||
public DateTime dateUpdate { get; set; }
|
||||
public DateTime? dateCreation { get; set; }
|
||||
public DateTime? dateUpdate { get; set; }
|
||||
public string instanceId { 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 lastName { 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 string? password { get; set; }
|
||||
}
|
||||
|
||||
@ -54,6 +54,12 @@ namespace ManagerService.Data
|
||||
/// </summary>
|
||||
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>
|
||||
public bool IncludeInAiKnowledge { get; set; } = false;
|
||||
|
||||
@ -77,7 +83,10 @@ namespace ManagerService.Data
|
||||
dateCreation = DateCreation,
|
||||
dateUpdate = DateUpdate,
|
||||
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")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IncludeInAiKnowledge")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@ -667,6 +670,9 @@ namespace ManagerService.Migrations
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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<ConfigurationDatabaseService>();
|
||||
services.AddScoped<ResourceDatabaseService>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ResourceUsageService>();
|
||||
services.AddScoped<DeviceDatabaseService>();
|
||||
services.AddScoped<InstanceDatabaseService>();
|
||||
services.AddScoped<ApiKeyDatabaseService>();
|
||||
|
||||
@ -29,3 +29,4 @@
|
||||
"Landing": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user