mirror of
https://github.com/Ombi-app/Ombi.git
synced 2025-07-14 01:02:57 -07:00
Made a lot of changes around the notifcations to support the custom app name
also started on the welcome email ##1456
This commit is contained in:
parent
8b4c61c065
commit
0cf1aa50e1
21 changed files with 199 additions and 62 deletions
|
@ -144,6 +144,7 @@ namespace Ombi.DependencyInjection
|
||||||
services.AddTransient<IRadarrCacher, RadarrCacher>();
|
services.AddTransient<IRadarrCacher, RadarrCacher>();
|
||||||
services.AddTransient<IOmbiAutomaticUpdater, OmbiAutomaticUpdater>();
|
services.AddTransient<IOmbiAutomaticUpdater, OmbiAutomaticUpdater>();
|
||||||
services.AddTransient<IPlexUserImporter, PlexUserImporter>();
|
services.AddTransient<IPlexUserImporter, PlexUserImporter>();
|
||||||
|
services.AddTransient<IWelcomeEmail, WelcomeEmail>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
AdminNote,
|
AdminNote,
|
||||||
Test,
|
Test,
|
||||||
RequestDeclined,
|
RequestDeclined,
|
||||||
ItemAddedToFaultQueue
|
ItemAddedToFaultQueue,
|
||||||
|
WelcomeEmail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ namespace Ombi.Notifications.Templates
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
return Path.Combine(Directory.GetCurrentDirectory(), "bin", "Debug", "netcoreapp1.1", "Templates", "BasicTemplate.html");
|
return Path.Combine(Directory.GetCurrentDirectory(), "bin", "Debug", "netcoreapp2.0", "Templates", "BasicTemplate.html");
|
||||||
#else
|
#else
|
||||||
return Path.Combine(Directory.GetCurrentDirectory(), "Templates","BasicTemplate.html");
|
return Path.Combine(Directory.GetCurrentDirectory(), "Templates","BasicTemplate.html");
|
||||||
#endif
|
#endif
|
||||||
|
@ -22,9 +22,9 @@ namespace Ombi.Notifications.Templates
|
||||||
private const string BodyKey = "{@BODY}";
|
private const string BodyKey = "{@BODY}";
|
||||||
private const string ImgSrc = "{@IMGSRC}";
|
private const string ImgSrc = "{@IMGSRC}";
|
||||||
private const string DateKey = "{@DATENOW}";
|
private const string DateKey = "{@DATENOW}";
|
||||||
private const string Logo = "{@DATENOW}";
|
private const string Logo = "{@LOGO}";
|
||||||
|
|
||||||
public string LoadTemplate(string subject, string body, string img, string logo)
|
public string LoadTemplate(string subject, string body, string img = default(string), string logo = default(string))
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(File.ReadAllText(TemplateLocation));
|
var sb = new StringBuilder(File.ReadAllText(TemplateLocation));
|
||||||
sb.Replace(SubjectKey, subject);
|
sb.Replace(SubjectKey, subject);
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{
|
{
|
||||||
public interface IEmailBasicTemplate
|
public interface IEmailBasicTemplate
|
||||||
{
|
{
|
||||||
string LoadTemplate(string subject, string body, string img, string logo);
|
string LoadTemplate(string subject, string body, string img = default(string), string logo = default(string));
|
||||||
string TemplateLocation { get; }
|
string TemplateLocation { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -8,6 +8,7 @@ using Ombi.Core.Settings;
|
||||||
using Ombi.Helpers;
|
using Ombi.Helpers;
|
||||||
using Ombi.Notifications.Interfaces;
|
using Ombi.Notifications.Interfaces;
|
||||||
using Ombi.Notifications.Models;
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
using Ombi.Settings.Settings.Models.Notifications;
|
using Ombi.Settings.Settings.Models.Notifications;
|
||||||
using Ombi.Store.Entities;
|
using Ombi.Store.Entities;
|
||||||
using Ombi.Store.Repository;
|
using Ombi.Store.Repository;
|
||||||
|
@ -17,7 +18,7 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
public class DiscordNotification : BaseNotification<DiscordNotificationSettings>, IDiscordNotification
|
public class DiscordNotification : BaseNotification<DiscordNotificationSettings>, IDiscordNotification
|
||||||
{
|
{
|
||||||
public DiscordNotification(IDiscordApi api, ISettingsService<DiscordNotificationSettings> sn, ILogger<DiscordNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t) : base(sn, r, m, t)
|
public DiscordNotification(IDiscordApi api, ISettingsService<DiscordNotificationSettings> sn, ILogger<DiscordNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t, ISettingsService<CustomizationSettings> s) : base(sn, r, m, t,s)
|
||||||
{
|
{
|
||||||
Api = api;
|
Api = api;
|
||||||
Logger = log;
|
Logger = log;
|
||||||
|
|
|
@ -17,13 +17,11 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
public class EmailNotification : BaseNotification<EmailNotificationSettings>, IEmailNotification
|
public class EmailNotification : BaseNotification<EmailNotificationSettings>, IEmailNotification
|
||||||
{
|
{
|
||||||
public EmailNotification(ISettingsService<EmailNotificationSettings> settings, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t, IEmailProvider prov, ISettingsService<CustomizationSettings> c) : base(settings, r, m, t)
|
public EmailNotification(ISettingsService<EmailNotificationSettings> settings, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t, IEmailProvider prov, ISettingsService<CustomizationSettings> c) : base(settings, r, m, t, c)
|
||||||
{
|
{
|
||||||
EmailProvider = prov;
|
EmailProvider = prov;
|
||||||
CustomizationSettings = c;
|
|
||||||
}
|
}
|
||||||
private IEmailProvider EmailProvider { get; }
|
private IEmailProvider EmailProvider { get; }
|
||||||
private ISettingsService<CustomizationSettings> CustomizationSettings { get; }
|
|
||||||
public override string NotificationName => nameof(EmailNotification);
|
public override string NotificationName => nameof(EmailNotification);
|
||||||
|
|
||||||
protected override bool ValidateConfiguration(EmailNotificationSettings settings)
|
protected override bool ValidateConfiguration(EmailNotificationSettings settings)
|
||||||
|
@ -51,10 +49,8 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
var parsed = await LoadTemplate(NotificationAgent.Email, type, model);
|
var parsed = await LoadTemplate(NotificationAgent.Email, type, model);
|
||||||
|
|
||||||
var customization = await CustomizationSettings.GetSettingsAsync();
|
|
||||||
|
|
||||||
var email = new EmailBasicTemplate();
|
var email = new EmailBasicTemplate();
|
||||||
var html = email.LoadTemplate(parsed.Subject, parsed.Message,parsed.Image, customization.Logo);
|
var html = email.LoadTemplate(parsed.Subject, parsed.Message,parsed.Image, Customization.Logo);
|
||||||
|
|
||||||
|
|
||||||
var message = new NotificationMessage
|
var message = new NotificationMessage
|
||||||
|
@ -67,8 +63,6 @@ namespace Ombi.Notifications.Agents
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
protected override async Task NewRequest(NotificationOptions model, EmailNotificationSettings settings)
|
protected override async Task NewRequest(NotificationOptions model, EmailNotificationSettings settings)
|
||||||
{
|
{
|
||||||
var message = await LoadTemplate(NotificationType.NewRequest, model, settings);
|
var message = await LoadTemplate(NotificationType.NewRequest, model, settings);
|
||||||
|
@ -77,8 +71,6 @@ namespace Ombi.Notifications.Agents
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//message.Other.Add("PlainTextBody", $"Hello! The user '{model.RequestedUser}' has requested the {model.RequestType} '{model.Title}'! Please log in to approve this request. Request Date: {model.DateTime:f}");
|
|
||||||
|
|
||||||
await Send(message, settings);
|
await Send(message, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,8 +82,6 @@ namespace Ombi.Notifications.Agents
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//message.Other.Add("PlainTextBody", $"Hello! The user '{model.RequestedUser}' has reported a new issue {model.Body} for the title {model.Title}!");
|
|
||||||
|
|
||||||
await Send(message, settings);
|
await Send(message, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,21 +104,17 @@ namespace Ombi.Notifications.Agents
|
||||||
img = TvRequest.ParentRequest.PosterPath;
|
img = TvRequest.ParentRequest.PosterPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
var customization = await CustomizationSettings.GetSettingsAsync();
|
|
||||||
var html = email.LoadTemplate(
|
var html = email.LoadTemplate(
|
||||||
"Ombi: A request could not be added.",
|
$"{Customization.ApplicationName}: A request could not be added.",
|
||||||
$"Hello! The user '{user}' has requested {title} but it could not be added. This has been added into the requests queue and will keep retrying", img, customization.Logo);
|
$"Hello! The user '{user}' has requested {title} but it could not be added. This has been added into the requests queue and will keep retrying", img, Customization.Logo);
|
||||||
|
|
||||||
var message = new NotificationMessage
|
var message = new NotificationMessage
|
||||||
{
|
{
|
||||||
Message = html,
|
Message = html,
|
||||||
Subject = $"Ombi: A request could not be added",
|
Subject = $"{Customization.ApplicationName}: A request could not be added",
|
||||||
To = settings.AdminEmail,
|
To = settings.AdminEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
//message.Other.Add("PlainTextBody", $"Hello! The user '{model.RequestedUser}' has requested {model.Title} but it could not be added. This has been added into the requests queue and will keep retrying");
|
|
||||||
|
|
||||||
|
|
||||||
await Send(message, settings);
|
await Send(message, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,10 +125,9 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
message.To = model.RequestType == RequestType.Movie
|
||||||
//message.Other.Add("PlainTextBody", $"Hello! Your request for {model.Title} has been declined, Sorry!");
|
? MovieRequest.RequestedUser.Email
|
||||||
|
: TvRequest.RequestedUser.Email;
|
||||||
|
|
||||||
await Send(message, settings);
|
await Send(message, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,9 +138,9 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
message.To = model.RequestType == RequestType.Movie
|
||||||
//message.Other.Add("PlainTextBody", $"Hello! Your request for {model.Title} has been approved!");
|
? MovieRequest.RequestedUser.Email
|
||||||
|
: TvRequest.RequestedUser.Email;
|
||||||
await Send(message, settings);
|
await Send(message, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -166,9 +151,9 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
message.To = model.RequestType == RequestType.Movie
|
||||||
//message.Other.Add("PlainTextBody", $"Hello! You requested {model.Title} on Ombi! This is now available on Plex! :)");
|
? MovieRequest.RequestedUser.Email
|
||||||
|
: TvRequest.RequestedUser.Email;
|
||||||
await Send(message, settings);
|
await Send(message, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,10 +165,9 @@ namespace Ombi.Notifications.Agents
|
||||||
protected override async Task Test(NotificationOptions model, EmailNotificationSettings settings)
|
protected override async Task Test(NotificationOptions model, EmailNotificationSettings settings)
|
||||||
{
|
{
|
||||||
var email = new EmailBasicTemplate();
|
var email = new EmailBasicTemplate();
|
||||||
var customization = await CustomizationSettings.GetSettingsAsync();
|
|
||||||
var html = email.LoadTemplate(
|
var html = email.LoadTemplate(
|
||||||
"Test Message",
|
"Test Message",
|
||||||
"This is just a test! Success!", "", customization.Logo);
|
"This is just a test! Success!", "", Customization.Logo);
|
||||||
var message = new NotificationMessage
|
var message = new NotificationMessage
|
||||||
{
|
{
|
||||||
Message = html,
|
Message = html,
|
||||||
|
|
|
@ -10,6 +10,7 @@ using Ombi.Core.Settings;
|
||||||
using Ombi.Helpers;
|
using Ombi.Helpers;
|
||||||
using Ombi.Notifications.Interfaces;
|
using Ombi.Notifications.Interfaces;
|
||||||
using Ombi.Notifications.Models;
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
using Ombi.Settings.Settings.Models.Notifications;
|
using Ombi.Settings.Settings.Models.Notifications;
|
||||||
using Ombi.Store.Entities;
|
using Ombi.Store.Entities;
|
||||||
using Ombi.Store.Repository;
|
using Ombi.Store.Repository;
|
||||||
|
@ -19,7 +20,8 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
public class MattermostNotification : BaseNotification<MattermostNotificationSettings>, IMattermostNotification
|
public class MattermostNotification : BaseNotification<MattermostNotificationSettings>, IMattermostNotification
|
||||||
{
|
{
|
||||||
public MattermostNotification(IMattermostApi api, ISettingsService<MattermostNotificationSettings> sn, ILogger<MattermostNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t) : base(sn, r, m, t)
|
public MattermostNotification(IMattermostApi api, ISettingsService<MattermostNotificationSettings> sn, ILogger<MattermostNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t,
|
||||||
|
ISettingsService<CustomizationSettings> s) : base(sn, r, m, t,s)
|
||||||
{
|
{
|
||||||
Api = api;
|
Api = api;
|
||||||
Logger = log;
|
Logger = log;
|
||||||
|
|
|
@ -6,6 +6,7 @@ using Ombi.Core.Settings;
|
||||||
using Ombi.Helpers;
|
using Ombi.Helpers;
|
||||||
using Ombi.Notifications.Interfaces;
|
using Ombi.Notifications.Interfaces;
|
||||||
using Ombi.Notifications.Models;
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
using Ombi.Settings.Settings.Models.Notifications;
|
using Ombi.Settings.Settings.Models.Notifications;
|
||||||
using Ombi.Store.Entities;
|
using Ombi.Store.Entities;
|
||||||
using Ombi.Store.Repository;
|
using Ombi.Store.Repository;
|
||||||
|
@ -15,7 +16,8 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
public class PushbulletNotification : BaseNotification<PushbulletSettings>, IPushbulletNotification
|
public class PushbulletNotification : BaseNotification<PushbulletSettings>, IPushbulletNotification
|
||||||
{
|
{
|
||||||
public PushbulletNotification(IPushbulletApi api, ISettingsService<PushbulletSettings> sn, ILogger<PushbulletNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t) : base(sn, r, m, t)
|
public PushbulletNotification(IPushbulletApi api, ISettingsService<PushbulletSettings> sn, ILogger<PushbulletNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t,
|
||||||
|
ISettingsService<CustomizationSettings> s) : base(sn, r, m, t,s)
|
||||||
{
|
{
|
||||||
Api = api;
|
Api = api;
|
||||||
Logger = log;
|
Logger = log;
|
||||||
|
|
|
@ -7,6 +7,7 @@ using Ombi.Core.Settings;
|
||||||
using Ombi.Helpers;
|
using Ombi.Helpers;
|
||||||
using Ombi.Notifications.Interfaces;
|
using Ombi.Notifications.Interfaces;
|
||||||
using Ombi.Notifications.Models;
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
using Ombi.Settings.Settings.Models.Notifications;
|
using Ombi.Settings.Settings.Models.Notifications;
|
||||||
using Ombi.Store.Entities;
|
using Ombi.Store.Entities;
|
||||||
using Ombi.Store.Repository;
|
using Ombi.Store.Repository;
|
||||||
|
@ -16,7 +17,8 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
public class PushoverNotification : BaseNotification<PushoverSettings>, IPushoverNotification
|
public class PushoverNotification : BaseNotification<PushoverSettings>, IPushoverNotification
|
||||||
{
|
{
|
||||||
public PushoverNotification(IPushoverApi api, ISettingsService<PushoverSettings> sn, ILogger<PushbulletNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t) : base(sn, r, m, t)
|
public PushoverNotification(IPushoverApi api, ISettingsService<PushoverSettings> sn, ILogger<PushbulletNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t,
|
||||||
|
ISettingsService<CustomizationSettings> s) : base(sn, r, m, t, s)
|
||||||
{
|
{
|
||||||
Api = api;
|
Api = api;
|
||||||
Logger = log;
|
Logger = log;
|
||||||
|
|
|
@ -7,6 +7,7 @@ using Ombi.Core.Settings;
|
||||||
using Ombi.Helpers;
|
using Ombi.Helpers;
|
||||||
using Ombi.Notifications.Interfaces;
|
using Ombi.Notifications.Interfaces;
|
||||||
using Ombi.Notifications.Models;
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
using Ombi.Settings.Settings.Models.Notifications;
|
using Ombi.Settings.Settings.Models.Notifications;
|
||||||
using Ombi.Store.Entities;
|
using Ombi.Store.Entities;
|
||||||
using Ombi.Store.Repository;
|
using Ombi.Store.Repository;
|
||||||
|
@ -16,7 +17,8 @@ namespace Ombi.Notifications.Agents
|
||||||
{
|
{
|
||||||
public class SlackNotification : BaseNotification<SlackNotificationSettings>, ISlackNotification
|
public class SlackNotification : BaseNotification<SlackNotificationSettings>, ISlackNotification
|
||||||
{
|
{
|
||||||
public SlackNotification(ISlackApi api, ISettingsService<SlackNotificationSettings> sn, ILogger<SlackNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t) : base(sn, r, m, t)
|
public SlackNotification(ISlackApi api, ISettingsService<SlackNotificationSettings> sn, ILogger<SlackNotification> log, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t,
|
||||||
|
ISettingsService<CustomizationSettings> s) : base(sn, r, m, t, s)
|
||||||
{
|
{
|
||||||
Api = api;
|
Api = api;
|
||||||
Logger = log;
|
Logger = log;
|
||||||
|
|
|
@ -17,11 +17,17 @@ namespace Ombi.Notifications
|
||||||
CustomizationSettings = s;
|
CustomizationSettings = s;
|
||||||
}
|
}
|
||||||
private ISettingsService<CustomizationSettings> CustomizationSettings { get; }
|
private ISettingsService<CustomizationSettings> CustomizationSettings { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This will load up the Email template and generate the HTML
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model"></param>
|
||||||
|
/// <param name="settings"></param>
|
||||||
|
/// <returns></returns>
|
||||||
public async Task SendAdHoc(NotificationMessage model, EmailNotificationSettings settings)
|
public async Task SendAdHoc(NotificationMessage model, EmailNotificationSettings settings)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
||||||
var email = new EmailBasicTemplate();
|
var email = new EmailBasicTemplate();
|
||||||
|
|
||||||
var customization = await CustomizationSettings.GetSettingsAsync();
|
var customization = await CustomizationSettings.GetSettingsAsync();
|
||||||
|
|
|
@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using Ombi.Core.Settings;
|
using Ombi.Core.Settings;
|
||||||
using Ombi.Helpers;
|
using Ombi.Helpers;
|
||||||
using Ombi.Notifications.Models;
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
using Ombi.Store.Entities;
|
using Ombi.Store.Entities;
|
||||||
using Ombi.Store.Entities.Requests;
|
using Ombi.Store.Entities.Requests;
|
||||||
using Ombi.Store.Repository;
|
using Ombi.Store.Repository;
|
||||||
|
@ -13,18 +14,23 @@ namespace Ombi.Notifications.Interfaces
|
||||||
{
|
{
|
||||||
public abstract class BaseNotification<T> : INotification where T : Settings.Settings.Models.Settings, new()
|
public abstract class BaseNotification<T> : INotification where T : Settings.Settings.Models.Settings, new()
|
||||||
{
|
{
|
||||||
protected BaseNotification(ISettingsService<T> settings, INotificationTemplatesRepository templateRepo, IMovieRequestRepository movie, ITvRequestRepository tv)
|
protected BaseNotification(ISettingsService<T> settings, INotificationTemplatesRepository templateRepo, IMovieRequestRepository movie, ITvRequestRepository tv,
|
||||||
|
ISettingsService<CustomizationSettings> customization)
|
||||||
{
|
{
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
TemplateRepository = templateRepo;
|
TemplateRepository = templateRepo;
|
||||||
MovieRepository = movie;
|
MovieRepository = movie;
|
||||||
TvRepository = tv;
|
TvRepository = tv;
|
||||||
|
CustomizationSettings = customization;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected ISettingsService<T> Settings { get; }
|
protected ISettingsService<T> Settings { get; }
|
||||||
protected INotificationTemplatesRepository TemplateRepository { get; }
|
protected INotificationTemplatesRepository TemplateRepository { get; }
|
||||||
protected IMovieRequestRepository MovieRepository { get; }
|
protected IMovieRequestRepository MovieRepository { get; }
|
||||||
protected ITvRequestRepository TvRepository { get; }
|
protected ITvRequestRepository TvRepository { get; }
|
||||||
|
protected CustomizationSettings Customization { get; set; }
|
||||||
|
private ISettingsService<CustomizationSettings> CustomizationSettings { get; }
|
||||||
|
|
||||||
|
|
||||||
protected ChildRequests TvRequest { get; set; }
|
protected ChildRequests TvRequest { get; set; }
|
||||||
protected MovieRequests MovieRequest { get; set; }
|
protected MovieRequests MovieRequest { get; set; }
|
||||||
|
@ -54,6 +60,8 @@ namespace Ombi.Notifications.Interfaces
|
||||||
{
|
{
|
||||||
await LoadRequest(model.RequestId, model.RequestType);
|
await LoadRequest(model.RequestId, model.RequestType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Customization = await CustomizationSettings.GetSettingsAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
switch (model.NotificationType)
|
switch (model.NotificationType)
|
||||||
|
@ -129,11 +137,11 @@ namespace Ombi.Notifications.Interfaces
|
||||||
var curlys = new NotificationMessageCurlys();
|
var curlys = new NotificationMessageCurlys();
|
||||||
if (model.RequestType == RequestType.Movie)
|
if (model.RequestType == RequestType.Movie)
|
||||||
{
|
{
|
||||||
curlys.Setup(MovieRequest);
|
curlys.Setup(MovieRequest, Customization);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
curlys.Setup(TvRequest);
|
curlys.Setup(TvRequest, Customization);
|
||||||
}
|
}
|
||||||
var parsed = resolver.ParseMessage(template, curlys);
|
var parsed = resolver.ParseMessage(template, curlys);
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
|
using Ombi.Store.Entities;
|
||||||
using Ombi.Store.Entities.Requests;
|
using Ombi.Store.Entities.Requests;
|
||||||
|
|
||||||
namespace Ombi.Notifications
|
namespace Ombi.Notifications
|
||||||
|
@ -7,8 +9,9 @@ namespace Ombi.Notifications
|
||||||
public class NotificationMessageCurlys
|
public class NotificationMessageCurlys
|
||||||
{
|
{
|
||||||
|
|
||||||
public void Setup(FullBaseRequest req)
|
public void Setup(FullBaseRequest req, CustomizationSettings s)
|
||||||
{
|
{
|
||||||
|
ApplicationName = string.IsNullOrEmpty(s.ApplicationName) ? "Ombi" : s.ApplicationName;
|
||||||
RequestedUser = string.IsNullOrEmpty(req.RequestedUser.Alias)
|
RequestedUser = string.IsNullOrEmpty(req.RequestedUser.Alias)
|
||||||
? req.RequestedUser.UserName
|
? req.RequestedUser.UserName
|
||||||
: req.RequestedUser.Alias;
|
: req.RequestedUser.Alias;
|
||||||
|
@ -20,8 +23,9 @@ namespace Ombi.Notifications
|
||||||
PosterImage = req.PosterPath;
|
PosterImage = req.PosterPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Setup(ChildRequests req)
|
public void Setup(ChildRequests req, CustomizationSettings s)
|
||||||
{
|
{
|
||||||
|
ApplicationName = string.IsNullOrEmpty(s.ApplicationName) ? "Ombi" : s.ApplicationName;
|
||||||
RequestedUser = string.IsNullOrEmpty(req.RequestedUser.Alias)
|
RequestedUser = string.IsNullOrEmpty(req.RequestedUser.Alias)
|
||||||
? req.RequestedUser.UserName
|
? req.RequestedUser.UserName
|
||||||
: req.RequestedUser.Alias;
|
: req.RequestedUser.Alias;
|
||||||
|
@ -34,6 +38,12 @@ namespace Ombi.Notifications
|
||||||
// DO Episode and Season Lists
|
// DO Episode and Season Lists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Setup(OmbiUser user, CustomizationSettings s)
|
||||||
|
{
|
||||||
|
ApplicationName = string.IsNullOrEmpty(s.ApplicationName) ? "Ombi" : s.ApplicationName;
|
||||||
|
RequestedUser = user.UserName;
|
||||||
|
}
|
||||||
|
|
||||||
// User Defined
|
// User Defined
|
||||||
public string RequestedUser { get; set; }
|
public string RequestedUser { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
@ -45,6 +55,7 @@ namespace Ombi.Notifications
|
||||||
public string EpisodesList { get; set; }
|
public string EpisodesList { get; set; }
|
||||||
public string SeasonsList { get; set; }
|
public string SeasonsList { get; set; }
|
||||||
public string PosterImage { get; set; }
|
public string PosterImage { get; set; }
|
||||||
|
public string ApplicationName { get; set; }
|
||||||
|
|
||||||
// System Defined
|
// System Defined
|
||||||
private string LongDate => DateTime.Now.ToString("D");
|
private string LongDate => DateTime.Now.ToString("D");
|
||||||
|
@ -68,6 +79,7 @@ namespace Ombi.Notifications
|
||||||
{nameof(EpisodesList),EpisodesList},
|
{nameof(EpisodesList),EpisodesList},
|
||||||
{nameof(SeasonsList),SeasonsList},
|
{nameof(SeasonsList),SeasonsList},
|
||||||
{nameof(PosterImage),PosterImage},
|
{nameof(PosterImage),PosterImage},
|
||||||
|
{nameof(ApplicationName),ApplicationName},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
10
src/Ombi.Schedule/Jobs/Ombi/IWelcomeEmail.cs
Normal file
10
src/Ombi.Schedule/Jobs/Ombi/IWelcomeEmail.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Ombi.Store.Entities;
|
||||||
|
|
||||||
|
namespace Ombi.Schedule.Jobs.Ombi
|
||||||
|
{
|
||||||
|
public interface IWelcomeEmail
|
||||||
|
{
|
||||||
|
Task SendEmail(OmbiUser user);
|
||||||
|
}
|
||||||
|
}
|
64
src/Ombi.Schedule/Jobs/Ombi/WelcomeEmail.cs
Normal file
64
src/Ombi.Schedule/Jobs/Ombi/WelcomeEmail.cs
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Ombi.Core.Settings;
|
||||||
|
using Ombi.Helpers;
|
||||||
|
using Ombi.Notifications;
|
||||||
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Settings.Settings.Models;
|
||||||
|
using Ombi.Settings.Settings.Models.Notifications;
|
||||||
|
using Ombi.Store.Entities;
|
||||||
|
using Ombi.Store.Repository;
|
||||||
|
|
||||||
|
namespace Ombi.Schedule.Jobs.Ombi
|
||||||
|
{
|
||||||
|
public class WelcomeEmail : IWelcomeEmail
|
||||||
|
{
|
||||||
|
public WelcomeEmail(ISettingsService<EmailNotificationSettings> email, INotificationTemplatesRepository template, ISettingsService<CustomizationSettings> c,
|
||||||
|
IEmailProvider provider)
|
||||||
|
{
|
||||||
|
_emailSettings = email;
|
||||||
|
_email = provider;
|
||||||
|
_templates = template;
|
||||||
|
_customizationSettings = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ISettingsService<EmailNotificationSettings> _emailSettings;
|
||||||
|
private readonly ISettingsService<CustomizationSettings> _customizationSettings;
|
||||||
|
private readonly INotificationTemplatesRepository _templates;
|
||||||
|
private readonly IEmailProvider _email;
|
||||||
|
|
||||||
|
public async Task SendEmail(OmbiUser user)
|
||||||
|
{
|
||||||
|
var settings = await _emailSettings.GetSettingsAsync();
|
||||||
|
if (!settings.Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var template = await _templates.GetTemplate(NotificationAgent.Email, NotificationType.WelcomeEmail);
|
||||||
|
if (!template.Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cs = await _customizationSettings.GetSettingsAsync();
|
||||||
|
var parsed = Parse(user, template, cs);
|
||||||
|
|
||||||
|
var message = new NotificationMessage
|
||||||
|
{
|
||||||
|
Message = parsed.Message,
|
||||||
|
Subject = parsed.Subject,
|
||||||
|
To = user.Email,
|
||||||
|
};
|
||||||
|
await _email.SendAdHoc(message, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NotificationMessageContent Parse(OmbiUser u, NotificationTemplates template, CustomizationSettings cs)
|
||||||
|
{
|
||||||
|
var resolver = new NotificationMessageResolver();
|
||||||
|
var curlys = new NotificationMessageCurlys();
|
||||||
|
curlys.Setup(u, cs);
|
||||||
|
var parsed = resolver.ParseMessage(template, curlys);
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -115,7 +115,7 @@ namespace Ombi.Store.Context
|
||||||
{
|
{
|
||||||
NotificationType = notificationType,
|
NotificationType = notificationType,
|
||||||
Message = "Hello! The user '{RequestedUser}' has requested the {Type} '{Title}'! Please log in to approve this request. Request Date: {RequestedDate}",
|
Message = "Hello! The user '{RequestedUser}' has requested the {Type} '{Title}'! Please log in to approve this request. Request Date: {RequestedDate}",
|
||||||
Subject = "Ombi: New {Type} request for {Title}!",
|
Subject = "{ApplicationName}: New {Type} request for {Title}!",
|
||||||
Agent = agent,
|
Agent = agent,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
};
|
};
|
||||||
|
@ -125,7 +125,7 @@ namespace Ombi.Store.Context
|
||||||
{
|
{
|
||||||
NotificationType = notificationType,
|
NotificationType = notificationType,
|
||||||
Message = "Hello! The user '{RequestedUser}' has reported a new issue for the title {Title}! </br> {Issue}",
|
Message = "Hello! The user '{RequestedUser}' has reported a new issue for the title {Title}! </br> {Issue}",
|
||||||
Subject = "Ombi: New issue for {Title}!",
|
Subject = "{ApplicationName}: New issue for {Title}!",
|
||||||
Agent = agent,
|
Agent = agent,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
};
|
};
|
||||||
|
@ -134,8 +134,8 @@ namespace Ombi.Store.Context
|
||||||
notificationToAdd = new NotificationTemplates
|
notificationToAdd = new NotificationTemplates
|
||||||
{
|
{
|
||||||
NotificationType = notificationType,
|
NotificationType = notificationType,
|
||||||
Message = "Hello! You requested {Title} on Ombi! This is now available! :)",
|
Message = "Hello! You requested {Title} on {ApplicationName}! This is now available! :)",
|
||||||
Subject = "Ombi: {Title} is now available!",
|
Subject = "{ApplicationName}: {Title} is now available!",
|
||||||
Agent = agent,
|
Agent = agent,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
};
|
};
|
||||||
|
@ -145,7 +145,7 @@ namespace Ombi.Store.Context
|
||||||
{
|
{
|
||||||
NotificationType = notificationType,
|
NotificationType = notificationType,
|
||||||
Message = "Hello! Your request for {Title} has been approved!",
|
Message = "Hello! Your request for {Title} has been approved!",
|
||||||
Subject = "Ombi: your request has been approved",
|
Subject = "{ApplicationName}: your request has been approved",
|
||||||
Agent = agent,
|
Agent = agent,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
};
|
};
|
||||||
|
@ -159,13 +159,23 @@ namespace Ombi.Store.Context
|
||||||
{
|
{
|
||||||
NotificationType = notificationType,
|
NotificationType = notificationType,
|
||||||
Message = "Hello! Your request for {Title} has been declined, Sorry!",
|
Message = "Hello! Your request for {Title} has been declined, Sorry!",
|
||||||
Subject = "Ombi: your request has been declined",
|
Subject = "{ApplicationName}: your request has been declined",
|
||||||
Agent = agent,
|
Agent = agent,
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case NotificationType.ItemAddedToFaultQueue:
|
case NotificationType.ItemAddedToFaultQueue:
|
||||||
continue;
|
continue;
|
||||||
|
case NotificationType.WelcomeEmail:
|
||||||
|
notificationToAdd = new NotificationTemplates
|
||||||
|
{
|
||||||
|
NotificationType = notificationType,
|
||||||
|
Message = "Hello! You have been invited to use {ApplicationName}!",
|
||||||
|
Subject = "Invite to {ApplicationName}",
|
||||||
|
Agent = agent,
|
||||||
|
Enabled = true,
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ export enum NotificationType {
|
||||||
Test,
|
Test,
|
||||||
RequestDeclined,
|
RequestDeclined,
|
||||||
ItemAddedToFaultQueue,
|
ItemAddedToFaultQueue,
|
||||||
|
WelcomeEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDiscordNotifcationSettings extends INotificationSettings {
|
export interface IDiscordNotifcationSettings extends INotificationSettings {
|
||||||
|
|
|
@ -54,6 +54,10 @@ export class IdentityService extends ServiceAuthHelpers {
|
||||||
return this.regularHttp.post(this.url + "resetpassword", JSON.stringify(token), { headers: this.headers }).map(this.extractData);
|
return this.regularHttp.post(this.url + "resetpassword", JSON.stringify(token), { headers: this.headers }).map(this.extractData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sendWelcomeEmail(user: IUser): Observable<null> {
|
||||||
|
return this.http.post(`${this.url}welcomeEmail`, JSON.stringify(user), { headers: this.headers }).map(this.extractData);
|
||||||
|
}
|
||||||
|
|
||||||
public hasRole(role: string): boolean {
|
public hasRole(role: string): boolean {
|
||||||
const roles = localStorage.getItem("roles") as string[] | null;
|
const roles = localStorage.getItem("roles") as string[] | null;
|
||||||
if (roles) {
|
if (roles) {
|
||||||
|
|
|
@ -20,6 +20,7 @@ export class NotificationTemplate {
|
||||||
{EpisodesList} : A comma seperated list of Episodes requested<br/>
|
{EpisodesList} : A comma seperated list of Episodes requested<br/>
|
||||||
{SeasonsList} : A comma seperated list of seasons requested<br/>
|
{SeasonsList} : A comma seperated list of seasons requested<br/>
|
||||||
{PosterImage} : The requested poster image link<br/>
|
{PosterImage} : The requested poster image link<br/>
|
||||||
|
{ApplicationName} : The Application Name from the Customization Settings
|
||||||
{LongDate} : 15 June 2017 <br/>
|
{LongDate} : 15 June 2017 <br/>
|
||||||
{ShortDate} : 15/06/2017 <br/>
|
{ShortDate} : 15/06/2017 <br/>
|
||||||
{LongTime} : 16:02:34 <br/>
|
{LongTime} : 16:02:34 <br/>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Component, OnInit } from "@angular/core";
|
import { Component, OnInit } from "@angular/core";
|
||||||
|
|
||||||
import { IUser } from "../interfaces";
|
import { IEmailNotificationSettings, IUser } from "../interfaces";
|
||||||
import { IdentityService } from "../services";
|
import { IdentityService, NotificationService, SettingsService } from "../services";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
templateUrl: "./usermanagement.component.html",
|
templateUrl: "./usermanagement.component.html",
|
||||||
|
@ -10,8 +10,11 @@ export class UserManagementComponent implements OnInit {
|
||||||
|
|
||||||
public users: IUser[];
|
public users: IUser[];
|
||||||
public checkAll = false;
|
public checkAll = false;
|
||||||
|
public emailSettings: IEmailNotificationSettings;
|
||||||
|
|
||||||
constructor(private identityService: IdentityService) { }
|
constructor(private identityService: IdentityService,
|
||||||
|
private settingsService: SettingsService,
|
||||||
|
private notificationService: NotificationService) { }
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.users = [];
|
this.users = [];
|
||||||
|
@ -19,10 +22,15 @@ export class UserManagementComponent implements OnInit {
|
||||||
this.users = x;
|
this.users = x;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.settingsService.getEmailNotificationSettings().subscribe(x => this.emailSettings = x);
|
||||||
}
|
}
|
||||||
|
|
||||||
public welcomeEmail(user: IUser) {
|
public welcomeEmail(user: IUser) {
|
||||||
// todo
|
if (!this.emailSettings.enabled) {
|
||||||
|
this.notificationService.error("Email", "Email Notifications are not setup, cannot send welcome email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.identityService.sendWelcomeEmail(user).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
public checkAllBoxes() {
|
public checkAllBoxes() {
|
||||||
|
|
|
@ -5,6 +5,7 @@ using System.Net;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
|
using Hangfire;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
@ -18,13 +19,16 @@ using Ombi.Core.Claims;
|
||||||
using Ombi.Core.Helpers;
|
using Ombi.Core.Helpers;
|
||||||
using Ombi.Core.Models.UI;
|
using Ombi.Core.Models.UI;
|
||||||
using Ombi.Core.Settings;
|
using Ombi.Core.Settings;
|
||||||
|
using Ombi.Helpers;
|
||||||
using Ombi.Models;
|
using Ombi.Models;
|
||||||
using Ombi.Models.Identity;
|
using Ombi.Models.Identity;
|
||||||
using Ombi.Notifications;
|
using Ombi.Notifications;
|
||||||
using Ombi.Notifications.Models;
|
using Ombi.Notifications.Models;
|
||||||
|
using Ombi.Schedule.Jobs.Ombi;
|
||||||
using Ombi.Settings.Settings.Models;
|
using Ombi.Settings.Settings.Models;
|
||||||
using Ombi.Settings.Settings.Models.Notifications;
|
using Ombi.Settings.Settings.Models.Notifications;
|
||||||
using Ombi.Store.Entities;
|
using Ombi.Store.Entities;
|
||||||
|
using Ombi.Store.Repository;
|
||||||
using OmbiIdentityResult = Ombi.Models.Identity.IdentityResult;
|
using OmbiIdentityResult = Ombi.Models.Identity.IdentityResult;
|
||||||
|
|
||||||
namespace Ombi.Controllers
|
namespace Ombi.Controllers
|
||||||
|
@ -40,7 +44,8 @@ namespace Ombi.Controllers
|
||||||
public IdentityController(UserManager<OmbiUser> user, IMapper mapper, RoleManager<IdentityRole> rm, IEmailProvider prov,
|
public IdentityController(UserManager<OmbiUser> user, IMapper mapper, RoleManager<IdentityRole> rm, IEmailProvider prov,
|
||||||
ISettingsService<EmailNotificationSettings> s,
|
ISettingsService<EmailNotificationSettings> s,
|
||||||
ISettingsService<CustomizationSettings> c,
|
ISettingsService<CustomizationSettings> c,
|
||||||
IOptions<UserSettings> userSettings)
|
IOptions<UserSettings> userSettings,
|
||||||
|
IWelcomeEmail welcome)
|
||||||
{
|
{
|
||||||
UserManager = user;
|
UserManager = user;
|
||||||
Mapper = mapper;
|
Mapper = mapper;
|
||||||
|
@ -49,6 +54,7 @@ namespace Ombi.Controllers
|
||||||
EmailSettings = s;
|
EmailSettings = s;
|
||||||
CustomizationSettings = c;
|
CustomizationSettings = c;
|
||||||
UserSettings = userSettings;
|
UserSettings = userSettings;
|
||||||
|
WelcomeEmail = welcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserManager<OmbiUser> UserManager { get; }
|
private UserManager<OmbiUser> UserManager { get; }
|
||||||
|
@ -58,6 +64,7 @@ namespace Ombi.Controllers
|
||||||
private ISettingsService<EmailNotificationSettings> EmailSettings { get; }
|
private ISettingsService<EmailNotificationSettings> EmailSettings { get; }
|
||||||
private ISettingsService<CustomizationSettings> CustomizationSettings { get; }
|
private ISettingsService<CustomizationSettings> CustomizationSettings { get; }
|
||||||
private IOptions<UserSettings> UserSettings { get; }
|
private IOptions<UserSettings> UserSettings { get; }
|
||||||
|
private IWelcomeEmail WelcomeEmail { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This is what the Wizard will call when creating the user for the very first time.
|
/// This is what the Wizard will call when creating the user for the very first time.
|
||||||
|
@ -517,6 +524,17 @@ namespace Ombi.Controllers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("welcomeEmail")]
|
||||||
|
public void SendWelcomeEmail([FromBody] UserViewModel user)
|
||||||
|
{
|
||||||
|
var ombiUser = new OmbiUser
|
||||||
|
{
|
||||||
|
Email = user.EmailAddress,
|
||||||
|
UserName = user.Username
|
||||||
|
};
|
||||||
|
BackgroundJob.Enqueue(() => WelcomeEmail.SendEmail(ombiUser));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<Microsoft.AspNetCore.Identity.IdentityResult>> AddRoles(IEnumerable<ClaimCheckboxes> roles, OmbiUser ombiUser)
|
private async Task<List<Microsoft.AspNetCore.Identity.IdentityResult>> AddRoles(IEnumerable<ClaimCheckboxes> roles, OmbiUser ombiUser)
|
||||||
{
|
{
|
||||||
var roleResult = new List<Microsoft.AspNetCore.Identity.IdentityResult>();
|
var roleResult = new List<Microsoft.AspNetCore.Identity.IdentityResult>();
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue