From b77fa3307efcff98bc1464d21e7467bb259ffec9 Mon Sep 17 00:00:00 2001 From: Kukks Date: Fri, 10 Nov 2023 12:40:08 +0100 Subject: [PATCH] dynamic reports --- BTCPayServerPlugins.sln | 10 ++ ...BTCPayServer.Plugins.DynamicReports.csproj | 35 ++++ .../DynamicReportService.cs | 96 +++++++++++ .../DynamicReportsController.cs | 157 ++++++++++++++++++ .../DynamicReportsPlugin.cs | 24 +++ .../DynamicReportsSettings.cs | 25 +++ .../LegacyInvoiceExportReportProvider.cs | 150 +++++++++++++++++ .../PostgresReportProvider.cs | 124 ++++++++++++++ .../README.md | 10 ++ .../Views/DynamicReports/Update.cshtml | 131 +++++++++++++++ .../Shared/DynamicReportsPlugin/Nav.cshtml | 8 + .../_ViewImports.cshtml | 5 + 12 files changed, 775 insertions(+) create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/BTCPayServer.Plugins.DynamicReports.csproj create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportService.cs create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsController.cs create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsPlugin.cs create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsSettings.cs create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/LegacyInvoiceExportReportProvider.cs create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/PostgresReportProvider.cs create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/README.md create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/Views/DynamicReports/Update.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/Views/Shared/DynamicReportsPlugin/Nav.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.DynamicReports/_ViewImports.cshtml diff --git a/BTCPayServerPlugins.sln b/BTCPayServerPlugins.sln index 51357f0..9a59434 100644 --- a/BTCPayServerPlugins.sln +++ b/BTCPayServerPlugins.sln @@ -51,6 +51,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Prism" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.FileSeller", "Plugins\BTCPayServer.Plugins.FileSeller\BTCPayServer.Plugins.FileSeller.csproj", "{F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.DynamicReports", "Plugins\BTCPayServer.Plugins.DynamicReports\BTCPayServer.Plugins.DynamicReports.csproj", "{BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -243,6 +245,14 @@ Global {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Release|Any CPU.ActiveCfg = Release|Any CPU {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Release|Any CPU.Build.0 = Release|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Release|Any CPU.Build.0 = Release|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU + {BCB4E68D-089F-481E-A3AE-FC9CED6AA34D}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962} diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/BTCPayServer.Plugins.DynamicReports.csproj b/Plugins/BTCPayServer.Plugins.DynamicReports/BTCPayServer.Plugins.DynamicReports.csproj new file mode 100644 index 0000000..6d37b70 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/BTCPayServer.Plugins.DynamicReports.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + 10 + + + + + Dynamic Reports + Allows you to create custom reports using SQL. + 1.0.0 + + + + true + false + true + + + + + + StaticWebAssetsEnabled=false + false + runtime;native;build;buildTransitive;contentFiles + + + + + + + + + diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportService.cs b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportService.cs new file mode 100644 index 0000000..ecdf518 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportService.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Services; +using BTCPayServer.Services.Reporting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace BTCPayServer.Plugins.DynamicReports; + +public class DynamicReportService:IHostedService +{ + private readonly SettingsRepository _settingsRepository; + private readonly ReportService _reportService; + private readonly IServiceProvider _serviceProvider; + + public DynamicReportService(SettingsRepository settingsRepository, ReportService reportService, IServiceProvider serviceProvider) + { + _settingsRepository = settingsRepository; + _reportService = reportService; + _serviceProvider = serviceProvider; + } + public async Task StartAsync(CancellationToken cancellationToken) + { + var result = await _settingsRepository.GetSettingAsync(); + if (result?.Reports?.Any() is true) + { + foreach (var report in result.Reports) + { + var reportProvider = ActivatorUtilities.CreateInstance(_serviceProvider); + reportProvider.Setting = report.Value; + reportProvider.ReportName = report.Key; + _reportService.ReportProviders.TryAdd(report.Key, reportProvider); + } + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + } + + + + public async Task UpdateDynamicReport(string name, DynamicReportsSettings.DynamicReportSetting setting) + { + _reportService.ReportProviders.TryGetValue(name, out var report); + if (report is not null && report is not PostgresReportProvider) + { + throw new InvalidOperationException("Only PostgresReportProvider can be updated dynamically"); + } + + var result = await _settingsRepository.GetSettingAsync() ?? new DynamicReportsSettings(); + if (report is PostgresReportProvider postgresReportProvider) + { + if (setting is null) + { + //remove report + _reportService.ReportProviders.Remove(name); + + result.Reports.Remove(name); + await _settingsRepository.UpdateSetting(result); + } + else + { + postgresReportProvider.Setting = setting; + result.Reports[name] = setting; + postgresReportProvider.ReportName = name; + await _settingsRepository.UpdateSetting(result); + } + } + else if (setting is not null) + { + var reportProvider = ActivatorUtilities.CreateInstance(_serviceProvider); + reportProvider.Setting = setting; + + reportProvider.ReportName = name; + result.Reports[name] = setting; + await _settingsRepository.UpdateSetting(result); + _reportService.ReportProviders.TryAdd(name, reportProvider); + } + } + + public async Task IsLegacyEnabled() + { + var result = await _settingsRepository.GetSettingAsync(); + return result?.EnableLegacyInvoiceExport is true; + } + public async Task ToggleLegacy() + { + var result = await _settingsRepository.GetSettingAsync() ?? new DynamicReportsSettings(); + result.EnableLegacyInvoiceExport = !result.EnableLegacyInvoiceExport; + await _settingsRepository.UpdateSetting(result); + return result.EnableLegacyInvoiceExport; + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsController.cs b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsController.cs new file mode 100644 index 0000000..7d1e6b5 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsController.cs @@ -0,0 +1,157 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; +using BTCPayServer.Data; +using BTCPayServer.Services; +using BTCPayServer.Services.Reporting; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.DynamicReports; +[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] +[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] +[Route("~/plugins/dynamicreports")] +public class DynamicReportsController : Controller +{ + private readonly DynamicReportService _dynamicReportService; + private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly ReportService _reportService; + private readonly IScopeProvider _scopeProvider; + + public DynamicReportsController(ReportService reportService, + IScopeProvider scopeProvider, + DynamicReportService dynamicReportService, ApplicationDbContextFactory dbContextFactory) + { + _dynamicReportService = dynamicReportService; + _dbContextFactory = dbContextFactory; + _reportService = reportService; + _scopeProvider = scopeProvider; + } + + [HttpGet("toggle-legacy")] + public async Task ToggleLegacy() + { + var result = await _dynamicReportService.ToggleLegacy(); + TempData[WellKnownTempData.SuccessMessage] = $"Legacy report {(result? "enabled" : "disabled")}"; + return RedirectToAction(nameof(Update)); + } + + [HttpGet("update")] + [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public IActionResult Update( + string? reportName, string? viewName) + { + + if (!string.IsNullOrEmpty(viewName) && _reportService.ReportProviders.TryGetValue(viewName, out var vnReport) && + vnReport is PostgresReportProvider) + { + return RedirectToAction(nameof(Update), new {reportName = viewName}); + + } + + if (reportName is null) return View(new DynamicReportViewModel()); + + if (!_reportService.ReportProviders.TryGetValue(reportName, out var report)) + { + return NotFound(); + } + + if (report is not PostgresReportProvider postgresReportProvider) + { + return NotFound(); + } + + return View(new DynamicReportViewModel() + { + Name = reportName, + Sql = postgresReportProvider.Setting.Sql, + AllowForNonAdmins = postgresReportProvider.Setting.AllowForNonAdmins + }); + + } + [HttpPost("update")] + [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Update( + string reportName, DynamicReportViewModel vm, string command) + { + ModelState.Clear(); + if (command == "remove" && reportName is not null) + { + await _dynamicReportService.UpdateDynamicReport(reportName, null); + TempData[WellKnownTempData.SuccessMessage] = $"Report {reportName} removed"; + return RedirectToAction(nameof(Update)); + } + + if (command == "test") + { + if(string.IsNullOrEmpty(vm.Sql)) + { + ModelState.AddModelError(nameof(vm.Sql), "SQL is required"); + return View(vm); + } + try + { + var context = new QueryContext(_scopeProvider.GetCurrentStoreId(), DateTimeOffset.MinValue, + DateTimeOffset.MaxValue); + await PostgresReportProvider.ExecuteQuery(_dbContextFactory, context, vm.Sql, CancellationToken.None); + + TempData["Data"] = JsonConvert.SerializeObject(context); + TempData[WellKnownTempData.SuccessMessage] = $"Fetched {context.Data.Count} rows with {context.ViewDefinition?.Fields.Count} columns"; + + } + catch (Exception e) + { + ModelState.AddModelError(nameof(vm.Sql), "Could not execute SQL: " + e.Message); + } + + + return View(vm); + } + + string msg = null; + if(string.IsNullOrEmpty(vm.Sql)) + { + ModelState.AddModelError(nameof(vm.Sql), "SQL is required"); + } + else + { + try + { + var context = new QueryContext(_scopeProvider.GetCurrentStoreId(), DateTimeOffset.MinValue, + DateTimeOffset.MaxValue); + await PostgresReportProvider.ExecuteQuery(_dbContextFactory, context, vm.Sql, CancellationToken.None); + msg = $"Fetched {context.Data.Count} rows with {context.ViewDefinition?.Fields.Count} columns"; + TempData["Data"] = JsonConvert.SerializeObject(context); + } + catch (Exception e) + { + ModelState.AddModelError(nameof(vm.Sql), "Could not execute SQL: " + e.Message); + } + } + if(string.IsNullOrEmpty(vm.Name)) + { + ModelState.AddModelError(nameof(vm.Name), "Name is required"); + } + if (!ModelState.IsValid) + { + return View(vm); + } + + await _dynamicReportService.UpdateDynamicReport(reportName??vm.Name, vm); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Html = $"Report {reportName} {(reportName is null ? "created" : "updated")}{(msg is null? string.Empty: $"
{msg})")}" + }); + TempData[WellKnownTempData.SuccessMessage] = $"Report {reportName} {(reportName is null ? "created" : "updated")}"; + + return RedirectToAction(nameof(Update) , new {reportName = reportName??vm.Name}); + + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsPlugin.cs b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsPlugin.cs new file mode 100644 index 0000000..de36dc0 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsPlugin.cs @@ -0,0 +1,24 @@ +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace BTCPayServer.Plugins.DynamicReports; + +public class DynamicReportsPlugin : BaseBTCPayServerPlugin +{ + public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = + { + new() { Identifier = nameof(BTCPayServer), Condition = ">=1.11.7" } + }; + public override void Execute(IServiceCollection applicationBuilder) + { + applicationBuilder.AddSingleton(); + applicationBuilder.AddReportProvider(); + applicationBuilder.AddSingleton(provider => provider.GetRequiredService()); + applicationBuilder.AddSingleton(new UIExtension("DynamicReportsPlugin/Nav", + "server-nav")); + base.Execute(applicationBuilder); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsSettings.cs b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsSettings.cs new file mode 100644 index 0000000..aaa6c2d --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/DynamicReportsSettings.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace BTCPayServer.Plugins.DynamicReports; + +public class DynamicReportsSettings +{ + public Dictionary Reports { get; set; } = new(); + public bool EnableLegacyInvoiceExport { get; set; } + + public class DynamicReportSetting + { + [Required] + public string Sql { get; set; } + + public bool AllowForNonAdmins { get; set; } + } +} + +public class DynamicReportViewModel:DynamicReportsSettings.DynamicReportSetting +{ + [Required] + public string Name { get; set; } + +} diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/LegacyInvoiceExportReportProvider.cs b/Plugins/BTCPayServer.Plugins.DynamicReports/LegacyInvoiceExportReportProvider.cs new file mode 100644 index 0000000..9ed2ccd --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/LegacyInvoiceExportReportProvider.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Reporting; + +namespace BTCPayServer.Plugins.DynamicReports; + +public class LegacyInvoiceExportReportProvider : ReportProvider +{ + private readonly CurrencyNameTable _currencyNameTable; + private readonly InvoiceRepository _invoiceRepository; + private readonly SettingsRepository _settingsRepository; + + public override bool IsAvailable() => _settingsRepository.GetSettingAsync().GetAwaiter() + .GetResult()?.EnableLegacyInvoiceExport is true; + + public override string Name { get; } = "Invoice Export (legacy)"; + + public override async Task Query(QueryContext queryContext, CancellationToken cancellation) + { + var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery() + { + EndDate = queryContext.To, + StartDate = queryContext.From, + StoreId = new[] {queryContext.StoreId}, + }, cancellation); + + queryContext.ViewDefinition = new ViewDefinition() + { + Fields = new List() + { + new("ReceivedDate", "datetime"), + new("StoreId", "text"), + new("OrderId", "text"), + new("InvoiceId", "text"), + new("InvoiceCreatedDate", "datetime"), + new("InvoiceExpirationDate", "datetime"), + new("InvoiceMonitoringDate", "datetime"), + new("PaymentId", "text"), + new("Destination", "text"), + new("PaymentType", "text"), + new("CryptoCode", "text"), + new("Paid", "text"), + new("NetworkFee", "text"), + new("ConversionRate", "number"), + new("PaidCurrency", "text"), + new("InvoiceCurrency", "text"), + new("InvoiceDue", "number"), + new("InvoicePrice", "number"), + new("InvoiceItemCode", "text"), + new("InvoiceItemDesc", "text"), + new("InvoiceFullStatus", "text"), + new("InvoiceStatus", "text"), + new("InvoiceExceptionStatus", "text"), + new("BuyerEmail", "text"), + new("Accounted", "boolean") + } + }; + + foreach (var invoiceEntity in invoices) + { + var currency = _currencyNameTable.GetNumberFormatInfo(invoiceEntity.Currency, true); + var invoiceDue = invoiceEntity.Price; + var payments = invoiceEntity.GetPayments(false); + + if (payments.Count > 0) + { + foreach (var payment in payments) + { + var pdata = payment.GetCryptoPaymentData(); + invoiceDue -= payment.InvoicePaidAmount.Net; + var data = queryContext.AddData(); + + // Add each field in the order defined in ViewDefinition + data.Add(payment.ReceivedTime); + data.Add(invoiceEntity.StoreId); + data.Add(invoiceEntity.Metadata.OrderId ?? string.Empty); + data.Add(invoiceEntity.Id); + data.Add(invoiceEntity.InvoiceTime); + data.Add(invoiceEntity.ExpirationTime); + data.Add(invoiceEntity.MonitoringExpiration); + data.Add(pdata.GetPaymentId()); + data.Add(pdata.GetDestination()); + data.Add(payment.GetPaymentMethodId().PaymentType.ToPrettyString()); + data.Add(payment.Currency); + data.Add(payment.PaidAmount.Gross.ToString(CultureInfo.InvariantCulture)); + data.Add(payment.NetworkFee.ToString(CultureInfo.InvariantCulture)); + data.Add(payment.Rate); + data.Add(Math.Round(payment.InvoicePaidAmount.Gross, currency.NumberDecimalDigits) + .ToString(CultureInfo.InvariantCulture)); + data.Add(invoiceEntity.Currency); + data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits)); + data.Add(invoiceEntity.Price); + data.Add(invoiceEntity.Metadata.ItemCode); + data.Add(invoiceEntity.Metadata.ItemDesc); + data.Add(invoiceEntity.GetInvoiceState().ToString()); + data.Add(invoiceEntity.StatusString); + data.Add(invoiceEntity.ExceptionStatusString); + data.Add(invoiceEntity.Metadata.BuyerEmail); + data.Add(payment.Accounted); + } + } + else + { + var data = queryContext.AddData(); + + // Add fields for invoices without payments + data.Add(null); // ReceivedDate + data.Add(invoiceEntity.StoreId); + data.Add(invoiceEntity.Metadata.OrderId ?? string.Empty); + data.Add(invoiceEntity.Id); + data.Add(invoiceEntity.InvoiceTime); + data.Add(invoiceEntity.ExpirationTime); + data.Add(invoiceEntity.MonitoringExpiration); + data.Add(null); // PaymentId + data.Add(null); // Destination + data.Add(null); // PaymentType + data.Add(null); // CryptoCode + data.Add(null); // Paid + data.Add(null); // NetworkFee + data.Add(null); // ConversionRate + data.Add(null); // PaidCurrency + data.Add(invoiceEntity.Currency); + data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits)); // InvoiceDue + data.Add(invoiceEntity.Price); + data.Add(invoiceEntity.Metadata.ItemCode); + data.Add(invoiceEntity.Metadata.ItemDesc); + data.Add(invoiceEntity.GetInvoiceState().ToString()); + data.Add(invoiceEntity.StatusString); + data.Add(invoiceEntity.ExceptionStatusString); + data.Add(invoiceEntity.Metadata.BuyerEmail); + data.Add(null); // Accounted + } + } + } + + public LegacyInvoiceExportReportProvider(CurrencyNameTable currencyNameTable, InvoiceRepository invoiceRepository, + SettingsRepository settingsRepository) + { + _currencyNameTable = currencyNameTable; + _invoiceRepository = invoiceRepository; + _settingsRepository = settingsRepository; + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/PostgresReportProvider.cs b/Plugins/BTCPayServer.Plugins.DynamicReports/PostgresReportProvider.cs new file mode 100644 index 0000000..0e47df3 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/PostgresReportProvider.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Services.Reporting; +using Dapper; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Plugins.DynamicReports; + +public class PostgresReportProvider : ReportProvider +{ + public string ReportName { get; set; } + public override string Name => ReportName; + public DynamicReportsSettings.DynamicReportSetting Setting { get; set; } + + private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly IOptions _options; + private readonly IHttpContextAccessor _httpContextAccessor; + public PostgresReportProvider( ApplicationDbContextFactory dbContextFactory, + IOptions options, IHttpContextAccessor httpContextAccessor) + { + _dbContextFactory = dbContextFactory; + _options = options; + _httpContextAccessor = httpContextAccessor; + } + public override bool IsAvailable() + { + return _options.Value.DatabaseType == DatabaseType.Postgres && + Setting.AllowForNonAdmins || _httpContextAccessor.HttpContext?.User.IsInRole(Roles.ServerAdmin) is true; + } + public override async Task Query(QueryContext queryContext, CancellationToken cancellation) + { + await ExecuteQuery(_dbContextFactory, queryContext,Setting.Sql, cancellation); + } + + public static async Task ExecuteQuery(ApplicationDbContextFactory dbContextFactory, QueryContext queryContext, string sql, + CancellationToken cancellation) + { + await using var dbContext = dbContextFactory.CreateContext(); + await using var connection = dbContext.Database.GetDbConnection(); + await connection.OpenAsync(cancellation); + await using var transaction = await connection.BeginTransactionAsync(cancellation); + try + { + + var rows = (await connection.QueryAsync(sql, new + { + queryContext.From, + queryContext.To, + queryContext.StoreId + }))?.ToArray(); + if (rows?.Any() is true) + { + var firstRow = new RouteValueDictionary(rows.First()); + queryContext.ViewDefinition = new ViewDefinition() + { + Fields = firstRow.Keys.Select(f => new StoreReportResponse.Field(f, ObjectToFieldType(firstRow[f]))) + .ToList(), + Charts = new() + }; + } + else + { + return; + } + + foreach (var row in rows) + { + var rowParsed = new RouteValueDictionary(row); + var data = queryContext.CreateData(); + foreach (var field in queryContext.ViewDefinition.Fields) + { + var rowFieldValue = rowParsed[field.Name]; + field.Type ??= ObjectToFieldType(rowFieldValue); + data.Add(rowFieldValue); + } + + queryContext.Data.Add(data); + } + + queryContext.ViewDefinition.Fields.Where(field => field.Type is null).ToList() + .ForEach(field => field.Type = "string"); + + } + finally + { + + await transaction.RollbackAsync(cancellation); + } + } + + private static string? ObjectToFieldType(object? value) + { + + if (value is null) + return null; + if(value is string) + return "string"; + if(value is DateTime) + return "datetime"; + if(value is DateTimeOffset) + return "datetime"; + if(value is bool) + return "boolean"; + if(value is int) + return "amount"; + if(value is decimal) + return "amount"; + if(value is long) + return "amount"; + if(value is double) + return "amount"; + + + return "string"; + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/README.md b/Plugins/BTCPayServer.Plugins.DynamicReports/README.md new file mode 100644 index 0000000..c703713 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/README.md @@ -0,0 +1,10 @@ +# Dynamic Reports Plugin for BTCPay Server +This plugin allows you to create dynamic reports in BTCPay Server, along with re-enabling the old invoice export report. + +## Usage +After installing the plugin, you will see a new menu item in the server settings called "Dynamic Reports". Clicking on this will take you to the report builder. + +You can create new reports using raw sql (postgres). These reports are only viewable if you are a server admin by default. You can change this by explicitly specifying it in the report. + +## Re-enabling the old invoice export report +There is a toggle available on the report builder page to re-enable the old invoice export report. This report is available to all users on your instance. \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/Views/DynamicReports/Update.cshtml b/Plugins/BTCPayServer.Plugins.DynamicReports/Views/DynamicReports/Update.cshtml new file mode 100644 index 0000000..33dda49 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/Views/DynamicReports/Update.cshtml @@ -0,0 +1,131 @@ +@using Newtonsoft.Json +@using BTCPayServer.Services +@using BTCPayServer.Services.Reporting +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Abstractions.Contracts +@using BTCPayServer.Plugins.DynamicReports +@model BTCPayServer.Plugins.DynamicReports.DynamicReportViewModel +@inject IScopeProvider ScopeProvider +@inject ReportService ReportService +@inject DynamicReportService DynamicReportService + +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["NavPartialName"] = "../UIServer/_Nav"; + var storeId = ScopeProvider.GetCurrentStoreId(); + var reportName = Context.Request.Query["reportName"].ToString(); + reportName = string.IsNullOrEmpty(reportName) ? null : reportName; + var existingReports = ReportService.ReportProviders.Where(pair => pair.Value is PostgresReportProvider).Select(pair => pair.Key).ToList(); + ViewData.SetActivePage("DynamicReports", reportName is null ? "Create dynamic report" : $"Edit {reportName} dynamic report", reportName); + var legacyEnabled = await DynamicReportService.IsLegacyEnabled(); +} + + +
+ +
+

+ @ViewData["Title"] +

+
+ @if (reportName is null) + { + + } + else + { + + + @if (storeId is not null) + { + View + } + } + @if (storeId is not null) + { + + } + @if (existingReports.Count > 0) + { + + } + + @(legacyEnabled ? "Disable legacy report" : "Enable legacy report") + +
+
+
+
+
+ @if (reportName is null) + { +
+ + + +
+ } + else + { + + } +
+ + + If unchecked, only admins will be able to use this report. Executing raw SQL is very powerful. Executing queries without proper filtering may expose sensitive data to unrelated users. + +
+
+
+ +
+
+
+ + + You can use @@StoreId to reference the current store id, @@From and @@To for the specified date range filters. The queries are sandboxed inside a SQL transaction, which never gets committed, ensuring reports are read-only. + +
+
+
+ @if (TempData.TryGetValue("Data", out var dataV) && dataV is string dataS) + { + var queryContext = JsonConvert.DeserializeObject(dataS); +
+ +
+ +
+ + + + @foreach (var column in queryContext.ViewDefinition.Fields) + { + + } + + + + @foreach (var row in queryContext.Data) + { + + + @foreach (var column in row) + { + + } + + } +
@column.Name
@column
+
+
+
+ } + + \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/Views/Shared/DynamicReportsPlugin/Nav.cshtml b/Plugins/BTCPayServer.Plugins.DynamicReports/Views/Shared/DynamicReportsPlugin/Nav.cshtml new file mode 100644 index 0000000..f10a28c --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/Views/Shared/DynamicReportsPlugin/Nav.cshtml @@ -0,0 +1,8 @@ +@using BTCPayServer.Plugins.DynamicReports + +@{ + var isActive = ViewContext.RouteData.Values.TryGetValue("Controller", out var controller) && controller is not null && + nameof(DynamicReportsController).StartsWith(controller?.ToString(), StringComparison.InvariantCultureIgnoreCase); +} + +Dynamic Reports diff --git a/Plugins/BTCPayServer.Plugins.DynamicReports/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.DynamicReports/_ViewImports.cshtml new file mode 100644 index 0000000..d897d63 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.DynamicReports/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using BTCPayServer.Abstractions.Services +@inject Safe Safe +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer +@addTagHelper *, BTCPayServer.Abstractions \ No newline at end of file