diff --git a/app/Api/V1/Controllers/RuleController.php b/app/Api/V1/Controllers/RuleController.php index 07ff7c870c..c4e74e3721 100644 --- a/app/Api/V1/Controllers/RuleController.php +++ b/app/Api/V1/Controllers/RuleController.php @@ -23,25 +23,37 @@ declare(strict_types=1); namespace FireflyIII\Api\V1\Controllers; +use Carbon\Carbon; use FireflyIII\Api\V1\Requests\RuleRequest; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Jobs\ExecuteRuleOnExistingTransactions; +use FireflyIII\Models\AccountType; use FireflyIII\Models\Rule; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use FireflyIII\Repositories\Rule\RuleRepositoryInterface; +use FireflyIII\TransactionRules\TransactionMatcher; use FireflyIII\Transformers\RuleTransformer; +use FireflyIII\Transformers\TransactionTransformer; use FireflyIII\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use League\Fractal\Manager; use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Resource\Collection as FractalCollection; use League\Fractal\Resource\Item; use League\Fractal\Serializer\JsonApiSerializer; +use Log; /** * Class RuleController */ class RuleController extends Controller { + /** @var AccountRepositoryInterface Account repository */ + private $accountRepository; /** @var RuleRepositoryInterface The rule repository */ private $ruleRepository; @@ -59,6 +71,9 @@ class RuleController extends Controller $this->ruleRepository = app(RuleRepositoryInterface::class); $this->ruleRepository->setUser($user); + $this->accountRepository = app(AccountRepositoryInterface::class); + $this->accountRepository->setUser($user); + return $next($request); } ); @@ -155,6 +170,115 @@ class RuleController extends Controller return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); } + /** + * @param Request $request + * @param Rule $rule + * + * @return JsonResponse + * @throws FireflyException + */ + public function testRule(Request $request, Rule $rule): JsonResponse + { + $pageSize = (int)app('preferences')->getForUser(auth()->user(), 'listPageSize', 50)->data; + $page = 0 === (int)$request->query('page') ? 1 : (int)$request->query('page'); + $startDate = null === $request->query('start_date') ? null : Carbon::createFromFormat('Y-m-d', $request->query('start_date')); + $endDate = null === $request->query('end_date') ? null : Carbon::createFromFormat('Y-m-d', $request->query('end_date')); + $searchLimit = 0 === (int)$request->query('search_limit') ? (int)config('firefly.test-triggers.limit') : (int)$request->query('search_limit'); + $triggerLimit = 0 === (int)$request->query('triggered_limit') ? (int)config('firefly.test-triggers.range') : (int)$request->query('triggered_limit'); + $accountList = '' === (string)$request->query('accounts') ? [] : explode(',', $request->query('accounts')); + $accounts = new Collection; + + foreach ($accountList as $accountId) { + Log::debug(sprintf('Searching for asset account with id "%s"', $accountId)); + $account = $this->accountRepository->findNull((int)$accountId); + if (null !== $account && AccountType::ASSET === $account->accountType->type) { + Log::debug(sprintf('Found account #%d ("%s") and its an asset account', $account->id, $account->name)); + $accounts->push($account); + } + if (null === $account) { + Log::debug(sprintf('No asset account with id "%s"', $accountId)); + } + } + + /** @var Rule $rule */ + Log::debug(sprintf('Now testing rule #%d, "%s"', $rule->id, $rule->title)); + /** @var TransactionMatcher $matcher */ + $matcher = app(TransactionMatcher::class); + // set all parameters: + $matcher->setRule($rule); + $matcher->setStartDate($startDate); + $matcher->setEndDate($endDate); + $matcher->setSearchLimit($searchLimit); + $matcher->setTriggeredLimit($triggerLimit); + $matcher->setAccounts($accounts); + + $matchingTransactions = $matcher->findTransactionsByRule(); + $matchingTransactions = $matchingTransactions->unique('id'); + + // make paginator out of results. + $count = $matchingTransactions->count(); + $transactions = $matchingTransactions->slice(($page - 1) * $pageSize, $pageSize); + // make paginator: + $paginator = new LengthAwarePaginator($transactions, $count, $pageSize, $this->parameters->get('page')); + $paginator->setPath(route('api.v1.rules.test', [$rule->id]) . $this->buildParams()); + + // resulting list is presented as JSON thing. + $manager = new Manager(); + $baseUrl = $request->getSchemeAndHttpHost() . '/api/v1'; + $manager->setSerializer(new JsonApiSerializer($baseUrl)); + $repository = app(JournalRepositoryInterface::class); + + $resource = new FractalCollection($matchingTransactions, new TransactionTransformer($this->parameters, $repository), 'transactions'); + $resource->setPaginator(new IlluminatePaginatorAdapter($paginator)); + + return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); + } + + /** + * Execute the given rule group on a set of existing transactions. + * + * @param Request $request + * @param Rule $rule + * + * @return JsonResponse + */ + public function triggerRule(Request $request, Rule $rule): JsonResponse + { + // Get parameters specified by the user + /** @var User $user */ + $user = auth()->user(); + $startDate = new Carbon($request->get('start_date')); + $endDate = new Carbon($request->get('end_date')); + $accountList = '' === (string)$request->query('accounts') ? [] : explode(',', $request->query('accounts')); + $accounts = new Collection; + + foreach ($accountList as $accountId) { + Log::debug(sprintf('Searching for asset account with id "%s"', $accountId)); + $account = $this->accountRepository->findNull((int)$accountId); + if (null !== $account && AccountType::ASSET === $account->accountType->type) { + Log::debug(sprintf('Found account #%d ("%s") and its an asset account', $account->id, $account->name)); + $accounts->push($account); + } + if (null === $account) { + Log::debug(sprintf('No asset account with id "%s"', $accountId)); + } + } + + // Create a job to do the work asynchronously + $job = new ExecuteRuleOnExistingTransactions($rule); + + // Apply parameters to the job + $job->setUser($user); + $job->setAccounts($accounts); + $job->setStartDate($startDate); + $job->setEndDate($endDate); + + // Dispatch a new job to execute it in a queue + $this->dispatch($job); + + return response()->json([], 204); + } + /** * Update a rule. * diff --git a/app/Api/V1/Requests/RuleGroupRequest.php b/app/Api/V1/Requests/RuleGroupRequest.php index 14d754f03c..92cef35728 100644 --- a/app/Api/V1/Requests/RuleGroupRequest.php +++ b/app/Api/V1/Requests/RuleGroupRequest.php @@ -51,9 +51,7 @@ class RuleGroupRequest extends Request */ public function getAll(): array { - if (null === $this->get('active')) { - $active = true; - } + $active = true; if (null !== $this->get('active')) { $active = $this->boolean('active'); diff --git a/app/Api/V1/Requests/RuleRequest.php b/app/Api/V1/Requests/RuleRequest.php index 95f2316abf..84430f7397 100644 --- a/app/Api/V1/Requests/RuleRequest.php +++ b/app/Api/V1/Requests/RuleRequest.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace FireflyIII\Api\V1\Requests; +use FireflyIII\Rules\IsBoolean; use Illuminate\Validation\Validator; @@ -49,17 +50,30 @@ class RuleRequest extends Request */ public function getAll(): array { + $strict = true; + $active = true; + $stopProcessing = false; + if (null !== $this->get('active')) { + $active = $this->boolean('active'); + } + if (null !== $this->get('strict')) { + $strict = $this->boolean('strict'); + } + if (null !== $this->get('stop_processing')) { + $stopProcessing = $this->boolean('stop_processing'); + } + $data = [ 'title' => $this->string('title'), 'description' => $this->string('description'), 'rule_group_id' => $this->integer('rule_group_id'), 'rule_group_title' => $this->string('rule_group_title'), 'trigger' => $this->string('trigger'), - 'strict' => $this->boolean('strict'), - 'stop_processing' => $this->boolean('stop_processing'), - 'active' => $this->boolean('active'), - 'rule_triggers' => $this->getRuleTriggers(), - 'rule_actions' => $this->getRuleActions(), + 'strict' => $strict, + 'stop_processing' => $stopProcessing, + 'active' => $active, + 'triggers' => $this->getRuleTriggers(), + 'actions' => $this->getRuleActions(), ]; return $data; @@ -81,20 +95,20 @@ class RuleRequest extends Request $rules = [ - 'title' => 'required|between:1,100|uniqueObjectForUser:rules,title', - 'description' => 'between:1,5000|nullable', - 'rule_group_id' => 'required|belongsToUser:rule_groups|required_without:rule_group_title', - 'rule_group_title' => 'nullable|between:1,255|required_without:rule_group_id|belongsToUser:rule_groups,title', - 'trigger' => 'required|in:store-journal,update-journal', - 'rule_triggers.*.name' => 'required|in:' . implode(',', $validTriggers), - 'rule_triggers.*.stop_processing' => 'boolean', - 'rule_triggers.*.value' => 'required_if:rule_actions.*.type,' . $contextTriggers . '|min:1|ruleTriggerValue', - 'rule_actions.*.name' => 'required|in:' . implode(',', $validActions), - 'rule_actions.*.value' => 'required_if:rule_actions.*.type,' . $contextActions . '|ruleActionValue', - 'rule_actions.*.stop_processing' => 'boolean', - 'strict' => 'required|boolean', - 'stop_processing' => 'required|boolean', - 'active' => 'required|boolean', + 'title' => 'required|between:1,100|uniqueObjectForUser:rules,title', + 'description' => 'between:1,5000|nullable', + 'rule_group_id' => 'required|belongsToUser:rule_groups|required_without:rule_group_title', + 'rule_group_title' => 'nullable|between:1,255|required_without:rule_group_id|belongsToUser:rule_groups,title', + 'trigger' => 'required|in:store-journal,update-journal', + 'triggers.*.name' => 'required|in:' . implode(',', $validTriggers), + 'triggers.*.stop_processing' => [new IsBoolean], + 'triggers.*.value' => 'required_if:rule_actions.*.type,' . $contextTriggers . '|min:1|ruleTriggerValue', + 'actions.*.name' => 'required|in:' . implode(',', $validActions), + 'actions.*.value' => 'required_if:rule_actions.*.type,' . $contextActions . '|ruleActionValue', + 'actions.*.stop_processing' => [new IsBoolean], + 'strict' => [new IsBoolean], + 'stop_processing' => [new IsBoolean], + 'active' => [new IsBoolean], ]; return $rules; @@ -124,10 +138,10 @@ class RuleRequest extends Request */ protected function atLeastOneAction(Validator $validator): void { - $data = $validator->getData(); - $repetitions = $data['rule_actions'] ?? []; - // need at least one transaction - if (0 === \count($repetitions)) { + $data = $validator->getData(); + $actions = $data['actions'] ?? []; + // need at least one trigger + if (0 === \count($actions)) { $validator->errors()->add('title', (string)trans('validation.at_least_one_action')); } } @@ -139,10 +153,10 @@ class RuleRequest extends Request */ protected function atLeastOneTrigger(Validator $validator): void { - $data = $validator->getData(); - $repetitions = $data['rule_triggers'] ?? []; - // need at least one transaction - if (0 === \count($repetitions)) { + $data = $validator->getData(); + $triggers = $data['triggers'] ?? []; + // need at least one trugger + if (0 === \count($triggers)) { $validator->errors()->add('title', (string)trans('validation.at_least_one_trigger')); } } @@ -152,14 +166,14 @@ class RuleRequest extends Request */ private function getRuleActions(): array { - $actions = $this->get('rule_actions'); + $actions = $this->get('actions'); $return = []; if (\is_array($actions)) { foreach ($actions as $action) { $return[] = [ 'name' => $action['name'], 'value' => $action['value'], - 'stop_processing' => 1 === (int)($action['stop-processing'] ?? '0'), + 'stop_processing' => $this->convertBoolean((string)($action['stop_processing'] ?? 'false')), ]; } } @@ -172,14 +186,14 @@ class RuleRequest extends Request */ private function getRuleTriggers(): array { - $triggers = $this->get('rule_triggers'); + $triggers = $this->get('triggers'); $return = []; if (\is_array($triggers)) { foreach ($triggers as $trigger) { $return[] = [ 'name' => $trigger['name'], 'value' => $trigger['value'], - 'stop_processing' => 1 === (int)($trigger['stop-processing'] ?? '0'), + 'stop_processing' => $this->convertBoolean((string)($trigger['stop_processing'] ?? 'false')), ]; } } diff --git a/app/Http/Requests/Request.php b/app/Http/Requests/Request.php index ed4c1a8086..6bd87f2af6 100644 --- a/app/Http/Requests/Request.php +++ b/app/Http/Requests/Request.php @@ -27,6 +27,7 @@ use Illuminate\Foundation\Http\FormRequest; /** * Class Request. + * * @codeCoverageIgnore * * @SuppressWarnings(PHPMD.NumberOfChildren) @@ -52,6 +53,29 @@ class Request extends FormRequest return 1 === (int)$this->input($field); } + /** + * @param string $value + * + * @return bool + */ + public function convertBoolean(string $value): bool + { + if ('true' === $value) { + return true; + } + if (1 === $value) { + return true; + } + if ('1' === $value) { + return true; + } + if (true === $value) { + return true; + } + + return false; + } + /** * Return floating value. * diff --git a/app/Repositories/Rule/RuleRepository.php b/app/Repositories/Rule/RuleRepository.php index 7b9049eb58..e8ad4e58c3 100644 --- a/app/Repositories/Rule/RuleRepository.php +++ b/app/Repositories/Rule/RuleRepository.php @@ -301,9 +301,9 @@ class RuleRepository implements RuleRepositoryInterface $rule->rule_group_id = $data['rule_group_id']; $rule->order = ($order + 1); - $rule->active = true; - $rule->strict = $data['strict'] ?? false; - $rule->stop_processing = 1 === (int)$data['stop_processing']; + $rule->active = $data['active']; + $rule->strict = $data['strict']; + $rule->stop_processing = $data['stop_processing']; $rule->title = $data['title']; $rule->description = \strlen($data['description']) > 0 ? $data['description'] : null; @@ -399,7 +399,7 @@ class RuleRepository implements RuleRepositoryInterface private function storeActions(Rule $rule, array $data): bool { $order = 1; - foreach ($data['rule_actions'] as $action) { + foreach ($data['actions'] as $action) { $value = $action['value'] ?? ''; $stopProcessing = $action['stop_processing'] ?? false; @@ -435,7 +435,7 @@ class RuleRepository implements RuleRepositoryInterface ]; $this->storeTrigger($rule, $triggerValues); - foreach ($data['rule_triggers'] as $trigger) { + foreach ($data['triggers'] as $trigger) { $value = $trigger['value'] ?? ''; $stopProcessing = $trigger['stop_processing'] ?? false; diff --git a/app/Validation/FireflyValidator.php b/app/Validation/FireflyValidator.php index 7318dd34f5..9841ba6d4f 100644 --- a/app/Validation/FireflyValidator.php +++ b/app/Validation/FireflyValidator.php @@ -257,7 +257,7 @@ class FireflyValidator extends Validator $index = (int)($parts[1] ?? '0'); // get the name of the trigger from the data array: - $actionType = $this->data['rule_actions'][$index]['name'] ?? 'invalid'; + $actionType = $this->data['actions'][$index]['name'] ?? 'invalid'; // if it's "invalid" return false. if ('invalid' === $actionType) { @@ -320,7 +320,7 @@ class FireflyValidator extends Validator $index = (int)($parts[1] ?? '0'); // get the name of the trigger from the data array: - $triggerType = $this->data['rule_triggers'][$index]['name'] ?? 'invalid'; + $triggerType = $this->data['triggers'][$index]['name'] ?? 'invalid'; // invalid always returns false: if ('invalid' === $triggerType) { diff --git a/routes/api.php b/routes/api.php index f709e76e5e..a4eb5b6dae 100644 --- a/routes/api.php +++ b/routes/api.php @@ -216,6 +216,8 @@ Route::group( Route::get('{rule}', ['uses' => 'RuleController@show', 'as' => 'show']); Route::put('{rule}', ['uses' => 'RuleController@update', 'as' => 'update']); Route::delete('{rule}', ['uses' => 'RuleController@delete', 'as' => 'delete']); + Route::get('{rule}/test', ['uses' => 'RuleController@testRule', 'as' => 'test']); + Route::post('{rule}/trigger', ['uses' => 'RuleController@triggerRule', 'as' => 'trigger']); } ); @@ -229,6 +231,9 @@ Route::group( Route::get('{ruleGroup}', ['uses' => 'RuleGroupController@show', 'as' => 'show']); Route::put('{ruleGroup}', ['uses' => 'RuleGroupController@update', 'as' => 'update']); Route::delete('{ruleGroup}', ['uses' => 'RuleGroupController@delete', 'as' => 'delete']); + Route::get('{ruleGroup}/test', ['uses' => 'RuleGroupController@testGroup', 'as' => 'test']); + Route::get('{ruleGroup}/rules', ['uses' => 'RuleGroupController@rules', 'as' => 'rules']); + Route::post('{ruleGroup}/trigger', ['uses' => 'RuleGroupController@triggerGroup', 'as' => 'trigger']); } );