diff --git a/src/core/core_admin.erl b/src/core/core_admin.erl index adeb89a..4a6bf1e 100644 --- a/src/core/core_admin.erl +++ b/src/core/core_admin.erl @@ -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, _ = '_'}, diff --git a/src/core/core_admin_audit.erl b/src/core/core_admin_audit.erl index f9ca0b4..af5c24c 100644 --- a/src/core/core_admin_audit.erl +++ b/src/core/core_admin_audit.erl @@ -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). \ No newline at end of file + 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)). \ No newline at end of file diff --git a/src/core/core_event.erl b/src/core/core_event.erl index cd1d515..72d1175 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -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}). diff --git a/src/core/core_report.erl b/src/core/core_report.erl index 3e3c2a4..411c973 100644 --- a/src/core/core_report.erl +++ b/src/core/core_report.erl @@ -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}). \ No newline at end of file diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index a52303a..14b05fa 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -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)). \ No newline at end of file + end, Ticket, maps:to_list(Updates)). \ No newline at end of file diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 67da224..7badaca 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -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}). diff --git a/src/handlers/admin/admin_handler_admins.erl b/src/handlers/admin/admin_handler_admins.erl index 728baef..6e5ee54 100644 --- a/src/handlers/admin/admin_handler_admins.erl +++ b/src/handlers/admin/admin_handler_admins.erl @@ -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; diff --git a/src/handlers/admin/admin_handler_stats.erl b/src/handlers/admin/admin_handler_stats.erl index d67cf0b..0cac589 100644 --- a/src/handlers/admin/admin_handler_stats.erl +++ b/src/handlers/admin/admin_handler_stats.erl @@ -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), diff --git a/src/logic/logic_stats.erl b/src/logic/logic_stats.erl index c56e39d..fdc762a 100644 --- a/src/logic/logic_stats.erl +++ b/src/logic/logic_stats.erl @@ -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(_, _) -> - #{}. \ No newline at end of file + 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). \ No newline at end of file diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl index 4bac0f5..9c95db7 100644 --- a/test/api/api_admin_tests.erl +++ b/test/api/api_admin_tests.erl @@ -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... "), diff --git a/test/unit/admin_handler_stats_tests.erl b/test/unit/admin_handler_stats_tests.erl index b0220b4..c7c23bd 100644 --- a/test/unit/admin_handler_stats_tests.erl +++ b/test/unit/admin_handler_stats_tests.erl @@ -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 non‑admin 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),