src/models/m_config.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2020 Marc Worrell
%% @doc Model for the zotonic config table. Performs a fallback to the site configuration when
%% a key is not defined in the configuration table.

%% Copyright 2009-2020 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(m_config).
-author("Marc Worrell <marc@worrell.nl").

-behaviour(zotonic_model).

%% interface functions
-export([
    m_get/3,
    all/1,
    get/2,
    get/3,
    get_value/3,
    get_value/4,

    get_boolean/3,
    get_boolean/4,

    set_default_value/4,
    set_value/4,
    set_prop/5,

    delete/3,
    get_id/3
]).

-include_lib("zotonic.hrl").

%% @doc Fetch the value for the key from a model source
-spec m_get( list(), zotonic_model:opt_msg(), z:context()) -> zotonic_model:return().
m_get([], _Msg, Context) ->
    case z_acl:is_admin(Context) of
        true -> {ok, {all(Context), []}};
        false -> {error, eacces}
    end;
m_get([ Module ], _Msg, Context) ->
    case z_acl:is_admin(Context) of
        true -> {ok, {get(Module, Context), []}};
        false -> {error, eacces}
    end;
m_get([ Module, Key | Rest ], _Msg, Context) ->
    case z_acl:is_admin(Context) of
        true -> {ok, {get(Module, Key, Context), Rest}};
        false -> {error, eacces}
    end;
m_get(_Vs, _Msg, _Context) ->
    {error, unknown_path}.

%% @doc Return all configurations from the configuration table. Returns a nested proplist (module, key)
%% @todo Change proplists to maps
-spec all( z:context() ) -> list().
all(Context) ->
    case z_depcache:get(config, Context) of
        {ok, Cs} ->
            Cs;
        undefined ->
            Cs = case z_db:has_connection(Context) of
                     true ->
                        try
                            z_db:assoc_props("select * from config order by module, key", Context)
                        catch
                            %% When Zotonic has not yet been installed, there is no config table yet
                            _:_ -> []
                        end;
                     false ->
                        []
                 end,
            Indexed = [
                {M, z_utils:index_proplist(key, CMs)}
                || {M, CMs} <- z_utils:group_proplists(module, Cs)
            ],
            Indexed1 = [
                {z_convert:to_atom(M), [{z_convert:to_atom(K), Vs} || {K, Vs} <- CMs]}
                || {M, CMs} <- Indexed
            ],
            z_depcache:set(config, Indexed1, ?DAY, Context),
            Indexed1
    end.


%% @doc Get the list of configuration key for the module.
%%      Returns the empty list for non existing keys, otherwise
%%      a property list with all the module settings.
%% @todo Change proplists to maps.
-spec get( atom() | binary() | undefined, z:context() ) -> proplists:proplist().
get(undefined, _Context) ->
    [];
get(Module, Context) when is_atom(Module) ->
    ConfigProps = case z_depcache:get_subkey(config, Module, Context) of
        {ok, undefined} ->
            [];
        {ok, Cs} ->
            Cs;
        undefined ->
            All = all(Context),
            proplists:get_value(Module, All, [])
    end,
    case m_site:get(Module, Context) of
        L when is_list(L) -> z_convert:to_list(ConfigProps) ++ L;
        _ -> ConfigProps
    end;
get(Module, Context) when is_binary(Module) ->
    try
        Module1 = binary_to_existing_atom(Module, utf8),
        get(Module1, Context)
    catch
        error:badarg ->
            []
    end.

%% @doc Get a configuration value for the given module/key combination.
-spec get( atom() | binary(), atom() | binary(), z:context() ) -> proplists:proplist() | undefined.
get(zotonic, Key, _Context) when is_atom(Key) ->
    [
        {value, z_config:get(Key)}
    ];
get(Module, Key, Context) when is_atom(Module), is_atom(Key) ->
    Value = case z_depcache:get_subkey(config, Module, Context) of
        {ok, undefined} ->
            undefined;
        {ok, Cs} ->
            proplists:get_value(Key, Cs);
        undefined ->
            All = all(Context),
            case proplists:get_value(Module, All) of
                undefined -> undefined;
                Cs -> proplists:get_value(Key, Cs)
            end
    end,
    case Value of
        undefined ->
            case m_site:get(Module, Key, Context) of
                undefined -> undefined;
                [H|_] = V when is_tuple(H) -> [{list, V}];
                V -> [{value, V}]
            end;
        _ ->
            Value
    end;
get(Module, Key, Context) when is_binary(Module) ->
    try
        Module1 = binary_to_existing_atom(Module, utf8),
        get(Module1, Key, Context)
    catch
        error:badarg ->
            undefined
    end;
get(Module, Key, Context) when is_binary(Key) ->
    try
        Key1 = binary_to_existing_atom(Key, utf8),
        get(Module, Key1, Context)
    catch
        error:badarg ->
            undefined
    end.



-spec get_value( atom() | binary(), atom() | binary(), z:context() ) -> term() | undefined.
get_value(Module, Key, Context) when is_atom(Module), is_atom(Key) ->
    Value = case get(Module, Key, Context) of
        undefined -> undefined;
        Cfg -> proplists:get_value(value, Cfg)
    end,
    case Value of
        undefined -> m_site:get(Module, Key, Context);
        _ -> Value
    end;
get_value(Module, Key, Context) when is_binary(Module) ->
    try
        Module1 = binary_to_existing_atom(Module, utf8),
        get_value(Module1, Key, Context)
    catch
        error:badarg ->
            undefined
    end;
get_value(Module, Key, Context) when is_binary(Key) ->
    try
        Key1 = binary_to_existing_atom(Key, utf8),
        get_value(Module, Key1, Context)
    catch
        error:badarg ->
            undefined
    end.


-spec get_value( atom() | binary(), atom() | binary(), term(), z:context() ) -> term() | undefined.
get_value(Module, Key, Default, Context) ->
    case get_value(Module, Key, Context) of
        undefined -> Default;
        Value -> Value
    end.

-spec get_boolean( atom() | binary(), atom() | binary(), z:context() ) -> boolean().
get_boolean(Module, Key, Context) ->
    z_convert:to_bool(get_value(Module, Key, Context)).

-spec get_boolean( atom() | binary(), atom() | binary(), term(), z:context() ) -> boolean().
get_boolean(Module, Key, Default, Context) ->
    z_convert:to_bool(get_value(Module, Key, Default, Context)).


%% @doc Set the value of a config iff the current value is 'undefined'. Useful for initialization
%% of new module data schemas.
-spec set_default_value( atom() | binary(), atom() | binary(), string() | binary() | atom(), z:context() ) -> ok | {error, term()}.
set_default_value(Module, Key, Value, Context) ->
    case get_value(Module, Key, Context) of
        undefined ->
            set_value(Module, Key, Value, Context);
        _Value ->
            ok
    end.

%% @doc Set a "simple" config value.
-spec set_value( atom() | binary(), atom() | binary(), string() | binary() | atom(), z:context() ) -> ok | {error, term()}.
set_value(Module, Key, Value, Context) ->
    case z_db:has_connection(Context) of
        true ->
            set_value_db(Module, Key, Value, Context);
        false ->
            m_site:put(Module, Key, Value, Context),
            z_depcache:flush(config, Context),
            z_notifier:notify(#m_config_update{module = Module, key = Key, value = Value}, Context),
            ok
    end.

-spec set_value_db( atom() | binary(), atom() | binary(), string() | binary() | atom(), z:context() ) -> ok | {error, term()}.
set_value_db(Module, Key, Value0, Context) ->
    ModuleAtom = z_convert:to_atom(Module),
    KeyAtom = z_convert:to_atom(Key),
    Value = z_convert:to_binary(Value0),
    Result = z_db:transaction(
        fun(Ctx) ->
            case z_db:q1("
                select value
                from config
                where module = $1
                  and key = $2",
                [ Module, Key ],
                Ctx)
            of
                Value ->
                    no_change;
                undefined ->
                    Props = #{
                        <<"module">> => ModuleAtom,
                        <<"key">> => KeyAtom,
                        <<"value">> => Value
                    },
                    {ok, _} = z_db:insert(config, Props, Ctx),
                    insert;
                OldValue ->
                    1 = z_db:q("
                        update config
                        set value = $1,
                            modified = now()
                        where module = $2
                          and key = $3",
                        [ Value, ModuleAtom, KeyAtom ],
                        Ctx),
                    {update, OldValue}
            end
        end,
        Context),
    case Result of
        no_change ->
            ok;
        insert ->
            z_depcache:flush(config, Context),
            z_notifier:notify(#m_config_update{module=Module, key=Key, value=Value}, Context),
            z:info(
                "Configuration key '~p.~p' inserted, new value: '~s'",
                [ ModuleAtom, KeyAtom, Value ],
                [ {module, ?MODULE}, {line, ?LINE} ],
                Context),
            ok;
        {update, OldV} ->
            z_depcache:flush(config, Context),
            z_notifier:notify(#m_config_update{module=Module, key=Key, value=Value}, Context),
            z:info(
                "Configuration key '~p.~p' changed, new value: '~s', old value '~s'",
                [ ModuleAtom, KeyAtom, Value, OldV ],
                [ {module, ?MODULE}, {line, ?LINE} ],
                Context),
            ok;
        {rollback,{no_database_connection, _Trace}} ->
            {error, no_database_connection};
        {rollback, {error, _} = Error} ->
            Error;
        {rollback, Error} ->
            {error, Error}
    end.

%% @doc Set a "complex" config value.
-spec set_prop(atom()|binary(), atom()|binary(), atom()|binary(), term(), z:context()) -> ok | {error, term()}.
set_prop(Module, Key, Prop, PropValue, Context) ->
    Result = z_db:transaction(
        fun(Ctx) ->
            PropB = z_convert:to_binary(Prop),
            case z_db:qmap_props_row("
                select *
                from config
                where module = $1
                  and key = $2",
                [ Module, Key ],
                Ctx)
            of
                {error, enoent} ->
                    Ins = #{
                        <<"module">> => Module,
                        <<"key">> => Key,
                        z_convert:to_binary(Prop) => PropValue
                    },
                    z_db:insert(config, Ins, Ctx);
                {ok, #{ PropB := PropValue }} ->
                    {ok, no_change};
                {ok, #{ <<"id">> := Id } } ->
                    Upd = #{
                        PropB => PropValue
                    },
                    z_db:update(config, Id, Upd, Ctx)
            end
        end,
        Context),
    z_depcache:flush(config, Context),
    case Result of
        {ok, no_change} ->
            ok;
        {ok, _} ->
            z_notifier:notify(
                #m_config_update_prop{module = Module, key = Key, prop = Prop, value = PropValue},
                Context
            ),
            z:info(
                "Configuration key '~s.~s' changed, update property '~p' value: ~p",
                [ z_convert:to_binary(Module), z_convert:to_binary(Key), Prop, PropValue ],
                [ {module, ?MODULE}, {line, ?LINE} ],
                Context),
            ok;
        {error, _} = Error ->
            Error
    end.


%% @doc Delete the specified module/key combination
-spec delete( atom()|binary(), atom()|binary(), z:context() ) -> ok.
delete(Module, Key, Context) ->
    Module1 = z_convert:to_atom(Module),
    Key1 = z_convert:to_atom(Key),
    z_db:q("delete from config where module = $1 and key = $2", [Module1, Key1], Context),
    z_depcache:flush(config, Context),
    z_notifier:notify(
        #m_config_update{module = Module1, key = Key1, value = undefined},
        Context
    ),
    z:info(
        "Configuration key '~s.~s' deleted",
        [ z_convert:to_binary(Module1), z_convert:to_binary(Key1) ],
        [ {module, ?MODULE}, {line, ?LINE} ],
        Context),
    ok.


%% @doc Lookup the unique id in the config table from the module/key combination.
get_id(Module, Key, Context) ->
    z_db:q1("select id from config where module = $1 and key = $2", [Module, Key], Context).