mirror of
https://github.com/Ombi-app/Ombi.git
synced 2025-07-12 16:22:55 -07:00
A lot more lidarr work, i'm done for the day wow... !wip #2313
This commit is contained in:
parent
207c60b7f8
commit
3750243f11
19 changed files with 174 additions and 94 deletions
|
@ -7,6 +7,6 @@
|
|||
public int trackCount { get; set; }
|
||||
public int totalTrackCount { get; set; }
|
||||
public int sizeOnDisk { get; set; }
|
||||
public decimal percentOfTracks { get; set; }
|
||||
public decimal percentOfEpisodes { get; set; }
|
||||
}
|
||||
}
|
|
@ -73,7 +73,7 @@ namespace Ombi.Core.Engine
|
|||
var vm = new List<SearchArtistViewModel>();
|
||||
foreach (var r in result)
|
||||
{
|
||||
vm.Add(MapIntoArtistVm(r));
|
||||
vm.Add(await MapIntoArtistVm(r));
|
||||
}
|
||||
|
||||
return vm;
|
||||
|
@ -107,7 +107,7 @@ namespace Ombi.Core.Engine
|
|||
return await _lidarrApi.GetArtist(artistId, settings.ApiKey, settings.FullUri);
|
||||
}
|
||||
|
||||
private SearchArtistViewModel MapIntoArtistVm(ArtistLookup a)
|
||||
private async Task<SearchArtistViewModel> MapIntoArtistVm(ArtistLookup a)
|
||||
{
|
||||
var vm = new SearchArtistViewModel
|
||||
{
|
||||
|
@ -121,13 +121,16 @@ namespace Ombi.Core.Engine
|
|||
Links = a.links,
|
||||
Overview = a.overview,
|
||||
};
|
||||
|
||||
|
||||
var poster = a.images?.FirstOrDefault(x => x.coverType.Equals("poaster"));
|
||||
if (poster == null)
|
||||
{
|
||||
vm.Poster = a.remotePoster;
|
||||
}
|
||||
|
||||
|
||||
await Rules.StartSpecificRules(vm, SpecificRules.LidarrArtist);
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
|
@ -162,6 +165,10 @@ namespace Ombi.Core.Engine
|
|||
vm.Cover = a.remoteCover;
|
||||
}
|
||||
|
||||
await Rules.StartSpecificRules(vm, SpecificRules.LidarrAlbum);
|
||||
|
||||
await RunSearchRules(vm);
|
||||
|
||||
return vm;
|
||||
}
|
||||
private LidarrSettings _settings;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
using System;
|
||||
using Ombi.Store.Entities;
|
||||
|
||||
namespace Ombi.Core.Models.Search
|
||||
{
|
||||
public class SearchAlbumViewModel
|
||||
public class SearchAlbumViewModel : SearchViewModel
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string ForeignAlbumId { get; set; }
|
||||
|
@ -14,6 +15,9 @@ namespace Ombi.Core.Models.Search
|
|||
public string ForeignArtistId { get; set; }
|
||||
public string Cover { get; set; }
|
||||
public string Disk { get; set; }
|
||||
|
||||
public decimal PercentOfTracks { get; set; }
|
||||
public override RequestType Type => RequestType.Album;
|
||||
public bool PartiallyAvailable => PercentOfTracks != 100 && PercentOfTracks > 0;
|
||||
public bool FullyAvailable => PercentOfTracks == 100;
|
||||
}
|
||||
}
|
|
@ -12,8 +12,6 @@ namespace Ombi.Core.Models.Search
|
|||
public string Poster { get; set; }
|
||||
public string Logo { get; set; }
|
||||
public bool Monitored { get; set; }
|
||||
public bool Available { get; set; }
|
||||
public bool Requested { get; set; }
|
||||
public string ArtistType { get; set; }
|
||||
public string CleanName { get; set; }
|
||||
public Link[] Links { get; set; } // Couldn't be bothered to map it
|
||||
|
|
|
@ -3,5 +3,7 @@
|
|||
public enum SpecificRules
|
||||
{
|
||||
CanSendNotification,
|
||||
LidarrArtist,
|
||||
LidarrAlbum,
|
||||
}
|
||||
}
|
|
@ -11,13 +11,15 @@ namespace Ombi.Core.Rule.Rules.Search
|
|||
{
|
||||
public class ExistingRule : BaseSearchRule, IRules<SearchViewModel>
|
||||
{
|
||||
public ExistingRule(IMovieRequestRepository movie, ITvRequestRepository tv)
|
||||
public ExistingRule(IMovieRequestRepository movie, ITvRequestRepository tv, IMusicRequestRepository music)
|
||||
{
|
||||
Movie = movie;
|
||||
Tv = tv;
|
||||
Music = music;
|
||||
}
|
||||
|
||||
private IMovieRequestRepository Movie { get; }
|
||||
private IMusicRequestRepository Music { get; }
|
||||
private ITvRequestRepository Tv { get; }
|
||||
|
||||
public async Task<RuleResult> Execute(SearchViewModel obj)
|
||||
|
@ -37,7 +39,7 @@ namespace Ombi.Core.Rule.Rules.Search
|
|||
}
|
||||
return Success();
|
||||
}
|
||||
else if (obj.Type == RequestType.Album)
|
||||
if (obj.Type == RequestType.TvShow)
|
||||
{
|
||||
//var tvRequests = Tv.GetRequest(obj.Id);
|
||||
//if (tvRequests != null) // Do we already have a request for this?
|
||||
|
@ -50,7 +52,7 @@ namespace Ombi.Core.Rule.Rules.Search
|
|||
// return Task.FromResult(Success());
|
||||
//}
|
||||
|
||||
var request = (SearchTvShowViewModel) obj;
|
||||
var request = (SearchTvShowViewModel)obj;
|
||||
var tvRequests = Tv.GetRequest(obj.Id);
|
||||
if (tvRequests != null) // Do we already have a request for this?
|
||||
{
|
||||
|
@ -96,6 +98,21 @@ namespace Ombi.Core.Rule.Rules.Search
|
|||
|
||||
return Success();
|
||||
}
|
||||
if (obj.Type == RequestType.Album)
|
||||
{
|
||||
var album = (SearchAlbumViewModel) obj;
|
||||
var albumRequest = await Music.GetRequestAsync(album.ForeignAlbumId);
|
||||
if (albumRequest != null) // Do we already have a request for this?
|
||||
{
|
||||
obj.Requested = true;
|
||||
obj.RequestId = albumRequest.Id;
|
||||
obj.Approved = albumRequest.Approved;
|
||||
obj.Available = albumRequest.Available;
|
||||
|
||||
return Success();
|
||||
}
|
||||
return Success();
|
||||
}
|
||||
return Success();
|
||||
}
|
||||
}
|
||||
|
|
36
src/Ombi.Core/Rule/Rules/Search/LidarrAlbumCacheRule.cs
Normal file
36
src/Ombi.Core/Rule/Rules/Search/LidarrAlbumCacheRule.cs
Normal file
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Ombi.Core.Models.Search;
|
||||
using Ombi.Core.Rule.Interfaces;
|
||||
using Ombi.Store.Entities;
|
||||
using Ombi.Store.Repository;
|
||||
|
||||
namespace Ombi.Core.Rule.Rules.Search
|
||||
{
|
||||
public class LidarrAlbumCacheRule : SpecificRule, ISpecificRule<object>
|
||||
{
|
||||
public LidarrAlbumCacheRule(IRepository<LidarrAlbumCache> db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
private readonly IRepository<LidarrAlbumCache> _db;
|
||||
|
||||
public Task<RuleResult> Execute(object objec)
|
||||
{
|
||||
var obj = (SearchAlbumViewModel) objec;
|
||||
// Check if it's in Lidarr
|
||||
var result = _db.GetAll().FirstOrDefault(x => x.ForeignAlbumId.Equals(obj.ForeignAlbumId, StringComparison.InvariantCultureIgnoreCase));
|
||||
if (result != null)
|
||||
{
|
||||
obj.PercentOfTracks = result.PercentOfTracks;
|
||||
obj.Monitored = true; // It's in Lidarr so it's monitored
|
||||
}
|
||||
|
||||
return Task.FromResult(Success());
|
||||
}
|
||||
|
||||
public override SpecificRules Rule => SpecificRules.LidarrAlbum;
|
||||
}
|
||||
}
|
35
src/Ombi.Core/Rule/Rules/Search/LidarrArtistCacheRule.cs
Normal file
35
src/Ombi.Core/Rule/Rules/Search/LidarrArtistCacheRule.cs
Normal file
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Ombi.Core.Models.Search;
|
||||
using Ombi.Core.Rule.Interfaces;
|
||||
using Ombi.Store.Entities;
|
||||
using Ombi.Store.Repository;
|
||||
|
||||
namespace Ombi.Core.Rule.Rules.Search
|
||||
{
|
||||
public class LidarrArtistCacheRule : SpecificRule, ISpecificRule<object>
|
||||
{
|
||||
public LidarrArtistCacheRule(IRepository<LidarrArtistCache> db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
private readonly IRepository<LidarrArtistCache> _db;
|
||||
|
||||
public Task<RuleResult> Execute(object objec)
|
||||
{
|
||||
var obj = (SearchArtistViewModel) objec;
|
||||
// Check if it's in Lidarr
|
||||
var result = _db.GetAll().FirstOrDefault(x => x.ForeignArtistId.Equals(obj.ForignArtistId, StringComparison.InvariantCultureIgnoreCase));
|
||||
if (result != null)
|
||||
{
|
||||
obj.Monitored = true; // It's in Lidarr so it's monitored
|
||||
}
|
||||
|
||||
return Task.FromResult(Success());
|
||||
}
|
||||
|
||||
public override SpecificRules Rule => SpecificRules.LidarrArtist;
|
||||
}
|
||||
}
|
|
@ -48,23 +48,24 @@ namespace Ombi.Schedule.Jobs.Lidarr
|
|||
// Let's remove the old cached data
|
||||
await _ctx.Database.ExecuteSqlCommandAsync("DELETE FROM LidarrAlbumCache");
|
||||
|
||||
var artistCache = new List<LidarrAlbumCache>();
|
||||
var albumCache = new List<LidarrAlbumCache>();
|
||||
foreach (var a in albums)
|
||||
{
|
||||
if (a.id > 0)
|
||||
{
|
||||
artistCache.Add(new LidarrAlbumCache
|
||||
albumCache.Add(new LidarrAlbumCache
|
||||
{
|
||||
ArtistId = a.artistId,
|
||||
ForeignAlbumId = a.foreignAlbumId,
|
||||
ReleaseDate = a.releaseDate,
|
||||
TrackCount = a.currentRelease.trackCount,
|
||||
Monitored = a.monitored,
|
||||
Title = a.title
|
||||
Title = a.title,
|
||||
PercentOfTracks = a.statistics?.percentOfEpisodes ?? 0m
|
||||
});
|
||||
}
|
||||
}
|
||||
await _ctx.LidarrAlbumCache.AddRangeAsync(artistCache);
|
||||
await _ctx.LidarrAlbumCache.AddRangeAsync(albumCache);
|
||||
|
||||
await _ctx.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
@ -12,8 +12,6 @@ namespace Ombi.Store.Entities
|
|||
public DateTime ReleaseDate { get; set; }
|
||||
public bool Monitored { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
[ForeignKey(nameof(ArtistId))]
|
||||
public LidarrArtistCache Artist { get; set; }
|
||||
public decimal PercentOfTracks { get; set; }
|
||||
}
|
||||
}
|
|
@ -6,12 +6,9 @@ namespace Ombi.Store.Entities
|
|||
[Table("LidarrArtistCache")]
|
||||
public class LidarrArtistCache : Entity
|
||||
{
|
||||
[ForeignKey(nameof(ArtistId))]
|
||||
public int ArtistId { get; set; }
|
||||
public string ArtistName { get; set; }
|
||||
public string ForeignArtistId { get; set; }
|
||||
public bool Monitored { get; set; }
|
||||
|
||||
public List<LidarrAlbumCache> Albums { get; set; }
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ using Ombi.Store.Context;
|
|||
namespace Ombi.Store.Migrations
|
||||
{
|
||||
[DbContext(typeof(OmbiContext))]
|
||||
[Migration("20180824202308_LidarrSyncJobs")]
|
||||
[Migration("20180824211553_LidarrSyncJobs")]
|
||||
partial class LidarrSyncJobs
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
|
@ -257,6 +257,8 @@ namespace Ombi.Store.Migrations
|
|||
|
||||
b.Property<bool>("Monitored");
|
||||
|
||||
b.Property<decimal>("PercentOfTracks");
|
||||
|
||||
b.Property<DateTime>("ReleaseDate");
|
||||
|
||||
b.Property<string>("Title");
|
||||
|
@ -265,8 +267,6 @@ namespace Ombi.Store.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArtistId");
|
||||
|
||||
b.ToTable("LidarrAlbumCache");
|
||||
});
|
||||
|
||||
|
@ -965,14 +965,6 @@ namespace Ombi.Store.Migrations
|
|||
.HasPrincipalKey("EmbyId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Ombi.Store.Entities.LidarrAlbumCache", b =>
|
||||
{
|
||||
b.HasOne("Ombi.Store.Entities.LidarrArtistCache", "Artist")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("ArtistId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Ombi.Store.Entities.NotificationUserId", b =>
|
||||
{
|
||||
b.HasOne("Ombi.Store.Entities.OmbiUser", "User")
|
|
@ -7,6 +7,25 @@ namespace Ombi.Store.Migrations
|
|||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LidarrAlbumCache",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ArtistId = table.Column<int>(nullable: false),
|
||||
ForeignAlbumId = table.Column<string>(nullable: true),
|
||||
TrackCount = table.Column<int>(nullable: false),
|
||||
ReleaseDate = table.Column<DateTime>(nullable: false),
|
||||
Monitored = table.Column<bool>(nullable: false),
|
||||
Title = table.Column<string>(nullable: true),
|
||||
PercentOfTracks = table.Column<decimal>(nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LidarrAlbumCache", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LidarrArtistCache",
|
||||
columns: table => new
|
||||
|
@ -22,35 +41,6 @@ namespace Ombi.Store.Migrations
|
|||
{
|
||||
table.PrimaryKey("PK_LidarrArtistCache", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LidarrAlbumCache",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ArtistId = table.Column<int>(nullable: false),
|
||||
ForeignAlbumId = table.Column<string>(nullable: true),
|
||||
TrackCount = table.Column<int>(nullable: false),
|
||||
ReleaseDate = table.Column<DateTime>(nullable: false),
|
||||
Monitored = table.Column<bool>(nullable: false),
|
||||
Title = table.Column<string>(nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LidarrAlbumCache", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LidarrAlbumCache_LidarrArtistCache_ArtistId",
|
||||
column: x => x.ArtistId,
|
||||
principalTable: "LidarrArtistCache",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LidarrAlbumCache_ArtistId",
|
||||
table: "LidarrAlbumCache",
|
||||
column: "ArtistId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
@ -255,6 +255,8 @@ namespace Ombi.Store.Migrations
|
|||
|
||||
b.Property<bool>("Monitored");
|
||||
|
||||
b.Property<decimal>("PercentOfTracks");
|
||||
|
||||
b.Property<DateTime>("ReleaseDate");
|
||||
|
||||
b.Property<string>("Title");
|
||||
|
@ -263,8 +265,6 @@ namespace Ombi.Store.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArtistId");
|
||||
|
||||
b.ToTable("LidarrAlbumCache");
|
||||
});
|
||||
|
||||
|
@ -963,14 +963,6 @@ namespace Ombi.Store.Migrations
|
|||
.HasPrincipalKey("EmbyId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Ombi.Store.Entities.LidarrAlbumCache", b =>
|
||||
{
|
||||
b.HasOne("Ombi.Store.Entities.LidarrArtistCache", "Artist")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("ArtistId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Ombi.Store.Entities.NotificationUserId", b =>
|
||||
{
|
||||
b.HasOne("Ombi.Store.Entities.OmbiUser", "User")
|
||||
|
|
|
@ -29,6 +29,8 @@ export interface ILink {
|
|||
}
|
||||
|
||||
export interface ISearchAlbumResult {
|
||||
id: number;
|
||||
requestId: number;
|
||||
albumType: string;
|
||||
artistName: string;
|
||||
cover: string;
|
||||
|
@ -39,10 +41,11 @@ export interface ISearchAlbumResult {
|
|||
rating: number;
|
||||
releaseDate: Date;
|
||||
title: string;
|
||||
approved: boolean;
|
||||
fullyAvailable: boolean;
|
||||
partiallyAvailable: boolean;
|
||||
requested: boolean;
|
||||
requestId: number;
|
||||
available: boolean;
|
||||
approved: boolean;
|
||||
subscribed: boolean;
|
||||
|
||||
// for the UI
|
||||
showSubscribe: boolean;
|
||||
|
|
|
@ -33,27 +33,32 @@
|
|||
<a *ngIf="result.homepage" href="{{result.homepage}}" id="homePageLabel" target="_blank"><span class="label label-info" [translate]="'Search.Movies.HomePage'"></span></a>
|
||||
|
||||
<a *ngIf="result.trailer" href="{{result.trailer}}" id="trailerLabel" target="_blank"><span class="label label-info" [translate]="'Search.Movies.Trailer'"></span></a> -->
|
||||
|
||||
<ng-template [ngIf]="result.releaseDate">
|
||||
<span class="label label-info" id="availableLabel">Release Date: {{result.releaseDate | date:'yyyy-MM-dd'}}</span>
|
||||
<ng-template [ngIf]="!result.requested && !result.fullyAvailable && !result.approved">
|
||||
<span class="label label-danger" id="notRequestedLabel" [translate]="'Common.NotRequested'"></span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="result.fullyAvailable">
|
||||
<span class="label label-success" id="availableLabel" [translate]="'Common.Available'"></span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="result.partiallyAvailable">
|
||||
<span class="label label-info" id="availableLabel" [translate]="'Common.PartiallyAvailable'"></span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="result.monitored && !result.fullyAvailable">
|
||||
<span class="label label-info" id="processingRequestLabel" [translate]="'Common.Monitored'"></span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="result.rating">
|
||||
<span class="label label-info" id="availableLabel">{{result.rating}}/10</span>
|
||||
</ng-template>
|
||||
|
||||
|
||||
<ng-template [ngIf]="result.available">
|
||||
<span class="label label-success" id="availableLabel" [translate]="'Common.Available'"></span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="result.approved && !result.available">
|
||||
<span class="label label-info" id="processingRequestLabel" [translate]="'Common.ProcessingRequest'"></span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="result.requested && !result.approved && !result.available">
|
||||
<ng-template [ngIf]="result.requested && !result.approved && !result.available">
|
||||
<span class="label label-warning" id="pendingApprovalLabel" [translate]="'Common.PendingApproval'"></span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="!result.requested && !result.available && !result.approved">
|
||||
<span class="label label-danger" id="notRequestedLabel" [translate]="'Common.NotRequested'"></span>
|
||||
</ng-template>
|
||||
|
||||
<ng-template [ngIf]="result.approved && !result.available"><span class="label label-info" id="processingRequestLabel" [translate]="'Common.ProcessingRequest'"></span></ng-template>
|
||||
|
||||
|
||||
|
||||
<ng-template [ngIf]="result.releaseDate">
|
||||
<span class="label label-info" id="availableLabel">Release Date: {{result.releaseDate | date:'yyyy-MM-dd'}}</span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="result.rating">
|
||||
<span class="label label-info" id="availableLabel">{{result.rating}}/10</span>
|
||||
</ng-template>
|
||||
|
||||
|
||||
</span>
|
||||
|
|
|
@ -34,6 +34,7 @@ export class JobsComponent implements OnInit {
|
|||
refreshMetadata: [x.refreshMetadata, Validators.required],
|
||||
newsletter: [x.newsletter, Validators.required],
|
||||
plexRecentlyAddedSync: [x.plexRecentlyAddedSync, Validators.required],
|
||||
lidarrArtistSync: [x.lidarrArtistSync, Validators.required],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,14 +3,14 @@
|
|||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:3579/",
|
||||
"applicationUrl": "http://localhost:3577/",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"commandLineArgs": "--host http://*:3579",
|
||||
"commandLineArgs": "--host http://*:3577",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
"Common": {
|
||||
"ContinueButton": "Continue",
|
||||
"Available": "Available",
|
||||
"PartiallyAvailable": "Partially Available",
|
||||
"Monitored": "Monitored",
|
||||
"NotAvailable": "Not Available",
|
||||
"ProcessingRequest": "Processing Request",
|
||||
"PendingApproval": "Pending Approval",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue