feat(templateMatcher): Для синхронизации негруппированных работ добавлены метрики для мониторинга основных этапов.
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using PARR.Core.Common.Interfaces;
|
||||
using PARR.Core.Repositories.Interfaces.Job;
|
||||
using PARR.Core.Repositories.Interfaces.Unit;
|
||||
using PARR.Core.Services.UnitFilterService;
|
||||
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
|
||||
using PARR.Core.Services.UnitFilterService.Models;
|
||||
using PARR.Core.Services.UnitService.Interfaces;
|
||||
using PARR.DAL.Context;
|
||||
using PARR.Domain.Cache.Models;
|
||||
using PARR.Domain.Entities.Job;
|
||||
@@ -98,6 +101,13 @@ public class UnitFilterServiceTests
|
||||
public Mock<IUnitFieldRepository> UnitField { get; set; }
|
||||
public Mock<IJobRepository> Job { get; set; }
|
||||
public Mock<IRedisCacheService> Cache { get; set; }
|
||||
// Новые зависимости после рефакторинга
|
||||
public Mock<IUnitService> UnitService { get; set; }
|
||||
public Mock<IUnitFieldMatcher> FieldMatcher { get; set; }
|
||||
public Mock<IUnitRelationshipMatcher> RelationshipMatcher { get; set; }
|
||||
public Mock<IUmbrellaFilter> UmbrellaFilter { get; set; }
|
||||
public Mock<IUnitFilterResultLoader> ResultLoader { get; set; }
|
||||
public Mock<IUnitNameResolver> NameResolver { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -137,6 +147,56 @@ public class UnitFilterServiceTests
|
||||
cacheMock.Setup(r => r.GetCachedDataAsync<UnitFilterIds>(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync((UnitFilterIds)null!);
|
||||
|
||||
var unitServiceMock = new Mock<IUnitService>();
|
||||
var fieldMatcherMock = new Mock<IUnitFieldMatcher>();
|
||||
var relationshipMatcherMock = new Mock<IUnitRelationshipMatcher>();
|
||||
var umbrellaFilterMock = new Mock<IUmbrellaFilter>();
|
||||
var resultLoaderMock = new Mock<IUnitFilterResultLoader>();
|
||||
var nameResolverMock = new Mock<IUnitNameResolver>();
|
||||
|
||||
// Настройка NameResolver: возвращаем все ID юнитов из контекста (эмуляция кэш-промаха + БД)
|
||||
nameResolverMock.Setup(r => r.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string mask, CancellationToken ct) =>
|
||||
context.Units.Select(u => u.Id).ToList());
|
||||
|
||||
// Настройка FieldMatcher: эмуляция SQL-фильтрации через InMemory-контекст
|
||||
fieldMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobFieldFilter>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobFieldFilter> filters, CancellationToken ct) =>
|
||||
ids.ToList());
|
||||
|
||||
// Настройка RelationshipMatcher: возвращаем контексты без изменений
|
||||
relationshipMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobRelationshipFilter>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
|
||||
ids.Select(id => new UnitFilterMatchResult { UnitId = id }).ToList());
|
||||
|
||||
// Настройка UmbrellaFilter: пропускаем без изменений
|
||||
umbrellaFilterMock.Setup(r => r.Apply(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<Job>()))
|
||||
.Returns((List<UnitFilterMatchResult> ctx, Job j) => ctx);
|
||||
|
||||
// Настройка ResultLoader: формируем DTO из контекста
|
||||
resultLoaderMock.Setup(r => r.LoadAsync(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((List<UnitFilterMatchResult> contexts, CancellationToken ct) =>
|
||||
{
|
||||
var unitIds = contexts.Select(c => c.UnitId).ToHashSet();
|
||||
var units = context.Units.Where(u => unitIds.Contains(u.Id)).ToList();
|
||||
return units.Select(u => new UnitFilterResultDto
|
||||
{
|
||||
Id = u.Id,
|
||||
Name = u.Name,
|
||||
Values = u.UnitValues?.Select(v => new UnitValueDto
|
||||
{
|
||||
FieldId = v.FieldId,
|
||||
Value = v.Value?.Value
|
||||
}).ToList() ?? new List<UnitValueDto>(),
|
||||
Parents = contexts.First(c => c.UnitId == u.Id).ValidParentIds
|
||||
.Select(pid => new RelatedUnitDto { UnitId = pid })
|
||||
.ToList(),
|
||||
Children = contexts.First(c => c.UnitId == u.Id).ValidChildIds
|
||||
.Select(cid => new RelatedUnitDto { UnitId = cid })
|
||||
.ToList()
|
||||
}).ToList();
|
||||
});
|
||||
|
||||
return new RepositoryMocks
|
||||
{
|
||||
Unit = unitRepoMock,
|
||||
@@ -144,7 +204,13 @@ public class UnitFilterServiceTests
|
||||
UnitInValue = unitInValueRepoMock,
|
||||
UnitField = unitFieldRepoMock,
|
||||
Job = jobRepoMock,
|
||||
Cache = cacheMock
|
||||
Cache = cacheMock,
|
||||
UnitService = unitServiceMock,
|
||||
FieldMatcher = fieldMatcherMock,
|
||||
RelationshipMatcher = relationshipMatcherMock,
|
||||
UmbrellaFilter = umbrellaFilterMock,
|
||||
ResultLoader = resultLoaderMock,
|
||||
NameResolver = nameResolverMock
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,12 +220,10 @@ public class UnitFilterServiceTests
|
||||
/// </summary>
|
||||
/// <param name="mocks">Подготовленные моки репозиториев</param>
|
||||
/// <param name="logger">Экземпляр логгера для сервиса</param>
|
||||
private static PARR.Core.Services.UnitFilterService.UnitFilterService CreateSut(
|
||||
private static Core.Services.UnitFilterService.UnitFilterService CreateSut(
|
||||
RepositoryMocks mocks,
|
||||
ILogger<PARR.Core.Services.UnitFilterService.UnitFilterService> logger)
|
||||
ILogger<Core.Services.UnitFilterService.UnitFilterService> logger)
|
||||
{
|
||||
var serviceOptions = Options.Create(new PARR.Core.Services.UnitFilterService.Models.UnitFilterServiceOptions { LoadBatchSize = 100 });
|
||||
|
||||
return new PARR.Core.Services.UnitFilterService.UnitFilterService(
|
||||
logger,
|
||||
mocks.Job.Object,
|
||||
@@ -167,8 +231,13 @@ public class UnitFilterServiceTests
|
||||
mocks.UnitInUnit.Object,
|
||||
mocks.UnitInValue.Object,
|
||||
mocks.Cache.Object,
|
||||
serviceOptions,
|
||||
mocks.UnitField.Object
|
||||
mocks.UnitField.Object,
|
||||
mocks.UnitService.Object,
|
||||
mocks.FieldMatcher.Object,
|
||||
mocks.RelationshipMatcher.Object,
|
||||
mocks.UmbrellaFilter.Object,
|
||||
mocks.ResultLoader.Object,
|
||||
mocks.NameResolver.Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,7 +333,7 @@ public class UnitFilterServiceTests
|
||||
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(fieldId, It.IsAny<string>()))
|
||||
.Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable());
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var service = CreateSut(mocks, logger);
|
||||
|
||||
// === Act ===
|
||||
@@ -376,7 +445,7 @@ public class UnitFilterServiceTests
|
||||
.Distinct()
|
||||
.AsQueryable());
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var service = CreateSut(mocks, logger);
|
||||
|
||||
// === Act ===
|
||||
@@ -481,7 +550,7 @@ public class UnitFilterServiceTests
|
||||
.Distinct()
|
||||
.AsQueryable());
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var service = CreateSut(mocks, logger);
|
||||
|
||||
// === Act ===
|
||||
@@ -593,7 +662,7 @@ public class UnitFilterServiceTests
|
||||
.Distinct()
|
||||
.AsQueryable());
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
|
||||
var service = CreateSut(mocks, logger);
|
||||
|
||||
// === Act ===
|
||||
|
||||
@@ -18,4 +18,11 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Разрешаем тестовой сборке видеть internal классы PARR.Core -->
|
||||
<InternalsVisibleTo Include="PARR.Test" />
|
||||
<!-- Разрешаем Castle DynamicProxy (Moq / NSubstitute) видеть internal классы PARR.Core -->
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -18,6 +18,7 @@ using PARR.Domain.Settings;
|
||||
using PARR.TemplateMatcher.Models;
|
||||
using PARR.TemplateMatcher.Services.Interfaces;
|
||||
using PARR.TemplateMatcher.Settings;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations;
|
||||
|
||||
@@ -99,8 +100,8 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
|
||||
return;
|
||||
}
|
||||
|
||||
// === Обычная логика для всех остальных Job ===
|
||||
logger.LogDebug("Начало синхронизации шаблонов для Job {JobId}", jobId);
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
logger.LogInformation("Начало синхронизации шаблонов для Job {JobId}", jobId);
|
||||
|
||||
// === Проверка: уже запущена? ===
|
||||
var existingStatus = await matchingStatusService.GetStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
|
||||
@@ -126,6 +127,8 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
|
||||
|
||||
try
|
||||
{
|
||||
// === ЭТАП 1: Загрузка Job ===
|
||||
var stageSw = Stopwatch.StartNew();
|
||||
var job = await jobService.Get()
|
||||
.AsNoTracking()
|
||||
.Include(j => j.AutoControl)
|
||||
@@ -142,48 +145,22 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
|
||||
await UpdateMatchingStatusAsync(jobId, "Job не найден");
|
||||
return;
|
||||
}
|
||||
stageSw.Stop();
|
||||
logger.LogDebug("[Perf] Job {JobId} | Этап: Загрузка Job | Время: {Ms} мс",
|
||||
jobId, stageSw.ElapsedMilliseconds);
|
||||
|
||||
// === Получение отфильтрованных юнитов с полной информацией ===
|
||||
// === ЭТАП 2: Фильтрация юнитов ===
|
||||
stageSw.Restart();
|
||||
var filteredUnits = await unitFilterService.GetUnitsByJobFilterAsync(jobId);
|
||||
if (filteredUnits == null || !filteredUnits.Any())
|
||||
{
|
||||
logger.LogInformation("Для Job {JobId} фильтры не дали Unit'ов.", jobId);
|
||||
stageSw.Stop();
|
||||
var filterCount = filteredUnits?.Count() ?? 0;
|
||||
logger.LogDebug("[Perf] Job {JobId} | Этап: Фильтрация юнитов | Время: {Ms} мс | Результат: {Count}",
|
||||
jobId, stageSw.ElapsedMilliseconds, filterCount);
|
||||
|
||||
var existingTemplatesForDeactivation = await templateService.Get()
|
||||
.AsNoTracking()
|
||||
.Include(t => t.UnitsInTemplate)
|
||||
.Where(t => t.JobId == jobId && t.StatusTypeId == TemplateStatusTypeEnum.Used)
|
||||
.ToListAsync();
|
||||
|
||||
await UpdateMatchingStatusAsync(jobId, $"Нет Unit'ов. Деактивация {existingTemplatesForDeactivation.Count} шаблонов...");
|
||||
|
||||
foreach (var unusedTemplate in existingTemplatesForDeactivation)
|
||||
{
|
||||
logger.LogInformation("Деактивация лишнего шаблона {TemplateId} (Job {JobId}, UnitId {UnitId}).", unusedTemplate.Id, jobId, unusedTemplate.UnitId);
|
||||
await templateDeactivator.DeactivateTemplateAsync(unusedTemplate, initiator);
|
||||
}
|
||||
|
||||
await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена: нет Unit'ов");
|
||||
await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
|
||||
logger.LogInformation("Синхронизация шаблонов завершена для Job {JobId}.", jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Извлекаем ID юнитов для последующих операций
|
||||
var unitIds = filteredUnits.Select(u => u.Id).ToList();
|
||||
|
||||
#if DEBUG
|
||||
// Отладка: проверить, есть ли юнит в unitIds
|
||||
if (unitIds.Contains(targetUnitId))
|
||||
{
|
||||
logger.LogDebug("Юнит {TargetUnitId} найден в unitIds.", targetUnitId);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Юнит {TargetUnitId} НЕ найден в unitIds.", targetUnitId);
|
||||
}
|
||||
#endif
|
||||
var unitIds = filteredUnits?.Select(u => u.Id).ToHashSet() ?? new HashSet<Guid>();
|
||||
|
||||
// === ЭТАП 3: Загрузка существующих шаблонов ===
|
||||
stageSw.Restart();
|
||||
var existingTemplates = await templateService.Get()
|
||||
.Include(t => t.UnitsInTemplate)
|
||||
.Include(t => t.Job)
|
||||
@@ -199,25 +176,48 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
|
||||
.Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Used)
|
||||
.ToList();
|
||||
|
||||
var existingUnitIds = existingUsedTemplates
|
||||
.Select(t => t.UnitId)
|
||||
.ToHashSet();
|
||||
var existingUnitIds = existingUsedTemplates.Select(t => t.UnitId).ToHashSet();
|
||||
stageSw.Stop();
|
||||
logger.LogDebug("[Perf] Job {JobId} | Этап: Загрузка шаблонов | Время: {Ms} мс | Используется: {Count}",
|
||||
jobId, stageSw.ElapsedMilliseconds, existingUsedTemplates.Count);
|
||||
|
||||
// === ЭТАП 4: Расчёт диффа (создание / деактивация / переименование) ===
|
||||
stageSw.Restart();
|
||||
|
||||
var newUnitIds = unitIds.Except(existingUnitIds).ToList();
|
||||
var unusedTemplates = existingUsedTemplates
|
||||
.Where(t => !unitIds.Contains(t.UnitId))
|
||||
.ToList();
|
||||
|
||||
// Проверка имён существующих шаблонов
|
||||
var templatesToRename = new List<(Template Template, string ExpectedName)>();
|
||||
foreach (var template in existingUsedTemplates)
|
||||
{
|
||||
if (!unitIds.Contains(template.UnitId))
|
||||
continue;
|
||||
|
||||
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(template);
|
||||
if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
templatesToRename.Add((template, expectedName));
|
||||
}
|
||||
}
|
||||
|
||||
stageSw.Stop();
|
||||
logger.LogDebug("[Perf] Job {JobId} | Этап: Расчёт диффа | Время: {Ms} мс | Создать: {Create}, Деактивировать: {Deactivate}, Переименовать: {Rename}",
|
||||
jobId, stageSw.ElapsedMilliseconds, newUnitIds.Count, unusedTemplates.Count, templatesToRename.Count);
|
||||
|
||||
// === ЭТАП 5: Создание новых шаблонов ===
|
||||
stageSw.Restart();
|
||||
foreach (var unitId in newUnitIds)
|
||||
{
|
||||
// Вычисляем флаги активности из конфигурации автоконтрола
|
||||
var isActiveTemplate = job.AutoControl?.InitUsedTemplateState ?? DefaultUsedTemplateState;
|
||||
var isActiveSchedule = job.AutoControl?.InitUsedScheduleState ?? DefaultUsedScheduleState;
|
||||
|
||||
var request = new TemplateAllocationRequest(
|
||||
TargetJob: job,
|
||||
TargetUnitId: unitId,
|
||||
TargetUnit: null, // Для простого шаблона не критично, нормализатор загрузит при необходимости
|
||||
TargetUnit: null,
|
||||
Index: null,
|
||||
UnitsInTemplate: new List<UnitInTemplateMessage>(),
|
||||
IsActiveTemplate: isActiveTemplate,
|
||||
@@ -226,17 +226,16 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
|
||||
|
||||
await templateAllocationService.AllocateAsync(request);
|
||||
}
|
||||
stageSw.Stop();
|
||||
logger.LogDebug("[Perf] Job {JobId} | Этап: Создание шаблонов | Время: {Ms} мс | Количество: {Count}",
|
||||
jobId, stageSw.ElapsedMilliseconds, newUnitIds.Count);
|
||||
|
||||
// === Обработка существующих шаблонов (проверка имени) ===
|
||||
foreach (var template in existingUsedTemplates)
|
||||
// === ЭТАП 6: Обновление имён существующих шаблонов ===
|
||||
stageSw.Restart();
|
||||
foreach (var (template, expectedName) in templatesToRename)
|
||||
{
|
||||
if (unitIds.Contains(template.UnitId))
|
||||
{
|
||||
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(template);
|
||||
if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogDebug("Шаблон {TemplateId} требует обновления имени: старое = '{OldName}', новое = '{NewName}'", template.Id, template.Name, expectedName);
|
||||
|
||||
logger.LogDebug("Шаблон {TemplateId} требует обновления имени: '{OldName}' → '{NewName}'",
|
||||
template.Id, template.Name, expectedName);
|
||||
|
||||
var updateRequest = new TemplateUpdaterMessage
|
||||
{
|
||||
@@ -250,34 +249,45 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
|
||||
Index = template.Index,
|
||||
StatusTypeId = TemplateStatusTypeEnum.Used,
|
||||
Initiator = initiator,
|
||||
UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона
|
||||
UnitsInTemplate = new List<UnitInTemplateMessage>()
|
||||
};
|
||||
|
||||
await templateMqPublisher.PublishUpdateAsync(updateRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
stageSw.Stop();
|
||||
logger.LogDebug("[Perf] Job {JobId} | Этап: Обновление имён | Время: {Ms} мс | Количество: {Count}",
|
||||
jobId, stageSw.ElapsedMilliseconds, templatesToRename.Count);
|
||||
|
||||
// === Деактивация лишних шаблонов ===
|
||||
// === ЭТАП 7: Деактивация лишних шаблонов ===
|
||||
stageSw.Restart();
|
||||
foreach (var unusedTemplate in unusedTemplates)
|
||||
{
|
||||
logger.LogInformation("Деактивация лишнего шаблона {TemplateId} (Job {JobId}, UnitId {UnitId}).", unusedTemplate.Id, jobId, unusedTemplate.UnitId);
|
||||
logger.LogInformation("Деактивация лишнего шаблона {TemplateId} (Job {JobId}, UnitId {UnitId}).",
|
||||
unusedTemplate.Id, jobId, unusedTemplate.UnitId);
|
||||
await templateDeactivator.DeactivateTemplateAsync(unusedTemplate, initiator);
|
||||
}
|
||||
stageSw.Stop();
|
||||
logger.LogDebug("[Perf] Job {JobId} | Этап: Деактивация | Время: {Ms} мс | Количество: {Count}",
|
||||
jobId, stageSw.ElapsedMilliseconds, unusedTemplates.Count);
|
||||
|
||||
// === ИТОГО ===
|
||||
totalSw.Stop();
|
||||
logger.LogInformation("[Perf] Job {JobId} | ИТОГО: {TotalMs} мс", jobId, totalSw.ElapsedMilliseconds);
|
||||
|
||||
// === Успешное завершение ===
|
||||
await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена успешно");
|
||||
await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
|
||||
logger.LogInformation("Синхронизация шаблонов завершена для Job {JobId}.", jobId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка при синхронизации Job {JobId}", jobId);
|
||||
totalSw.Stop();
|
||||
logger.LogError(ex, "Ошибка при синхронизации Job {JobId} через {ElapsedMs} мс", jobId, totalSw.ElapsedMilliseconds);
|
||||
await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator)
|
||||
{
|
||||
logger.LogWarning("SimpleTemplateSynchronizer: SyncTemplatesForJobGroup вызван для JobGroup {JobGroupId}. Это не поддерживаемая операция.", jobGroupId);
|
||||
|
||||
Reference in New Issue
Block a user