mirror of
https://github.com/Ombi-app/Ombi.git
synced 2025-07-15 09:42:56 -07:00
More work on the calendar, including unit tests
This commit is contained in:
parent
9a267465a7
commit
7fdbc10ccc
8 changed files with 344 additions and 37 deletions
152
src/Ombi.Core.Tests/Engine/CalendarEngineTests.cs
Normal file
152
src/Ombi.Core.Tests/Engine/CalendarEngineTests.cs
Normal file
|
@ -0,0 +1,152 @@
|
|||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Principal;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Ombi.Core.Authentication;
|
||||
using Ombi.Core.Engine.V2;
|
||||
using Ombi.Store.Entities.Requests;
|
||||
using Ombi.Store.Repository.Requests;
|
||||
|
||||
namespace Ombi.Core.Tests.Engine
|
||||
{
|
||||
[TestFixture]
|
||||
public class CalendarEngineTests
|
||||
{
|
||||
public Mock<IMovieRequestRepository> MovieRepo { get; set; }
|
||||
public Mock<ITvRequestRepository> TvRepo { get; set; }
|
||||
public CalendarEngine CalendarEngine { get; set; }
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
MovieRepo = new Mock<IMovieRequestRepository>();
|
||||
TvRepo = new Mock<ITvRequestRepository>();
|
||||
var principle = new Mock<IPrincipal>();
|
||||
var identity = new Mock<IIdentity>();
|
||||
identity.Setup(x => x.Name).Returns("UnitTest");
|
||||
principle.Setup(x => x.Identity).Returns(identity.Object);
|
||||
CalendarEngine = new CalendarEngine(principle.Object, null, null, MovieRepo.Object, TvRepo.Object);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Calendar_Movies_OnlyGet_PreviousAndFuture_90_Days()
|
||||
{
|
||||
var movies = new List<MovieRequests>
|
||||
{
|
||||
new MovieRequests
|
||||
{
|
||||
Title="Invalid",
|
||||
ReleaseDate = new DateTime(2018,10,01)
|
||||
},
|
||||
new MovieRequests
|
||||
{
|
||||
Title="Invalid",
|
||||
ReleaseDate = DateTime.Now.AddDays(91)
|
||||
},
|
||||
|
||||
new MovieRequests
|
||||
{
|
||||
Title="Valid",
|
||||
ReleaseDate = DateTime.Now
|
||||
}
|
||||
};
|
||||
MovieRepo.Setup(x => x.GetAll()).Returns(movies.AsQueryable());
|
||||
var data = await CalendarEngine.GetCalendarData();
|
||||
|
||||
Assert.That(data.Count, Is.EqualTo(1));
|
||||
Assert.That(data[0].Title, Is.EqualTo("Valid"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Calendar_Episodes_OnlyGet_PreviousAndFuture_90_Days()
|
||||
{
|
||||
var tv = new List<ChildRequests>
|
||||
{
|
||||
new ChildRequests
|
||||
{
|
||||
SeasonRequests = new List<SeasonRequests>
|
||||
{
|
||||
new SeasonRequests
|
||||
{
|
||||
Episodes = new List<EpisodeRequests>
|
||||
{
|
||||
new EpisodeRequests
|
||||
{
|
||||
Title = "Invalid",
|
||||
AirDate = new DateTime(2018,01,01)
|
||||
},
|
||||
new EpisodeRequests
|
||||
{
|
||||
Title = "Invalid",
|
||||
AirDate = DateTime.Now.AddDays(91)
|
||||
},
|
||||
new EpisodeRequests
|
||||
{
|
||||
Title = "Valid",
|
||||
AirDate = DateTime.Now
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
TvRepo.Setup(x => x.GetChild()).Returns(tv.AsQueryable());
|
||||
var data = await CalendarEngine.GetCalendarData();
|
||||
|
||||
Assert.That(data.Count, Is.EqualTo(1));
|
||||
Assert.That(data[0].Title, Is.EqualTo("Valid"));
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(StatusColorData))]
|
||||
public async Task<string> Calendar_StatusColor(AvailabilityTestModel model)
|
||||
{
|
||||
var movies = new List<MovieRequests>
|
||||
{
|
||||
new MovieRequests
|
||||
{
|
||||
Title="Valid",
|
||||
ReleaseDate = DateTime.Now,
|
||||
Denied = model.Denied,
|
||||
Approved = model.Approved,
|
||||
Available = model.Available
|
||||
},
|
||||
};
|
||||
MovieRepo.Setup(x => x.GetAll()).Returns(movies.AsQueryable());
|
||||
var data = await CalendarEngine.GetCalendarData();
|
||||
|
||||
return data[0].BackgroundColor;
|
||||
}
|
||||
|
||||
public static IEnumerable<TestCaseData> StatusColorData
|
||||
{
|
||||
get
|
||||
{
|
||||
yield return new TestCaseData(new AvailabilityTestModel
|
||||
{
|
||||
Approved = true,
|
||||
Denied = true
|
||||
}).Returns("red").SetName("Calendar_DeniedRequest");
|
||||
yield return new TestCaseData(new AvailabilityTestModel
|
||||
{
|
||||
Available = true,
|
||||
Approved = true
|
||||
}).Returns("#469c83").SetName("Calendar_AvailableRequest");
|
||||
yield return new TestCaseData(new AvailabilityTestModel
|
||||
{
|
||||
Approved = true
|
||||
}).Returns("teal").SetName("Calendar_ApprovedRequest");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AvailabilityTestModel
|
||||
{
|
||||
public bool Available { get; set; }
|
||||
public bool Denied { get; set; }
|
||||
public bool Approved { get; set; }
|
||||
}
|
||||
}
|
|
@ -3,16 +3,22 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Security.Principal;
|
||||
using System.Threading.Tasks;
|
||||
using Ombi.Api.Sonarr.Models;
|
||||
using Ombi.Core.Authentication;
|
||||
using Ombi.Core.Engine.Interfaces;
|
||||
using Ombi.Core.Models.Search.V2;
|
||||
using Ombi.Core.Rule.Interfaces;
|
||||
using Ombi.Helpers;
|
||||
using Ombi.Store.Entities;
|
||||
using Ombi.Store.Entities.Requests;
|
||||
using Ombi.Store.Repository.Requests;
|
||||
|
||||
namespace Ombi.Core.Engine.V2
|
||||
{
|
||||
public class CalendarEngine : BaseEngine, ICalendarEngine
|
||||
{
|
||||
public DateTime DaysAgo => DateTime.Now.AddDays(-90);
|
||||
public DateTime DaysAhead => DateTime.Now.AddDays(90);
|
||||
public CalendarEngine(IPrincipal user, OmbiUserManager um, IRuleEvaluator rules, IMovieRequestRepository movieRepo,
|
||||
ITvRequestRepository tvRequestRepo) : base(user, um, rules)
|
||||
{
|
||||
|
@ -27,14 +33,21 @@ namespace Ombi.Core.Engine.V2
|
|||
{
|
||||
var viewModel = new List<CalendarViewModel>();
|
||||
var movies = _movieRepo.GetAll().Where(x =>
|
||||
x.ReleaseDate > DateTime.Now.AddDays(-30) && x.ReleaseDate < DateTime.Now.AddDays(30));
|
||||
var episodes = _tvRepo.GetChild().SelectMany(x => x.SeasonRequests.SelectMany(e => e.Episodes)).ToList();
|
||||
x.ReleaseDate > DaysAgo && x.ReleaseDate < DaysAhead);
|
||||
var episodes = _tvRepo.GetChild().SelectMany(x => x.SeasonRequests.SelectMany(e => e.Episodes
|
||||
.Where(w => w.AirDate > DaysAgo && w.AirDate < DaysAhead)));
|
||||
foreach (var e in episodes)
|
||||
{
|
||||
viewModel.Add(new CalendarViewModel
|
||||
{
|
||||
Title = e.Title,
|
||||
Start = e.AirDate.Date
|
||||
Start = e.AirDate.Date,
|
||||
Type = RequestType.TvShow,
|
||||
BackgroundColor = GetBackgroundColor(e),
|
||||
ExtraParams = new List<ExtraParams>
|
||||
{
|
||||
new ExtraParams { Overview = e.Season?.ChildRequest?.ParentRequest?.Overview ?? string.Empty, ProviderId = e.Season?.ChildRequest?.ParentRequest?.TvDbId ?? 0}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -43,11 +56,72 @@ namespace Ombi.Core.Engine.V2
|
|||
viewModel.Add(new CalendarViewModel
|
||||
{
|
||||
Title = m.Title,
|
||||
Start = m.ReleaseDate.Date
|
||||
Start = m.ReleaseDate.Date,
|
||||
BackgroundColor = GetBackgroundColor(m),
|
||||
Type = RequestType.Movie,
|
||||
ExtraParams = new List<ExtraParams>
|
||||
{
|
||||
new ExtraParams { Overview = m.Overview, ProviderId = m.TheMovieDbId}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
private string GetBackgroundColor(MovieRequests req)
|
||||
{
|
||||
if (req.Available)
|
||||
{
|
||||
return "#469c83";
|
||||
}
|
||||
|
||||
if (!req.Available)
|
||||
{
|
||||
if (req.Denied ?? false)
|
||||
{
|
||||
return "red";
|
||||
}
|
||||
if (req.Approved)
|
||||
{
|
||||
// We are approved state
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (!req.Approved)
|
||||
{
|
||||
// Processing
|
||||
return "teal";
|
||||
}
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
private string GetBackgroundColor(EpisodeRequests req)
|
||||
{
|
||||
if (req.Available)
|
||||
{
|
||||
return "#469c83";
|
||||
}
|
||||
|
||||
if (!req.Available)
|
||||
{
|
||||
if (req.Approved)
|
||||
{
|
||||
// We are approved state
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (!req.Approved)
|
||||
{
|
||||
// Processing
|
||||
return "teal";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Ombi.Store.Entities;
|
||||
|
||||
namespace Ombi.Core.Models.Search.V2
|
||||
{
|
||||
|
@ -6,5 +8,32 @@ namespace Ombi.Core.Models.Search.V2
|
|||
{
|
||||
public string Title { get; set; }
|
||||
public DateTime Start { get; set; }
|
||||
public string BackgroundColor { get; set; }
|
||||
public RequestType Type { get; set; }
|
||||
public List<ExtraParams> ExtraParams { get; set; }
|
||||
|
||||
public string BorderColor
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Type)
|
||||
{
|
||||
case RequestType.TvShow:
|
||||
return "#ff0000";
|
||||
case RequestType.Movie:
|
||||
return "#0d5a3e";
|
||||
case RequestType.Album:
|
||||
return "#797979";
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ExtraParams
|
||||
{
|
||||
public int ProviderId { get; set; }
|
||||
public string Overview { get; set; }
|
||||
}
|
||||
}
|
|
@ -28,7 +28,7 @@ namespace Ombi.Helpers
|
|||
return result;
|
||||
}
|
||||
|
||||
using (await _mutex.LockAsync())
|
||||
//using (await _mutex.LockAsync())
|
||||
{
|
||||
if (_memoryCache.TryGetValue(cacheKey, out result))
|
||||
{
|
||||
|
|
82
src/Ombi/ClientApp/package-lock.json
generated
82
src/Ombi/ClientApp/package-lock.json
generated
|
@ -2631,6 +2631,11 @@
|
|||
"integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=",
|
||||
"dev": true
|
||||
},
|
||||
"cookiejar": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz",
|
||||
"integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA=="
|
||||
},
|
||||
"copy-concurrently": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
|
||||
|
@ -3881,6 +3886,11 @@
|
|||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"formidable": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz",
|
||||
"integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg=="
|
||||
},
|
||||
"forwarded": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
|
||||
|
@ -4477,6 +4487,18 @@
|
|||
"rimraf": "2"
|
||||
}
|
||||
},
|
||||
"fullcalendar": {
|
||||
"version": "4.0.0-alpha.2",
|
||||
"resolved": "https://registry.npmjs.org/fullcalendar/-/fullcalendar-4.0.0-alpha.2.tgz",
|
||||
"integrity": "sha512-2trFzbvQWHijyt+u8Zv98PPfDkFH5bU5Yoqvn2ot5PTwIkLK95xrNat5jTHfpBMwh+KqHQSnux/BcGXARYgwcw==",
|
||||
"requires": {
|
||||
"luxon": "^1.4.2",
|
||||
"moment": "^2.22.2",
|
||||
"moment-timezone": "^0.5.21",
|
||||
"rrule": "^2.5.6",
|
||||
"superagent": "^3.8.3"
|
||||
}
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
|
@ -5988,6 +6010,11 @@
|
|||
"yallist": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"luxon": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.12.0.tgz",
|
||||
"integrity": "sha512-enPnPIHd5ZnZT0vpj9Xv8aq4j0yueAkhnh4xUKUHpqlgSm1r/8s6xTMjfyp2ugOWP7zivqJqgVTkW+rpHed61w=="
|
||||
},
|
||||
"magic-string": {
|
||||
"version": "0.25.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.2.tgz",
|
||||
|
@ -6220,8 +6247,7 @@
|
|||
"methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
|
||||
"dev": true
|
||||
"integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
|
||||
},
|
||||
"micromatch": {
|
||||
"version": "3.1.10",
|
||||
|
@ -6256,9 +6282,7 @@
|
|||
"mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.38.0",
|
||||
|
@ -6416,6 +6440,14 @@
|
|||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
|
||||
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
|
||||
},
|
||||
"moment-timezone": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.23.tgz",
|
||||
"integrity": "sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==",
|
||||
"requires": {
|
||||
"moment": ">= 2.9.0"
|
||||
}
|
||||
},
|
||||
"move-concurrently": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
|
||||
|
@ -8168,6 +8200,14 @@
|
|||
"inherits": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"rrule": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.0.tgz",
|
||||
"integrity": "sha512-TRigkTJtG7Y1yOjNSKvFvVmvj/PzRZLR8lLcPW9GASOlaoqoL1J0kNuUV9I3LuZc7qFT+QB2NbxSLL9d33/ylg==",
|
||||
"requires": {
|
||||
"luxon": "^1.3.3"
|
||||
}
|
||||
},
|
||||
"run-async": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz",
|
||||
|
@ -9145,6 +9185,38 @@
|
|||
"when": "~3.6.x"
|
||||
}
|
||||
},
|
||||
"superagent": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
|
||||
"integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==",
|
||||
"requires": {
|
||||
"component-emitter": "^1.2.0",
|
||||
"cookiejar": "^2.1.0",
|
||||
"debug": "^3.1.0",
|
||||
"extend": "^3.0.0",
|
||||
"form-data": "^2.3.1",
|
||||
"formidable": "^1.2.0",
|
||||
"methods": "^1.1.1",
|
||||
"mime": "^1.4.1",
|
||||
"qs": "^6.5.1",
|
||||
"readable-stream": "^2.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
|
||||
"integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
|
||||
"integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { CalendarService } from "../../services/calendar.service";
|
||||
import { ICalendarModel } from "../../interfaces/ICalendar";
|
||||
|
||||
|
@ -19,31 +20,6 @@ export class CalendarComponent implements OnInit {
|
|||
debugger;
|
||||
this.loading()
|
||||
this.entries = await this.calendarService.getCalendarEntries();
|
||||
this.events = [
|
||||
{
|
||||
"title": "All Day Event",
|
||||
"start": new Date(),
|
||||
"eventColor":"black"
|
||||
},
|
||||
{
|
||||
"title": "Long Event",
|
||||
"start": "2016-01-07",
|
||||
"end": "2016-01-10"
|
||||
},
|
||||
{
|
||||
"title": "Repeating Event",
|
||||
"start": "2016-01-09T16:00:00"
|
||||
},
|
||||
{
|
||||
"title": "Repeating Event",
|
||||
"start": "2016-01-16T16:00:00"
|
||||
},
|
||||
{
|
||||
"title": "Conference",
|
||||
"start": "2016-01-11",
|
||||
"end": "2016-01-13"
|
||||
}
|
||||
];
|
||||
|
||||
this.options = {
|
||||
defaultDate: new Date(),
|
||||
|
@ -52,6 +28,10 @@ export class CalendarComponent implements OnInit {
|
|||
center: 'title',
|
||||
right: 'month,agendaWeek'
|
||||
},
|
||||
eventClick: (e: any) => {
|
||||
debugger;
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
this.finishLoading();
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
<button mat-raised-button class="btn-green btn-spacing" *ngIf="movie.available"> {{
|
||||
'Common.Available' | translate }}</button>
|
||||
<span *ngIf="!movie.available">
|
||||
<span *ngIf="movie.requested|| movie.approved; then requestedBtn else notRequestedBtn"></span>
|
||||
<span *ngIf="movie.requested || movie.approved; then requestedBtn else notRequestedBtn"></span>
|
||||
|
||||
<ng-template #requestedBtn>
|
||||
<button mat-raised-button *ngIf="!hasRequest || hasRequest && !movieRequest.denied" class="btn-spacing" color="warn" [disabled]><i class="fa fa-check"></i>
|
||||
|
|
|
@ -23,7 +23,7 @@ namespace Ombi.Controllers.V2
|
|||
|
||||
|
||||
[HttpGet]
|
||||
public async Task<List<CalendarViewModel>> GetCalendarEntried()
|
||||
public async Task<List<CalendarViewModel>> GetCalendarEntries()
|
||||
{
|
||||
return await _calendarEngine.GetCalendarData();
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue