Merge branch '20231017_live_preview_refactor' into v5-develop

This commit is contained in:
David Bomba 2023-10-19 10:44:47 +11:00
commit df18a11006
7 changed files with 431 additions and 81 deletions

View File

@ -11,42 +11,43 @@
namespace App\Http\Controllers;
use App\DataMapper\Analytics\LivePreview;
use App\Factory\CreditFactory;
use App\Factory\InvoiceFactory;
use App\Factory\QuoteFactory;
use App\Factory\RecurringInvoiceFactory;
use App\Http\Requests\Preview\DesignPreviewRequest;
use App\Http\Requests\Preview\PreviewInvoiceRequest;
use App\Jobs\Util\PreviewPdf;
use App\Libraries\MultiDB;
use App\Utils\Ninja;
use App\Models\Quote;
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\Credit;
use App\Models\Invoice;
use App\Models\InvoiceInvitation;
use App\Models\Quote;
use App\Models\RecurringInvoice;
use App\Repositories\CreditRepository;
use App\Repositories\InvoiceRepository;
use App\Repositories\QuoteRepository;
use App\Repositories\RecurringInvoiceRepository;
use App\Utils\HtmlEngine;
use App\Libraries\MultiDB;
use App\Factory\QuoteFactory;
use App\Jobs\Util\PreviewPdf;
use App\Models\ClientContact;
use App\Services\Pdf\PdfMock;
use App\Factory\CreditFactory;
use App\Factory\InvoiceFactory;
use App\Utils\Traits\MakesHash;
use App\Models\RecurringInvoice;
use App\Utils\PhantomJS\Phantom;
use App\Models\InvoiceInvitation;
use App\Services\PdfMaker\Design;
use App\Utils\HostedPDF\NinjaPdf;
use Illuminate\Support\Facades\DB;
use App\Services\PdfMaker\PdfMaker;
use Illuminate\Support\Facades\App;
use App\Repositories\QuoteRepository;
use Illuminate\Support\Facades\Cache;
use App\Repositories\CreditRepository;
use App\Utils\Traits\MakesInvoiceHtml;
use Turbo124\Beacon\Facades\LightLogs;
use App\Repositories\InvoiceRepository;
use App\Utils\Traits\Pdf\PageNumbering;
use App\Factory\RecurringInvoiceFactory;
use Illuminate\Support\Facades\Response;
use App\DataMapper\Analytics\LivePreview;
use App\Repositories\RecurringInvoiceRepository;
use App\Http\Requests\Preview\DesignPreviewRequest;
use App\Services\PdfMaker\Design as PdfDesignModel;
use App\Services\PdfMaker\Design as PdfMakerDesign;
use App\Services\PdfMaker\PdfMaker;
use App\Utils\HostedPDF\NinjaPdf;
use App\Utils\HtmlEngine;
use App\Utils\Ninja;
use App\Utils\PhantomJS\Phantom;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\MakesInvoiceHtml;
use App\Utils\Traits\Pdf\PageNumbering;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Response;
use Turbo124\Beacon\Facades\LightLogs;
use App\Http\Requests\Preview\PreviewInvoiceRequest;
class PreviewController extends BaseController
{
@ -56,15 +57,177 @@ class PreviewController extends BaseController
public function __construct()
{
parent::__construct();
parent::__construct();
}
private function purgeCache()
{
Cache::pull("preview_".auth()->user()->id);
}
/**
* Refactor - 2023-10-19
*
* New method does not require Transactions.
*
* @param PreviewInvoiceRequest $request
* @return mixed
*/
public function live(PreviewInvoiceRequest $request): mixed
{
if (Ninja::isHosted() && !in_array($request->getHost(), ['preview.invoicing.co','staging.invoicing.co'])) {
return response()->json(['message' => 'This server cannot handle this request.'], 400);
}
$start = microtime(true);
/** Build models */
$invitation = $request->resolveInvitation();
$client = $request->getClient();
$settings = $client->getMergedSettings();
/** Set translations */
App::forgetInstance('translator');
$t = app('translator');
App::setLocale($invitation->contact->preferredLocale());
$t->replace(Ninja::transformTranslations($settings));
$entity_prop = str_replace("recurring_", "", $request->entity);
$entity_obj = $invitation->{$request->entity};
/** Update necessary objecty props */
if(!$entity_obj->id) {
$entity_obj->design_id = intval($this->decodePrimaryKey($settings->{$entity_prop."_design_id"}));
$entity_obj->footer = $settings->{$entity_prop."_footer"};
$entity_obj->terms = $settings->{$entity_prop."_terms"};
$entity_obj->public_notes = $request->getClient()->public_notes;
$invitation->{$request->entity} = $entity_obj;
}
/** Generate variables */
$html = new HtmlEngine($invitation);
$html->settings = $settings;
$variables = $html->generateLabelsAndValues();
$design = \App\Models\Design::withTrashed()->find($entity_obj->design_id ?? 2);
/* Catch all in case migration doesn't pass back a valid design */
if (! $design) {
$design = \App\Models\Design::find(2);
}
if ($design->is_custom) {
$options = [
'custom_partials' => json_decode(json_encode($design->design), true),
];
$template = new PdfMakerDesign(PdfDesignModel::CUSTOM, $options);
} else {
$template = new PdfMakerDesign(strtolower($design->name));
}
$state = [
'template' => $template->elements([
'client' => $client,
'entity' => $entity_obj,
'pdf_variables' => (array) $settings->pdf_variables,
'$product' => $design->design->product,
'variables' => $variables,
]),
'variables' => $variables,
'options' => [
'all_pages_header' => $client->getSetting('all_pages_header'),
'all_pages_footer' => $client->getSetting('all_pages_footer'),
],
'process_markdown' => $client->company->markdown_enabled,
];
$maker = new PdfMaker($state);
$maker
->design($template)
->build();
/** Generate HTML */
$html = $maker->getCompiledHTML(true);
if (request()->query('html') == 'true')
return $html;
//if phantom js...... inject here..
if (config('ninja.phantomjs_pdf_generation') || config('ninja.pdf_generator') == 'phantom') {
return (new Phantom)->convertHtmlToPdf($html);
}
/** @var \App\Models\User $user */
$user = auth()->user();
$company = $user->company();
if (config('ninja.invoiceninja_hosted_pdf_generation') || config('ninja.pdf_generator') == 'hosted_ninja') {
$pdf = (new NinjaPdf())->build($html);
$numbered_pdf = $this->pageNumbering($pdf, $company);
if ($numbered_pdf)
$pdf = $numbered_pdf;
return $pdf;
}
$pdf = (new PreviewPdf($html, $company))->handle();
if (Ninja::isHosted()) {
LightLogs::create(new LivePreview())
->increment()
->batch();
}
/** Return PDF */
return response()->streamDownload(function () use ($pdf) {
echo $pdf;
}, 'preview.pdf', [
'Content-Disposition' => 'inline',
'Content-Type' => 'application/pdf',
'Cache-Control:' => 'no-cache',
'Server-Timing' => microtime(true)-$start
]);
}
/**
* Returns the mocked PDF for the invoice design preview.
*
* Only used in Settings > Invoice Design as a general overview
*
* @param DesignPreviewRequest $request
* @return mixed
*/
public function design(DesignPreviewRequest $request): mixed
{
$start = microtime(true);
/** @var \App\Models\User $user */
$user = auth()->user();
/** @var \App\Models\Company $company */
$company = $user->company();
$pdf = (new PdfMock($request->all(), $company))->build()->getPdf();
$response = Response::make($pdf, 200);
$response->header('Content-Type', 'application/pdf');
$response->header('Server-Timing', microtime(true)-$start);
return $response;
}
/**
* Returns a template filled with entity variables.
*
*
* Used in the Custom Designer to preview design changes
* @return mixed
*/
public function show()
{
if (request()->has('entity') &&
@ -72,6 +235,7 @@ class PreviewController extends BaseController
! empty(request()->input('entity')) &&
! empty(request()->input('entity_id')) &&
request()->has('body')) {
$design_object = json_decode(json_encode(request()->input('design')));
if (! is_object($design_object)) {
@ -128,55 +292,57 @@ class PreviewController extends BaseController
return (new Phantom)->convertHtmlToPdf($maker->getCompiledHTML(true));
}
/** @var \App\Models\User $user */
$user = auth()->user();
$company = $user->company();
if (config('ninja.invoiceninja_hosted_pdf_generation') || config('ninja.pdf_generator') == 'hosted_ninja') {
$pdf = (new NinjaPdf())->build($maker->getCompiledHTML(true));
$numbered_pdf = $this->pageNumbering($pdf, $company);
if ($numbered_pdf) {
if ($numbered_pdf)
$pdf = $numbered_pdf;
}
return $pdf;
}
$file_path = (new PreviewPdf($maker->getCompiledHTML(true), $company))->handle();
$pdf = (new PreviewPdf($maker->getCompiledHTML(true), $company))->handle();
return response()->streamDownload(function () use ($pdf) {
echo $pdf;
}, 'preview.pdf', [
'Content-Disposition' => 'inline',
'Content-Type' => 'application/pdf',
'Cache-Control:' => 'no-cache',
]);
return response()->download($file_path, basename($file_path), ['Cache-Control:' => 'no-cache'])->deleteFileAfterSend(true);
}
return $this->blankEntity();
}
public function design(DesignPreviewRequest $request)
/**
* @deprecated due to usage of transactions
*
* @param mixed $request
* @return void
*/
public function livex(PreviewInvoiceRequest $request)
{
/** @var \App\Models\User $user */
$user = auth()->user();
/** @var \App\Models\Company $company */
$company = $user->company();
if(Cache::has("preview_".auth()->user()->id))
return response()->json(['message' => 'Please wait a few seconds before trying again, this many requests are not good.'], 400);
$pdf = (new PdfMock($request->all(), $company))->build()->getPdf();
$response = Response::make($pdf, 200);
$response->header('Content-Type', 'application/pdf');
return $response;
}
public function live(PreviewInvoiceRequest $request)
{
if (Ninja::isHosted() && !in_array($request->getHost(), ['preview.invoicing.co','staging.invoicing.co'])) {
return response()->json(['message' => 'This server cannot handle this request.'], 400);
}
Cache::put("preview_".auth()->user()->id, 60);
$start = microtime(true);
/** @var \App\Models\User $user */
@ -287,9 +453,13 @@ class PreviewController extends BaseController
DB::connection(config('database.default'))->rollBack();
if (request()->query('html') == 'true') {
$this->purgeCache();
return $maker->getCompiledHTML();
}
} catch(\Exception $e) {
$this->purgeCache();
DB::connection(config('database.default'))->rollBack();
@ -302,6 +472,7 @@ class PreviewController extends BaseController
//if phantom js...... inject here..
if (config('ninja.phantomjs_pdf_generation') || config('ninja.pdf_generator') == 'phantom') {
$this->purgeCache();
return (new Phantom)->convertHtmlToPdf($maker->getCompiledHTML(true));
}
@ -319,7 +490,7 @@ class PreviewController extends BaseController
if ($numbered_pdf) {
$pdf = $numbered_pdf;
}
$this->purgeCache();
return $pdf;
}
@ -335,7 +506,7 @@ class PreviewController extends BaseController
$response->header('Content-Type', 'application/pdf');
$response->header('Server-Timing', microtime(true)-$start);
$this->purgeCache();
return $response;
}
@ -523,7 +694,6 @@ class PreviewController extends BaseController
$response = Response::make($file_path, 200);
$response->header('Content-Type', 'application/pdf');
return $response;
}
}

View File

@ -23,7 +23,7 @@ class ProtectedDownloadController extends BaseController
public function index(Request $request)
{
/** @var string $hashed_path */
$hashed_path = Cache::pull($request->hash);
if (!$hashed_path) {

View File

@ -32,11 +32,14 @@ class DesignPreviewRequest extends Request
*/
public function authorize() : bool
{
return auth()->user()->can('create', Invoice::class) ||
auth()->user()->can('create', Quote::class) ||
auth()->user()->can('create', RecurringInvoice::class) ||
auth()->user()->can('create', Credit::class) ||
auth()->user()->can('create', PurchaseOrder::class);
/** @var \App\Models\User $user */
$user = auth()->user();
return $user->can('create', Invoice::class) ||
$user->can('create', Quote::class) ||
$user->can('create', RecurringInvoice::class) ||
$user->can('create', Credit::class) ||
$user->can('create', PurchaseOrder::class);
}
public function rules()

View File

@ -11,15 +11,29 @@
namespace App\Http\Requests\Preview;
use App\Models\Quote;
use App\Models\Client;
use App\Models\Credit;
use App\Models\Invoice;
use App\Http\Requests\Request;
use App\Utils\Traits\CleanLineItems;
use App\Models\QuoteInvitation;
use App\Utils\Traits\MakesHash;
use Illuminate\Validation\Rule;
use App\Models\CreditInvitation;
use App\Models\RecurringInvoice;
use App\Models\InvoiceInvitation;
use App\Utils\Traits\CleanLineItems;
use App\Models\RecurringInvoiceInvitation;
class PreviewInvoiceRequest extends Request
{
use MakesHash;
use CleanLineItems;
private string $entity_plural = '';
private ?Client $client = null;
/**
* Determine if the user is authorized to make this request.
*
@ -27,20 +41,32 @@ class PreviewInvoiceRequest extends Request
*/
public function authorize() : bool
{
return auth()->user()->hasIntersectPermissionsOrAdmin(['view_invoice', 'view_quote', 'view_recurring_invoice', 'view_credit', 'create_invoice', 'create_quote', 'create_recurring_invoice', 'create_credit','edit_invoice', 'edit_quote', 'edit_recurring_invoice', 'edit_credit']);
/** @var \App\Models\User $user */
$user = auth()->user();
return $user->hasIntersectPermissionsOrAdmin(['view_invoice', 'view_quote', 'view_recurring_invoice', 'view_credit', 'create_invoice', 'create_quote', 'create_recurring_invoice', 'create_credit','edit_invoice', 'edit_quote', 'edit_recurring_invoice', 'edit_credit']);
}
public function rules()
{
$rules = [];
/** @var \App\Models\User $user */
$user = auth()->user();
$rules['number'] = ['nullable'];
return [
'number' => 'nullable',
'entity' => 'bail|sometimes|in:invoice,quote,credit,recurring_invoice',
'entity_id' => ['bail','sometimes','integer',Rule::exists($this->entity_plural, 'id')->where('is_deleted',0)->where('company_id', $user->company()->id)],
'client_id' => ['required', Rule::exists(Client::class, 'id')->where('is_deleted', 0)->where('company_id', $user->company()->id)],
];
return $rules;
}
public function prepareForValidation()
{
/** @var \App\Models\User $user */
$user = auth()->user();
$input = $this->all();
$input = $this->decodePrimaryKeys($input);
@ -50,6 +76,112 @@ class PreviewInvoiceRequest extends Request
$input['balance'] = 0;
$input['number'] = isset($input['number']) ? $input['number'] : ctrans('texts.live_preview').' #'.rand(0, 1000);
if($input['entity_id'] ?? false)
$input['entity_id'] = $this->decodePrimaryKey($input['entity_id'], true);
$this->convertEntityPlural($input['entity'] ?? 'invoice');
$this->replace($input);
}
public function resolveInvitation()
{
$invitation = false;
if(! $this->entity_id ?? false)
return $this->stubInvitation();
match($this->entity){
'invoice' => $invitation = InvoiceInvitation::withTrashed()->where('invoice_id', $this->entity_id)->first(),
'quote' => $invitation = QuoteInvitation::withTrashed()->where('quote_id', $this->entity_id)->first(),
'credit' => $invitation = CreditInvitation::withTrashed()->where('credit_id', $this->entity_id)->first(),
'recurring_invoice' => $invitation = RecurringInvoiceInvitation::withTrashed()->where('recurring_invoice_id', $this->entity_id)->first(),
};
if($invitation)
return $invitation;
$invitation = $this->stubInvitation();
}
public function getClient(): ?Client
{
if(!$this->client)
$this->client = Client::query()->with('contacts', 'company', 'user')->withTrashed()->find($this->client_id);
return $this->client;
}
public function setClient(Client $client): self
{
$this->client = $client;
return $this;
}
public function stubInvitation()
{
$client = Client::query()->with('contacts', 'company', 'user')->withTrashed()->find($this->client_id);
$this->setClient($client);
$invitation = false;
match($this->entity) {
'invoice' => $invitation = InvoiceInvitation::factory()->make(),
'quote' => $invitation = QuoteInvitation::factory()->make(),
'credit' => $invitation = CreditInvitation::factory()->make(),
'recurring_invoice' => $invitation = RecurringInvoiceInvitation::factory()->make(),
default => $invitation = InvoiceInvitation::factory()->make(),
};
$entity = $this->stubEntity($client);
$invitation->make();
$invitation->setRelation($this->entity, $entity);
$invitation->setRelation('contact', $client->contacts->first()->load('client.company'));
$invitation->setRelation('company', $client->company);
return $invitation;
}
private function stubEntity(Client $client)
{
$entity = false;
match($this->entity){
'invoice' => $entity = Invoice::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]),
'quote' => $entity = Quote::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]),
'credit' => $entity = Credit::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]),
'recurring_invoice' => $entity = RecurringInvoice::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]),
default => $entity = Invoice::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]),
};
$entity->setRelation('client', $client);
$entity->setRelation('company', $client->company);
$entity->setRelation('user', $client->user);
$entity->fill($this->all());
return $entity;
}
private function convertEntityPlural(string $entity) :self
{
switch ($entity) {
case 'invoice':
$this->entity_plural = 'invoices';
return $this;
case 'quote':
$this->entity_plural = 'quotes';
return $this;
case 'credit':
$this->entity_plural = 'credits';
return $this;
case 'recurring_invoice':
$this->entity_plural = 'invoices';
return $this;
default:
$this->entity_plural = 'invoices';
return $this;
}
}
}

View File

@ -24,25 +24,14 @@ class PreviewPdf implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, PdfMaker, PageNumbering;
public $company;
private $disk;
public $design_string;
/**
* Create a new job instance.
*
* @param $design_string
* @param Company $company
*/
public function __construct($design_string, Company $company)
public function __construct(public string $design_string, public Company $company)
{
$this->company = $company;
$this->design_string = $design_string;
$this->disk = $disk ?? config('filesystems.default');
}
public function handle()

View File

@ -0,0 +1,30 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class RecurringInvoiceInvitationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'key' => Str::random(40),
];
}
}

View File

@ -13,7 +13,9 @@ namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Design;
use App\Utils\HtmlEngine;
use Tests\MockAccountData;
use App\Models\InvoiceInvitation;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@ -41,6 +43,30 @@ class LiveDesignTest extends TestCase
}
}
public function testSyntheticInvitations()
{
$this->assertGreaterThanOrEqual(1, $this->client->contacts->count());
$ii = InvoiceInvitation::factory()
->for($this->invoice)
->for($this->client->contacts->first(), 'contact')
->for($this->company)
->for($this->user)
->make();
$this->assertInstanceOf(InvoiceInvitation::class, $ii);
$engine = new HtmlEngine($ii);
$this->assertNotNull($engine);
$data = $engine->generateLabelsAndValues();
$this->assertIsArray($data);
nlog($data);
}
public function testDesignRoute200()
{
$data = [