All order flow goes through Purchase BFF API. Also some refactorings.
This commit is contained in:
parent
2a81a080ba
commit
b0d4ae5a72
@ -235,6 +235,7 @@ services:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- urls__basket=http://basket.api
|
||||
- urls__catalog=http://catalog.api
|
||||
- urls__orders=http://ordering.api
|
||||
- urls__identity=http://identity.api #Local: You need to open your local dev-machine firewall at range 5100-5110.
|
||||
ports:
|
||||
- "5120:80"
|
||||
|
@ -15,10 +15,16 @@ namespace PurchaseBff.Config
|
||||
public class BasketOperations
|
||||
{
|
||||
public static string GetItemById(string id) => $"/api/v1/basket/{id}";
|
||||
public static string UpdateBasket() => $"/api/v1/basket";
|
||||
public static string UpdateBasket() => "/api/v1/basket";
|
||||
}
|
||||
|
||||
public class OrdersOperations
|
||||
{
|
||||
public static string GetOrderDraft() => "/api/v1/orders/draft";
|
||||
}
|
||||
|
||||
public string Basket { get; set; }
|
||||
public string Catalog { get; set; }
|
||||
public string Orders { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -64,6 +64,9 @@ namespace PurchaseBff.Controllers
|
||||
|
||||
// Step 1: Get the item from catalog
|
||||
var item = await _catalog.GetCatalogItem(data.CatalogItemId);
|
||||
|
||||
//item.PictureUri =
|
||||
|
||||
// Step 2: Get current basket status
|
||||
var currentBasket = (await _basket.GetById(data.BasketId)) ?? new BasketData(data.BasketId);
|
||||
// Step 3: Merge current status with new product
|
||||
|
@ -13,9 +13,11 @@ namespace PurchaseBff.Controllers
|
||||
public class OrderController : Controller
|
||||
{
|
||||
private readonly IBasketService _basketService;
|
||||
public OrderController(IBasketService basketService)
|
||||
private readonly IOrderApiClient _orderClient;
|
||||
public OrderController(IBasketService basketService, IOrderApiClient orderClient)
|
||||
{
|
||||
_basketService = basketService;
|
||||
_orderClient = orderClient;
|
||||
}
|
||||
|
||||
[Route("draft/{basketId}")]
|
||||
@ -33,8 +35,8 @@ namespace PurchaseBff.Controllers
|
||||
return BadRequest($"No basket found for id {basketId}");
|
||||
}
|
||||
|
||||
var order = _basketService.MapBasketToOrder(basket, isDraft: true);
|
||||
return Ok(order);
|
||||
var orderDraft = await _orderClient.GetOrderDraftFromBasket(basket);
|
||||
return Ok(orderDraft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,31 +44,6 @@ namespace PurchaseBff.Services
|
||||
int i = 0;
|
||||
}
|
||||
|
||||
public OrderData MapBasketToOrder(BasketData basket, bool isDraft)
|
||||
{
|
||||
var order = new OrderData
|
||||
{
|
||||
Total = 0,
|
||||
IsDraft = isDraft
|
||||
};
|
||||
|
||||
basket.Items.ForEach(x =>
|
||||
{
|
||||
order.OrderItems.Add(new OrderItemData()
|
||||
{
|
||||
ProductId = int.Parse(x.ProductId),
|
||||
|
||||
PictureUrl = x.PictureUrl,
|
||||
ProductName = x.ProductName,
|
||||
Units = x.Quantity,
|
||||
UnitPrice = x.UnitPrice
|
||||
});
|
||||
order.Total += (x.Quantity * x.UnitPrice);
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
async Task<string> GetUserTokenAsync()
|
||||
{
|
||||
var context = _httpContextAccessor.HttpContext;
|
||||
|
@ -11,6 +11,5 @@ namespace PurchaseBff.Services
|
||||
Task<BasketData> GetById(string id);
|
||||
Task Update(BasketData currentBasket);
|
||||
|
||||
OrderData MapBasketToOrder(BasketData basket, bool isDraft);
|
||||
}
|
||||
}
|
||||
|
13
src/BFFs/PurchaseBff/Services/IOrderApiClient.cs
Normal file
13
src/BFFs/PurchaseBff/Services/IOrderApiClient.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using PurchaseBff.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PurchaseBff.Services
|
||||
{
|
||||
public interface IOrderApiClient
|
||||
{
|
||||
Task<OrderData> GetOrderDraftFromBasket(BasketData basket);
|
||||
}
|
||||
}
|
37
src/BFFs/PurchaseBff/Services/OrderApiClient.cs
Normal file
37
src/BFFs/PurchaseBff/Services/OrderApiClient.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.eShopOnContainers.BuildingBlocks.Resilience.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using PurchaseBff.Config;
|
||||
using PurchaseBff.Models;
|
||||
|
||||
namespace PurchaseBff.Services
|
||||
{
|
||||
public class OrderApiClient : IOrderApiClient
|
||||
{
|
||||
|
||||
private readonly IHttpClient _apiClient;
|
||||
private readonly ILogger<OrderApiClient> _logger;
|
||||
private readonly UrlsConfig _urls;
|
||||
|
||||
public OrderApiClient(IHttpClient httpClient, ILogger<OrderApiClient> logger, IOptionsSnapshot<UrlsConfig> config)
|
||||
{
|
||||
_apiClient = httpClient;
|
||||
_logger = logger;
|
||||
_urls = config.Value;
|
||||
}
|
||||
|
||||
public async Task<OrderData> GetOrderDraftFromBasket(BasketData basket)
|
||||
{
|
||||
var url = _urls.Orders + UrlsConfig.OrdersOperations.GetOrderDraft();
|
||||
var response = await _apiClient.PostAsync<BasketData>(url, basket);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var jsonResponse = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<OrderData>(jsonResponse);
|
||||
}
|
||||
}
|
||||
}
|
@ -35,6 +35,7 @@ namespace PurchaseBff
|
||||
services.AddSingleton<IHttpClient, StandardHttpClient>();
|
||||
services.AddTransient<ICatalogService, CatalogService>();
|
||||
services.AddTransient<IBasketService, BasketService>();
|
||||
services.AddTransient<IOrderApiClient, OrderApiClient>();
|
||||
|
||||
services.AddOptions();
|
||||
services.Configure<UrlsConfig>(Configuration.GetSection("urls"));
|
||||
|
@ -2,6 +2,7 @@
|
||||
"urls": {
|
||||
"basket": "http://localhost:55105",
|
||||
"catalog": "http://localhost:55101",
|
||||
"identity": "http://localhost:55105"
|
||||
"orders": "http://localhost:55102",
|
||||
"identity": "http://localhost:55105"
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:57622/",
|
||||
"applicationUrl": "http://localhost:50920/",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
|
@ -66,6 +66,11 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API.Controllers
|
||||
}
|
||||
|
||||
var item = await _catalogContext.CatalogItems.SingleOrDefaultAsync(ci => ci.Id == id);
|
||||
|
||||
var baseUri = _settings.PicBaseUrl;
|
||||
var azureStorageEnabled = _settings.AzureStorageEnabled;
|
||||
item.FillProductUrl(baseUri, azureStorageEnabled: azureStorageEnabled);
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
return Ok(item);
|
||||
@ -244,13 +249,12 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API.Controllers
|
||||
private List<CatalogItem> ChangeUriPlaceholder(List<CatalogItem> items)
|
||||
{
|
||||
var baseUri = _settings.PicBaseUrl;
|
||||
var azureStorageEnabled = _settings.AzureStorageEnabled;
|
||||
|
||||
items.ForEach(catalogItem =>
|
||||
foreach (var item in items)
|
||||
{
|
||||
catalogItem.PictureUri = _settings.AzureStorageEnabled
|
||||
? baseUri + catalogItem.PictureFileName
|
||||
: baseUri.Replace("[0]", catalogItem.Id.ToString());
|
||||
});
|
||||
item.FillProductUrl(baseUri, azureStorageEnabled: azureStorageEnabled);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.eShopOnContainers.Services.Catalog.API.Model
|
||||
{
|
||||
public static class CatalogItemExtensions
|
||||
{
|
||||
public static void FillProductUrl(this CatalogItem item, string picBaseUrl, bool azureStorageEnabled)
|
||||
{
|
||||
item.PictureUri = azureStorageEnabled
|
||||
? picBaseUrl + item.PictureFileName
|
||||
: picBaseUrl.Replace("[0]", item.Id.ToString());
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Collections;
|
||||
using Ordering.API.Application.Models;
|
||||
using System.Linq;
|
||||
|
||||
namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands
|
||||
{
|
||||
@ -68,7 +69,7 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands
|
||||
string cardNumber, string cardHolderName, DateTime cardExpiration,
|
||||
string cardSecurityNumber, int cardTypeId) : this()
|
||||
{
|
||||
_orderItems = MapToOrderItems(basketItems);
|
||||
_orderItems = basketItems.ToOrderItemsDTO().ToList();
|
||||
UserId = userId;
|
||||
City = city;
|
||||
Street = street;
|
||||
@ -83,20 +84,6 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands
|
||||
CardExpiration = cardExpiration;
|
||||
}
|
||||
|
||||
private List<OrderItemDTO> MapToOrderItems(List<BasketItem> basketItems)
|
||||
{
|
||||
var result = new List<OrderItemDTO>();
|
||||
basketItems.ForEach((item) => {
|
||||
result.Add(new OrderItemDTO() {
|
||||
ProductId = int.TryParse(item.ProductId, out int id) ? id : -1,
|
||||
ProductName = item.ProductName,
|
||||
PictureUrl = item.PictureUrl,
|
||||
UnitPrice = item.UnitPrice,
|
||||
Units = item.Quantity
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public class OrderItemDTO
|
||||
{
|
||||
|
@ -0,0 +1,27 @@
|
||||
using MediatR;
|
||||
using Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate;
|
||||
using Ordering.API.Application.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using static Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands.CreateOrderCommand;
|
||||
|
||||
namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands
|
||||
{
|
||||
public class CreateOrderDraftCommand : IRequest<OrderDraftDTO>
|
||||
{
|
||||
|
||||
public string BuyerId { get; private set; }
|
||||
|
||||
public IEnumerable<BasketItem> Items { get; private set; }
|
||||
|
||||
public CreateOrderDraftCommand(string buyerId, IEnumerable<BasketItem> items)
|
||||
{
|
||||
BuyerId = buyerId;
|
||||
Items = items;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands
|
||||
{
|
||||
using Domain.AggregatesModel.OrderAggregate;
|
||||
using global::Ordering.API.Application.Models;
|
||||
using MediatR;
|
||||
using Microsoft.eShopOnContainers.Services.Ordering.API.Infrastructure.Services;
|
||||
using Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Idempotency;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using static Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands.CreateOrderCommand;
|
||||
|
||||
// Regular CommandHandler
|
||||
public class CreateOrderDraftCommandHandler
|
||||
: IRequestHandler<CreateOrderDraftCommand, OrderDraftDTO>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IIdentityService _identityService;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
// Using DI to inject infrastructure persistence Repositories
|
||||
public CreateOrderDraftCommandHandler(IMediator mediator, IIdentityService identityService)
|
||||
{
|
||||
_identityService = identityService ?? throw new ArgumentNullException(nameof(identityService));
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
}
|
||||
|
||||
public Task<OrderDraftDTO> Handle(CreateOrderDraftCommand message, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
var order = Order.NewDraft();
|
||||
var orderItems = message.Items.Select(i => i.ToOrderItemDTO());
|
||||
foreach (var item in orderItems)
|
||||
{
|
||||
order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units);
|
||||
}
|
||||
|
||||
return Task.FromResult(OrderDraftDTO.FromOrder(order));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class OrderDraftDTO
|
||||
{
|
||||
public IEnumerable<OrderItemDTO> OrderItems { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
|
||||
public static OrderDraftDTO FromOrder(Order order)
|
||||
{
|
||||
return new OrderDraftDTO()
|
||||
{
|
||||
OrderItems = order.OrderItems.Select(oi => new OrderItemDTO
|
||||
{
|
||||
Discount = oi.GetCurrentDiscount(),
|
||||
ProductId = oi.ProductId,
|
||||
UnitPrice = oi.GetUnitPrice(),
|
||||
PictureUrl = oi.GetPictureUri(),
|
||||
Units = oi.GetUnits(),
|
||||
ProductName = oi.GetOrderItemProductName()
|
||||
}),
|
||||
Total = order.GetTotal()
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
@ -5,6 +5,7 @@ using Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands;
|
||||
using Microsoft.eShopOnContainers.Services.Ordering.API.Application.Queries;
|
||||
using Microsoft.eShopOnContainers.Services.Ordering.API.Infrastructure.Services;
|
||||
using Ordering.API.Application.Commands;
|
||||
using Ordering.API.Application.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
@ -101,6 +102,14 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.API.Controllers
|
||||
|
||||
return Ok(cardTypes);
|
||||
}
|
||||
|
||||
[Route("draft")]
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> GetOrderDraftFromBasketData([FromBody] CreateOrderDraftCommand createOrderDraftCommand)
|
||||
{
|
||||
var draft = await _mediator.Send(createOrderDraftCommand);
|
||||
return Ok(draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,32 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using static Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands.CreateOrderCommand;
|
||||
|
||||
namespace Ordering.API.Application.Models
|
||||
{
|
||||
public static class BasketItemExtensions
|
||||
{
|
||||
public static IEnumerable<OrderItemDTO> ToOrderItemsDTO(this IEnumerable<BasketItem> basketItems)
|
||||
{
|
||||
foreach (var item in basketItems)
|
||||
{
|
||||
yield return item.ToOrderItemDTO();
|
||||
}
|
||||
}
|
||||
|
||||
public static OrderItemDTO ToOrderItemDTO(this BasketItem item)
|
||||
{
|
||||
return new OrderItemDTO()
|
||||
{
|
||||
ProductId = int.TryParse(item.ProductId, out int id) ? id : -1,
|
||||
ProductName = item.ProductName,
|
||||
PictureUrl = item.PictureUrl,
|
||||
UnitPrice = item.UnitPrice,
|
||||
Units = item.Quantity
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:5102/",
|
||||
"applicationUrl": "http://localhost:55102/",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
@ -19,7 +19,7 @@
|
||||
"Microsoft.eShopOnContainers.Services.Ordering.API": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "http://localhost:5000/api/environmentInfo/machinename",
|
||||
"launchUrl": "http://localhost:55102/",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
@ -25,6 +25,10 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
|
||||
|
||||
private string _description;
|
||||
|
||||
|
||||
// Draft orders have this set to true. Currently we don't check anywhere the draft status of an Order, but we could do it if needed
|
||||
private bool _isDraft;
|
||||
|
||||
// DDD Patterns comment
|
||||
// Using a private collection field, better for DDD Aggregate's encapsulation
|
||||
// so OrderItems cannot be added from "outside the AggregateRoot" directly to the collection,
|
||||
@ -34,12 +38,21 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
|
||||
|
||||
private int? _paymentMethodId;
|
||||
|
||||
protected Order() { _orderItems = new List<OrderItem>(); }
|
||||
public static Order NewDraft()
|
||||
{
|
||||
var order = new Order();
|
||||
order._isDraft = true;
|
||||
return order;
|
||||
}
|
||||
|
||||
protected Order() {
|
||||
_orderItems = new List<OrderItem>();
|
||||
_isDraft = false;
|
||||
}
|
||||
|
||||
public Order(string userId, Address address, int cardTypeId, string cardNumber, string cardSecurityNumber,
|
||||
string cardHolderName, DateTime cardExpiration, int? buyerId = null, int? paymentMethodId = null)
|
||||
string cardHolderName, DateTime cardExpiration, int? buyerId = null, int? paymentMethodId = null) : this()
|
||||
{
|
||||
_orderItems = new List<OrderItem>();
|
||||
_buyerId = buyerId;
|
||||
_paymentMethodId = paymentMethodId;
|
||||
_orderStatusId = OrderStatus.Submitted.Id;
|
||||
@ -92,12 +105,12 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
|
||||
}
|
||||
|
||||
public void SetAwaitingValidationStatus()
|
||||
{
|
||||
{
|
||||
if (_orderStatusId == OrderStatus.Submitted.Id)
|
||||
{
|
||||
AddDomainEvent(new OrderStatusChangedToAwaitingValidationDomainEvent(Id, _orderItems));
|
||||
_orderStatusId = OrderStatus.AwaitingValidation.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetStockConfirmedStatus()
|
||||
@ -108,7 +121,7 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
|
||||
|
||||
_orderStatusId = OrderStatus.StockConfirmed.Id;
|
||||
_description = "All the items were confirmed with available stock.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPaidStatus()
|
||||
@ -119,7 +132,7 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
|
||||
|
||||
_orderStatusId = OrderStatus.Paid.Id;
|
||||
_description = "The payment was performed at a simulated \"American Bank checking bank account endinf on XX35071\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetShippedStatus()
|
||||
@ -157,13 +170,13 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
|
||||
|
||||
var itemsStockRejectedDescription = string.Join(", ", itemsStockRejectedProductNames);
|
||||
_description = $"The product items don't have stock: ({itemsStockRejectedDescription}).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddOrderStartedDomainEvent(string userId, int cardTypeId, string cardNumber,
|
||||
string cardSecurityNumber, string cardHolderName, DateTime cardExpiration)
|
||||
{
|
||||
var orderStartedDomainEvent = new OrderStartedDomainEvent(this, userId, cardTypeId,
|
||||
var orderStartedDomainEvent = new OrderStartedDomainEvent(this, userId, cardTypeId,
|
||||
cardNumber, cardSecurityNumber,
|
||||
cardHolderName, cardExpiration);
|
||||
|
||||
|
@ -41,13 +41,7 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
|
||||
_pictureUrl = PictureUrl;
|
||||
}
|
||||
|
||||
public void SetPictureUri(string pictureUri)
|
||||
{
|
||||
if (!String.IsNullOrWhiteSpace(pictureUri))
|
||||
{
|
||||
_pictureUrl = pictureUri;
|
||||
}
|
||||
}
|
||||
public string GetPictureUri() => _pictureUrl;
|
||||
|
||||
public decimal GetCurrentDiscount()
|
||||
{
|
||||
|
@ -3,7 +3,7 @@
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:57625/",
|
||||
"applicationUrl": "http://localhost:50921/",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user