494 lines
24 KiB
C#
494 lines
24 KiB
C#
using System.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Learning;
|
|
|
|
internal sealed class StudentLearningTargetService(
|
|
ILearningAccessPersistence access,
|
|
IQuestionBankPersistence content,
|
|
IEffectiveLearningAccessService effectiveAccess) : IStudentLearningTargetService
|
|
{
|
|
public async Task<LearningContextItem> GetContextAsync(
|
|
LearningActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await EnsureTenantActiveAsync(actor.TenantId, cancellationToken);
|
|
var now = DateTimeOffset.UtcNow;
|
|
var licenses = await (
|
|
from license in access.TenantBusinessLicenses.AsNoTracking()
|
|
join business in access.BusinessLines.AsNoTracking() on license.BusinessLineId equals business.Id
|
|
where license.TenantId == actor.TenantId &&
|
|
license.Status == LicenseLifecycleStatus.Active &&
|
|
license.StartsAt <= now &&
|
|
(license.EndsAt == null || license.EndsAt > now) &&
|
|
business.IsActive
|
|
orderby business.Name, business.Code
|
|
select new { License = license, Business = business })
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
var items = new List<LearningBusinessContextItem>(licenses.Length);
|
|
foreach (var row in licenses)
|
|
{
|
|
var policy = await GetPolicyAsync(row.Business.Id, cancellationToken);
|
|
var selections = await CurrentSelectionsAsync(actor, row.Business.Id, cancellationToken);
|
|
items.Add(new LearningBusinessContextItem(
|
|
row.Business.Id,
|
|
row.Business.Code,
|
|
row.Business.Name,
|
|
row.License.Id,
|
|
row.License.EndsAt,
|
|
selections.SingleOrDefault(item => item.Role == TargetSelectionRole.Primary)
|
|
?.ExamTargetProfileVersionId,
|
|
selections.Where(item => item.Role == TargetSelectionRole.Alternate)
|
|
.Select(item => item.ExamTargetProfileVersionId).Order().ToArray(),
|
|
policy.MaxActiveTargets,
|
|
policy.MaxAlternateTargets,
|
|
policy.TargetChangeCooldownDays));
|
|
}
|
|
|
|
return new LearningContextItem(items);
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<LearningTargetItem>> GetTargetsAsync(
|
|
LearningActor actor,
|
|
Guid businessLineId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var license = await RequireLicenseAsync(actor.TenantId, businessLineId, now, cancellationToken);
|
|
var selections = await CurrentSelectionsAsync(actor, businessLineId, cancellationToken);
|
|
var selectedIds = selections.Select(item => item.ExamTargetProfileVersionId).ToHashSet();
|
|
var targetAccess = await LoadTargetAccessAsync(actor, businessLineId, now, cancellationToken);
|
|
var manifestTargets = targetAccess.Targets;
|
|
var targetRoles = manifestTargets
|
|
.GroupBy(item => item.ExamTargetProfileVersionId)
|
|
.ToDictionary(group => group.Key, group => new
|
|
{
|
|
Primary = group.Any(item => item.MayBePrimary),
|
|
Alternate = group.Any(item => item.MayBeAlternate)
|
|
});
|
|
|
|
var licensed = await access.TenantLicensedTargets.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.TenantBusinessLicenseId == license.Id &&
|
|
item.StartsAt <= now &&
|
|
(item.EndsAt == null || item.EndsAt > now))
|
|
.ToArrayAsync(cancellationToken);
|
|
var profileIds = licensed.Select(item => item.ExamTargetProfileVersionId).Distinct().ToArray();
|
|
var profiles = await (
|
|
from version in content.ExamTargetProfileVersions.AsNoTracking()
|
|
join profile in content.ExamTargetProfiles.AsNoTracking()
|
|
on version.ExamTargetProfileId equals profile.Id
|
|
where profileIds.Contains(version.Id) &&
|
|
profile.BusinessLineId == businessLineId &&
|
|
profile.IsActive &&
|
|
version.Status == ContentDefinitionStatus.Published
|
|
select new { Version = version, Profile = profile })
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return profiles.Select(row =>
|
|
{
|
|
targetRoles.TryGetValue(row.Version.Id, out var role);
|
|
var selection = selections.SingleOrDefault(item => item.ExamTargetProfileVersionId == row.Version.Id);
|
|
return new LearningTargetItem(
|
|
row.Version.Id,
|
|
row.Profile.Code,
|
|
row.Version.DisplayName,
|
|
row.Version.ExamYear,
|
|
licensed.Single(item => item.ExamTargetProfileVersionId == row.Version.Id).IsBaseTarget,
|
|
role?.Primary == true,
|
|
role?.Alternate == true,
|
|
selection?.Role == TargetSelectionRole.Primary,
|
|
selection?.Role == TargetSelectionRole.Alternate);
|
|
}).Where(item => selectedIds.Contains(item.ExamTargetProfileVersionId) ||
|
|
item.MayBePrimary || item.MayBeAlternate)
|
|
.OrderByDescending(item => item.ExamYear)
|
|
.ThenBy(item => item.DisplayName)
|
|
.ToArray();
|
|
}
|
|
|
|
public async Task<LearningTargetSelectionItem> ChangeTargetsAsync(
|
|
LearningActor actor,
|
|
Guid businessLineId,
|
|
ChangeLearningTargetsCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await ChangeTargetsCoreAsync(
|
|
actor,
|
|
businessLineId,
|
|
command,
|
|
actor.UserId,
|
|
"student_self_service",
|
|
true,
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<LearningTargetSelectionItem> OverrideTargetsAsync(
|
|
LearningActor student,
|
|
Guid businessLineId,
|
|
ChangeLearningTargetsCommand command,
|
|
Guid changedBy,
|
|
string reason,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (changedBy == Guid.Empty || string.IsNullOrWhiteSpace(reason))
|
|
throw Error("student_target_override_reason_required",
|
|
"An administrator and a non-empty reason are required for a target override.");
|
|
|
|
return await ChangeTargetsCoreAsync(
|
|
student,
|
|
businessLineId,
|
|
command,
|
|
changedBy,
|
|
reason.Trim(),
|
|
false,
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task<LearningTargetSelectionItem> ChangeTargetsCoreAsync(
|
|
LearningActor actor,
|
|
Guid businessLineId,
|
|
ChangeLearningTargetsCommand command,
|
|
Guid changedBy,
|
|
string reason,
|
|
bool enforceCooldown,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var alternateIds = command.AlternateProfileVersionIds.Distinct().ToArray();
|
|
if (alternateIds.Length != command.AlternateProfileVersionIds.Count ||
|
|
alternateIds.Contains(command.PrimaryProfileVersionId))
|
|
throw Error("student_target_selection_duplicate", "Primary and alternate targets must be unique.");
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
await using var transaction = await access.Database.BeginTransactionAsync(
|
|
IsolationLevel.ReadCommitted,
|
|
cancellationToken);
|
|
var lockKey = $"student-target:{actor.TenantId:N}:{actor.UserId:N}:{businessLineId:N}";
|
|
await access.Database.ExecuteSqlInterpolatedAsync(
|
|
$"SELECT pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
|
|
cancellationToken);
|
|
var license = await RequireLicenseAsync(actor.TenantId, businessLineId, now, cancellationToken);
|
|
var policy = await GetPolicyAsync(businessLineId, cancellationToken);
|
|
if (policy.MaxPrimaryTargets < 1 ||
|
|
1 + alternateIds.Length > Math.Min(16, policy.MaxActiveTargets) ||
|
|
alternateIds.Length > policy.MaxAlternateTargets)
|
|
throw Error("student_target_policy_limit_exceeded",
|
|
"The requested primary and alternate targets exceed the business policy.");
|
|
|
|
var requestedIds = alternateIds.Append(command.PrimaryProfileVersionId).ToArray();
|
|
var licensedIds = await access.TenantLicensedTargets.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.TenantBusinessLicenseId == license.Id &&
|
|
item.StartsAt <= now &&
|
|
(item.EndsAt == null || item.EndsAt > now) &&
|
|
requestedIds.Contains(item.ExamTargetProfileVersionId))
|
|
.Select(item => item.ExamTargetProfileVersionId)
|
|
.Distinct()
|
|
.ToArrayAsync(cancellationToken);
|
|
if (licensedIds.Length != requestedIds.Length)
|
|
throw Error("student_target_not_licensed", "One or more requested targets are not tenant licensed.");
|
|
|
|
var targetAccess = await LoadTargetAccessAsync(actor, businessLineId, now, cancellationToken);
|
|
var entitledTargets = targetAccess.Targets;
|
|
if (1 + alternateIds.Length > targetAccess.MaxActiveTargets ||
|
|
alternateIds.Length > targetAccess.MaxAlternateTargets)
|
|
throw Error("student_target_product_limit_exceeded",
|
|
"The requested primary and alternate targets exceed the active product limit.");
|
|
if (!entitledTargets.Any(item => item.ExamTargetProfileVersionId == command.PrimaryProfileVersionId &&
|
|
item.MayBePrimary) ||
|
|
alternateIds.Any(id => !entitledTargets.Any(item =>
|
|
item.ExamTargetProfileVersionId == id && item.MayBeAlternate)))
|
|
throw Error("student_target_not_entitled", "One or more requested targets are not product entitled.");
|
|
|
|
var current = await access.StudentTargetSelectionHistory
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.BusinessLineId == businessLineId &&
|
|
item.IsCurrent)
|
|
.ToArrayAsync(cancellationToken);
|
|
if (SameSelection(current, command.PrimaryProfileVersionId, alternateIds))
|
|
{
|
|
await transaction.CommitAsync(cancellationToken);
|
|
var changedAt = current.Max(item => item.EffectiveAt);
|
|
return Result(businessLineId, command.PrimaryProfileVersionId, alternateIds, changedAt, policy);
|
|
}
|
|
|
|
var lastChangeAt = current.Length == 0
|
|
? await access.StudentTargetSelectionHistory.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.BusinessLineId == businessLineId)
|
|
.Select(item => (DateTimeOffset?)item.EffectiveAt)
|
|
.MaxAsync(cancellationToken)
|
|
: current.Max(item => item.EffectiveAt);
|
|
var nextAllowedAt = lastChangeAt?.AddDays(policy.TargetChangeCooldownDays);
|
|
if (enforceCooldown && nextAllowedAt > now)
|
|
throw Error("student_target_change_cooldown",
|
|
$"Targets cannot be changed before {nextAllowedAt:O}.");
|
|
|
|
foreach (var selection in current)
|
|
{
|
|
selection.IsCurrent = false;
|
|
selection.EndedAt = now;
|
|
selection.UpdatedAt = now;
|
|
}
|
|
|
|
var primarySource = ResolveSelectionSource(
|
|
targetAccess, command.PrimaryProfileVersionId, TargetSelectionRole.Primary);
|
|
access.StudentTargetSelectionHistory.Add(NewSelection(
|
|
actor, businessLineId, command.PrimaryProfileVersionId, TargetSelectionRole.Primary, primarySource,
|
|
changedBy, reason, now));
|
|
foreach (var alternateId in alternateIds)
|
|
{
|
|
var alternateSource = ResolveSelectionSource(
|
|
targetAccess, alternateId, TargetSelectionRole.Alternate);
|
|
access.StudentTargetSelectionHistory.Add(NewSelection(
|
|
actor, businessLineId, alternateId, TargetSelectionRole.Alternate, alternateSource,
|
|
changedBy, reason, now));
|
|
}
|
|
|
|
var version = await access.LearningAccessVersions.SingleOrDefaultAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
|
cancellationToken);
|
|
version ??= new LearningAccessVersion { TenantId = actor.TenantId, UserId = actor.UserId };
|
|
if (access.Entry(version).State == EntityState.Detached) access.LearningAccessVersions.Add(version);
|
|
version.GrantVersion++;
|
|
version.UpdatedAt = now;
|
|
await access.EffectiveAccessProjections
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.BusinessLineId == businessLineId)
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
await access.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
await effectiveAccess.InvalidateAsync(actor.TenantId, actor.UserId, businessLineId, cancellationToken);
|
|
return Result(businessLineId, command.PrimaryProfileVersionId, alternateIds, now, policy);
|
|
}
|
|
|
|
private async Task<TargetAccess> LoadTargetAccessAsync(
|
|
LearningActor actor,
|
|
Guid businessLineId,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var entitlementManifests = await (
|
|
from entitlement in access.StudentEntitlementsV2.AsNoTracking()
|
|
join product in access.LearningProducts.AsNoTracking()
|
|
on new { entitlement.TenantId, Id = entitlement.LearningProductId }
|
|
equals new { product.TenantId, product.Id }
|
|
join manifest in access.ProductAccessManifestVersions.AsNoTracking()
|
|
on new { entitlement.TenantId, Id = entitlement.ProductAccessManifestVersionId }
|
|
equals new { manifest.TenantId, manifest.Id }
|
|
where entitlement.TenantId == actor.TenantId &&
|
|
entitlement.UserId == actor.UserId &&
|
|
entitlement.Status == StudentEntitlementStatus.Active &&
|
|
entitlement.StartsAt <= now &&
|
|
(entitlement.EndsAt == null || entitlement.EndsAt > now) &&
|
|
product.BusinessLineId == businessLineId &&
|
|
product.IsActive &&
|
|
manifest.Status == AccessManifestStatus.Published
|
|
select new ManifestGrant(
|
|
manifest.Id,
|
|
manifest.MaxActiveTargets,
|
|
manifest.MaxAlternateTargets,
|
|
"student_entitlement",
|
|
entitlement.Id))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
var classManifests = await (
|
|
from member in access.TenantClassMembers.AsNoTracking()
|
|
join grant in access.ClassAssignmentGrants.AsNoTracking()
|
|
on new { member.TenantId, member.ClassId } equals new { grant.TenantId, grant.ClassId }
|
|
join manifest in access.ProductAccessManifestVersions.AsNoTracking()
|
|
on new { grant.TenantId, Id = grant.ProductAccessManifestVersionId }
|
|
equals new { manifest.TenantId, manifest.Id }
|
|
join definition in access.ProductAccessManifests.AsNoTracking()
|
|
on new { manifest.TenantId, Id = manifest.ProductAccessManifestId }
|
|
equals new { definition.TenantId, definition.Id }
|
|
join product in access.LearningProducts.AsNoTracking()
|
|
on new { definition.TenantId, Id = definition.LearningProductId }
|
|
equals new { product.TenantId, product.Id }
|
|
where member.TenantId == actor.TenantId &&
|
|
member.UserId == actor.UserId &&
|
|
member.MemberType == TenantClassMemberType.Student &&
|
|
member.Status == TenantClassMemberStatus.Active &&
|
|
grant.Status == ClassAssignmentGrantStatus.Active &&
|
|
grant.StartsAt <= now &&
|
|
(grant.EndsAt == null || grant.EndsAt > now) &&
|
|
manifest.Status == AccessManifestStatus.Published &&
|
|
product.BusinessLineId == businessLineId &&
|
|
product.IsActive
|
|
select new ManifestGrant(
|
|
manifest.Id,
|
|
manifest.MaxActiveTargets,
|
|
manifest.MaxAlternateTargets,
|
|
"class_assignment_grant",
|
|
grant.Id))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
var manifests = entitlementManifests.Concat(classManifests)
|
|
.DistinctBy(item => new { item.ManifestVersionId, item.SourceType, item.SourceId })
|
|
.ToArray();
|
|
if (manifests.Length == 0) return new TargetAccess([], [], 0, 0);
|
|
var manifestIds = manifests.Select(item => item.ManifestVersionId).ToArray();
|
|
var targets = await access.ProductManifestTargets.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && manifestIds.Contains(item.ProductAccessManifestVersionId))
|
|
.ToArrayAsync(cancellationToken);
|
|
return new TargetAccess(
|
|
targets,
|
|
manifests,
|
|
manifests.Max(item => item.MaxActiveTargets),
|
|
manifests.Max(item => item.MaxAlternateTargets));
|
|
}
|
|
|
|
private async Task<TenantBusinessLicense> RequireLicenseAsync(
|
|
Guid tenantId,
|
|
Guid businessLineId,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await EnsureTenantActiveAsync(tenantId, cancellationToken);
|
|
return await access.TenantBusinessLicenses.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.BusinessLineId == businessLineId &&
|
|
item.Status == LicenseLifecycleStatus.Active &&
|
|
item.StartsAt <= now &&
|
|
(item.EndsAt == null || item.EndsAt > now),
|
|
cancellationToken) ?? throw Error("learning_license_required",
|
|
"The tenant has no active license for the requested business.");
|
|
}
|
|
|
|
private async Task EnsureTenantActiveAsync(Guid tenantId, CancellationToken cancellationToken)
|
|
{
|
|
if (!await access.Tenants.AsNoTracking().AnyAsync(
|
|
item => item.Id == tenantId && item.Status == TenantStatus.Active,
|
|
cancellationToken))
|
|
throw Error("learning_tenant_inactive", "The tenant is not active.");
|
|
}
|
|
|
|
private async Task<BusinessTargetPolicyVersion> GetPolicyAsync(
|
|
Guid businessLineId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await (
|
|
from policy in content.BusinessTargetPolicies.AsNoTracking()
|
|
join version in content.BusinessTargetPolicyVersions.AsNoTracking()
|
|
on new { PolicyId = policy.Id, VersionId = policy.CurrentVersionId }
|
|
equals new { PolicyId = version.BusinessTargetPolicyId, VersionId = (Guid?)version.Id }
|
|
where policy.BusinessLineId == businessLineId &&
|
|
policy.IsActive &&
|
|
version.Status == ContentDefinitionStatus.Published
|
|
select version)
|
|
.SingleOrDefaultAsync(cancellationToken) ?? throw Error("business_target_policy_required",
|
|
"The business has no published target policy.");
|
|
}
|
|
|
|
private Task<StudentTargetSelectionHistory[]> CurrentSelectionsAsync(
|
|
LearningActor actor,
|
|
Guid businessLineId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return access.StudentTargetSelectionHistory.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.BusinessLineId == businessLineId &&
|
|
item.IsCurrent)
|
|
.ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
private static bool SameSelection(
|
|
IReadOnlyCollection<StudentTargetSelectionHistory> current,
|
|
Guid primaryId,
|
|
IReadOnlyCollection<Guid> alternateIds)
|
|
{
|
|
return current.Count(item => item.Role == TargetSelectionRole.Primary) == 1 &&
|
|
current.Single(item => item.Role == TargetSelectionRole.Primary).ExamTargetProfileVersionId == primaryId &&
|
|
current.Where(item => item.Role == TargetSelectionRole.Alternate)
|
|
.Select(item => item.ExamTargetProfileVersionId).ToHashSet().SetEquals(alternateIds);
|
|
}
|
|
|
|
private static StudentTargetSelectionHistory NewSelection(
|
|
LearningActor actor,
|
|
Guid businessLineId,
|
|
Guid profileVersionId,
|
|
TargetSelectionRole role,
|
|
SelectionSource source,
|
|
Guid changedBy,
|
|
string reason,
|
|
DateTimeOffset now)
|
|
{
|
|
return new StudentTargetSelectionHistory
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
BusinessLineId = businessLineId,
|
|
ExamTargetProfileVersionId = profileVersionId,
|
|
Role = role,
|
|
EntitlementId = source.SourceType == "student_entitlement" ? source.SourceId : null,
|
|
SourceType = source.SourceType,
|
|
SourceId = source.SourceId,
|
|
EffectiveAt = now,
|
|
IsCurrent = true,
|
|
ChangedBy = changedBy,
|
|
Reason = reason
|
|
};
|
|
}
|
|
|
|
private static LearningTargetSelectionItem Result(
|
|
Guid businessLineId,
|
|
Guid primaryId,
|
|
IReadOnlyCollection<Guid> alternateIds,
|
|
DateTimeOffset changedAt,
|
|
BusinessTargetPolicyVersion policy)
|
|
{
|
|
return new LearningTargetSelectionItem(
|
|
businessLineId,
|
|
primaryId,
|
|
alternateIds.Order().ToArray(),
|
|
changedAt,
|
|
policy.TargetChangeCooldownDays == 0 ? changedAt : changedAt.AddDays(policy.TargetChangeCooldownDays));
|
|
}
|
|
|
|
private static LearningAccessException Error(string code, string message) => new(code, message);
|
|
|
|
private static SelectionSource ResolveSelectionSource(
|
|
TargetAccess access,
|
|
Guid profileVersionId,
|
|
TargetSelectionRole role)
|
|
{
|
|
var manifestIds = access.Targets
|
|
.Where(item => item.ExamTargetProfileVersionId == profileVersionId &&
|
|
(role == TargetSelectionRole.Primary ? item.MayBePrimary : item.MayBeAlternate))
|
|
.Select(item => item.ProductAccessManifestVersionId)
|
|
.ToHashSet();
|
|
var grant = access.Grants
|
|
.Where(item => manifestIds.Contains(item.ManifestVersionId))
|
|
.OrderBy(item => item.SourceType == "student_entitlement" ? 0 : 1)
|
|
.ThenBy(item => item.SourceId)
|
|
.FirstOrDefault() ?? throw Error(
|
|
"student_target_source_missing",
|
|
"The selected target has no auditable active entitlement source.");
|
|
return new SelectionSource(grant.SourceType, grant.SourceId);
|
|
}
|
|
|
|
private sealed record ManifestGrant(
|
|
Guid ManifestVersionId,
|
|
int MaxActiveTargets,
|
|
int MaxAlternateTargets,
|
|
string SourceType,
|
|
Guid SourceId);
|
|
|
|
private sealed record SelectionSource(string SourceType, Guid SourceId);
|
|
|
|
private sealed record TargetAccess(
|
|
ProductManifestTarget[] Targets,
|
|
ManifestGrant[] Grants,
|
|
int MaxActiveTargets,
|
|
int MaxAlternateTargets);
|
|
}
|