Added schedule DB interaction functions

main
noah metz 2024-01-26 23:56:30 -07:00
parent 4e61172f6d
commit 01ddf903b4
3 changed files with 177 additions and 144 deletions

@ -34,7 +34,7 @@ init(_) ->
start => {mqtt, start_link, []}
},
#{id => event_mgr,
start => {event_mgr, start_link, [[mqtt, none]]}
start => {event_mgr, start_link, [[mqtt]]}
}
],
{ok, {SupFlags, ChildSpecs}}.

@ -3,88 +3,17 @@
-behaviour(gen_server).
-export([start_link/1, init/1, handle_cast/2, handle_call/3, handle_info/2]).
-export([update_config/2]).
-record(event_config, {database_file = none, skills_fields = [], practice_fields = [], divisions = []}).
-export([add_fields/2]).
-record(division_config, {fields = [], teams = [], teams_hash = "", practice_matches = [], practice_rounds = 0, practice_seed = 0, qualification_matches = [], qualification_rounds = [], qualification_seed = 0, elimination_alliances = 0}).
-record(event_state, {owner, config = #event_config{}, fields = [], database = none, supervisor}).
-record(event_state, {owner, config = none, fields = [], database = none, supervisor}).
start_link(Args) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, Args, []).
init([Owner, none]) ->
{ok, Supervisor} = event_sup:start_link(),
{ok, #event_state{owner = Owner, supervisor = Supervisor}};
init([Owner, ConfigJSON]) ->
init([Owner]) ->
{ok, Supervisor} = event_sup:start_link(),
{ok, parse_event_config(ConfigJSON, #event_state{owner = Owner, supervisor = Supervisor})}.
get_matches_from_db(Database, Division, Round, Seed, Size, TeamsHash) ->
if Size == 0 -> [];
Size > 0 -> SQL = io_lib:format("SELECT number, blue_1, blue_2, red_1, red_2 FROM matches WHERE division = '~p' AND round = '~p' AND size = '~p' AND seed = '~p' AND teams = '~s';", [Division, Round, Size, Seed, TeamsHash]),
[{columns, Columns}, {rows, Rows}] = sqlite3:sql_exec(Database, SQL),
[]
end.
parse_division([], _, Parsed, Fields, _) -> {Parsed, Fields};
parse_division([Division | Divisions], Database, Parsed, Fields, N) ->
TeamsBinary = maps:get(<<"teams">>, Division),
TeamsList = [binary_to_list(X) || X <- TeamsBinary],
TeamsHash = schedule:hash_teams_list(TeamsList),
PracticeRounds = maps:get(<<"practice_rounds">>, Division),
PracticeSeed = maps:get(<<"practice_seed">>, Division),
PracticeMatches = get_matches_from_db(Database, N, practice, PracticeSeed, PracticeRounds, TeamsHash),
QualificationRounds = maps:get(<<"qualification_rounds">>, Division),
QualificationSeed = maps:get(<<"qualification_seed">>, Division),
QualificationMatches = get_matches_from_db(Database, N, qualification, QualificationSeed, PracticeRounds, TeamsHash),
DivConfig = #division_config{
fields = [binary_to_list(X) || X <- maps:get(<<"fields">>, Division)],
elimination_alliances = maps:get(<<"elimination_alliances">>, Division),
practice_rounds = PracticeRounds,
practice_matches = PracticeMatches,
practice_seed = PracticeSeed,
qualification_rounds = QualificationRounds,
qualification_matches = QualificationMatches,
qualification_seed = QualificationSeed,
teams = TeamsList,
teams_hash = TeamsHash
},
parse_division(Divisions, Database, [DivConfig | Parsed], lists:append([DivConfig#division_config.fields, Fields]), N+1).
parse_divisions(Divisions, Database) ->
parse_division(Divisions, Database, [], [], 0).
parse_event_config(ConfigJSON, State) ->
Config = jsone:decode(list_to_binary(ConfigJSON)),
CurrentFile = State#event_state.config#event_config.database_file,
NewFile = binary_to_list(maps:get(<<"database">>, Config)),
{ok, Database} = if CurrentFile == NewFile -> {ok, State#event_state.database};
CurrentFile == none -> sqlite3:open(anonymous, [{file, NewFile}]);
true -> sqlite3:close(State#event_state.database), sqlite3:open(anonymous, [{file, NewFile}])
end,
{Divisions, DivisionFields} = parse_divisions(maps:get(<<"divisions">>, Config), Database),
PracticeFields = [binary_to_list(X) || X <- maps:get(<<"testing_fields">>, Config)],
SkillsFields = [binary_to_list(X) || X <- maps:get(<<"skills_fields">>, Config)],
EliminationFields = [binary_to_list(X) || X <- maps:get(<<"elimination_fields">>, Config)],
Fields = sets:to_list(sets:from_list(lists:append([DivisionFields, SkillsFields, PracticeFields, EliminationFields]))),
FieldStates = add_fields(Fields, State#event_state.fields),
State#event_state{config = #event_config{
database_file = binary_to_list(maps:get(<<"database">>, Config)),
skills_fields = SkillsFields,
practice_fields = PracticeFields,
divisions = Divisions
}, fields = FieldStates, database = Database}.
update_config(Server, ConfigJSON) ->
gen_server:call(Server, {event_config, ConfigJSON}).
{ok, #event_state{owner = Owner, supervisor = Supervisor}}.
get_field_state(_, []) -> free;
get_field_state(Name, [{FieldName, FieldState} | FieldStates]) ->
@ -96,32 +25,40 @@ add_fields(Fields, FieldStates) ->
[{Name, get_field_state(Name, FieldStates)} || Name <- Fields].
update_field({Name, free}, State) ->
% TODO: Calculate which event should be running on this field
% Priority List Logic:
% 1) if testing field, assign testing event
% 2) if skills field, assign skills event
% 3) if division field
% 0) in general, assign match if: (state != done && (num % num_fields) == field_index)
% 1) Get practice match from DB
% 2) Get qualification match from DB
% 2) Get lists of elimination matches from DB, assign(and generate if necessary) first where:
FieldState = free,
{{Name, FieldState}, State};
update_field({Name, FieldState}, State) -> {{Name, FieldState}, State}.
% TODO: Calculate which event should be running on this field
% Priority List Logic:
% 1) if testing field, assign testing event
% 2) if skills field, assign skills event
% 3) if division field
% 0) in general, assign match if: (state != done && (num % num_fields) == field_index)
% 1) get practice match from DB
% 2) get qualification match from DB
% 3) get elimination match from DB
% 4) generate/write elimination match to DB
% 4) if finals field
% 1) get finals match from db
% 2) generate/write finals match to DB
%
% If the event requires multiple fields, then we need to check if the other field is free
% <- does this add the opportunity bad behaviour? Is there a possibility for two events
% that want to be on fields A & B to try and start at the same time? If so then both events would
% take both fields(since they only know the state before starting update_field)
% <- I can deal with this by making update_field 'filter' through the list with both halfs available
% for each call
update_fields(State, [], Updated) ->
State#event_state{fields = Updated};
update_fields(State, [{Name, free} | FieldStates], Updated) ->
% TODO: assign field logic..
update_fields(State, FieldStates, [{Name, free} | Updated]);
update_fields(State, [FieldState | FieldStates], Updated) ->
update_fields(State, FieldStates, [FieldState | Updated]).
update_events(State) ->
% TODO: calculate which events should be running
% TODO: stop events on fields that no longer exist
% TODO: stop events on fields that are running the wrong event
% TODO: start every event that should be running given the config
{NewFields, NewState} = lists:mapfoldl(fun update_field/2, State, State#event_state.fields),
NewState#event_state{fields = NewFields}.
handle_call({event_config, ConfigJSON}, _, State) ->
NewState = parse_event_config(ConfigJSON, State),
State#event_state.owner ! {new_event_config, NewState#event_state.config},
{reply, ok, NewState};
update_fields(State, State#event_state.fields, []).
handle_call(_, _, State) ->
{noreply, update_events(State)}.

@ -4,14 +4,16 @@
-export([start_link/0, init/1, handle_cast/2, handle_call/3]).
-export([init_db/2, generate/2]).
-include("erlk.hrl").
-record(state, {database_file = none, database = none, teams = [], config = none}).
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
init([]) ->
rand:seed(default),
{ok, []}.
{ok, #state{}}.
duplicate(1, Original, Constructed) -> lists:append(Original, Constructed);
duplicate(N, Original, Constructed) -> duplicate(N-1, Original, lists:append(Original, Constructed)).
@ -40,14 +42,7 @@ fill_padding_schedule(Matches, Teams) ->
hash_teams_list(Teams) ->
[ Y || <<X:4>> <= crypto:hash(sha, Teams), Y <- integer_to_list(X,16)].
generate(Process, {DatabaseFile, Division, Round, Seed, Matches, Teams}) -> gen_server:call(Process, {new_schedule, DatabaseFile, Division, Round, Seed, Matches, Teams}).
init_db(Process, DatabaseFile) -> gen_server:call(Process, {init_db, DatabaseFile}).
init_db(File) ->
IsFile = filelib:is_regular(File),
if IsFile -> file:delete(File);
true -> true end,
{ok, Database} = sqlite3:open(anonymous, [{file, File}]),
init_db(Database) ->
ok = sqlite3:create_table(Database, teams, [{name, text, [not_null]},
{inspection, blob}],
[{primary_key, [name]}]),
@ -61,30 +56,30 @@ init_db(File) ->
{red_2, text, [not_null]},
{blue_1, text, [not_null]},
{blue_2, text, [not_null]}],
[{primary_key, [division, round, size, teams, number, seed]},
{check, "round IN ('practice', 'qualification', 'elimination')"},
{foreign_key, {[blue_1], teams, [name], "ON DELETE RESTRICT"}},
{foreign_key, {[blue_2], teams, [name], "ON DELETE RESTRICT"}},
{foreign_key, {[red_1], teams, [name], "ON DELETE RESTRICT"}},
{foreign_key, {[red_2], teams, [name], "ON DELETE RESTRICT"}}]),
[{primary_key, [division, round, size, teams, number, seed]},
{check, "round IN ('practice', 'qualification', 'elimination')"},
{foreign_key, {[blue_1], teams, [name], "ON DELETE RESTRICT"}},
{foreign_key, {[blue_2], teams, [name], "ON DELETE RESTRICT"}},
{foreign_key, {[red_1], teams, [name], "ON DELETE RESTRICT"}},
{foreign_key, {[red_2], teams, [name], "ON DELETE RESTRICT"}}]),
ok = sqlite3:create_table(Database, match_scores, [{division, integer, [not_null]},
{round, text, [not_null]},
{size, integer, [not_null]},
{teams, text, [not_null]},
{number, integer, [not_null]},
{seed, integer, [not_null]},
{instance, integer, [not_null]},
{score, blob, [not_null]}],
[{primary_key, [division, round, size, teams, number, seed, instance]},
{foreign_key, {[division, round, size, teams, number, seed], matches, [division, round, size, teams, number, seed], "ON DELETE CASCADE"}}]),
{round, text, [not_null]},
{size, integer, [not_null]},
{teams, text, [not_null]},
{number, integer, [not_null]},
{seed, integer, [not_null]},
{instance, integer, [not_null]},
{score, blob, [not_null]}],
[{primary_key, [division, round, size, teams, number, seed, instance]},
{foreign_key, {[division, round, size, teams, number, seed], matches, [division, round, size, teams, number, seed], "ON DELETE CASCADE"}}]),
ok = sqlite3:create_table(Database, match_states, [{division, integer, [not_null]},
{round, text, [not_null]},
{size, integer, [not_null]},
{teams, text, [not_null]},
{number, integer, [not_null]},
{seed, integer, [not_null]},
{time, integer, [not_null]},
{state, blob, [not_null]}],
{round, text, [not_null]},
{size, integer, [not_null]},
{teams, text, [not_null]},
{number, integer, [not_null]},
{seed, integer, [not_null]},
{time, integer, [not_null]},
{state, blob, [not_null]}],
[{primary_key, [division, round, size, teams, number, seed, time]},
{foreign_key, {[division, round, size, teams, number, seed], matches, [division, round, size, teams, number, seed], "ON DELETE CASCADE"}}]),
@ -96,25 +91,43 @@ init_db(File) ->
{score, blob, [not_null]}],
[{primary_key, [team, type, attempt]},
{foreign_key, {[team], teams, [name], "ON DELETE CASCADE"}}]),
sqlite3:close(Database).
write(DatabaseFile, Division, Round, Seed, Matches, Teams) ->
MatchList = create_schedule(Seed, Matches, Teams),
ok.
delete_teams(Database, Teams) ->
SQL = lists:append(["BEGIN; ",
lists:append([io_lib:format("DELETE FROM teams WHERE name = ~p; ",
[Team]) || Team <- Teams]),
"COMMIT;"]),
sqlite3:sql_exec_script(Database, SQL).
add_teams(Database, Teams) ->
SQL = lists:append(["BEGIN; ",
lists:append([io_lib:format("INSERT INTO teams(name) VALUES('~s') ON CONFLICT(name) DO NOTHING; ",
[Team]) || Team <- Teams]),
"COMMIT;"]),
sqlite3:sql_exec_script(Database, SQL).
first_error([]) -> ok;
first_error([ok | Rest]) -> first_error(Rest);
first_error([Error | _]) -> Error.
delete_matches(Database, Division, Size, Seed, TeamsHash, Round) ->
SQL = io_lib:format("DELETE FROM matches WHERE division = ~p AND size = ~p AND seed = ~p AND teams = '~s' AND round = '~p'", [Division, Size, Seed, TeamsHash, Round]),
sqlite3:sql_exec_script(Database, SQL).
write_matches(Database, Division, Size, Seed, TeamsHash, Round, MatchTeams) ->
Schedule = [[{division, Division},
{round, atom_to_list(Round)},
{size, Matches},
{teams, hash_teams_list(Teams)},
{size, Size},
{teams, TeamsHash},
{number, N},
{seed, Seed},
{blue_1, B1},
{blue_2, B2},
{red_1, R1},
{red_2, R2}] || {N, [B1, B2, R1, R2]} <- lists:enumerate(MatchList)],
{ok, Database} = sqlite3:open(anonymous, [{file, DatabaseFile}]),
{red_2, R2}] || {N, [B1, B2, R1, R2]} <- lists:enumerate(MatchTeams)],
[ok | Resp] = sqlite3:write_many(Database, matches, Schedule),
ok = lists:last(Resp),
ok = sqlite3:close(Database).
lists:last(Resp).
create_schedule(Seed, Matches, Teams) ->
TeamsRepeated = duplicate(Matches, Teams),
@ -139,9 +152,92 @@ pick_matches(Teams, Matches) ->
pick_matches(Teams) -> pick_matches(Teams, []).
handle_cast(_, State) -> {noreply, State}.
handle_call({new_db, DatabaseFile}, _, State) ->
ok = if State#state.database =:= none -> ok;
true -> sqlite3:close(State#state.database)
end,
Exists = filelib:is_regular(DatabaseFile),
ok = if Exists -> file:delete(DatabaseFile);
true -> ok
end,
{ok, Database} = sqlite3:open(schedule_db, [{file, DatabaseFile}]),
ok = sqlite3:sql_exec(Database, "PRAGMA foreign_keys = ON;"),
{reply, init_db(Database), State#state{database_file = DatabaseFile, database = Database}};
handle_call({load_db, DatabaseFile}, _, State) ->
true = filelib:is_regular(DatabaseFile),
{ok, Database} = if DatabaseFile == State#state.database_file ->
{ok, State#state.database};
true ->
ok = if State#state.database =:= none -> ok;
true -> sqlite3:close(State#state.database)
end,
{ok, DB} = sqlite3:open(schedule_db, [{file, DatabaseFile}]),
{sqlite3:sql_exec(DB, "PRAGMA foreign_keys = ON;"), DB}
end,
{reply, ok, State#state{database_file = DatabaseFile, database = Database}}.
handle_cast({delete_teams, Removed}, State) ->
Teams = lists:filter(fun(X) -> lists:member(X, Removed) =:= false end, State#state.teams),
ok = first_error(delete_teams(State#state.database, Removed)),
{noreply, State#state{teams = Teams}};
handle_cast({add_teams, Teams}, State) ->
ok = first_error(add_teams(State#state.database, Teams)),
{noreply, State#state{teams = Teams}};
handle_cast({new_schedule, Division, Size, Seed, Teams, Round}, State) ->
MatchTeams = create_schedule(Seed, Size, Teams),
ok = write_matches(State#state.database, Division, Size, Seed, hash_teams_list(Teams), Round, MatchTeams),
{noreply, State};
handle_cast({delete_schedule, Division, Size, Seed, TeamsHash, Round}, State) ->
ok = first_error(delete_matches(State#state.database, Division, Size, Seed, TeamsHash, Round)),
{noreply, State}.
get_matches_from_db(Database, Division, Round, Seed, Size, TeamsHash) ->
if Size == 0 -> [];
Size > 0 -> SQL = io_lib:format("SELECT number, blue_1, blue_2, red_1, red_2 FROM
matches WHERE
division = '~p' AND
round = '~p' AND
size = '~p' AND
seed = '~p' AND
teams = '~s';",
[Division, Round, Size, Seed, TeamsHash]),
[{columns, Columns}, {rows, Rows}] = sqlite3:sql_exec(Database, SQL),
[]
end.
handle_call({init_db, DatabaseFile}, _, State) ->
{reply, init_db(DatabaseFile), State};
handle_call({new_schedule, DatabaseFile, Division, Round, Seed, Matches, Teams}, _, State) ->
{reply, write(DatabaseFile, Division, Round, Seed, Matches, Teams), State}.
parse_division(Division) ->
TeamsBinary = maps:get(<<"teams">>, Division),
TeamsList = [binary_to_list(X) || X <- TeamsBinary],
TeamsHash = schedule:hash_teams_list(TeamsList),
PracticeRounds = maps:get(<<"practice_rounds">>, Division),
PracticeSeed = maps:get(<<"practice_seed">>, Division),
QualificationRounds = maps:get(<<"qualification_rounds">>, Division),
QualificationSeed = maps:get(<<"qualification_seed">>, Division),
#division_config{
fields = [binary_to_list(X) || X <- maps:get(<<"fields">>, Division)],
elimination_alliances = maps:get(<<"elimination_alliances">>, Division),
practice_rounds = PracticeRounds,
practice_seed = PracticeSeed,
qualification_rounds = QualificationRounds,
qualification_seed = QualificationSeed,
teams = TeamsList,
teams_hash = TeamsHash
}.
parse_event_config(ConfigJSON) ->
Config = jsone:decode(list_to_binary(ConfigJSON)),
Divisions = lists:map(fun parse_division/1, maps:get(<<"divisions">>, Config)),
TestingFields = [binary_to_list(X) || X <- maps:get(<<"testing_fields">>, Config)],
SkillsFields = [binary_to_list(X) || X <- maps:get(<<"skills_fields">>, Config)],
FinalsFields = [binary_to_list(X) || X <- maps:get(<<"finals_fields">>, Config)],
#event_config{
database_file = binary_to_list(maps:get(<<"database">>, Config)),
finals_fields = FinalsFields,
skills_fields = SkillsFields,
testing_fields = TestingFields,
divisions = Divisions
}.