feat: upsert stats counters, daily buckets and ETS tops for admin metrics. Refs EventHub/EventHubBack#42 #43 #44 #45 #46
This commit is contained in:
+15
-6
@@ -282,12 +282,21 @@
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
-record(stats, {
|
||||
id :: binary(),
|
||||
metric :: atom(),
|
||||
entity_id :: binary(),
|
||||
value :: integer() | {integer(), integer()},
|
||||
timestamp :: calendar:datetime()
|
||||
%% Upsert-счётчики админ-статистики (абсолютные значения).
|
||||
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
|
||||
%% users_total-подобные totals не храним (table_info),
|
||||
%% размерности: {user_by_status, pending}, {event_by_type, single}, ...
|
||||
-record(stats_counter, {
|
||||
key :: term(),
|
||||
value :: integer(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Дневные бакеты: key = {Metric :: atom(), Date :: calendar:date()}.
|
||||
-record(stats_daily, {
|
||||
key :: {atom(), calendar:date()},
|
||||
value :: integer(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
-record(node_metric, {
|
||||
|
||||
+27
-11
@@ -163,6 +163,7 @@ unfreeze(Id, Reason) ->
|
||||
-spec count_calendars_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_calendars_by_date(From, To) ->
|
||||
core_stats:with_daily(calendars_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#calendar{_ = '_'}),
|
||||
Filtered = lists:filter(fun(C) -> C#calendar.created_at >= From andalso
|
||||
C#calendar.created_at =< To end, All),
|
||||
@@ -173,7 +174,8 @@ count_calendars_by_date(From, To) ->
|
||||
{Day, Cnt} -> lists:keyreplace(Day, 1, Acc, {Day, Cnt+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
|
||||
@@ -183,13 +185,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_calendars_by_type() -> [{atom(), non_neg_integer()}].
|
||||
count_calendars_by_type() ->
|
||||
core_stats:with_dim(calendar, type, fun() ->
|
||||
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
|
||||
lists:foldl(fun(#calendar{type = Type}, Acc) ->
|
||||
case lists:keyfind(Type, 1, Acc) of
|
||||
false -> [{Type, 1} | Acc];
|
||||
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
|
||||
end
|
||||
end, [], Calendars).
|
||||
end, [], Calendars)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт календарей по статусам (active, frozen, deleted).
|
||||
@@ -197,13 +201,15 @@ count_calendars_by_type() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_calendars_by_status() -> [{atom(), non_neg_integer()}].
|
||||
count_calendars_by_status() ->
|
||||
core_stats:with_dim(calendar, status, fun() ->
|
||||
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
|
||||
lists:foldl(fun(#calendar{status = Status}, Acc) ->
|
||||
case lists:keyfind(Status, 1, Acc) of
|
||||
false -> [{Status, 1} | Acc];
|
||||
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
|
||||
end
|
||||
end, [], Calendars).
|
||||
end, [], Calendars)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N календарей по количеству отзывов.
|
||||
@@ -211,11 +217,15 @@ count_calendars_by_status() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_top_calendars_by_reviews(N :: non_neg_integer()) -> [#calendar{}].
|
||||
get_top_calendars_by_reviews(N) ->
|
||||
case stats_tops:get_top_calendars_by_reviews(N) of
|
||||
{ok, Calendars} -> Calendars;
|
||||
{error, _} ->
|
||||
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
|
||||
Sorted = lists:reverse(lists:sort(
|
||||
fun(A, B) -> A#calendar.rating_count =< B#calendar.rating_count end,
|
||||
Calendars)),
|
||||
lists:sublist(Sorted, N).
|
||||
lists:sublist(Sorted, N)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N календарей по количеству позитивных отзывов (rating >= 4).
|
||||
@@ -223,15 +233,17 @@ get_top_calendars_by_reviews(N) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_top_calendars_by_positive_reviews(N :: non_neg_integer()) -> [#calendar{}].
|
||||
get_top_calendars_by_positive_reviews(N) ->
|
||||
top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end).
|
||||
case stats_tops:get_top_calendars_by_positive_reviews(N) of
|
||||
{ok, Calendars} -> Calendars;
|
||||
{error, _} -> top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N календарей по количеству негативных отзывов (rating <= 2).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_top_calendars_by_negative_reviews(N :: non_neg_integer()) -> [#calendar{}].
|
||||
get_top_calendars_by_negative_reviews(N) ->
|
||||
top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end).
|
||||
case stats_tops:get_top_calendars_by_negative_reviews(N) of
|
||||
{ok, Calendars} -> Calendars;
|
||||
{error, _} -> top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N календарей по среднему рейтингу.
|
||||
@@ -239,11 +251,15 @@ get_top_calendars_by_negative_reviews(N) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_top_calendars_by_rating(N :: non_neg_integer()) -> [#calendar{}].
|
||||
get_top_calendars_by_rating(N) ->
|
||||
case stats_tops:get_top_calendars_by_rating(N) of
|
||||
{ok, Calendars} -> Calendars;
|
||||
{error, _} ->
|
||||
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
|
||||
Sorted = lists:reverse(lists:sort(
|
||||
fun(A, B) -> A#calendar.rating_avg =< B#calendar.rating_avg end,
|
||||
Calendars)),
|
||||
lists:sublist(Sorted, N).
|
||||
lists:sublist(Sorted, N)
|
||||
end.
|
||||
|
||||
%% @private Вспомогательная функция для фильтрации отзывов и подсчёта по календарям.
|
||||
top_by_review_filter(N, FilterFun) ->
|
||||
|
||||
+14
-4
@@ -240,6 +240,7 @@ list_all() ->
|
||||
-spec count_events_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_events_by_date(From, To) ->
|
||||
core_stats:with_daily(events_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
|
||||
E#event.created_at =< To end, All),
|
||||
@@ -250,7 +251,8 @@ count_events_by_date(From, To) ->
|
||||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт событий по типам (single, recurring).
|
||||
@@ -258,13 +260,15 @@ count_events_by_date(From, To) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_events_by_type() -> [{atom(), non_neg_integer()}].
|
||||
count_events_by_type() ->
|
||||
core_stats:with_dim(event, type, fun() ->
|
||||
Events = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
lists:foldl(fun(#event{event_type = Type}, Acc) ->
|
||||
case lists:keyfind(Type, 1, Acc) of
|
||||
false -> [{Type, 1} | Acc];
|
||||
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
|
||||
end
|
||||
end, [], Events).
|
||||
end, [], Events)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт событий по статусам (active, cancelled, completed).
|
||||
@@ -272,13 +276,15 @@ count_events_by_type() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_events_by_status() -> [{atom(), non_neg_integer()}].
|
||||
count_events_by_status() ->
|
||||
core_stats:with_dim(event, status, fun() ->
|
||||
Events = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
lists:foldl(fun(#event{status = Status}, Acc) ->
|
||||
case lists:keyfind(Status, 1, Acc) of
|
||||
false -> [{Status, 1} | Acc];
|
||||
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
|
||||
end
|
||||
end, [], Events).
|
||||
end, [], Events)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Возвращает топ-N событий по среднему рейтингу (по убыванию).
|
||||
@@ -286,11 +292,15 @@ count_events_by_status() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_top_events_by_rating(N :: non_neg_integer()) -> [#event{}].
|
||||
get_top_events_by_rating(N) ->
|
||||
case stats_tops:get_top_events_by_rating(N) of
|
||||
{ok, Events} -> Events;
|
||||
{error, _} ->
|
||||
Events = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
Sorted = lists:reverse(lists:sort(
|
||||
fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end,
|
||||
Events)),
|
||||
lists:sublist(Sorted, N).
|
||||
lists:sublist(Sorted, N)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Выделить из datetime только дату `{Y,M,D}`.
|
||||
|
||||
+38
-15
@@ -129,8 +129,10 @@ count_reports() ->
|
||||
-spec count_reports_by_status(Status :: pending | reviewed | dismissed) ->
|
||||
non_neg_integer().
|
||||
count_reports_by_status(Status) ->
|
||||
core_stats:with_counter({report_by_status, Status}, fun() ->
|
||||
Match = #report{status = Status, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match)).
|
||||
length(mnesia:dirty_match_object(Match))
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Количество жалоб, разрешённых конкретным администратором.
|
||||
@@ -139,8 +141,10 @@ count_reports_by_status(Status) ->
|
||||
-spec count_reports_resolved_by_admin(AdminId :: binary(), Status :: reviewed | dismissed) ->
|
||||
non_neg_integer().
|
||||
count_reports_resolved_by_admin(AdminId, Status) ->
|
||||
Match = #report{resolved_by = AdminId, status = Status, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match)).
|
||||
Rows = index_read(report, AdminId, #report.resolved_by, fun() ->
|
||||
mnesia:dirty_match_object(#report{resolved_by = AdminId, _ = '_'})
|
||||
end),
|
||||
length([R || R <- Rows, R#report.status =:= Status]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Среднее время разрешения жалоб заданного статуса (в часах).
|
||||
@@ -148,16 +152,22 @@ count_reports_resolved_by_admin(AdminId, Status) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec avg_resolution_time(Status :: reviewed | dismissed) -> float().
|
||||
avg_resolution_time(Status) ->
|
||||
core_stats:with_avg_hours(
|
||||
{report_resolved_count, Status},
|
||||
{report_resolved_seconds_sum, Status},
|
||||
fun() ->
|
||||
Match = #report{status = Status, _ = '_'},
|
||||
Reports = mnesia:dirty_match_object(Match),
|
||||
case Reports of
|
||||
Resolved = [R || R <- Reports, R#report.resolved_at /= undefined],
|
||||
case Resolved of
|
||||
[] -> 0.0;
|
||||
_ ->
|
||||
Total = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) -
|
||||
calendar:datetime_to_gregorian_seconds(R#report.created_at)
|
||||
|| R <- Reports, R#report.resolved_at /= undefined]),
|
||||
Total / length(Reports) / 3600.0
|
||||
end.
|
||||
|| R <- Resolved]),
|
||||
Total / length(Resolved) / 3600.0
|
||||
end
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт жалоб, созданных в заданном временном диапазоне.
|
||||
@@ -166,6 +176,7 @@ avg_resolution_time(Status) ->
|
||||
-spec count_reports_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_reports_by_date(From, To) ->
|
||||
core_stats:with_daily(reports_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#report{_ = '_'}),
|
||||
Filtered = lists:filter(fun(R) -> R#report.created_at >= From andalso
|
||||
R#report.created_at =< To end, All),
|
||||
@@ -176,7 +187,8 @@ count_reports_by_date(From, To) ->
|
||||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт жалоб по типам цели (event, calendar, review).
|
||||
@@ -184,13 +196,15 @@ count_reports_by_date(From, To) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_reports_by_target_type() -> [{atom(), non_neg_integer()}].
|
||||
count_reports_by_target_type() ->
|
||||
core_stats:with_dim(report, target_type, fun() ->
|
||||
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
|
||||
lists:foldl(fun(#report{target_type = Type}, Acc) ->
|
||||
case lists:keyfind(Type, 1, Acc) of
|
||||
false -> [{Type, 1} | Acc];
|
||||
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
|
||||
end
|
||||
end, [], Reports).
|
||||
end, [], Reports)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт жалоб по статусам.
|
||||
@@ -198,13 +212,15 @@ count_reports_by_target_type() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_reports_by_status() -> [{atom(), non_neg_integer()}].
|
||||
count_reports_by_status() ->
|
||||
core_stats:with_dim(report, status, fun() ->
|
||||
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
|
||||
lists:foldl(fun(#report{status = Status}, Acc) ->
|
||||
case lists:keyfind(Status, 1, Acc) of
|
||||
false -> [{Status, 1} | Acc];
|
||||
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
|
||||
end
|
||||
end, [], Reports).
|
||||
end, [], Reports)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N целей по количеству жалоб.
|
||||
@@ -214,8 +230,10 @@ count_reports_by_status() ->
|
||||
-spec get_top_targets_by_reports(N :: non_neg_integer()) ->
|
||||
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
|
||||
get_top_targets_by_reports(N) ->
|
||||
case stats_tops:get_top_targets_by_reports(N) of
|
||||
{ok, List} -> List;
|
||||
{error, _} ->
|
||||
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
|
||||
% Группируем по {TargetType, TargetId}
|
||||
Dict = lists:foldl(fun(#report{target_type = Type, target_id = Id}, Acc) ->
|
||||
Key = {Type, Id},
|
||||
case lists:keyfind(Key, 1, Acc) of
|
||||
@@ -223,10 +241,15 @@ get_top_targets_by_reports(N) ->
|
||||
{Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1})
|
||||
end
|
||||
end, [], Reports),
|
||||
% Сортируем по убыванию Count
|
||||
Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)),
|
||||
% Преобразуем в тройки и берём первые N
|
||||
Top = lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N),
|
||||
Top.
|
||||
lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N)
|
||||
end.
|
||||
|
||||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
|
||||
%% @private Index read with fallback to full match (tests without index).
|
||||
index_read(Table, Key, Pos, FallbackFun) ->
|
||||
case catch mnesia:dirty_index_read(Table, Key, Pos) of
|
||||
{'EXIT', _} -> FallbackFun();
|
||||
Rows when is_list(Rows) -> Rows
|
||||
end.
|
||||
|
||||
+21
-14
@@ -193,6 +193,7 @@ list_all() ->
|
||||
-spec count_reviews_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_reviews_by_date(From, To) ->
|
||||
core_stats:with_daily(reviews_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#review{_ = '_'}),
|
||||
Filtered = lists:filter(fun(R) -> R#review.created_at >= From andalso
|
||||
R#review.created_at =< To end, All),
|
||||
@@ -203,7 +204,8 @@ count_reviews_by_date(From, To) ->
|
||||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
|
||||
@@ -213,13 +215,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_reviews_by_target_type() -> [{atom(), non_neg_integer()}].
|
||||
count_reviews_by_target_type() ->
|
||||
core_stats:with_dim(review, target_type, fun() ->
|
||||
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
|
||||
lists:foldl(fun(#review{target_type = Type}, Acc) ->
|
||||
case lists:keyfind(Type, 1, Acc) of
|
||||
false -> [{Type, 1} | Acc];
|
||||
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
|
||||
end
|
||||
end, [], Reviews).
|
||||
end, [], Reviews)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт отзывов по статусам (visible, hidden, deleted).
|
||||
@@ -227,13 +231,15 @@ count_reviews_by_target_type() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_reviews_by_status() -> [{atom(), non_neg_integer()}].
|
||||
count_reviews_by_status() ->
|
||||
core_stats:with_dim(review, status, fun() ->
|
||||
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
|
||||
lists:foldl(fun(#review{status = Status}, Acc) ->
|
||||
case lists:keyfind(Status, 1, Acc) of
|
||||
false -> [{Status, 1} | Acc];
|
||||
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
|
||||
end
|
||||
end, [], Reviews).
|
||||
end, [], Reviews)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N целей по общему количеству отзывов.
|
||||
@@ -242,25 +248,26 @@ count_reviews_by_status() ->
|
||||
-spec get_top_targets_by_reviews(N :: non_neg_integer()) ->
|
||||
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
|
||||
get_top_targets_by_reviews(N) ->
|
||||
aggregate_targets_by(fun(_Review) -> true end, N).
|
||||
case stats_tops:get_top_targets_by_reviews(N) of
|
||||
{ok, List} -> List;
|
||||
{error, _} -> aggregate_targets_by(fun(_Review) -> true end, N)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N целей по количеству позитивных отзывов (rating >= 4).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_top_targets_by_positive_reviews(N :: non_neg_integer()) ->
|
||||
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
|
||||
get_top_targets_by_positive_reviews(N) ->
|
||||
aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N).
|
||||
case stats_tops:get_top_targets_by_positive_reviews(N) of
|
||||
{ok, List} -> List;
|
||||
{error, _} -> aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Топ-N целей по количеству негативных отзывов (rating <= 2).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_top_targets_by_negative_reviews(N :: non_neg_integer()) ->
|
||||
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
|
||||
get_top_targets_by_negative_reviews(N) ->
|
||||
aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N).
|
||||
case stats_tops:get_top_targets_by_negative_reviews(N) of
|
||||
{ok, List} -> List;
|
||||
{error, _} -> aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N)
|
||||
end.
|
||||
|
||||
%% @private Вспомогательная функция агрегации целей с фильтром.
|
||||
aggregate_targets_by(Pred, N) ->
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Чтение upsert-агрегатов admin-статистики.
|
||||
%%% Live-значения в ETS коллектора; Mnesia — persistence.
|
||||
%%% Если collector не запущен — {error, not_running} (caller может fallback-скан).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_stats).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
available/0,
|
||||
get_counter/1,
|
||||
get_dim/2,
|
||||
get_daily/3,
|
||||
rebuild/0,
|
||||
with_counter/2,
|
||||
with_dim/3,
|
||||
with_daily/4,
|
||||
with_avg_hours/3
|
||||
]).
|
||||
|
||||
%%%===================================================================
|
||||
%%% API
|
||||
%%%===================================================================
|
||||
|
||||
-spec available() -> boolean().
|
||||
available() ->
|
||||
case whereis(stats_collector) of
|
||||
undefined -> false;
|
||||
_Pid -> stats_collector:is_ready()
|
||||
end.
|
||||
|
||||
-spec get_counter(term()) -> {ok, integer()} | {error, not_running | not_ready}.
|
||||
get_counter(Key) ->
|
||||
call_collector({get_counter, Key}).
|
||||
|
||||
%% @doc Размерность: get_dim(user, status) → [{pending, N}, {active, M}, ...].
|
||||
-spec get_dim(atom(), atom()) -> {ok, [{atom(), integer()}]} | {error, not_running | not_ready}.
|
||||
get_dim(Entity, Field) ->
|
||||
call_collector({get_dim, Entity, Field}).
|
||||
|
||||
%% @doc Дневной ряд Metric за [From, To] (по дате, включительно).
|
||||
-spec get_daily(atom(), calendar:datetime(), calendar:datetime()) ->
|
||||
{ok, [{calendar:date(), non_neg_integer()}]} | {error, not_running | not_ready}.
|
||||
get_daily(Metric, From, To) ->
|
||||
call_collector({get_daily, Metric, From, To}).
|
||||
|
||||
-spec rebuild() -> ok | {error, not_running}.
|
||||
rebuild() ->
|
||||
case whereis(stats_collector) of
|
||||
undefined -> {error, not_running};
|
||||
_Pid -> stats_collector:rebuild()
|
||||
end.
|
||||
|
||||
%% @doc Счётчик или FallbackFun/0 при недоступном коллекторе.
|
||||
-spec with_counter(term(), fun(() -> integer())) -> integer().
|
||||
with_counter(Key, FallbackFun) ->
|
||||
case get_counter(Key) of
|
||||
{ok, V} -> V;
|
||||
{error, _} -> FallbackFun()
|
||||
end.
|
||||
|
||||
-spec with_dim(atom(), atom(), fun(() -> [{atom(), integer()}])) -> [{atom(), integer()}].
|
||||
with_dim(Entity, Field, FallbackFun) ->
|
||||
case get_dim(Entity, Field) of
|
||||
{ok, List} -> List;
|
||||
{error, _} -> FallbackFun()
|
||||
end.
|
||||
|
||||
-spec with_daily(atom(), calendar:datetime(), calendar:datetime(),
|
||||
fun(() -> [{calendar:date(), non_neg_integer()}])) ->
|
||||
[{calendar:date(), non_neg_integer()}].
|
||||
with_daily(Metric, From, To, FallbackFun) ->
|
||||
case get_daily(Metric, From, To) of
|
||||
{ok, List} -> List;
|
||||
{error, _} -> FallbackFun()
|
||||
end.
|
||||
|
||||
%% @doc Avg hours from count+seconds_sum counters, or FallbackFun/0.
|
||||
-spec with_avg_hours(term(), term(), fun(() -> float())) -> float().
|
||||
with_avg_hours(CountKey, SumKey, FallbackFun) ->
|
||||
case {get_counter(CountKey), get_counter(SumKey)} of
|
||||
{{ok, Count}, {ok, Sum}} when Count > 0 ->
|
||||
(Sum / Count) / 3600.0;
|
||||
{{ok, _}, {ok, _}} ->
|
||||
0.0;
|
||||
_ ->
|
||||
FallbackFun()
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
call_collector(Msg) ->
|
||||
case whereis(stats_collector) of
|
||||
undefined ->
|
||||
{error, not_running};
|
||||
_Pid ->
|
||||
try stats_collector:call(Msg) of
|
||||
{error, not_ready} -> {error, not_ready};
|
||||
Other -> Other
|
||||
catch
|
||||
exit:{noproc, _} -> {error, not_running};
|
||||
exit:{timeout, _} -> {error, not_running}
|
||||
end
|
||||
end.
|
||||
@@ -310,6 +310,7 @@ count_subscriptions() ->
|
||||
-spec count_subscriptions_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_subscriptions_by_date(From, To) ->
|
||||
core_stats:with_daily(subscriptions_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#subscription{_ = '_'}),
|
||||
Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso
|
||||
S#subscription.created_at =< To end, All),
|
||||
@@ -320,7 +321,8 @@ count_subscriptions_by_date(From, To) ->
|
||||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
|
||||
@@ -330,13 +332,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_subscriptions_by_plan() -> [{atom(), non_neg_integer()}].
|
||||
count_subscriptions_by_plan() ->
|
||||
core_stats:with_dim(subscription, plan, fun() ->
|
||||
Subs = mnesia:dirty_match_object(#subscription{_ = '_'}),
|
||||
lists:foldl(fun(#subscription{plan = Plan}, Acc) ->
|
||||
case lists:keyfind(Plan, 1, Acc) of
|
||||
false -> [{Plan, 1} | Acc];
|
||||
{Plan, C} -> lists:keyreplace(Plan, 1, Acc, {Plan, C+1})
|
||||
end
|
||||
end, [], Subs).
|
||||
end, [], Subs)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт подписок по статусам.
|
||||
@@ -344,13 +348,15 @@ count_subscriptions_by_plan() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_subscriptions_by_status() -> [{atom(), non_neg_integer()}].
|
||||
count_subscriptions_by_status() ->
|
||||
core_stats:with_dim(subscription, status, fun() ->
|
||||
Subs = mnesia:dirty_match_object(#subscription{_ = '_'}),
|
||||
lists:foldl(fun(#subscription{status = Status}, Acc) ->
|
||||
case lists:keyfind(Status, 1, Acc) of
|
||||
false -> [{Status, 1} | Acc];
|
||||
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
|
||||
end
|
||||
end, [], Subs).
|
||||
end, [], Subs)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Количество пробных подписок (trial_used = false).
|
||||
@@ -358,8 +364,10 @@ count_subscriptions_by_status() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_trial_subscriptions() -> non_neg_integer().
|
||||
count_trial_subscriptions() ->
|
||||
core_stats:with_counter(trial_subscriptions, fun() ->
|
||||
Match = #subscription{trial_used = false, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match)).
|
||||
length(mnesia:dirty_match_object(Match))
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней.
|
||||
|
||||
+28
-27
@@ -218,8 +218,10 @@ count_tickets() ->
|
||||
-spec count_tickets_by_status(Status :: open | in_progress | resolved | closed) ->
|
||||
non_neg_integer().
|
||||
count_tickets_by_status(Status) ->
|
||||
core_stats:with_counter({ticket_by_status, Status}, fun() ->
|
||||
Match = #ticket{status = Status, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match)).
|
||||
length(mnesia:dirty_match_object(Match))
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Среднее время разрешения тикетов (в часах).
|
||||
@@ -227,6 +229,7 @@ count_tickets_by_status(Status) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec avg_resolution_time() -> float().
|
||||
avg_resolution_time() ->
|
||||
core_stats:with_avg_hours(ticket_resolved_count, ticket_resolved_seconds_sum, fun() ->
|
||||
Tickets = list_all(),
|
||||
Closed = [T || T <- Tickets, T#ticket.closed_at /= ?DEFAULT_CLOSED_AT],
|
||||
case Closed of
|
||||
@@ -236,25 +239,19 @@ avg_resolution_time() ->
|
||||
calendar:datetime_to_gregorian_seconds(T#ticket.first_seen)
|
||||
|| T <- Closed]),
|
||||
Total / length(Closed) / 3600.0
|
||||
end.
|
||||
end
|
||||
end).
|
||||
|
||||
%% @doc Статистика по тикетам (используется в admin_handler_ticket_stats)
|
||||
stats() ->
|
||||
Tickets = list_all(),
|
||||
#{
|
||||
total => length(Tickets),
|
||||
open => count_by_status(open, Tickets),
|
||||
in_progress => count_by_status(in_progress, Tickets),
|
||||
resolved => count_by_status(resolved, Tickets),
|
||||
closed => count_by_status(closed, Tickets)
|
||||
total => count_tickets(),
|
||||
open => count_tickets_by_status(open),
|
||||
in_progress => count_tickets_by_status(in_progress),
|
||||
resolved => count_tickets_by_status(resolved),
|
||||
closed => count_tickets_by_status(closed)
|
||||
}.
|
||||
|
||||
%% ── функции подсчёта с нормализацией статуса ─────────────
|
||||
|
||||
%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный)
|
||||
count_by_status(Status, Tickets) ->
|
||||
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Количество тикетов, назначенных на администратора.
|
||||
%%% @end
|
||||
@@ -262,11 +259,14 @@ count_by_status(Status, Tickets) ->
|
||||
-spec count_tickets_by_admin(AdminId :: binary(), StatusOrAll :: atom()) ->
|
||||
non_neg_integer().
|
||||
count_tickets_by_admin(AdminId, all) ->
|
||||
Match = #ticket{assigned_to = AdminId, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match));
|
||||
length(index_read(ticket, AdminId, #ticket.assigned_to, fun() ->
|
||||
mnesia:dirty_match_object(#ticket{assigned_to = AdminId, _ = '_'})
|
||||
end));
|
||||
count_tickets_by_admin(AdminId, Status) ->
|
||||
Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match)).
|
||||
Rows = index_read(ticket, AdminId, #ticket.assigned_to, fun() ->
|
||||
mnesia:dirty_match_object(#ticket{assigned_to = AdminId, _ = '_'})
|
||||
end),
|
||||
length([T || T <- Rows, T#ticket.status =:= Status]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт тикетов, созданных в заданном временном диапазоне.
|
||||
@@ -275,6 +275,7 @@ count_tickets_by_admin(AdminId, Status) ->
|
||||
-spec count_tickets_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_tickets_by_date(From, To) ->
|
||||
core_stats:with_daily(tickets_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#ticket{_ = '_'}),
|
||||
Filtered = lists:filter(fun(T) -> T#ticket.first_seen >= From andalso
|
||||
T#ticket.first_seen =< To end, All),
|
||||
@@ -285,22 +286,22 @@ count_tickets_by_date(From, To) ->
|
||||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
|
||||
%% @private Index read with fallback to full match (tests without index).
|
||||
index_read(Table, Key, Pos, FallbackFun) ->
|
||||
case catch mnesia:dirty_index_read(Table, Key, Pos) of
|
||||
{'EXIT', _} -> FallbackFun();
|
||||
Rows when is_list(Rows) -> Rows
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% ВНУТРЕННИЕ ФУНКЦИИ
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Преобразует бинарный статус в атом, если нужно.
|
||||
%% Атомы возвращает без изменений.
|
||||
normalize_status(Status) when is_atom(Status) -> Status;
|
||||
normalize_status(Status) when is_binary(Status) ->
|
||||
try binary_to_existing_atom(Status, utf8)
|
||||
catch error:badarg -> Status
|
||||
end.
|
||||
|
||||
apply_updates(Ticket, Updates) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end,
|
||||
Ticket, Updates),
|
||||
|
||||
+17
-9
@@ -266,6 +266,7 @@ list_all() ->
|
||||
-spec count_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_users_by_date(From, To) ->
|
||||
core_stats:with_daily(users_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#user{_ = '_'}),
|
||||
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
|
||||
U#user.created_at =< To end, All),
|
||||
@@ -276,7 +277,8 @@ count_users_by_date(From, To) ->
|
||||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Количество неподтверждённых пользователей (status = pending).
|
||||
@@ -284,8 +286,10 @@ count_users_by_date(From, To) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_pending_users() -> non_neg_integer().
|
||||
count_pending_users() ->
|
||||
core_stats:with_counter({user_by_status, pending}, fun() ->
|
||||
Match = #user{status = pending, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match)).
|
||||
length(mnesia:dirty_match_object(Match))
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт неподтверждённых пользователей (status=pending) по дням.
|
||||
@@ -294,6 +298,7 @@ count_pending_users() ->
|
||||
-spec count_pending_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_pending_users_by_date(From, To) ->
|
||||
core_stats:with_daily(pending_users_created, From, To, fun() ->
|
||||
All = mnesia:dirty_match_object(#user{_ = '_'}),
|
||||
Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All),
|
||||
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
|
||||
@@ -305,7 +310,8 @@ count_pending_users_by_date(From, To) ->
|
||||
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
|
||||
end
|
||||
end, [], Filtered),
|
||||
lists:sort(Counts).
|
||||
lists:sort(Counts)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт пользователей по ролям.
|
||||
@@ -314,14 +320,15 @@ count_pending_users_by_date(From, To) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_users_by_role() -> [{atom(), non_neg_integer()}].
|
||||
count_users_by_role() ->
|
||||
core_stats:with_dim(user, role, fun() ->
|
||||
Users = mnesia:dirty_match_object(#user{_ = '_'}),
|
||||
Dict = lists:foldl(fun(#user{role = Role}, Acc) ->
|
||||
lists:foldl(fun(#user{role = Role}, Acc) ->
|
||||
case lists:keyfind(Role, 1, Acc) of
|
||||
false -> [{Role, 1} | Acc];
|
||||
{Role, C} -> lists:keyreplace(Role, 1, Acc, {Role, C+1})
|
||||
end
|
||||
end, [], Users),
|
||||
Dict.
|
||||
end, [], Users)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт пользователей по статусам.
|
||||
@@ -330,14 +337,15 @@ count_users_by_role() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_users_by_status() -> [{atom(), non_neg_integer()}].
|
||||
count_users_by_status() ->
|
||||
core_stats:with_dim(user, status, fun() ->
|
||||
Users = mnesia:dirty_match_object(#user{_ = '_'}),
|
||||
Dict = lists:foldl(fun(#user{status = Status}, Acc) ->
|
||||
lists:foldl(fun(#user{status = Status}, Acc) ->
|
||||
case lists:keyfind(Status, 1, Acc) of
|
||||
false -> [{Status, 1} | Acc];
|
||||
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
|
||||
end
|
||||
end, [], Users),
|
||||
Dict.
|
||||
end, [], Users)
|
||||
end).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Выделить из datetime только дату `{Y,M,D}`.
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
review, report, banned_word, automod_settings, automod_hit,
|
||||
ticket, subscription,
|
||||
admin_audit, notification,
|
||||
stats, node_metric, schema_migration
|
||||
stats_counter, stats_daily, node_metric, schema_migration
|
||||
]).
|
||||
|
||||
-define(DISC_TABLES, ?TABLES -- [session, verification, admin_session, node_metric]).
|
||||
@@ -303,7 +303,8 @@ table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
||||
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
|
||||
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
||||
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
||||
table_opts(stats) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats)}];
|
||||
table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
||||
table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
|
||||
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||
@@ -340,4 +341,6 @@ create_indices() ->
|
||||
mnesia:add_table_index(notification, is_read),
|
||||
mnesia:add_table_index(auth_session, family_id),
|
||||
mnesia:add_table_index(auth_session, subject_id),
|
||||
mnesia:add_table_index(report, resolved_by),
|
||||
mnesia:add_table_index(ticket, assigned_to),
|
||||
ok.
|
||||
@@ -21,7 +21,9 @@
|
||||
-define(ALL_MIGRATIONS, [
|
||||
'20260501120000_base_schema',
|
||||
'20260504150000_test_migration',
|
||||
'20260716230000_ticket_source_and_hash_index'
|
||||
'20260716230000_ticket_source_and_hash_index',
|
||||
'20260717180000_stats_counters',
|
||||
'20260717190000_admin_stats_indexes'
|
||||
]).
|
||||
|
||||
%% ------------------------------
|
||||
|
||||
+542
-106
@@ -1,133 +1,569 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Сбор admin-статистики через Mnesia detailed-subscribe.
|
||||
%%% ETS держит абсолютные upsert-счётчики и дневные бакеты;
|
||||
%%% периодический flush пишет в stats_counter / stats_daily.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(stats_collector).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([start_link/0, get_stats/0, subscribe/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||
-export([start_link/0, subscribe/0, rebuild/0, is_ready/0, call/1,
|
||||
get_stats/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||
terminate/2, code_change/3]).
|
||||
|
||||
-define(FLUSH_INTERVAL, 300000).
|
||||
-define(FLUSH_INTERVAL, 300000). %% 5 min
|
||||
-define(CLEANUP_INTERVAL, 3600000). %% 1 h
|
||||
-define(DAILY_RETENTION_DAYS, 730).
|
||||
-define(COUNTER_ETS, stats_counter_ets).
|
||||
-define(DAILY_ETS, stats_daily_ets).
|
||||
-define(SUB_TABLES, [
|
||||
user, event, calendar, booking, review, report, ticket, subscription
|
||||
]).
|
||||
|
||||
start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
get_stats() -> gen_server:call(?MODULE, get_stats).
|
||||
subscribe() -> gen_server:call(?MODULE, subscribe).
|
||||
-record(state, {
|
||||
ready = false :: boolean(),
|
||||
subscribed = false :: boolean()
|
||||
}).
|
||||
|
||||
%%%===================================================================
|
||||
%%% API
|
||||
%%%===================================================================
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
subscribe() ->
|
||||
gen_server:call(?MODULE, subscribe, infinity).
|
||||
|
||||
rebuild() ->
|
||||
gen_server:call(?MODULE, rebuild, infinity).
|
||||
|
||||
is_ready() ->
|
||||
gen_server:call(?MODULE, is_ready).
|
||||
|
||||
call(Msg) ->
|
||||
gen_server:call(?MODULE, Msg).
|
||||
|
||||
%% @doc Совместимость: снимок counter ETS.
|
||||
get_stats() ->
|
||||
gen_server:call(?MODULE, get_stats).
|
||||
|
||||
%%%===================================================================
|
||||
%%% gen_server
|
||||
%%%===================================================================
|
||||
|
||||
init([]) ->
|
||||
ets:new(stats_ets, [set, public, named_table, {keypos, 1}]),
|
||||
ets:new(?COUNTER_ETS, [named_table, public, set, {keypos, 1},
|
||||
{write_concurrency, true}]),
|
||||
ets:new(?DAILY_ETS, [named_table, public, set, {keypos, 1},
|
||||
{write_concurrency, true}]),
|
||||
stats_tops:init_tables(),
|
||||
erlang:send_after(?FLUSH_INTERVAL, self(), flush),
|
||||
{ok, #{}}.
|
||||
erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_daily),
|
||||
{ok, #state{}}.
|
||||
|
||||
handle_call(subscribe, _From, State) ->
|
||||
mnesia:subscribe({table, event, simple}),
|
||||
mnesia:subscribe({table, booking, simple}),
|
||||
mnesia:subscribe({table, review, simple}),
|
||||
{reply, ok, State};
|
||||
NewState = do_subscribe(State),
|
||||
{reply, ok, NewState};
|
||||
handle_call(rebuild, _From, State) ->
|
||||
do_rebuild(),
|
||||
{reply, ok, State#state{ready = true}};
|
||||
handle_call(is_ready, _From, State) ->
|
||||
{reply, State#state.ready, State};
|
||||
handle_call(get_stats, _From, State) ->
|
||||
{reply, ets:tab2list(stats_ets), State};
|
||||
{reply, ets:tab2list(?COUNTER_ETS), State};
|
||||
handle_call({get_counter, _Key}, _From, #state{ready = false} = State) ->
|
||||
{reply, {error, not_ready}, State};
|
||||
handle_call({get_counter, Key}, _From, State) ->
|
||||
{reply, {ok, ets_get(?COUNTER_ETS, Key)}, State};
|
||||
handle_call({get_dim, _E, _F}, _From, #state{ready = false} = State) ->
|
||||
{reply, {error, not_ready}, State};
|
||||
handle_call({get_dim, Entity, Field}, _From, State) ->
|
||||
{reply, {ok, read_dim(Entity, Field)}, State};
|
||||
handle_call({get_daily, _M, _F, _T}, _From, #state{ready = false} = State) ->
|
||||
{reply, {error, not_ready}, State};
|
||||
handle_call({get_daily, Metric, From, To}, _From, State) ->
|
||||
{reply, {ok, read_daily(Metric, From, To)}, State};
|
||||
handle_call(_Msg, _From, State) ->
|
||||
{reply, {error, unknown}, State}.
|
||||
|
||||
handle_cast(_Msg, State) -> {noreply, State}.
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(flush, State) ->
|
||||
flush_stats(),
|
||||
flush_to_mnesia(),
|
||||
erlang:send_after(?FLUSH_INTERVAL, self(), flush),
|
||||
{noreply, State};
|
||||
handle_info({mnesia_table_event, {write, Record, _ActivityId}}, State) ->
|
||||
Table = element(1, Record),
|
||||
process_write(Table, Record, []),
|
||||
handle_info(cleanup_daily, State) ->
|
||||
cleanup_old_daily(?DAILY_RETENTION_DAYS),
|
||||
erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_daily),
|
||||
{noreply, State};
|
||||
handle_info({write, Table, New, Old, _ActivityId}, State) ->
|
||||
process_write(Table, New, Old),
|
||||
handle_info({mnesia_table_event, Event}, State) ->
|
||||
handle_mnesia_event(Event),
|
||||
{noreply, State};
|
||||
handle_info(_Info, State) -> {noreply, State}.
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, _State) -> ok.
|
||||
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
||||
terminate(_Reason, _State) ->
|
||||
flush_to_mnesia(),
|
||||
ok.
|
||||
|
||||
ensure_counter(Key) ->
|
||||
case ets:member(stats_ets, Key) of
|
||||
false -> ets:insert(stats_ets, {Key, 0});
|
||||
true -> ok
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Bootstrap
|
||||
%%%===================================================================
|
||||
|
||||
do_subscribe(#state{subscribed = true} = State) ->
|
||||
State;
|
||||
do_subscribe(State) ->
|
||||
load_from_mnesia(),
|
||||
case ets:info(?COUNTER_ETS, size) =:= 0 andalso ets:info(?DAILY_ETS, size) =:= 0 of
|
||||
true -> do_rebuild();
|
||||
false -> stats_tops:rebuild()
|
||||
end,
|
||||
lists:foreach(fun(Table) ->
|
||||
mnesia:subscribe({table, Table, detailed})
|
||||
end, ?SUB_TABLES),
|
||||
State#state{ready = true, subscribed = true}.
|
||||
|
||||
do_rebuild() ->
|
||||
ets:delete_all_objects(?COUNTER_ETS),
|
||||
ets:delete_all_objects(?DAILY_ETS),
|
||||
backfill_all(),
|
||||
stats_tops:rebuild(),
|
||||
flush_to_mnesia(),
|
||||
ok.
|
||||
|
||||
load_from_mnesia() ->
|
||||
case lists:member(stats_counter, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
lists:foreach(fun(#stats_counter{key = K, value = V}) ->
|
||||
ets:insert(?COUNTER_ETS, {K, V})
|
||||
end, mnesia:dirty_match_object(#stats_counter{_ = '_'}));
|
||||
false -> ok
|
||||
end,
|
||||
case lists:member(stats_daily, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
lists:foreach(fun(#stats_daily{key = K, value = V}) ->
|
||||
ets:insert(?DAILY_ETS, {K, V})
|
||||
end, mnesia:dirty_match_object(#stats_daily{_ = '_'}));
|
||||
false -> ok
|
||||
end,
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Mnesia events
|
||||
%%%===================================================================
|
||||
|
||||
handle_mnesia_event({write, Table, New, Olds, _ActivityId}) ->
|
||||
Old = case Olds of
|
||||
[O | _] -> O;
|
||||
[] -> undefined
|
||||
end,
|
||||
process_write(Table, New, Old);
|
||||
handle_mnesia_event({delete, _Table, _Key, Olds, _ActivityId}) ->
|
||||
lists:foreach(fun(Old) -> process_delete(element(1, Old), Old) end, Olds);
|
||||
handle_mnesia_event({delete_object, Table, Old, _Olds, _ActivityId}) ->
|
||||
process_delete(Table, Old);
|
||||
handle_mnesia_event(_) ->
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Write / delete handlers
|
||||
%%%===================================================================
|
||||
|
||||
process_write(user, New, undefined) ->
|
||||
#user{status = St, role = Role, created_at = Created} = New,
|
||||
add_dim(user, status, St, 1),
|
||||
add_dim(user, role, Role, 1),
|
||||
add_daily(users_created, date_of(Created), 1),
|
||||
case St of
|
||||
pending -> add_daily(pending_users_created, date_of(Created), 1);
|
||||
_ -> ok
|
||||
end;
|
||||
process_write(user, New, Old) ->
|
||||
#user{status = StNew, role = RoleNew, created_at = Created} = New,
|
||||
#user{status = StOld, role = RoleOld} = Old,
|
||||
move_dim(user, status, StOld, StNew),
|
||||
move_dim(user, role, RoleOld, RoleNew),
|
||||
case {StOld, StNew} of
|
||||
{pending, pending} -> ok;
|
||||
{pending, _} -> add_daily(pending_users_created, date_of(Created), -1);
|
||||
{_, pending} -> add_daily(pending_users_created, date_of(Created), 1);
|
||||
_ -> ok
|
||||
end;
|
||||
|
||||
process_write(event, New, undefined) ->
|
||||
#event{status = St, event_type = Type, created_at = Created} = New,
|
||||
add_dim(event, status, St, 1),
|
||||
add_dim(event, type, Type, 1),
|
||||
add_daily(events_created, date_of(Created), 1),
|
||||
stats_tops:on_event(New, undefined);
|
||||
process_write(event, New, Old) ->
|
||||
#event{status = StNew, event_type = TypeNew} = New,
|
||||
#event{status = StOld, event_type = TypeOld} = Old,
|
||||
move_dim(event, status, StOld, StNew),
|
||||
move_dim(event, type, TypeOld, TypeNew),
|
||||
stats_tops:on_event(New, Old);
|
||||
|
||||
process_write(calendar, New, undefined) ->
|
||||
#calendar{status = St, type = Type, created_at = Created} = New,
|
||||
add_dim(calendar, status, St, 1),
|
||||
add_dim(calendar, type, Type, 1),
|
||||
add_daily(calendars_created, date_of(Created), 1),
|
||||
stats_tops:on_calendar(New, undefined);
|
||||
process_write(calendar, New, Old) ->
|
||||
#calendar{status = StNew, type = TypeNew} = New,
|
||||
#calendar{status = StOld, type = TypeOld} = Old,
|
||||
move_dim(calendar, status, StOld, StNew),
|
||||
move_dim(calendar, type, TypeOld, TypeNew),
|
||||
stats_tops:on_calendar(New, Old);
|
||||
|
||||
process_write(booking, New, undefined) ->
|
||||
#booking{status = St, created_at = Created} = New,
|
||||
add_dim(booking, status, St, 1),
|
||||
add_daily(bookings_created, date_of(Created), 1);
|
||||
process_write(booking, New, Old) ->
|
||||
move_dim(booking, status, Old#booking.status, New#booking.status);
|
||||
|
||||
process_write(review, New, undefined) ->
|
||||
#review{status = St, target_type = TT, created_at = Created} = New,
|
||||
add_dim(review, status, St, 1),
|
||||
add_dim(review, target_type, TT, 1),
|
||||
add_daily(reviews_created, date_of(Created), 1),
|
||||
stats_tops:on_review(New, undefined);
|
||||
process_write(review, New, Old) ->
|
||||
#review{status = StNew, target_type = TTNew} = New,
|
||||
#review{status = StOld, target_type = TTOld} = Old,
|
||||
move_dim(review, status, StOld, StNew),
|
||||
move_dim(review, target_type, TTOld, TTNew),
|
||||
stats_tops:on_review(New, Old);
|
||||
|
||||
process_write(report, New, undefined) ->
|
||||
#report{status = St, target_type = TT, created_at = Created} = New,
|
||||
add_dim(report, status, St, 1),
|
||||
add_dim(report, target_type, TT, 1),
|
||||
add_daily(reports_created, date_of(Created), 1),
|
||||
apply_report_resolution(report_resolution(New)),
|
||||
stats_tops:on_report(New, undefined);
|
||||
process_write(report, New, Old) ->
|
||||
#report{status = StNew, target_type = TTNew} = New,
|
||||
#report{status = StOld, target_type = TTOld} = Old,
|
||||
move_dim(report, status, StOld, StNew),
|
||||
move_dim(report, target_type, TTOld, TTNew),
|
||||
move_resolution(report_resolution(Old), report_resolution(New)),
|
||||
stats_tops:on_report(New, Old);
|
||||
|
||||
process_write(ticket, New, undefined) ->
|
||||
#ticket{status = St, count = Count, first_seen = First} = New,
|
||||
add_dim(ticket, status, St, 1),
|
||||
add_counter(ticket_errors_sum, Count),
|
||||
add_daily(tickets_created, date_of(First), 1),
|
||||
apply_ticket_resolution(ticket_resolution(New));
|
||||
process_write(ticket, New, Old) ->
|
||||
#ticket{status = StNew, count = CountNew} = New,
|
||||
#ticket{status = StOld, count = CountOld} = Old,
|
||||
move_dim(ticket, status, StOld, StNew),
|
||||
add_counter(ticket_errors_sum, CountNew - CountOld),
|
||||
move_resolution(ticket_resolution(Old), ticket_resolution(New));
|
||||
|
||||
process_write(subscription, New, undefined) ->
|
||||
#subscription{status = St, plan = Plan, trial_used = Trial,
|
||||
created_at = Created} = New,
|
||||
add_dim(subscription, status, St, 1),
|
||||
add_dim(subscription, plan, Plan, 1),
|
||||
case Trial of
|
||||
false -> add_counter(trial_subscriptions, 1);
|
||||
_ -> ok
|
||||
end,
|
||||
add_daily(subscriptions_created, date_of(Created), 1);
|
||||
process_write(subscription, New, Old) ->
|
||||
#subscription{status = StNew, plan = PlanNew, trial_used = TrialNew} = New,
|
||||
#subscription{status = StOld, plan = PlanOld, trial_used = TrialOld} = Old,
|
||||
move_dim(subscription, status, StOld, StNew),
|
||||
move_dim(subscription, plan, PlanOld, PlanNew),
|
||||
case {TrialOld, TrialNew} of
|
||||
{false, false} -> ok;
|
||||
{false, _} -> add_counter(trial_subscriptions, -1);
|
||||
{_, false} -> add_counter(trial_subscriptions, 1);
|
||||
_ -> ok
|
||||
end;
|
||||
|
||||
process_write(_, _, _) ->
|
||||
ok.
|
||||
|
||||
process_delete(user, #user{status = St, role = Role, created_at = Created}) ->
|
||||
add_dim(user, status, St, -1),
|
||||
add_dim(user, role, Role, -1),
|
||||
add_daily(users_created, date_of(Created), -1),
|
||||
case St of
|
||||
pending -> add_daily(pending_users_created, date_of(Created), -1);
|
||||
_ -> ok
|
||||
end;
|
||||
process_delete(event, #event{status = St, event_type = Type, created_at = Created} = E) ->
|
||||
add_dim(event, status, St, -1),
|
||||
add_dim(event, type, Type, -1),
|
||||
add_daily(events_created, date_of(Created), -1),
|
||||
stats_tops:remove_event(E);
|
||||
process_delete(calendar, #calendar{status = St, type = Type, created_at = Created} = C) ->
|
||||
add_dim(calendar, status, St, -1),
|
||||
add_dim(calendar, type, Type, -1),
|
||||
add_daily(calendars_created, date_of(Created), -1),
|
||||
stats_tops:remove_calendar(C);
|
||||
process_delete(booking, #booking{status = St, created_at = Created}) ->
|
||||
add_dim(booking, status, St, -1),
|
||||
add_daily(bookings_created, date_of(Created), -1);
|
||||
process_delete(review, #review{status = St, target_type = TT, created_at = Created} = R) ->
|
||||
add_dim(review, status, St, -1),
|
||||
add_dim(review, target_type, TT, -1),
|
||||
add_daily(reviews_created, date_of(Created), -1),
|
||||
stats_tops:remove_review(R);
|
||||
process_delete(report, #report{status = St, target_type = TT, created_at = Created} = R) ->
|
||||
add_dim(report, status, St, -1),
|
||||
add_dim(report, target_type, TT, -1),
|
||||
add_daily(reports_created, date_of(Created), -1),
|
||||
remove_report_resolution(report_resolution(R)),
|
||||
stats_tops:remove_report(R);
|
||||
process_delete(ticket, #ticket{status = St, count = Count, first_seen = First} = T) ->
|
||||
add_dim(ticket, status, St, -1),
|
||||
add_counter(ticket_errors_sum, -Count),
|
||||
add_daily(tickets_created, date_of(First), -1),
|
||||
remove_ticket_resolution(ticket_resolution(T));
|
||||
process_delete(subscription, #subscription{status = St, plan = Plan,
|
||||
trial_used = Trial, created_at = Created}) ->
|
||||
add_dim(subscription, status, St, -1),
|
||||
add_dim(subscription, plan, Plan, -1),
|
||||
case Trial of
|
||||
false -> add_counter(trial_subscriptions, -1);
|
||||
_ -> ok
|
||||
end,
|
||||
add_daily(subscriptions_created, date_of(Created), -1);
|
||||
process_delete(_, _) ->
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Backfill
|
||||
%%%===================================================================
|
||||
|
||||
backfill_all() ->
|
||||
lists:foreach(fun(U) -> process_write(user, U, undefined) end,
|
||||
safe_match(user, #user{_ = '_'})),
|
||||
lists:foreach(fun(E) -> process_write(event, E, undefined) end,
|
||||
safe_match(event, #event{_ = '_'})),
|
||||
lists:foreach(fun(C) -> process_write(calendar, C, undefined) end,
|
||||
safe_match(calendar, #calendar{_ = '_'})),
|
||||
lists:foreach(fun(B) -> process_write(booking, B, undefined) end,
|
||||
safe_match(booking, #booking{_ = '_'})),
|
||||
lists:foreach(fun(R) -> process_write(review, R, undefined) end,
|
||||
safe_match(review, #review{_ = '_'})),
|
||||
lists:foreach(fun(R) -> process_write(report, R, undefined) end,
|
||||
safe_match(report, #report{_ = '_'})),
|
||||
lists:foreach(fun(T) -> process_write(ticket, T, undefined) end,
|
||||
safe_match(ticket, #ticket{_ = '_'})),
|
||||
lists:foreach(fun(S) -> process_write(subscription, S, undefined) end,
|
||||
safe_match(subscription, #subscription{_ = '_'})),
|
||||
ok.
|
||||
|
||||
safe_match(Table, Pattern) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true -> mnesia:dirty_match_object(Pattern);
|
||||
false -> []
|
||||
end.
|
||||
|
||||
process_write(event, New, Old) ->
|
||||
CId = element(#event.calendar_id, New),
|
||||
StNew = element(#event.status, New),
|
||||
StOld = if Old =:= [] -> []; true -> element(#event.status, Old) end,
|
||||
if StNew =:= active, StOld =/= active ->
|
||||
ensure_counter({events_created, CId}),
|
||||
ets:update_counter(stats_ets, {events_created, CId}, {2, 1});
|
||||
StNew =:= cancelled, StOld =/= cancelled ->
|
||||
ensure_counter({events_cancelled, CId}),
|
||||
ets:update_counter(stats_ets, {events_cancelled, CId}, {2, 1});
|
||||
true -> ok
|
||||
end;
|
||||
process_write(booking, New, Old) ->
|
||||
EvId = element(#booking.event_id, New),
|
||||
StNew = element(#booking.status, New),
|
||||
StOld = if Old =:= [] -> []; true -> element(#booking.status, Old) end,
|
||||
CId = case mnesia:dirty_read({event, EvId}) of
|
||||
[#event{calendar_id = C}] -> C;
|
||||
_ -> undefined
|
||||
end,
|
||||
if CId =/= undefined ->
|
||||
if StNew =:= confirmed, StOld =/= confirmed ->
|
||||
ensure_counter({bookings_confirmed, CId}),
|
||||
ets:update_counter(stats_ets, {bookings_confirmed, CId}, {2, 1});
|
||||
StNew =:= cancelled, StOld =/= cancelled ->
|
||||
ensure_counter({bookings_cancelled, CId}),
|
||||
ets:update_counter(stats_ets, {bookings_cancelled, CId}, {2, 1});
|
||||
true -> ok
|
||||
end,
|
||||
if Old =:= [] ->
|
||||
ensure_counter({bookings_created, CId}),
|
||||
ets:update_counter(stats_ets, {bookings_created, CId}, {2, 1});
|
||||
true -> ok
|
||||
end;
|
||||
true -> ok
|
||||
end;
|
||||
process_write(review, New, Old) ->
|
||||
CId = case element(#review.target_type, New) of
|
||||
event ->
|
||||
EvId = element(#review.target_id, New),
|
||||
case mnesia:dirty_read({event, EvId}) of
|
||||
[#event{calendar_id = C}] -> C;
|
||||
_ -> undefined
|
||||
end;
|
||||
calendar ->
|
||||
element(#review.target_id, New)
|
||||
end,
|
||||
Rating = element(#review.rating, New),
|
||||
if CId =/= undefined ->
|
||||
if Old =:= [] ->
|
||||
ensure_counter({reviews_created, CId}),
|
||||
ets:update_counter(stats_ets, {reviews_created, CId}, {2, 1}),
|
||||
ensure_counter({reviews_sum, CId}),
|
||||
ets:update_counter(stats_ets, {reviews_sum, CId}, {2, Rating}),
|
||||
ensure_counter({reviews_count, CId}),
|
||||
ets:update_counter(stats_ets, {reviews_count, CId}, {2, 1});
|
||||
true ->
|
||||
OldRating = element(#review.rating, Old),
|
||||
if Rating =/= OldRating ->
|
||||
ensure_counter({reviews_sum, CId}),
|
||||
ets:update_counter(stats_ets, {reviews_sum, CId}, {2, Rating - OldRating});
|
||||
true -> ok
|
||||
end
|
||||
end;
|
||||
true -> ok
|
||||
end;
|
||||
process_write(_, _, _) -> ok.
|
||||
%%%===================================================================
|
||||
%%% ETS helpers
|
||||
%%%===================================================================
|
||||
|
||||
flush_stats() ->
|
||||
All = ets:tab2list(stats_ets),
|
||||
lists:foreach(fun({{Metric, EntityId}, Value}) ->
|
||||
mnesia:dirty_write(#stats{
|
||||
id = list_to_binary(io_lib:format("~p_~p_~p", [Metric, EntityId, os:system_time(millisecond)])),
|
||||
metric = Metric,
|
||||
entity_id = EntityId,
|
||||
value = Value,
|
||||
timestamp = calendar:local_time()
|
||||
})
|
||||
end, All),
|
||||
ets:match_delete(stats_ets, '_').
|
||||
dim_key(Entity, Field, Value) ->
|
||||
{list_to_atom(atom_to_list(Entity) ++ "_by_" ++ atom_to_list(Field)), Value}.
|
||||
|
||||
add_dim(Entity, Field, Value, Delta) when is_atom(Value) ->
|
||||
add_counter(dim_key(Entity, Field, Value), Delta);
|
||||
add_dim(_, _, _, _) ->
|
||||
ok.
|
||||
|
||||
move_dim(_E, _F, Same, Same) ->
|
||||
ok;
|
||||
move_dim(Entity, Field, OldVal, NewVal) ->
|
||||
add_dim(Entity, Field, OldVal, -1),
|
||||
add_dim(Entity, Field, NewVal, 1).
|
||||
|
||||
add_counter(_Key, 0) ->
|
||||
ok;
|
||||
add_counter(Key, Delta) ->
|
||||
ensure_key(?COUNTER_ETS, Key),
|
||||
ets:update_counter(?COUNTER_ETS, Key, {2, Delta}),
|
||||
%% clamp at 0 for safety under races
|
||||
case ets:lookup(?COUNTER_ETS, Key) of
|
||||
[{Key, V}] when V < 0 -> ets:insert(?COUNTER_ETS, {Key, 0});
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
add_daily(_Metric, undefined, _) ->
|
||||
ok;
|
||||
add_daily(_Metric, _Date, 0) ->
|
||||
ok;
|
||||
add_daily(Metric, Date, Delta) ->
|
||||
Key = {Metric, Date},
|
||||
ensure_key(?DAILY_ETS, Key),
|
||||
ets:update_counter(?DAILY_ETS, Key, {2, Delta}),
|
||||
case ets:lookup(?DAILY_ETS, Key) of
|
||||
[{Key, V}] when V < 0 -> ets:insert(?DAILY_ETS, {Key, 0});
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
ensure_key(Tab, Key) ->
|
||||
case ets:member(Tab, Key) of
|
||||
true -> ok;
|
||||
false -> ets:insert(Tab, {Key, 0})
|
||||
end.
|
||||
|
||||
ets_get(Tab, Key) ->
|
||||
case ets:lookup(Tab, Key) of
|
||||
[{Key, V}] -> V;
|
||||
[] -> 0
|
||||
end.
|
||||
|
||||
read_dim(Entity, Field) ->
|
||||
Prefix = list_to_atom(atom_to_list(Entity) ++ "_by_" ++ atom_to_list(Field)),
|
||||
lists:filtermap(fun
|
||||
({{P, Val}, V}) when P =:= Prefix, V > 0, is_atom(Val) -> {true, {Val, V}};
|
||||
(_) -> false
|
||||
end, ets:tab2list(?COUNTER_ETS)).
|
||||
|
||||
read_daily(Metric, From, To) ->
|
||||
FromDate = date_of(From),
|
||||
ToDate = date_of(To),
|
||||
Matches = lists:filtermap(fun
|
||||
({{M, Day}, V}) when M =:= Metric, V > 0,
|
||||
Day >= FromDate, Day =< ToDate ->
|
||||
{true, {Day, V}};
|
||||
(_) -> false
|
||||
end, ets:tab2list(?DAILY_ETS)),
|
||||
lists:sort(Matches).
|
||||
|
||||
date_of({{Y, M, D}, _}) -> {Y, M, D};
|
||||
date_of({Y, M, D} = Date) when is_integer(Y), is_integer(M), is_integer(D) -> Date;
|
||||
date_of(_) -> undefined.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Resolution time (avg)
|
||||
%%%===================================================================
|
||||
|
||||
-define(TICKET_EPOCH, {{1970, 1, 1}, {0, 0, 0}}).
|
||||
|
||||
%% Ticket: same semantics as core_ticket:avg_resolution_time — closed_at set.
|
||||
ticket_resolution(#ticket{first_seen = First, closed_at = Closed}) ->
|
||||
case is_set_datetime(Closed) of
|
||||
true ->
|
||||
case resolution_seconds(First, Closed) of
|
||||
undefined -> undefined;
|
||||
Sec -> {ticket, Sec}
|
||||
end;
|
||||
false -> undefined
|
||||
end.
|
||||
|
||||
%% Report: only reviewed|dismissed with resolved_at.
|
||||
report_resolution(#report{status = St, created_at = Created, resolved_at = Resolved})
|
||||
when St =:= reviewed; St =:= dismissed ->
|
||||
case is_set_datetime(Resolved) of
|
||||
true ->
|
||||
case resolution_seconds(Created, Resolved) of
|
||||
undefined -> undefined;
|
||||
Sec -> {{report, St}, Sec}
|
||||
end;
|
||||
false -> undefined
|
||||
end;
|
||||
report_resolution(_) ->
|
||||
undefined.
|
||||
|
||||
move_resolution(Same, Same) ->
|
||||
ok;
|
||||
move_resolution(Old, New) ->
|
||||
remove_resolution(Old),
|
||||
apply_resolution(New).
|
||||
|
||||
apply_resolution(undefined) -> ok;
|
||||
apply_resolution({ticket, Sec}) -> apply_ticket_resolution({ticket, Sec});
|
||||
apply_resolution({{report, St}, Sec}) -> apply_report_resolution({{report, St}, Sec}).
|
||||
|
||||
remove_resolution(undefined) -> ok;
|
||||
remove_resolution({ticket, Sec}) -> remove_ticket_resolution({ticket, Sec});
|
||||
remove_resolution({{report, St}, Sec}) -> remove_report_resolution({{report, St}, Sec}).
|
||||
|
||||
apply_ticket_resolution(undefined) -> ok;
|
||||
apply_ticket_resolution({ticket, Sec}) ->
|
||||
add_counter(ticket_resolved_count, 1),
|
||||
add_counter(ticket_resolved_seconds_sum, Sec).
|
||||
|
||||
remove_ticket_resolution(undefined) -> ok;
|
||||
remove_ticket_resolution({ticket, Sec}) ->
|
||||
add_counter(ticket_resolved_count, -1),
|
||||
add_counter(ticket_resolved_seconds_sum, -Sec).
|
||||
|
||||
apply_report_resolution(undefined) -> ok;
|
||||
apply_report_resolution({{report, St}, Sec}) ->
|
||||
add_counter({report_resolved_count, St}, 1),
|
||||
add_counter({report_resolved_seconds_sum, St}, Sec).
|
||||
|
||||
remove_report_resolution(undefined) -> ok;
|
||||
remove_report_resolution({{report, St}, Sec}) ->
|
||||
add_counter({report_resolved_count, St}, -1),
|
||||
add_counter({report_resolved_seconds_sum, St}, -Sec).
|
||||
|
||||
is_set_datetime(undefined) -> false;
|
||||
is_set_datetime(?TICKET_EPOCH) -> false;
|
||||
is_set_datetime({{_, _, _}, {_, _, _}}) -> true;
|
||||
is_set_datetime(_) -> false.
|
||||
|
||||
resolution_seconds(Start, End) ->
|
||||
case {is_set_datetime(Start), is_set_datetime(End)} of
|
||||
{true, true} ->
|
||||
max(0, calendar:datetime_to_gregorian_seconds(End) -
|
||||
calendar:datetime_to_gregorian_seconds(Start));
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Persist / cleanup
|
||||
%%%===================================================================
|
||||
|
||||
flush_to_mnesia() ->
|
||||
Now = calendar:universal_time(),
|
||||
case lists:member(stats_counter, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
lists:foreach(fun({Key, Value}) ->
|
||||
mnesia:dirty_write(#stats_counter{key = Key, value = Value, updated_at = Now})
|
||||
end, ets:tab2list(?COUNTER_ETS));
|
||||
false -> ok
|
||||
end,
|
||||
case lists:member(stats_daily, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
lists:foreach(fun({Key, Value}) ->
|
||||
mnesia:dirty_write(#stats_daily{key = Key, value = Value, updated_at = Now})
|
||||
end, ets:tab2list(?DAILY_ETS));
|
||||
false -> ok
|
||||
end,
|
||||
ok.
|
||||
|
||||
cleanup_old_daily(RetentionDays) ->
|
||||
case lists:member(stats_daily, mnesia:system_info(tables)) of
|
||||
false -> ok;
|
||||
true ->
|
||||
{Date, _} = calendar:universal_time(),
|
||||
CutoffSec = calendar:datetime_to_gregorian_seconds({Date, {0, 0, 0}})
|
||||
- RetentionDays * 86400,
|
||||
Cutoff = element(1, calendar:gregorian_seconds_to_datetime(CutoffSec)),
|
||||
lists:foreach(fun
|
||||
(#stats_daily{key = {_, Day}} = Row) when Day < Cutoff ->
|
||||
mnesia:dirty_delete_object(Row),
|
||||
ets:delete(?DAILY_ETS, Row#stats_daily.key);
|
||||
(_) -> ok
|
||||
end, mnesia:dirty_match_object(#stats_daily{_ = '_'})),
|
||||
ok
|
||||
end.
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc ETS ordered_set tops для admin stats.
|
||||
%%% Класс A: denormalized rating на event/calendar.
|
||||
%%% Класс B: агрегаты целей review/report (+ calendar pos/neg).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(stats_tops).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
init_tables/0,
|
||||
clear/0,
|
||||
rebuild/0,
|
||||
on_event/2,
|
||||
on_calendar/2,
|
||||
on_review/2,
|
||||
on_report/2,
|
||||
remove_event/1,
|
||||
remove_calendar/1,
|
||||
remove_review/1,
|
||||
remove_report/1,
|
||||
get_top_events_by_rating/1,
|
||||
get_top_calendars_by_rating/1,
|
||||
get_top_calendars_by_reviews/1,
|
||||
get_top_calendars_by_positive_reviews/1,
|
||||
get_top_calendars_by_negative_reviews/1,
|
||||
get_top_targets_by_reviews/1,
|
||||
get_top_targets_by_positive_reviews/1,
|
||||
get_top_targets_by_negative_reviews/1,
|
||||
get_top_targets_by_reports/1
|
||||
]).
|
||||
|
||||
-define(IDX, stats_tops_idx).
|
||||
-define(ORD_EVENT_RATING, stats_top_event_rating).
|
||||
-define(ORD_CAL_RATING, stats_top_calendar_rating).
|
||||
-define(ORD_CAL_REVIEWS, stats_top_calendar_reviews).
|
||||
-define(ORD_CAL_POS, stats_top_calendar_pos).
|
||||
-define(ORD_CAL_NEG, stats_top_calendar_neg).
|
||||
-define(ORD_REVIEWS_ALL, stats_top_reviews_all).
|
||||
-define(ORD_REVIEWS_POS, stats_top_reviews_pos).
|
||||
-define(ORD_REVIEWS_NEG, stats_top_reviews_neg).
|
||||
-define(ORD_REPORTS_ALL, stats_top_reports_all).
|
||||
|
||||
-define(ALL_ORD, [
|
||||
?ORD_EVENT_RATING, ?ORD_CAL_RATING, ?ORD_CAL_REVIEWS,
|
||||
?ORD_CAL_POS, ?ORD_CAL_NEG,
|
||||
?ORD_REVIEWS_ALL, ?ORD_REVIEWS_POS, ?ORD_REVIEWS_NEG, ?ORD_REPORTS_ALL
|
||||
]).
|
||||
|
||||
%%%===================================================================
|
||||
%%% Lifecycle
|
||||
%%%===================================================================
|
||||
|
||||
init_tables() ->
|
||||
ensure_ets(?IDX, [named_table, public, set, {read_concurrency, true}]),
|
||||
lists:foreach(fun(T) ->
|
||||
ensure_ets(T, [named_table, public, ordered_set, {read_concurrency, true}])
|
||||
end, ?ALL_ORD),
|
||||
ok.
|
||||
|
||||
clear() ->
|
||||
case ready() of
|
||||
true ->
|
||||
ets:delete_all_objects(?IDX),
|
||||
lists:foreach(fun(T) -> ets:delete_all_objects(T) end, ?ALL_ORD);
|
||||
false -> ok
|
||||
end,
|
||||
ok.
|
||||
|
||||
rebuild() ->
|
||||
clear(),
|
||||
foreach_table(event, #event{_ = '_'}, fun(E) -> on_event(E, undefined) end),
|
||||
foreach_table(calendar, #calendar{_ = '_'}, fun(C) -> on_calendar(C, undefined) end),
|
||||
foreach_table(review, #review{_ = '_'}, fun(R) -> on_review(R, undefined) end),
|
||||
foreach_table(report, #report{_ = '_'}, fun(R) -> on_report(R, undefined) end),
|
||||
ok.
|
||||
|
||||
ready() ->
|
||||
ets:info(?IDX) =/= undefined.
|
||||
|
||||
foreach_table(Table, Pattern, Fun) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true -> lists:foreach(Fun, mnesia:dirty_match_object(Pattern));
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Entity rating updates (class A)
|
||||
%%%===================================================================
|
||||
|
||||
on_event(undefined, _) -> ok;
|
||||
on_event(#event{id = Id, rating_avg = Avg}, Old) ->
|
||||
when_ready(fun() ->
|
||||
OldAvg = case Old of #event{rating_avg = A} -> A; _ -> undefined end,
|
||||
set_score(?ORD_EVENT_RATING, {event_rating, Id}, Id, OldAvg, Avg)
|
||||
end);
|
||||
on_event(_, _) -> ok.
|
||||
|
||||
on_calendar(undefined, _) -> ok;
|
||||
on_calendar(#calendar{id = Id, rating_avg = Avg, rating_count = Count}, Old) ->
|
||||
when_ready(fun() ->
|
||||
{OldAvg, OldCount} = case Old of
|
||||
#calendar{rating_avg = A, rating_count = C} -> {A, C};
|
||||
_ -> {undefined, undefined}
|
||||
end,
|
||||
set_score(?ORD_CAL_RATING, {calendar_rating, Id}, Id, OldAvg, Avg),
|
||||
set_score(?ORD_CAL_REVIEWS, {calendar_reviews, Id}, Id, OldCount, Count)
|
||||
end);
|
||||
on_calendar(_, _) -> ok.
|
||||
|
||||
remove_event(#event{id = Id, rating_avg = Avg}) ->
|
||||
when_ready(fun() -> clear_score(?ORD_EVENT_RATING, {event_rating, Id}, Id, Avg) end);
|
||||
remove_event(_) -> ok.
|
||||
|
||||
remove_calendar(#calendar{id = Id, rating_avg = Avg, rating_count = Count}) ->
|
||||
when_ready(fun() ->
|
||||
clear_score(?ORD_CAL_RATING, {calendar_rating, Id}, Id, Avg),
|
||||
clear_score(?ORD_CAL_REVIEWS, {calendar_reviews, Id}, Id, Count)
|
||||
end);
|
||||
remove_calendar(_) -> ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Review / report target updates (class B)
|
||||
%%%===================================================================
|
||||
|
||||
on_review(undefined, _) -> ok;
|
||||
on_review(New, undefined) ->
|
||||
when_ready(fun() -> apply_review_delta(New, 1) end);
|
||||
on_review(New, Old) when is_tuple(Old) ->
|
||||
when_ready(fun() ->
|
||||
apply_review_delta(Old, -1),
|
||||
apply_review_delta(New, 1)
|
||||
end);
|
||||
on_review(_, _) -> ok.
|
||||
|
||||
remove_review(Review) ->
|
||||
when_ready(fun() -> apply_review_delta(Review, -1) end).
|
||||
|
||||
on_report(undefined, _) -> ok;
|
||||
on_report(New, undefined) ->
|
||||
when_ready(fun() -> apply_report_delta(New, 1) end);
|
||||
on_report(New, Old) when is_tuple(Old) ->
|
||||
when_ready(fun() ->
|
||||
apply_report_delta(Old, -1),
|
||||
apply_report_delta(New, 1)
|
||||
end);
|
||||
on_report(_, _) -> ok.
|
||||
|
||||
remove_report(Report) ->
|
||||
when_ready(fun() -> apply_report_delta(Report, -1) end).
|
||||
|
||||
apply_review_delta(#review{target_type = Type, target_id = Id, rating = Rating}, Delta)
|
||||
when is_atom(Type), is_binary(Id) ->
|
||||
Target = {Type, Id},
|
||||
bump(?ORD_REVIEWS_ALL, {reviews_all, Type, Id}, Target, Delta),
|
||||
case Rating >= 4 of
|
||||
true ->
|
||||
bump(?ORD_REVIEWS_POS, {reviews_pos, Type, Id}, Target, Delta),
|
||||
case Type of
|
||||
calendar -> bump(?ORD_CAL_POS, {calendar_pos, Id}, Id, Delta);
|
||||
_ -> ok
|
||||
end;
|
||||
false -> ok
|
||||
end,
|
||||
case Rating =< 2 of
|
||||
true ->
|
||||
bump(?ORD_REVIEWS_NEG, {reviews_neg, Type, Id}, Target, Delta),
|
||||
case Type of
|
||||
calendar -> bump(?ORD_CAL_NEG, {calendar_neg, Id}, Id, Delta);
|
||||
_ -> ok
|
||||
end;
|
||||
false -> ok
|
||||
end;
|
||||
apply_review_delta(_, _) -> ok.
|
||||
|
||||
apply_report_delta(#report{target_type = Type, target_id = Id}, Delta)
|
||||
when is_atom(Type), is_binary(Id) ->
|
||||
bump(?ORD_REPORTS_ALL, {reports_all, Type, Id}, {Type, Id}, Delta);
|
||||
apply_report_delta(_, _) -> ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Reads
|
||||
%%%===================================================================
|
||||
|
||||
get_top_events_by_rating(N) ->
|
||||
get_entities(?ORD_EVENT_RATING, N, fun load_event/1).
|
||||
|
||||
get_top_calendars_by_rating(N) ->
|
||||
get_entities(?ORD_CAL_RATING, N, fun load_calendar/1).
|
||||
|
||||
get_top_calendars_by_reviews(N) ->
|
||||
get_entities(?ORD_CAL_REVIEWS, N, fun load_calendar/1).
|
||||
|
||||
get_top_calendars_by_positive_reviews(N) ->
|
||||
get_entities(?ORD_CAL_POS, N, fun load_calendar/1).
|
||||
|
||||
get_top_calendars_by_negative_reviews(N) ->
|
||||
get_entities(?ORD_CAL_NEG, N, fun load_calendar/1).
|
||||
|
||||
get_top_targets_by_reviews(N) ->
|
||||
get_target_triples(?ORD_REVIEWS_ALL, N).
|
||||
|
||||
get_top_targets_by_positive_reviews(N) ->
|
||||
get_target_triples(?ORD_REVIEWS_POS, N).
|
||||
|
||||
get_top_targets_by_negative_reviews(N) ->
|
||||
get_target_triples(?ORD_REVIEWS_NEG, N).
|
||||
|
||||
get_top_targets_by_reports(N) ->
|
||||
get_target_triples(?ORD_REPORTS_ALL, N).
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
when_ready(Fun) ->
|
||||
case ready() of
|
||||
true -> Fun();
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
ensure_ets(Name, Opts) ->
|
||||
case ets:info(Name) of
|
||||
undefined -> ets:new(Name, Opts);
|
||||
_ -> Name
|
||||
end.
|
||||
|
||||
set_score(_Ord, _IdxKey, _Entity, Same, Same) ->
|
||||
ok;
|
||||
set_score(Ord, IdxKey, Entity, _OldScore, NewScore) ->
|
||||
case ets:lookup(?IDX, IdxKey) of
|
||||
[{IdxKey, Prev}] -> ets:delete(Ord, ord_key(Prev, Entity));
|
||||
[] -> ok
|
||||
end,
|
||||
ets:insert(Ord, {ord_key(NewScore, Entity), Entity}),
|
||||
ets:insert(?IDX, {IdxKey, NewScore}),
|
||||
ok.
|
||||
|
||||
clear_score(Ord, IdxKey, Entity, Score) ->
|
||||
case ets:lookup(?IDX, IdxKey) of
|
||||
[{IdxKey, Prev}] ->
|
||||
ets:delete(Ord, ord_key(Prev, Entity)),
|
||||
ets:delete(?IDX, IdxKey);
|
||||
[] ->
|
||||
ets:delete(Ord, ord_key(Score, Entity))
|
||||
end,
|
||||
ok.
|
||||
|
||||
bump(_Ord, _IdxKey, _Entity, 0) ->
|
||||
ok;
|
||||
bump(Ord, IdxKey, Entity, Delta) ->
|
||||
Old = case ets:lookup(?IDX, IdxKey) of
|
||||
[{IdxKey, C}] -> C;
|
||||
[] -> 0
|
||||
end,
|
||||
case Old > 0 of
|
||||
true -> ets:delete(Ord, ord_key(Old, Entity));
|
||||
false -> ok
|
||||
end,
|
||||
New = max(0, Old + Delta),
|
||||
case New > 0 of
|
||||
true ->
|
||||
ets:insert(Ord, {ord_key(New, Entity), Entity}),
|
||||
ets:insert(?IDX, {IdxKey, New});
|
||||
false ->
|
||||
ets:delete(?IDX, IdxKey)
|
||||
end,
|
||||
ok.
|
||||
|
||||
ord_key(Score, Entity) when is_number(Score) ->
|
||||
{-Score, Entity};
|
||||
ord_key(_, Entity) ->
|
||||
{0, Entity}.
|
||||
|
||||
get_entities(Ord, N, LoadFun) ->
|
||||
case ready() of
|
||||
false -> {error, not_ready};
|
||||
true ->
|
||||
Keys = take_entities(Ord, ets:first(Ord), N, []),
|
||||
{ok, lists:filtermap(LoadFun, Keys)}
|
||||
end.
|
||||
|
||||
get_target_triples(Ord, N) ->
|
||||
case ready() of
|
||||
false -> {error, not_ready};
|
||||
true ->
|
||||
Items = take_entities(Ord, ets:first(Ord), N, []),
|
||||
Triples = lists:filtermap(fun
|
||||
({Type, Id}) ->
|
||||
case ets:lookup(?IDX, idx_for_ord(Ord, Type, Id)) of
|
||||
[{_, Count}] when Count > 0 -> {true, {Type, Id, Count}};
|
||||
_ -> false
|
||||
end;
|
||||
(_) -> false
|
||||
end, Items),
|
||||
{ok, Triples}
|
||||
end.
|
||||
|
||||
idx_for_ord(?ORD_REVIEWS_ALL, Type, Id) -> {reviews_all, Type, Id};
|
||||
idx_for_ord(?ORD_REVIEWS_POS, Type, Id) -> {reviews_pos, Type, Id};
|
||||
idx_for_ord(?ORD_REVIEWS_NEG, Type, Id) -> {reviews_neg, Type, Id};
|
||||
idx_for_ord(?ORD_REPORTS_ALL, Type, Id) -> {reports_all, Type, Id}.
|
||||
|
||||
take_entities(_Ord, '$end_of_table', _N, Acc) ->
|
||||
lists:reverse(Acc);
|
||||
take_entities(_Ord, _Key, 0, Acc) ->
|
||||
lists:reverse(Acc);
|
||||
take_entities(Ord, Key, N, Acc) ->
|
||||
case ets:lookup(Ord, Key) of
|
||||
[{Key, Entity}] ->
|
||||
take_entities(Ord, ets:next(Ord, Key), N - 1, [Entity | Acc]);
|
||||
[] ->
|
||||
take_entities(Ord, ets:next(Ord, Key), N, Acc)
|
||||
end.
|
||||
|
||||
load_event(Id) when is_binary(Id) ->
|
||||
case mnesia:dirty_read({event, Id}) of
|
||||
[E] -> {true, E};
|
||||
_ -> false
|
||||
end;
|
||||
load_event(_) -> false.
|
||||
|
||||
load_calendar(Id) when is_binary(Id) ->
|
||||
case mnesia:dirty_read({calendar, Id}) of
|
||||
[C] -> {true, C};
|
||||
_ -> false
|
||||
end;
|
||||
load_calendar(_) -> false.
|
||||
@@ -141,26 +141,19 @@ delete_ticket(AdminId, TicketId) ->
|
||||
get_statistics(AdminId) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
All = core_ticket:list_all(),
|
||||
Open = count_by_status(All, open),
|
||||
InProgress = count_by_status(All, in_progress),
|
||||
Resolved = count_by_status(All, resolved),
|
||||
Closed = count_by_status(All, closed),
|
||||
TotalErrors = lists:sum([T#ticket.count || T <- All]),
|
||||
#{
|
||||
total_tickets => length(All),
|
||||
open => Open,
|
||||
in_progress => InProgress,
|
||||
resolved => Resolved,
|
||||
closed => Closed,
|
||||
total_errors => TotalErrors
|
||||
total_tickets => core_ticket:count_tickets(),
|
||||
open => core_ticket:count_tickets_by_status(open),
|
||||
in_progress => core_ticket:count_tickets_by_status(in_progress),
|
||||
resolved => core_ticket:count_tickets_by_status(resolved),
|
||||
closed => core_ticket:count_tickets_by_status(closed),
|
||||
total_errors => core_stats:with_counter(ticket_errors_sum, fun() ->
|
||||
lists:sum([T#ticket.count || T <- core_ticket:list_all()])
|
||||
end)
|
||||
};
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
|
||||
count_by_status(Tickets, Status) ->
|
||||
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
|
||||
|
||||
normalize_status(Status) when is_atom(Status) -> Status;
|
||||
normalize_status(Status) when is_binary(Status) ->
|
||||
try binary_to_existing_atom(Status, utf8)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
%% @doc Replace append-only `stats` with upsert `stats_counter` + `stats_daily`.
|
||||
-module('20260717180000_stats_counters').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
ensure_table(stats_counter, record_info(fields, stats_counter)),
|
||||
ensure_table(stats_daily, record_info(fields, stats_daily)),
|
||||
drop_table(stats),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
%% Old append-only shape (record removed from records.hrl).
|
||||
ensure_table(stats, [id, metric, entity_id, value, timestamp]),
|
||||
drop_table(stats_counter),
|
||||
drop_table(stats_daily),
|
||||
ok.
|
||||
|
||||
ensure_table(Table, Attrs) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table}} -> ok;
|
||||
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||
end
|
||||
end.
|
||||
|
||||
drop_table(Table) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
case mnesia:delete_table(Table) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, Reason} -> error({delete_table_failed, Table, Reason})
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end.
|
||||
@@ -0,0 +1,53 @@
|
||||
%% @doc Indexes for per-admin stats: report.resolved_by, ticket.assigned_to.
|
||||
-module('20260717190000_admin_stats_indexes').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
ensure_index(report, resolved_by, #report.resolved_by),
|
||||
ensure_index(ticket, assigned_to, #ticket.assigned_to),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
del_index(report, resolved_by),
|
||||
del_index(ticket, assigned_to),
|
||||
ok.
|
||||
|
||||
ensure_index(Table, Attr, Pos) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
false ->
|
||||
ok;
|
||||
true ->
|
||||
Indexes = mnesia:table_info(Table, index),
|
||||
HasIndex = lists:any(fun
|
||||
(A) when A =:= Attr -> true;
|
||||
(N) when is_integer(N) -> N =:= Pos;
|
||||
(_) -> false
|
||||
end, Indexes),
|
||||
case HasIndex of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case mnesia:add_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, {already_exists, _, _}} -> ok;
|
||||
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
del_index(Table, Attr) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
false ->
|
||||
ok;
|
||||
true ->
|
||||
case catch mnesia:del_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {no_exists, _}} -> ok;
|
||||
{aborted, {no_exists, _, _}} -> ok;
|
||||
_ -> ok
|
||||
end
|
||||
end.
|
||||
@@ -0,0 +1,73 @@
|
||||
-module(core_admin_stats_index_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [report, ticket, user]).
|
||||
-define(ADMIN_A, <<"adm_a">>).
|
||||
-define(ADMIN_B, <<"adm_b">>).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
ok.
|
||||
|
||||
admin_stats_index_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"reports resolved_by index filters by admin", fun test_reports_by_admin/0},
|
||||
{"tickets assigned_to index filters by admin", fun test_tickets_by_admin/0}
|
||||
]}.
|
||||
|
||||
test_reports_by_admin() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(#report{
|
||||
id = <<"r1">>, reporter_id = <<"u1">>, target_type = calendar,
|
||||
target_id = <<"c1">>, reason = <<"spam">>, status = reviewed,
|
||||
created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_A
|
||||
}),
|
||||
ok = mnesia:dirty_write(#report{
|
||||
id = <<"r2">>, reporter_id = <<"u1">>, target_type = calendar,
|
||||
target_id = <<"c2">>, reason = <<"spam">>, status = reviewed,
|
||||
created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_B
|
||||
}),
|
||||
ok = mnesia:dirty_write(#report{
|
||||
id = <<"r3">>, reporter_id = <<"u1">>, target_type = event,
|
||||
target_id = <<"e1">>, reason = <<"spam">>, status = dismissed,
|
||||
created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_A
|
||||
}),
|
||||
?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_A, reviewed)),
|
||||
?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_A, dismissed)),
|
||||
?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_B, reviewed)),
|
||||
?assertEqual(0, core_report:count_reports_resolved_by_admin(?ADMIN_B, dismissed)).
|
||||
|
||||
test_tickets_by_admin() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(#ticket{
|
||||
id = <<"t1">>, reporter_id = <<"u1">>, error_hash = <<"h1">>,
|
||||
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
|
||||
count = 1, first_seen = Now, last_seen = Now, status = open,
|
||||
assigned_to = ?ADMIN_A, resolution_note = <<>>, closed_at = undefined,
|
||||
source = <<"backend">>
|
||||
}),
|
||||
ok = mnesia:dirty_write(#ticket{
|
||||
id = <<"t2">>, reporter_id = <<"u1">>, error_hash = <<"h2">>,
|
||||
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
|
||||
count = 1, first_seen = Now, last_seen = Now, status = resolved,
|
||||
assigned_to = ?ADMIN_A, resolution_note = <<>>, closed_at = Now,
|
||||
source = <<"backend">>
|
||||
}),
|
||||
ok = mnesia:dirty_write(#ticket{
|
||||
id = <<"t3">>, reporter_id = <<"u1">>, error_hash = <<"h3">>,
|
||||
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
|
||||
count = 1, first_seen = Now, last_seen = Now, status = open,
|
||||
assigned_to = ?ADMIN_B, resolution_note = <<>>, closed_at = undefined,
|
||||
source = <<"backend">>
|
||||
}),
|
||||
?assertEqual(2, core_ticket:count_tickets_by_admin(?ADMIN_A, all)),
|
||||
?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_A, open)),
|
||||
?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_A, resolved)),
|
||||
?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_B, all)).
|
||||
@@ -72,8 +72,27 @@ stop_mnesia() ->
|
||||
|
||||
ensure_tables(Tables) when is_list(Tables) ->
|
||||
lists:foreach(fun ensure_table/1, Tables),
|
||||
maybe_add_admin_stats_indexes(Tables),
|
||||
ok.
|
||||
|
||||
maybe_add_admin_stats_indexes(Tables) ->
|
||||
case lists:member(report, Tables) of
|
||||
true -> add_index(report, resolved_by);
|
||||
false -> ok
|
||||
end,
|
||||
case lists:member(ticket, Tables) of
|
||||
true -> add_index(ticket, assigned_to);
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
add_index(Table, Attr) ->
|
||||
case catch mnesia:add_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, {already_exists, _, _}} -> ok;
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
ensure_table(Table) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
@@ -133,8 +152,10 @@ table_opts(admin_audit) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
||||
table_opts(notification) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
||||
table_opts(stats) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, stats)}];
|
||||
table_opts(stats_counter) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
||||
table_opts(stats_daily) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
|
||||
table_opts(session) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||
table_opts(verification) ->
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
-module(stats_collector_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [user, event, calendar, booking, review, report, ticket,
|
||||
subscription, stats_counter, stats_daily]).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
{ok, _} = stats_collector:start_link(),
|
||||
ok = stats_collector:subscribe(),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
case whereis(stats_collector) of
|
||||
undefined -> ok;
|
||||
Pid ->
|
||||
unlink(Pid),
|
||||
exit(Pid, kill),
|
||||
wait_dead(Pid, 50)
|
||||
end,
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
ok.
|
||||
|
||||
wait_dead(_Pid, 0) -> ok;
|
||||
wait_dead(Pid, N) ->
|
||||
case is_process_alive(Pid) of
|
||||
true ->
|
||||
timer:sleep(20),
|
||||
wait_dead(Pid, N - 1);
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
stats_collector_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"backfill from existing rows", fun test_backfill/0},
|
||||
{"live write updates dims and daily", fun test_live_write/0},
|
||||
{"status transition moves counters", fun test_status_move/0},
|
||||
{"core_stats reads via collector", fun test_core_stats_api/0},
|
||||
{"ticket avg resolution from counters", fun test_ticket_avg_resolution/0},
|
||||
{"report avg resolution from counters", fun test_report_avg_resolution/0}
|
||||
]}.
|
||||
|
||||
test_backfill() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(eh_test_support:make_user(#{
|
||||
id => <<"u1">>, status => pending, role => user, created_at => Now
|
||||
})),
|
||||
ok = mnesia:dirty_write(eh_test_support:make_user(#{
|
||||
id => <<"u2">>, status => active, role => user, created_at => Now
|
||||
})),
|
||||
ok = stats_collector:rebuild(),
|
||||
?assertEqual(1, core_stats:with_counter({user_by_status, pending}, fun() -> -1 end)),
|
||||
?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> -1 end)),
|
||||
{Date, _} = Now,
|
||||
From = {Date, {0, 0, 0}},
|
||||
To = {Date, {23, 59, 59}},
|
||||
Daily = core_stats:with_daily(users_created, From, To, fun() -> [] end),
|
||||
?assertEqual([{Date, 2}], Daily).
|
||||
|
||||
test_live_write() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(eh_test_support:make_user(#{
|
||||
id => <<"live1">>, status => active, created_at => Now
|
||||
})),
|
||||
wait_counter({user_by_status, active}, 1, 20),
|
||||
?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> 0 end)).
|
||||
|
||||
test_status_move() ->
|
||||
Now = calendar:universal_time(),
|
||||
User = eh_test_support:make_user(#{
|
||||
id => <<"move1">>, status => pending, created_at => Now
|
||||
}),
|
||||
ok = mnesia:dirty_write(User),
|
||||
wait_counter({user_by_status, pending}, 1, 20),
|
||||
ok = mnesia:dirty_write(User#user{status = active}),
|
||||
wait_counter({user_by_status, pending}, 0, 20),
|
||||
wait_counter({user_by_status, active}, 1, 20),
|
||||
?assertEqual(0, core_stats:with_counter({user_by_status, pending}, fun() -> -1 end)),
|
||||
?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> -1 end)).
|
||||
|
||||
test_core_stats_api() ->
|
||||
?assert(core_stats:available()),
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(#report{
|
||||
id = <<"r1">>,
|
||||
reporter_id = <<"u1">>,
|
||||
target_type = calendar,
|
||||
target_id = <<"c1">>,
|
||||
reason = <<"spam">>,
|
||||
status = pending,
|
||||
created_at = Now,
|
||||
resolved_at = undefined,
|
||||
resolved_by = <<>>
|
||||
}),
|
||||
wait_counter({report_by_status, pending}, 1, 20),
|
||||
{ok, Dim} = core_stats:get_dim(report, status),
|
||||
?assertEqual(1, proplists:get_value(pending, Dim, 0)).
|
||||
|
||||
test_ticket_avg_resolution() ->
|
||||
First = {{2026, 1, 1}, {0, 0, 0}},
|
||||
Closed = {{2026, 1, 1}, {2, 0, 0}}, %% 2 hours
|
||||
ok = mnesia:dirty_write(#ticket{
|
||||
id = <<"t_avg">>, reporter_id = <<"u1">>, error_hash = <<"h">>,
|
||||
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
|
||||
count = 1, first_seen = First, last_seen = Closed, status = closed,
|
||||
assigned_to = <<>>, resolution_note = <<>>, closed_at = Closed,
|
||||
source = <<"backend">>
|
||||
}),
|
||||
wait_counter(ticket_resolved_count, 1, 20),
|
||||
Avg = core_ticket:avg_resolution_time(),
|
||||
?assertEqual(2.0, Avg).
|
||||
|
||||
test_report_avg_resolution() ->
|
||||
Created = {{2026, 1, 1}, {0, 0, 0}},
|
||||
Resolved = {{2026, 1, 1}, {3, 0, 0}}, %% 3 hours
|
||||
ok = mnesia:dirty_write(#report{
|
||||
id = <<"r_avg">>, reporter_id = <<"u1">>, target_type = calendar,
|
||||
target_id = <<"c1">>, reason = <<"spam">>, status = reviewed,
|
||||
created_at = Created, resolved_at = Resolved, resolved_by = <<"adm">>
|
||||
}),
|
||||
wait_counter({report_resolved_count, reviewed}, 1, 20),
|
||||
Avg = core_report:avg_resolution_time(reviewed),
|
||||
?assertEqual(3.0, Avg).
|
||||
|
||||
wait_counter(Key, Expected, 0) ->
|
||||
Actual = core_stats:with_counter(Key, fun() -> -999 end),
|
||||
?assertEqual(Expected, Actual);
|
||||
wait_counter(Key, Expected, N) ->
|
||||
case core_stats:with_counter(Key, fun() -> -999 end) of
|
||||
Expected -> ok;
|
||||
_ ->
|
||||
timer:sleep(25),
|
||||
wait_counter(Key, Expected, N - 1)
|
||||
end.
|
||||
@@ -0,0 +1,198 @@
|
||||
-module(stats_tops_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [event, calendar, review, report, stats_counter, stats_daily]).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
{ok, _} = stats_collector:start_link(),
|
||||
ok = stats_collector:subscribe(),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
case whereis(stats_collector) of
|
||||
undefined -> ok;
|
||||
Pid ->
|
||||
unlink(Pid),
|
||||
exit(Pid, kill),
|
||||
wait_dead(Pid, 50)
|
||||
end,
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
ok.
|
||||
|
||||
wait_dead(_Pid, 0) -> ok;
|
||||
wait_dead(Pid, N) ->
|
||||
case is_process_alive(Pid) of
|
||||
true -> timer:sleep(20), wait_dead(Pid, N - 1);
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
stats_tops_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"top events by rating order", fun test_event_rating_top/0},
|
||||
{"top calendars by rating and reviews", fun test_calendar_tops/0},
|
||||
{"rating update reorders top", fun test_rating_update/0},
|
||||
{"review target tops all/pos/neg", fun test_review_target_tops/0},
|
||||
{"report target tops", fun test_report_target_tops/0},
|
||||
{"calendar pos/neg from reviews", fun test_calendar_pos_neg/0}
|
||||
]}.
|
||||
|
||||
test_event_rating_top() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(make_event(<<"e_low">>, 2.0, Now)),
|
||||
ok = mnesia:dirty_write(make_event(<<"e_high">>, 5.0, Now)),
|
||||
ok = mnesia:dirty_write(make_event(<<"e_mid">>, 3.5, Now)),
|
||||
wait_top_event(<<"e_high">>, 30),
|
||||
Top = core_event:get_top_events_by_rating(2),
|
||||
Ids = [E#event.id || E <- Top],
|
||||
?assertEqual([<<"e_high">>, <<"e_mid">>], Ids).
|
||||
|
||||
test_calendar_tops() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(make_calendar(<<"c1">>, 4.0, 10, Now)),
|
||||
ok = mnesia:dirty_write(make_calendar(<<"c2">>, 5.0, 2, Now)),
|
||||
ok = mnesia:dirty_write(make_calendar(<<"c3">>, 3.0, 20, Now)),
|
||||
wait_top_cal_rating(<<"c2">>, 30),
|
||||
ByRating = [C#calendar.id || C <- core_calendar:get_top_calendars_by_rating(2)],
|
||||
?assertEqual([<<"c2">>, <<"c1">>], ByRating),
|
||||
ByReviews = [C#calendar.id || C <- core_calendar:get_top_calendars_by_reviews(2)],
|
||||
?assertEqual([<<"c3">>, <<"c1">>], ByReviews).
|
||||
|
||||
test_rating_update() ->
|
||||
Now = calendar:universal_time(),
|
||||
E1 = make_event(<<"eu1">>, 4.0, Now),
|
||||
E2 = make_event(<<"eu2">>, 3.0, Now),
|
||||
ok = mnesia:dirty_write(E1),
|
||||
ok = mnesia:dirty_write(E2),
|
||||
wait_top_event(<<"eu1">>, 30),
|
||||
ok = mnesia:dirty_write(E2#event{rating_avg = 4.5}),
|
||||
wait_top_event(<<"eu2">>, 30),
|
||||
Top = core_event:get_top_events_by_rating(1),
|
||||
?assertEqual([<<"eu2">>], [E#event.id || E <- Top]).
|
||||
|
||||
test_review_target_tops() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(make_review(<<"r1">>, calendar, <<"cA">>, 5, Now)),
|
||||
ok = mnesia:dirty_write(make_review(<<"r2">>, calendar, <<"cA">>, 5, Now)),
|
||||
ok = mnesia:dirty_write(make_review(<<"r3">>, event, <<"eA">>, 1, Now)),
|
||||
ok = mnesia:dirty_write(make_review(<<"r4">>, calendar, <<"cB">>, 2, Now)),
|
||||
wait_review_top({calendar, <<"cA">>}, 30),
|
||||
All = core_review:get_top_targets_by_reviews(3),
|
||||
?assertMatch([{calendar, <<"cA">>, 2} | _], All),
|
||||
AllKeys = [{T, I} || {T, I, _} <- All],
|
||||
?assert(lists:member({event, <<"eA">>}, AllKeys)),
|
||||
?assert(lists:member({calendar, <<"cB">>}, AllKeys)),
|
||||
Pos = core_review:get_top_targets_by_positive_reviews(1),
|
||||
?assertEqual([{calendar, <<"cA">>, 2}], Pos),
|
||||
Neg = core_review:get_top_targets_by_negative_reviews(2),
|
||||
NegIds = [{T, I} || {T, I, _} <- Neg],
|
||||
?assert(lists:member({event, <<"eA">>}, NegIds)),
|
||||
?assert(lists:member({calendar, <<"cB">>}, NegIds)).
|
||||
|
||||
test_report_target_tops() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(make_report(<<"p1">>, calendar, <<"cX">>, Now)),
|
||||
ok = mnesia:dirty_write(make_report(<<"p2">>, calendar, <<"cX">>, Now)),
|
||||
ok = mnesia:dirty_write(make_report(<<"p3">>, event, <<"eX">>, Now)),
|
||||
wait_report_top({calendar, <<"cX">>}, 30),
|
||||
Top = core_report:get_top_targets_by_reports(2),
|
||||
?assertEqual([{calendar, <<"cX">>, 2}, {event, <<"eX">>, 1}], Top).
|
||||
|
||||
test_calendar_pos_neg() ->
|
||||
Now = calendar:universal_time(),
|
||||
ok = mnesia:dirty_write(make_calendar(<<"cp1">>, 0.0, 0, Now)),
|
||||
ok = mnesia:dirty_write(make_calendar(<<"cp2">>, 0.0, 0, Now)),
|
||||
ok = mnesia:dirty_write(make_review(<<"rp1">>, calendar, <<"cp1">>, 5, Now)),
|
||||
ok = mnesia:dirty_write(make_review(<<"rp2">>, calendar, <<"cp1">>, 4, Now)),
|
||||
ok = mnesia:dirty_write(make_review(<<"rn1">>, calendar, <<"cp2">>, 1, Now)),
|
||||
wait_cal_pos(<<"cp1">>, 30),
|
||||
Pos = [C#calendar.id || C <- core_calendar:get_top_calendars_by_positive_reviews(1)],
|
||||
?assertEqual([<<"cp1">>], Pos),
|
||||
Neg = [C#calendar.id || C <- core_calendar:get_top_calendars_by_negative_reviews(1)],
|
||||
?assertEqual([<<"cp2">>], Neg).
|
||||
|
||||
make_review(Id, Type, TargetId, Rating, Now) ->
|
||||
#review{
|
||||
id = Id, user_id = <<"u1">>, target_type = Type, target_id = TargetId,
|
||||
rating = Rating, comment = <<>>, status = visible, reason = <<>>,
|
||||
likes = 0, dislikes = 0, created_at = Now, updated_at = Now
|
||||
}.
|
||||
|
||||
make_report(Id, Type, TargetId, Now) ->
|
||||
#report{
|
||||
id = Id, reporter_id = <<"u1">>, target_type = Type, target_id = TargetId,
|
||||
reason = <<"spam">>, status = pending, created_at = Now,
|
||||
resolved_at = undefined, resolved_by = <<>>
|
||||
}.
|
||||
|
||||
make_event(Id, Avg, Now) ->
|
||||
#event{
|
||||
id = Id, calendar_id = <<"cal">>, title = Id, description = <<>>,
|
||||
event_type = single, start_time = Now, duration = 60,
|
||||
recurrence_rule = <<>>, master_id = <<>>, is_instance = false,
|
||||
specialist_id = <<>>, location = undefined, tags = [],
|
||||
capacity = 10, online_link = <<>>, status = active, reason = <<>>,
|
||||
rating_avg = Avg, rating_count = 1, attachments = [], edit_history = [],
|
||||
created_at = Now, updated_at = Now
|
||||
}.
|
||||
|
||||
make_calendar(Id, Avg, Count, Now) ->
|
||||
#calendar{
|
||||
id = Id, owner_id = <<"u1">>, title = Id, description = <<>>,
|
||||
short_name = Id, category = <<>>, color = <<>>, image_url = <<>>,
|
||||
settings = #{}, tags = [], type = personal, confirmation = auto,
|
||||
rating_avg = Avg, rating_count = Count, status = active, reason = <<>>,
|
||||
created_at = Now, updated_at = Now
|
||||
}.
|
||||
|
||||
wait_top_event(ExpectedId, 0) ->
|
||||
Top = core_event:get_top_events_by_rating(1),
|
||||
?assertEqual([ExpectedId], [E#event.id || E <- Top]);
|
||||
wait_top_event(ExpectedId, N) ->
|
||||
case core_event:get_top_events_by_rating(1) of
|
||||
[#event{id = ExpectedId}] -> ok;
|
||||
_ -> timer:sleep(25), wait_top_event(ExpectedId, N - 1)
|
||||
end.
|
||||
|
||||
wait_top_cal_rating(ExpectedId, 0) ->
|
||||
Top = core_calendar:get_top_calendars_by_rating(1),
|
||||
?assertEqual([ExpectedId], [C#calendar.id || C <- Top]);
|
||||
wait_top_cal_rating(ExpectedId, N) ->
|
||||
case core_calendar:get_top_calendars_by_rating(1) of
|
||||
[#calendar{id = ExpectedId}] -> ok;
|
||||
_ -> timer:sleep(25), wait_top_cal_rating(ExpectedId, N - 1)
|
||||
end.
|
||||
|
||||
wait_review_top(Expected, 0) ->
|
||||
case core_review:get_top_targets_by_reviews(1) of
|
||||
[{T, I, _} | _] -> ?assertEqual(Expected, {T, I});
|
||||
Other -> ?assertEqual(Expected, Other)
|
||||
end;
|
||||
wait_review_top(Expected = {T, I}, N) ->
|
||||
case core_review:get_top_targets_by_reviews(1) of
|
||||
[{T, I, _} | _] -> ok;
|
||||
_ -> timer:sleep(25), wait_review_top(Expected, N - 1)
|
||||
end.
|
||||
|
||||
wait_report_top(Expected, 0) ->
|
||||
case core_report:get_top_targets_by_reports(1) of
|
||||
[{T, I, _} | _] -> ?assertEqual(Expected, {T, I});
|
||||
Other -> ?assertEqual(Expected, Other)
|
||||
end;
|
||||
wait_report_top(Expected = {T, I}, N) ->
|
||||
case core_report:get_top_targets_by_reports(1) of
|
||||
[{T, I, _} | _] -> ok;
|
||||
_ -> timer:sleep(25), wait_report_top(Expected, N - 1)
|
||||
end.
|
||||
|
||||
wait_cal_pos(ExpectedId, 0) ->
|
||||
Top = core_calendar:get_top_calendars_by_positive_reviews(1),
|
||||
?assertEqual([ExpectedId], [C#calendar.id || C <- Top]);
|
||||
wait_cal_pos(ExpectedId, N) ->
|
||||
case core_calendar:get_top_calendars_by_positive_reviews(1) of
|
||||
[#calendar{id = ExpectedId}] -> ok;
|
||||
_ -> timer:sleep(25), wait_cal_pos(ExpectedId, N - 1)
|
||||
end.
|
||||
Reference in New Issue
Block a user