mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
dynamic reports
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<LangVersion>10</LangVersion>
|
||||
</PropertyGroup>
|
||||
<!-- -->
|
||||
<!-- Plugin specific properties -->
|
||||
<PropertyGroup>
|
||||
<Product>Dynamic Reports</Product>
|
||||
<Description>Allows you to create custom reports using SQL.</Description>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
<!-- Plugin development properties -->
|
||||
<PropertyGroup>
|
||||
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
|
||||
<PreserveCompilationContext>false</PreserveCompilationContext>
|
||||
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- This will make sure that referencing BTCPayServer doesn't put any artifact in the published directory -->
|
||||
<ItemDefinitionGroup>
|
||||
<ProjectReference>
|
||||
<Properties>StaticWebAssetsEnabled=false</Properties>
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime;native;build;buildTransitive;contentFiles</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**" />
|
||||
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<DynamicReportsSettings>();
|
||||
if (result?.Reports?.Any() is true)
|
||||
{
|
||||
foreach (var report in result.Reports)
|
||||
{
|
||||
var reportProvider = ActivatorUtilities.CreateInstance<PostgresReportProvider>(_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<DynamicReportsSettings>() ?? 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<PostgresReportProvider>(_serviceProvider);
|
||||
reportProvider.Setting = setting;
|
||||
|
||||
reportProvider.ReportName = name;
|
||||
result.Reports[name] = setting;
|
||||
await _settingsRepository.UpdateSetting(result);
|
||||
_reportService.ReportProviders.TryAdd(name, reportProvider);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsLegacyEnabled()
|
||||
{
|
||||
var result = await _settingsRepository.GetSettingAsync<DynamicReportsSettings>();
|
||||
return result?.EnableLegacyInvoiceExport is true;
|
||||
}
|
||||
public async Task<bool> ToggleLegacy()
|
||||
{
|
||||
var result = await _settingsRepository.GetSettingAsync<DynamicReportsSettings>() ?? new DynamicReportsSettings();
|
||||
result.EnableLegacyInvoiceExport = !result.EnableLegacyInvoiceExport;
|
||||
await _settingsRepository.UpdateSetting(result);
|
||||
return result.EnableLegacyInvoiceExport;
|
||||
}
|
||||
}
|
||||
@@ -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<IActionResult> 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<IActionResult> 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: $"<br/>{msg})")}"
|
||||
});
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"Report {reportName} {(reportName is null ? "created" : "updated")}";
|
||||
|
||||
return RedirectToAction(nameof(Update) , new {reportName = reportName??vm.Name});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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<DynamicReportService>();
|
||||
applicationBuilder.AddReportProvider<LegacyInvoiceExportReportProvider>();
|
||||
applicationBuilder.AddSingleton<IHostedService>(provider => provider.GetRequiredService<DynamicReportService>());
|
||||
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("DynamicReportsPlugin/Nav",
|
||||
"server-nav"));
|
||||
base.Execute(applicationBuilder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace BTCPayServer.Plugins.DynamicReports;
|
||||
|
||||
public class DynamicReportsSettings
|
||||
{
|
||||
public Dictionary<string, DynamicReportSetting> 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; }
|
||||
|
||||
}
|
||||
@@ -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<DynamicReportsSettings>().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<StoreReportResponse.Field>()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<DatabaseOptions> _options;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
public PostgresReportProvider( ApplicationDbContextFactory dbContextFactory,
|
||||
IOptions<DatabaseOptions> 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";
|
||||
}
|
||||
}
|
||||
10
Plugins/BTCPayServer.Plugins.DynamicReports/README.md
Normal file
10
Plugins/BTCPayServer.Plugins.DynamicReports/README.md
Normal file
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
<form method="post" asp-action="Update" asp-controller="DynamicReports" asp-route-reportName="@reportName">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h2 class="mb-0">
|
||||
<span>@ViewData["Title"]</span>
|
||||
</h2>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
@if (reportName is null)
|
||||
{
|
||||
<button type="submit" class="btn btn-primary" id="SaveButton">Create</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
|
||||
<button name="command" value="remove" type="submit" class="btn btn-danger order-sm-1">Remove</button>
|
||||
@if (storeId is not null)
|
||||
{
|
||||
<a class="btn btn-secondary" asp-controller="UIReports" asp-action="StoreReports" asp-route-storeId="@storeId" asp-route-viewName="@reportName">View</a>
|
||||
}
|
||||
}
|
||||
@if (storeId is not null)
|
||||
{
|
||||
<button name="command" value="test" type="submit" class="btn btn-outline-secondary order-sm-1">Test</button>
|
||||
}
|
||||
@if (existingReports.Count > 0)
|
||||
{
|
||||
<select onChange="window.location.href=this.value" class="form-select" name="selectedReport">
|
||||
<option selected="@(reportName is null)" value="@Url.Action("Update", "DynamicReports")">Create new report</option>
|
||||
@foreach (var rep in existingReports)
|
||||
{
|
||||
<option selected="@(rep == reportName)" value="@Url.Action("Update", "DynamicReports", new {reportName = rep})">Edit @rep</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
<a class="btn btn-outline-secondary text-nowrap" asp-controller="DynamicReports" asp-action="ToggleLegacy" >
|
||||
@(legacyEnabled ? "Disable legacy report" : "Enable legacy report")
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xl-8 col-xxl-constrain">
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
@if (reportName is null)
|
||||
{
|
||||
<div class="form-group">
|
||||
<label asp-for="Name" class="form-label" data-required></label>
|
||||
<input asp-for="Name" class="form-control" required/>
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="hidden" asp-for="Name"/>
|
||||
}
|
||||
<div class="form-group form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="AllowForNonAdmins"/>
|
||||
<label asp-for="AllowForNonAdmins" class="form-check-label">Allow report to be used by non-admins</label>
|
||||
<small class="form-text text-muted d-block">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. </small>
|
||||
<span asp-validation-for="AllowForNonAdmins" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
<div class="form-group">
|
||||
<label asp-for="Sql" class="form-label" data-required>SQL</label>
|
||||
<textarea asp-for="Sql" rows="10" cols="40" class="form-control" required></textarea>
|
||||
<small class="form-text text-muted">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.</small>
|
||||
<span asp-validation-for="Sql" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (TempData.TryGetValue("Data", out var dataV) && dataV is string dataS)
|
||||
{
|
||||
var queryContext = JsonConvert.DeserializeObject<QueryContext>(dataS);
|
||||
<div class="row">
|
||||
|
||||
<div class="col-12 col-xxl-constrain">
|
||||
|
||||
<div class="table-responsive" style=" transform: rotateX(180deg);">
|
||||
<table class="table table-hover w-100" style=" transform: rotateX(180deg);">
|
||||
<thead>
|
||||
<tr>
|
||||
@foreach (var column in queryContext.ViewDefinition.Fields)
|
||||
{
|
||||
<th>@column.Name</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in queryContext.Data)
|
||||
{
|
||||
<tr>
|
||||
|
||||
@foreach (var column in row)
|
||||
{
|
||||
<td>@column</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</form>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
<a class="nav-link @(isActive ? "active" : string.Empty)" asp-action="Update" asp-controller="DynamicReports">Dynamic Reports</a>
|
||||
@@ -0,0 +1,5 @@
|
||||
@using BTCPayServer.Abstractions.Services
|
||||
@inject Safe Safe
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper *, BTCPayServer
|
||||
@addTagHelper *, BTCPayServer.Abstractions
|
||||
Reference in New Issue
Block a user