oneshot
This commit is contained in:
@@ -12,8 +12,8 @@ defmodule Rsh26pool.Application do
|
||||
Rsh26pool.Repo,
|
||||
{DNSCluster, query: Application.get_env(:rsh26pool, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Rsh26pool.PubSub},
|
||||
# Start a worker by calling: Rsh26pool.Worker.start_link(arg)
|
||||
# {Rsh26pool.Worker, arg},
|
||||
# Periodically closes rooms that have reached their auto-end time.
|
||||
Rsh26pool.RoomCloser,
|
||||
# Start to serve requests, typically the last entry
|
||||
Rsh26poolWeb.Endpoint
|
||||
]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
defmodule Rsh26pool.JST do
|
||||
@moduledoc """
|
||||
Helpers for converting between UTC (how timestamps are stored) and
|
||||
Japan Standard Time (JST, UTC+9) which is what the UI shows and accepts.
|
||||
|
||||
A full time zone database is not configured for this demo, and JST has no
|
||||
daylight saving time, so a fixed +9h offset is sufficient and dependency free.
|
||||
"""
|
||||
|
||||
@offset_seconds 9 * 3600
|
||||
|
||||
@doc """
|
||||
Parses a `datetime-local` form value (e.g. `"2026-07-04T15:30"`), interpreting
|
||||
it as wall-clock JST, and returns the corresponding UTC `DateTime`.
|
||||
"""
|
||||
@spec parse_local(String.t()) :: {:ok, DateTime.t()} | :error
|
||||
def parse_local(value) when is_binary(value) do
|
||||
with {:ok, naive} <- NaiveDateTime.from_iso8601(ensure_seconds(value)) do
|
||||
utc =
|
||||
naive
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> DateTime.add(-@offset_seconds, :second)
|
||||
|> DateTime.truncate(:second)
|
||||
|
||||
{:ok, utc}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def parse_local(_), do: :error
|
||||
|
||||
@doc "Converts a stored UTC `DateTime` to a JST `DateTime` for display."
|
||||
@spec to_local(DateTime.t()) :: DateTime.t()
|
||||
def to_local(%DateTime{} = dt), do: DateTime.add(dt, @offset_seconds, :second)
|
||||
|
||||
@doc "Formats a stored UTC `DateTime` as a human friendly JST string."
|
||||
@spec format(DateTime.t() | nil) :: String.t()
|
||||
def format(nil), do: ""
|
||||
|
||||
def format(%DateTime{} = dt) do
|
||||
l = to_local(dt)
|
||||
|
||||
"~4..0B/~2..0B/~2..0B ~2..0B:~2..0B"
|
||||
|> :io_lib.format([l.year, l.month, l.day, l.hour, l.minute])
|
||||
|> to_string()
|
||||
end
|
||||
|
||||
defp ensure_seconds(value) do
|
||||
if Regex.match?(~r/T\d{2}:\d{2}$/, value), do: value <> ":00", else: value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
defmodule Rsh26pool.RoomCloser do
|
||||
@moduledoc """
|
||||
Periodically closes rooms whose `closes_at` deadline has passed (the "auto
|
||||
end" feature). Closing broadcasts to any connected LiveViews so open pages
|
||||
transition to the results/grouping view without a manual refresh.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias Rsh26pool.Voting
|
||||
|
||||
@interval :timer.seconds(10)
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
schedule()
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:sweep, state) do
|
||||
sweep()
|
||||
schedule()
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp sweep do
|
||||
for room <- Voting.list_rooms_to_auto_close() do
|
||||
case Voting.close_room(room) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
Logger.warning("RoomCloser failed to close room #{room.id}: #{inspect(other)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule, do: Process.send_after(self(), :sweep, @interval)
|
||||
end
|
||||
@@ -0,0 +1,523 @@
|
||||
defmodule Rsh26pool.Voting do
|
||||
@moduledoc """
|
||||
The Voting context: rooms, participants, ideas/opinions, votes, results and
|
||||
(in grouping mode) team assignment via the Hungarian algorithm.
|
||||
|
||||
Timestamps are stored in UTC. Real-time updates are published over
|
||||
`Phoenix.PubSub`; interested LiveViews call `subscribe/1` and handle
|
||||
`{:voting, event}` messages.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Rsh26pool.Repo
|
||||
alias Rsh26pool.Voting.{Hungarian, Idea, Participant, Room, Vote}
|
||||
|
||||
@pubsub Rsh26pool.PubSub
|
||||
|
||||
## Real-time -------------------------------------------------------------
|
||||
|
||||
@doc "Subscribe the calling process to a room's update stream."
|
||||
def subscribe(room_id) do
|
||||
Phoenix.PubSub.subscribe(@pubsub, topic(room_id))
|
||||
end
|
||||
|
||||
defp broadcast(room_id, event) do
|
||||
Phoenix.PubSub.broadcast(@pubsub, topic(room_id), {:voting, event})
|
||||
end
|
||||
|
||||
defp topic(room_id), do: "room:#{room_id}"
|
||||
|
||||
## Rooms ----------------------------------------------------------------
|
||||
|
||||
@doc "Builds a creation changeset for the room settings form."
|
||||
def change_room_creation(%Room{} = room \\ %Room{}, attrs \\ %{}) do
|
||||
Room.create_changeset(room, attrs)
|
||||
end
|
||||
|
||||
@doc "Builds a settings changeset for the manage page rename form."
|
||||
def change_room_settings(%Room{} = room, attrs \\ %{}) do
|
||||
Room.settings_changeset(room, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a room from the settings form and its owner participant.
|
||||
|
||||
Returns `{:ok, %{room: room, owner: owner}}` or `{:error, changeset}`.
|
||||
"""
|
||||
def create_room(attrs) do
|
||||
changeset = Room.create_changeset(%Room{}, attrs)
|
||||
|
||||
if changeset.valid? do
|
||||
changeset = Ecto.Changeset.put_change(changeset, :room_number, unique_room_number())
|
||||
|
||||
Repo.transaction(fn ->
|
||||
room = Repo.insert!(changeset)
|
||||
|
||||
owner =
|
||||
Repo.insert!(%Participant{
|
||||
room_id: room.id,
|
||||
role: "owner",
|
||||
name: "オーナー",
|
||||
token: generate_token()
|
||||
})
|
||||
|
||||
%{room: room, owner: owner}
|
||||
end)
|
||||
else
|
||||
{:error, %{changeset | action: :insert}}
|
||||
end
|
||||
end
|
||||
|
||||
def get_room_by_number(number) when is_binary(number) do
|
||||
Repo.get_by(Room, room_number: number)
|
||||
end
|
||||
|
||||
def get_room_by_number(_), do: nil
|
||||
|
||||
def get_room!(id), do: Repo.get!(Room, id)
|
||||
|
||||
def reload_room(%Room{id: id}), do: Repo.get(Room, id)
|
||||
|
||||
@doc "Updates owner-editable settings (currently the room name)."
|
||||
def update_room_settings(%Room{} = room, attrs) do
|
||||
room
|
||||
|> Room.settings_changeset(attrs)
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, room} = ok ->
|
||||
broadcast(room.id, :room_updated)
|
||||
ok
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
The status a room should be treated as *right now*, accounting for a passed
|
||||
`closes_at` deadline even if the background closer has not run yet.
|
||||
"""
|
||||
def effective_status(%Room{status: "closed"}), do: "closed"
|
||||
def effective_status(%Room{closes_at: nil}), do: "open"
|
||||
|
||||
def effective_status(%Room{closes_at: closes_at}) do
|
||||
if DateTime.compare(closes_at, DateTime.utc_now()) != :gt, do: "closed", else: "open"
|
||||
end
|
||||
|
||||
def room_open?(%Room{} = room), do: effective_status(room) == "open"
|
||||
|
||||
@doc """
|
||||
Closes a room: marks it closed and, in grouping mode, computes the team
|
||||
assignments (idempotent — safe to call more than once).
|
||||
"""
|
||||
def close_room(%Room{} = room) do
|
||||
room = Repo.get!(Room, room.id)
|
||||
|
||||
if room.status == "closed" do
|
||||
{:ok, room}
|
||||
else
|
||||
{:ok, closed} =
|
||||
room
|
||||
|> Ecto.Changeset.change(status: "closed")
|
||||
|> Repo.update()
|
||||
|
||||
closed = if closed.grouping_mode, do: compute_grouping(closed), else: closed
|
||||
broadcast(closed.id, :room_updated)
|
||||
{:ok, closed}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ensures a room past its deadline is actually closed. Returns the (possibly
|
||||
updated) room. Used lazily by pages so viewers never see a stale open room.
|
||||
"""
|
||||
def ensure_closed(%Room{} = room) do
|
||||
if room.status == "open" and effective_status(room) == "closed" do
|
||||
{:ok, closed} = close_room(room)
|
||||
closed
|
||||
else
|
||||
room
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Lists rooms that are open but past their auto-close deadline."
|
||||
def list_rooms_to_auto_close do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
Repo.all(
|
||||
from r in Room,
|
||||
where: r.status == "open" and not is_nil(r.closes_at) and r.closes_at <= ^now
|
||||
)
|
||||
end
|
||||
|
||||
## Participants ---------------------------------------------------------
|
||||
|
||||
@doc "Changeset for the join form."
|
||||
def change_participant(%Participant{} = participant \\ %Participant{}, attrs \\ %{}) do
|
||||
Participant.changeset(participant, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Joins a room as a member, generating an auth token.
|
||||
|
||||
Returns `{:ok, participant}` or `{:error, changeset}`.
|
||||
"""
|
||||
def join_room(%Room{} = room, attrs) do
|
||||
%Participant{room_id: room.id, role: "member", token: generate_token()}
|
||||
|> Participant.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def get_participant_by_token(nil), do: nil
|
||||
|
||||
def get_participant_by_token(token) when is_binary(token) do
|
||||
Repo.one(from p in Participant, where: p.token == ^token, preload: [:room])
|
||||
end
|
||||
|
||||
def list_participants(%Room{} = room) do
|
||||
Repo.all(from p in Participant, where: p.room_id == ^room.id, order_by: [asc: p.id])
|
||||
end
|
||||
|
||||
def count_participants(%Room{} = room) do
|
||||
Repo.aggregate(from(p in Participant, where: p.room_id == ^room.id), :count)
|
||||
end
|
||||
|
||||
## Ideas ----------------------------------------------------------------
|
||||
|
||||
def change_idea(%Idea{} = idea \\ %Idea{}, attrs \\ %{}) do
|
||||
Idea.changeset(idea, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Posts an idea/opinion. In grouping mode each participant has a single opinion,
|
||||
so an existing one is updated instead of inserting a second.
|
||||
"""
|
||||
def create_idea(%Room{} = room, %Participant{} = participant, attrs) do
|
||||
cond do
|
||||
not room_open?(room) ->
|
||||
{:error, :closed}
|
||||
|
||||
room.grouping_mode ->
|
||||
case get_participant_idea(room, participant) do
|
||||
nil -> do_insert_idea(room, participant, attrs)
|
||||
existing -> do_update_idea(room, existing, attrs)
|
||||
end
|
||||
|
||||
true ->
|
||||
do_insert_idea(room, participant, attrs)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_insert_idea(room, participant, attrs) do
|
||||
%Idea{room_id: room.id, participant_id: participant.id}
|
||||
|> Idea.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|> tap_broadcast(room, :ideas_changed)
|
||||
end
|
||||
|
||||
defp do_update_idea(room, idea, attrs) do
|
||||
idea
|
||||
|> Idea.changeset(attrs)
|
||||
|> Repo.update()
|
||||
|> tap_broadcast(room, :ideas_changed)
|
||||
end
|
||||
|
||||
def get_participant_idea(%Room{} = room, %Participant{} = participant) do
|
||||
Repo.one(
|
||||
from i in Idea,
|
||||
where: i.room_id == ^room.id and i.participant_id == ^participant.id,
|
||||
order_by: [asc: i.id],
|
||||
limit: 1
|
||||
)
|
||||
end
|
||||
|
||||
def list_ideas(%Room{} = room) do
|
||||
Repo.all(
|
||||
from i in Idea,
|
||||
where: i.room_id == ^room.id,
|
||||
order_by: [asc: i.id],
|
||||
preload: [:participant]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ideas ordered by a per-viewer deterministic shuffle. Order is stable for a
|
||||
given `seed` (so it does not jump around on live updates) but differs between
|
||||
viewers, satisfying the "shuffled choices" requirement.
|
||||
"""
|
||||
def shuffled_ideas(%Room{} = room, seed) do
|
||||
room
|
||||
|> list_ideas()
|
||||
|> Enum.sort_by(fn idea -> :erlang.phash2({seed, idea.id}) end)
|
||||
end
|
||||
|
||||
## Votes ----------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Casts a vote for `idea_id` by `participant`, enforcing the room's per-user
|
||||
vote limit, one-vote-per-idea, and that the room is still open.
|
||||
"""
|
||||
def vote(%Participant{} = participant, idea_id) do
|
||||
room = Repo.get!(Room, participant.room_id)
|
||||
idea = Repo.get_by(Idea, id: idea_id, room_id: room.id)
|
||||
|
||||
cond do
|
||||
not room_open?(room) -> {:error, :closed}
|
||||
is_nil(idea) -> {:error, :not_found}
|
||||
voted?(participant, idea.id) -> {:error, :already_voted}
|
||||
votes_used(participant) >= room.votes_per_user -> {:error, :limit_reached}
|
||||
true -> insert_vote(room, participant, idea)
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_vote(room, participant, idea) do
|
||||
%Vote{}
|
||||
|> Vote.changeset(%{idea_id: idea.id, participant_id: participant.id})
|
||||
|> Repo.insert()
|
||||
|> case do
|
||||
{:ok, vote} ->
|
||||
broadcast(room.id, :votes_changed)
|
||||
{:ok, vote}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Removes a participant's vote for an idea, if the room is still open."
|
||||
def unvote(%Participant{} = participant, idea_id) do
|
||||
room = Repo.get!(Room, participant.room_id)
|
||||
|
||||
cond do
|
||||
not room_open?(room) ->
|
||||
{:error, :closed}
|
||||
|
||||
true ->
|
||||
case Repo.get_by(Vote, idea_id: idea_id, participant_id: participant.id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
vote ->
|
||||
{:ok, _} = Repo.delete(vote)
|
||||
broadcast(room.id, :votes_changed)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def voted?(%Participant{} = participant, idea_id) do
|
||||
Repo.exists?(
|
||||
from v in Vote, where: v.participant_id == ^participant.id and v.idea_id == ^idea_id
|
||||
)
|
||||
end
|
||||
|
||||
def votes_used(%Participant{} = participant) do
|
||||
Repo.aggregate(from(v in Vote, where: v.participant_id == ^participant.id), :count)
|
||||
end
|
||||
|
||||
def votes_remaining(%Room{} = room, %Participant{} = participant) do
|
||||
max(room.votes_per_user - votes_used(participant), 0)
|
||||
end
|
||||
|
||||
@doc "Set of idea ids the participant has voted for (for UI highlighting)."
|
||||
def voted_idea_ids(%Participant{} = participant) do
|
||||
Repo.all(from v in Vote, where: v.participant_id == ^participant.id, select: v.idea_id)
|
||||
|> MapSet.new()
|
||||
end
|
||||
|
||||
## Results --------------------------------------------------------------
|
||||
|
||||
@doc "Map of `idea_id => vote_count` for a room."
|
||||
def vote_counts(%Room{} = room) do
|
||||
Repo.all(
|
||||
from v in Vote,
|
||||
join: i in Idea,
|
||||
on: i.id == v.idea_id,
|
||||
where: i.room_id == ^room.id,
|
||||
group_by: v.idea_id,
|
||||
select: {v.idea_id, count(v.id)}
|
||||
)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc "Ideas with their vote counts, highest first (for the manage page)."
|
||||
def results(%Room{} = room) do
|
||||
counts = vote_counts(room)
|
||||
|
||||
room
|
||||
|> list_ideas()
|
||||
|> Enum.map(fn idea -> %{idea: idea, count: Map.get(counts, idea.id, 0)} end)
|
||||
|> Enum.sort_by(& &1.count, :desc)
|
||||
end
|
||||
|
||||
## Grouping mode --------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns the computed grouping as `%{groups: [...], ungrouped: [...]}` where
|
||||
each group is `%{leader: participant, opinion: idea, members: [participant]}`.
|
||||
Only meaningful after a grouping-mode room has been closed.
|
||||
"""
|
||||
def group_assignments(%Room{} = room) do
|
||||
members = room |> list_participants() |> Enum.filter(&(&1.role == "member"))
|
||||
counts = vote_counts(room)
|
||||
idea_by_participant = idea_by_participant(room)
|
||||
|
||||
leaders =
|
||||
members
|
||||
|> Enum.filter(& &1.is_leader)
|
||||
|> Enum.sort_by(&(-opinion_votes(&1, idea_by_participant, counts)))
|
||||
|
||||
groups =
|
||||
Enum.map(leaders, fn leader ->
|
||||
group_members =
|
||||
Enum.filter(members, fn p ->
|
||||
not p.is_leader and p.assigned_leader_id == leader.id
|
||||
end)
|
||||
|
||||
%{
|
||||
leader: leader,
|
||||
opinion: Map.get(idea_by_participant, leader.id),
|
||||
members: group_members
|
||||
}
|
||||
end)
|
||||
|
||||
ungrouped =
|
||||
Enum.filter(members, fn p -> not p.is_leader and is_nil(p.assigned_leader_id) end)
|
||||
|
||||
%{groups: groups, ungrouped: ungrouped}
|
||||
end
|
||||
|
||||
defp compute_grouping(room) do
|
||||
members = room |> list_participants() |> Enum.filter(&(&1.role == "member"))
|
||||
counts = vote_counts(room)
|
||||
idea_by_participant = idea_by_participant(room)
|
||||
|
||||
leaders =
|
||||
Enum.filter(members, fn p ->
|
||||
Map.has_key?(idea_by_participant, p.id) and
|
||||
opinion_votes(p, idea_by_participant, counts) >= room.leader_threshold
|
||||
end)
|
||||
|
||||
regular = members -- leaders
|
||||
|
||||
Repo.transaction(fn ->
|
||||
if leaders == [] do
|
||||
Enum.each(members, &set_group(&1, false, nil))
|
||||
else
|
||||
Enum.each(leaders, &set_group(&1, true, &1.id))
|
||||
|
||||
regular
|
||||
|> assign_regular_members(leaders, room, idea_by_participant)
|
||||
|> Enum.each(fn {member, leader_id} -> set_group(member, false, leader_id) end)
|
||||
end
|
||||
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
Repo.update!(Ecto.Changeset.change(room, grouped_at: now))
|
||||
end)
|
||||
|> case do
|
||||
{:ok, updated_room} -> updated_room
|
||||
end
|
||||
end
|
||||
|
||||
# Assigns non-leader members to leader groups minimising unmet preferences via
|
||||
# the Hungarian algorithm. Returns `[{member, leader_id}]`.
|
||||
defp assign_regular_members([], _leaders, _room, _idea_by_participant), do: []
|
||||
|
||||
defp assign_regular_members(regular, leaders, room, idea_by_participant) do
|
||||
m = length(regular)
|
||||
l = length(leaders)
|
||||
base = div(m, l)
|
||||
remainder = rem(m, l)
|
||||
|
||||
# Balanced capacity per leader; expanded into one slot per capacity unit.
|
||||
caps = Enum.map(0..(l - 1), fn i -> base + if(i < remainder, do: 1, else: 0) end)
|
||||
|
||||
slot_leaders =
|
||||
Enum.flat_map(Enum.zip(leaders, caps), fn {leader, cap} ->
|
||||
List.duplicate(leader, cap)
|
||||
end)
|
||||
|
||||
voted_by = votes_by_participant(room)
|
||||
|
||||
cost =
|
||||
Enum.map(regular, fn member ->
|
||||
voted = Map.get(voted_by, member.id, MapSet.new())
|
||||
|
||||
Enum.map(slot_leaders, fn leader ->
|
||||
opinion = Map.get(idea_by_participant, leader.id)
|
||||
|
||||
if opinion && MapSet.member?(voted, opinion.id), do: 0, else: 1
|
||||
end)
|
||||
end)
|
||||
|
||||
{_total, assignment} = Hungarian.min_cost_assignment(cost)
|
||||
|
||||
regular
|
||||
|> Enum.zip(assignment)
|
||||
|> Enum.map(fn {member, slot_index} ->
|
||||
leader = Enum.at(slot_leaders, slot_index)
|
||||
{member, leader.id}
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_group(participant, is_leader, leader_id) do
|
||||
participant
|
||||
|> Ecto.Changeset.change(is_leader: is_leader, assigned_leader_id: leader_id)
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
defp idea_by_participant(room) do
|
||||
room
|
||||
|> list_ideas()
|
||||
|> Map.new(fn idea -> {idea.participant_id, idea} end)
|
||||
end
|
||||
|
||||
defp opinion_votes(participant, idea_by_participant, counts) do
|
||||
case Map.get(idea_by_participant, participant.id) do
|
||||
nil -> 0
|
||||
idea -> Map.get(counts, idea.id, 0)
|
||||
end
|
||||
end
|
||||
|
||||
defp votes_by_participant(room) do
|
||||
Repo.all(
|
||||
from v in Vote,
|
||||
join: i in Idea,
|
||||
on: i.id == v.idea_id,
|
||||
where: i.room_id == ^room.id,
|
||||
select: {v.participant_id, v.idea_id}
|
||||
)
|
||||
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|
||||
|> Map.new(fn {participant_id, idea_ids} -> {participant_id, MapSet.new(idea_ids)} end)
|
||||
end
|
||||
|
||||
## Helpers --------------------------------------------------------------
|
||||
|
||||
defp generate_token do
|
||||
:crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false)
|
||||
end
|
||||
|
||||
defp unique_room_number(attempts \\ 0)
|
||||
|
||||
defp unique_room_number(attempts) when attempts < 20 do
|
||||
number = Integer.to_string(:rand.uniform(900_000) + 99_999)
|
||||
|
||||
if Repo.exists?(from r in Room, where: r.room_number == ^number) do
|
||||
unique_room_number(attempts + 1)
|
||||
else
|
||||
number
|
||||
end
|
||||
end
|
||||
|
||||
defp unique_room_number(_attempts) do
|
||||
raise "could not allocate a unique room number"
|
||||
end
|
||||
|
||||
defp tap_broadcast({:ok, _} = result, room, event) do
|
||||
broadcast(room.id, event)
|
||||
result
|
||||
end
|
||||
|
||||
defp tap_broadcast(other, _room, _event), do: other
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
defmodule Rsh26pool.Voting.Hungarian do
|
||||
@moduledoc """
|
||||
Solves the assignment problem — a minimum-cost perfect matching in a weighted
|
||||
bipartite graph — with the Hungarian (Kuhn–Munkres) algorithm in O(n^3).
|
||||
|
||||
This is the algorithm the proposal calls for when placing members into groups
|
||||
in grouping mode: given a cost of assigning each member to each group slot, it
|
||||
finds the assignment with the lowest total cost.
|
||||
|
||||
`min_cost_assignment/1` takes an `n x m` cost matrix (a list of `n` rows, each
|
||||
a list of `m` numbers) with `n <= m`, and returns `{total_cost, assignment}`
|
||||
where `assignment` is a list of length `n` and `Enum.at(assignment, i)` is the
|
||||
0-based column matched to row `i`.
|
||||
"""
|
||||
|
||||
@inf 1_000_000_000
|
||||
|
||||
@spec min_cost_assignment([[number()]]) :: {number(), [non_neg_integer()]}
|
||||
def min_cost_assignment([]), do: {0, []}
|
||||
|
||||
def min_cost_assignment(cost) when is_list(cost) do
|
||||
n = length(cost)
|
||||
m = length(hd(cost))
|
||||
|
||||
if n > m do
|
||||
raise ArgumentError, "cost matrix must have rows <= cols (got #{n} x #{m})"
|
||||
end
|
||||
|
||||
# 1-indexed access into an immutable tuple-of-tuples.
|
||||
c = cost |> Enum.map(&List.to_tuple/1) |> List.to_tuple()
|
||||
cost_at = fn i, j -> c |> elem(i - 1) |> elem(j - 1) end
|
||||
|
||||
u = fill(n + 1, 0)
|
||||
v = fill(m + 1, 0)
|
||||
p = fill(m + 1, 0)
|
||||
way = fill(m + 1, 0)
|
||||
|
||||
{_u, _v, p, _way} =
|
||||
Enum.reduce(1..n, {u, v, p, way}, fn i, {u, v, p, way} ->
|
||||
phase(i, m, cost_at, u, v, p, way)
|
||||
end)
|
||||
|
||||
# p[j] holds the row matched to column j; invert to get column-per-row.
|
||||
row_to_col =
|
||||
Enum.reduce(1..m, %{}, fn j, acc ->
|
||||
row = elem(p, j)
|
||||
if row >= 1 and row <= n, do: Map.put(acc, row, j - 1), else: acc
|
||||
end)
|
||||
|
||||
assignment = Enum.map(1..n, &Map.fetch!(row_to_col, &1))
|
||||
|
||||
total =
|
||||
assignment
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.reduce(0, fn {col0, i}, sum -> sum + cost_at.(i, col0 + 1) end)
|
||||
|
||||
{total, assignment}
|
||||
end
|
||||
|
||||
# Augmenting phase for row `i` (see the classic e-maxx Hungarian description).
|
||||
defp phase(i, m, cost_at, u, v, p, way) do
|
||||
p = put_elem(p, 0, i)
|
||||
minv = fill(m + 1, @inf)
|
||||
used = fill(m + 1, false)
|
||||
walk(0, m, cost_at, u, v, p, way, minv, used)
|
||||
end
|
||||
|
||||
defp walk(j0, m, cost_at, u, v, p, way, minv, used) do
|
||||
used = put_elem(used, j0, true)
|
||||
i0 = elem(p, j0)
|
||||
ui0 = elem(u, i0)
|
||||
|
||||
{delta, j1, minv, way} =
|
||||
Enum.reduce(1..m, {@inf, -1, minv, way}, fn j, {delta, j1, minv, way} ->
|
||||
if elem(used, j) do
|
||||
{delta, j1, minv, way}
|
||||
else
|
||||
cur = cost_at.(i0, j) - ui0 - elem(v, j)
|
||||
|
||||
{minv, way} =
|
||||
if cur < elem(minv, j),
|
||||
do: {put_elem(minv, j, cur), put_elem(way, j, j0)},
|
||||
else: {minv, way}
|
||||
|
||||
mvj = elem(minv, j)
|
||||
if mvj < delta, do: {mvj, j, minv, way}, else: {delta, j1, minv, way}
|
||||
end
|
||||
end)
|
||||
|
||||
{u, v, minv} =
|
||||
Enum.reduce(0..m, {u, v, minv}, fn j, {u, v, minv} ->
|
||||
if elem(used, j) do
|
||||
pj = elem(p, j)
|
||||
{put_elem(u, pj, elem(u, pj) + delta), put_elem(v, j, elem(v, j) - delta), minv}
|
||||
else
|
||||
{u, v, put_elem(minv, j, elem(minv, j) - delta)}
|
||||
end
|
||||
end)
|
||||
|
||||
if elem(p, j1) == 0 do
|
||||
{u, v, reconstruct(j1, p, way), way}
|
||||
else
|
||||
walk(j1, m, cost_at, u, v, p, way, minv, used)
|
||||
end
|
||||
end
|
||||
|
||||
defp reconstruct(j0, p, way) do
|
||||
j1 = elem(way, j0)
|
||||
p = put_elem(p, j0, elem(p, j1))
|
||||
if j1 == 0, do: p, else: reconstruct(j1, p, way)
|
||||
end
|
||||
|
||||
defp fill(size, value), do: value |> List.duplicate(size) |> List.to_tuple()
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
defmodule Rsh26pool.Voting.Idea do
|
||||
@moduledoc """
|
||||
A voting option. In normal mode it is an idea posted by a participant; in
|
||||
grouping mode it is the participant's opinion (one per participant).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.{Participant, Room, Vote}
|
||||
|
||||
schema "ideas" do
|
||||
field :text, :string
|
||||
|
||||
belongs_to :room, Room
|
||||
belongs_to :participant, Participant
|
||||
has_many :votes, Vote
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(idea, attrs) do
|
||||
idea
|
||||
|> cast(attrs, [:text])
|
||||
|> validate_required([:text], message: "内容を入力してください")
|
||||
|> validate_length(:text, max: 200, message: "200文字以内で入力してください")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
defmodule Rsh26pool.Voting.Participant do
|
||||
@moduledoc """
|
||||
A user taking part in a room. Identified across requests by a random `token`
|
||||
stored in the session cookie. Owners have `role == "owner"`.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.Room
|
||||
|
||||
schema "participants" do
|
||||
field :token, :string
|
||||
field :name, :string
|
||||
field :role, :string, default: "member"
|
||||
|
||||
# Grouping-mode results, filled in when voting closes.
|
||||
field :is_leader, :boolean, default: false
|
||||
|
||||
belongs_to :room, Room
|
||||
belongs_to :assigned_leader, __MODULE__, foreign_key: :assigned_leader_id
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc "Changeset for the user-supplied fields (their display name)."
|
||||
def changeset(participant, attrs) do
|
||||
participant
|
||||
|> cast(attrs, [:name])
|
||||
|> validate_required([:name], message: "名前を入力してください")
|
||||
|> validate_length(:name, max: 40, message: "名前は40文字以内で入力してください")
|
||||
|> unique_constraint(:token)
|
||||
end
|
||||
|
||||
@doc "Owner? convenience predicate."
|
||||
def owner?(%__MODULE__{role: "owner"}), do: true
|
||||
def owner?(%__MODULE__{}), do: false
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
defmodule Rsh26pool.Voting.Room do
|
||||
@moduledoc """
|
||||
A voting room. Identified publicly by a short `room_number` used in URLs.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.{Idea, Participant}
|
||||
|
||||
schema "rooms" do
|
||||
field :room_number, :string
|
||||
field :name, :string
|
||||
field :votes_per_user, :integer, default: 3
|
||||
field :status, :string, default: "open"
|
||||
field :grouping_mode, :boolean, default: false
|
||||
field :leader_threshold, :integer, default: 2
|
||||
field :closes_at, :utc_datetime
|
||||
field :grouped_at, :utc_datetime
|
||||
|
||||
# Virtual field backing the `datetime-local` input (wall-clock JST).
|
||||
field :closes_at_local, :string, virtual: true
|
||||
|
||||
has_many :participants, Participant
|
||||
has_many :ideas, Idea
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc "Changeset used when the owner creates a new room."
|
||||
def create_changeset(room, attrs) do
|
||||
room
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:votes_per_user,
|
||||
:grouping_mode,
|
||||
:leader_threshold,
|
||||
:closes_at_local
|
||||
])
|
||||
|> validate_required([:name, :votes_per_user], message: "入力してください")
|
||||
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
|
||||
|> validate_number(:votes_per_user,
|
||||
greater_than_or_equal_to: 1,
|
||||
less_than_or_equal_to: 50,
|
||||
message: "1〜50の数値を入力してください"
|
||||
)
|
||||
|> validate_number(:leader_threshold,
|
||||
greater_than_or_equal_to: 1,
|
||||
less_than_or_equal_to: 50,
|
||||
message: "1〜50の数値を入力してください"
|
||||
)
|
||||
|> put_closes_at()
|
||||
end
|
||||
|
||||
@doc "Changeset for owner-editable settings on the manage page."
|
||||
def settings_changeset(room, attrs) do
|
||||
room
|
||||
|> cast(attrs, [:name])
|
||||
|> validate_required([:name], message: "入力してください")
|
||||
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
|
||||
end
|
||||
|
||||
defp put_closes_at(changeset) do
|
||||
case get_field(changeset, :closes_at_local) do
|
||||
blank when blank in [nil, ""] ->
|
||||
put_change(changeset, :closes_at, nil)
|
||||
|
||||
value ->
|
||||
case Rsh26pool.JST.parse_local(value) do
|
||||
{:ok, dt} ->
|
||||
if DateTime.compare(dt, DateTime.utc_now()) == :gt do
|
||||
put_change(changeset, :closes_at, dt)
|
||||
else
|
||||
add_error(changeset, :closes_at_local, "未来の日時を指定してください")
|
||||
end
|
||||
|
||||
:error ->
|
||||
add_error(changeset, :closes_at_local, "日時の形式が正しくありません")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Rsh26pool.Voting.Vote do
|
||||
@moduledoc """
|
||||
A single vote cast by a participant for an idea. A participant may vote for a
|
||||
given idea at most once (enforced by a unique index).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.{Idea, Participant}
|
||||
|
||||
schema "votes" do
|
||||
belongs_to :idea, Idea
|
||||
belongs_to :participant, Participant
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(vote, attrs) do
|
||||
vote
|
||||
|> cast(attrs, [:idea_id, :participant_id])
|
||||
|> validate_required([:idea_id, :participant_id])
|
||||
|> unique_constraint([:idea_id, :participant_id],
|
||||
name: :votes_idea_id_participant_id_index,
|
||||
message: "は既に投票済みです"
|
||||
)
|
||||
|> foreign_key_constraint(:idea_id)
|
||||
|> foreign_key_constraint(:participant_id)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user