Добавить автомодерацию: политики, порог жалоб, settings/hits API и тесты.
Refs EventHub/EventHubBack#37 Refs EventHub/EventHubBack#38 Refs EventHub/EventHubBack#39 Refs EventHub/EventHubBack#40
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Admin: настройки автомодерации и очередь hits.
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_automod).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, _Opts) ->
|
||||
Path = cowboy_req:path(Req),
|
||||
Method = cowboy_req:method(Req),
|
||||
case {Method, Path} of
|
||||
{<<"GET">>, <<"/v1/admin/automod/settings">>} -> get_settings(Req);
|
||||
{<<"PUT">>, <<"/v1/admin/automod/settings">>} -> put_settings(Req);
|
||||
{<<"GET">>, <<"/v1/admin/automod/hits">>} -> list_hits(Req);
|
||||
{<<"PUT">>, _} ->
|
||||
case cowboy_req:binding(id, Req) of
|
||||
undefined -> handler_utils:send_error(Req, 404, <<"Not found">>);
|
||||
Id -> resolve_hit(Req, Id)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{path => <<"/v1/admin/automod/settings">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get automoderation settings">>,
|
||||
tags => [<<"Automoderation">>],
|
||||
responses => #{200 => #{description => <<"Settings">>}}},
|
||||
#{path => <<"/v1/admin/automod/settings">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update automoderation settings">>,
|
||||
tags => [<<"Automoderation">>],
|
||||
responses => #{200 => #{description => <<"Updated">>}, 403 => #{description => <<"Forbidden">>}}},
|
||||
#{path => <<"/v1/admin/automod/hits">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List automoderation hits">>,
|
||||
tags => [<<"Automoderation">>],
|
||||
responses => #{200 => #{description => <<"Hits">>}}},
|
||||
#{path => <<"/v1/admin/automod/hits/{id}">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Approve or reject an automod hit">>,
|
||||
tags => [<<"Automoderation">>],
|
||||
responses => #{200 => #{description => <<"Resolved">>}, 404 => #{description => <<"Not found">>}}}
|
||||
].
|
||||
|
||||
get_settings(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:check_role(AdminId, [superadmin, admin, moderator]) of
|
||||
true ->
|
||||
{ok, S} = logic_automoderation:get_settings(),
|
||||
handler_utils:send_json(Req1, 200, logic_automoderation:settings_to_json(S));
|
||||
false ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
put_settings(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
Updates = parse_settings(Map),
|
||||
case logic_automoderation:update_settings(AdminId, Updates) of
|
||||
{ok, S} ->
|
||||
admin_utils:log_admin_action(AdminId, <<"update">>, <<"automod_settings">>,
|
||||
<<"default">>, <<>>, Req2),
|
||||
handler_utils:send_json(Req2, 200, logic_automoderation:settings_to_json(S));
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, {invalid_field, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid settings">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
list_hits(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
Qs = cowboy_req:parse_qs(Req1),
|
||||
Filters = lists:filtermap(fun
|
||||
({<<"status">>, <<"open">>}) -> {true, {status, open}};
|
||||
({<<"status">>, <<"approved">>}) -> {true, {status, approved}};
|
||||
({<<"status">>, <<"rejected">>}) -> {true, {status, rejected}};
|
||||
({<<"trigger">>, <<"keyword">>}) -> {true, {trigger, keyword}};
|
||||
({<<"trigger">>, <<"report_threshold">>}) -> {true, {trigger, report_threshold}};
|
||||
(_) -> false
|
||||
end, Qs),
|
||||
case logic_automoderation:list_hits(AdminId, Filters) of
|
||||
{ok, Hits} ->
|
||||
Sorted = lists:reverse(lists:keysort(#automod_hit.created_at, Hits)),
|
||||
handler_utils:send_json(Req1, 200, [logic_automoderation:hit_to_json(H) || H <- Sorted]);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
resolve_hit(Req, Id) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"status">> := StatusBin} ->
|
||||
Decision = case StatusBin of
|
||||
<<"approved">> -> approved;
|
||||
<<"rejected">> -> rejected;
|
||||
_ -> invalid
|
||||
end,
|
||||
case Decision of
|
||||
invalid ->
|
||||
handler_utils:send_error(Req2, 400, <<"status must be approved or rejected">>);
|
||||
_ ->
|
||||
case logic_automoderation:resolve_hit(AdminId, Id, Decision) of
|
||||
{ok, Hit} ->
|
||||
admin_utils:log_admin_action(AdminId, StatusBin, <<"automod_hit">>, Id, <<>>, Req2),
|
||||
handler_utils:send_json(Req2, 200, logic_automoderation:hit_to_json(Hit));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Hit not found">>);
|
||||
{error, already_resolved} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Already resolved">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing status">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
parse_settings(Map) ->
|
||||
lists:filtermap(fun
|
||||
({<<"keyword_action">>, <<"reject">>}) -> {true, {keyword_action, reject}};
|
||||
({<<"keyword_action">>, <<"flag">>}) -> {true, {keyword_action, flag}};
|
||||
({<<"keyword_action">>, <<"censor">>}) -> {true, {keyword_action, censor}};
|
||||
({<<"match_mode">>, <<"word_boundary">>}) -> {true, {match_mode, word_boundary}};
|
||||
({<<"match_mode">>, <<"substring">>}) -> {true, {match_mode, substring}};
|
||||
({<<"report_threshold">>, V}) when is_integer(V) ->
|
||||
{true, {report_threshold, V}};
|
||||
(_) -> false
|
||||
end, maps:to_list(Map)).
|
||||
@@ -136,6 +136,8 @@ create_calendar(Req) ->
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, user_inactive} ->
|
||||
handler_utils:send_error(Req2, 403, <<"User account is not active">>);
|
||||
{error, {content_banned, _Words}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
|
||||
@@ -186,6 +186,8 @@ update_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
|
||||
@@ -171,11 +171,12 @@ create_event(Req) ->
|
||||
case handler_utils:parse_datetime(StartTimeStr) of
|
||||
{ok, StartTime} ->
|
||||
Location = parse_location(maps:get(<<"location">>, Decoded, undefined)),
|
||||
case maps:get(<<"recurrence">>, Decoded, undefined) of
|
||||
Description = maps:get(<<"description">>, Decoded, <<>>),
|
||||
case maps:get(<<"recurrence">>, Decoded, undefined) of
|
||||
undefined ->
|
||||
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of
|
||||
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration, Description) of
|
||||
{ok, Event} ->
|
||||
update_event_fields(Event#event.id, Location, Decoded),
|
||||
update_event_fields(UserId, Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
@@ -185,13 +186,15 @@ create_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
RRule ->
|
||||
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of
|
||||
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) of
|
||||
{ok, Event} ->
|
||||
update_event_fields(Event#event.id, Location, Decoded),
|
||||
update_event_fields(UserId, Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
@@ -203,6 +206,8 @@ create_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end
|
||||
@@ -257,14 +262,21 @@ list_events(Req) ->
|
||||
%%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
|
||||
update_event_fields(EventId, Location, Decoded) ->
|
||||
update_event_fields(UserId, EventId, Location, Decoded) ->
|
||||
Updates = [],
|
||||
Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end,
|
||||
Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of undefined -> Updates1; Cap -> [{capacity, Cap} | Updates1] end,
|
||||
Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of undefined -> Updates2; Tags -> [{tags, Tags} | Updates2] end,
|
||||
Updates4 = case maps:get(<<"description">>, Decoded, undefined) of undefined -> Updates3; Desc -> [{description, Desc} | Updates3] end,
|
||||
Updates5 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates4; Link -> [{online_link, Link} | Updates4] end,
|
||||
if Updates5 /= [] -> core_event:update(EventId, Updates5); true -> ok end.
|
||||
%% description already applied in create_event/6 when present
|
||||
Updates4 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates3; Link -> [{online_link, Link} | Updates3] end,
|
||||
case Updates4 of
|
||||
[] -> ok;
|
||||
_ ->
|
||||
case logic_event:update_event(UserId, EventId, Updates4) of
|
||||
{ok, _} -> ok;
|
||||
_ -> ok
|
||||
end
|
||||
end.
|
||||
|
||||
parse_location(undefined) -> undefined;
|
||||
parse_location(LocationMap) when is_map(LocationMap) ->
|
||||
|
||||
@@ -33,7 +33,7 @@ trails() ->
|
||||
type => object,
|
||||
required => [<<"target_type">>, <<"target_id">>, <<"reason">>],
|
||||
properties => #{
|
||||
target_type => #{type => string, enum => [<<"event">>, <<"calendar">>]},
|
||||
target_type => #{type => string, enum => [<<"event">>, <<"calendar">>, <<"review">>]},
|
||||
target_id => #{type => string},
|
||||
reason => #{type => string}
|
||||
}
|
||||
@@ -106,14 +106,19 @@ create_report(Req) ->
|
||||
case Decoded of
|
||||
#{<<"target_type">> := TargetTypeBin, <<"target_id">> := TargetId, <<"reason">> := Reason} ->
|
||||
TargetType = parse_target_type(TargetTypeBin),
|
||||
case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of
|
||||
{ok, Report} ->
|
||||
Response = handler_utils:report_to_json(Report),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, target_not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Target not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
case TargetType of
|
||||
undefined ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid target_type">>);
|
||||
_ ->
|
||||
case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of
|
||||
{ok, Report} ->
|
||||
Response = handler_utils:report_to_json(Report),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, target_not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Target not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing required fields">>)
|
||||
@@ -161,7 +166,7 @@ list_reports(Req) ->
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Преобразует бинарное имя типа в атом.
|
||||
%% Поддерживаются только 'event' и 'calendar'.
|
||||
%% Поддерживаются 'event', 'calendar', 'review'.
|
||||
-spec parse_target_type(binary()) -> event | calendar | review | undefined.
|
||||
parse_target_type(<<"event">>) -> event;
|
||||
parse_target_type(<<"calendar">>) -> calendar;
|
||||
|
||||
@@ -138,6 +138,8 @@ create_review(Req) ->
|
||||
handler_utils:send_error(Req2, 403, <<"Cannot review this target">>);
|
||||
{error, target_not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Target not found">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
|
||||
Reference in New Issue
Block a user