Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ given user is a delegate, organizer or another staff member at the given competi

Now to the crucial integration element - data synchronization. At the WCA we developed
a standardized format for exchanging competition data between tools/systems, it's
called [WCIF](https://github.com/thewca/wcif/blob/master/specification.md) (WCA Competition Interchange Format)
called [WCIF](https://github.com/thewca/wcif/blob/latest/specification.md) (WCA Competition Interchange Format)
and defines a huge JSON object that contains all relevant competition information
(events, rounds, schedule, competitors, results, etc.). The idea behind it is to
have a well defined data format and empower developers to build tools working with
this data (more on that in the [vision](https://github.com/thewca/wcif/blob/master/vision.md)
document). The WCA website offers endpoints for fetching and saving data in this
format and that's exactly what WCA Live uses. When a delegate/organizer imports a
format. WCA Live requests WCIF v2 and exports WCIF 2.2. It supports sequential
rounds; linked rounds and reserved places are rejected during import because the
current scoring model cannot represent them. When a delegate/organizer imports a
competition into the app, we fetch WCIF data for this competition and save all of
it into the database. Later, competition admins can trigger synchronization, which
works both ways: gets new data from the WCA website saving changes to the local
Expand Down
3 changes: 2 additions & 1 deletion lib/wca_live/competitions/qualification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ defmodule WcaLive.Competitions.Qualification do
import Ecto.Changeset

@required_fields [:type, :result_type, :when_date]
@optional_fields [:level]
@optional_fields [:level, :earliest_result_date]

@primary_key false
embedded_schema do
field :type, :string
field :result_type, :string
field :level, :integer
field :when_date, :date
field :earliest_result_date, :date
end

def changeset(qualification, attrs) do
Expand Down
56 changes: 40 additions & 16 deletions lib/wca_live/synchronization/export.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ defmodule WcaLive.Synchronization.Export do

defp competition_to_wcif(competition) do
%{
"formatVersion" => "1.0",
"formatVersion" => "2.2",
"id" => competition.wca_id,
"name" => competition.name,
"shortName" => competition.short_name,
Expand Down Expand Up @@ -87,7 +87,7 @@ defmodule WcaLive.Synchronization.Export do
%{
"eventId" => personal_best.event_id,
"type" => personal_best.type,
"best" => personal_best.best,
"value" => personal_best.best,
"worldRanking" => personal_best.world_ranking,
"continentalRanking" => personal_best.continental_ranking,
"nationalRanking" => personal_best.national_ranking
Expand All @@ -102,28 +102,39 @@ defmodule WcaLive.Synchronization.Export do
competition_event.qualification &&
competition_event.qualification |> qualification_to_wcif(),
"rounds" =>
competition_event.rounds |> Enum.sort_by(& &1.number) |> Enum.map(&round_to_wcif/1)
competition_event.rounds
|> Enum.sort_by(& &1.number)
|> Enum.map(fn round ->
previous = Enum.find(competition_event.rounds, &(&1.number == round.number - 1))
round_to_wcif(round, previous)
end)
}
end

defp qualification_to_wcif(qualification) do
%{
"type" => qualification.type,
"resultType" => qualification.result_type,
"level" => qualification.level,
"whenDate" => qualification.when_date |> Date.to_iso8601()
"resultCondition" => %{
"type" => if(qualification.type == "ranking", do: "ranking", else: "resultAchieved"),
"scope" => qualification.result_type,
"value" => if(qualification.type == "anyResult", do: nil, else: qualification.level)
},
"latestResultDate" => qualification.when_date |> Date.to_iso8601(),
"earliestResultDate" =>
qualification.earliest_result_date && Date.to_iso8601(qualification.earliest_result_date)
}
end

defp round_to_wcif(round) do
defp round_to_wcif(round, previous) do
%{
"id" => round_wcif_id(round),
"linkedRounds" => nil,
"format" => round.format_id,
"timeLimit" => round.time_limit && round.time_limit |> time_limit_to_wcif(),
"cutoff" => round.cutoff && round.cutoff |> cutoff_to_wcif(),
"advancementCondition" =>
round.advancement_condition &&
round.advancement_condition |> advancement_condition_to_wcif(),
"participationRuleset" => %{
"participationSource" => participation_source_to_wcif(round, previous),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rename to participation_source_wcif since we are converting advancement condition instead.

"reservedPlaces" => nil
},
"results" => round.results |> Enum.map(&result_to_wcif/1),
"scrambleSetCount" => round.scramble_set_count
# "scrambleSets" => [] # ignored for now
Expand All @@ -147,15 +158,28 @@ defmodule WcaLive.Synchronization.Export do

defp cutoff_to_wcif(cutoff) do
%{
"attemptResult" => cutoff.attempt_result,
"resultValue" => cutoff.attempt_result,
"numberOfAttempts" => cutoff.number_of_attempts
}
end

defp advancement_condition_to_wcif(advancement_condition) do
defp participation_source_to_wcif(%{number: 1}, _previous), do: %{"type" => "registrations"}
defp participation_source_to_wcif(_round, nil), do: nil
defp participation_source_to_wcif(_round, %{advancement_condition: nil}), do: nil

defp participation_source_to_wcif(_round, previous) do
condition = previous.advancement_condition
format = WcaLive.Wca.Format.get_by_id!(previous.format_id)

%{
"type" => advancement_condition.type,
"level" => advancement_condition.level
"type" => "round",
"roundId" => round_wcif_id(previous),
"resultCondition" => %{
"type" =>
if(condition.type == "attemptResult", do: "resultAchieved", else: condition.type),
"scope" => if(format.sort_by == :best, do: "single", else: "average"),
"value" => condition.level
}
}
end

Expand All @@ -171,7 +195,7 @@ defmodule WcaLive.Synchronization.Export do

defp attempt_to_wcif(attempt) do
%{
"result" => attempt.result,
"value" => attempt.result,
"reconstruction" => attempt.reconstruction
}
end
Expand Down
105 changes: 90 additions & 15 deletions lib/wca_live/synchronization/import.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ defmodule WcaLive.Synchronization.Import do
def import_competition(competition, wcif) do
Repo.transaction_with(
fn ->
with {:ok, competition} <- insert_or_update_competition(competition, wcif),
with :ok <- validate_supported_wcif(wcif),
{:ok, competition} <- insert_or_update_competition(competition, wcif),
:ok <- insert_new_users(wcif),
{:ok, competition} <- update_competition_staff_members(competition, wcif),
{:ok, competition} <- update_competition_events(competition, wcif),
Expand All @@ -47,6 +48,12 @@ defmodule WcaLive.Synchronization.Import do
@spec import_results(%Competition{}, map(), %User{}) ::
{:ok, %Competition{}} | {:error, any()}
def import_results(competition, wcif, user) do
with :ok <- validate_supported_wcif(wcif) do
do_import_results(competition, wcif, user)
end
end

defp do_import_results(competition, wcif, user) do
Repo.transaction_with(fn ->
competition =
Repo.preload(competition, [:people, competition_events: [rounds: :results]])
Expand Down Expand Up @@ -74,6 +81,56 @@ defmodule WcaLive.Synchronization.Import do
end)
end

defp validate_supported_wcif(%{"formatVersion" => "2." <> _version} = wcif) do
error =
Enum.find_value(wcif["events"], fn
%{"id" => "fto"} -> "FTO is not supported by WCA Live"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added an additional guard for importing FTO comps, since rn it causes the site to completely crash. I assume we'll force FTO comps to use ILR?

event -> Enum.find_value(event["rounds"], &unsupported_round_message(&1, event["rounds"]))
end)

if error, do: {:error, error}, else: :ok
end

defp validate_supported_wcif(_wcif), do: {:error, "expected WCIF version 2"}

defp unsupported_round_message(round, rounds) do
source = get_in(round, ["participationRuleset", "participationSource"])

cond do
round["linkedRounds"] not in [nil, []] or source["type"] == "linkedRounds" ->
"Dual rounds are only supported by the WCA's Integrated Live Results platform"

get_in(round, ["participationRuleset", "reservedPlaces"]) != nil ->
"Reserved places are not supported by WCA Live (#{round["id"]})"

not supported_source?(source, round, rounds) ->
"Unsupported participation source or result condition in #{round["id"]}"

true ->
nil
end
end

defp supported_source?(nil, _round, _rounds), do: true

defp supported_source?(%{"type" => "registrations"}, round, _rounds) do
Wcif.ActivityCode.parse!(round["id"]).round_number == 1
end

defp supported_source?(%{"type" => "round"} = source, round, rounds) do
%{event_id: event_id, round_number: number} = Wcif.ActivityCode.parse!(round["id"])
previous = Enum.find(rounds, &(&1["id"] == source["roundId"]))
condition = source["resultCondition"]
sort_by = previous && Wca.Format.get_by_id!(previous["format"]).sort_by
scope = if sort_by == :best, do: "single", else: "average"

previous != nil and source["roundId"] == "#{event_id}-r#{number - 1}" and
condition["type"] in ["ranking", "percent", "resultAchieved"] and
condition["scope"] == scope and is_integer(condition["value"])
end

defp supported_source?(_source, _round, _rounds), do: false

@doc """
Returns round information for every round taht has no results, but
whose WCIF round has results.
Expand Down Expand Up @@ -108,7 +165,7 @@ defmodule WcaLive.Synchronization.Import do
person = Enum.find(competition.people, &(&1.registrant_id == wcif_result["personId"])) do
attempts =
Enum.map(wcif_result["attempts"], fn attempt ->
%{result: attempt["result"], reconstruction: attempt["reconstruction"]}
%{result: attempt["value"], reconstruction: attempt["reconstruction"]}
end)

%Scoretaking.Result{}
Expand Down Expand Up @@ -367,7 +424,7 @@ defmodule WcaLive.Synchronization.Import do
|> Repo.update()
end

defp competition_event_changeset(competition_event, wcif_event, competition) do
defp competition_event_changeset(competition_event, wcif_event, _competition) do
competition_event = Repo.preload(competition_event, :rounds)

competition_event
Expand All @@ -377,7 +434,7 @@ defmodule WcaLive.Synchronization.Import do
qualification: wcif_event["qualification"] |> wcif_qualification_to_attrs()
})
|> build_assoc(:rounds, wcif_event["rounds"],
with: &round_changeset(&1, &2, competition),
with: &round_changeset(&1, &2, wcif_event["rounds"]),
equality: fn round, wcif_round ->
%{event_id: event_id, round_number: number} = Wcif.ActivityCode.parse!(wcif_round["id"])
competition_event.event_id == event_id and round.number == number
Expand All @@ -388,23 +445,37 @@ defmodule WcaLive.Synchronization.Import do
defp wcif_qualification_to_attrs(nil), do: nil

defp wcif_qualification_to_attrs(wcif_qualification) do
condition = wcif_qualification["resultCondition"]

%{
type: wcif_qualification["type"],
result_type: wcif_qualification["resultType"],
level: wcif_qualification["level"],
when_date: wcif_qualification["whenDate"]
type: qualification_type(condition),
result_type: condition["scope"],
level: condition["value"],
when_date: wcif_qualification["latestResultDate"],
earliest_result_date: wcif_qualification["earliestResultDate"]
}
end

defp round_changeset(round, wcif_round, _competition) do
defp qualification_type(%{"type" => "resultAchieved", "value" => nil}), do: "anyResult"
defp qualification_type(%{"type" => "resultAchieved"}), do: "attemptResult"
defp qualification_type(condition), do: condition["type"]

defp round_changeset(round, wcif_round, wcif_rounds) do
%{event_id: event_id, round_number: number} = Wcif.ActivityCode.parse!(wcif_round["id"])

next_round =
Enum.find(wcif_rounds, &(&1["id"] == "#{event_id}-r#{number + 1}"))

condition =
get_in(next_round, ["participationRuleset", "participationSource", "resultCondition"])

round
|> Scoretaking.Round.changeset(%{
number: wcif_round["id"] |> Wcif.ActivityCode.parse!() |> Map.fetch!(:round_number),
number: number,
format_id: wcif_round["format"],
time_limit: wcif_round["timeLimit"] |> wcif_time_limit_to_attrs(),
cutoff: wcif_round["cutoff"] |> wcif_cutoff_to_attrs(),
advancement_condition:
wcif_round["advancementCondition"] |> wcif_advancement_condition_to_attrs(),
advancement_condition: condition |> wcif_advancement_condition_to_attrs(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have it as wcif_advancement_condition_attrs, since the input is participation ruleset that we are converting.

scramble_set_count: wcif_round["scrambleSetCount"]
})
end
Expand All @@ -422,16 +493,20 @@ defmodule WcaLive.Synchronization.Import do

defp wcif_cutoff_to_attrs(wcif_cutoff) do
%{
attempt_result: wcif_cutoff["attemptResult"],
attempt_result: wcif_cutoff["resultValue"],
number_of_attempts: wcif_cutoff["numberOfAttempts"]
}
end

defp wcif_advancement_condition_to_attrs(nil), do: nil

defp wcif_advancement_condition_to_attrs(%{"type" => "resultAchieved", "value" => value}) do
%{level: value, type: "attemptResult"}
end

defp wcif_advancement_condition_to_attrs(wcif_advancement_condition) do
%{
level: wcif_advancement_condition["level"],
level: wcif_advancement_condition["value"],
type: wcif_advancement_condition["type"]
}
end
Expand Down Expand Up @@ -580,7 +655,7 @@ defmodule WcaLive.Synchronization.Import do
|> Competitions.PersonalBest.changeset(%{
event_id: wcif_personal_best["eventId"],
type: wcif_personal_best["type"],
best: wcif_personal_best["best"],
best: wcif_personal_best["value"],
world_ranking: wcif_personal_best["worldRanking"],
continental_ranking: wcif_personal_best["continentalRanking"],
national_ranking: wcif_personal_best["nationalRanking"]
Expand Down
2 changes: 1 addition & 1 deletion lib/wca_live/wca/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ defmodule WcaLive.Wca.Api do
build_req()
|> with_user_token(access_token)
|> with_long_timeout()
|> request(url: "/competitions/#{competition_wca_id}/wcif")
|> request(url: "/competitions/#{competition_wca_id}/wcif/version/2")
end

@doc """
Expand Down
19 changes: 11 additions & 8 deletions test/wca_live/synchronization/export_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule WcaLive.Synchronization.ExportTest do
alias WcaLive.Synchronization.Export

@wcif %{
"formatVersion" => "1.0",
"formatVersion" => "2.2",
"id" => "WC2019",
"name" => "WCA World Championship 2019",
"shortName" => "WCA WC 2019",
Expand Down Expand Up @@ -42,7 +42,7 @@ defmodule WcaLive.Synchronization.ExportTest do
"personalBests" => [
%{
"eventId" => "333",
"best" => 790,
"value" => 790,
"type" => "single",
"worldRanking" => 995,
"continentalRanking" => 105,
Expand All @@ -64,19 +64,22 @@ defmodule WcaLive.Synchronization.ExportTest do
},
"cutoff" => %{
"numberOfAttempts" => 2,
"attemptResult" => 3000
"resultValue" => 3000
},
"linkedRounds" => nil,
"participationRuleset" => %{
"participationSource" => %{"type" => "registrations"},
"reservedPlaces" => nil
},
"advancementCondition" => nil,
"results" => [],
"scrambleSetCount" => 4
}
],
"competitorLimit" => nil,
"qualification" => %{
"type" => "ranking",
"resultType" => "average",
"level" => 50,
"whenDate" => "2019-07-01"
"resultCondition" => %{"type" => "ranking", "scope" => "average", "value" => 50},
"latestResultDate" => "2019-07-01",
"earliestResultDate" => nil
}
}
],
Expand Down
Loading