Compare commits
26 Commits
4ed080f58a
...
9a12643eb6
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a12643eb6 | |||
| f9c0a8be55 | |||
| 77fde199e1 | |||
| 9d15dfe8a5 | |||
| c41c394f48 | |||
| 34d0741ac8 | |||
| 0f3fd320b0 | |||
| 3b6df031a6 | |||
| b00902e461 | |||
| 74f4d06031 | |||
| 5ee3ca2d99 | |||
| a62923c8d6 | |||
| 9901726f5a | |||
| b8074cfaf1 | |||
| dbfae5cdad | |||
| d02bebc6e2 | |||
| 9165f9d746 | |||
| 5a226bfcea | |||
| 5cefe1457f | |||
| f4390d992a | |||
| 906d99105c | |||
| 3b4671c8e5 | |||
| 534b254d0a | |||
| 2cd7c035eb | |||
| aa34bdd279 | |||
| 2de0b5bfa3 |
77
src/ReC.API/Controllers/OutResController.cs
Normal file
77
src/ReC.API/Controllers/OutResController.cs
Normal file
@ -0,0 +1,77 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ReC.API.Extensions;
|
||||
using ReC.Application.OutResults.Queries;
|
||||
|
||||
namespace ReC.API.Controllers;
|
||||
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class OutResController(IMediator mediator, IConfiguration config) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets output results based on the provided query parameters.
|
||||
/// </summary>
|
||||
/// <param name="query">The query to filter output results.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>A list of output results matching the query.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Get([FromQuery] ReadOutResQuery query, CancellationToken cancel) => Ok(await mediator.Send(query, cancel));
|
||||
|
||||
/// <summary>
|
||||
/// Gets output results for a fake/test profile.
|
||||
/// </summary>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>A list of output results for the fake profile.</returns>
|
||||
[HttpGet("fake")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Get(CancellationToken cancel) => Ok(await mediator.Send(new ReadOutResQuery()
|
||||
{
|
||||
ProfileId = config.GetFakeProfileId()
|
||||
}, cancel));
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific output result for a fake/test profile and action.
|
||||
/// </summary>
|
||||
/// <param name="actionId">The ID of the action to retrieve the result for.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <param name="resultType">Specifies which part of the result to return (Full, Header, or Body).</param>
|
||||
/// <returns>The requested output result or a part of it (header/body).</returns>
|
||||
[HttpGet("fake/{actionId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Get([FromRoute] long actionId, CancellationToken cancel, ResultType resultType = ResultType.Full)
|
||||
{
|
||||
var res = (await mediator.Send(new ReadOutResQuery()
|
||||
{
|
||||
ProfileId = config.GetFakeProfileId(),
|
||||
ActionId = actionId
|
||||
}, cancel)).First();
|
||||
|
||||
return resultType switch
|
||||
{
|
||||
ResultType.Body => res.Body is null ? Ok(new object { }) : Ok(res.Body.JsonToDynamic()),
|
||||
ResultType.Header => res.Header is null ? Ok(new object { }) : Ok(res.Header.JsonToDynamic()),
|
||||
_ => Ok(res),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the type of result to be returned from an output result query.
|
||||
/// </summary>
|
||||
public enum ResultType
|
||||
{
|
||||
/// <summary>
|
||||
/// Return the full result object.
|
||||
/// </summary>
|
||||
Full,
|
||||
/// <summary>
|
||||
/// Return only the header part of the result.
|
||||
/// </summary>
|
||||
Header,
|
||||
/// <summary>
|
||||
/// Return only the body part of the result.
|
||||
/// </summary>
|
||||
Body
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ReC.API.Extensions;
|
||||
using ReC.API.Models;
|
||||
using ReC.Application.RecActions.Commands;
|
||||
using ReC.Application.RecActions.Queries;
|
||||
@ -9,38 +10,68 @@ namespace ReC.API.Controllers;
|
||||
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class RecActionController(IMediator mediator) : ControllerBase
|
||||
public class RecActionController(IMediator mediator, IConfiguration config) : ControllerBase
|
||||
{
|
||||
private const long FakeProfileId = 2;
|
||||
|
||||
[HttpPost("invoke/{profileId}")]
|
||||
/// <summary>
|
||||
/// Invokes a batch of RecActions for a given profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The ID of the profile.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>An HTTP 202 Accepted response indicating the process has been started.</returns>
|
||||
[HttpPost("invoke/{cmd}")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> Invoke([FromRoute] int profileId, CancellationToken cancel)
|
||||
{
|
||||
await mediator.InvokeBatchRecAction(profileId, cancel);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a batch of RecActions for a fake/test profile.
|
||||
/// </summary>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>An HTTP 202 Accepted response indicating the process has been started.</returns>
|
||||
[HttpPost("invoke/fake")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> Invoke(CancellationToken cancel)
|
||||
{
|
||||
await mediator.InvokeBatchRecAction(FakeProfileId, cancel);
|
||||
await mediator.InvokeBatchRecAction(config.GetFakeProfileId(), cancel);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
#region CRUD
|
||||
/// <summary>
|
||||
/// Gets all RecActions for a given profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The ID of the profile.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>A list of RecActions for the specified profile.</returns>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get([FromQuery] long profileId, CancellationToken cancel) => Ok(await mediator.Send(new ReadRecActionQuery()
|
||||
{
|
||||
ProfileId = profileId
|
||||
}, cancel));
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Get([FromQuery] ReadRecActionQuery query, CancellationToken cancel) => Ok(await mediator.Send(query, cancel));
|
||||
|
||||
/// <summary>
|
||||
/// Gets all RecActions for a fake/test profile.
|
||||
/// </summary>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <param name="invoked"></param>
|
||||
/// <returns>A list of RecActions for the fake profile.</returns>
|
||||
[HttpGet("fake")]
|
||||
public async Task<IActionResult> Get(CancellationToken cancel) => Ok(await mediator.Send(new ReadRecActionQuery()
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Get(CancellationToken cancel, [FromQuery] bool invoked = false) => Ok(await mediator.Send(new ReadRecActionQuery()
|
||||
{
|
||||
ProfileId = FakeProfileId
|
||||
ProfileId = config.GetFakeProfileId(),
|
||||
Invoked = invoked
|
||||
}, cancel));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new RecAction.
|
||||
/// </summary>
|
||||
/// <param name="command">The command containing the details for the new RecAction.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>An HTTP 201 Created response.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateAction([FromBody] CreateRecActionCommand command, CancellationToken cancel)
|
||||
{
|
||||
await mediator.Send(command, cancel);
|
||||
@ -48,7 +79,17 @@ public class RecActionController(IMediator mediator) : ControllerBase
|
||||
return CreatedAtAction(nameof(CreateAction), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new fake RecAction for testing purposes.
|
||||
/// </summary>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <param name="request">The optional request body and header for the fake action.</param>
|
||||
/// <param name="endpointUri">The target endpoint URI.</param>
|
||||
/// <param name="endpointPath">The optional path to append to the endpoint URI.</param>
|
||||
/// <param name="type">The HTTP method type (e.g., GET, POST).</param>
|
||||
/// <returns>An HTTP 201 Created response.</returns>
|
||||
[HttpPost("fake")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateFakeAction(
|
||||
CancellationToken cancel,
|
||||
[FromBody] FakeRequest? request = null,
|
||||
@ -64,7 +105,7 @@ public class RecActionController(IMediator mediator) : ControllerBase
|
||||
|
||||
await mediator.Send(new CreateRecActionCommand()
|
||||
{
|
||||
ProfileId = FakeProfileId,
|
||||
ProfileId = config.GetFakeProfileId(),
|
||||
EndpointUri = endpointUri,
|
||||
Type = type,
|
||||
BodyQuery = $@"SELECT '{bodyJson ?? "NULL"}' AS REQUEST_BODY;",
|
||||
@ -76,23 +117,32 @@ public class RecActionController(IMediator mediator) : ControllerBase
|
||||
return CreatedAtAction(nameof(CreateFakeAction), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all RecActions associated with a specific profile.
|
||||
/// </summary>
|
||||
/// <param name="cmd"></param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>An HTTP 204 No Content response upon successful deletion.</returns>
|
||||
[HttpDelete]
|
||||
public async Task<IActionResult> Delete([FromQuery] int profileId, CancellationToken cancel)
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Delete([FromQuery] DeleteRecActionsCommand cmd, CancellationToken cancel)
|
||||
{
|
||||
await mediator.Send(new DeleteRecActionsCommand()
|
||||
{
|
||||
ProfileId = FakeProfileId
|
||||
}, cancel);
|
||||
|
||||
await mediator.Send(cmd, cancel);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all RecActions for a fake/test profile.
|
||||
/// </summary>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>An HTTP 204 No Content response upon successful deletion.</returns>
|
||||
[HttpDelete("fake")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Delete(CancellationToken cancel)
|
||||
{
|
||||
await mediator.Send(new DeleteRecActionsCommand()
|
||||
{
|
||||
ProfileId = FakeProfileId
|
||||
ProfileId = config.GetFakeProfileId()
|
||||
}, cancel);
|
||||
|
||||
return NoContent();
|
||||
|
||||
6
src/ReC.API/Extensions/ConfigExtensions.cs
Normal file
6
src/ReC.API/Extensions/ConfigExtensions.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace ReC.API.Extensions;
|
||||
|
||||
public static class ConfigurationExtensions
|
||||
{
|
||||
public static int GetFakeProfileId(this IConfiguration config) => config.GetValue("FakeProfileId", 2);
|
||||
}
|
||||
53
src/ReC.API/Extensions/JsonExtensions.cs
Normal file
53
src/ReC.API/Extensions/JsonExtensions.cs
Normal file
@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
|
||||
public static class JsonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserialize JSON string and automatically parse nested JSON strings.
|
||||
/// </summary>
|
||||
public static dynamic? JsonToDynamic(this string json)
|
||||
{
|
||||
// Deserialize the top-level JSON
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
// Recursively fix stringified JSON objects
|
||||
return JsonToDynamic(result);
|
||||
}
|
||||
|
||||
private static dynamic? JsonToDynamic(JsonElement obj)
|
||||
{
|
||||
switch (obj.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var dict = new Dictionary<string, dynamic?>();
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
dict[prop.Name] = JsonToDynamic(prop.Value);
|
||||
}
|
||||
return dict;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
var list = new List<dynamic>();
|
||||
foreach (var item in obj.EnumerateArray())
|
||||
{
|
||||
list.Add(JsonToDynamic(item));
|
||||
}
|
||||
return list;
|
||||
|
||||
case JsonValueKind.String:
|
||||
var str = obj.GetString();
|
||||
// Try to parse string as JSON
|
||||
if (!string.IsNullOrWhiteSpace(str) && (str.StartsWith('{') || str.StartsWith('[')))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonToDynamic(JsonSerializer.Deserialize<JsonElement>(str));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return str;
|
||||
default:
|
||||
return obj.GetRawText();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,5 +13,6 @@
|
||||
"RecAction": {
|
||||
"MaxConcurrentInvocations": 5
|
||||
},
|
||||
"AddedWho": "ReC.API"
|
||||
"AddedWho": "ReC.API",
|
||||
"FakeProfileId": 2
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
using AutoMapper;
|
||||
using ReC.Domain.Entities;
|
||||
using ReC.Domain.Entities;
|
||||
|
||||
namespace ReC.Application.Common.Dto;
|
||||
|
||||
@ -8,5 +7,6 @@ public class DtoMappingProfile : AutoMapper.Profile
|
||||
public DtoMappingProfile()
|
||||
{
|
||||
CreateMap<RecActionView, RecActionDto>();
|
||||
CreateMap<OutRes, OutResDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,37 @@
|
||||
namespace ReC.Application.OutResults.Queries;
|
||||
using AutoMapper;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ReC.Application.Common.Dto;
|
||||
using ReC.Domain.Entities;
|
||||
|
||||
public class ReadOutResQuery
|
||||
namespace ReC.Application.OutResults.Queries;
|
||||
|
||||
public record ReadOutResQuery : IRequest<IEnumerable<OutResDto>>
|
||||
{
|
||||
public long? ProfileId { get; set; }
|
||||
public long? ProfileId { get; init; }
|
||||
|
||||
public long? ActionId { get; set; }
|
||||
public long? ActionId { get; init; }
|
||||
}
|
||||
|
||||
public class ReadOutResHandler(IRepository<OutRes> repo, IMapper mapper) : IRequestHandler<ReadOutResQuery, IEnumerable<OutResDto>>
|
||||
{
|
||||
public async Task<IEnumerable<OutResDto>> Handle(ReadOutResQuery request, CancellationToken cancel)
|
||||
{
|
||||
var q = repo.Query;
|
||||
|
||||
if(request.ActionId is long actionId)
|
||||
q = q.Where(res => res.ActionId == actionId);
|
||||
|
||||
if(request.ProfileId is long profileId)
|
||||
q = q.Where(res => res.Action!.ProfileId == profileId);
|
||||
|
||||
var resList = await q.ToListAsync(cancel);
|
||||
|
||||
if (resList.Count == 0)
|
||||
throw new NotFoundException();
|
||||
|
||||
return mapper.Map<IEnumerable<OutResDto>>(resList);
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ public class ReadOutResQueryValidator : AbstractValidator<ReadOutResQuery>
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.ActionId.HasValue || x.ProfileId.HasValue)
|
||||
.WithMessage("At least one of ActionId or ProfileId must be provided.");
|
||||
.WithMessage("At least one of ActionId or ProfileId must be provided.")
|
||||
.WithName("Identifier");
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ namespace ReC.Application.RecActions.Commands;
|
||||
|
||||
public class DeleteRecActionsCommand : IRequest
|
||||
{
|
||||
public long ProfileId { get; init; } = 2;
|
||||
public required long ProfileId { get; init; }
|
||||
}
|
||||
|
||||
public class DeleteRecActionsCommandHandler(IRepository<RecAction> repo) : IRequestHandler<DeleteRecActionsCommand>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReC.Application.RecActions.Queries;
|
||||
|
||||
@ -12,11 +13,11 @@ public static class InvokeBatchRecActionsCommandExtensions
|
||||
=> sender.Send(new InvokeBatchRecActionsCommand { ProfileId = profileId }, cancel);
|
||||
}
|
||||
|
||||
public class InvokeRecActionsCommandHandler(ISender sender, IHttpClientFactory clientFactory, ILogger<InvokeRecActionsCommandHandler>? logger = null) : IRequestHandler<InvokeBatchRecActionsCommand>
|
||||
public class InvokeRecActionsCommandHandler(ISender sender, IServiceScopeFactory scopeFactory, IHttpClientFactory clientFactory, ILogger<InvokeRecActionsCommandHandler>? logger = null) : IRequestHandler<InvokeBatchRecActionsCommand>
|
||||
{
|
||||
public async Task Handle(InvokeBatchRecActionsCommand request, CancellationToken cancel)
|
||||
{
|
||||
var actions = await sender.Send(request.ToReadQuery(), cancel);
|
||||
var actions = await sender.Send(request.ToReadQuery(q => q.Invoked = false), cancel);
|
||||
|
||||
var http = clientFactory.CreateClient();
|
||||
|
||||
@ -27,7 +28,9 @@ public class InvokeRecActionsCommandHandler(ISender sender, IHttpClientFactory c
|
||||
await semaphore.WaitAsync(cancel);
|
||||
try
|
||||
{
|
||||
await sender.Send(action.ToInvokeCommand(), cancel);
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
await sender.Send(action.ToInvokeCommand(), cancel);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
|
||||
@ -12,13 +12,20 @@ public record ReadRecActionQueryBase
|
||||
{
|
||||
public long ProfileId { get; init; }
|
||||
|
||||
public ReadRecActionQuery ToReadQuery() => new(this);
|
||||
public ReadRecActionQuery ToReadQuery(Action<ReadRecActionQuery> modify)
|
||||
{
|
||||
ReadRecActionQuery query = new(this);
|
||||
modify(query);
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
public record ReadRecActionQuery : ReadRecActionQueryBase, IRequest<IEnumerable<RecActionDto>>
|
||||
{
|
||||
public ReadRecActionQuery(ReadRecActionQueryBase root) : base(root) { }
|
||||
|
||||
public bool? Invoked { get; set; } = null;
|
||||
|
||||
public ReadRecActionQuery() { }
|
||||
}
|
||||
|
||||
@ -26,7 +33,12 @@ public class ReadRecActionQueryHandler(IRepository<RecActionView> repo, IMapper
|
||||
{
|
||||
public async Task<IEnumerable<RecActionDto>> Handle(ReadRecActionQuery request, CancellationToken cancel)
|
||||
{
|
||||
var actions = await repo.Where(x => x.ProfileId == request.ProfileId).ToListAsync(cancel);
|
||||
var query = repo.Where(act => act.ProfileId == request.ProfileId);
|
||||
|
||||
if (request.Invoked is bool invoked)
|
||||
query = invoked ? query.Where(act => act.Root!.OutRes != null) : query.Where(act => act.Root!.OutRes == null);
|
||||
|
||||
var actions = await query.ToListAsync(cancel);
|
||||
|
||||
if(actions.Count == 0)
|
||||
throw new NotFoundException($"No actions found for the profile {request.ProfileId}.");
|
||||
|
||||
@ -14,6 +14,9 @@ public class OutRes
|
||||
[Column("ACTION_ID")]
|
||||
public long? ActionId { get; set; }
|
||||
|
||||
[ForeignKey("ActionId")]
|
||||
public RecAction? Action { get; set; }
|
||||
|
||||
[Column("RESULT_HEADER")]
|
||||
public string? Header { get; set; }
|
||||
|
||||
|
||||
@ -69,4 +69,6 @@ public class RecAction
|
||||
|
||||
[Column("CHANGED_WHEN")]
|
||||
public DateTime? ChangedWhen { get; set; }
|
||||
|
||||
public OutRes? OutRes { get; set; }
|
||||
}
|
||||
@ -17,9 +17,15 @@ public class RecActionView
|
||||
[Column("ACTION_ID")]
|
||||
public required long Id { get; set; }
|
||||
|
||||
[ForeignKey("Id")]
|
||||
public RecAction? Root { get; set; }
|
||||
|
||||
[Column("PROFILE_ID")]
|
||||
public long? ProfileId { get; set; }
|
||||
|
||||
[ForeignKey("ProfileId")]
|
||||
public Profile? Profile { get; set; }
|
||||
|
||||
[Column("PROFILE_NAME")]
|
||||
[MaxLength(100)]
|
||||
public string? ProfileName { get; set; }
|
||||
|
||||
@ -35,5 +35,10 @@ public class RecDbContext(DbContextOptions<RecDbContext> options) : DbContext(op
|
||||
modelBuilder.Entity<HeaderQueryResult>().HasNoKey();
|
||||
|
||||
modelBuilder.Entity<BodyQueryResult>().HasNoKey();
|
||||
|
||||
modelBuilder.Entity<RecAction>()
|
||||
.HasOne(act => act.OutRes)
|
||||
.WithOne(res => res.Action)
|
||||
.HasForeignKey<OutRes>(res => res.ActionId);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user