1044 lines
48 KiB
C#
1044 lines
48 KiB
C#
using ScadaLink.Commons.Entities.Templates;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Types;
|
|
|
|
namespace ScadaLink.TemplateEngine;
|
|
|
|
/// <summary>
|
|
/// Core service for Template Engine operations.
|
|
/// Covers CRUD for templates and their members (attributes, alarms, scripts, compositions),
|
|
/// inheritance and composition rules, override/locking validation, collision detection, and acyclicity enforcement.
|
|
/// </summary>
|
|
public class TemplateService
|
|
{
|
|
private readonly ITemplateEngineRepository _repository;
|
|
private readonly IAuditService _auditService;
|
|
|
|
public TemplateService(ITemplateEngineRepository repository, IAuditService auditService)
|
|
{
|
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
|
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-1: Template CRUD with Inheritance
|
|
// ========================================================================
|
|
|
|
public async Task<Result<Template>> CreateTemplateAsync(
|
|
string name,
|
|
string? description,
|
|
int? parentTemplateId,
|
|
string user,
|
|
int? folderId = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return Result<Template>.Failure("Template name is required.");
|
|
|
|
// Validate parent exists if specified
|
|
if (parentTemplateId.HasValue)
|
|
{
|
|
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
|
|
if (parent == null)
|
|
return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
|
|
}
|
|
|
|
var template = new Template(name)
|
|
{
|
|
Description = description,
|
|
ParentTemplateId = parentTemplateId,
|
|
FolderId = folderId
|
|
};
|
|
|
|
// No collision or acyclicity check is needed here: a freshly created
|
|
// template has no members of its own, the parent (validated above to
|
|
// exist) was already collision-checked when its members were added,
|
|
// and a brand-new child cannot be an ancestor of its parent. Naming
|
|
// collisions are enforced on every member-mutating call (AddAttribute,
|
|
// AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.
|
|
|
|
await _repository.AddTemplateAsync(template, cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<Template>.Success(template);
|
|
}
|
|
|
|
public async Task<Result<Template>> UpdateTemplateAsync(
|
|
int templateId,
|
|
string name,
|
|
string? description,
|
|
int? parentTemplateId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return Result<Template>.Failure("Template name is required.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<Template>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
// ParentTemplateId is immutable after creation — set once at create time.
|
|
// Reject any attempt to change it (null→value, value→null, or value→other).
|
|
if (parentTemplateId != template.ParentTemplateId)
|
|
{
|
|
return Result<Template>.Failure(
|
|
"Parent template cannot be changed after creation.");
|
|
}
|
|
|
|
template.Name = name;
|
|
template.Description = description;
|
|
|
|
// Check for naming collisions after the change
|
|
var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
|
|
if (collisionResult != null)
|
|
return Result<Template>.Failure(collisionResult);
|
|
|
|
await _repository.UpdateTemplateAsync(template, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "Template", templateId.ToString(), name, template, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<Template>.Success(template);
|
|
}
|
|
|
|
public async Task<Result<bool>> DeleteTemplateAsync(
|
|
int templateId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
// Deletion-constraint logic (instances / child / derived / composing
|
|
// templates) lives in exactly one place — TemplateDeletionService — so a
|
|
// future rule change cannot drift between two implementations
|
|
// (TemplateEngine-014). TemplateService owns only the audit-logging side
|
|
// effect, which the deletion service is unaware of.
|
|
var deletionService = new Services.TemplateDeletionService(_repository);
|
|
var deleteResult = await deletionService.DeleteTemplateAsync(templateId, cancellationToken);
|
|
if (deleteResult.IsFailure)
|
|
return deleteResult;
|
|
|
|
// TemplateDeletionService already persisted the delete; the audit entry
|
|
// is added to the change tracker here and needs its own SaveChangesAsync.
|
|
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
public async Task<Result<Template>> MoveTemplateAsync(
|
|
int templateId,
|
|
int? newFolderId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<Template>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
if (newFolderId.HasValue)
|
|
{
|
|
var folder = await _repository.GetFolderByIdAsync(newFolderId.Value, cancellationToken);
|
|
if (folder == null)
|
|
return Result<Template>.Failure($"Target folder with ID {newFolderId.Value} not found.");
|
|
}
|
|
|
|
template.FolderId = newFolderId;
|
|
await _repository.UpdateTemplateAsync(template, cancellationToken);
|
|
await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<Template>.Success(template);
|
|
}
|
|
|
|
public async Task<Template?> GetTemplateByIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-2: Attribute Definitions with Lock Flags
|
|
// ========================================================================
|
|
|
|
public async Task<Result<TemplateAttribute>> AddAttributeAsync(
|
|
int templateId,
|
|
TemplateAttribute attribute,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateAttribute>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
// Check for duplicate name at this template level
|
|
if (template.Attributes.Any(a => a.Name == attribute.Name))
|
|
return Result<TemplateAttribute>.Failure(
|
|
$"Attribute '{attribute.Name}' already exists on template '{template.Name}'.");
|
|
|
|
attribute.TemplateId = templateId;
|
|
|
|
// If inheriting, validate not trying to add a member that would collide
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
|
|
|
|
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateAttribute", "0", attribute.Name, attribute, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAttribute>.Success(attribute);
|
|
}
|
|
|
|
public async Task<Result<TemplateAttribute>> UpdateAttributeAsync(
|
|
int attributeId,
|
|
TemplateAttribute proposed,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existing = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
|
|
if (existing == null)
|
|
return Result<TemplateAttribute>.Failure($"Attribute with ID {attributeId} not found.");
|
|
|
|
// Validate override rules if this is an override (parent has same-named attribute)
|
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null && parentMember.IsLocked)
|
|
return Result<TemplateAttribute>.Failure(
|
|
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
|
|
|
|
// Derived templates may not override fields the base marked LockedInDerived.
|
|
if (template.IsDerived)
|
|
{
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
|
var baseAttr = baseTemplate?.Attributes.FirstOrDefault(a => a.Name == existing.Name);
|
|
if (baseAttr != null && baseAttr.LockedInDerived)
|
|
return Result<TemplateAttribute>.Failure(
|
|
$"Attribute '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
|
}
|
|
}
|
|
|
|
// Validate lock change rules
|
|
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
|
if (lockError != null)
|
|
return Result<TemplateAttribute>.Failure(lockError);
|
|
|
|
// Validate fixed-field granularity. DataType and DataSourceReference are
|
|
// fixed by the defining level for every attribute — locked or not — so
|
|
// the error is always honoured (a locked attribute is already rejected
|
|
// earlier inside the helper).
|
|
var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
|
|
if (granularityError != null)
|
|
return Result<TemplateAttribute>.Failure(granularityError);
|
|
|
|
// Apply overridable fields. DataType / DataSourceReference are fixed and
|
|
// are deliberately not copied from the proposed attribute.
|
|
existing.Value = proposed.Value;
|
|
existing.Description = proposed.Description;
|
|
existing.IsLocked = proposed.IsLocked;
|
|
if (template?.IsDerived == true)
|
|
existing.IsInherited = proposed.IsInherited;
|
|
else
|
|
existing.LockedInDerived = proposed.LockedInDerived;
|
|
|
|
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAttribute>.Success(existing);
|
|
}
|
|
|
|
public async Task<Result<bool>> DeleteAttributeAsync(
|
|
int attributeId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var attribute = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
|
|
if (attribute == null)
|
|
return Result<bool>.Failure($"Attribute with ID {attributeId} not found.");
|
|
|
|
// Cannot remove inherited parent members — only direct members
|
|
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
attribute.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null)
|
|
return Result<bool>.Failure(
|
|
$"Cannot remove attribute '{attribute.Name}': it is inherited from parent template.");
|
|
}
|
|
|
|
await _repository.DeleteTemplateAttributeAsync(attributeId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-3: Alarm Definitions
|
|
// ========================================================================
|
|
|
|
public async Task<Result<TemplateAlarm>> AddAlarmAsync(
|
|
int templateId,
|
|
TemplateAlarm alarm,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateAlarm>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
if (template.Alarms.Any(a => a.Name == alarm.Name))
|
|
return Result<TemplateAlarm>.Failure(
|
|
$"Alarm '{alarm.Name}' already exists on template '{template.Name}'.");
|
|
|
|
// Validate priority range
|
|
if (alarm.PriorityLevel < 0 || alarm.PriorityLevel > 1000)
|
|
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
|
|
|
|
alarm.TemplateId = templateId;
|
|
|
|
// Check collisions
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
|
|
|
|
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateAlarm", "0", alarm.Name, alarm, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAlarm>.Success(alarm);
|
|
}
|
|
|
|
public async Task<Result<TemplateAlarm>> UpdateAlarmAsync(
|
|
int alarmId,
|
|
TemplateAlarm proposed,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existing = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
|
|
if (existing == null)
|
|
return Result<TemplateAlarm>.Failure($"Alarm with ID {alarmId} not found.");
|
|
|
|
// Validate priority range
|
|
if (proposed.PriorityLevel < 0 || proposed.PriorityLevel > 1000)
|
|
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
|
|
|
|
// Validate lock change
|
|
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
|
if (lockError != null)
|
|
return Result<TemplateAlarm>.Failure(lockError);
|
|
|
|
// Check parent lock
|
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null && parentMember.IsLocked)
|
|
return Result<TemplateAlarm>.Failure(
|
|
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
|
|
|
|
// Derived templates may not override alarms the base marked LockedInDerived.
|
|
if (template.IsDerived)
|
|
{
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
|
var baseAlarm = baseTemplate?.Alarms.FirstOrDefault(a => a.Name == existing.Name);
|
|
if (baseAlarm != null && baseAlarm.LockedInDerived)
|
|
return Result<TemplateAlarm>.Failure(
|
|
$"Alarm '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
|
}
|
|
}
|
|
|
|
// Validate fixed fields
|
|
var overrideError = LockEnforcer.ValidateAlarmOverride(existing, proposed);
|
|
if (overrideError != null)
|
|
return Result<TemplateAlarm>.Failure(overrideError);
|
|
|
|
// Apply overridable fields
|
|
existing.PriorityLevel = proposed.PriorityLevel;
|
|
existing.TriggerConfiguration = proposed.TriggerConfiguration;
|
|
existing.Description = proposed.Description;
|
|
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
|
|
existing.IsLocked = proposed.IsLocked;
|
|
if (template?.IsDerived == true)
|
|
existing.IsInherited = proposed.IsInherited;
|
|
else
|
|
existing.LockedInDerived = proposed.LockedInDerived;
|
|
// Name and TriggerType are NOT updated (fixed)
|
|
|
|
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAlarm>.Success(existing);
|
|
}
|
|
|
|
public async Task<Result<bool>> DeleteAlarmAsync(
|
|
int alarmId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var alarm = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
|
|
if (alarm == null)
|
|
return Result<bool>.Failure($"Alarm with ID {alarmId} not found.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
alarm.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null)
|
|
return Result<bool>.Failure(
|
|
$"Cannot remove alarm '{alarm.Name}': it is inherited from parent template.");
|
|
}
|
|
|
|
await _repository.DeleteTemplateAlarmAsync(alarmId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-4: Script Definitions
|
|
// ========================================================================
|
|
|
|
public async Task<Result<TemplateScript>> AddScriptAsync(
|
|
int templateId,
|
|
TemplateScript script,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateScript>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
if (template.Scripts.Any(s => s.Name == script.Name))
|
|
return Result<TemplateScript>.Failure(
|
|
$"Script '{script.Name}' already exists on template '{template.Name}'.");
|
|
|
|
script.TemplateId = templateId;
|
|
|
|
// Check collisions
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var testTemplate = CloneTemplateWithNewScript(template, script);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
|
|
|
|
await _repository.AddTemplateScriptAsync(script, cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateScript", "0", script.Name, script, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateScript>.Success(script);
|
|
}
|
|
|
|
public async Task<Result<TemplateScript>> UpdateScriptAsync(
|
|
int scriptId,
|
|
TemplateScript proposed,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existing = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
|
|
if (existing == null)
|
|
return Result<TemplateScript>.Failure($"Script with ID {scriptId} not found.");
|
|
|
|
// Validate lock change
|
|
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
|
if (lockError != null)
|
|
return Result<TemplateScript>.Failure(lockError);
|
|
|
|
// Check parent lock
|
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null && parentMember.IsLocked)
|
|
return Result<TemplateScript>.Failure(
|
|
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
|
|
|
|
// Derived templates may not override scripts the base marked LockedInDerived.
|
|
if (template.IsDerived)
|
|
{
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
|
var baseScript = baseTemplate?.Scripts.FirstOrDefault(s => s.Name == existing.Name);
|
|
if (baseScript != null && baseScript.LockedInDerived)
|
|
return Result<TemplateScript>.Failure(
|
|
$"Script '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
|
}
|
|
}
|
|
|
|
// Validate fixed fields
|
|
var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
|
|
if (overrideError != null)
|
|
return Result<TemplateScript>.Failure(overrideError);
|
|
|
|
// Apply overridable fields
|
|
existing.Code = proposed.Code;
|
|
existing.TriggerType = proposed.TriggerType;
|
|
existing.TriggerConfiguration = proposed.TriggerConfiguration;
|
|
existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
|
|
existing.ParameterDefinitions = proposed.ParameterDefinitions;
|
|
existing.ReturnDefinition = proposed.ReturnDefinition;
|
|
existing.IsLocked = proposed.IsLocked;
|
|
if (template?.IsDerived == true)
|
|
existing.IsInherited = proposed.IsInherited;
|
|
else
|
|
existing.LockedInDerived = proposed.LockedInDerived;
|
|
// Name is NOT updated (fixed)
|
|
|
|
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateScript>.Success(existing);
|
|
}
|
|
|
|
public async Task<Result<bool>> DeleteScriptAsync(
|
|
int scriptId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var script = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
|
|
if (script == null)
|
|
return Result<bool>.Failure($"Script with ID {scriptId} not found.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
script.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null)
|
|
return Result<bool>.Failure(
|
|
$"Cannot remove script '{script.Name}': it is inherited from parent template.");
|
|
}
|
|
|
|
await _repository.DeleteTemplateScriptAsync(scriptId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-6: Composition with Recursive Nesting
|
|
// ========================================================================
|
|
|
|
public async Task<Result<TemplateComposition>> AddCompositionAsync(
|
|
int templateId,
|
|
int composedTemplateId,
|
|
string instanceName,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(instanceName))
|
|
return Result<TemplateComposition>.Failure("Instance name is required for composition.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
|
|
if (baseTemplate == null)
|
|
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
|
|
|
|
// Only base templates can be composed; derived templates are slot-owned.
|
|
if (baseTemplate.IsDerived)
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Cannot compose template '{baseTemplate.Name}': it is a derived template. Compose its base instead.");
|
|
|
|
// Check for duplicate instance name
|
|
if (template.Compositions.Any(c => c.InstanceName == instanceName))
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
|
|
|
|
// Acyclicity is checked against the base, not the to-be-created derived template —
|
|
// the derived inherits from the base, so a base→base cycle is the meaningful check.
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
|
|
if (cycleError != null)
|
|
return Result<TemplateComposition>.Failure(cycleError);
|
|
|
|
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
|
|
if (crossCycleError != null)
|
|
return Result<TemplateComposition>.Failure(crossCycleError);
|
|
|
|
var probeComposition = new TemplateComposition(instanceName)
|
|
{
|
|
TemplateId = templateId,
|
|
ComposedTemplateId = composedTemplateId
|
|
};
|
|
|
|
var testTemplate = CloneTemplateWithNewComposition(template, probeComposition);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
|
|
|
// Derived template name uses dot-separated path: "<parent>.<slot>". The
|
|
// cascade may create additional derived templates one level per slot
|
|
// (composing $Sensor with a Probe1 slot into $Pump produces both
|
|
// $Pump.TempSensor and $Pump.TempSensor.Probe1). Pre-check every name
|
|
// the cascade is about to introduce so a deep collision aborts before
|
|
// any rows mutate.
|
|
var byId = allTemplates.ToDictionary(t => t.Id);
|
|
var cascadeNames = EnumerateCascadeNames(template.Name, instanceName, baseTemplate, byId).ToList();
|
|
var existingNames = allTemplates.Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
|
|
var nameCollision = cascadeNames.FirstOrDefault(n => existingNames.Contains(n));
|
|
if (nameCollision != null)
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Cannot create derived template '{nameCollision}': a template with that name already exists.");
|
|
|
|
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateComposition>.Success(composition);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a derived template under <paramref name="outerTemplate"/> that
|
|
/// wraps <paramref name="source"/>, then recursively cascades the source's
|
|
/// own compositions so the slot graph is replicated under the new derived.
|
|
/// Used both for the user-initiated top-level compose and the recursive
|
|
/// children — neither path re-validates (caller pre-flights).
|
|
/// </summary>
|
|
private async Task<TemplateComposition> CreateCascadedCompositionAsync(
|
|
Template outerTemplate,
|
|
Template source,
|
|
string instanceName,
|
|
string user,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var derivedName = $"{outerTemplate.Name}.{instanceName}";
|
|
var derived = BuildDerivedTemplate(source, derivedName);
|
|
|
|
await _repository.AddTemplateAsync(derived, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
var composition = new TemplateComposition(instanceName)
|
|
{
|
|
TemplateId = outerTemplate.Id,
|
|
ComposedTemplateId = derived.Id
|
|
};
|
|
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
derived.OwnerCompositionId = composition.Id;
|
|
await _repository.UpdateTemplateAsync(derived, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
// Cascade — replicate each of the source's compositions onto the new
|
|
// derived. The child's ParentTemplateId points at the source-side
|
|
// child (so override chains stay intact across nesting).
|
|
foreach (var childComp in source.Compositions.ToList())
|
|
{
|
|
var childSource = await _repository.GetTemplateByIdAsync(childComp.ComposedTemplateId, cancellationToken);
|
|
if (childSource == null) continue;
|
|
await CreateCascadedCompositionAsync(derived, childSource, childComp.InstanceName, user, cancellationToken);
|
|
}
|
|
|
|
return composition;
|
|
}
|
|
|
|
private static IEnumerable<string> EnumerateCascadeNames(
|
|
string outerName, string instanceName, Template source, IReadOnlyDictionary<int, Template> byId)
|
|
{
|
|
var derivedName = $"{outerName}.{instanceName}";
|
|
yield return derivedName;
|
|
foreach (var comp in source.Compositions)
|
|
{
|
|
if (!byId.TryGetValue(comp.ComposedTemplateId, out var child)) continue;
|
|
foreach (var name in EnumerateCascadeNames(derivedName, comp.InstanceName, child, byId))
|
|
yield return name;
|
|
}
|
|
}
|
|
|
|
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
|
|
int compositionId,
|
|
string newInstanceName,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newInstanceName))
|
|
return Result<TemplateComposition>.Failure("Slot name is required.");
|
|
|
|
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
|
|
if (composition == null)
|
|
return Result<TemplateComposition>.Failure($"Composition with ID {compositionId} not found.");
|
|
|
|
if (composition.InstanceName == newInstanceName) return Result<TemplateComposition>.Success(composition);
|
|
|
|
var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken);
|
|
if (owner == null)
|
|
return Result<TemplateComposition>.Failure($"Owning template with ID {composition.TemplateId} not found.");
|
|
|
|
if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName))
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
|
|
|
|
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
|
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
|
|
{
|
|
var newDerivedName = $"{owner.Name}.{newInstanceName}";
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
|
|
// The cascade of derived templates created by AddComposition follows a
|
|
// dotted path (Pump.TempSensor and the nested Pump.TempSensor.Probe1).
|
|
// Renaming the slot must rename every derived template in that cascade
|
|
// so the dotted-path naming invariant holds — pre-check every new name
|
|
// the cascade will introduce before any row mutates.
|
|
var renames = new List<(Template Template, string NewName)>();
|
|
await CollectCascadeRenamesAsync(derived, newDerivedName, renames, cancellationToken);
|
|
|
|
var renamedIds = renames.Select(r => r.Template.Id).ToHashSet();
|
|
foreach (var (_, newName) in renames)
|
|
{
|
|
if (allTemplates.Any(t => !renamedIds.Contains(t.Id) && t.Name == newName))
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Cannot rename derived template to '{newName}': a template with that name already exists.");
|
|
}
|
|
|
|
foreach (var (template, newName) in renames)
|
|
{
|
|
template.Name = newName;
|
|
await _repository.UpdateTemplateAsync(template, cancellationToken);
|
|
}
|
|
}
|
|
|
|
composition.InstanceName = newInstanceName;
|
|
await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateComposition>.Success(composition);
|
|
}
|
|
|
|
public async Task<Result<bool>> DeleteCompositionAsync(
|
|
int compositionId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
|
|
if (composition == null)
|
|
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
|
|
|
|
// Identify the slot-owned derived template (post Phase-3 migration this is the
|
|
// typical case; pre-migration the composition may still point at a base).
|
|
var composedTemplate = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
|
|
|
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
|
|
|
|
if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
|
|
{
|
|
await CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken);
|
|
}
|
|
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively collects the (template, new name) pairs for a renamed derived
|
|
/// template and every cascaded inner derived template beneath it. Each inner
|
|
/// derived's new name is re-derived from its renamed parent and the slot's
|
|
/// instance name (mirroring the cascade <see cref="CreateCascadedCompositionAsync"/>
|
|
/// builds and the recursion in <see cref="CascadeDeleteDerivedAsync"/>).
|
|
/// </summary>
|
|
private async Task CollectCascadeRenamesAsync(
|
|
Template derived,
|
|
string newName,
|
|
List<(Template Template, string NewName)> renames,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
renames.Add((derived, newName));
|
|
|
|
foreach (var child in derived.Compositions.ToList())
|
|
{
|
|
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
|
|
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
|
|
await CollectCascadeRenamesAsync(
|
|
childDerived, $"{newName}.{child.InstanceName}", renames, cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively deletes a derived template along with the cascade of inner
|
|
/// derived templates the compose flow created. Each composition row on the
|
|
/// derived has its slot-owned child template removed first, then the row,
|
|
/// then the derived itself.
|
|
/// </summary>
|
|
private async Task CascadeDeleteDerivedAsync(Template derived, string user, CancellationToken cancellationToken)
|
|
{
|
|
foreach (var child in derived.Compositions.ToList())
|
|
{
|
|
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
|
|
await _repository.DeleteTemplateCompositionAsync(child.Id, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateComposition", child.Id.ToString(), child.InstanceName, null, cancellationToken);
|
|
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
|
|
await CascadeDeleteDerivedAsync(childDerived, user, cancellationToken);
|
|
}
|
|
|
|
await _repository.DeleteTemplateAsync(derived.Id, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "Template", derived.Id.ToString(), derived.Name, null, cancellationToken);
|
|
}
|
|
|
|
private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
|
|
{
|
|
var derived = new Template(derivedName)
|
|
{
|
|
Description = baseTemplate.Description,
|
|
ParentTemplateId = baseTemplate.Id,
|
|
IsDerived = true,
|
|
};
|
|
|
|
foreach (var attr in baseTemplate.Attributes)
|
|
{
|
|
derived.Attributes.Add(new TemplateAttribute(attr.Name)
|
|
{
|
|
Value = attr.Value,
|
|
DataType = attr.DataType,
|
|
IsLocked = attr.IsLocked,
|
|
Description = attr.Description,
|
|
DataSourceReference = attr.DataSourceReference,
|
|
IsInherited = true,
|
|
LockedInDerived = false,
|
|
});
|
|
}
|
|
|
|
foreach (var alarm in baseTemplate.Alarms)
|
|
{
|
|
derived.Alarms.Add(new TemplateAlarm(alarm.Name)
|
|
{
|
|
Description = alarm.Description,
|
|
PriorityLevel = alarm.PriorityLevel,
|
|
IsLocked = alarm.IsLocked,
|
|
TriggerType = alarm.TriggerType,
|
|
TriggerConfiguration = alarm.TriggerConfiguration,
|
|
OnTriggerScriptId = alarm.OnTriggerScriptId,
|
|
IsInherited = true,
|
|
LockedInDerived = false,
|
|
});
|
|
}
|
|
|
|
foreach (var script in baseTemplate.Scripts)
|
|
{
|
|
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
|
|
{
|
|
IsLocked = script.IsLocked,
|
|
TriggerType = script.TriggerType,
|
|
TriggerConfiguration = script.TriggerConfiguration,
|
|
ParameterDefinitions = script.ParameterDefinitions,
|
|
ReturnDefinition = script.ReturnDefinition,
|
|
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
|
IsInherited = true,
|
|
LockedInDerived = false,
|
|
});
|
|
}
|
|
|
|
return derived;
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Resolves all effective members for a template using canonical (path-qualified) names.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<TemplateResolver.ResolvedTemplateMember>> ResolveTemplateMembersAsync(
|
|
int templateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-8/9/10/11: Override validation (integrated into Update* methods above)
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Validates whether overriding a member by canonical name is allowed.
|
|
/// Used for composition overrides (WP-11) and inheritance overrides (WP-10).
|
|
/// </summary>
|
|
public async Task<Result<bool>> ValidateOverrideAsync(
|
|
int templateId,
|
|
string canonicalName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
|
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
|
|
|
|
if (member == null)
|
|
return Result<bool>.Failure($"No member found with canonical name '{canonicalName}'.");
|
|
|
|
if (member.IsLocked)
|
|
return Result<bool>.Failure($"Member '{canonicalName}' is locked and cannot be overridden.");
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-12: Naming Collision Detection
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Checks a template for naming collisions across all its members.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<string>> DetectCollisionsAsync(
|
|
int templateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return new[] { $"Template with ID {templateId} not found." };
|
|
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
return CollisionDetector.DetectCollisions(template, allTemplates);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-13: Graph Acyclicity
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Validates that a proposed inheritance or composition change does not create a cycle.
|
|
/// </summary>
|
|
public async Task<Result<bool>> ValidateAcyclicityAsync(
|
|
int templateId,
|
|
int? proposedParentId,
|
|
int? proposedComposedTemplateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
|
|
if (proposedParentId.HasValue)
|
|
{
|
|
var inheritanceCycle = CycleDetector.DetectInheritanceCycle(templateId, proposedParentId.Value, allTemplates);
|
|
if (inheritanceCycle != null)
|
|
return Result<bool>.Failure(inheritanceCycle);
|
|
}
|
|
|
|
if (proposedComposedTemplateId.HasValue)
|
|
{
|
|
var compositionCycle = CycleDetector.DetectCompositionCycle(templateId, proposedComposedTemplateId.Value, allTemplates);
|
|
if (compositionCycle != null)
|
|
return Result<bool>.Failure(compositionCycle);
|
|
}
|
|
|
|
var crossCycle = CycleDetector.DetectCrossGraphCycle(templateId, proposedParentId, proposedComposedTemplateId, allTemplates);
|
|
if (crossCycle != null)
|
|
return Result<bool>.Failure(crossCycle);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Helper methods
|
|
// ========================================================================
|
|
|
|
private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return string.Join(" ", collisions);
|
|
return null;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewAttribute(Template original, TemplateAttribute newAttr)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
clone.Attributes.Add(newAttr);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
return clone;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewAlarm(Template original, TemplateAlarm newAlarm)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
clone.Alarms.Add(newAlarm);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
return clone;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewScript(Template original, TemplateScript newScript)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
clone.Scripts.Add(newScript);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
return clone;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewComposition(Template original, TemplateComposition newComp)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
clone.Compositions.Add(newComp);
|
|
return clone;
|
|
}
|
|
}
|