diff --git a/engine/action.go b/engine/action.go index ba234bac0..ff085e574 100644 --- a/engine/action.go +++ b/engine/action.go @@ -197,6 +197,7 @@ func init() { actionFuncMap[utils.MetaDynamicDestination] = dynamicDestination actionFuncMap[utils.MetaDynamicFilter] = dynamicFilter actionFuncMap[utils.MetaDynamicRoute] = dynamicRoute + actionFuncMap[utils.MetaDynamicRanking] = dynamicRanking } func getActionFunc(typ string) (f actionTypeFunc, exists bool) { @@ -1460,6 +1461,7 @@ func remoteSetAccount(ub *Account, a *Action, _ Actions, _ *FilterS, _ any, _ Sh // 8 Weight: float, should be higher than the threshold weight that triggers this action // 9 ActionIDs: strings separated by "&". // 10 Async: bool +// 11 EeIDs: strings separated by "&". // 11 APIOpts: set of key-value pairs (separated by "&"). // // Parameters are separated by ";" and must be provided in the specified order. @@ -1477,8 +1479,8 @@ func dynamicThreshold(_ *Account, act *Action, _ Actions, _ *FilterS, ev any, } // Parse action parameters based on the predefined format. params := strings.Split(act.ExtraParameters, utils.InfieldSep) - if len(params) != 12 { - return errors.New(fmt.Sprintf("invalid number of parameters <%d> expected 12", len(params))) + if len(params) != 13 { + return errors.New(fmt.Sprintf("invalid number of parameters <%d> expected 13", len(params))) } // parse dynamic parameters for i := range params { @@ -1560,9 +1562,13 @@ func dynamicThreshold(_ *Account, act *Action, _ Actions, _ *FilterS, ev any, return err } } - // populate Threshold's APIOpts + // populate Threshold's EeIDs if params[11] != utils.EmptyString { - if err := parseParamStringToMap(params[11], thProf.APIOpts); err != nil { + thProf.EeIDs = strings.Split(params[11], utils.ANDSep) + } + // populate Threshold's APIOpts + if params[12] != utils.EmptyString { + if err := parseParamStringToMap(params[12], thProf.APIOpts); err != nil { return err } } @@ -2174,10 +2180,10 @@ func dynamicFilter(_ *Account, act *Action, _ Actions, _ *FilterS, ev any, // 9 RouteRatingPlanIDs: strings separated by "&". // 10 RouteResourceIDs: strings separated by "&". // 11 RouteStatIDs: strings separated by "&". -// 12 RouteWeight: string -// 13 RouteBlocker: string +// 12 RouteWeight: float +// 13 RouteBlocker: bool // 14 RouteParameters: string -// 15 Weight: string +// 15 Weight: float // 16 APIOpts: set of key-value pairs (separated by "&"). // // Parameters are separated by ";" and must be provided in the specified order. @@ -2294,3 +2300,87 @@ func dynamicRoute(_ *Account, act *Action, _ Actions, _ *FilterS, ev any, var reply string return connMgr.Call(context.Background(), connCfg.ConnIDs, utils.APIerSv1SetRouteProfile, route, &reply) } + +// dynamicRanking processes the `ExtraParameters` field from the action to +// construct a RankingProfile +// +// The ExtraParameters field format is expected as follows: +// +// 0 Tenant: string +// 1 ID: string +// 2 Schedule: string +// 3 StatIDs: strings separated by "&". +// 4 MetricIDs: strings separated by "&". +// 5 Sorting: string +// 6 SortingParameters: strings separated by "&". +// 7 Stored: bool +// 8 ThresholdIDs: strings separated by "&". +// 9 APIOpts: set of key-value pairs (separated by "&"). +// +// Parameters are separated by ";" and must be provided in the specified order. +func dynamicRanking(_ *Account, act *Action, _ Actions, _ *FilterS, ev any, + _ SharedActionsData, connCfg ActionConnCfg) (err error) { + cgrEv, canCast := ev.(*utils.CGREvent) + if !canCast { + return errors.New("Couldn't cast event to CGREvent") + } + dP := utils.MapStorage{ // create DataProvider from event + utils.MetaReq: cgrEv.Event, + utils.MetaTenant: cgrEv.Tenant, + utils.MetaNow: time.Now(), + utils.MetaOpts: cgrEv.APIOpts, + } + // Parse action parameters based on the predefined format. + params := strings.Split(act.ExtraParameters, utils.InfieldSep) + if len(params) != 10 { + return errors.New(fmt.Sprintf("invalid number of parameters <%d> expected 10", len(params))) + } + // parse dynamic parameters + for i := range params { + if params[i], err = utils.ParseParamForDataProvider(params[i], dP, false); err != nil { + return err + } + } + // Prepare request arguments based on provided parameters. + ranking := &RankingProfileWithAPIOpts{ + RankingProfile: &RankingProfile{ + Tenant: params[0], + ID: params[1], + Schedule: params[2], + Sorting: params[5], + }, + APIOpts: make(map[string]any), + } + // populate Ranking's StatIDs + if params[3] != utils.EmptyString { + ranking.StatIDs = strings.Split(params[3], utils.ANDSep) + } + // populate Ranking's MetricIDs + if params[4] != utils.EmptyString { + ranking.MetricIDs = strings.Split(params[4], utils.ANDSep) + } + // populate Ranking's SortingParameters + if params[6] != utils.EmptyString { + ranking.SortingParameters = strings.Split(params[6], utils.ANDSep) + } + // populate Ranking's Stored + if params[7] != utils.EmptyString { + ranking.Stored, err = strconv.ParseBool(params[7]) + if err != nil { + return err + } + } + // populate Ranking's ThresholdIDs + if params[8] != utils.EmptyString { + ranking.ThresholdIDs = strings.Split(params[8], utils.ANDSep) + } + // populate Ranking's APIOpts + if params[9] != utils.EmptyString { + if err := parseParamStringToMap(params[9], ranking.APIOpts); err != nil { + return err + } + } + // create the RankingProfile based on the populated parameters + var reply string + return connMgr.Call(context.Background(), connCfg.ConnIDs, utils.APIerSv1SetRankingProfile, ranking, &reply) +} diff --git a/engine/actions_test.go b/engine/actions_test.go index b339a1177..174e4c736 100644 --- a/engine/actions_test.go +++ b/engine/actions_test.go @@ -4982,12 +4982,13 @@ func TestDynamicThreshold(t *testing.T) { Weight: 10, ActionIDs: []string{"ACT_LOG_WARNING"}, Async: true, + EeIDs: []string{"eeID1", "eeID2"}, }, APIOpts: map[string]any{ "key": "value", }, }, - extraParams: "cgrates.org;THD_ACNT_1001;*string:~*req.Account:1001;2014-07-29T15:00:00Z;1;1;1s;true;10;ACT_LOG_WARNING;true;key:value", + extraParams: "cgrates.org;THD_ACNT_1001;*string:~*req.Account:1001;2014-07-29T15:00:00Z;1;1;1s;true;10;ACT_LOG_WARNING;true;eeID1&eeID2;key:value", }, { name: "SuccessfulRequestWithDynamicPaths", @@ -5008,12 +5009,13 @@ func TestDynamicThreshold(t *testing.T) { Weight: 10, ActionIDs: []string{"ACT_LOG_WARNING"}, Async: true, + EeIDs: []string{"eeID1", "eeID2"}, }, APIOpts: map[string]any{ "key": "value", }, }, - extraParams: "*tenant;THD_ACNT_<~*req.Account>;*string:~*req.Account:<~*req.Account>;*now&3000-07-29T15:00:00Z;1;1;1s;true;10;ACT_LOG_WARNING;true;key:value", + extraParams: "*tenant;THD_ACNT_<~*req.Account>;*string:~*req.Account:<~*req.Account>;*now&3000-07-29T15:00:00Z;1;1;1s;true;10;ACT_LOG_WARNING;true;eeID1&eeID2;key:value", }, { name: "SuccessfulRequestEmptyFields", @@ -5031,69 +5033,70 @@ func TestDynamicThreshold(t *testing.T) { Weight: 0, ActionIDs: nil, Async: false, + EeIDs: nil, }, APIOpts: map[string]any{}, }, - extraParams: "cgrates.org;THD_ACNT_1001;;;;;;;;;;", + extraParams: "cgrates.org;THD_ACNT_1001;;;;;;;;;;;", }, { name: "MissingConns", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: "MANDATORY_IE_MISSING: [connIDs]", }, { name: "WrongNumberOfParams", extraParams: "tenant;;1;", - expectedErr: "invalid number of parameters <4> expected 12", + expectedErr: "invalid number of parameters <4> expected 13", }, { name: "ActivationIntervalLengthFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z&&;1;1;1s;false;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z&&;1;1;1s;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: utils.ErrUnsupportedFormat.Error(), }, { name: "ActivationIntervalBadStringFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;bad String;1;1;1s;false;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;bad String;1;1;1s;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: `parsing time "bad String" as "2006-01-02T15:04:05Z07:00": cannot parse "bad String" as "2006"`, }, { name: "ActivationIntervalBadStringFail2", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z&bad String;1;1;1s;false;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z&bad String;1;1;1s;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: `parsing time "bad String" as "2006-01-02T15:04:05Z07:00": cannot parse "bad String" as "2006"`, }, { name: "MaxHitsFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;BadString;1;1s;false;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;BadString;1;1s;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: `strconv.Atoi: parsing "BadString": invalid syntax`, }, { name: "MinHitsFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;BadString;1s;false;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;BadString;1s;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: `strconv.Atoi: parsing "BadString": invalid syntax`, }, { name: "MinSleepFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;BadString;false;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;BadString;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: `time: invalid duration "BadString"`, }, { name: "BlockerFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;BadString;10;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;BadString;10;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: `strconv.ParseBool: parsing "BadString": invalid syntax`, }, { name: "WeightFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;BadString;ACT_LOG_WARNING;true;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;BadString;ACT_LOG_WARNING;true;eeID1&eeID2;", expectedErr: `strconv.ParseFloat: parsing "BadString": invalid syntax`, }, { name: "AsyncFail", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;10;ACT_LOG_WARNING;BadString;", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;10;ACT_LOG_WARNING;BadString;eeID1&eeID2;", expectedErr: `strconv.ParseBool: parsing "BadString": invalid syntax`, }, { name: "InvalidOptsMap", - extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;10;ACT_LOG_WARNING;true;opt", + extraParams: "cgrates.org;THD_ACNT_1001;FLTR_ACNT_1001;2014-07-29T15:00:00Z;1;1;1s;false;10;ACT_LOG_WARNING;true;eeID1&eeID2;opt", expectedErr: "invalid key-value pair: opt", }, } @@ -6476,3 +6479,153 @@ func TestDynamicRoute(t *testing.T) { }) } } + +func TestDynamicRanking(t *testing.T) { + tempConn := connMgr + tmpDm := dm + tmpCache := Cache + defer func() { + config.SetCgrConfig(config.NewDefaultCGRConfig()) + SetConnManager(tempConn) + dm = tmpDm + Cache = tmpCache + }() + Cache.Clear(nil) + var rpwo *RankingProfileWithAPIOpts + ccMock := &ccMock{ + calls: map[string]func(ctx *context.Context, args any, reply any) error{ + utils.APIerSv1SetRankingProfile: func(ctx *context.Context, args, reply any) error { + var canCast bool + if rpwo, canCast = args.(*RankingProfileWithAPIOpts); !canCast { + return fmt.Errorf("couldnt cast") + } + return nil + }, + }, + } + connID := utils.ConcatenatedKey(utils.MetaInternal, utils.MetaApier) + clientconn := make(chan birpc.ClientConnector, 1) + clientconn <- ccMock + NewConnManager(config.NewDefaultCGRConfig(), map[string]chan birpc.ClientConnector{ + connID: clientconn, + }) + testcases := []struct { + name string + extraParams string + connIDs []string + expRpwo *RankingProfileWithAPIOpts + expectedErr string + }{ + { + name: "SuccessfulRequest", + connIDs: []string{connID}, + expRpwo: &RankingProfileWithAPIOpts{ + RankingProfile: &RankingProfile{ + Tenant: "cgrates.org", + ID: "RKNG_ACNT_1001", + Schedule: "@every 15m", + StatIDs: []string{"Stats2", "Stats3", "Stats4"}, + MetricIDs: []string{"Metric1", "Metric3"}, + Sorting: "*asc", + SortingParameters: []string{"metricA:true", "metricB:false"}, + Stored: true, + ThresholdIDs: []string{"THD1", "THD2"}, + }, + APIOpts: map[string]any{ + "key": "value", + }, + }, + extraParams: "cgrates.org;RKNG_ACNT_1001;@every 15m;Stats2&Stats3&Stats4;Metric1&Metric3;*asc;metricA:true&metricB:false;true;THD1&THD2;key:value", + }, + { + name: "SuccessfulRequestWithDynamicPaths", + connIDs: []string{connID}, + expRpwo: &RankingProfileWithAPIOpts{ + RankingProfile: &RankingProfile{ + Tenant: "cgrates.org", + ID: "RKNG_ACNT_1001", + Schedule: "@every 15m", + StatIDs: []string{"Stats2", "Stats3", "Stats4"}, + MetricIDs: []string{"Metric1", "Metric3"}, + Sorting: "*asc", + SortingParameters: []string{"metricA:true", "metricB:false"}, + Stored: true, + ThresholdIDs: []string{"THD1", "THD2"}, + }, + APIOpts: map[string]any{ + "key": "value", + }, + }, + extraParams: "*tenant;RKNG_ACNT_<~*req.Account>;@every 15m;Stats2&Stats3&Stats4;Metric1&Metric3;*asc;metricA:true&metricB:false;true;THD1&THD2;key:value", + }, + { + name: "SuccessfulRequestEmptyFields", + connIDs: []string{connID}, + expRpwo: &RankingProfileWithAPIOpts{ + RankingProfile: &RankingProfile{ + Tenant: "cgrates.org", + ID: "RKNG_ACNT_1001", + Schedule: "@every 15m", + StatIDs: nil, + MetricIDs: nil, + Sorting: "", + SortingParameters: nil, + Stored: false, + ThresholdIDs: nil, + }, + APIOpts: map[string]any{}, + }, + extraParams: "cgrates.org;RKNG_ACNT_1001;@every 15m;;;;;;;", + }, + { + name: "MissingConns", + extraParams: "cgrates.org;RKNG_ACNT_1001;@every 15m;Stats2&Stats3&Stats4;Metric1&Metric3;*asc;metricA:true&metricB:false;true;THD1&THD2;key:value", + expectedErr: "MANDATORY_IE_MISSING: [connIDs]", + }, + { + name: "WrongNumberOfParams", + extraParams: "tenant;RKNG;;", + expectedErr: "invalid number of parameters <4> expected 10", + }, + { + name: "RankingStoredFail", + extraParams: "cgrates.org;RKNG_ACNT_1001;@every 15m;Stats2&Stats3&Stats4;Metric1&Metric3;*asc;metricA:true&metricB:false;BadString;THD1&THD2;key:value", + expectedErr: `strconv.ParseBool: parsing "BadString": invalid syntax`, + }, + { + name: "InvalidOptsMap", + extraParams: "cgrates.org;RKNG_ACNT_1001;@every 15m;Stats2&Stats3&Stats4;Metric1&Metric3;*asc;metricA:true&metricB:false;true;THD1&THD2;opt", + expectedErr: "invalid key-value pair: opt", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + action := &Action{ExtraParameters: tc.extraParams} + ev := &utils.CGREvent{ + Tenant: "cgrates.org", + ID: "evID", + Time: &time.Time{}, + Event: map[string]any{ + utils.AccountField: "1001", + }, + } + t.Cleanup(func() { + rpwo = nil + }) + err := dynamicRanking(nil, action, nil, nil, ev, + SharedActionsData{}, ActionConnCfg{ + ConnIDs: tc.connIDs, + }) + if tc.expectedErr != "" { + if err == nil || err.Error() != tc.expectedErr { + t.Errorf("expected error <%v>, received <%v>", tc.expectedErr, err) + } + } else if err != nil { + t.Error(err) + } else if !reflect.DeepEqual(rpwo, tc.expRpwo) { + t.Errorf("Expected <%v>\nReceived\n<%v>", utils.ToJSON(tc.expRpwo), utils.ToJSON(rpwo)) + } + }) + } +} diff --git a/general_tests/dynamic_acc_sts_thld_it_test.go b/general_tests/dynamic_acc_sts_thld_it_test.go index 9c670518c..5b31c7f78 100644 --- a/general_tests/dynamic_acc_sts_thld_it_test.go +++ b/general_tests/dynamic_acc_sts_thld_it_test.go @@ -156,12 +156,12 @@ func TestDynamicAccountWithStatsAndThreshold(t *testing.T) { // dynamic threshold for already created dynamic accounts, needed so we can ignore matching thresholds the events (which dont come from stats) where an account has already been dynamicaly created by the initial threhold THD_DYNAMIC_STATS_AND_THRESHOLD_INIT. The threshold itself is only used for blocking Identifier: utils.MetaDynamicThreshold, // get tenant and accountID from event, threshold triggers when the event account matches the already dynamicaly created account. If it matches the filter, it will block other thresholds from matching with the event. Make sure dynamic thresholds weight is higher than the initiative threshold THD_DYNAMIC_STATS_AND_THRESHOLD_INIT - ExtraParameters: "*tenant;THD_BLOCKER_ACNT_<~*req.Account>;*string:~*opts.*accountID:<~*req.Account>;*now;-1;1;;true;3;;true;", + ExtraParameters: "*tenant;THD_BLOCKER_ACNT_<~*req.Account>;*string:~*opts.*accountID:<~*req.Account>;*now;-1;1;;true;3;;true;;", }, { Identifier: utils.MetaDynamicThreshold, // get tenant and accountID from event, threshold triggers when sum of statID hits 100, after triggers the action, the threshold will be disabled for 5 seconds, make sure dynamic thresholds weight is higher than the initiative threshold THD_DYNAMIC_STATS_AND_THRESHOLD_INIT and blocker threshold THD_BLOCKER_ACNT_<~*req.Account> - ExtraParameters: "*tenant;THD_ACNT_<~*req.Account>;*string:~*req.StatID:Stat_<~*req.Account>&*string:~*req.*sum#1:100;*now;-1;1;5s;true;4;ACT_BLOCK_ACC&ACT_DYN_ACT_PLAN_ACC_ENABLE;true;", + ExtraParameters: "*tenant;THD_ACNT_<~*req.Account>;*string:~*req.StatID:Stat_<~*req.Account>&*string:~*req.*sum#1:100;*now;-1;1;5s;true;4;ACT_BLOCK_ACC&ACT_DYN_ACT_PLAN_ACC_ENABLE;true;;", }, { Identifier: utils.MetaDynamicStats, diff --git a/general_tests/dynamic_thresholds_it_test.go b/general_tests/dynamic_thresholds_it_test.go index 274fb5f61..07afe3443 100644 --- a/general_tests/dynamic_thresholds_it_test.go +++ b/general_tests/dynamic_thresholds_it_test.go @@ -127,7 +127,7 @@ func testDynThdSetAction(t *testing.T) { ActionsId: "DYNAMIC_THRESHOLD_ACTION", Actions: []*utils.TPAction{{ Identifier: utils.MetaDynamicThreshold, - ExtraParameters: "cgrates.org;DYNAMICLY_THR_<~*req.ID>;*string:~*opts.*eventType:AccountUpdate;;1;;;true;10;;true;~*opts", + ExtraParameters: "cgrates.org;DYNAMICLY_THR_<~*req.ID>;*string:~*opts.*eventType:AccountUpdate;;1;;;true;10;;true;;~*opts", }}} if err := dynThdRpc.Call(context.Background(), utils.APIerSv2SetActions, act, &reply); err != nil { diff --git a/utils/consts.go b/utils/consts.go index d1d44dac7..f8d38e4fa 100644 --- a/utils/consts.go +++ b/utils/consts.go @@ -1214,6 +1214,7 @@ const ( MetaDynamicDestination = "*dynamic_destination" MetaDynamicFilter = "*dynamic_filter" MetaDynamicRoute = "*dynamic_route" + MetaDynamicRanking = "*dynamic_ranking" ActionID = "ActionID" ActionType = "ActionType" ActionValue = "ActionValue"