From d621f7e6fc1ac0c67b5dabef7cff7b27c2bea1fd Mon Sep 17 00:00:00 2001 From: Samuele Locatelli Date: Tue, 3 Sep 2024 17:18:59 +0200 Subject: [PATCH 01/20] AuthUtente x LAND: - aggiunta DbModels - aggiunta controillers - aggiunta servizio --- MP.AppAuth/AppAuthContext.cs | 68 ++++++++++ MP.AppAuth/Controllers/AppAuthController.cs | 133 +++++++++++++++----- MP.AppAuth/Controllers/MPUserController.cs | 59 +++++++++ MP.AppAuth/Models/Permessi2FunzioneModel.cs | 22 ++++ MP.AppAuth/Models/PermessiModel.cs | 30 +++++ MP.AppAuth/Models/UserDirittiModel.cs | 29 +++++ MP.AppAuth/UserAuthContext.cs | 104 +++++++++++++++ 7 files changed, 415 insertions(+), 30 deletions(-) create mode 100644 MP.AppAuth/Controllers/MPUserController.cs create mode 100644 MP.AppAuth/Models/Permessi2FunzioneModel.cs create mode 100644 MP.AppAuth/Models/PermessiModel.cs create mode 100644 MP.AppAuth/Models/UserDirittiModel.cs create mode 100644 MP.AppAuth/UserAuthContext.cs diff --git a/MP.AppAuth/AppAuthContext.cs b/MP.AppAuth/AppAuthContext.cs index 4ea914e2..c8c74357 100644 --- a/MP.AppAuth/AppAuthContext.cs +++ b/MP.AppAuth/AppAuthContext.cs @@ -59,6 +59,8 @@ namespace MP.AppAuth public virtual DbSet DbSetGruppi2Oper { get; set; } public virtual DbSet DbSetUpdMan { get; set; } public virtual DbSet DbSetVocabolario { get; set; } + public virtual DbSet DbSetPermessi { get; set; } = null!; + public virtual DbSet DbSetPermessi2Funzione { get; set; } = null!; #endregion Public Properties @@ -134,6 +136,72 @@ namespace MP.AppAuth entity.Property(e => e.CodGruppo).HasMaxLength(50); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.CodPermesso); + + entity.ToTable("Permessi"); + + entity.Property(e => e.CodPermesso) + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnName("COD_PERMESSO") + .UseCollation("Latin1_General_CI_AS"); + + entity.Property(e => e.Descrizione) + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnName("DESCRIZIONE") + .UseCollation("Latin1_General_CI_AS"); + + entity.Property(e => e.Gruppo).HasColumnName("GRUPPO"); + + entity.Property(e => e.Nome) + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnName("NOME") + .UseCollation("Latin1_General_CI_AS"); + + entity.Property(e => e.Numero).HasColumnName("NUMERO"); + + entity.Property(e => e.Url) + .HasMaxLength(250) + .IsUnicode(false) + .HasColumnName("URL") + .UseCollation("Latin1_General_CI_AS"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.CodPermesso, e.CodFunzione }); + + entity.ToTable("Permessi2Funzione"); + + entity.Property(e => e.CodPermesso) + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnName("COD_PERMESSO") + .UseCollation("Latin1_General_CI_AS"); + + entity.Property(e => e.CodFunzione) + .HasMaxLength(31) + .HasColumnName("COD_FUNZIONE") + .UseCollation("Latin1_General_CI_AS"); + + entity.Property(e => e.Readwrite) + .HasMaxLength(1) + .IsUnicode(false) + .HasColumnName("READWRITE") + .IsFixedLength() + .UseCollation("Latin1_General_CI_AS"); + + entity.HasOne(d => d.PermessiNav) + .WithMany(p => p.Permessi2FunzioneNav) + .HasForeignKey(d => d.CodPermesso) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_Permessi2Funzione_Permessi"); + }); + // modelBuilder.Seed(); diff --git a/MP.AppAuth/Controllers/AppAuthController.cs b/MP.AppAuth/Controllers/AppAuthController.cs index d0fce9ae..24757db7 100644 --- a/MP.AppAuth/Controllers/AppAuthController.cs +++ b/MP.AppAuth/Controllers/AppAuthController.cs @@ -11,14 +11,6 @@ namespace MP.AppAuth.Controllers { public class AppAuthController : IDisposable { - #region Private Fields - - private static IConfiguration _configuration; - private static AppAuthContext dbCtx; - private static Logger Log = LogManager.GetCurrentClassLogger(); - - #endregion Private Fields - #region Public Constructors public AppAuthController(IConfiguration configuration) @@ -48,6 +40,7 @@ namespace MP.AppAuth.Controllers } return dbResult; } + /// /// Elenco Record x Gruppi /// @@ -64,28 +57,6 @@ namespace MP.AppAuth.Controllers return dbResult; } - public List AnagOpGetAll(string searchVal) - { - List dbResult = new List(); - using (AppAuthContext localDbCtx = new AppAuthContext(_configuration)) - { - if (!string.IsNullOrEmpty(searchVal)) - { - dbResult = localDbCtx - .DbSetAnagOpr - .Where(x => x.Cognome.Contains(searchVal) || x.Nome.Contains(searchVal)) - .ToList(); - } - else - { - dbResult = localDbCtx - .DbSetAnagOpr - .ToList(); - } - } - // ritorno - return dbResult; - } public List AnagOpByGruppoGetFilt(string codGruppo, string searchVal) { List dbResult = new List(); @@ -122,12 +93,106 @@ namespace MP.AppAuth.Controllers return dbResult; } + public List AnagOpGetAll(string searchVal) + { + List dbResult = new List(); + using (AppAuthContext localDbCtx = new AppAuthContext(_configuration)) + { + if (!string.IsNullOrEmpty(searchVal)) + { + dbResult = localDbCtx + .DbSetAnagOpr + .Where(x => x.Cognome.Contains(searchVal) || x.Nome.Contains(searchVal)) + .ToList(); + } + else + { + dbResult = localDbCtx + .DbSetAnagOpr + .ToList(); + } + } + // ritorno + return dbResult; + } + public void Dispose() { // Clear database context dbCtx.Dispose(); } + /// + /// Elenco completo permessi2funzione + /// + /// + public List Permessi2FunzioneGetAll() + { + List dbResult = new List(); + using (AppAuthContext dbCtx = new AppAuthContext(_configuration)) + { + dbResult = dbCtx + .DbSetPermessi2Funzione + .ToList(); + } + return dbResult; + } + + /// + /// Elenco permessi2funzione filtrato x elenco funzioni + /// + /// + public List Permessi2FunzioneGetFilt(List FunList) + { + List dbResult = new List(); + using (AppAuthContext dbCtx = new AppAuthContext(_configuration)) + { + dbResult = dbCtx + .DbSetPermessi2Funzione + .Where(x => FunList.Contains(x.CodFunzione)) + .ToList(); + } + return dbResult; + } + + /// + /// Elenco completo permessi + /// + /// + public List PermessiGetAll() + { + List dbResult = new List(); + using (AppAuthContext dbCtx = new AppAuthContext(_configuration)) + { + dbResult = dbCtx + .DbSetPermessi + .ToList(); + } + return dbResult; + } + + /// + /// Elenco permessi dato elenco funzioni + /// + /// + /// + public List PermessiGetByFunc(List ListCodFun) + { + List dbResult = new List(); + using (AppAuthContext dbCtx = new AppAuthContext(_configuration)) + { + var listPer = PermessiGetAll(); + var listP2F = Permessi2FunzioneGetFilt(ListCodFun); + + var query = from permesso in listPer + join p2f in listP2F on permesso.CodPermesso equals p2f.CodPermesso + select permesso; + + dbResult = query.ToList(); + } + return dbResult; + } + public void ResetController() { dbCtx = new AppAuthContext(_configuration); @@ -190,5 +255,13 @@ namespace MP.AppAuth.Controllers } #endregion Public Methods + + #region Private Fields + + private static IConfiguration _configuration; + private static AppAuthContext dbCtx; + private static Logger Log = LogManager.GetCurrentClassLogger(); + + #endregion Private Fields } } \ No newline at end of file diff --git a/MP.AppAuth/Controllers/MPUserController.cs b/MP.AppAuth/Controllers/MPUserController.cs new file mode 100644 index 00000000..ab42c488 --- /dev/null +++ b/MP.AppAuth/Controllers/MPUserController.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Configuration; +using MP.AppAuth.Models; +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MP.AppAuth.Controllers +{ + public class MPUserController : IDisposable + { + #region Public Constructors + + public MPUserController(IConfiguration configuration) + { + _configuration = configuration; + Log.Info("Avviata classe MPUserController"); + } + + #endregion Public Constructors + + #region Public Methods + + /// + /// Elenco Diritti utente da modulo + /// + /// UserName cercato + /// Modulo desiderato, se "" allora tutti i diritti + /// + public List DirittiUtente(string UserName, string Modulo) + { + List dbResult = new List(); + using (UserAuthContext dbCtx = new UserAuthContext(_configuration)) + { + dbResult = dbCtx + .DbSetUserDiritti + .Where(x => x.UserName == UserName && (string.IsNullOrEmpty(Modulo) || x.Modulo == Modulo)) + .ToList(); + } + return dbResult; + } + + public void Dispose() + { + GC.Collect(); + } + + #endregion Public Methods + + #region Private Fields + + private static IConfiguration _configuration = null!; + private static Logger Log = LogManager.GetCurrentClassLogger(); + + #endregion Private Fields + } +} \ No newline at end of file diff --git a/MP.AppAuth/Models/Permessi2FunzioneModel.cs b/MP.AppAuth/Models/Permessi2FunzioneModel.cs new file mode 100644 index 00000000..e031e923 --- /dev/null +++ b/MP.AppAuth/Models/Permessi2FunzioneModel.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MP.AppAuth.Models +{ + // + // This is here so CodeMaid doesn't reorganize this document + // + [Table("Permessi2Funzione")] + public partial class Permessi2FunzioneModel + { + public string CodPermesso { get; set; } = null!; + public string CodFunzione { get; set; } = null!; + public string? Readwrite { get; set; } + + public virtual PermessiModel PermessiNav { get; set; } = null!; + } +} diff --git a/MP.AppAuth/Models/PermessiModel.cs b/MP.AppAuth/Models/PermessiModel.cs new file mode 100644 index 00000000..007aee5c --- /dev/null +++ b/MP.AppAuth/Models/PermessiModel.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MP.AppAuth.Models +{ + // + // This is here so CodeMaid doesn't reorganize this document + // + [Table("Permessi")] + public partial class PermessiModel + { + public PermessiModel() + { + Permessi2FunzioneNav = new HashSet(); + } + + public string CodPermesso { get; set; } = null!; + public string Url { get; set; } = null!; + public int? Gruppo { get; set; } + public int? Numero { get; set; } + public string? Nome { get; set; } + public string? Descrizione { get; set; } + + public virtual ICollection Permessi2FunzioneNav { get; set; } + } +} diff --git a/MP.AppAuth/Models/UserDirittiModel.cs b/MP.AppAuth/Models/UserDirittiModel.cs new file mode 100644 index 00000000..f097342e --- /dev/null +++ b/MP.AppAuth/Models/UserDirittiModel.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MP.AppAuth.Models +{ + + // + // This is here so CodeMaid doesn't reorganize this document + // + [Table("DIRITTI")] + public partial class UserDirittiModel + { + [Column("USER_NAME"), MaxLength(50)] + public string UserName { get; set; } = ""; + [Column("COD_CDC"), MaxLength(50)] + public string CdC { get; set; } = ""; + [Column("COD_MODULO"), MaxLength(31)] + public string Modulo { get; set; } = ""; + [Column("COD_FUNZIONE"), MaxLength(31)] + public string Funzione { get; set; } = ""; + [Column("VALUE"), MaxLength(255)] + public string? Valore { get; set; } = ""; + } +} diff --git a/MP.AppAuth/UserAuthContext.cs b/MP.AppAuth/UserAuthContext.cs new file mode 100644 index 00000000..8a0823aa --- /dev/null +++ b/MP.AppAuth/UserAuthContext.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using MP.AppAuth.Models; +using NLog; +using NLog.Fluent; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MP.AppAuth +{ + public partial class UserAuthContext : DbContext + { + #region Public Constructors + + [Obsolete("This constructor should never be used directly, and is only needed to generate entityframework stuff. Connection string can be adapted as pleased.")] + public UserAuthContext() + { + } + + public UserAuthContext(IConfiguration configuration) + { + _configuration = configuration; + try + { + // se non ci fosse... crea o migra! + Database.Migrate(); + } + catch (Exception exc) + { + Log.Error(exc, "Exception during context initialization 01"); + } + } + + public UserAuthContext(DbContextOptions options) : base(options) + { + try + { + // se non ci fosse... crea o migra! + Database.Migrate(); + } + catch (Exception exc) + { + Log.Error(exc, "Exception during context initialization 02"); + } + } + + #endregion Public Constructors + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + string connString = _configuration.GetConnectionString("MP.Land.Auth"); + if (!string.IsNullOrEmpty(connString)) + { + optionsBuilder.UseSqlServer(connString); + } + else + { + optionsBuilder.UseSqlServer("Server=SQL2016DEV;Database=MoonPro_Anagrafica;Trusted_Connection=True;"); + } + } + } + + #region Public Properties + + public virtual DbSet DbSetUserDiritti { get; set; } = null!; + + #endregion Public Properties + + #region Protected Methods + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.UserName, e.CdC, e.Modulo, e.Funzione }); + + entity.ToTable("DIRITTI"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + #endregion Protected Methods + + #region Private Fields + + private static NLog.Logger Log = LogManager.GetCurrentClassLogger(); + + private IConfiguration _configuration; + + #endregion Private Fields + + #region Private Methods + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); + + #endregion Private Methods + } +} \ No newline at end of file From a6411a4fc561397f57637f3a91a9f8addbae727f Mon Sep 17 00:00:00 2001 From: Samuele Locatelli Date: Tue, 3 Sep 2024 17:21:09 +0200 Subject: [PATCH 02/20] Update x gestione pagina config Sync: - nav laterale controllata da permessi utente - fix conf appsetting.json (dev e prod) - fix servizi redis in startup --- MP.Land/Data/AppAuthService.cs | 178 +++++++++++++++++++++++++++- MP.Land/MP.Land.csproj | 2 +- MP.Land/Resources/ChangeLog.html | 2 +- MP.Land/Resources/VersNum.txt | 2 +- MP.Land/Resources/manifest.xml | 2 +- MP.Land/Shared/NavMenu.razor | 100 ++++++++-------- MP.Land/Shared/NavMenu.razor.cs | 150 +++++++++++++++++++++++ MP.Land/Startup.cs | 10 ++ MP.Land/appsettings.Production.json | 1 + MP.Land/appsettings.json | 6 + 10 files changed, 393 insertions(+), 60 deletions(-) create mode 100644 MP.Land/Shared/NavMenu.razor.cs diff --git a/MP.Land/Data/AppAuthService.cs b/MP.Land/Data/AppAuthService.cs index a4c803b7..665a7977 100644 --- a/MP.Land/Data/AppAuthService.cs +++ b/MP.Land/Data/AppAuthService.cs @@ -12,6 +12,8 @@ using System.Text; using Newtonsoft.Json; using System.Diagnostics.Eventing.Reader; using MP.AppAuth.Models; +using StackExchange.Redis; +using MP.AppAuth.Controllers; namespace MP.Land.Data { @@ -19,17 +21,36 @@ namespace MP.Land.Data { #region Public Fields + // diritti (cablòato) + public const string RoleSuperAdmin = "MoonPro_SuperAdmin"; + public static AppAuth.Controllers.AppAuthController dbController; public static AppAuth.Controllers.MPController MpDbController; + public static AppAuth.Controllers.MPUserController userController; #endregion Public Fields #region Public Constructors - public AppAuthService(IConfiguration configuration, ILogger logger, IMemoryCache memoryCache, IDistributedCache distributedCache) + public AppAuthService(IConfiguration configuration, ILogger logger, IConnectionMultiplexer redisConnMult, IMemoryCache memoryCache, IDistributedCache distributedCache) { _logger = logger; _configuration = configuration; + + // cod app + CodApp = _configuration.GetValue("ServerConf:CodApp"); + Modulo = _configuration.GetValue("ServerConf:Modulo"); + + // Conf cache + redisConn = redisConnMult; + redisDb = this.redisConn.GetDatabase(); + + // json serializer... FIX errore loop circolare https://www.ryadel.com/en/jsonserializationexception-self-referencing-loop-detected-error-fix-entity-framework-asp-net-core/ + JSSettings = new JsonSerializerSettings() + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }; + // conf cache this.memoryCache = memoryCache; this.distributedCache = distributedCache; @@ -41,8 +62,9 @@ namespace MP.Land.Data } else { - dbController = new AppAuth.Controllers.AppAuthController(configuration); - MpDbController = new AppAuth.Controllers.MPController(configuration); + dbController = new AppAuthController(configuration); + MpDbController = new MPController(configuration); + userController = new MPUserController(configuration); _logger.LogInformation("DbController OK"); } } @@ -200,6 +222,48 @@ namespace MP.Land.Data return await Task.FromResult(answ); } + /// + /// Elenco diritti dato utente + /// + /// + /// + public async Task> DirittiGetByUser(string UserName) + { + string source = "DB"; + List? dbResult = new List(); + string currKey = $"{rKeyDirittiUser}:{UserName}"; + Stopwatch sw = new Stopwatch(); + sw.Start(); + string? rawData = await redisDb.StringGetAsync(currKey); + if (!string.IsNullOrEmpty(rawData) && rawData.Length > 2) + { + source = "REDIS"; + var tempResult = JsonConvert.DeserializeObject>(rawData); + if (tempResult == null) + { + dbResult = new List(); + } + else + { + dbResult = tempResult; + } + } + else + { + // recupero diritti utente + dbResult = userController.DirittiUtente(UserName, Modulo); + rawData = JsonConvert.SerializeObject(dbResult, JSSettings); + await redisDb.StringSetAsync(currKey, rawData, UltraLongCache); + } + if (dbResult == null) + { + dbResult = new List(); + } + sw.Stop(); + Log.Debug($"DirittiGetByUser | {source} | {sw.ElapsedMilliseconds} ms"); + return dbResult; + } + public void Dispose() { // Clear database controller @@ -207,6 +271,56 @@ namespace MP.Land.Data MpDbController.Dispose(); } + /// + /// Elenco permessi dato utente + /// + /// + /// + public async Task> PermessiGetByUser(string UserName) + { + string source = "DB"; + List? dbResult = new List(); + string currKey = $"{rKeyPermUser}:{UserName}"; + Stopwatch sw = new Stopwatch(); + sw.Start(); + string? rawData = await redisDb.StringGetAsync(currKey); + if (!string.IsNullOrEmpty(rawData)) + { + source = "REDIS"; + var tempResult = JsonConvert.DeserializeObject>(rawData); + if (tempResult == null) + { + dbResult = new List(); + } + else + { + dbResult = tempResult; + } + } + else + { + // recupero diritti utente + var userRightList = userController.DirittiUtente(UserName, Modulo); + // proietto come funzioni... + var ListFunc = userRightList.Select(x => x.Funzione).ToList(); + // trasformo i permessi utente + if (ListFunc == null) + { + ListFunc = new List(); + } + dbResult = dbController.PermessiGetByFunc(ListFunc); + rawData = JsonConvert.SerializeObject(dbResult, JSSettings); + await redisDb.StringSetAsync(currKey, rawData, UltraLongCache); + } + if (dbResult == null) + { + dbResult = new List(); + } + sw.Stop(); + Log.Debug($"PermessiGetByUser | {source} | {sw.ElapsedMilliseconds} ms"); + return dbResult; + } + public async Task ResetCache() { string cacheKey = ":MP:VOCAB"; @@ -324,18 +438,32 @@ namespace MP.Land.Data #region Private Fields + // gestione key Redis + private const string redisBaseAddr = "MP:LAND"; + + private const string rKeyDirittiUser = $"{redisBaseAddr}:DIR_USER"; + private const string rKeyPermUser = $"{redisBaseAddr}:PERM_USER"; private static IConfiguration _configuration; - private static ILogger _logger; - private static List ElencoMacchine = new List(); - + private static JsonSerializerSettings? JSSettings; private static Logger Log = LogManager.GetCurrentClassLogger(); + private static string Modulo = ""; private readonly IDistributedCache distributedCache; private readonly IMemoryCache memoryCache; + /// + /// Durata cache lunga IN SECONDI + /// + private int cacheTtlLong = 60 * 5; + + /// + /// Durata cache breve IN SECONDI + /// + private int cacheTtlShort = 60 * 1; + /// /// Durata assoluta massima della cache /// @@ -347,6 +475,18 @@ namespace MP.Land.Data /// private int chSliExp = 5; + /// + /// Oggetto per connessione a REDIS + /// + private IConnectionMultiplexer redisConn; + + //ISubscriber sub = redis.GetSubscriber(); + /// + /// Oggetto DB redis da impiegare x chiamate R/W + /// + private IDatabase redisDb = null!; + + private Random rnd = new Random(); private Dictionary Vocabolario = new Dictionary(); #endregion Private Fields @@ -369,6 +509,32 @@ namespace MP.Land.Data } } + private string CodApp { get; set; } = ""; + + /// + /// Durata cache lunga (+ perturbazione percentuale +/-10%) + /// + private TimeSpan FastCache + { + get => TimeSpan.FromSeconds(cacheTtlShort * rnd.Next(900, 1100) / 1000); + } + + /// + /// Durata cache lunga (+ perturbazione percentuale +/-10%) + /// + private TimeSpan LongCache + { + get => TimeSpan.FromSeconds(cacheTtlLong * rnd.Next(900, 1100) / 1000); + } + + /// + /// Durata cache lunga (+ perturbazione percentuale +/-10%) + /// + private TimeSpan UltraLongCache + { + get => TimeSpan.FromSeconds(cacheTtlLong * 10 * rnd.Next(900, 1100) / 1000); + } + #endregion Private Properties } } \ No newline at end of file diff --git a/MP.Land/MP.Land.csproj b/MP.Land/MP.Land.csproj index 2c47ece8..1d5ae9dc 100644 --- a/MP.Land/MP.Land.csproj +++ b/MP.Land/MP.Land.csproj @@ -3,7 +3,7 @@ net6.0 MP.Land - 6.16.2407.3117 + 6.16.2409.0317 diff --git a/MP.Land/Resources/ChangeLog.html b/MP.Land/Resources/ChangeLog.html index 68770304..9f01dd6e 100644 --- a/MP.Land/Resources/ChangeLog.html +++ b/MP.Land/Resources/ChangeLog.html @@ -1,6 +1,6 @@ Modulo Tablet MAPO - DotNet6 -

Versione: 6.16.2407.3117

+

Versione: 6.16.2409.0317


Note di rilascio: