Fix/by approver silent failures #25
@@ -1,7 +1,9 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -25,10 +27,16 @@ namespace Tnsu.ApprovalRoute.Application.RequestManage;
|
|||||||
public sealed class RequestManageService(
|
public sealed class RequestManageService(
|
||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
ISharePointClient sharePoint,
|
ISharePointClient sharePoint,
|
||||||
ListPartitionRegistry partitionRegistry)
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
ILogger<RequestManageService> logger)
|
||||||
{
|
{
|
||||||
private const int DefaultPageSize = 20;
|
private const int DefaultPageSize = 20;
|
||||||
|
|
||||||
|
// Сколько типов заявок читаем из SharePoint одновременно. Без ограничения
|
||||||
|
// 18 типов × партиции разом упирались в троттлинг (429), а ошибки глотались
|
||||||
|
// и экран показывал пустой список.
|
||||||
|
private const int MaxParallelTypes = 3;
|
||||||
|
|
||||||
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
||||||
// requestManageService.ts (тот же самый список SharePoint, тот же провижининг).
|
// requestManageService.ts (тот же самый список SharePoint, тот же провижининг).
|
||||||
private const string ApproverField = "TansuSiteApprover";
|
private const string ApproverField = "TansuSiteApprover";
|
||||||
@@ -142,82 +150,186 @@ public sealed class RequestManageService(
|
|||||||
.Where(x => x.ListId != null && x.SiteUrl != null)
|
.Where(x => x.ListId != null && x.SiteUrl != null)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var statusFilter = string.Join(" or ", activeStatuses.Select(s => $"{StatusField} eq '{Escape(s)}'"));
|
|
||||||
var email = approverEmail.Trim();
|
var email = approverEmail.Trim();
|
||||||
|
|
||||||
var results = await Task.WhenAll(
|
// DbContext не потокобезопасен: архивные партиции читаем из БД ПОСЛЕДОВАТЕЛЬНО,
|
||||||
requestTypes.Select(rt => FindForTypeSafeAsync(rt, statusFilter, email, cancellationToken)));
|
// и только дальнейшее чтение SharePoint (без БД) идёт параллельно. Раньше
|
||||||
|
// GetArchivedAsync вызывался из параллельных задач — «вторая операция на
|
||||||
|
// контексте» падала, а пустой catch превращал это в пустой список.
|
||||||
|
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||||
|
foreach (var rt in requestTypes)
|
||||||
|
archivedByType[rt.Id] = await partitionRegistry.GetArchivedAsync(rt, cancellationToken);
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(results.SelectMany(r => r).ToList());
|
using var gate = new SemaphoreSlim(MaxParallelTypes);
|
||||||
|
|
||||||
|
// Search не поймать и не подменить обходом списков: если он недоступен, это
|
||||||
|
// видно по ошибке запроса, а не по тихо неполному результату.
|
||||||
|
var rows = await FindViaSearchAsync(requestTypes, archivedByType, activeStatuses, email, gate, cancellationToken);
|
||||||
|
return Result.Ok<IReadOnlyList<UserApprovalRowDto>>(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeSafeAsync(
|
private const string SearchApproverProperty = "TansuSiteApproverOWSUSER";
|
||||||
RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken)
|
private const string SearchStatusProperty = "ApprovalStatusOWSCHCS";
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await FindForTypeAsync(requestType, statusFilter, approverEmail, cancellationToken);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Один недоступный/переименованный список не должен обнулять результат по
|
|
||||||
// остальным типам заявок — та же терпимость, что и в ReminderService.
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IReadOnlyList<UserApprovalRowDto>> FindForTypeAsync(
|
private static readonly Regex ApprovalListSuffix = new(@"\s*—\s*согласование\s*$", RegexOptions.Compiled);
|
||||||
RequestType requestType, string statusFilter, string approverEmail, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var siteUrl = requestType.SiteUrl!;
|
|
||||||
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
|
|
||||||
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
|
||||||
|
|
||||||
var partitions = archived
|
private static UserApprovalRowDto ToUserRow(
|
||||||
.Select(x => (ListId: x.ListId, Title: x.Title))
|
ManagedApprovalRowDto r, RequestType requestType, string siteUrl, string dataListId, string approvalListTitle,
|
||||||
.Append((current.ListId, current.Title));
|
ManagedRequestSummaryDto? dataRow) =>
|
||||||
|
new(
|
||||||
|
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
|
||||||
|
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
|
||||||
|
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
|
||||||
|
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тот же результат, что и обход списков по партициям, но кандидатов находит ОДИН
|
||||||
|
/// запрос Search на сайт (по человеку и статусам, сразу по всем спискам
|
||||||
|
/// согласования): ListId/ListItemID из индекса. Остальные поля строк согласования
|
||||||
|
/// индекс отдаёт не все (нет TicketId, SLA, комментария), поэтому найденные
|
||||||
|
/// строки дочитываются по REST — только в тех списках, где они реально нашлись.
|
||||||
|
/// Индекс отстаёт от списков, поэтому статус и согласующий перепроверяются по REST.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<UserApprovalRowDto>> FindViaSearchAsync(
|
||||||
|
IReadOnlyList<RequestType> requestTypes,
|
||||||
|
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||||
|
IReadOnlyList<string> activeStatuses,
|
||||||
|
string approverEmail,
|
||||||
|
SemaphoreSlim gate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var email = approverEmail.Replace("\"", string.Empty);
|
||||||
|
var statusQuery = string.Join(
|
||||||
|
" OR ", activeStatuses.Select(s => $"{SearchStatusProperty}:\"{s.Replace("\"", string.Empty)}\""));
|
||||||
|
var kql = $"{SearchApproverProperty}:\"{email}\" ({statusQuery}) contentclass:STS_ListItem_GenericList";
|
||||||
|
|
||||||
var result = new List<UserApprovalRowDto>();
|
var result = new List<UserApprovalRowDto>();
|
||||||
foreach (var (dataListId, dataListTitle) in partitions)
|
foreach (var siteUrl in requestTypes.Select(x => x.SiteUrl!).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dataListTitle)) continue;
|
var hits = await sharePoint.SearchAsync(siteUrl, kql, ["Path", "ListItemID"], cancellationToken);
|
||||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
|
||||||
if (approvalListTitle is null) continue;
|
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> hits;
|
// Путь вида https://host/sites/x/Lists/L/DispForm.aspx?ID=5 → серверный url списка.
|
||||||
try
|
var byList = hits
|
||||||
{
|
.Select(h => (
|
||||||
hits = await sharePoint.GetItemsByListTitleAsync(
|
Url: ListUrlFromHitPath(h.GetValueOrDefault("Path")),
|
||||||
siteUrl, approvalListTitle, $"{ApprovalRowSelect}&$filter=({statusFilter})&$top=2000", cancellationToken);
|
Id: int.TryParse(h.GetValueOrDefault("ListItemID"), out var id) ? id : 0))
|
||||||
}
|
.Where(x => x.Url is not null && x.Id > 0)
|
||||||
catch
|
.GroupBy(x => x.Url!, StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
|
||||||
continue; // партиция сломана/переименована вручную — остальные не теряем.
|
|
||||||
}
|
|
||||||
|
|
||||||
var matched = hits
|
|
||||||
.Select(ToApprovalRow)
|
|
||||||
.Where(r => string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
if (matched.Count == 0) continue;
|
|
||||||
|
|
||||||
var ticketIds = matched.Select(r => r.TicketId).Distinct().ToList();
|
var siteTypes = requestTypes
|
||||||
var dataById = await ReadDataRowsAsync(siteUrl, dataListId, ticketIds, cancellationToken);
|
.Where(x => string.Equals(x.SiteUrl, siteUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
result.AddRange(matched.Select(r =>
|
var perList = await Task.WhenAll(byList.Select(async group =>
|
||||||
{
|
{
|
||||||
var dataRow = dataById.GetValueOrDefault(r.TicketId);
|
await gate.WaitAsync(cancellationToken);
|
||||||
return new UserApprovalRowDto(
|
try
|
||||||
r.Id, r.TicketId, r.ApproverId, r.ApproverName, r.ApproverEmail, r.Position, r.Status, r.DateIso,
|
{
|
||||||
r.Comment, r.Order, r.AllowReassign, r.Phase, r.SlaHours, r.AskOverdueReason,
|
return await ReadSearchHitListAsync(
|
||||||
requestType.Id, requestType.Name, siteUrl, dataListId, approvalListTitle,
|
siteUrl, group.Key, group.Select(x => x.Id).Distinct().ToList(), siteTypes, archivedByType,
|
||||||
dataRow?.Title ?? $"#{r.TicketId}", dataRow?.UniqueCode);
|
activeStatuses, approverEmail, cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
result.AddRange(perList.SelectMany(x => x));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пара «список согласования → (его заголовок, адрес парного списка данных)»
|
||||||
|
// не меняется, пока список не переименуют, — запоминаем на время жизни процесса,
|
||||||
|
// чтобы не тратить два запроса к SharePoint на каждый вызов. Ключ — адрес списка.
|
||||||
|
private static readonly ConcurrentDictionary<string, (string ApprovalTitle, string DataListUrl)> ListPairs =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private async Task<(string ApprovalTitle, string DataListUrl)?> ResolveListPairAsync(
|
||||||
|
string siteUrl, string approvalListUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ListPairs.TryGetValue(approvalListUrl, out var cached)) return cached;
|
||||||
|
|
||||||
|
var approvalList = await sharePoint.GetListByUrlAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||||
|
if (approvalList is null || !ApprovalListSuffix.IsMatch(approvalList.Title)) return null;
|
||||||
|
|
||||||
|
var dataTitle = ApprovalListSuffix.Replace(approvalList.Title, " — данные");
|
||||||
|
var dataList = await sharePoint.GetListByTitleAsync(siteUrl, dataTitle, cancellationToken);
|
||||||
|
if (dataList is null) return null;
|
||||||
|
|
||||||
|
var pair = (approvalList.Title, dataList.ServerRelativeUrl);
|
||||||
|
ListPairs[approvalListUrl] = pair;
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ListUrlFromHitPath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !Uri.TryCreate(path, UriKind.Absolute, out var uri)) return null;
|
||||||
|
|
||||||
|
var local = Uri.UnescapeDataString(uri.AbsolutePath);
|
||||||
|
var cut = local.LastIndexOf('/');
|
||||||
|
return cut > 0 ? local[..cut] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<UserApprovalRowDto>> ReadSearchHitListAsync(
|
||||||
|
string siteUrl,
|
||||||
|
string approvalListUrl,
|
||||||
|
IReadOnlyList<int> itemIds,
|
||||||
|
IReadOnlyList<RequestType> siteTypes,
|
||||||
|
IReadOnlyDictionary<int, IReadOnlyList<RequestListPartition>> archivedByType,
|
||||||
|
IReadOnlyList<string> activeStatuses,
|
||||||
|
string approverEmail,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pair = await ResolveListPairAsync(siteUrl, approvalListUrl, cancellationToken);
|
||||||
|
if (pair is null) return [];
|
||||||
|
var (approvalTitle, dataListUrl) = pair.Value;
|
||||||
|
|
||||||
|
// Какому типу заявок принадлежит эта партиция (текущая или архивная).
|
||||||
|
var requestType = siteTypes.FirstOrDefault(rt =>
|
||||||
|
string.Equals(rt.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| archivedByType[rt.Id].Any(a =>
|
||||||
|
string.Equals(a.ListId, dataListUrl, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
if (requestType is null) return [];
|
||||||
|
|
||||||
|
var rows = new List<ManagedApprovalRowDto>();
|
||||||
|
foreach (var chunk in itemIds.Chunk(50))
|
||||||
|
{
|
||||||
|
var filter = string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
||||||
|
var items = await sharePoint.GetItemsByListUrlAsync(
|
||||||
|
siteUrl, approvalListUrl, $"{ApprovalRowSelect}&$filter={filter}&$top={chunk.Length}",
|
||||||
|
cancellationToken);
|
||||||
|
rows.AddRange(items.Select(ToApprovalRow));
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched = rows
|
||||||
|
.Where(r => activeStatuses.Contains(r.Status)
|
||||||
|
&& string.Equals(r.ApproverEmail, approverEmail, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (matched.Count == 0) return [];
|
||||||
|
|
||||||
|
var dataById = await ReadDataRowsAsync(
|
||||||
|
siteUrl, dataListUrl, matched.Select(r => r.TicketId).Distinct().ToList(),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return matched
|
||||||
|
.Select(r => ToUserRow(
|
||||||
|
r, requestType, siteUrl, dataListUrl, approvalTitle,
|
||||||
|
dataById.GetValueOrDefault(r.TicketId)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "by-approver: список «{ApprovalListUrl}» из Search пропущен", approvalListUrl);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
|
private async Task<IReadOnlyDictionary<int, ManagedRequestSummaryDto>> ReadDataRowsAsync(
|
||||||
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
string siteUrl, string listId, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -303,8 +415,6 @@ public sealed class RequestManageService(
|
|||||||
return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal));
|
return words.All(w => haystack.Contains(w.ToLowerInvariant(), StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Escape(string value) => value.Replace("'", "''");
|
|
||||||
|
|
||||||
private static string? GetString(JsonElement e, string name) =>
|
private static string? GetString(JsonElement e, string name) =>
|
||||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ public interface ISharePointClient
|
|||||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
||||||
|
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
||||||
|
/// как «имя managed property → значение» (RowLimit — не больше 500).
|
||||||
|
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||||
|
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
|
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
|
||||||
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
|
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
|
||||||
@@ -220,6 +229,57 @@ public sealed class SharePointClient(
|
|||||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||||
|
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var body = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
request = new
|
||||||
|
{
|
||||||
|
Querytext = queryText,
|
||||||
|
RowLimit = 500,
|
||||||
|
SelectProperties = selectProperties,
|
||||||
|
TrimDuplicates = false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var json = await SendAsync(
|
||||||
|
HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body,
|
||||||
|
allowNotFound: false, cancellationToken);
|
||||||
|
|
||||||
|
var result = new List<IReadOnlyDictionary<string, string?>>();
|
||||||
|
if (json is null
|
||||||
|
|| !json.Value.TryGetProperty("PrimaryQueryResult", out var primary)
|
||||||
|
|| !primary.TryGetProperty("RelevantResults", out var relevant)
|
||||||
|
|| !relevant.TryGetProperty("Table", out var table)
|
||||||
|
|| !table.TryGetProperty("Rows", out var rows))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба.
|
||||||
|
if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped))
|
||||||
|
rows = wrapped;
|
||||||
|
if (rows.ValueKind != JsonValueKind.Array) return result;
|
||||||
|
|
||||||
|
foreach (var row in rows.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!row.TryGetProperty("Cells", out var cells)) continue;
|
||||||
|
if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells))
|
||||||
|
cells = wrappedCells;
|
||||||
|
|
||||||
|
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var cell in cells.EnumerateArray())
|
||||||
|
{
|
||||||
|
var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null;
|
||||||
|
if (key is null) continue;
|
||||||
|
dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public Task UpdateItemByListTitleAsync(
|
public Task UpdateItemByListTitleAsync(
|
||||||
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
|
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
|
||||||
MergeAsync(
|
MergeAsync(
|
||||||
|
|||||||
Reference in New Issue
Block a user