Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity
- WP-23: ITemplateEngineRepository full EF Core implementation - WP-1: Template CRUD with deletion constraints (instances, children, compositions) - WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity - WP-5: Shared script CRUD with syntax validation - WP-6–7: Composition with recursive nesting and canonical naming - WP-8–11: Override granularity, locking rules, inheritance/composition scope - WP-12: Naming collision detection on canonical names (recursive) - WP-13: Graph acyclicity (inheritance + composition cycles) Core services: TemplateService, SharedScriptService, TemplateResolver, LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
This commit is contained in:
176
src/ScadaLink.TemplateEngine/Services/AreaService.cs
Normal file
176
src/ScadaLink.TemplateEngine/Services/AreaService.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Hierarchical area management per site.
|
||||
/// - CRUD for areas with parent-child relationships
|
||||
/// - Deletion constrained if instances are assigned
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class AreaService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public AreaService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new area within a site.
|
||||
/// </summary>
|
||||
public async Task<Result<Area>> CreateAreaAsync(
|
||||
string name, int siteId, int? parentAreaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
// Validate parent area if specified
|
||||
if (parentAreaId.HasValue)
|
||||
{
|
||||
var parent = await _repository.GetAreaByIdAsync(parentAreaId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} not found.");
|
||||
if (parent.SiteId != siteId)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.ParentAreaId == parentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level in site {siteId}.");
|
||||
|
||||
var area = new Area(name)
|
||||
{
|
||||
SiteId = siteId,
|
||||
ParentAreaId = parentAreaId
|
||||
};
|
||||
|
||||
await _repository.AddAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an area's name.
|
||||
/// </summary>
|
||||
public async Task<Result<Area>> UpdateAreaAsync(
|
||||
int areaId, string name, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.Id != areaId &&
|
||||
a.ParentAreaId == area.ParentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level.");
|
||||
|
||||
area.Name = name;
|
||||
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteAreaAsync(
|
||||
int areaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<bool>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for instances assigned to this area
|
||||
var allInstances = await _repository.GetInstancesBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var allAreas = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
|
||||
// Collect this area and all descendant area IDs
|
||||
var descendantIds = GetDescendantAreaIds(areaId, allAreas);
|
||||
descendantIds.Add(areaId);
|
||||
|
||||
var assignedInstances = allInstances
|
||||
.Where(i => i.AreaId.HasValue && descendantIds.Contains(i.AreaId.Value))
|
||||
.ToList();
|
||||
|
||||
if (assignedInstances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", assignedInstances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': {assignedInstances.Count} instance(s) are assigned to it or its sub-areas ({names}{(assignedInstances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check for child areas (must delete children first, or we delete recursively)
|
||||
var childAreas = allAreas.Where(a => a.ParentAreaId == areaId).ToList();
|
||||
if (childAreas.Count > 0)
|
||||
{
|
||||
var childNames = string.Join(", ", childAreas.Select(a => a.Name));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': it has child areas ({childNames}). Delete child areas first.");
|
||||
}
|
||||
|
||||
await _repository.DeleteAreaAsync(areaId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Area", areaId.ToString(), area.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all areas for a site.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single area by ID.
|
||||
/// </summary>
|
||||
public async Task<Area?> GetAreaByIdAsync(int areaId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
|
||||
private static HashSet<int> GetDescendantAreaIds(int parentId, IReadOnlyList<Area> allAreas)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(parentId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var child in allAreas.Where(a => a.ParentAreaId == current))
|
||||
{
|
||||
if (result.Add(child.Id))
|
||||
queue.Enqueue(child.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
285
src/ScadaLink.TemplateEngine/Services/InstanceService.cs
Normal file
285
src/ScadaLink.TemplateEngine/Services/InstanceService.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Instance CRUD operations.
|
||||
/// - Create instance from template at site
|
||||
/// - Assign to area
|
||||
/// - Override non-locked attribute values
|
||||
/// - Cannot add or remove attributes (only override existing ones)
|
||||
/// - Per-attribute connection binding (bulk assignment support)
|
||||
/// - Enabled/disabled state with optimistic concurrency
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public InstanceService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance from a template at a site.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> CreateInstanceAsync(
|
||||
string uniqueName,
|
||||
int templateId,
|
||||
int siteId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uniqueName))
|
||||
return Result<Instance>.Failure("Instance unique name is required.");
|
||||
|
||||
// Verify template exists
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<Instance>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Check for duplicate unique name
|
||||
var existing = await _repository.GetInstanceByUniqueNameAsync(uniqueName, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Instance>.Failure($"Instance with unique name '{uniqueName}' already exists.");
|
||||
|
||||
// Verify area exists if specified
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != siteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
var instance = new Instance(uniqueName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
SiteId = siteId,
|
||||
AreaId = areaId,
|
||||
State = InstanceState.Disabled // New instances start disabled
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Instance", instance.Id.ToString(),
|
||||
uniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns an instance to an area.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> AssignToAreaAsync(
|
||||
int instanceId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != instance.SiteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to the instance's site.");
|
||||
}
|
||||
|
||||
instance.AreaId = areaId;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "AssignArea", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute override for an instance. Only non-locked attributes can be overridden.
|
||||
/// Cannot add or remove attributes — only override values of existing template attributes.
|
||||
/// </summary>
|
||||
public async Task<Result<InstanceAttributeOverride>> SetAttributeOverrideAsync(
|
||||
int instanceId,
|
||||
string attributeName,
|
||||
string? overrideValue,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAttributeOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify attribute exists in the template and is not locked
|
||||
var templateAttrs = await _repository.GetAttributesByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
||||
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
|
||||
if (templateAttr == null)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides.");
|
||||
|
||||
if (templateAttr.IsLocked)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' is locked and cannot be overridden.");
|
||||
|
||||
// Find existing override or create new one
|
||||
var overrides = await _repository.GetOverridesByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingOverride = overrides.FirstOrDefault(o => o.AttributeName == attributeName);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.OverrideValue = overrideValue;
|
||||
await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateOverride", "InstanceAttributeOverride",
|
||||
existingOverride.Id.ToString(), attributeName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAttributeOverride(attributeName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
OverrideValue = overrideValue
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateOverride", "InstanceAttributeOverride",
|
||||
newOverride.Id.ToString(), attributeName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets connection bindings for an instance in bulk.
|
||||
/// </summary>
|
||||
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
|
||||
int instanceId,
|
||||
IReadOnlyList<(string AttributeName, int DataConnectionId)> bindings,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
var existingBindings = await _repository.GetBindingsByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingMap = existingBindings.ToDictionary(b => b.AttributeName, StringComparer.Ordinal);
|
||||
|
||||
var results = new List<InstanceConnectionBinding>();
|
||||
|
||||
foreach (var (attrName, connId) in bindings)
|
||||
{
|
||||
if (existingMap.TryGetValue(attrName, out var existing))
|
||||
{
|
||||
existing.DataConnectionId = connId;
|
||||
await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken);
|
||||
results.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var binding = new InstanceConnectionBinding(attrName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
DataConnectionId = connId
|
||||
};
|
||||
await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken);
|
||||
results.Add(binding);
|
||||
}
|
||||
}
|
||||
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "SetConnectionBindings", "Instance",
|
||||
instance.Id.ToString(), instance.UniqueName, bindings, cancellationToken);
|
||||
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Success(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables an instance.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> EnableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Enabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Enable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables an instance.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> DisableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Disabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Disable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an instance.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<bool>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an instance by ID.
|
||||
/// </summary>
|
||||
public async Task<Instance?> GetByIdAsync(int instanceId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all instances for a site.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Instance>> GetBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
}
|
||||
206
src/ScadaLink.TemplateEngine/Services/SiteService.cs
Normal file
206
src/ScadaLink.TemplateEngine/Services/SiteService.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Site and data connection management.
|
||||
/// - Site CRUD (name, identifier, description)
|
||||
/// - Data connection CRUD (name, protocol, config)
|
||||
/// - Assign connections to sites
|
||||
/// - Connection names not standardized across sites
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class SiteService
|
||||
{
|
||||
private readonly ISiteRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public SiteService(ISiteRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
// --- Site CRUD ---
|
||||
|
||||
public async Task<Result<Site>> CreateSiteAsync(
|
||||
string name, string siteIdentifier, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Site>.Failure("Site name is required.");
|
||||
if (string.IsNullOrWhiteSpace(siteIdentifier))
|
||||
return Result<Site>.Failure("Site identifier is required.");
|
||||
|
||||
var existing = await _repository.GetSiteByIdentifierAsync(siteIdentifier, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Site>.Failure($"Site with identifier '{siteIdentifier}' already exists.");
|
||||
|
||||
var site = new Site(name, siteIdentifier) { Description = description };
|
||||
await _repository.AddSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
public async Task<Result<Site>> UpdateSiteAsync(
|
||||
int siteId, string name, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<Site>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
site.Name = name;
|
||||
site.Description = description;
|
||||
await _repository.UpdateSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteSiteAsync(int siteId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<bool>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
// Check for instances assigned to this site
|
||||
var instances = await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete site '{site.Name}': {instances.Count} instance(s) are assigned to it ({names}{(instances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
await _repository.DeleteSiteAsync(siteId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Site", siteId.ToString(), site.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
public async Task<Site?> GetSiteByIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAllSitesAsync(cancellationToken);
|
||||
|
||||
// --- Data Connection CRUD ---
|
||||
|
||||
public async Task<Result<DataConnection>> CreateDataConnectionAsync(
|
||||
string name, string protocol, string? configuration, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<DataConnection>.Failure("Data connection name is required.");
|
||||
if (string.IsNullOrWhiteSpace(protocol))
|
||||
return Result<DataConnection>.Failure("Protocol is required.");
|
||||
|
||||
var connection = new DataConnection(name, protocol) { Configuration = configuration };
|
||||
await _repository.AddDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
public async Task<Result<DataConnection>> UpdateDataConnectionAsync(
|
||||
int connectionId, string name, string protocol, string? configuration, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<DataConnection>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
connection.Name = name;
|
||||
connection.Protocol = protocol;
|
||||
connection.Configuration = configuration;
|
||||
await _repository.UpdateDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteDataConnectionAsync(
|
||||
int connectionId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<bool>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
await _repository.DeleteDataConnectionAsync(connectionId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "DataConnection",
|
||||
connectionId.ToString(), connection.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// --- Site-Connection Assignment ---
|
||||
|
||||
public async Task<Result<SiteDataConnectionAssignment>> AssignConnectionToSiteAsync(
|
||||
int siteId, int dataConnectionId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<SiteDataConnectionAssignment>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(dataConnectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<SiteDataConnectionAssignment>.Failure($"Data connection with ID {dataConnectionId} not found.");
|
||||
|
||||
// Check if assignment already exists
|
||||
var existing = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<SiteDataConnectionAssignment>.Failure(
|
||||
$"Data connection '{connection.Name}' is already assigned to site '{site.Name}'.");
|
||||
|
||||
var assignment = new SiteDataConnectionAssignment
|
||||
{
|
||||
SiteId = siteId,
|
||||
DataConnectionId = dataConnectionId
|
||||
};
|
||||
|
||||
await _repository.AddSiteDataConnectionAssignmentAsync(assignment, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "AssignConnection", "SiteDataConnectionAssignment",
|
||||
assignment.Id.ToString(), $"{site.Name}/{connection.Name}", assignment, cancellationToken);
|
||||
|
||||
return Result<SiteDataConnectionAssignment>.Success(assignment);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> RemoveConnectionFromSiteAsync(
|
||||
int siteId, int dataConnectionId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assignment = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken);
|
||||
if (assignment == null)
|
||||
return Result<bool>.Failure("Assignment not found.");
|
||||
|
||||
await _repository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "RemoveConnection", "SiteDataConnectionAssignment",
|
||||
assignment.Id.ToString(), $"Site:{siteId}/Conn:{dataConnectionId}", null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces template deletion constraints (WP-25).
|
||||
/// Template deletion is blocked when:
|
||||
/// - Instances reference the template
|
||||
/// - Child templates reference it (as parent)
|
||||
/// - Other templates compose it
|
||||
/// Returns clear error messages listing the referencing entities.
|
||||
/// </summary>
|
||||
public class TemplateDeletionService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
|
||||
public TemplateDeletionService(ITemplateEngineRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a template can be safely deleted and returns any blocking reasons.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> CanDeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check 1: Instances reference this template
|
||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 2: Child templates reference it as parent
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var childTemplates = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
if (childTemplates.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", childTemplates.Select(t => t.Name).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {childTemplates.Count} child template(s) inherit from it ({names}{(childTemplates.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 3: Other templates compose it
|
||||
var composingTemplates = new List<(string TemplateName, string InstanceName)>();
|
||||
foreach (var t in allTemplates)
|
||||
{
|
||||
var compositions = await _repository.GetCompositionsByTemplateIdAsync(t.Id, cancellationToken);
|
||||
foreach (var comp in compositions)
|
||||
{
|
||||
if (comp.ComposedTemplateId == templateId)
|
||||
composingTemplates.Add((t.Name, comp.InstanceName));
|
||||
}
|
||||
}
|
||||
|
||||
if (composingTemplates.Count > 0)
|
||||
{
|
||||
var details = string.Join(", ",
|
||||
composingTemplates.Take(10).Select(c => $"'{c.TemplateName}' (as '{c.InstanceName}')"));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {composingTemplates.Count} template(s) compose it ({details}{(composingTemplates.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
return Result<bool>.Failure(string.Join(" ", errors));
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a template after checking all constraints.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var canDelete = await CanDeleteTemplateAsync(templateId, cancellationToken);
|
||||
if (canDelete.IsFailure)
|
||||
return canDelete;
|
||||
|
||||
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user