From 91fc7f168b4ed98ad344cfe169f7dbf81dee78fb Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 11 Sep 2026 12:45:13 +0100 Subject: [PATCH] implements a dico --- .../PostIt.Tests/HomePageProviderFlowTests.cs | 15 ++ .../ViewModels/Layout/HomePageViewModel.cs | 5 +- src/PostIt/PostIt/Views/Blogs/MainPage.axaml | 10 +- .../Business/DictionnaireMetierController.cs | 181 ++++++++++++++++++ .../DictionnaireMetierTests.cs | 76 ++++++++ .../Models/ApplicationDbContext.cs | 25 +++ .../Models/Workflow/DictionnaireMetier.cs | 79 ++++++++ .../DictionnaireMetierModerationService.cs | 116 +++++++++++ 8 files changed, 502 insertions(+), 5 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs create mode 100644 src/Yavsc.Api/Controllers/Business/DictionnaireMetierController.cs create mode 100644 src/Yavsc.Org.Tests/DictionnaireMetierTests.cs create mode 100644 src/Yavsc.Server/Models/Workflow/DictionnaireMetier.cs create mode 100644 src/Yavsc.Server/Services/DictionnaireMetierModerationService.cs diff --git a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs new file mode 100644 index 000000000..16f01c5e9 --- /dev/null +++ b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs @@ -0,0 +1,15 @@ +using PostIt.ViewModels; + +namespace PostIt.Tests; + +public class HomePageProviderFlowTests +{ + [Fact] + public void HomePage_exposes_provider_requests_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenProviderRequests); + Assert.True(vm.OpenProviderRequests.CanExecute(null)); + } +} diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 830be4dde..f26567572 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -30,10 +30,11 @@ public class HomePageViewModel : ViewModelBase SessionStatus = sessionStatus; OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); - + OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); } public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync); public IAsyncRelayCommand OpenActivities { get; } + public IAsyncRelayCommand OpenProviderRequests { get; } private async Task OpenActivitiesAsync() { @@ -48,6 +49,8 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + private Task OpenProviderRequestsAsync() => OpenActivitiesAsync(); + /// /// Avalonia designer constructor. Builds a self-contained VM /// with a freshly-constructed Settings so the XAML preview can diff --git a/src/PostIt/PostIt/Views/Blogs/MainPage.axaml b/src/PostIt/PostIt/Views/Blogs/MainPage.axaml index 9c5c7715e..4c426cd73 100644 --- a/src/PostIt/PostIt/Views/Blogs/MainPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/MainPage.axaml @@ -29,7 +29,7 @@ - + @@ -40,7 +40,7 @@ - + @@ -57,7 +57,8 @@ + Label="ACL" + Command="{Binding ManageAclAsync}"> @@ -114,7 +115,8 @@ - + >> GetTerms( + [FromRoute] string activityCode, + [FromQuery] string langue = "fr", + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(activityCode)) + { + return BadRequest("Activity code is required."); + } + + var activityCodes = await ResolveActivityCodesAsync(activityCode, cancellationToken); + if (activityCodes.Count == 0) + { + return NotFound(); + } + + var dictionaryIds = await _context.DictionnaireMetier + .AsNoTracking() + .Where(d => activityCodes.Contains(d.DomaineActiviteCode) && d.Langue == langue) + .Select(d => d.Id) + .ToListAsync(cancellationToken); + + if (dictionaryIds.Count == 0) + { + return Ok(new List()); + } + + var terms = await _context.TermeMetier + .AsNoTracking() + .Where(t => dictionaryIds.Contains(t.DictionnaireMetierId) + && t.StatutValidation == StatutValidationTerme.Valide) + .OrderBy(t => t.Mot) + .ToListAsync(cancellationToken); + + return Ok(terms); + } + + [HttpGet("dictionnaires/{activityCode}")] + public async Task>> GetDictionaries( + [FromRoute] string activityCode, + [FromQuery] string langue = "fr", + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(activityCode)) + { + return BadRequest("Activity code is required."); + } + + var activityCodes = await ResolveActivityCodesAsync(activityCode, cancellationToken); + if (activityCodes.Count == 0) + { + return NotFound(); + } + + var dictionaries = await _context.DictionnaireMetier + .AsNoTracking() + .Where(d => activityCodes.Contains(d.DomaineActiviteCode) && d.Langue == langue) + .OrderBy(d => d.Nom) + .ToListAsync(cancellationToken); + + return Ok(dictionaries); + } + + [HttpPost("proposer")] + public async Task> ProposeTerm( + [FromBody] TermeMetier term, + CancellationToken cancellationToken) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (string.IsNullOrWhiteSpace(term.Mot) || string.IsNullOrWhiteSpace(term.Definition)) + { + return BadRequest("Le terme et sa définition sont requis."); + } + + var dictionary = await _context.DictionnaireMetier + .SingleOrDefaultAsync(d => d.Id == term.DictionnaireMetierId, cancellationToken); + + if (dictionary is null) + { + return NotFound("Dictionary not found."); + } + + var proposerId = User.GetUserId(); + var result = await _moderationService.ProposerTermAsync( + term.DictionnaireMetierId, + term.Mot, + term.Definition, + term.Langue, + proposerId); + + return CreatedAtAction(nameof(GetTerms), new { activityCode = dictionary.DomaineActiviteCode }, result); + } + + [HttpPut("{id}/valider")] + [Authorize("AdministratorOnly")] + public async Task ValidateTerm( + [FromRoute] long id, + CancellationToken cancellationToken) + { + try + { + var term = await _moderationService.ValiderTermAsync(id, User.GetUserId()); + return Ok(term); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + } + + [HttpPut("{id}/rejeter")] + [Authorize("AdministratorOnly")] + public async Task RejectTerm( + [FromRoute] long id, + CancellationToken cancellationToken) + { + try + { + var term = await _moderationService.RejeterTermAsync(id, User.GetUserId()); + return Ok(term); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + } + + private async Task> ResolveActivityCodesAsync(string activityCode, CancellationToken cancellationToken) + { + var result = new HashSet(); + var currentCode = activityCode; + + while (!string.IsNullOrWhiteSpace(currentCode)) + { + result.Add(currentCode); + + var current = await _context.Activities + .AsNoTracking() + .SingleOrDefaultAsync(a => a.Code == currentCode, cancellationToken); + + if (current is null || string.IsNullOrWhiteSpace(current.ParentCode)) + { + break; + } + + currentCode = current.ParentCode; + } + + return result.ToList(); + } + } +} diff --git a/src/Yavsc.Org.Tests/DictionnaireMetierTests.cs b/src/Yavsc.Org.Tests/DictionnaireMetierTests.cs new file mode 100644 index 000000000..a05b24e88 --- /dev/null +++ b/src/Yavsc.Org.Tests/DictionnaireMetierTests.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using Yavsc.Models; +using Yavsc.Models.Workflow; +using Yavsc.Server.Services; + +namespace Yavsc.Org.Tests; + +public class DictionnaireMetierTests +{ + [Fact] + public void DictionnaireMetier_and_TermeMetier_can_be_constructed() + { + var dictionary = new DictionnaireMetier + { + Id = 1, + Nom = "Droit", + Langue = "fr", + DomaineActiviteCode = "Droit" + }; + + var term = new TermeMetier + { + Id = 2, + DictionnaireMetierId = dictionary.Id, + DictionnaireMetier = dictionary, + Mot = "contrat", + Definition = "Accord de volontés", + Langue = "fr", + StatutValidation = StatutValidationTerme.Propose, + ProposeParId = "user-1" + }; + + Assert.Equal("Droit", dictionary.DomaineActiviteCode); + Assert.Equal("contrat", term.Mot); + Assert.Equal(StatutValidationTerme.Propose, term.StatutValidation); + } + + [Fact] + public async Task DictionnaireMetier_moderation_flow_allows_propose_validate_and_reject() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + await using var context = new ApplicationDbContext(options); + context.Activities.Add(new Activity + { + Code = "Droit", + Name = "Droit", + ParentCode = null, + Description = "Domaine de référence", + Hidden = false, + Forms = new List() + }); + + context.DictionnaireMetier.Add(new DictionnaireMetier + { + Nom = "Droit civil", + Langue = "fr", + DomaineActiviteCode = "Droit" + }); + await context.SaveChangesAsync(); + + var service = new DictionnaireMetierModerationService(context); + + var proposed = await service.ProposerTermAsync(1, "contrat", "Accord de volontés", "fr", "user-proposer"); + Assert.Equal(StatutValidationTerme.Propose, proposed.StatutValidation); + + var validated = await service.ValiderTermAsync(proposed.Id, "user-moderator"); + Assert.Equal(StatutValidationTerme.Valide, validated.StatutValidation); + Assert.Equal("user-moderator", validated.ValideParId); + + var rejected = await service.RejeterTermAsync(1, "user-moderator"); + Assert.Equal(StatutValidationTerme.Rejete, rejected.StatutValidation); + } +} diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index 9d8064f76..fa2a9d498 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -126,6 +126,27 @@ namespace Yavsc.Models builder.Entity().Property(a => a.ParentCode).IsRequired(false); builder.Entity().Property(a => a.Description).IsRequired(false); + builder.Entity() + .HasOne(d => d.DomaineActivite) + .WithMany() + .HasForeignKey(d => d.DomaineActiviteCode) + .HasPrincipalKey(a => a.Code) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(d => new { d.DomaineActiviteCode, d.Langue, d.Nom }) + .IsUnique(); + + builder.Entity() + .HasOne(t => t.DictionnaireMetier) + .WithMany(d => d.Termes) + .HasForeignKey(t => t.DictionnaireMetierId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasIndex(t => new { t.DictionnaireMetierId, t.Langue, t.Mot }) + .IsUnique(); + builder.Entity().HasKey(c => c.Code); builder.Entity() .HasOne(v => v.Country) @@ -263,6 +284,10 @@ namespace Yavsc.Models /// public DbSet Activities { get; set; } + public DbSet DictionnaireMetier { get; set; } + + public DbSet TermeMetier { get; set; } + public DbSet UserActivities { get; set; } /// diff --git a/src/Yavsc.Server/Models/Workflow/DictionnaireMetier.cs b/src/Yavsc.Server/Models/Workflow/DictionnaireMetier.cs new file mode 100644 index 000000000..c6c8f1bf5 --- /dev/null +++ b/src/Yavsc.Server/Models/Workflow/DictionnaireMetier.cs @@ -0,0 +1,79 @@ +#nullable enable annotations + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Yavsc.Models.Workflow +{ + public enum StatutValidationTerme + { + Propose, + Valide, + Rejete + } + + public class DictionnaireMetier + { + [Key] + public long Id { get; set; } + + [Required, MaxLength(200)] + public string Nom { get; set; } = string.Empty; + + [Required, MaxLength(20)] + public string Langue { get; set; } = "fr"; + + [Required, MaxLength(128)] + [ForeignKey(nameof(DomaineActivite))] + public string DomaineActiviteCode { get; set; } = string.Empty; + + [JsonIgnore] + public virtual Activity? DomaineActivite { get; set; } + + [JsonIgnore] + public virtual ICollection Termes { get; set; } = new List(); + } + + public class TermeMetier + { + [Key] + public long Id { get; set; } + + [Required] + public long DictionnaireMetierId { get; set; } + + [ForeignKey(nameof(DictionnaireMetierId))] + [JsonIgnore] + public virtual DictionnaireMetier? DictionnaireMetier { get; set; } + + [Required, MaxLength(200)] + public string Mot { get; set; } = string.Empty; + + [Required, MaxLength(2000)] + public string Definition { get; set; } = string.Empty; + + [Required, MaxLength(20)] + public string Langue { get; set; } = "fr"; + + public StatutValidationTerme StatutValidation { get; set; } = StatutValidationTerme.Propose; + + [MaxLength(450)] + public string? ProposeParId { get; set; } + + [ForeignKey(nameof(ProposeParId))] + [JsonIgnore] + public virtual ApplicationUser? ProposePar { get; set; } + + [MaxLength(450)] + public string? ValideParId { get; set; } + + [ForeignKey(nameof(ValideParId))] + [JsonIgnore] + public virtual ApplicationUser? ValidePar { get; set; } + + public DateTime DateSoumission { get; set; } = DateTime.UtcNow; + + public DateTime? DateValidation { get; set; } + } +} diff --git a/src/Yavsc.Server/Services/DictionnaireMetierModerationService.cs b/src/Yavsc.Server/Services/DictionnaireMetierModerationService.cs new file mode 100644 index 000000000..92b13bfdf --- /dev/null +++ b/src/Yavsc.Server/Services/DictionnaireMetierModerationService.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore; +using Yavsc.Models; +using Yavsc.Models.Workflow; + +namespace Yavsc.Server.Services +{ + public class DictionnaireMetierModerationService + { + private readonly ApplicationDbContext _context; + + public DictionnaireMetierModerationService(ApplicationDbContext context) + { + _context = context; + } + + public async Task ProposerTermAsync( + long dictionnaireId, + string mot, + string definition, + string langue, + string userId) + { + if (string.IsNullOrWhiteSpace(mot)) + { + throw new ArgumentException("Le terme est requis.", nameof(mot)); + } + + if (string.IsNullOrWhiteSpace(definition)) + { + throw new ArgumentException("La définition est requise.", nameof(definition)); + } + + var dictionary = await _context.DictionnaireMetier + .SingleOrDefaultAsync(d => d.Id == dictionnaireId); + + if (dictionary is null) + { + throw new KeyNotFoundException($"Dictionnaire {dictionnaireId} introuvable."); + } + + var normalizedMot = mot.Trim(); + var exists = await _context.TermeMetier + .AnyAsync(t => t.DictionnaireMetierId == dictionnaireId + && t.Langue == langue + && t.Mot == normalizedMot); + + if (exists) + { + throw new InvalidOperationException($"Le terme '{normalizedMot}' existe déjà dans ce dictionnaire."); + } + + var term = new TermeMetier + { + DictionnaireMetierId = dictionnaireId, + DictionnaireMetier = dictionary, + Mot = normalizedMot, + Definition = definition.Trim(), + Langue = string.IsNullOrWhiteSpace(langue) ? "fr" : langue, + StatutValidation = StatutValidationTerme.Propose, + ProposeParId = userId, + ValideParId = null, + DateSoumission = DateTime.UtcNow, + DateValidation = null + }; + + _context.TermeMetier.Add(term); + await _context.SaveChangesAsync(); + return term; + } + + public async Task ValiderTermAsync(long termeId, string moderatorId) + { + var term = await _context.TermeMetier + .SingleOrDefaultAsync(t => t.Id == termeId); + + if (term is null) + { + throw new KeyNotFoundException($"Terme {termeId} introuvable."); + } + + term.StatutValidation = StatutValidationTerme.Valide; + term.ValideParId = moderatorId; + term.DateValidation = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + return term; + } + + public async Task RejeterTermAsync(long termeId, string moderatorId) + { + var term = await _context.TermeMetier + .SingleOrDefaultAsync(t => t.Id == termeId); + + if (term is null) + { + throw new KeyNotFoundException($"Terme {termeId} introuvable."); + } + + term.StatutValidation = StatutValidationTerme.Rejete; + term.ValideParId = moderatorId; + term.DateValidation = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + return term; + } + + public async Task> GetPendingAsync(long dictionnaireId) + { + return await _context.TermeMetier + .Where(t => t.DictionnaireMetierId == dictionnaireId + && t.StatutValidation == StatutValidationTerme.Propose) + .OrderBy(t => t.DateSoumission) + .ToListAsync(); + } + } +}