Admin page (#324)

* Updates based on documentation

* Getting the build passing

* Getting app functioning

* A few cleanups to confirm it's working as expected

* Fixing functional tests

* Updating dockerfile for 3.0

* Functional Tests now run sequentially

* Updating to latest version of moq

* Adding migration for post 3.0 upgrades

* Removing commented out lines

* Moving address and catalogitemordered configuration in to classes that own them

* Adding admin user

* Adding admin catalog screen

- will also only display menu option if user is logged in as an admin

* WIP - squash this

* Allow user to edit a catalog item

* Adding entry for new service

* Invalidating cache after catalog item update

- also a little bit of cleanup

* Fixing bad merge

* Removing Picture Uri and making Id readonly

* Adjusting style in menu dropdown so all options are shown

* Creating Cache helpers with unit tests
This commit is contained in:
Eric Fleming
2019-12-10 20:04:59 -07:00
committed by Steve Smith
parent 539d8c689d
commit f3f74a342e
20 changed files with 360 additions and 45 deletions

View File

@@ -0,0 +1,12 @@
namespace Microsoft.eShopWeb.ApplicationCore.Constants
{
public class AuthorizationConstants
{
public static class Roles
{
public const string ADMINISTRATORS = "Administrators";
}
public const string DEFAULT_PASSWORD = "Pass@word1";
}
}

View File

@@ -1,14 +1,23 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.eShopWeb.ApplicationCore.Constants;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Microsoft.eShopWeb.Infrastructure.Identity namespace Microsoft.eShopWeb.Infrastructure.Identity
{ {
public class AppIdentityDbContextSeed public class AppIdentityDbContextSeed
{ {
public static async Task SeedAsync(UserManager<ApplicationUser> userManager) public static async Task SeedAsync(UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager)
{ {
await roleManager.CreateAsync(new IdentityRole(AuthorizationConstants.Roles.ADMINISTRATORS));
var defaultUser = new ApplicationUser { UserName = "demouser@microsoft.com", Email = "demouser@microsoft.com" }; var defaultUser = new ApplicationUser { UserName = "demouser@microsoft.com", Email = "demouser@microsoft.com" };
await userManager.CreateAsync(defaultUser, "Pass@word1"); await userManager.CreateAsync(defaultUser, AuthorizationConstants.DEFAULT_PASSWORD);
string adminUserName = "admin@microsoft.com";
var adminUser = new ApplicationUser { UserName = adminUserName, Email = adminUserName };
await userManager.CreateAsync(adminUser, AuthorizationConstants.DEFAULT_PASSWORD);
adminUser = await userManager.FindByNameAsync(adminUserName);
await userManager.AddToRoleAsync(adminUser, AuthorizationConstants.Roles.ADMINISTRATORS);
} }
} }
} }

View File

@@ -0,0 +1,25 @@
using System;
namespace Microsoft.eShopWeb.Web.Extensions
{
public static class CacheHelpers
{
public static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromSeconds(30);
private static readonly string _itemsKeyTemplate = "items-{0}-{1}-{2}-{3}";
public static string GenerateCatalogItemCacheKey(int pageIndex, int itemsPage, int? brandId, int? typeId)
{
return string.Format(_itemsKeyTemplate, pageIndex, itemsPage, brandId, typeId);
}
public static string GenerateBrandsCacheKey()
{
return "brands";
}
public static string GenerateTypesCacheKey()
{
return "types";
}
}
}

View File

@@ -0,0 +1,10 @@
using Microsoft.eShopWeb.Web.ViewModels;
using System.Threading.Tasks;
namespace Microsoft.eShopWeb.Web.Interfaces
{
public interface ICatalogItemViewModelService
{
Task UpdateCatalogItem(CatalogItemViewModel viewModel);
}
}

View File

@@ -0,0 +1,38 @@
@page
@{
ViewData["Title"] = "Admin - Edit Catalog";
@model EditCatalogItemModel
}
<div class="container">
<div class="row">
<div class="col-md-8">
<img class="esh-catalog-thumbnail" src="@Model.CatalogModel.PictureUri" />
<form method="post">
<div class="form-group">
<label asp-for="CatalogModel.Name"></label>
<input asp-for="CatalogModel.Name" class="form-control" />
<span asp-validation-for="CatalogModel.Name"></span>
</div>
<div class="form-group">
<label asp-for="CatalogModel.Price"></label>
<input asp-for="CatalogModel.Price" class="form-control" />
<span asp-validation-for="CatalogModel.Price"></span>
</div>
<div class="form-group">
<label asp-for="CatalogModel.Id"></label>
<input asp-for="CatalogModel.Id" readonly class="form-control" />
<span asp-validation-for="CatalogModel.Id"></span>
</div>
<div class="col-md-2">
<input type="submit" value="Save" class="esh-catalog-button" />
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.eShopWeb.ApplicationCore.Constants;
using Microsoft.eShopWeb.Web.Interfaces;
using Microsoft.eShopWeb.Web.ViewModels;
using System.Threading.Tasks;
namespace Microsoft.eShopWeb.Web.Pages.Admin
{
[Authorize(Roles = AuthorizationConstants.Roles.ADMINISTRATORS)]
public class EditCatalogItemModel : PageModel
{
private readonly ICatalogItemViewModelService _catalogItemViewModelService;
public EditCatalogItemModel(ICatalogItemViewModelService catalogItemViewModelService)
{
_catalogItemViewModelService = catalogItemViewModelService;
}
[BindProperty]
public CatalogItemViewModel CatalogModel { get; set; } = new CatalogItemViewModel();
public async Task OnGet(CatalogItemViewModel catalogModel)
{
CatalogModel = catalogModel;
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
await _catalogItemViewModelService.UpdateCatalogItem(CatalogModel);
}
return RedirectToPage("/Admin/Index");
}
}
}

View File

@@ -0,0 +1,45 @@
@page
@{
ViewData["Title"] = "Admin - Catalog";
@model IndexModel
}
<section class="esh-catalog-hero">
<div class="container">
<img class="esh-catalog-title" src="~/images/main_banner_text.png" />
</div>
</section>
<section class="esh-catalog-filters">
<div class="container">
<form method="get">
<label class="esh-catalog-label" data-title="brand">
<select asp-for="@Model.CatalogModel.BrandFilterApplied" asp-items="@Model.CatalogModel.Brands" class="esh-catalog-filter"></select>
</label>
<label class="esh-catalog-label" data-title="type">
<select asp-for="@Model.CatalogModel.TypesFilterApplied" asp-items="@Model.CatalogModel.Types" class="esh-catalog-filter"></select>
</label>
<input class="esh-catalog-send" type="image" src="images/arrow-right.svg" />
</form>
</div>
</section>
<div class="container">
@if (Model.CatalogModel.CatalogItems.Any())
{
<partial name="_pagination" for="CatalogModel.PaginationInfo" />
<div class="esh-catalog-items row">
@foreach (var catalogItem in Model.CatalogModel.CatalogItems)
{
<div class="esh-catalog-item col-md-4">
<partial name="_editCatalog" for="@catalogItem" />
</div>
}
</div>
<partial name="_pagination" for="CatalogModel.PaginationInfo" />
}
else
{
<div class="esh-catalog-items row">
THERE ARE NO RESULTS THAT MATCH YOUR SEARCH
</div>
}
</div>

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.eShopWeb.ApplicationCore.Constants;
using Microsoft.eShopWeb.Web.Extensions;
using Microsoft.eShopWeb.Web.Services;
using Microsoft.eShopWeb.Web.ViewModels;
using Microsoft.Extensions.Caching.Memory;
using System.Threading.Tasks;
namespace Microsoft.eShopWeb.Web.Pages.Admin
{
[Authorize(Roles = AuthorizationConstants.Roles.ADMINISTRATORS)]
public class IndexModel : PageModel
{
private readonly ICatalogViewModelService _catalogViewModelService;
private readonly IMemoryCache _cache;
public IndexModel(ICatalogViewModelService catalogViewModelService, IMemoryCache cache)
{
_catalogViewModelService = catalogViewModelService;
_cache = cache;
}
public CatalogIndexViewModel CatalogModel { get; set; } = new CatalogIndexViewModel();
public async Task OnGet(CatalogIndexViewModel catalogModel, int? pageId)
{
var cacheKey = CacheHelpers.GenerateCatalogItemCacheKey(pageId.GetValueOrDefault(), Constants.ITEMS_PER_PAGE, catalogModel.BrandFilterApplied, catalogModel.TypesFilterApplied);
_cache.Remove(cacheKey);
CatalogModel = await _catalogViewModelService.GetCatalogItems(pageId.GetValueOrDefault(), Constants.ITEMS_PER_PAGE, catalogModel.BrandFilterApplied, catalogModel.TypesFilterApplied);
}
}
}

View File

@@ -1,26 +1,24 @@
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.eShopWeb.Web.Services; using Microsoft.eShopWeb.Web.Services;
using Microsoft.eShopWeb.Web.ViewModels; using Microsoft.eShopWeb.Web.ViewModels;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Microsoft.eShopWeb.Web.Pages namespace Microsoft.eShopWeb.Web.Pages
{ {
public class IndexModel : PageModel public class IndexModel : PageModel
{ {
private readonly ICatalogViewModelService _catalogViewModelService; private readonly ICatalogViewModelService _catalogViewModelService;
public IndexModel(ICatalogViewModelService catalogViewModelService) public IndexModel(ICatalogViewModelService catalogViewModelService)
{ {
_catalogViewModelService = catalogViewModelService; _catalogViewModelService = catalogViewModelService;
} }
public CatalogIndexViewModel CatalogModel { get; set; } = new CatalogIndexViewModel(); public CatalogIndexViewModel CatalogModel { get; set; } = new CatalogIndexViewModel();
public async Task OnGet(CatalogIndexViewModel catalogModel, int? pageId) public async Task OnGet(CatalogIndexViewModel catalogModel, int? pageId)
{ {
CatalogModel = await _catalogViewModelService.GetCatalogItems(pageId ?? 0, Constants.ITEMS_PER_PAGE, catalogModel.BrandFilterApplied, catalogModel.TypesFilterApplied); CatalogModel = await _catalogViewModelService.GetCatalogItems(pageId ?? 0, Constants.ITEMS_PER_PAGE, catalogModel.BrandFilterApplied, catalogModel.TypesFilterApplied);
} }
}
}
}
}

View File

@@ -0,0 +1,15 @@
@model CatalogItemViewModel
<form asp-page="/Admin/EditCatalogItem" method="get">
<div>
<img class="esh-catalog-thumbnail" src="@Model.PictureUri" />
<div class="esh-catalog-name">
<span>@Model.Name</span>
</div>
<input class="esh-catalog-button" type="submit" value="[ Edit ]" />
<input type="hidden" asp-for="@Model.Id" name="id" />
<input type="hidden" asp-for="@Model.Name" name="name" />
<input type="hidden" asp-for="@Model.PictureUri" name="pictureUri" />
<input type="hidden" asp-for="@Model.Price" name="price" />
</div>
</form>

View File

@@ -27,7 +27,8 @@ namespace Microsoft.eShopWeb.Web
await CatalogContextSeed.SeedAsync(catalogContext, loggerFactory); await CatalogContextSeed.SeedAsync(catalogContext, loggerFactory);
var userManager = services.GetRequiredService<UserManager<ApplicationUser>>(); var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
await AppIdentityDbContextSeed.SeedAsync(userManager); var roleManager = services.GetRequiredService<RoleManager<IdentityRole>>();
await AppIdentityDbContextSeed.SeedAsync(userManager, roleManager);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.eShopWeb.Web.ViewModels; using Microsoft.eShopWeb.Web.ViewModels;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using System; using Microsoft.eShopWeb.Web.Extensions;
namespace Microsoft.eShopWeb.Web.Services namespace Microsoft.eShopWeb.Web.Services
{ {
@@ -11,10 +11,6 @@ namespace Microsoft.eShopWeb.Web.Services
{ {
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly CatalogViewModelService _catalogViewModelService; private readonly CatalogViewModelService _catalogViewModelService;
private static readonly string _brandsKey = "brands";
private static readonly string _typesKey = "types";
private static readonly string _itemsKeyTemplate = "items-{0}-{1}-{2}-{3}";
private static readonly TimeSpan _defaultCacheDuration = TimeSpan.FromSeconds(30);
public CachedCatalogViewModelService(IMemoryCache cache, public CachedCatalogViewModelService(IMemoryCache cache,
CatalogViewModelService catalogViewModelService) CatalogViewModelService catalogViewModelService)
@@ -25,28 +21,29 @@ namespace Microsoft.eShopWeb.Web.Services
public async Task<IEnumerable<SelectListItem>> GetBrands() public async Task<IEnumerable<SelectListItem>> GetBrands()
{ {
return await _cache.GetOrCreateAsync(_brandsKey, async entry => return await _cache.GetOrCreateAsync(CacheHelpers.GenerateBrandsCacheKey(), async entry =>
{ {
entry.SlidingExpiration = _defaultCacheDuration; entry.SlidingExpiration = CacheHelpers.DefaultCacheDuration;
return await _catalogViewModelService.GetBrands(); return await _catalogViewModelService.GetBrands();
}); });
} }
public async Task<CatalogIndexViewModel> GetCatalogItems(int pageIndex, int itemsPage, int? brandId, int? typeId) public async Task<CatalogIndexViewModel> GetCatalogItems(int pageIndex, int itemsPage, int? brandId, int? typeId)
{ {
string cacheKey = String.Format(_itemsKeyTemplate, pageIndex, itemsPage, brandId, typeId); var cacheKey = CacheHelpers.GenerateCatalogItemCacheKey(pageIndex, Constants.ITEMS_PER_PAGE, brandId, typeId);
return await _cache.GetOrCreateAsync(cacheKey, async entry => return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{ {
entry.SlidingExpiration = _defaultCacheDuration; entry.SlidingExpiration = CacheHelpers.DefaultCacheDuration;
return await _catalogViewModelService.GetCatalogItems(pageIndex, itemsPage, brandId, typeId); return await _catalogViewModelService.GetCatalogItems(pageIndex, itemsPage, brandId, typeId);
}); });
} }
public async Task<IEnumerable<SelectListItem>> GetTypes() public async Task<IEnumerable<SelectListItem>> GetTypes()
{ {
return await _cache.GetOrCreateAsync(_typesKey, async entry => return await _cache.GetOrCreateAsync(CacheHelpers.GenerateTypesCacheKey(), async entry =>
{ {
entry.SlidingExpiration = _defaultCacheDuration; entry.SlidingExpiration = CacheHelpers.DefaultCacheDuration;
return await _catalogViewModelService.GetTypes(); return await _catalogViewModelService.GetTypes();
}); });
} }

View File

@@ -0,0 +1,31 @@
using Microsoft.eShopWeb.ApplicationCore.Entities;
using Microsoft.eShopWeb.ApplicationCore.Interfaces;
using Microsoft.eShopWeb.Web.Interfaces;
using Microsoft.eShopWeb.Web.ViewModels;
using System.Threading.Tasks;
namespace Microsoft.eShopWeb.Web.Services
{
public class CatalogItemViewModelService : ICatalogItemViewModelService
{
private readonly IAsyncRepository<CatalogItem> _catalogItemRepository;
public CatalogItemViewModelService(IAsyncRepository<CatalogItem> catalogItemRepository)
{
_catalogItemRepository = catalogItemRepository;
}
public async Task UpdateCatalogItem(CatalogItemViewModel viewModel)
{
//Get existing CatalogItem
var existingCatalogItem = await _catalogItemRepository.GetByIdAsync(viewModel.Id);
//Build updated CatalogItem
var updatedCatalogItem = existingCatalogItem;
updatedCatalogItem.Name = viewModel.Name;
updatedCatalogItem.Price = viewModel.Price;
await _catalogItemRepository.UpdateAsync(updatedCatalogItem);
}
}
}

View File

@@ -92,6 +92,7 @@ namespace Microsoft.eShopWeb.Web
services.AddScoped<IOrderService, OrderService>(); services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IOrderRepository, OrderRepository>(); services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<CatalogViewModelService>(); services.AddScoped<CatalogViewModelService>();
services.AddScoped<ICatalogItemViewModelService, CatalogItemViewModelService>();
services.Configure<CatalogSettings>(Configuration); services.Configure<CatalogSettings>(Configuration);
services.AddSingleton<IUriComposer>(new UriComposer(Configuration.Get<CatalogSettings>())); services.AddSingleton<IUriComposer>(new UriComposer(Configuration.Get<CatalogSettings>()));
services.AddScoped(typeof(IAppLogger<>), typeof(LoggerAdapter<>)); services.AddScoped(typeof(IAppLogger<>), typeof(LoggerAdapter<>));

View File

@@ -8,6 +8,13 @@
<img class="esh-identity-image" src="~/images/arrow-down.png"> <img class="esh-identity-image" src="~/images/arrow-down.png">
</section> </section>
<section class="esh-identity-drop"> <section class="esh-identity-drop">
@if (User.IsInRole("Administrators"))
{
<a class="esh-identity-item"
asp-page="/Admin/Index">
<div class="esh-identity-name esh-identity-name--upper">Admin</div>
</a>
}
<a class="esh-identity-item" <a class="esh-identity-item"
asp-controller="Order" asp-controller="Order"
asp-action="MyOrders"> asp-action="MyOrders">
@@ -18,8 +25,7 @@
asp-action="MyAccount"> asp-action="MyAccount">
<div class="esh-identity-name esh-identity-name--upper">My account</div> <div class="esh-identity-name esh-identity-name--upper">My account</div>
</a> </a>
<a class="esh-identity-item" <a class="esh-identity-item" href="javascript:document.getElementById('logoutForm').submit()">
href="javascript:document.getElementById('logoutForm').submit()">
<div class="esh-identity-name esh-identity-name--upper">Log Out</div> <div class="esh-identity-name esh-identity-name--upper">Log Out</div>
<img class="esh-identity-image" src="~/images/logout.png"> <img class="esh-identity-image" src="~/images/logout.png">
</a> </a>

View File

@@ -41,7 +41,7 @@
.esh-identity:hover .esh-identity-drop { .esh-identity:hover .esh-identity-drop {
border: 1px solid #EEEEEE; border: 1px solid #EEEEEE;
height: 10rem; height: 14rem;
transition: height 0.35s; transition: height 0.35s;
z-index: 10; z-index: 10;
} }

View File

@@ -64,8 +64,8 @@ namespace Microsoft.eShopWeb.FunctionalTests.Web.Controllers
// seed sample user data // seed sample user data
var userManager = scopedServices.GetRequiredService<UserManager<ApplicationUser>>(); var userManager = scopedServices.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scopedServices.GetRequiredService<RoleManager<IdentityRole>>();
AppIdentityDbContextSeed.SeedAsync(userManager).Wait(); AppIdentityDbContextSeed.SeedAsync(userManager, roleManager).Wait();
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -0,0 +1,16 @@
using Microsoft.eShopWeb.Web.Extensions;
using Xunit;
namespace Microsoft.eShopWeb.UnitTests.Web.Extensions.CacheHelpersTests
{
public class GenerateBrandsCacheKey_Should
{
[Fact]
public void ReturnBrandsCacheKey()
{
var result = CacheHelpers.GenerateBrandsCacheKey();
Assert.Equal("brands", result);
}
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.eShopWeb.Web;
using Microsoft.eShopWeb.Web.Extensions;
using Xunit;
namespace Microsoft.eShopWeb.UnitTests.Web.Extensions.CacheHelpersTests
{
public class GenerateCatalogItemCacheKey_Should
{
[Fact]
public void ReturnCatalogItemCacheKey()
{
var pageIndex = 0;
int? brandId = null;
int? typeId = null;
var result = CacheHelpers.GenerateCatalogItemCacheKey(pageIndex, Constants.ITEMS_PER_PAGE, brandId, typeId);
Assert.Equal("items-0-10--", result);
}
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.eShopWeb.Web.Extensions;
using Xunit;
namespace Microsoft.eShopWeb.UnitTests.Web.Extensions.CacheHelpersTests
{
public class GenerateTypesCacheKey_Should
{
[Fact]
public void ReturnTypesCacheKey()
{
var result = CacheHelpers.GenerateTypesCacheKey();
Assert.Equal("types", result);
}
}
}