Раздел api Календари #22

This commit is contained in:
2026-05-25 17:50:57 +03:00
parent 0de375c499
commit c2d2e934d9
8 changed files with 635 additions and 5 deletions
+14 -2
View File
@@ -1,7 +1,7 @@
-module(core_calendar).
-include("records.hrl").
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]).
-export([count_calendars/0]).
-export([count_calendars/0, list_all/0]).
-export([freeze/2, unfreeze/2]). % ← новые функции
%% Создание календаря
@@ -86,6 +86,13 @@ delete(Id) ->
count_calendars() ->
mnesia:table_info(calendar, size).
-spec list_all() -> [#calendar{}].
list_all() ->
{atomic, Calendars} = mnesia:transaction(fun() ->
mnesia:foldl(fun(C, Acc) -> [C | Acc] end, [], calendar)
end),
Calendars.
%% ── НОВЫЕ ФУНКЦИИ ──────────────────────────────────────────
freeze(Id, Reason) ->
update(Id, [{status, frozen}, {reason, Reason}]).
@@ -101,11 +108,16 @@ apply_updates(Calendar, Updates) ->
set_field(title, Value, C) -> C#calendar{title = Value};
set_field(description, Value, C) -> C#calendar{description = Value};
set_field(short_name, Value, C) -> C#calendar{short_name = Value};
set_field(category, Value, C) -> C#calendar{category = Value};
set_field(color, Value, C) -> C#calendar{color = Value};
set_field(image_url, Value, C) -> C#calendar{image_url = Value};
set_field(settings, Value, C) -> C#calendar{settings = Value};
set_field(tags, Value, C) -> C#calendar{tags = Value};
set_field(type, Value, C) -> C#calendar{type = Value};
set_field(confirmation, Value, C) -> C#calendar{confirmation = Value};
set_field(status, Value, C) -> C#calendar{status = Value};
set_field(rating_avg, Value, C) -> C#calendar{rating_avg = Value};
set_field(rating_count, Value, C) -> C#calendar{rating_count = Value};
set_field(reason, Value, C) -> C#calendar{reason = Value}; % ← поддержка поля reason
set_field(reason, Value, C) -> C#calendar{reason = Value};
set_field(_, _, C) -> C.
+3
View File
@@ -113,6 +113,9 @@ start_admin_http() ->
% ================== ПОЛЬЗОВАТЕЛИ ==================
{"/v1/admin/users", admin_handler_users, []},
{"/v1/admin/users/:id", admin_handler_user_by_id, []},
% ================== КАЛЕНДАРИ ==================
{"/v1/admin/calendars", admin_handler_calendars, []},
{"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []},
% ================== СОБЫТИЯ ==================
{"/v1/admin/events", admin_handler_events, []},
{"/v1/admin/events/:id", admin_handler_event_by_id, []},
@@ -0,0 +1,139 @@
-module(admin_handler_calendar_by_id).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_calendar(Req);
<<"PUT">> -> update_calendar(Req);
<<"DELETE">> -> delete_calendar(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger
-spec trails() -> [map()].
trails() ->
Path = <<"/v1/admin/calendars/{id}">>,
[
#{
path => Path,
method => <<"GET">>,
description => <<"Get calendar by ID (admin)">>,
tags => [<<"Admin Calendars">>],
parameters => [id_param()],
responses => #{
200 => #{description => <<"Calendar object">>, content => calendar_content()},
404 => #{description => <<"Not found">>}
}
},
#{
path => Path,
method => <<"PUT">>,
description => <<"Update calendar (admin)">>,
tags => [<<"Admin Calendars">>],
parameters => [id_param()],
requestBody => update_body(),
responses => #{
200 => #{description => <<"Updated calendar">>, content => calendar_content()},
400 => #{description => <<"Bad request">>},
404 => #{description => <<"Not found">>}
}
},
#{
path => Path,
method => <<"DELETE">>,
description => <<"Delete calendar (admin)">>,
tags => [<<"Admin Calendars">>],
parameters => [id_param()],
responses => #{
200 => #{description => <<"Deleted">>},
404 => #{description => <<"Not found">>}
}
}
].
id_param() -> #{name => id, in => path, required => true, schema => #{type => string}}.
calendar_content() -> #{<<"application/json">> => #{schema => admin_handler_calendars:calendar_schema()}}.
update_body() ->
#{
required => true,
content => #{
<<"application/json">> => #{
schema => #{
type => object,
properties => #{
title => #{type => string},
description => #{type => string},
is_public => #{type => boolean}
}
}
}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
-spec get_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_calendar(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1),
case logic_calendar:admin_get_by_id(Id) of
{ok, Calendar} ->
Json = handler_utils:calendar_to_json(Calendar),
handler_utils:send_json(Req1, 200, Json);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
-spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_calendar(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1),
case cowboy_req:read_body(Req1) of
{ok, Body, Req2} ->
try jsx:decode(Body, [return_maps]) of
Updates ->
case logic_calendar:admin_update(Id, Updates) of
{ok, Updated} ->
Json = handler_utils:calendar_to_json(Updated),
handler_utils:send_json(Req2, 200, Json);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, _} ->
handler_utils:send_error(Req2, 400, <<"Update failed">>)
end
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, _} ->
handler_utils:send_error(Req1, 400, <<"Missing body">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
-spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_calendar(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1),
case logic_calendar:admin_delete(Id) of
ok ->
handler_utils:send_json(Req1, 200, #{});
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
@@ -0,0 +1,193 @@
-module(admin_handler_calendars).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-export([calendar_schema/0]). % для использования в admin_handler_calendar_by_id
-include("records.hrl").
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_calendars(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
trails() ->
[
#{
path => <<"/v1/admin/calendars">>,
method => <<"GET">>,
description => <<"List all calendars with filtering, sorting, pagination (admin)">>,
tags => [<<"Admin Calendars">>],
parameters => [
#{name => limit, in => query, schema => #{type => integer}, description => <<"Limit">>},
#{name => offset, in => query, schema => #{type => integer}, description => <<"Offset">>},
#{name => status, in => query, schema => #{type => string}, description => <<"Filter by status (active, frozen, deleted)">>},
#{name => type, in => query, schema => #{type => string}, description => <<"Filter by type (personal, commercial)">>},
#{name => q, in => query, schema => #{type => string}, description => <<"Search query">>},
#{name => sort_by, in => query, schema => #{type => string, enum => [<<"title">>, <<"created_at">>, <<"updated_at">>]}, description => <<"Sort field">>},
#{name => sort_order, in => query, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>}
],
responses => #{
200 => #{
description => <<"Array of calendars">>,
content => #{
<<"application/json">> => #{
schema => #{
type => array,
items => calendar_schema()
}
}
}
},
403 => #{description => <<"Forbidden">>}
}
}
].
calendar_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
owner_id => #{type => string},
title => #{type => string},
description => #{type => string},
short_name => #{type => string},
category => #{type => string},
color => #{type => string},
image_url => #{type => string},
settings => #{type => object},
tags => #{type => array, items => #{type => string}},
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
confirmation => #{type => string},
rating_avg => #{type => number},
rating_count => #{type => integer},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string},
created_at => #{type => string, format => date_time},
updated_at => #{type => string, format => date_time}
}
}.
%%%===================================================================
%%% HTTP-метод
%%%===================================================================
list_calendars(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
% Парсим параметры
Qs = cowboy_req:parse_qs(Req1),
Filters = parse_filters(Qs),
Sort = parse_sort(Qs),
Pagination = handler_utils:parse_pagination_params(Req1),
% Получаем все календари
{ok, AllCalendars} = logic_calendar:admin_list_all(),
% Применяем фильтры
Filtered = apply_filters(AllCalendars, Filters),
% Сортируем
Sorted = apply_sort(Filtered, Sort),
% Пагинация
Total = length(Sorted),
Start = maps:get(offset, Pagination) + 1,
Limit = maps:get(limit, Pagination),
Page = lists:sublist(Sorted, Start, Limit),
Json = [handler_utils:calendar_to_json(C) || C <- Page],
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
%%%===================================================================
%%% Парсинг параметров
%%%===================================================================
parse_filters(Qs) ->
Status = proplists:get_value(<<"status">>, Qs, undefined),
Type = proplists:get_value(<<"type">>, Qs, undefined),
Query = proplists:get_value(<<"q">>, Qs, undefined),
#{
status => safe_to_atom(Status),
type => safe_to_atom(Type),
q => Query
}.
safe_to_atom(undefined) -> undefined;
safe_to_atom(Bin) when is_binary(Bin) ->
try binary_to_existing_atom(Bin, utf8)
catch error:badarg -> undefined
end.
parse_sort(Qs) ->
SortBy = proplists:get_value(<<"sort_by">>, Qs, <<"created_at">>),
SortOrder = proplists:get_value(<<"sort_order">>, Qs, <<"desc">>),
#{
sort_by => SortBy,
sort_order => SortOrder
}.
%%%===================================================================
%%% Фильтрация
%%%===================================================================
apply_filters(Calendars, Filters) ->
StatusFilter = maps:get(status, Filters, undefined),
TypeFilter = maps:get(type, Filters, undefined),
QueryFilter = maps:get(q, Filters, undefined),
F1 = case StatusFilter of
undefined -> Calendars;
Status -> [C || C <- Calendars, C#calendar.status =:= Status]
end,
F2 = case TypeFilter of
undefined -> F1;
Type -> [C || C <- F1, C#calendar.type =:= Type]
end,
case QueryFilter of
undefined -> F2;
Q ->
LowerQ = string:lowercase(binary_to_list(Q)),
[C || C <- F2,
string:str(string:lowercase(binary_to_list(C#calendar.title)), LowerQ) > 0 orelse
string:str(string:lowercase(binary_to_list(C#calendar.description)), LowerQ) > 0]
end.
%%%===================================================================
%%% Сортировка
%%%===================================================================
apply_sort(Calendars, #{sort_by := SortBy, sort_order := SortOrder}) ->
Field = binary_to_sort_field(SortBy),
Order = case SortOrder of
<<"asc">> -> fun(A, B) -> A < B end;
<<"desc">> -> fun(A, B) -> A > B end;
_ -> fun(A, B) -> A >= B end % default desc
end,
lists:sort(fun(A, B) ->
ValA = get_field(A, Field),
ValB = get_field(B, Field),
case {ValA, ValB} of
{undefined, _} -> false;
{_, undefined} -> true;
_ -> Order(ValA, ValB)
end
end, Calendars).
binary_to_sort_field(<<"title">>) -> title;
binary_to_sort_field(<<"created_at">>) -> created_at;
binary_to_sort_field(<<"updated_at">>) -> updated_at;
binary_to_sort_field(_) -> created_at.
get_field(#calendar{title = Title}, title) -> Title;
get_field(#calendar{created_at = CreatedAt}, created_at) -> CreatedAt;
get_field(#calendar{updated_at = UpdatedAt}, updated_at) -> UpdatedAt;
get_field(_, _) -> undefined.
+15 -1
View File
@@ -26,7 +26,9 @@
calendar_to_json/1,
subscription_to_json/1,
trails_for_crud/4,
is_superadmin/1]).
is_superadmin/1,
pagination_headers/2
]).
-include("records.hrl").
@@ -118,6 +120,18 @@ parse_int_qs(undefined, Default) -> Default;
parse_int_qs(Bin, Default) ->
try binary_to_integer(Bin) catch _:_ -> Default end.
-spec pagination_headers(map(), non_neg_integer()) -> map().
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
NextOffset = Offset + Limit,
HasMore = NextOffset < Total,
#{
<<"x-total-count">> => integer_to_binary(Total),
<<"x-next-offset">> => case HasMore of
true -> integer_to_binary(NextOffset);
false -> <<"">>
end
}.
%% @doc Преобразует бинарный ISO8601 параметр в datetime().
-spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined.
parse_datetime_qs(undefined) -> undefined;
+50 -1
View File
@@ -4,6 +4,7 @@
-export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1,
update_calendar/3, delete_calendar/2]).
-export([can_access/2, can_edit/2]).
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
%% Создание календаря с политикой по умолчанию (manual)
create_calendar(UserId, Title, Description) ->
@@ -113,4 +114,52 @@ validate_update({confirmation, Value}) ->
{timeout, N} when is_integer(N), N > 0 -> true;
_ -> false
end;
validate_update(_) -> false.
validate_update(_) -> false.
%% ─── Административные функции ────────────────────────────────────────
-spec admin_list_all() -> {ok, [map()]} | {error, term()}.
admin_list_all() ->
Calendars = core_calendar:list_all(),
{ok, Calendars}.
-spec admin_get_by_id(binary()) -> {ok, #calendar{}} | {error, not_found}.
admin_get_by_id(CalendarId) ->
core_calendar:get_by_id(CalendarId).
-spec admin_update(binary(), map()) -> {ok, #calendar{}} | {error, term()}.
admin_update(CalendarId, Updates) when is_map(Updates) ->
%% Преобразуем map в список {атом, значение}, исключая служебные ключи
TupleList = maps:fold(fun key_to_update/3, [], Updates),
core_calendar:update(CalendarId, TupleList).
-spec admin_delete(binary()) -> ok | {error, term()}.
admin_delete(Id) ->
case core_calendar:delete(Id) of
{ok, _} -> ok;
Error -> Error
end.
key_to_update(<<"title">>, V, Acc) -> [{title, V} | Acc];
key_to_update(<<"description">>, V, Acc) -> [{description, V} | Acc];
key_to_update(<<"short_name">>, V, Acc) -> [{short_name, V} | Acc];
key_to_update(<<"category">>, V, Acc) -> [{category, V} | Acc];
key_to_update(<<"color">>, V, Acc) -> [{color, V} | Acc];
key_to_update(<<"image_url">>, V, Acc) -> [{image_url, V} | Acc];
key_to_update(<<"settings">>, V, Acc) -> [{settings, V} | Acc];
key_to_update(<<"tags">>, V, Acc) -> [{tags, V} | Acc];
key_to_update(<<"type">>, V, Acc) ->
Type = binary_to_existing_atom(V, utf8),
[{type, Type} | Acc];
key_to_update(<<"status">>, V, Acc) ->
Status = binary_to_existing_atom(V, utf8),
[{status, Status} | Acc];
key_to_update(<<"confirmation">>, V, Acc) ->
% confirmation может быть строкой "auto", "manual" или "timeout:N"
Conf = case V of
<<"auto">> -> auto;
<<"manual">> -> manual;
_ -> V % для сложных значений пока оставляем как есть
end,
[{confirmation, Conf} | Acc];
key_to_update(<<"reason">>, V, Acc) -> [{reason, V} | Acc];
key_to_update(_, _V, Acc) -> Acc.