Статистика для дашборда, расширенная #7

This commit is contained in:
2026-04-28 23:04:17 +03:00
parent c87d56bb49
commit 3da4ee28d4
11 changed files with 261 additions and 70 deletions

View File

@@ -4,20 +4,25 @@
update_role/2, block/1, unblock/1, generate_id/0]).
create(Email, Password, Role) ->
Id = generate_id(),
{ok, Hash} = argon2:hash(Password),
Now = calendar:universal_time(),
Admin = #admin{
id = Id,
email = Email,
password_hash = Hash,
role = Role,
status = active,
created_at = Now,
updated_at = Now
},
mnesia:dirty_write(Admin),
{ok, Admin}.
case get_by_email(Email) of
{ok, _} ->
{error, email_exists};
{error, not_found} ->
Id = generate_id(),
{ok, Hash} = argon2:hash(Password),
Now = calendar:universal_time(),
Admin = #admin{
id = Id,
email = Email,
password_hash = Hash,
role = Role,
status = active,
created_at = Now,
updated_at = Now
},
mnesia:dirty_write(Admin),
{ok, Admin}
end.
get_by_email(Email) ->
Match = #admin{email = Email, _ = '_'},

View File

@@ -1,6 +1,7 @@
-module(core_admin_audit).
-include("records.hrl").
-export([log/7, list/0, list/1]).
-export([count_actions_by_admin/2]).
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip) ->
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, undefined).
@@ -26,15 +27,33 @@ list() ->
%% Фильтрация по параметрам (простая версия)
list(Filters) ->
All = list(),
All = list(), % все записи
lists:filter(fun(E) ->
% Фильтр по admin_id
case proplists:get_value(admin_id, Filters) of
undefined -> true;
Id -> E#admin_audit.admin_id =:= Id
end andalso
case proplists:get_value(action, Filters) of
undefined -> true;
Act -> E#admin_audit.action =:= Act
end
% можно добавить фильтр по дате и т.д.
end, All).
Id -> E#admin_audit.admin_id =:= Id
end
andalso
% Фильтр по action
case proplists:get_value(action, Filters) of
undefined -> true;
Act -> E#admin_audit.action =:= Act
end
andalso
% Фильтр по дате с
case proplists:get_value(date_from, Filters) of
undefined -> true;
From -> E#admin_audit.timestamp >= From
end
andalso
% Фильтр по дате по
case proplists:get_value(date_to, Filters) of
undefined -> true;
To -> E#admin_audit.timestamp =< To
end
end, All).
count_actions_by_admin(AdminId, Action) ->
Match = #admin_audit{admin_id = AdminId, action = Action, _ = '_'},
length(mnesia:dirty_match_object(Match)).

View File

@@ -4,7 +4,7 @@
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1,
update/2, delete/1, materialize_occurrence/3]).
-export([generate_id/0]).
-export([count_events/0]).
-export([count_events/0, count_events_by_date/2]).
%% Создание одиночного события
create(CalendarId, Title, StartTime, Duration) ->
@@ -171,6 +171,22 @@ delete(Id) ->
count_events() ->
mnesia:table_info(event, size).
count_events_by_date(From, To) ->
All = mnesia:dirty_match_object(#event{_ = '_'}),
Filtered = lists:filter(fun(E) ->
E#event.created_at >= From andalso E#event.created_at =< To
end, All),
Counts = lists:foldl(fun(E, Acc) ->
Day = date_part(E#event.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).

View File

@@ -5,6 +5,7 @@
-export([update_status/3, get_count_by_target/2]).
-export([generate_id/0]).
-export([count_reports_by_status/1, count_reports_by_admin/2]).
-export([count_reports_resolved_by_admin/2, avg_resolution_time/1]).
%% Создание жалобы
create(ReporterId, TargetType, TargetId, Reason) ->
@@ -92,6 +93,22 @@ count_reports_by_admin(AdminId, Status) ->
Match = #report{resolved_by = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
count_reports_resolved_by_admin(AdminId, Status) ->
Match = #report{resolved_by = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
avg_resolution_time(Status) ->
Match = #report{status = Status, _ = '_'},
Reports = mnesia:dirty_match_object(Match),
Resolved = lists:filter(fun(R) -> R#report.resolved_at =/= undefined end, Reports),
case Resolved of
[] -> 0;
_ ->
TotalSeconds = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) -
calendar:datetime_to_gregorian_seconds(R#report.created_at) || R <- Resolved]),
TotalSeconds / length(Resolved) / 3600.0
end.
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).

View File

@@ -8,6 +8,7 @@
create_ticket/1,
list_by_user/1]).
-export([count_tickets_by_status/1, count_tickets_by_admin/2]).
-export([avg_resolution_time/0]).
list_all() ->
mnesia:dirty_match_object(#ticket{_ = '_'}).
@@ -69,6 +70,27 @@ create_ticket(Data) ->
list_by_user(UserId) ->
mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}).
count_by_status(Status, Tickets) ->
length([T || T <- Tickets, T#ticket.status =:= Status]).
count_tickets_by_status(Status) ->
Match = #ticket{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
count_tickets_by_admin(AdminId, Status) ->
Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
avg_resolution_time() ->
Tickets = mnesia:dirty_match_object(#ticket{status = closed, _ = '_'}),
case Tickets of
[] -> 0;
_ ->
TotalSeconds = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.last_seen) -
calendar:datetime_to_gregorian_seconds(T#ticket.first_seen) || T <- Tickets]),
TotalSeconds / length(Tickets) / 3600.0
end.
%% ── внутренние ─────────────────────────────────────────
apply_updates(Ticket, Updates) ->
lists:foldl(fun({Key, Value}, Acc) ->
@@ -81,15 +103,4 @@ apply_updates(Ticket, Updates) ->
<<"context">> -> Acc#ticket{context = Value};
_ -> Acc
end
end, Ticket, maps:to_list(Updates)).
count_by_status(Status, Tickets) ->
length([T || T <- Tickets, T#ticket.status =:= Status]).
count_tickets_by_status(Status) ->
Match = #ticket{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
count_tickets_by_admin(AdminId, Status) ->
Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
end, Ticket, maps:to_list(Updates)).

View File

@@ -6,7 +6,7 @@
-export([generate_id/0]).
-export([list_users/0]).
-export([block/1, unblock/1]).
-export([count_users/0]).
-export([count_users/0, count_users_by_date/2]).
%% Создание пользователя
create(Email, Password) ->
@@ -126,6 +126,22 @@ unblock(Id) ->
count_users() ->
mnesia:table_info(user, size).
count_users_by_date(From, To) ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Filtered = lists:filter(fun(U) ->
U#user.created_at >= From andalso U#user.created_at =< To
end, All),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).

View File

@@ -39,12 +39,9 @@ create_admin(Req) ->
Role = binary_to_atom(RoleBin, utf8),
case core_admin:create(Email, Password, Role) of
{ok, Admin} ->
% Заглушка отправки email
% send_invitation_email(Email),
core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role,
<<"create_admin">>, <<"admin">>, Admin,
admin_utils:client_ip(Req)),
send_json(Req2, 201, admin_to_json(Admin));
{error, email_exists} ->
send_error(Req2, 409, <<"Email already exists">>);
{error, Reason} ->
send_error(Req2, 500, Reason)
end;

View File

@@ -14,7 +14,14 @@ get_stats(Req) ->
case admin_utils:is_admin(AdminId) of
true ->
{ok, Admin} = core_admin:get_by_id(AdminId),
Stats = logic_stats:get_stats(Admin#admin.role, AdminId),
Role = Admin#admin.role,
% Извлекаем параметры from и to из запроса
Stats = case parse_date_range(Req1) of
{ok, From, To} ->
logic_stats:get_stats(Role, AdminId, From, To);
_ ->
logic_stats:get_stats(Role, AdminId)
end,
send_json(Req1, 200, Stats);
false ->
send_error(Req1, 403, <<"Admin access required">>)
@@ -23,6 +30,28 @@ get_stats(Req) ->
send_error(Req1, Code, Message)
end.
parse_date_range(Req) ->
Qs = cowboy_req:parse_qs(Req),
From = proplists:get_value(<<"from">>, Qs),
To = proplists:get_value(<<"to">>, Qs),
case {From, To} of
{undefined, _} -> error;
{_, undefined} -> error;
{F, T} ->
try
FromDT = iso8601_to_datetime(F),
ToDT = iso8601_to_datetime(T),
{ok, FromDT, ToDT}
catch _:_ -> error
end
end.
iso8601_to_datetime(Str) ->
[Date, Time] = binary:split(Str, <<"T">>),
[Y, M, D] = [binary_to_integer(X) || X <- binary:split(Date, <<"-">>, [global])],
[H, Min, S] = [binary_to_integer(X) || X <- binary:split(Time, <<":">>, [global])],
{{Y, M, D}, {H, Min, S}}.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),

View File

@@ -1,29 +1,82 @@
-module(logic_stats).
-export([get_stats/2]).
-export([get_stats/2, get_stats/4]).
-include("records.hrl").
%% ========== Точка входа (без дат) =============================
-spec get_stats(Role :: atom(), AdminId :: binary()) -> map().
get_stats(superadmin, _AdminId) ->
get_stats(Role, AdminId) ->
{{Y, _, _}, _} = calendar:universal_time(),
From = {{Y, 1, 1}, {0, 0, 0}}, % начало текущего года
To = calendar:universal_time(),
get_stats(Role, AdminId, From, To).
%% ========== Точка входа (с фильтром по датам) =================
-spec get_stats(Role :: atom(), AdminId :: binary(),
From :: calendar:datetime(), To :: calendar:datetime()) -> map().
get_stats(superadmin, _AdminId, From, To) ->
build_superadmin_stats(From, To);
get_stats(moderator, AdminId, From, To) ->
build_moderator_stats(AdminId, From, To);
get_stats(support, AdminId, From, To) ->
build_support_stats(AdminId, From, To);
get_stats(_, _, _, _) ->
#{}.
%% ========== Суперадмин =========================================
build_superadmin_stats(From, To) ->
#{
users => core_user:count_users(),
calendars => core_calendar:count_calendars(),
events => core_event:count_events(),
bookings => core_booking:count_bookings(),
reviews => core_review:count_reviews(),
reports_total => core_report:count_reports_by_status(pending),
tickets_open => core_ticket:count_tickets_by_status(open),
subscriptions => core_subscription:count_subscription()
};
get_stats(moderator, AdminId) ->
users_total => core_user:count_users(),
events_total => core_event:count_events(),
calendars_total => core_calendar:count_calendars(),
reviews_total => core_review:count_reviews(),
reports_total => core_report:count_reports_by_status(pending),
tickets_open => core_ticket:count_tickets_by_status(open),
tickets_total => length(core_ticket:list_all()),
avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()),
registrations_by_day => date_list_to_json(core_user:count_users_by_date(From, To)),
events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)),
admin_activity => collect_admin_activity()
}.
%% ========== Модератор ==========================================
build_moderator_stats(AdminId, _From, _To) ->
#{
reports_reviewed => core_report:count_reports_by_admin(AdminId, reviewed),
events_moderated => 0 % пока заглушка, можно добавить позже
};
get_stats(support, AdminId) ->
reports_reviewed => core_report:count_reports_resolved_by_admin(AdminId, reviewed),
reports_dismissed => core_report:count_reports_resolved_by_admin(AdminId, dismissed),
avg_report_resolution_h => trunc_hours(core_report:avg_resolution_time(reviewed)),
events_moderated => 0 % заглушка, можно доработать
}.
%% ========== Поддержка ==========================================
build_support_stats(AdminId, _From, _To) ->
#{
tickets_assigned => core_ticket:count_tickets_by_admin(AdminId, open),
reports_pending => core_report:count_reports_by_status(pending)
};
get_stats(_, _) ->
#{}.
tickets_assigned_open => core_ticket:count_tickets_by_admin(AdminId, open),
tickets_assigned_total => core_ticket:count_tickets_by_admin(AdminId, closed) +
core_ticket:count_tickets_by_admin(AdminId, in_progress),
reports_pending => core_report:count_reports_by_status(pending)
}.
%% ========== Вспомогательные функции ============================
date_list_to_json(List) ->
[ #{<<"date">> => iso8601_date(Date), <<"count">> => Count} || {Date, Count} <- List ].
iso8601_date({{Y, M, D}, _}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D]));
iso8601_date({Y, M, D}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D])).
trunc_hours(Value) ->
round(Value * 10) / 10.0.
collect_admin_activity() ->
Admins = core_admin:list_all(),
lists:map(fun(A) ->
Actions = length(core_admin_audit:list([{admin_id, A#admin.id}])),
#{
admin_id => A#admin.id,
email => A#admin.email,
actions => Actions
}
end, Admins).

View File

@@ -1,4 +1,6 @@
-module(api_admin_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
test() ->
@@ -25,17 +27,23 @@ test() ->
%% TEST 3: Admin stats (superadmin)
io:format(" TEST 3: Admin stats (superadmin)... "),
% Логинимся под суперадмином (данные из api_test_runner)
LoginBody = jsx:encode(#{<<"email">> => <<"admin@eventhub.local">>, <<"password">> => <<"123456">>}),
{ok, {{_, 200, _}, _, LoginResp}} = httpc:request(post,
{AdminURL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []),
#{<<"token">> := SuperToken} = jsx:decode(list_to_binary(LoginResp), [return_maps]),
% Запрашиваем статистику
{ok, {{_, 200, _}, _, StatsResp}} = httpc:request(get,
% Без дат
{ok, {{_, 200, _}, _, StatsResp1}} = httpc:request(get,
{AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(SuperToken)}]}, [], []),
Stats = jsx:decode(list_to_binary(StatsResp), [return_maps]),
io:format(" OK (keys: ~p)~n", [maps:keys(Stats)]),
Stats1 = jsx:decode(list_to_binary(StatsResp1), [return_maps]),
io:format(" OK (keys: ~p)~n", [maps:keys(Stats1)]),
% С датами
{ok, {{_, 200, _}, _, StatsResp2}} = httpc:request(get,
{AdminURL ++ "/v1/admin/stats?from=2026-01-01T00:00:00&to=2026-12-31T23:59:59",
[{"Authorization", "Bearer " ++ binary_to_list(SuperToken)}]}, [], []),
Stats2 = jsx:decode(list_to_binary(StatsResp2), [return_maps]),
io:format(" (with dates, keys: ~p)~n", [maps:keys(Stats2)]),
%% TEST 4: List users
io:format(" TEST 4: List users... "),

View File

@@ -22,15 +22,17 @@ cleanup(_) ->
admin_stats_test_() ->
{setup, fun setup/0, fun cleanup/1, [
{"GET /admin/stats as superadmin returns 200 with system metrics", fun test_superadmin/0},
{"GET /admin/stats as superadmin with date filter", fun test_superadmin_dates/0},
{"GET /admin/stats as moderator returns 200 with own metrics", fun test_moderator/0},
{"GET /admin/stats as support returns 200 with assigned tickets", fun test_support/0},
{"GET /admin/stats with nonadmin token returns 403", fun test_forbidden/0},
{"POST /admin/stats returns 405", fun test_wrong_method/0}
]}.
%% --- Суперадмин ---
%% --- Суперадмин (без дат) ---
test_superadmin() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(cowboy_req, parse_qs, fun(_) -> [] end),
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
ok = meck:expect(admin_utils, is_admin, fun(_) -> true end),
@@ -41,9 +43,26 @@ test_superadmin() ->
{ok, _, _} = admin_handler_stats:init(req, []),
?assertEqual(200, erase(test_reply)).
%% --- Суперадмин (с датами) ---
test_superadmin_dates() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(cowboy_req, parse_qs, fun(_) ->
[{<<"from">>, <<"2026-01-01T00:00:00">>}, {<<"to">>, <<"2026-06-01T00:00:00">>}]
end),
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
ok = meck:expect(admin_utils, is_admin, fun(_) -> true end),
ok = meck:expect(core_admin, get_by_id,
fun(<<"adm1">>) -> {ok, #admin{id = <<"adm1">>, role = superadmin}} end),
ok = meck:expect(logic_stats, get_stats,
fun(superadmin, _, _, _) -> #{users => 10} end),
{ok, _, _} = admin_handler_stats:init(req, []),
?assertEqual(200, erase(test_reply)).
%% --- Модератор ---
test_moderator() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(cowboy_req, parse_qs, fun(_) -> [] end),
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"mod1">>, Req} end),
ok = meck:expect(admin_utils, is_admin, fun(_) -> true end),
@@ -57,6 +76,7 @@ test_moderator() ->
%% --- Поддержка ---
test_support() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(cowboy_req, parse_qs, fun(_) -> [] end),
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"sup1">>, Req} end),
ok = meck:expect(admin_utils, is_admin, fun(_) -> true end),