Merge pull request #2467 from MrTopCat/request-counter-fixed

Allow users with request limits to view the amount of requests they have remaining
This commit is contained in:
Jamie 2018-08-31 14:44:21 +01:00 committed by GitHub
commit a77ef6eafe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 287 additions and 21 deletions

3
.gitignore vendored
View file

@ -243,3 +243,6 @@ _Pvt_Extensions
# CAKE - C# Make # CAKE - C# Make
/Tools/* /Tools/*
*.db-journal *.db-journal
# Ignore local vscode config
*.vscode

View file

@ -17,7 +17,5 @@ namespace Ombi.Core.Engine.Interfaces
Task<RequestEngineResult> ApproveMovie(MovieRequests request); Task<RequestEngineResult> ApproveMovie(MovieRequests request);
Task<RequestEngineResult> ApproveMovieById(int requestId); Task<RequestEngineResult> ApproveMovieById(int requestId);
Task<RequestEngineResult> DenyMovieById(int modelId); Task<RequestEngineResult> DenyMovieById(int modelId);
} }
} }

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Ombi.Core.Models;
using Ombi.Core.Models.Requests; using Ombi.Core.Models.Requests;
using Ombi.Core.Models.UI; using Ombi.Core.Models.UI;
using Ombi.Store.Entities; using Ombi.Store.Entities;
@ -22,5 +23,6 @@ namespace Ombi.Core.Engine.Interfaces
Task<int> GetTotal(); Task<int> GetTotal();
Task UnSubscribeRequest(int requestId, RequestType type); Task UnSubscribeRequest(int requestId, RequestType type);
Task SubscribeToRequest(int requestId, RequestType type); Task SubscribeToRequest(int requestId, RequestType type);
Task<RequestQuotaCountModel> GetRemainingRequests();
} }
} }

View file

@ -19,6 +19,7 @@ using Ombi.Core.Settings;
using Ombi.Settings.Settings.Models; using Ombi.Settings.Settings.Models;
using Ombi.Store.Entities.Requests; using Ombi.Store.Entities.Requests;
using Ombi.Store.Repository; using Ombi.Store.Repository;
using Ombi.Core.Models;
namespace Ombi.Core.Engine namespace Ombi.Core.Engine
{ {
@ -484,5 +485,39 @@ namespace Ombi.Core.Engine
return new RequestEngineResult {Result = true, Message = $"{movieName} has been successfully added!"}; return new RequestEngineResult {Result = true, Message = $"{movieName} has been successfully added!"};
} }
public async Task<RequestQuotaCountModel> GetRemainingRequests()
{
OmbiUser user = await GetUser();
int limit = user.MovieRequestLimit ?? 0;
if (limit <= 0)
{
return new RequestQuotaCountModel()
{
HasLimit = false,
Limit = 0,
Remaining = 0,
NextRequest = DateTime.Now,
};
}
IQueryable<RequestLog> log = _requestLog.GetAll().Where(x => x.UserId == user.Id && x.RequestType == RequestType.Movie);
int count = limit - await log.CountAsync(x => x.RequestDate >= DateTime.UtcNow.AddDays(-7));
DateTime oldestRequestedAt = await log.Where(x => x.RequestDate >= DateTime.UtcNow.AddDays(-7))
.OrderBy(x => x.RequestDate)
.Select(x => x.RequestDate)
.FirstOrDefaultAsync();
return new RequestQuotaCountModel()
{
HasLimit = true,
Limit = limit,
Remaining = count,
NextRequest = oldestRequestedAt.AddDays(7),
};
}
} }
} }

View file

@ -23,6 +23,7 @@ using Ombi.Core.Settings;
using Ombi.Settings.Settings.Models; using Ombi.Settings.Settings.Models;
using Ombi.Store.Entities.Requests; using Ombi.Store.Entities.Requests;
using Ombi.Store.Repository; using Ombi.Store.Repository;
using Ombi.Core.Models;
namespace Ombi.Core.Engine namespace Ombi.Core.Engine
{ {
@ -608,9 +609,51 @@ namespace Ombi.Core.Engine
RequestDate = DateTime.UtcNow, RequestDate = DateTime.UtcNow,
RequestId = model.Id, RequestId = model.Id,
RequestType = RequestType.TvShow, RequestType = RequestType.TvShow,
EpisodeCount = model.SeasonRequests.Select(m => m.Episodes.Count).Sum(),
}); });
return new RequestEngineResult { Result = true }; return new RequestEngineResult { Result = true };
} }
public async Task<RequestQuotaCountModel> GetRemainingRequests()
{
OmbiUser user = await GetUser();
int limit = user.EpisodeRequestLimit ?? 0;
if (limit <= 0)
{
return new RequestQuotaCountModel()
{
HasLimit = false,
Limit = 0,
Remaining = 0,
NextRequest = DateTime.Now,
};
}
IQueryable<RequestLog> log = _requestLog.GetAll()
.Where(x => x.UserId == user.Id
&& x.RequestType == RequestType.TvShow
&& x.RequestDate >= DateTime.UtcNow.AddDays(-7));
// Needed, due to a bug which would cause all episode counts to be 0
int zeroEpisodeCount = await log.Where(x => x.EpisodeCount == 0).Select(x => x.EpisodeCount).CountAsync();
int episodeCount = await log.Where(x => x.EpisodeCount != 0).Select(x => x.EpisodeCount).SumAsync();
int count = limit - (zeroEpisodeCount + episodeCount);
DateTime oldestRequestedAt = await log.OrderBy(x => x.RequestDate)
.Select(x => x.RequestDate)
.FirstOrDefaultAsync();
return new RequestQuotaCountModel()
{
HasLimit = true,
Limit = limit,
Remaining = count,
NextRequest = oldestRequestedAt.AddDays(7),
};
}
} }
} }

View file

@ -0,0 +1,15 @@
using System;
namespace Ombi.Core.Models
{
public class RequestQuotaCountModel
{
public bool HasLimit { get; set; }
public int Limit { get; set; }
public int Remaining { get; set; }
public DateTime NextRequest { get; set; }
}
}

View file

@ -82,15 +82,20 @@ namespace Ombi.Core.Rule.Rules.Request
// Get the count of requests to be made // Get the count of requests to be made
foreach (var s in child.SeasonRequests) foreach (var s in child.SeasonRequests)
{ {
requestCount = s.Episodes.Count; requestCount += s.Episodes.Count;
} }
var tvLogs = requestLog.Where(x => x.RequestType == RequestType.TvShow); var tvLogs = requestLog.Where(x => x.RequestType == RequestType.TvShow);
// Count how many requests in the past 7 days // Count how many requests in the past 7 days
var tv = tvLogs.Where(x => x.RequestDate >= DateTime.UtcNow.AddDays(-7)); var tv = tvLogs.Where(x => x.RequestDate >= DateTime.UtcNow.AddDays(-7));
var count = await tv.Select(x => x.EpisodeCount).CountAsync();
count += requestCount; // Add the amount of requests in // Needed, due to a bug which would cause all episode counts to be 0
var zeroEpisodeCount = await tv.Where(x => x.EpisodeCount == 0).Select(x => x.EpisodeCount).CountAsync();
var episodeCount = await tv.Where(x => x.EpisodeCount != 0).Select(x => x.EpisodeCount).SumAsync();
var count = requestCount + episodeCount + zeroEpisodeCount; // Add the amount of requests in
if (count > episodeLimit) if (count > episodeLimit)
{ {
return Fail("You have exceeded your Episode request quota!"); return Fail("You have exceeded your Episode request quota!");

View file

@ -0,0 +1,6 @@
export interface IRemainingRequests {
hasLimit: boolean;
limit: number;
remaining: number;
nextRequest: Date;
}

View file

@ -80,6 +80,7 @@ export class MovieRequestsComponent implements OnInit {
}; };
this.loadInit(); this.loadInit();
this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser"); this.isAdmin = this.auth.hasRole("admin") || this.auth.hasRole("poweruser");
} }
public paginate(event: IPagenator) { public paginate(event: IPagenator) {

View file

@ -0,0 +1,18 @@
<div *ngIf="remaining?.hasLimit">
<h4 id="remainingRequests" class="text-center">
{{'Requests.Remaining.Quota' | translate: {remaining: remaining.remaining, total: remaining.limit} }}
</h4>
<h4 class="text-center" *ngIf="daysUntil > 1">
{{'Requests.Remaining.NextDays' | translate: {time: daysUntil} }}
</h4>
<h4 class="text-center" *ngIf="hoursUntil > 1 && daysUntil <= 1">
{{'Requests.Remaining.NextHours' | translate: {time: hoursUntil} }}
</h4>
<h4 class="text-center" *ngIf="minutesUntil >= 1 && hoursUntil <= 1 && daysUntil <= 1" #minutes>
{{(minutesUntil == 1 ? 'Requests.Remaining.NextMinute' : 'Requests.Remaining.NextMinutes') | translate: {time: minutesUntil} }}
</h4>
</div>
<br *ngIf="!remaining?.hasLimit" />
<br *ngIf="!remaining?.hasLimit" />

View file

@ -0,0 +1,67 @@
import { IRemainingRequests } from "../interfaces/IRemainingRequests";
import { RequestService } from "../services";
import { Component, Input, OnInit } from "@angular/core";
import { Observable } from "rxjs";
@Component({
selector: "remaining-requests",
templateUrl: "./remainingrequests.component.html",
})
export class RemainingRequestsComponent implements OnInit {
public remaining: IRemainingRequests;
@Input() public movie: boolean;
public daysUntil: number;
public hoursUntil: number;
public minutesUntil: number;
@Input() public quotaRefreshEvents: Observable<void>;
constructor(private requestService: RequestService) {
}
public ngOnInit() {
const self = this;
this.update();
this.quotaRefreshEvents.subscribe(() => {
this.update();
});
setInterval(() => {
self.update();
}, 60000);
}
public update(): void {
const callback = (remaining => {
this.remaining = remaining;
this.calculateTime();
});
if (this.movie) {
this.requestService.getRemainingMovieRequests().subscribe(callback);
} else {
this.requestService.getRemainingTvRequests().subscribe(callback);
}
}
private calculateTime(): void {
this.daysUntil = Math.ceil(this.daysUntilNextRequest());
this.hoursUntil = Math.ceil(this.hoursUntilNextRequest());
this.minutesUntil = Math.ceil(this.minutesUntilNextRequest());
}
private daysUntilNextRequest(): number {
return (new Date(this.remaining.nextRequest).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24;
}
private hoursUntilNextRequest(): number {
return (new Date(this.remaining.nextRequest).getTime() - new Date().getTime()) / 1000 / 60 / 60;
}
private minutesUntilNextRequest(): number {
return (new Date(this.remaining.nextRequest).getTime() - new Date().getTime()) / 1000 / 60;
}
}

View file

@ -0,0 +1,27 @@
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { NgbModule } from "@ng-bootstrap/ng-bootstrap";
import { SidebarModule, TooltipModule, TreeTableModule } from "primeng/primeng";
import { RequestService } from "../services";
@NgModule({
imports: [
FormsModule,
NgbModule.forRoot(),
TreeTableModule,
SidebarModule,
TooltipModule,
],
declarations: [
],
exports: [
RouterModule,
],
providers: [
RequestService,
],
})
export class SearchModule { }

View file

@ -1,5 +1,6 @@
<!-- Movie tab --> <!-- Movie tab -->
<div role="tabpanel" class="tab-pane active" id="MoviesTab"> <div role="tabpanel" class="tab-pane active" id="MoviesTab">
<div class="input-group"> <div class="input-group">
<input id="search" type="text" class="form-control form-control-custom form-control-search form-control-withbuttons" (keyup)="search($event)"> <input id="search" type="text" class="form-control form-control-custom form-control-search form-control-withbuttons" (keyup)="search($event)">
<div class="input-group-addon right-radius"> <div class="input-group-addon right-radius">
@ -18,8 +19,9 @@
<i class="fa fa-search"></i> <i class="fa fa-search"></i>
</div> </div>
</div> </div>
<br />
<br /> <remaining-requests [movie]="true" [quotaRefreshEvents]="movieRequested.asObservable()" #remainingFilms></remaining-requests>
<!-- Movie content --> <!-- Movie content -->
<div id="movieList"> <div id="movieList">
<div *ngIf="searchApplied && movieResults?.length <= 0" class='no-search-results'> <div *ngIf="searchApplied && movieResults?.length <= 0" class='no-search-results'>

View file

@ -17,8 +17,10 @@ export class MovieSearchComponent implements OnInit {
public searchText: string; public searchText: string;
public searchChanged: Subject<string> = new Subject<string>(); public searchChanged: Subject<string> = new Subject<string>();
public movieRequested: Subject<void> = new Subject<void>();
public movieResults: ISearchMovieResult[]; public movieResults: ISearchMovieResult[];
public result: IRequestEngineResult; public result: IRequestEngineResult;
public searchApplied = false; public searchApplied = false;
@Input() public issueCategories: IIssueCategory[]; @Input() public issueCategories: IIssueCategory[];
@ -35,7 +37,6 @@ export class MovieSearchComponent implements OnInit {
private notificationService: NotificationService, private authService: AuthService, private notificationService: NotificationService, private authService: AuthService,
private readonly translate: TranslateService, private sanitizer: DomSanitizer, private readonly translate: TranslateService, private sanitizer: DomSanitizer,
private readonly platformLocation: PlatformLocation) { private readonly platformLocation: PlatformLocation) {
this.searchChanged.pipe( this.searchChanged.pipe(
debounceTime(600), // Wait Xms after the last event before emitting last event debounceTime(600), // Wait Xms after the last event before emitting last event
distinctUntilChanged(), // only emit if value is different from previous value distinctUntilChanged(), // only emit if value is different from previous value
@ -69,6 +70,7 @@ export class MovieSearchComponent implements OnInit {
result: false, result: false,
errorMessage: "", errorMessage: "",
}; };
this.popularMovies(); this.popularMovies();
} }
@ -87,8 +89,8 @@ export class MovieSearchComponent implements OnInit {
try { try {
this.requestService.requestMovie({ theMovieDbId: searchResult.id }) this.requestService.requestMovie({ theMovieDbId: searchResult.id })
.subscribe(x => { .subscribe(x => {
this.movieRequested.next();
this.result = x; this.result = x;
if (this.result.result) { if (this.result.result) {
this.translate.get("Search.RequestAdded", { title: searchResult.title }).subscribe(x => { this.translate.get("Search.RequestAdded", { title: searchResult.title }).subscribe(x => {
this.notificationService.success(x); this.notificationService.success(x);

View file

@ -21,6 +21,7 @@ import { SearchService } from "../services";
import { AuthGuard } from "../auth/auth.guard"; import { AuthGuard } from "../auth/auth.guard";
import { RemainingRequestsComponent } from "../requests/remainingrequests.component";
import { SharedModule } from "../shared/shared.module"; import { SharedModule } from "../shared/shared.module";
const routes: Routes = [ const routes: Routes = [
@ -44,6 +45,7 @@ const routes: Routes = [
TvSearchComponent, TvSearchComponent,
SeriesInformationComponent, SeriesInformationComponent,
MovieSearchGridComponent, MovieSearchGridComponent,
RemainingRequestsComponent,
MusicSearchComponent, MusicSearchComponent,
ArtistSearchComponent, ArtistSearchComponent,
AlbumSearchComponent, AlbumSearchComponent,

View file

@ -1,4 +1,4 @@
import { Component, Input, OnInit} from "@angular/core"; import { Component, Input, OnInit } from "@angular/core";
import { NotificationService } from "../services"; import { NotificationService } from "../services";
import { RequestService } from "../services"; import { RequestService } from "../services";
@ -8,6 +8,8 @@ import { INewSeasonRequests, IRequestEngineResult, ISeasonsViewModel, ITvRequest
import { IEpisodesRequests } from "../interfaces"; import { IEpisodesRequests } from "../interfaces";
import { ISearchTvResult } from "../interfaces"; import { ISearchTvResult } from "../interfaces";
import { Subject } from "rxjs";
@Component({ @Component({
selector: "seriesinformation", selector: "seriesinformation",
templateUrl: "./seriesinformation.component.html", templateUrl: "./seriesinformation.component.html",
@ -18,7 +20,7 @@ export class SeriesInformationComponent implements OnInit {
public result: IRequestEngineResult; public result: IRequestEngineResult;
public series: ISearchTvResult; public series: ISearchTvResult;
public requestedEpisodes: IEpisodesRequests[] = []; public requestedEpisodes: IEpisodesRequests[] = [];
@Input() public tvRequested: Subject<void>;
@Input() private seriesId: number; @Input() private seriesId: number;
constructor(private searchService: SearchService, private requestService: RequestService, private notificationService: NotificationService) { } constructor(private searchService: SearchService, private requestService: RequestService, private notificationService: NotificationService) { }
@ -62,6 +64,7 @@ export class SeriesInformationComponent implements OnInit {
this.requestService.requestTv(viewModel) this.requestService.requestTv(viewModel)
.subscribe(x => { .subscribe(x => {
this.tvRequested.next();
this.result = x as IRequestEngineResult; this.result = x as IRequestEngineResult;
if (this.result.result) { if (this.result.result) {
this.notificationService.success( this.notificationService.success(

View file

@ -26,15 +26,13 @@
<i id="tvSearchButton" class="fa fa-search"></i> <i id="tvSearchButton" class="fa fa-search"></i>
</div> </div>
</div> </div>
<br />
<br /> <remaining-requests [movie]="false" [quotaRefreshEvents]="tvRequested.asObservable()" #remainingTvShows></remaining-requests>
<!-- Movie content --> <!-- Movie content -->
<div id="actorMovieList"> <div id="actorMovieList">
</div> </div>
<br />
<br />
<!-- TV content --> <!-- TV content -->
<div id="tvList"> <div id="tvList">
@ -155,7 +153,7 @@
</div> </div>
<!--This is the section that holds the child seasons if they want to specify specific episodes--> <!--This is the section that holds the child seasons if they want to specify specific episodes-->
<div *ngIf="node.open"> <div *ngIf="node.open">
<seriesinformation [seriesId]="node.id"></seriesinformation> <seriesinformation [seriesId]="node.id" [tvRequested]="tvRequested"></seriesinformation>
</div> </div>
<br/> <br/>

View file

@ -18,6 +18,7 @@ export class TvSearchComponent implements OnInit {
public searchText: string; public searchText: string;
public searchChanged = new Subject<string>(); public searchChanged = new Subject<string>();
public tvResults: ISearchTvResult[]; public tvResults: ISearchTvResult[];
public tvRequested: Subject<void> = new Subject<void>();
public result: IRequestEngineResult; public result: IRequestEngineResult;
public searchApplied = false; public searchApplied = false;
public defaultPoster: string; public defaultPoster: string;
@ -161,6 +162,7 @@ export class TvSearchComponent implements OnInit {
this.requestService.requestTv(viewModel) this.requestService.requestTv(viewModel)
.subscribe(x => { .subscribe(x => {
this.tvRequested.next();
this.result = x; this.result = x;
if (this.result.result) { if (this.result.result) {
this.notificationService.success( this.notificationService.success(

View file

@ -10,12 +10,22 @@ import { FilterType, IAlbumRequest, IAlbumRequestModel, IAlbumUpdateModel, IChil
import { ITvRequestViewModel } from "../interfaces"; import { ITvRequestViewModel } from "../interfaces";
import { ServiceHelpers } from "./service.helpers"; import { ServiceHelpers } from "./service.helpers";
import { IRemainingRequests } from "../interfaces/IRemainingRequests";
@Injectable() @Injectable()
export class RequestService extends ServiceHelpers { export class RequestService extends ServiceHelpers {
constructor(http: HttpClient, public platformLocation: PlatformLocation) { constructor(http: HttpClient, public platformLocation: PlatformLocation) {
super(http, "/api/v1/Request/", platformLocation); super(http, "/api/v1/Request/", platformLocation);
} }
public getRemainingMovieRequests(): Observable<IRemainingRequests> {
return this.http.get<IRemainingRequests>(`${this.url}movie/remaining`, {headers: this.headers});
}
public getRemainingTvRequests(): Observable<IRemainingRequests> {
return this.http.get<IRemainingRequests>(`${this.url}tv/remaining`, {headers: this.headers});
}
public requestMovie(movie: IMovieRequestModel): Observable<IRequestEngineResult> { public requestMovie(movie: IMovieRequestModel): Observable<IRequestEngineResult> {
return this.http.post<IRequestEngineResult>(`${this.url}Movie/`, JSON.stringify(movie), {headers: this.headers}); return this.http.post<IRequestEngineResult>(`${this.url}Movie/`, JSON.stringify(movie), {headers: this.headers});
} }

View file

@ -7,7 +7,7 @@ import { NotificationService } from "../../services";
import { SettingsService } from "../../services"; import { SettingsService } from "../../services";
@Component({ @Component({
templateUrl: "./Lidarr.component.html", templateUrl: "./lidarr.component.html",
}) })
export class LidarrComponent implements OnInit { export class LidarrComponent implements OnInit {

View file

@ -1,2 +1,2 @@
@import './styles.scss'; @import './Styles.scss';
@import './scrollbar.scss'; @import './scrollbar.scss';

View file

@ -12,6 +12,7 @@ using Ombi.Attributes;
using Ombi.Core.Models.UI; using Ombi.Core.Models.UI;
using Ombi.Models; using Ombi.Models;
using Ombi.Store.Entities; using Ombi.Store.Entities;
using Ombi.Core.Models;
namespace Ombi.Controllers namespace Ombi.Controllers
{ {
@ -464,5 +465,23 @@ namespace Ombi.Controllers
await TvRequestEngine.UnSubscribeRequest(requestId, RequestType.TvShow); await TvRequestEngine.UnSubscribeRequest(requestId, RequestType.TvShow);
return true; return true;
} }
/// <summary>
/// Gets model containing remaining number of requests.
/// </summary>
[HttpGet("movie/remaining")]
public async Task<RequestQuotaCountModel> GetRemainingMovieRequests()
{
return await MovieRequestEngine.GetRemainingRequests();
}
/// <summary>
/// Gets model containing remaining number of requests.
/// </summary>
[HttpGet("tv/remaining")]
public async Task<RequestQuotaCountModel> GetRemainingTvRequests()
{
return await TvRequestEngine.GetRemainingRequests();
}
} }
} }

View file

@ -8,6 +8,7 @@ using Ombi.Api.Lidarr.Models;
using Ombi.Core; using Ombi.Core;
using Ombi.Core.Engine; using Ombi.Core.Engine;
using Ombi.Core.Engine.Interfaces; using Ombi.Core.Engine.Interfaces;
using Ombi.Core.Models;
using Ombi.Core.Models.Search; using Ombi.Core.Models.Search;
using StackExchange.Profiling; using StackExchange.Profiling;

View file

@ -150,7 +150,14 @@
"SortRequestDateAsc": "Request Date ▲", "SortRequestDateAsc": "Request Date ▲",
"SortRequestDateDesc": "Request Date ▼", "SortRequestDateDesc": "Request Date ▼",
"SortStatusAsc":"Status ▲", "SortStatusAsc":"Status ▲",
"SortStatusDesc":"Status ▼" "SortStatusDesc":"Status ▼",
"Remaining": {
"Quota": "{{remaining}}/{{total}} requests remaining",
"NextDays": "Another request will be added in {{time}} days",
"NextHours": "Another request will be added in {{time}} hours",
"NextMinutes": "Another request will be added in {{time}} minutes",
"NextMinute": "Another request will be added in {{time}} minute"
}
}, },
"Issues":{ "Issues":{
"Title":"Issues", "Title":"Issues",