Files
raven/server/AyaNova/biz/ReportBiz.cs
2025-04-03 21:05:18 +00:00

1288 lines
58 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AyaNova.Api.ControllerHelpers;
using AyaNova.Models;
using AyaNova.Util;
using EnumsNET;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using PuppeteerSharp;
using StackExchange.Profiling.Internal;
namespace AyaNova.Biz
{
internal class ReportBiz : BizObject, IJobObject, ISearchAbleObject
{
internal ReportBiz(
AyContext dbcontext,
long currentUserId,
long userTranslationId,
AuthorizationRoles UserRoles
)
{
ct = dbcontext;
UserId = currentUserId;
UserTranslationId = userTranslationId;
CurrentUserRoles = UserRoles;
BizType = AyaType.Report;
}
internal static ReportBiz GetBiz(
AyContext ct,
Microsoft.AspNetCore.Http.HttpContext httpContext = null
)
{
if (httpContext != null)
return new ReportBiz(
ct,
UserIdFromContext.Id(httpContext.Items),
UserTranslationIdFromContext.Id(httpContext.Items),
UserRolesFromContext.Roles(httpContext.Items)
);
else
return new ReportBiz(
ct,
1,
ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID,
AuthorizationRoles.BizAdmin
);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//EXISTS
internal async Task<bool> ExistsAsync(long id)
{
return await ct.Report.AnyAsync(z => z.Id == id);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//CREATE
//
internal async Task<Report> CreateAsync(Report newObject)
{
await ValidateAsync(newObject, null);
if (HasErrors)
return null;
else
{
await ct.Report.AddAsync(newObject);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(
new Event(UserId, newObject.Id, BizType, AyaEvent.Created),
ct
);
await SearchIndexAsync(newObject, true);
return newObject;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//IMPORT
//
internal async Task<bool> ImportAsync(JObject o, bool skipIfAlreadyPresent = false)
{
//Report newObject = new Report();
var newObject = o.ToObject<Report>();
var proposedName = (string)o["Name"];
string newUniqueName = proposedName;
bool NotUnique = true;
long l = 1;
do
{
NotUnique = await ct.Report.AnyAsync(z => z.Name == newUniqueName);
if (NotUnique)
{
if (!skipIfAlreadyPresent)
newUniqueName = Util.StringUtil.UniqueNameBuilder(proposedName, l++, 255);
else
{
return true;
}
}
} while (NotUnique);
newObject.Name = newUniqueName;
await ValidateAsync(newObject, null);
if (HasErrors)
return false;
await ct.Report.AddAsync(newObject);
await ct.SaveChangesAsync();
await EventLogProcessor.LogEventToDatabaseAsync(
new Event(UserId, newObject.Id, BizType, AyaEvent.Created),
ct
);
if (skipIfAlreadyPresent)
{
ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger<ReportBiz>();
log.LogInformation($"Stock report '{proposedName}' imported");
}
return true;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//GET
//
internal async Task<Report> GetAsync(long id, bool logTheGetEvent = true)
{
var ret = await ct.Report.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);
if (logTheGetEvent && ret != null)
await EventLogProcessor.LogEventToDatabaseAsync(
new Event(UserId, id, BizType, AyaEvent.Retrieved),
ct
);
return ret;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//UPDATE
//
internal async Task<Report> PutAsync(Report putObject)
{
var dbObject = await GetAsync(putObject.Id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND, "id");
return null;
}
if (dbObject.Concurrency != putObject.Concurrency)
{
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
await ValidateAsync(putObject, dbObject);
if (HasErrors)
return null;
ct.Replace(dbObject, putObject);
try
{
await ct.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!await ExistsAsync(putObject.Id))
AddError(ApiErrorCode.NOT_FOUND);
else
AddError(ApiErrorCode.CONCURRENCY_CONFLICT);
return null;
}
await EventLogProcessor.LogEventToDatabaseAsync(
new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified),
ct
);
await SearchIndexAsync(putObject, false);
return putObject;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//DELETE
//
internal async Task<bool> DeleteAsync(long id)
{
using (var transaction = await ct.Database.BeginTransactionAsync())
{
var dbObject = await GetAsync(id, false);
if (dbObject == null)
{
AddError(ApiErrorCode.NOT_FOUND);
return false;
}
await ValidateCanDelete(dbObject);
if (HasErrors)
return false;
{
var IDList = await ct
.Review.AsNoTracking()
.Where(x => x.AType == AyaType.Report && x.ObjectId == id)
.Select(x => x.Id)
.ToListAsync();
if (IDList.Count() > 0)
{
ReviewBiz b = new ReviewBiz(
ct,
UserId,
UserTranslationId,
CurrentUserRoles
);
foreach (long ItemId in IDList)
if (!await b.DeleteAsync(ItemId, transaction))
{
AddError(
ApiErrorCode.CHILD_OBJECT_ERROR,
null,
$"Review [{ItemId}]: {b.GetErrorsAsString()}"
);
return false;
}
}
}
ct.Report.Remove(dbObject);
await ct.SaveChangesAsync();
await EventLogProcessor.DeleteObjectLogAsync(
UserId,
BizType,
dbObject.Id,
dbObject.Name,
ct
);
await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType, ct);
await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct);
await transaction.CommitAsync();
return true;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
//GET LIST
//
internal async Task<List<NameIdItem>> GetReportListAsync(AyaType aType)
{
var rpts = await ct
.Report.AsNoTracking()
.Where(z => z.AType == aType && z.Active == true)
.Select(z => new
{
id = z.Id,
name = z.Name,
roles = z.Roles,
})
.OrderBy(z => z.name)
.ToListAsync();
var ret = new List<NameIdItem>();
foreach (var item in rpts)
{
if (CurrentUserRoles.HasAnyFlags(item.roles))
{
ret.Add(new NameIdItem() { Name = item.name, Id = item.id });
}
}
return ret;
}
////////////////////////////////////////////////////////////////////////////////////////////////
//SEARCH
//
private async Task SearchIndexAsync(Report obj, bool isNew)
{
var SearchParams = new Search.SearchIndexProcessObjectParameters(
UserTranslationId,
obj.Id,
BizType
);
DigestSearchText(obj, SearchParams);
if (isNew)
await Search.ProcessNewObjectKeywordsAsync(SearchParams);
else
await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams);
}
public async Task<Search.SearchIndexProcessObjectParameters> GetSearchResultSummary(
long id,
AyaType specificType
)
{
var obj = await GetAsync(id, false);
var SearchParams = new Search.SearchIndexProcessObjectParameters();
DigestSearchText(obj, SearchParams);
return SearchParams;
}
public void DigestSearchText(
Report obj,
Search.SearchIndexProcessObjectParameters searchParams
)
{
if (obj != null)
searchParams
.AddText(obj.Notes)
.AddText(obj.Name)
.AddText(obj.Template)
.AddText(obj.Style)
.AddText(obj.JsPrerender)
.AddText(obj.JsHelpers)
.AddText(obj.HeaderTemplate)
.AddText(obj.FooterTemplate);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//VALIDATION
//
private async Task ValidateAsync(Report proposedObj, Report currentObj)
{
bool isNew = currentObj == null;
//Name required
if (string.IsNullOrWhiteSpace(proposedObj.Name))
AddError(ApiErrorCode.VALIDATION_REQUIRED, "Name");
//If name is otherwise OK, check that name is unique
if (!PropertyHasErrors("Name"))
{
//Use Any command is efficient way to check existance, it doesn't return the record, just a true or false
//NOTE: unlike other objects reports can have the same name as long as the type differs
if (
await ct.Report.AnyAsync(z =>
z.Name == proposedObj.Name
&& z.AType == proposedObj.AType
&& z.Id != proposedObj.Id
)
)
{
AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, "Name");
}
}
}
private async Task ValidateCanDelete(Report inObj)
{
//Referential integrity error
if (await ct.NotifySubscription.AnyAsync(z => z.LinkReportId == inObj.Id) == true)
{
//Note: errorbox will ensure it appears in the general errror box and not field specific
//the translation key is to indicate what the linked object is that is causing the error
AddError(
ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY,
"generalerror",
await Translate("NotifySubscription")
);
}
if (
await ct.GlobalBizSettings.AnyAsync(z =>
z.CustomerDefaultWorkOrderReportId == inObj.Id
) == true
)
AddError(
ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY,
"generalerror",
await Translate("GlobalSettings")
);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//REPORT DATA
//Data fetched to return to report render or for designer for Client report design usage
public async Task<Newtonsoft.Json.Linq.JArray> GetReportDataForReportDesigner(
DataListSelectedRequest selectedRequest
)
{
var log = AyaNova.Util.ApplicationLogging.CreateLogger(
"ReportBiz::GetReportDataForReportDesigner"
);
AuthorizationRoles effectiveRoles = CurrentUserRoles;
if (selectedRequest.AType == AyaType.NoType)
{
AddError(ApiErrorCode.VALIDATION_REQUIRED, null, $"AType is required");
return null;
}
//Do we need to rehydrate the ID List from a DataList?
if (selectedRequest.SelectedRowIds.Length == 0)
selectedRequest.SelectedRowIds =
await DataListSelectedProcessingOptions.RehydrateIdList(
selectedRequest,
ct,
effectiveRoles,
log,
UserId,
UserTranslationId
);
//case 4568 get translation id ultimately without failing so as to not affect any existing callers that this might not work with
long RequestorUserTranslationId = UserTranslationId;
try
{
JToken clientMeta = ((DataListReportRequest)selectedRequest).ClientMeta;
if (clientMeta["UserId"] != null)
{
RequestorUserTranslationId = await ct
.User.AsNoTracking()
.Where(a => a.Id == clientMeta["UserId"].ToObject<long>())
.Select(m => m.UserOptions.TranslationId)
.FirstAsync(); //m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken }).FirstAsync();
}
}
catch (Exception exx)
{
log.LogDebug(exx, $"Failed to get RequestionUserTranslationId from ClientMeta");
}
;
log.LogDebug($"Instantiating biz object handler for {selectedRequest.AType}");
var biz = BizObjectFactory.GetBizObject(
selectedRequest.AType,
ct,
UserId,
CurrentUserRoles,
RequestorUserTranslationId
);
log.LogDebug(
$"Fetching data for {selectedRequest.SelectedRowIds.Length} {selectedRequest.AType} items"
);
return await ((IReportAbleObject)biz).GetReportData(selectedRequest, Guid.Empty); //Guid.empty signifies it's not a job calling it
}
public async Task<Newtonsoft.Json.Linq.JArray> GetReportData(
DataListSelectedRequest selectedRequest,
Guid jobId,
bool requestIsCustomerWorkOrderReport = false
)
{
var log = AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::GetReportData");
AuthorizationRoles effectiveRoles = CurrentUserRoles;
if (selectedRequest.AType == AyaType.NoType)
{
AddError(ApiErrorCode.VALIDATION_REQUIRED, null, $"AType is required");
return null;
}
if (
!requestIsCustomerWorkOrderReport
&& !AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole(
effectiveRoles,
selectedRequest.AType
)
)
{
AddError(
ApiErrorCode.NOT_AUTHORIZED,
null,
$"User not authorized for {selectedRequest.AType} type object"
);
return null;
}
//Do we need to rehydrate the ID List from a DataList?
if (selectedRequest.SelectedRowIds.Length == 0)
selectedRequest.SelectedRowIds =
await DataListSelectedProcessingOptions.RehydrateIdList(
selectedRequest,
ct,
effectiveRoles,
log,
UserId,
UserTranslationId
);
if (!ReportRenderManager.KeepGoing(jobId))
return null;
//case 4568 get translation id ultimately without failing so as to not affect any existing callers that this might not work with
long RequestorUserTranslationId = UserTranslationId;
try
{
JToken clientMeta = ((DataListReportRequest)selectedRequest).ClientMeta;
if (clientMeta["UserId"] != null)
{
RequestorUserTranslationId = await ct
.User.AsNoTracking()
.Where(a => a.Id == clientMeta["UserId"].ToObject<long>())
.Select(m => m.UserOptions.TranslationId)
.FirstAsync(); //m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken }).FirstAsync();
}
}
catch (Exception exx)
{
log.LogDebug(exx, $"Failed to get RequestionUserTranslationId from ClientMeta");
}
;
log.LogDebug($"Instantiating biz object handler for {selectedRequest.AType}");
var biz = BizObjectFactory.GetBizObject(
selectedRequest.AType,
ct,
UserId,
CurrentUserRoles,
RequestorUserTranslationId
);
log.LogDebug(
$"Fetching data for {selectedRequest.SelectedRowIds.Length} {selectedRequest.AType} items"
);
return await ((IReportAbleObject)biz).GetReportData(selectedRequest, jobId);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//RENDER
//
public async Task<Guid?> RequestRenderReport(
DataListReportRequest reportRequest,
DateTime renderTimeOutExpiry,
string apiUrl,
string userName
)
{
var log = AyaNova.Util.ApplicationLogging.CreateLogger(
"ReportBiz::RequestRenderReport"
);
log.LogDebug(
$"report id {reportRequest.ReportId}, timeout @ {renderTimeOutExpiry.ToString()}"
);
//Is reporting api url overridden in CORS issue scenario (case 4398)
if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_REPORT_RENDER_API_URL_OVERRIDE))
{
/*
Plan of action:
Testing here my fix should work as I was able to reproduce the issue exactly so maybe he did something wrong at his end, in any case we need more infor and verification of the fix tested properly so:
Send him the diagnostic report with his patched version and have him run the report via notification both overridden and not overridden should show a difference
I need to know he made the correct setting in the config as that might be what is the issue here
He must first login as superuser and gather support information from the menu item "copy support information", THEN run the test with either override set or not set as a customer notification witht he diagnostic report. Send me the attached support infor and the generated diga report pdf
Then make the opposite override setting, reboot teh server, copy the support info again run the test again with override set the opposite way to before and send me the report with the support info at attachments
If nothing actionable there then rebuild with the patch code commented out and only put into a NEXT folder for download, send him the direct url to install and then he needs to repeat the above exactly as before but with my patch code now removed so it should replicate is first discovery that notify worked when he removed the override and I should get the atual working url and can back figure it out from there
2025-02-20 Ok, confirmed he correctly set the override and in both overriden or not overriden the api call failed the same way and in both cases it was using the special 127 localhost address for the customer notification generated report
so I will build a reversion build and get him to try it again...actually, scratch that he can just install the old one from the old folder. Or can he? Hmm...
https://ayanova.com/download/old/2025-02-18-v8.2.3/ayanova-windows-x64-lan-setup.exe
Here is a programming problem I would like suggestions on resolving:
This is an API web server application for the back end with the API server coded in C# and using the Microsoft dotnet v8 runtime and it is running under IIS.
Part of the API server's duties is to render pdf reports from an html and Javascript template and in order to do this it uses the PuppeteerSharp library to spawn a
instance of the chrome browser, provide it with html and javascript in order to generate a report and then render it to a pdf file.
The problem arises when the API server is set up under TLS security and the javascript running locally at that API server needs to call an API method on it's own API server to get some information.
IIS seems to reject the call as not allowed regardless if the url used to access the local api server is set to localhost or the registered domain.
What could be the issue here?
*/
//case 4632, override breaks special api url when processing customer notification
//in order to minimize side effects will go by special user name used here
if (
userName
!= ServerBootConfig.CUSTOMER_NOTIFICATION_ATTACHED_REPORT_RENDER_USERNAME
)
apiUrl =
$"{ServerBootConfig.AYANOVA_REPORT_RENDER_API_URL_OVERRIDE.TrimEnd().TrimEnd('/')}/api/{AyaNovaVersion.CurrentApiVersion}/";
}
//Customer User Report?
bool RequestIsCustomerWorkOrderReport = false;
if (reportRequest.ReportId == -100)
{
log.LogDebug("customer user report requested");
//get the user and workorder data and set the actual report id or return null if not found
var woTags = await ct
.WorkOrder.AsNoTracking()
.Where(x => x.Id == reportRequest.SelectedRowIds[0])
.Select(x => x.Tags)
.FirstOrDefaultAsync();
if (woTags == null)
return null;
var cr = await UserBiz.CustomerUserEffectiveRightsAsync(UserId, woTags);
if (cr.ThisWOEffectiveWOReportId == null)
return null;
reportRequest.ReportId = (long)cr.ThisWOEffectiveWOReportId;
RequestIsCustomerWorkOrderReport = true;
}
//get report, vet security, see what we need before init in case of issue
log.LogDebug($"get report from db");
var report = await ct.Report.FirstOrDefaultAsync(z => z.Id == reportRequest.ReportId);
if (report == null)
{
AddError(ApiErrorCode.NOT_FOUND, "id");
return null;
}
//If we get here via the /viewreport url in the client then there is no object type set so we need to set it here from the report
if (reportRequest.AType == AyaType.NoType)
{
reportRequest.AType = report.AType;
}
AuthorizationRoles effectiveRoles = CurrentUserRoles;
if (
!RequestIsCustomerWorkOrderReport
&& !AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole(
effectiveRoles,
report.AType
)
)
{
log.LogDebug($"bail: user unauthorized");
AddError(
ApiErrorCode.NOT_AUTHORIZED,
null,
$"User not authorized for {report.AType} type object"
);
return null;
}
//Client meta data is required
if (reportRequest.ClientMeta == null)
{
log.LogDebug($"bail: ClientMeta parameter is missing");
AddError(
ApiErrorCode.VALIDATION_MISSING_PROPERTY,
null,
"ClientMeta parameter is missing and required to render report"
);
return null;
}
//includeWoItemDescendants?
reportRequest.IncludeWoItemDescendants = report.IncludeWoItemDescendants;
//Do we need to rehydrate the ID List from a DataList?
if (reportRequest.SelectedRowIds.Length == 0)
reportRequest.SelectedRowIds =
await DataListSelectedProcessingOptions.RehydrateIdList(
reportRequest,
ct,
effectiveRoles,
log,
UserId,
UserTranslationId
);
var JobName =
$"LT:Report id: \"{reportRequest.ReportId}\" LT:{reportRequest.AType} ({reportRequest.SelectedRowIds.LongLength}) LT:User {userName}";
JObject o = JObject.FromObject(
new
{
reportRequest = reportRequest,
requestIsCustomerWorkOrderReport = RequestIsCustomerWorkOrderReport,
apiUrl = apiUrl,
userName = userName,
}
);
OpsJob j = new OpsJob();
j.Name = JobName;
j.AType = AyaType.Report;
j.JobType = JobType.RenderReport;
j.SubType = JobSubType.NotSet;
j.Exclusive = false;
j.JobInfo = o.ToString();
await JobsBiz.AddJobAsync(j);
await EventLogProcessor.LogEventToDatabaseAsync(
new Event(UserId, 0, AyaType.ServerJob, AyaEvent.Created, JobName),
ct
);
return j.GId;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}
public async Task DoRenderJob(OpsJob job)
{
var log = AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::DoRenderJob");
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Running);
ReportRenderManager.AddJob(job.GId, log);
//rehydrate job objects
log.LogDebug($"Start; rehydrate job {job.Name}");
JObject jobData = JObject.Parse(job.JobInfo);
var reportRequest = jobData["reportRequest"].ToObject<DataListReportRequest>();
var RequestIsCustomerWorkOrderReport = jobData["requestIsCustomerWorkOrderReport"]
.ToObject<bool>();
var apiUrl = jobData["apiUrl"].ToObject<string>();
var userName = jobData["userName"].ToObject<string>();
var report = await ct.Report.FirstOrDefaultAsync(z => z.Id == reportRequest.ReportId);
if (report == null)
{
await JobsBiz.LogJobAsync(
job.GId,
$"rendererror:error,\"LT:ErrorAPI2010 LT:Report({reportRequest.ReportId})\""
);
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed);
return;
}
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//Get data
log.LogDebug("Getting report data now");
// var watch = new Stopwatch();
// watch.Start();
var ReportData = await GetReportData(
reportRequest,
job.GId,
RequestIsCustomerWorkOrderReport
);
// watch.Stop();
// log.LogInformation($"GetReportData took {watch.ElapsedMilliseconds}ms to execute");
//THIS is here to catch scenario where report data returned null because it expired, not because of an issue
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//if GetReportData errored then will return null so need to return that as well here
if (ReportData == null)
{
log.LogDebug($"bail: ReportData == null");
await JobsBiz.LogJobAsync(
job.GId,
$"rendererror:error,\"{this.GetErrorsAsString()}\""
); //?? circle back on this one
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed);
}
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//initialization
log.LogDebug("Initializing report rendering system");
bool AutoDownloadBrowser = true;
if (string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PATH))
{
log.LogDebug($"Using default browser (downloaded)");
}
else
{
log.LogDebug(
$"Using user specified browser at {ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PATH}"
);
AutoDownloadBrowser = false;
}
var ReportJSFolderPath = Path.Combine(
ServerBootConfig.AYANOVA_CONTENT_ROOT_PATH,
"resource",
"rpt"
);
//Keep for debugging headfully
//var lo = new LaunchOptions { Headless = false };
var lo = new LaunchOptions { Headless = true };
if (!AutoDownloadBrowser)
{
lo.ExecutablePath = ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PATH;
/*
troubleshooting links:
https://developers.google.com/web/tools/puppeteer/troubleshooting
These links might be worth looking at in future if diagnosing other issues:
https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-on-alpine
https://github.com/puppeteer/puppeteer/issues/1825
const chromeFlags = [
'--headless',
'--no-sandbox',
"--disable-gpu",
"--single-process",
"--no-zygote"
]
*/
}
else
{
log.LogDebug($"Calling browserFetcher download async now:");
//await new BrowserFetcher().DownloadAsync(BrowserFetcher.DefaultChromiumRevision);
//case 4586
//puppeteer has gone chrome dev by default and so does puppeteersharp which tests ok so I'm going to allow the default
//this means the files go into binary folder under Chrome and ChromeHeadlessShell
await new BrowserFetcher().DownloadAsync();
}
//Set Chromium args
//*** DANGER: --disable-dev-shm-usage will crash linux ayanova when it runs out of memory ****
//that was only suitable for dockerized scenario as it had an alt swap system
//var OriginalDefaultArgs = "--disable-dev-shm-usage --single-process --no-sandbox --disable-gpu --no-zygote ";
//Keep for debugging headfully
// var DefaultArgs = "--no-sandbox";
var DefaultArgs = "--headless --no-sandbox "; // --ignore-certificate-errors (potential proposal, not implemented yet for case 4632 saving here in case want to try it)
if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PARAMS))
{
log.LogDebug(
$"AYANOVA_REPORT_RENDER_BROWSER_PARAMS will be used: {ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PARAMS}"
);
lo.Args = new string[]
{
$"{ServerBootConfig.AYANOVA_REPORT_RENDER_BROWSER_PARAMS}",
}; // AYANOVA_REPORT_RENDER_BROWSER_PARAMS
}
else
{
log.LogDebug(
$"AYANOVA_REPORT_RENDER_BROWSER_PARAMS not set, using defaults '{DefaultArgs}'"
);
lo.Args = new string[] { DefaultArgs };
}
System.Text.StringBuilder PageLog = new System.Text.StringBuilder();
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//API DOCS http://www.puppeteersharp.com/api/index.html
log.LogDebug($"Launching headless Browser and new page now:");
using (var browser = await Puppeteer.LaunchAsync(lo))
using (var page = (await browser.PagesAsync())[0])
{
//track this process for timeout purposes
ReportRenderManager.SetProcess(job.GId, browser.Process.Id, log);
page.DefaultTimeout = 0; //infinite timeout as we are controlling how long the process can live for with the reportprocessmanager
try
{
//info and error logging
//case 4586 not calling task whenall now means non async so this page.Console += async (sender, args) => is now this:
page.Console += (sender, args) =>
{
switch (args.Message.Type)
{
case ConsoleType.Error:
try
{
//case 4586 todo force an error here and see what we can do with it old vs new behaviour test on current release same report with erorr triggered
//ok, I tested it with an exception and it worked just find so shrug emoji
PageLog.AppendLine($"ERROR: {args.Message.Text}");
//case 4586 this no longer works due to no executioncontext in arg in teh block below
// var errorArgs = await Task.WhenAll(args.Message.Args.Select(arg => arg.ExecutionContext.EvaluateFunctionAsync("(arg) => arg instanceof Error ? arg.message : arg", arg)));
// PageLog.AppendLine($"ERROR: {args.Message.Text} args: [{string.Join<object>(", ", errorArgs)}]");
}
catch { }
break;
case ConsoleType.Warning:
PageLog.AppendLine($"WARNING: {args.Message.Text}");
break;
default:
PageLog.AppendLine($"INFO: {args.Message.Text}");
break;
}
};
log.LogDebug($"Preparing page: adding base reporting scripts to page");
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//Add Handlebars JS for compiling and presenting
//https://handlebarsjs.com/
await page.AddScriptTagAsync(
new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-hb.js") }
);
//add Marked for markdown processing
//https://github.com/markedjs/marked
await page.AddScriptTagAsync(
new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-md.js") }
);
//add DOM Purify for markdown template sanitization processing
//https://github.com/cure53/DOMPurify
await page.AddScriptTagAsync(
new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-pf.js") }
);
//add Bar code library if our bar code helper is referenced
//https://github.com/metafloor/bwip-js
if (report.Template.Contains("ayBC ") || report.JsHelpers.Contains("ayBC "))
await page.AddScriptTagAsync(
new AddTagOptions()
{
Path = Path.Combine(ReportJSFolderPath, "ay-bc.js"),
}
);
//add stock helpers
await page.AddScriptTagAsync(
new AddTagOptions()
{
Path = Path.Combine(ReportJSFolderPath, "ay-report.js"),
}
);
//execute to add to handlebars
await page.EvaluateExpressionAsync("ayRegisterHelpers();");
if (!ReportRenderManager.KeepGoing(job.GId))
return;
log.LogDebug(
$"Preparing page: adding this report's scripts, style and templates to page"
);
//add report pre-render, helpers and style
if (string.IsNullOrWhiteSpace(report.JsPrerender))
{
report.JsPrerender =
"async function ayPrepareData(reportData){return reportData;}";
}
await page.AddScriptTagAsync(
new AddTagOptions() { Content = report.JsPrerender }
);
if (!string.IsNullOrWhiteSpace(report.JsHelpers))
await page.AddScriptTagAsync(
new AddTagOptions() { Content = report.JsHelpers }
);
log.LogDebug($"Preparing page: adding Client meta data");
//Client meta data to JSON string
var clientMeta = reportRequest.ClientMeta.ToString();
log.LogDebug($"Preparing page: adding Server meta data");
//Server meta data
var logo = await ct.Logo.AsNoTracking().SingleOrDefaultAsync();
var HasSmallLogo = "false";
var HasMediumLogo = "false";
var HasLargeLogo = "false";
if (logo != null)
{
if (logo.Small != null)
HasSmallLogo = "true";
if (logo.Medium != null)
HasMediumLogo = "true";
if (logo.Large != null)
HasLargeLogo = "true";
}
var HasPostalAddress = !string.IsNullOrWhiteSpace(
ServerGlobalBizSettings.Cache.PostAddress
)
? "true"
: "false";
var HasStreetAddress = !string.IsNullOrWhiteSpace(
ServerGlobalBizSettings.Cache.Address
)
? "true"
: "false";
//case 4209
//latitude and longitude are the only nullable fields in global biz settings and need to be converted to empty strings if null
string sLatitude = "null";
string sLongitude = "null";
if (ServerGlobalBizSettings.Cache.Latitude != null)
sLatitude = ServerGlobalBizSettings.Cache.Latitude.ToString();
if (ServerGlobalBizSettings.Cache.Longitude != null)
sLongitude = ServerGlobalBizSettings.Cache.Longitude.ToString();
var serverMeta =
$"{{ayApiUrl:`{apiUrl}`, HasSmallLogo:{HasSmallLogo}, HasMediumLogo:{HasMediumLogo}, HasLargeLogo:{HasLargeLogo},CompanyName: `{AyaNova.Core.License.ActiveKey.RegisteredTo}`,CompanyWebAddress:`{ServerGlobalBizSettings.Cache.WebAddress}`,CompanyEmailAddress:`{ServerGlobalBizSettings.Cache.EmailAddress}`,CompanyPhone1:`{ServerGlobalBizSettings.Cache.Phone1}`,CompanyPhone2:`{ServerGlobalBizSettings.Cache.Phone2}`,HasPostalAddress:{HasPostalAddress},CompanyPostAddress:`{ServerGlobalBizSettings.Cache.PostAddress}`,CompanyPostCity:`{ServerGlobalBizSettings.Cache.PostCity}`,CompanyPostRegion:`{ServerGlobalBizSettings.Cache.PostRegion}`,CompanyPostCountry:`{ServerGlobalBizSettings.Cache.PostCountry}`,CompanyPostCode:`{ServerGlobalBizSettings.Cache.PostCode}`,HasStreetAddress:{HasStreetAddress},CompanyAddress:`{ServerGlobalBizSettings.Cache.Address}`,CompanyCity:`{ServerGlobalBizSettings.Cache.City}`,CompanyRegion:`{ServerGlobalBizSettings.Cache.Region}`,CompanyCountry:`{ServerGlobalBizSettings.Cache.Country}`,CompanyAddressPostal:`{ServerGlobalBizSettings.Cache.AddressPostal}`,CompanyLatitude:{sLatitude},CompanyLongitude:{sLongitude}}}";
log.LogDebug($"Preparing page: adding Report meta data");
//Custom fields definition for report usage
string CustomFieldsTemplate = "null";
var FormCustomization = await ct
.FormCustom.AsNoTracking()
.SingleOrDefaultAsync(z => z.FormKey == report.AType.ToString());
if (FormCustomization != null)
{
CustomFieldsTemplate = FormCustomization.Template;
}
//Report meta data
var reportMeta =
$"{{Id:{report.Id},Name:`{report.Name}`,Notes:`{report.Notes}`,AType:`{report.AType}`,CustomFieldsDefinition:{CustomFieldsTemplate},DataListKey:`{reportRequest.DataListKey}`,SelectedRowIds: `{string.Join(",", reportRequest.SelectedRowIds)}`}}";
//duplicate meta data in report page wide variable for use by our internal functions
await page.AddScriptTagAsync(
new AddTagOptions()
{
Content =
$"var AYMETA={{ ayReportMetaData:{reportMeta}, ayClientMetaData:{clientMeta}, ayServerMetaData:{serverMeta} }}",
}
);
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//prePareData / preRender
var ReportDataObject =
$"{{ ayReportData:{ReportData}, ayReportMetaData:{reportMeta}, ayClientMetaData:{clientMeta}, ayServerMetaData:{serverMeta} }}";
//case 4209
log.LogDebug(
$"ReportData object about to be pre-rendered is:{ReportDataObject}"
);
log.LogDebug($"PageLog before render:{PageLog.ToString()}");
log.LogDebug($"Calling ayPreRender...");
await page.WaitForExpressionAsync($"ayPreRender({ReportDataObject})");
log.LogDebug($"ayPreRender completed");
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//compile the template
log.LogDebug($"Calling Handlebars.compile...");
var compileScript =
$"Handlebars.compile(`{report.Template}`)(PreParedReportDataObject);";
if (!ReportRenderManager.KeepGoing(job.GId))
return;
var compiledHTML = await page.EvaluateExpressionAsync<string>(compileScript);
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//render report as HTML
log.LogDebug($"Setting render page content style and compiled HTML");
await page.SetContentAsync($"<style>{report.Style}</style>{compiledHTML}");
string outputFileName =
StringUtil.ReplaceLastOccurrence(FileUtil.NewRandomFileName, ".", "")
+ ".pdf";
string outputFullPath = System.IO.Path.Combine(
FileUtil.TemporaryFilesFolder,
outputFileName
);
//Set PDF options
//https://pptr.dev/#?product=Puppeteer&version=v5.3.0&show=api-pagepdfoptions
log.LogDebug($"Resolving PDF Options from report settings");
var PdfOptions = new PdfOptions() { };
PdfOptions.DisplayHeaderFooter = report.DisplayHeaderFooter;
if (report.DisplayHeaderFooter)
{
var ClientPDFDate = reportRequest.ClientMeta["PDFDate"].Value<string>();
var ClientPDFTime = reportRequest.ClientMeta["PDFTime"].Value<string>();
PdfOptions.HeaderTemplate = report
.HeaderTemplate.Replace("PDFDate", ClientPDFDate)
.Replace("PDFTime", ClientPDFTime);
PdfOptions.FooterTemplate = report
.FooterTemplate.Replace("PDFDate", ClientPDFDate)
.Replace("PDFTime", ClientPDFTime);
}
if (report.PaperFormat != ReportPaperFormat.NotSet)
{
switch (report.PaperFormat)
{
case ReportPaperFormat.A0:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A0;
break;
case ReportPaperFormat.A1:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A1;
break;
case ReportPaperFormat.A2:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A2;
break;
case ReportPaperFormat.A3:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A3;
break;
case ReportPaperFormat.A4:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A4;
break;
case ReportPaperFormat.A5:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A5;
break;
case ReportPaperFormat.A6:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A6;
break;
case ReportPaperFormat.Ledger:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Ledger;
break;
case ReportPaperFormat.Legal:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Legal;
break;
case ReportPaperFormat.Letter:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Letter;
break;
case ReportPaperFormat.Tabloid:
PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Tabloid;
break;
}
}
PdfOptions.Landscape = report.Landscape;
if (!string.IsNullOrWhiteSpace(report.MarginOptionsBottom))
PdfOptions.MarginOptions.Bottom = report.MarginOptionsBottom;
if (!string.IsNullOrWhiteSpace(report.MarginOptionsLeft))
PdfOptions.MarginOptions.Left = report.MarginOptionsLeft;
if (!string.IsNullOrWhiteSpace(report.MarginOptionsRight))
PdfOptions.MarginOptions.Right = report.MarginOptionsRight;
if (!string.IsNullOrWhiteSpace(report.MarginOptionsTop))
PdfOptions.MarginOptions.Top = report.MarginOptionsTop;
PdfOptions.PreferCSSPageSize = report.PreferCSSPageSize;
PdfOptions.PrintBackground = report.PrintBackground;
//Defaults to 1. Scale amount must be between 0.1 and 2.
PdfOptions.Scale = report.Scale;
if (!ReportRenderManager.KeepGoing(job.GId))
return;
//render to pdf and return
log.LogDebug($"Calling render page contents to PDF");
await page.PdfAsync(outputFullPath, PdfOptions);
log.LogDebug($"Closing Page");
await page.CloseAsync();
log.LogDebug($"Closing Browser");
await browser.CloseAsync();
log.LogDebug(
$"Render completed successfully, output filename is: {outputFileName}, logging to job for client"
);
var json = Newtonsoft.Json.JsonConvert.SerializeObject(
new { reportfilename = outputFileName },
Newtonsoft.Json.Formatting.None
);
await JobsBiz.LogJobAsync(job.GId, json);
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed);
return;
}
catch (ReportRenderTimeOutException)
{
await HandleTimeOut(job, log, reportRequest, userName);
return;
}
catch (PuppeteerSharp.TargetClosedException)
{
log.LogDebug(
"Caught PuppeteerSharp.TargetClosedException - report was cancelled by user OR timed out"
);
//we closed it because the timeout hit and the CoreJobReportRenderEngineProcessCleanup job cleaned it out
//so return the error the client expects in this scenario
await HandleTimeOut(job, log, reportRequest, userName);
return;
}
catch (Exception ex)
{
log.LogDebug(ex, $"Error during report rendering");
//This is the error when a helper is used on the template but doesn't exist:
//Evaluation failed: d
//(it might also mean other things wrong with template)
if (PageLog.Length > 0)
{
log.LogInformation(
$"Exception caught while rendering report \"{report.Name}\", report Page console log:"
);
log.LogInformation(PageLog.ToString());
var json = Newtonsoft.Json.JsonConvert.SerializeObject(
new
{
rendererror = new
{
pagelog = PageLog.ToString(),
exception = ExceptionUtil.ExtractAllExceptionMessages(ex),
},
},
Newtonsoft.Json.Formatting.None
);
await JobsBiz.LogJobAsync(job.GId, json);
}
else
{
var json = Newtonsoft.Json.JsonConvert.SerializeObject(
new
{
rendererror = new
{
exception = ExceptionUtil.ExtractAllExceptionMessages(ex),
},
},
Newtonsoft.Json.Formatting.None
);
await JobsBiz.LogJobAsync(job.GId, json);
}
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed);
// var v=await page.GetContentAsync();//for debugging purposes
return;
}
finally
{
log.LogDebug($"reached finally block");
if (!page.IsClosed)
{
log.LogDebug($"Page not closed in finally block, closing now");
await page.CloseAsync();
}
if (!browser.IsClosed)
{
log.LogDebug($"Browser not closed in finally block, closing now");
await browser.CloseAsync();
}
log.LogDebug(
$"Calling ReportRenderManager.RemoveJob to stop tracking this job/process"
);
await ReportRenderManager.RemoveJob(job.GId, log, false);
}
}
static async Task HandleTimeOut(
OpsJob job,
ILogger log,
DataListReportRequest reportRequest,
string userName
)
{
log.LogDebug(
$"Report render cancelled by user OR exceeded timeout setting of {ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT} minutes, report id: {reportRequest.ReportId}, record count:{reportRequest.SelectedRowIds.LongLength}, user:{userName}"
);
var json = Newtonsoft.Json.JsonConvert.SerializeObject(
new
{
rendererror = new
{
timeout = true,
timeoutsetting = ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT,
},
},
Newtonsoft.Json.Formatting.None
);
//"{\"rendererror\":{\"timeout\":1}}"
await JobsBiz.LogJobAsync(job.GId, json);
await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Failed);
}
}
public async Task CancelJob(Guid jobId)
{
await ReportRenderManager.RemoveJob(
jobId,
AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::CancelJob"),
true
);
}
////////////////////////////////////////////////////////////////////////////////////////////////
//JOB / OPERATIONS
//
public async Task HandleJobAsync(OpsJob job)
{
switch (job.JobType)
{
case JobType.RenderReport:
await DoRenderJob(job);
break;
default:
throw new System.ArgumentOutOfRangeException(
$"ReportBiz.HandleJob-> Invalid job type{job.JobType.ToString()}"
);
}
}
//Other job handlers here...
/////////////////////////////////////////////////////////////////////
} //eoc
} //eons