Added search and results profile persistence when logged in.

This commit is contained in:
Harrison Deng 2021-05-25 18:09:06 -05:00
parent bbb2d4bd04
commit 235196f8e5
31 changed files with 1047 additions and 466 deletions

View File

@ -12,20 +12,5 @@ namespace MultiShop.Shop.Framework
public int? PurchaseCount { get; set; }
public int? ReviewCount { get; set; }
public bool ConvertedPrices { get; set; }
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
ProductListing b = (ProductListing)obj;
return this.URL == b.URL;
}
public override int GetHashCode()
{
return URL.GetHashCode();
}
}
}

View File

@ -1,8 +1,10 @@
using System;
using MultiShop.Shared;
using MultiShop.Shared.Models;
namespace MultiShop.Shared
namespace MultiShop.Client.Extensions.Models
{
public static class ResultCategoryExtensions
public static class ResultProfileExtensions
{
public static int? CompareListings(this ResultsProfile.Category category, ProductListingInfo a, ProductListingInfo b)
{

View File

@ -0,0 +1,9 @@
using MultiShop.Shared.Models;
namespace MultiShop.Client.Extensions.Models
{
public static class SearchProfileExtensions
{
}
}

View File

@ -0,0 +1,26 @@
using System.Collections;
using System.Collections.Generic;
using Microsoft.AspNetCore.Components;
using MultiShop.Client.Pages;
using MultiShop.Shared.Models;
namespace MultiShop.Client.Listing
{
public abstract class ListingView : ComponentBase
{
public abstract Views View { get; }
[Parameter]
public IList<ProductListingInfo> Listings { get; set; }
[Parameter]
public Search.Status Status { get; set; }
private protected abstract string GetCategoryTag(ResultsProfile.Category category);
public ListingView(Search.Status status)
{
this.Status = status;
}
}
}

View File

@ -0,0 +1,102 @@
@using MultiShop.Shared.Models
@using Pages
@inherits ListingView
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Price</th>
<th scope="col">Shipping</th>
<th scope="col">Purchases</th>
<th scope="col">Rating</th>
<th scope="col">Reviews</th>
<th scope="col"></th>
</tr>
</thead>
@if (!Status.SearchConfiguring && !Status.Searching)
{
<tbody>
<Virtualize Items="@Listings" Context="product">
<tr>
<th scope="row" @key="product.Listing">
<div class="text-truncate">@product.Listing.Name</div>
<small>From @product.ShopName</small>
@if (product.Listing.ConvertedPrices)
{
<span class="ml-3 mr-1 badge badge-warning">Converted price</span>
}
@foreach (ResultsProfile.Category c in product.Tops)
{
<span class="mx-1 badge badge-primary">@GetCategoryTag(c)</span>
}
</th>
<td>
@if (product.Listing.UpperPrice != product.Listing.LowerPrice)
{
<div class="text-truncate">
@product.Listing.LowerPrice to @product.Listing.UpperPrice
</div>
}
else
{
<div class="text-truncate">
@GetOrNA(product.Listing.LowerPrice)
</div>
}
</td>
<td>
<div class="text-truncate">
@GetOrNA(product.Listing.Shipping)
</div>
</td>
<td>
<div class="text-truncate">
@GetOrNA(product.Listing.PurchaseCount)
</div>
</td>
<td>
<div class="text-truncate">
@(product.Listing.Rating != null ? string.Format("{0:P2}", product.Listing.Rating) : "N/A")
</div>
</td>
<td>@GetOrNA(product.Listing.ReviewCount)</td>
<td>
<a href="@product.Listing.URL" class="btn btn-primary" target="_blank">View</a>
</td>
</tr>
</Virtualize>
</tbody>
}
</table>
</div>
@code {
public override Views View => Views.Table;
public TableView(Search.Status status) : base(status)
{
}
private protected override string GetCategoryTag(ResultsProfile.Category c)
{
switch (c)
{
case ResultsProfile.Category.RatingPriceRatio:
return "Best rating to price ratio";
case ResultsProfile.Category.Price:
return "Lowest price";
case ResultsProfile.Category.Purchases:
return "Most purchases";
case ResultsProfile.Category.Reviews:
return "Most reviews";
}
throw new ArgumentException($"{c} does not have an associated string.");
}
private string GetOrNA(object data, string prepend = null, string append = null)
{
return data != null ? (prepend + data.ToString() + append) : "N/A";
}
}

View File

@ -0,0 +1,7 @@
namespace MultiShop.Client.Listing
{
public enum Views
{
Table
}
}

View File

@ -1,19 +1,16 @@
@page "/search/{Query?}"
@using Microsoft.Extensions.Configuration
@using MultiShop.Shared
@inject HttpClient Http
@inject IConfiguration Configuration
@inject IJSRuntime js
@using MultiShop.Client.Extensions.Models
<div class="my-2">
<div class="input-group my-2">
<input type="text" class="form-control" placeholder="What are you looking for?" aria-label="What are you looking for?" id="search-input" @bind="Query" @onkeyup="@(async (a) => {if (a.Code == "Enter" || a.Code == "NumpadEnter") await PerformSearch(Query);})" disabled="@searching">
<input type="text" class="form-control" placeholder="What are you looking for?" aria-label="What are you looking for?" id="search-input" @bind="Query" @onkeyup="@(async (a) => {if (a.Code == "Enter" || a.Code == "NumpadEnter") await PerformSearch(Query);})" disabled="@status.Searching" >
<div class="input-group-append">
<button class=@ToggleSearchConfigButtonCss type="button" @onclick="@(() => showSearchConfiguration = !showSearchConfiguration)" title="Configure"><span class="oi oi-cog align-text-top"></span></button>
<button class="btn btn-outline-primary" type="button" @onclick="@(async () => await PerformSearch(Query))" disabled="@searching">Search</button>
<ToggleableButton class="btn btn-outline-secondary" title="Configure" OnToggleCallback="@((t) => status.SearchConfiguring = t)"><span class="oi oi-cog align-text-top"></span></ToggleableButton>
<button class="btn btn-outline-primary" type="button" @onclick="@(async () => await PerformSearch(Query))" disabled="@status.Searching">Search</button>
</div>
</div>
@if (showSearchConfiguration)
@if (status.SearchConfiguring)
{
<div class="mb-2 mt-4 py-2">
<h4>Configuration</h4>
@ -24,8 +21,8 @@
<h6 class="card-subtitle mb-2 text-muted">How many results from each store?</h6>
<p class="card-text">This is the maximum number of results we gather for each store we have access to. The larger the result, the longer it takes to load search queries.</p>
<div class="form-group">
<label for="quantitySlider">Quantity: @activeProfile.maxResults</label>
<input class="form-control-range" type="range" id="quantitySlider" min="1" max="200" step="1" @bind="activeProfile.maxResults" @bind:event="oninput">
<label for="quantitySlider">Quantity: @activeSearchProfile.MaxResults</label>
<input class="form-control-range" type="range" id="quantitySlider" min="1" max="200" step="1" @bind="activeSearchProfile.MaxResults" @bind:event="oninput">
</div>
</div>
</div>
@ -38,10 +35,10 @@
<div class="input-group-prepend">
<label class="input-group-text" for="currency-select">Currency</label>
</div>
<select class="form-control custom-select" id="currency-select" @bind="activeProfile.currency">
<select class="form-control custom-select" id="currency-select" @bind="activeSearchProfile.Currency">
@foreach (Currency currency in Enum.GetValues<Currency>())
{
@if (currency == activeProfile.currency)
@if (currency == activeSearchProfile.Currency)
{
<option selected>@currency</option>
}
@ -60,11 +57,11 @@
<h6 class="card-subtitle mb-2 text-muted">We'll crop out the lower rated stuff.</h6>
<p class="card-text">We'll only show products that have a rating greater than or equal to the set minimum rating. Optionally, we can also show those that don't have rating.</p>
<div class="form-group">
<label for="ratingSlider">Minimum rating: @(string.Format("{0:P0}", activeProfile.minRating))</label>
<input class="form-control-range" type="range" id="ratingSlider" min="0" max="1" step="0.01" @bind="activeProfile.minRating" @bind:event="oninput">
<label for="ratingSlider">Minimum rating: @(string.Format("{0:P0}", activeSearchProfile.MinRating))</label>
<input class="form-control-range" type="range" id="ratingSlider" min="0" max="1" step="0.01" @bind="activeSearchProfile.MinRating" @bind:event="oninput">
</div>
<div class="form-group form-check">
<input class="form-check-input" type="checkbox" id="keepUnratedCheckbox" @bind="activeProfile.keepUnrated">
<input class="form-check-input" type="checkbox" id="keepUnratedCheckbox" @bind="activeSearchProfile.KeepUnrated">
<label class="form-check-label" for="keepUnratedCheckbox">Keep unrated results</label>
</div>
</div>
@ -77,11 +74,11 @@
<div class="input-group my-2">
<div class="input-group-prepend">
<div class="input-group-text">
<input type="checkbox" @bind="activeProfile.enableUpperPrice">
<input type="checkbox" @bind="activeSearchProfile.EnableUpperPrice">
</div>
<span class="input-group-text">Upper limit</span>
</div>
<input type="number" class="form-control" @bind="activeProfile.UpperPrice" disabled="@(!activeProfile.enableUpperPrice)">
<input type="number" class="form-control" @bind="activeSearchProfile.UpperPrice" disabled="@(!activeSearchProfile.EnableUpperPrice)">
<div class="input-group-append">
<span class="input-group-text">.00</span>
</div>
@ -90,7 +87,7 @@
<div class="input-group-prepend">
<span class="input-group-text">Lower limit</span>
</div>
<input type="number" class="form-control" @bind="activeProfile.lowerPrice">
<input type="number" class="form-control" @bind="activeSearchProfile.LowerPrice">
<div class="input-group-prepend">
<span class="input-group-text">.00</span>
</div>
@ -105,7 +102,7 @@
@foreach (string shop in Shops.Keys)
{
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id=@(shop + "Checkbox") @bind="activeProfile.shopStates[shop]" disabled="@(!activeProfile.shopStates.IsToggleable(shop))">
<input class="form-check-input" type="checkbox" id=@(shop + "Checkbox") @bind="@(activeSearchProfile.ShopStates[shop])" disabled="@(!activeSearchProfile.ShopStates.IsShopToggleable(shop))">
<label class="form-check-label" for=@(shop + "Checkbox")>@shop enabled</label>
</div>
}
@ -120,10 +117,10 @@
<div class="input-group-prepend">
<span class="input-group-text">Minimum purchases</span>
</div>
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.minPurchases">
<input type="number" class="form-control" min="0" step="1" @bind="activeSearchProfile.MinPurchases">
</div>
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id="keepNullPurchasesCheckbox" @bind="activeProfile.keepUnknownPurchaseCount">
<input class="form-check-input" type="checkbox" id="keepNullPurchasesCheckbox" @bind="activeSearchProfile.KeepUnknownPurchaseCount">
<label class="form-check-label" for="keepNullPurchasesCheckbox">Keep unknown listings</label>
</div>
</div>
@ -137,10 +134,10 @@
<div class="input-group-prepend">
<span class="input-group-text">Minimum reviews</span>
</div>
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.minReviews">
<input type="number" class="form-control" min="0" step="1" @bind="activeSearchProfile.MinReviews">
</div>
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id="keepNullRatingsCheckbox" @bind="activeProfile.keepUnknownRatingCount">
<input class="form-check-input" type="checkbox" id="keepNullRatingsCheckbox" @bind="activeSearchProfile.KeepUnknownRatingCount">
<label class="form-check-label" for="keepNullRatingsCheckbox">Keep unknown listings</label>
</div>
</div>
@ -153,17 +150,17 @@
<div class="input-group my-2">
<div class="input-group-prepend">
<span class="input-group-text">
<input type="checkbox" @bind="activeProfile.enableMaxShippingFee">
<input type="checkbox" @bind="activeSearchProfile.EnableMaxShippingFee">
</span>
<span class="input-group-text">Max shipping</span>
</div>
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.MaxShippingFee" disabled="@(!activeProfile.enableMaxShippingFee)">
<input type="number" class="form-control" min="0" step="1" @bind="activeSearchProfile.MaxShippingFee" disabled="@(!activeSearchProfile.EnableMaxShippingFee)">
<div class="input-group-append">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id="keepNullShipping" @bind="activeProfile.keepUnknownShipping">
<input class="form-check-input" type="checkbox" id="keepNullShipping" @bind="activeSearchProfile.KeepUnknownShipping">
<label class="form-check-label" for="keepNullShipping">Keep unknown listings</label>
</div>
</div>
@ -175,9 +172,9 @@
<div class="my-3 py-2">
<div class="d-inline-flex" style="width: 100%; border-bottom-style: solid; border-bottom-width: 1px; border-color: lightgray;">
<button type="button" class=@ToggleResultsConfigurationcss @onclick="@(() => showResultsConfiguration = !showResultsConfiguration)"><span class="oi oi-sort-descending"></span></button>
<ToggleableButton class="btn btn-outline-secondary btn-tab" OnToggleCallback="@(t => status.ResultsConfiguring = t)"><span class="oi oi-sort-descending"></span></ToggleableButton>
</div>
@if (showResultsConfiguration)
@if (status.ResultsConfiguring)
{
<div style="border-color: lightgray;" class="p-1">
<div class="card m-2" style="max-width: 23em;">
@ -197,7 +194,7 @@
<div class="d-flex flex-wrap" style="width: 100%; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: lightgray;">
<div class="align-self-end">
@if (searching)
@if (status.Searching)
{
@if (listings.Count != 0)
{
@ -216,7 +213,7 @@
}
else if (listings.Count != 0)
{
@if (organizing)
@if (status.Organizing)
{
<div class="spinner-border spinner-border-sm text-success my-auto mr-1" role="status">
<span class="sr-only">Loading...</span>
@ -228,7 +225,7 @@
<span class="text-muted">Looked through @resultsChecked listings and found @listings.Count viable results.</span>
}
}
else if (searched)
else if (status.Searched)
{
<span class="text-muted">We've found @resultsChecked listings and unfortunately none matched your search.</span>
}
@ -241,74 +238,7 @@
<div>
@if (listings.Count > 0)
{
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Price</th>
<th scope="col">Shipping</th>
<th scope="col">Purchases</th>
<th scope="col">Rating</th>
<th scope="col">Reviews</th>
<th scope="col"></th>
</tr>
</thead>
@if (!showSearchConfiguration && !searching) {
<tbody>
<Virtualize Items="@listings" Context="product">
<tr>
<th scope="row" @key="product.Listing">
<div class="text-truncate">@product.Listing.Name</div>
<small>From @product.ShopName</small>
@if (product.Listing.ConvertedPrices)
{
<span class="ml-3 mr-1 badge badge-warning">Converted price</span>
}
@foreach (ResultsProfile.Category c in product.Tops)
{
<span class="mx-1 badge badge-primary">@CategoryTags(c)</span>
}
</th>
<td>
@if (product.Listing.UpperPrice != product.Listing.LowerPrice)
{
<div class="text-truncate">
@product.Listing.LowerPrice to @product.Listing.UpperPrice
</div>
}
else
{
<div class="text-truncate">
@GetOrNA(product.Listing.LowerPrice)
</div>
}
</td>
<td>
<div class="text-truncate">
@GetOrNA(product.Listing.Shipping)
</div>
</td>
<td>
<div class="text-truncate">
@GetOrNA(product.Listing.PurchaseCount)
</div>
</td>
<td>
<div class="text-truncate">
@(product.Listing.Rating != null ? string.Format("{0:P2}", product.Listing.Rating) : "N/A")
</div>
</td>
<td>@GetOrNA(product.Listing.ReviewCount)</td>
<td>
<a href="@product.Listing.URL" class="btn btn-primary" target="_blank">View</a>
</td>
</tr>
</Virtualize>
</tbody>
}
</table>
</div>
@listingViews[CurrentView]
}
</div>
</div>

View File

@ -1,71 +1,95 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using MultiShop.Shared;
using Microsoft.AspNetCore.Components.Authorization;
using MultiShop.Client.Extensions.Models;
using MultiShop.Client.Listing;
using MultiShop.Shared.Models;
using MultiShop.Shop.Framework;
using SimpleLogger;
namespace MultiShop.Client.Pages
{
public partial class Search
public partial class Search : IAsyncDisposable
{
[CascadingParameter]
Task<AuthenticationState> AuthenticationStateTask { get; set; }
[CascadingParameter(Name = "Shops")]
public Dictionary<string, IShop> Shops { get; set; }
[Parameter]
public string Query { get; set; }
private SearchProfile activeProfile = new SearchProfile();
private ResultsProfile activeResultsProfile = new ResultsProfile();
[Inject]
private HttpClient Http { get; set; }
private bool showSearchConfiguration = false;
private bool showResultsConfiguration = false;
private Status status = new Status();
private string ToggleSearchConfigButtonCss
{
get => "btn btn-outline-secondary" + (showSearchConfiguration ? " active" : "");
}
private Dictionary<Views, ListingView> listingViews;
private string ToggleResultsConfigurationcss {
get => "btn btn-outline-secondary btn-tab" + (showResultsConfiguration ? " active" : "");
}
private Views CurrentView = Views.Table;
private bool searched = false;
private bool searching = false;
private bool organizing = false;
private SearchProfile activeSearchProfile;
private ResultsProfile activeResultsProfile;
private int resultsChecked = 0;
private List<ProductListingInfo> listings = new List<ProductListingInfo>();
protected override void OnInitialized()
{
foreach (string shop in Shops.Keys)
{
activeProfile.shopStates[shop] = true;
}
base.OnInitialized();
}
private int resultsChecked = 0;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
AuthenticationState authState = await AuthenticationStateTask;
listingViews = new Dictionary<Views, ListingView>() {
{Views.Table, new TableView(status)}
};
if (authState.User.Identity.IsAuthenticated) {
Logger.Log($"User \"{authState.User.Identity.Name}\" is authenticated. Checking for saved profiles.", LogLevel.Debug);
HttpResponseMessage searchProfileResponse = await Http.GetAsync("Profile/Search");
if (searchProfileResponse.IsSuccessStatusCode) {
activeSearchProfile = await searchProfileResponse.Content.ReadFromJsonAsync<SearchProfile>();
Logger.Log("Received: " + await searchProfileResponse.Content.ReadAsStringAsync());
Logger.Log("Serialized then deserialized: " + JsonSerializer.Serialize(activeSearchProfile));
} else {
Logger.Log("Could not load search profile from server. Using default.", LogLevel.Warning);
activeSearchProfile = new SearchProfile();
}
HttpResponseMessage resultsProfileResponse = await Http.GetAsync("Profile/Results");
if (resultsProfileResponse.IsSuccessStatusCode) {
activeResultsProfile = await resultsProfileResponse.Content.ReadFromJsonAsync<ResultsProfile>();
} else {
Logger.Log("Could not load results profile from server.", LogLevel.Debug);
activeResultsProfile = new ResultsProfile();
}
} else {
activeSearchProfile = new SearchProfile();
activeResultsProfile = new ResultsProfile();
}
activeSearchProfile.ShopStates.TotalShops = Shops.Count;
}
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (!string.IsNullOrEmpty(Query))
{
await PerformSearch(Query);
}
await base.OnParametersSetAsync();
}
private async Task PerformSearch(string query)
{
if (string.IsNullOrWhiteSpace(query)) return;
if (searching) return;
searching = true;
if (status.Searching) return;
status.Searching = true;
Logger.Log($"Received search request for \"{query}\".", LogLevel.Debug);
resultsChecked = 0;
listings.Clear();
@ -73,10 +97,10 @@ namespace MultiShop.Client.Pages
List<ProductListingInfo>>();
foreach (string shopName in Shops.Keys)
{
if (activeProfile.shopStates[shopName])
if (activeSearchProfile.ShopStates[shopName])
{
Logger.Log($"Querying \"{shopName}\" for products.");
Shops[shopName].SetupSession(query, activeProfile.currency);
Shops[shopName].SetupSession(query, activeSearchProfile.Currency);
int shopViableResults = 0;
await foreach (ProductListing listing in Shops[shopName])
{
@ -88,12 +112,12 @@ namespace MultiShop.Client.Pages
}
if (listing.Shipping == null && !activeProfile.keepUnknownShipping || (activeProfile.enableMaxShippingFee && listing.Shipping > activeProfile.MaxShippingFee)) continue;
if (listing.Shipping == null && !activeSearchProfile.KeepUnknownShipping || (activeSearchProfile.EnableMaxShippingFee && listing.Shipping > activeSearchProfile.MaxShippingFee)) continue;
float shippingDifference = listing.Shipping != null ? listing.Shipping.Value : 0;
if (!(listing.LowerPrice + shippingDifference >= activeProfile.lowerPrice && (!activeProfile.enableUpperPrice || listing.UpperPrice + shippingDifference <= activeProfile.UpperPrice))) continue;
if ((listing.Rating == null && !activeProfile.keepUnrated) && activeProfile.minRating > (listing.Rating == null ? 0 : listing.Rating)) continue;
if ((listing.PurchaseCount == null && !activeProfile.keepUnknownPurchaseCount) || activeProfile.minPurchases > (listing.PurchaseCount == null ? 0 : listing.PurchaseCount)) continue;
if ((listing.ReviewCount == null && !activeProfile.keepUnknownRatingCount) || activeProfile.minReviews > (listing.ReviewCount == null ? 0 : listing.ReviewCount)) continue;
if (!(listing.LowerPrice + shippingDifference >= activeSearchProfile.LowerPrice && (!activeSearchProfile.EnableUpperPrice || listing.UpperPrice + shippingDifference <= activeSearchProfile.UpperPrice))) continue;
if ((listing.Rating == null && !activeSearchProfile.KeepUnrated) && activeSearchProfile.MinRating > (listing.Rating == null ? 0 : listing.Rating)) continue;
if ((listing.PurchaseCount == null && !activeSearchProfile.KeepUnknownPurchaseCount) || activeSearchProfile.MinPurchases > (listing.PurchaseCount == null ? 0 : listing.PurchaseCount)) continue;
if ((listing.ReviewCount == null && !activeSearchProfile.KeepUnknownRatingCount) || activeSearchProfile.MinReviews > (listing.ReviewCount == null ? 0 : listing.ReviewCount)) continue;
ProductListingInfo info = new ProductListingInfo(listing, shopName);
listings.Add(info);
@ -119,7 +143,7 @@ namespace MultiShop.Client.Pages
}
shopViableResults += 1;
if (shopViableResults >= activeProfile.maxResults) break;
if (shopViableResults >= activeSearchProfile.MaxResults) break;
}
Logger.Log($"\"{shopName}\" has completed. There are {listings.Count} results in total.", LogLevel.Debug);
}
@ -128,8 +152,8 @@ namespace MultiShop.Client.Pages
Logger.Log($"Skipping {shopName} since it's disabled.");
}
}
searching = false;
searched = true;
status.Searching = false;
status.Searched = true;
int tagsAdded = 0;
foreach (ResultsProfile.Category c in greatest.Keys)
@ -145,10 +169,10 @@ namespace MultiShop.Client.Pages
await Organize(activeResultsProfile.Order);
}
private async Task Organize(List<ResultsProfile.Category> order)
private async Task Organize(IList<ResultsProfile.Category> order)
{
if (searching || listings.Count <= 1) return;
organizing = true;
if (status.Searching || listings.Count <= 1) return;
status.Organizing = true;
Comparison<ProductListingInfo> comparer = (a, b) =>
{
foreach (ResultsProfile.Category category in activeResultsProfile.Order)
@ -162,13 +186,15 @@ namespace MultiShop.Client.Pages
return 0;
};
Func<(int, int), Task<int>> partition = async (ilh) => {
Func<(int, int), Task<int>> partition = async (ilh) =>
{
ProductListingInfo swapTemp;
ProductListingInfo pivot = listings[ilh.Item2];
int lastSwap = ilh.Item1 - 1;
for (int j = ilh.Item1; j <= ilh.Item2 - 1; j++)
{
if (comparer.Invoke(listings[j], pivot) <= 0) {
if (comparer.Invoke(listings[j], pivot) <= 0)
{
lastSwap += 1;
swapTemp = listings[lastSwap];
listings[lastSwap] = listings[j];
@ -176,26 +202,29 @@ namespace MultiShop.Client.Pages
}
await Task.Yield();
}
swapTemp = listings[lastSwap+1];
listings[lastSwap+1] = listings[ilh.Item2];
swapTemp = listings[lastSwap + 1];
listings[lastSwap + 1] = listings[ilh.Item2];
listings[ilh.Item2] = swapTemp;
return lastSwap + 1;
};
Func<(int, int), Task> quickSort = async (ilh) => {
Func<(int, int), Task> quickSort = async (ilh) =>
{
Stack<(int, int)> iterativeStack = new Stack<(int, int)>();
iterativeStack.Push(ilh);
while (iterativeStack.Count > 0)
{
(int, int) lh = iterativeStack.Pop();
int p = await partition.Invoke((lh.Item1, lh.Item2));
if (p - 1 > lh.Item1) {
if (p - 1 > lh.Item1)
{
iterativeStack.Push((lh.Item1, p - 1));
}
if (p + 1 < lh.Item2) {
if (p + 1 < lh.Item2)
{
iterativeStack.Push((p + 1, lh.Item2));
}
@ -206,30 +235,26 @@ namespace MultiShop.Client.Pages
await quickSort((0, listings.Count - 1));
organizing = false;
status.Organizing = false;
StateHasChanged();
}
private string GetOrNA(object data, string prepend = null, string append = null)
public async ValueTask DisposeAsync()
{
return data != null ? (prepend + data.ToString() + append) : "N/A";
AuthenticationState authState = await AuthenticationStateTask;
if (authState.User.Identity.IsAuthenticated) {
await Http.PutAsJsonAsync("Profile/Search", activeSearchProfile);
await Http.PutAsJsonAsync("Profile/Results", activeResultsProfile);
}
}
private string CategoryTags(ResultsProfile.Category c)
public class Status
{
switch (c)
{
case ResultsProfile.Category.RatingPriceRatio:
return "Best rating to price ratio";
case ResultsProfile.Category.Price:
return "Lowest price";
case ResultsProfile.Category.Purchases:
return "Most purchases";
case ResultsProfile.Category.Reviews:
return "Most reviews";
}
throw new ArgumentException($"{c} does not have an associated string.");
public bool SearchConfiguring { get; set; }
public bool ResultsConfiguring { get; set; }
public bool Organizing { get; set; }
public bool Searching { get; set; }
public bool Searched { get; set; }
}
}
}

View File

@ -4,10 +4,4 @@ tbody > tr > th > div {
.table.table thead th {
border-top-style: none;
}
.btn.btn-tab {
border-bottom-style: none;
border-bottom-left-radius: 0em;
border-bottom-right-radius: 0em;
}

View File

@ -22,7 +22,6 @@ namespace MultiShop.Client
builder.Services.AddHttpClient("MultiShop.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)).AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
// Supply HttpClient instances that include access tokens when making requests to the server project
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("MultiShop.ServerAPI"));
Action<HttpClient> configureClient = client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);

View File

@ -22,7 +22,7 @@
@code {
[Parameter]
public List<TItem> Items { get; set; }
public IList<TItem> Items { get; set; }
[Parameter]
public string AdditionalListClasses { get; set; }

View File

@ -36,7 +36,7 @@
<a class="nav-link" href="authentication/profile">Hello, @auth.User.Identity.Name!</a>
</li>
<li class="nav-item">
<button class="nav-link btn btn-outline-primary" @onclick="BeginSignOut">Log out</button>
<button class="nav-link btn btn-link" @onclick="BeginSignOut">Log out</button>
</li>
</Authorized>
<NotAuthorized>

View File

@ -0,0 +1,28 @@
<button @attributes="AdditionalAttributes" class=@ButtonClasses type="button" @onclick="OnClick" title="Configure">@ChildContent</button>
@code {
[Parameter(CaptureUnmatchedValues = true)]
public IReadOnlyDictionary<string, object> AdditionalAttributes { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; }
[Parameter]
public EventCallback<bool> OnToggleCallback { get; set; }
private async Task OnClick() {
state = !state;
await OnToggleCallback.InvokeAsync(state);
}
private bool state;
private string ButtonClasses
{
get
{
IReadOnlyDictionary<string, object> t = AdditionalAttributes;
return (state ? "active " : "") + (AdditionalAttributes["class"] as string);
}
}
}

View File

@ -26,3 +26,9 @@ html, body {
right: 0.75rem;
top: 0.5rem;
}
.btn.btn-tab {
border-bottom-style: none;
border-bottom-left-radius: 0em;
border-bottom-right-radius: 0em;
}

View File

@ -0,0 +1,63 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using MultiShop.Server.Data;
using MultiShop.Server.Models;
using MultiShop.Shared.Models;
namespace MultiShop.Server.Controllers
{
[ApiController]
[Authorize]
[Route("[controller]")]
public class ProfileController : ControllerBase
{
private UserManager<ApplicationUser> userManager;
private ApplicationDbContext dbContext;
public ProfileController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext)
{
this.userManager = userManager;
this.dbContext = dbContext;
}
[HttpGet]
[Route("Search")]
public async Task<IActionResult> GetSearchProfile() {
ApplicationUser userModel = await userManager.GetUserAsync(User);
return Ok(userModel.SearchProfile);
}
[HttpGet]
[Route("Results")]
public async Task<IActionResult> GetResultsProfile() {
ApplicationUser userModel = await userManager.GetUserAsync(User);
return Ok(userModel.ResultsProfile);
}
[HttpPut]
[Route("Search")]
public async Task<IActionResult> PutSearchProfile(SearchProfile searchProfile) {
ApplicationUser userModel = await userManager.GetUserAsync(User);
if (userModel.SearchProfile.Id != searchProfile.Id || userModel.Id != searchProfile.ApplicationUserId) {
return BadRequest();
}
dbContext.Entry(userModel.SearchProfile).CurrentValues.SetValues(searchProfile);
await userManager.UpdateAsync(userModel);
return NoContent();
}
[HttpPut]
[Route("Results")]
public async Task<IActionResult> PutResultsProfile(ResultsProfile resultsProfile) {
ApplicationUser userModel = await userManager.GetUserAsync(User);
if (userModel.ResultsProfile.Id != resultsProfile.Id) {
return BadRequest();
}
dbContext.Entry(userModel.ResultsProfile).CurrentValues.SetValues(resultsProfile);
await userManager.UpdateAsync(userModel);
return NoContent();
}
}
}

View File

@ -16,10 +16,10 @@ namespace MultiShop.Server.Controllers
}
[HttpGet]
public IReadOnlyDictionary<string, string> GetPublicConfiguration() {
return new Dictionary<string, string> {
public IActionResult GetPublicConfiguration() {
return Ok(new Dictionary<string, string> {
{"IdentityServer:Registration", configuration["IdentityServer:Registration"]}
};
});
}
}
}

View File

@ -22,20 +22,22 @@ namespace MultiShop.Server.Controllers
this.shopAssemblyData = new Dictionary<string, byte[]>();
}
public IEnumerable<string> GetShopModuleNames() {
public IActionResult GetShopModuleNames() {
List<string> moduleNames = new List<string>();
ShopOptions options = configuration.GetSection(ShopOptions.Shop).Get<ShopOptions>();
foreach (string file in Directory.EnumerateFiles(options.Directory))
{
if (Path.GetExtension(file).ToLower().Equals(".dll") && !(options.Disabled != null && options.Disabled.Contains(Path.GetFileNameWithoutExtension(file)))) {
yield return Path.GetFileNameWithoutExtension(file);
moduleNames.Add(Path.GetFileNameWithoutExtension(file));
}
}
return Ok(moduleNames);
}
[HttpGet]
[Route("{shopModuleName}")]
public ActionResult GetModule(string shopModuleName) {
public IActionResult GetModule(string shopModuleName) {
ShopOptions options = configuration.GetSection(ShopOptions.Shop).Get<ShopOptions>();
string shopPath = Path.Join(options.Directory, shopModuleName);
shopPath += ".dll";

View File

@ -7,15 +7,44 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MultiShop.Shared.Models;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace MultiShop.Server.Data
{
public class ApplicationDbContext : ApiAuthorizationDbContext<ApplicationUser>
{
public ApplicationDbContext(
DbContextOptions options,
IOptions<OperationalStoreOptions> operationalStoreOptions) : base(options, operationalStoreOptions)
public ApplicationDbContext(DbContextOptions options, IOptions<OperationalStoreOptions> operationalStoreOptions) : base(options, operationalStoreOptions)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ResultsProfile>()
.Property(e => e.Order)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<List<ResultsProfile.Category>>(v, null),
new ValueComparer<IList<ResultsProfile.Category>>(
(a, b) => a.SequenceEqual(b),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => (IList<ResultsProfile.Category>) c.ToList()
)
);
modelBuilder.Entity<SearchProfile>()
.Property(e => e.ShopStates)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<SearchProfile.ShopToggler>(v, null),
new ValueComparer<SearchProfile.ShopToggler>(
(a, b) => a.Equals(b),
c => c.GetHashCode(),
c => c.Clone()
)
);
}
}
}

View File

@ -1,86 +1,22 @@
// <auto-generated />
using System;
using MultiShop.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MultiShop.Server.Data;
namespace MultiShop.Server.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("00000000000000_CreateIdentitySchema")]
partial class CreateIdentitySchema
[Migration("20210525224658_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.0-rc.1.20417.2");
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
.HasAnnotation("ProductVersion", "5.0.6");
modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.DeviceFlowCodes", b =>
{
@ -317,6 +253,154 @@ namespace MultiShop.Server.Data.Migrations
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<string>("Order")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ResultsProfile");
});
modelBuilder.Entity("MultiShop.Shared.Models.SearchProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<bool>("EnableMaxShippingFee")
.HasColumnType("INTEGER");
b.Property<bool>("EnableUpperPrice")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnknownPurchaseCount")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnknownRatingCount")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnknownShipping")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnrated")
.HasColumnType("INTEGER");
b.Property<int>("LowerPrice")
.HasColumnType("INTEGER");
b.Property<int>("MaxResults")
.HasColumnType("INTEGER");
b.Property<int>("MaxShippingFee")
.HasColumnType("INTEGER");
b.Property<int>("MinPurchases")
.HasColumnType("INTEGER");
b.Property<float>("MinRating")
.HasColumnType("REAL");
b.Property<int>("MinReviews")
.HasColumnType("INTEGER");
b.Property<string>("ShopStates")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UpperPrice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchProfile");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -367,6 +451,29 @@ namespace MultiShop.Server.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
.WithOne("ResultsProfile")
.HasForeignKey("MultiShop.Shared.Models.ResultsProfile", "ApplicationUserId");
});
modelBuilder.Entity("MultiShop.Shared.Models.SearchProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
.WithOne("SearchProfile")
.HasForeignKey("MultiShop.Shared.Models.SearchProfile", "ApplicationUserId");
});
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Navigation("ResultsProfile")
.IsRequired();
b.Navigation("SearchProfile")
.IsRequired();
});
#pragma warning restore 612, 618
}
}

View File

@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace MultiShop.Server.Data.Migrations
{
public partial class CreateIdentitySchema : Migration
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
@ -191,6 +191,60 @@ namespace MultiShop.Server.Data.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ResultsProfile",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: true),
Order = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ResultsProfile", x => x.Id);
table.ForeignKey(
name: "FK_ResultsProfile_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "SearchProfile",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: true),
Currency = table.Column<int>(type: "INTEGER", nullable: false),
MaxResults = table.Column<int>(type: "INTEGER", nullable: false),
MinRating = table.Column<float>(type: "REAL", nullable: false),
KeepUnrated = table.Column<bool>(type: "INTEGER", nullable: false),
EnableUpperPrice = table.Column<bool>(type: "INTEGER", nullable: false),
UpperPrice = table.Column<int>(type: "INTEGER", nullable: false),
LowerPrice = table.Column<int>(type: "INTEGER", nullable: false),
MinPurchases = table.Column<int>(type: "INTEGER", nullable: false),
KeepUnknownPurchaseCount = table.Column<bool>(type: "INTEGER", nullable: false),
MinReviews = table.Column<int>(type: "INTEGER", nullable: false),
KeepUnknownRatingCount = table.Column<bool>(type: "INTEGER", nullable: false),
EnableMaxShippingFee = table.Column<bool>(type: "INTEGER", nullable: false),
MaxShippingFee = table.Column<int>(type: "INTEGER", nullable: false),
KeepUnknownShipping = table.Column<bool>(type: "INTEGER", nullable: false),
ShopStates = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SearchProfile", x => x.Id);
table.ForeignKey(
name: "FK_SearchProfile_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
@ -253,6 +307,18 @@ namespace MultiShop.Server.Data.Migrations
name: "IX_PersistedGrants_SubjectId_SessionId_Type",
table: "PersistedGrants",
columns: new[] { "SubjectId", "SessionId", "Type" });
migrationBuilder.CreateIndex(
name: "IX_ResultsProfile_ApplicationUserId",
table: "ResultsProfile",
column: "ApplicationUserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SearchProfile_ApplicationUserId",
table: "SearchProfile",
column: "ApplicationUserId",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
@ -278,6 +344,12 @@ namespace MultiShop.Server.Data.Migrations
migrationBuilder.DropTable(
name: "PersistedGrants");
migrationBuilder.DropTable(
name: "ResultsProfile");
migrationBuilder.DropTable(
name: "SearchProfile");
migrationBuilder.DropTable(
name: "AspNetRoles");

View File

@ -1,9 +1,9 @@
// <auto-generated />
using System;
using MultiShop.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MultiShop.Server.Data;
namespace MultiShop.Server.Data.Migrations
{
@ -14,71 +14,7 @@ namespace MultiShop.Server.Data.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.0-rc.1.20417.2");
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
.HasAnnotation("ProductVersion", "5.0.6");
modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.DeviceFlowCodes", b =>
{
@ -315,6 +251,154 @@ namespace MultiShop.Server.Data.Migrations
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<string>("Order")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ResultsProfile");
});
modelBuilder.Entity("MultiShop.Shared.Models.SearchProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<bool>("EnableMaxShippingFee")
.HasColumnType("INTEGER");
b.Property<bool>("EnableUpperPrice")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnknownPurchaseCount")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnknownRatingCount")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnknownShipping")
.HasColumnType("INTEGER");
b.Property<bool>("KeepUnrated")
.HasColumnType("INTEGER");
b.Property<int>("LowerPrice")
.HasColumnType("INTEGER");
b.Property<int>("MaxResults")
.HasColumnType("INTEGER");
b.Property<int>("MaxShippingFee")
.HasColumnType("INTEGER");
b.Property<int>("MinPurchases")
.HasColumnType("INTEGER");
b.Property<float>("MinRating")
.HasColumnType("REAL");
b.Property<int>("MinReviews")
.HasColumnType("INTEGER");
b.Property<string>("ShopStates")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UpperPrice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchProfile");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -365,6 +449,29 @@ namespace MultiShop.Server.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
.WithOne("ResultsProfile")
.HasForeignKey("MultiShop.Shared.Models.ResultsProfile", "ApplicationUserId");
});
modelBuilder.Entity("MultiShop.Shared.Models.SearchProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
.WithOne("SearchProfile")
.HasForeignKey("MultiShop.Shared.Models.SearchProfile", "ApplicationUserId");
});
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Navigation("ResultsProfile")
.IsRequired();
b.Navigation("SearchProfile")
.IsRequired();
});
#pragma warning restore 612, 618
}
}

View File

@ -1,12 +1,15 @@
using Microsoft.AspNetCore.Identity;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
using MultiShop.Shared.Models;
namespace MultiShop.Server.Models
{
public class ApplicationUser : IdentityUser
{
[Required]
public virtual SearchProfile SearchProfile { get; private set; } = new SearchProfile();
[Required]
public virtual ResultsProfile ResultsProfile { get; private set; } = new ResultsProfile();
}
}

View File

@ -11,6 +11,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="5.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="5.0.6" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.2" />
</ItemGroup>

View File

@ -10,6 +10,8 @@ using Microsoft.Extensions.Hosting;
using System.Linq;
using MultiShop.Server.Data;
using MultiShop.Server.Models;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
namespace MultiShop.Server
{
@ -26,9 +28,10 @@ namespace MultiShop.Server
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(
Configuration.GetConnectionString("DefaultConnection")));
services.AddDbContext<ApplicationDbContext>(options => {
options.UseLazyLoadingProxies();
options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"));
});
services.AddDatabaseDeveloperPageExceptionFilter();
@ -38,6 +41,8 @@ namespace MultiShop.Server
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
services.Configure<IdentityOptions>(Options => Options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier); //Note: Despite default, doesn't work without this.
services.AddAuthentication()
.AddIdentityServerJwt();

View File

@ -0,0 +1,72 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using MultiShop.Shared;
using MultiShop.Shop.Framework;
namespace MultiShop.Shared.Models
{
public class ProductListingInfo
{
public int Id { get; set; }
private ProductListing? cachedListing;
[Required]
private string _listing = null;
public ProductListing Listing
{
get
{
if (cachedListing == null) cachedListing = JsonSerializer.Deserialize<ProductListing>(_listing);
return cachedListing.Value;
}
set
{
_listing = JsonSerializer.Serialize(value);
cachedListing = value;
}
}
public string ShopName { get; private set; }
public float? RatingToPriceRatio
{
get
{
int reviewFactor = Listing.ReviewCount.HasValue ? Listing.ReviewCount.Value : 1;
int purchaseFactor = Listing.PurchaseCount.HasValue ? Listing.PurchaseCount.Value : 1;
return (Listing.Rating * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor)) / (Listing.LowerPrice * Listing.UpperPrice);
}
}
public ISet<ResultsProfile.Category> Tops { get; private set; } = new HashSet<ResultsProfile.Category>();
public ProductListingInfo(ProductListing listing, string shopName)
{
this.Listing = listing;
this.ShopName = shopName;
}
public ProductListingInfo()
{
}
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
ProductListingInfo other = (ProductListingInfo)obj;
return Id == other.Id && ShopName.Equals(other.ShopName) && Listing.Equals(other.Listing);
}
public override int GetHashCode()
{
return Id;
}
}
}

View File

@ -1,26 +1,28 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text.Json;
namespace MultiShop.Shared
namespace MultiShop.Shared.Models
{
public class ResultsProfile
{
public List<Category> Order { get; private set; } = new List<Category>(Enum.GetValues<Category>().Length);
public int Id { get; set; }
public string ApplicationUserId { get; set; }
[Required]
public IList<Category> Order { get; set; }
public ResultsProfile()
{
Order = new List<Category>(Enum.GetValues<Category>().Length);
foreach (Category category in Enum.GetValues<Category>())
{
Order.Add(category);
}
}
public Category GetCategory(int position)
{
return Order[position];
}
public enum Category
{
RatingPriceRatio,

View File

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using MultiShop.Shop.Framework;
namespace MultiShop.Shared.Models
{
public class SearchProfile
{
public int Id { get; set; }
public string ApplicationUserId { get; set; }
public Currency Currency { get; set; } = Currency.CAD;
public int MaxResults { get; set; } = 100;
public float MinRating { get; set; } = 0.8f;
public bool KeepUnrated { get; set; } = true;
public bool EnableUpperPrice { get; set; } = false;
private int _upperPrice;
public int UpperPrice
{
get
{
return _upperPrice;
}
set
{
if (EnableUpperPrice) _upperPrice = value;
}
}
public int LowerPrice { get; set; }
public int MinPurchases { get; set; }
public bool KeepUnknownPurchaseCount { get; set; } = true;
public int MinReviews { get; set; }
public bool KeepUnknownRatingCount { get; set; } = true;
public bool EnableMaxShippingFee { get; set; }
private int _maxShippingFee;
public int MaxShippingFee
{
get
{
return _maxShippingFee;
}
set
{
if (EnableMaxShippingFee) _maxShippingFee = value;
}
}
public bool KeepUnknownShipping { get; set; }
[Required]
public ShopToggler ShopStates { get; set; } = new ShopToggler();
public sealed class ShopToggler : HashSet<string>
{
public int TotalShops { get; set; }
public bool this[string name] {
get {
return !this.Contains(name);
}
set {
if (value == false && TotalShops - Count <= 1) return;
if (value)
{
this.Remove(name);
}
else
{
this.Add(name);
}
}
}
public ShopToggler Clone() {
ShopToggler clone = new ShopToggler();
clone.Union(this);
return clone;
}
public bool IsShopToggleable(string shop)
{
return (!Contains(shop) && TotalShops - Count > 1) || Contains(shop);
}
}
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
SearchProfile other = (SearchProfile) obj;
return
Id == other.Id &&
Currency == other.Currency &&
MaxResults == other.MaxResults &&
MinRating == other.MinRating &&
KeepUnrated == other.KeepUnrated &&
EnableUpperPrice == other.EnableUpperPrice &&
UpperPrice == other.UpperPrice &&
LowerPrice == other.LowerPrice &&
MinPurchases == other.MinPurchases &&
KeepUnknownPurchaseCount == other.KeepUnknownPurchaseCount &&
MinReviews == other.MinReviews &&
KeepUnknownRatingCount == other.KeepUnknownRatingCount &&
EnableMaxShippingFee == other.EnableMaxShippingFee &&
MaxShippingFee == other.MaxShippingFee &&
KeepUnknownShipping == other.KeepUnknownShipping &&
ShopStates.Equals(other.ShopStates);
}
public override int GetHashCode()
{
return Id;
}
}
}

View File

@ -1,25 +0,0 @@
using System.Collections.Generic;
using MultiShop.Shop.Framework;
namespace MultiShop.Shared
{
public class ProductListingInfo
{
public ProductListing Listing { get; private set; }
public string ShopName { get; private set; }
public float? RatingToPriceRatio {
get {
int reviewFactor = Listing.ReviewCount.HasValue ? Listing.ReviewCount.Value : 1;
int purchaseFactor = Listing.PurchaseCount.HasValue ? Listing.PurchaseCount.Value : 1;
return (Listing.Rating * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor))/(Listing.LowerPrice * Listing.UpperPrice);
}
}
public ISet<ResultsProfile.Category> Tops { get; private set; } = new HashSet<ResultsProfile.Category>();
public ProductListingInfo(ProductListing listing, string shopName)
{
this.Listing = listing;
this.ShopName = shopName;
}
}
}

View File

@ -1,89 +0,0 @@
using System.Collections.Generic;
using MultiShop.Shop.Framework;
namespace MultiShop.Shared
{
public class SearchProfile
{
public Currency currency;
public int maxResults;
public float minRating;
public bool keepUnrated;
public bool enableUpperPrice;
private int upperPrice;
public int UpperPrice
{
get
{
return upperPrice;
}
set
{
if (enableUpperPrice) upperPrice = value;
}
}
public int lowerPrice;
public int minPurchases;
public bool keepUnknownPurchaseCount;
public int minReviews;
public bool keepUnknownRatingCount;
public bool enableMaxShippingFee;
private int maxShippingFee;
public int MaxShippingFee {
get {
return maxShippingFee;
}
set {
if (enableMaxShippingFee) maxShippingFee = value;
}
}
public bool keepUnknownShipping;
public ShopStateTracker shopStates = new ShopStateTracker();
public SearchProfile()
{
currency = Currency.CAD;
maxResults = 100;
minRating = 0.8f;
keepUnrated = true;
enableUpperPrice = false;
upperPrice = 0;
lowerPrice = 0;
minPurchases = 0;
keepUnknownPurchaseCount = true;
minReviews = 0;
keepUnknownRatingCount = true;
enableMaxShippingFee = false;
maxShippingFee = 0;
keepUnknownShipping = true;
}
public class ShopStateTracker
{
private HashSet<string> shopsEnabled = new HashSet<string>();
public bool this[string name]
{
get
{
return shopsEnabled.Contains(name);
}
set
{
if (value == false && !(shopsEnabled.Count > 1)) return;
if (value)
{
shopsEnabled.Add(name);
}
else
{
shopsEnabled.Remove(name);
}
}
}
public bool IsToggleable(string shop) {
return (shopsEnabled.Contains(shop) && shopsEnabled.Count > 1) || !shopsEnabled.Contains(shop);
}
}
}
}