dynamic reports

This commit is contained in:
Kukks
2023-11-10 12:40:08 +01:00
parent 9e8d694209
commit b77fa3307e
12 changed files with 775 additions and 0 deletions

View File

@@ -51,6 +51,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Prism"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.FileSeller", "Plugins\BTCPayServer.Plugins.FileSeller\BTCPayServer.Plugins.FileSeller.csproj", "{F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.FileSeller", "Plugins\BTCPayServer.Plugins.FileSeller\BTCPayServer.Plugins.FileSeller.csproj", "{F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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-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.ActiveCfg = Release|Any CPU
{F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962} {B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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});
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View 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.

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
@using BTCPayServer.Abstractions.Services
@inject Safe Safe
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, BTCPayServer
@addTagHelper *, BTCPayServer.Abstractions