Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
56d3154
Add preliminary string collector answer UI
backspace Feb 28, 2026
ba8ae5f
Add grouping/sorting of specifications table
backspace Feb 28, 2026
006e23e
Add preliminary answer gathering via sensors
backspace Feb 28, 2026
c2e885d
Add cleanup for Bluetooth sensor
backspace Feb 28, 2026
19b1435
Add live-updating of answers upon return to list
backspace Feb 28, 2026
e447f29
Add support for clicking label to choose item
backspace Feb 28, 2026
fc3a833
Add fix to not submit unchanged answers
backspace Feb 28, 2026
ebd335c
Remove order requirement for these concepts
backspace Feb 28, 2026
63a4e7e
Change answer validation to sometimes need order
backspace Feb 28, 2026
734673a
Add full-stack test for editing answers
backspace Feb 28, 2026
c38468b
Change deletion to only finalise on save
backspace Feb 28, 2026
6b5d32a
Add specification creation
backspace Mar 1, 2026
5534107
Add test fix
backspace Mar 1, 2026
3dec561
Merge branch 'main' into specification-answers
backspace Mar 1, 2026
8e178cc
Add warning/gating for manual sensor answers
backspace Mar 1, 2026
9248780
Merge remote-tracking branch 'origin/main' into validation
backspace Mar 13, 2026
1a363ce
Add preliminary validation feature
backspace Mar 13, 2026
cf0e84c
Update platform metadata
backspace Mar 13, 2026
7d10e3a
Add full-stack validation test
backspace Mar 13, 2026
13fe24f
Add role management UI
backspace Mar 16, 2026
3b3337d
Change supervision to not require admin status
backspace Mar 16, 2026
51ee98e
Rename supervision role
backspace Mar 16, 2026
cce0920
Fix ordering of custom validation subroutes
backspace Mar 18, 2026
35ea90d
Add unvalidated section to supervisor dashboard
backspace Mar 18, 2026
4a23836
Add creator to specification list items
backspace Mar 18, 2026
6e1fd4b
Update validation test
backspace Mar 18, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci-waydowntown-full-stack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
api-level: 24
arch: x86_64
profile: Nexus 6
script: flutter test integration_test/token_refresh_test.dart integration_test/string_collector_test.dart integration_test/fill_in_the_blank_test.dart integration_test/team_game_test.dart integration_test/edit_specification_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json
script: flutter test integration_test/token_refresh_test.dart integration_test/string_collector_test.dart integration_test/fill_in_the_blank_test.dart integration_test/team_game_test.dart integration_test/edit_specification_test.dart integration_test/validation_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json

- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
Expand Down
65 changes: 65 additions & 0 deletions registrations/lib/registrations/accounts.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
defmodule Registrations.Accounts do
@moduledoc false
import Ecto.Query, warn: false

alias Registrations.Repo
alias Registrations.UserRole

def list_user_roles(user) do
from(r in UserRole, where: r.user_id == ^user.id)
|> Repo.all()
|> Repo.preload([:user, :assigned_by])
end

def list_all_user_roles(filters \\ %{}) do
UserRole
|> filter_roles_query(filters)
|> Repo.all()
|> Repo.preload([:user, :assigned_by])
end

defp filter_roles_query(query, filters) do
Enum.reduce(filters, query, fn
{"role", role}, query when is_binary(role) ->
from(r in query, where: r.role == ^role)

_, query ->
query
end)
end

def get_user_role!(id) do
UserRole
|> Repo.get!(id)
|> Repo.preload([:user, :assigned_by])
end

def assign_role(user_id, role, assigned_by_id \\ nil) do
%UserRole{}
|> UserRole.changeset(%{user_id: user_id, role: role, assigned_by_id: assigned_by_id})
|> Repo.insert()
|> case do
{:ok, user_role} -> {:ok, Repo.preload(user_role, [:user, :assigned_by])}
error -> error
end
end

def remove_role(id) do
UserRole
|> Repo.get!(id)
|> Repo.delete()
end

def has_role?(user, role) do
Repo.exists?(from(r in UserRole, where: r.user_id == ^user.id and r.role == ^role))
end

def list_users_with_role(role) do
from(r in UserRole,
where: r.role == ^role,
preload: [:user]
)
|> Repo.all()
|> Enum.map(& &1.user)
end
end
30 changes: 30 additions & 0 deletions registrations/lib/registrations/user_role.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Registrations.UserRole do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset

@primary_key {:id, :binary_id, autogenerate: true}

@valid_roles ~w(validator validation_supervisor)

schema "user_roles" do
field(:role, :string)

belongs_to(:user, RegistrationsWeb.User, type: :binary_id)
belongs_to(:assigned_by, RegistrationsWeb.User, type: :binary_id, foreign_key: :assigned_by_id)

timestamps()
end

@doc false
def changeset(user_role, attrs) do
user_role
|> cast(attrs, [:user_id, :role, :assigned_by_id])
|> validate_required([:user_id, :role])
|> validate_inclusion(:role, @valid_roles)
|> unique_constraint([:user_id, :role])
|> assoc_constraint(:user)
end

def valid_roles, do: @valid_roles
end
107 changes: 106 additions & 1 deletion registrations/lib/registrations/waydowntown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ defmodule Registrations.Waydowntown do
alias Registrations.Waydowntown.Reveal
alias Registrations.Waydowntown.Run
alias Registrations.Waydowntown.Specification
alias Registrations.Waydowntown.SpecificationValidation
alias Registrations.Waydowntown.Submission
alias Registrations.Waydowntown.ValidationComment
alias RegistrationsWeb.User

def update_user(user, attrs) do
Expand Down Expand Up @@ -338,7 +340,7 @@ defmodule Registrations.Waydowntown do
end

def list_specifications do
Specification |> Repo.all() |> Repo.preload(answers: [:region], region: [parent: [parent: [:parent]]])
Specification |> Repo.all() |> Repo.preload(answers: [:region], region: [parent: [parent: [:parent]]], creator: user_preload_query())
end

def list_specifications_for(user) do
Expand Down Expand Up @@ -904,4 +906,107 @@ defmodule Registrations.Waydowntown do
|> Repo.get!(id)
|> Repo.preload([:answer, :user])
end

# Specification Validations

defp validation_preloads do
user_query = user_preload_query()

[
specification: [answers: [:region], region: [parent: [parent: [:parent]]]],
validation_comments: [:answer],
validator: user_query,
assigned_by: user_query,
run: []
]
end

def create_specification_validation(attrs) do
%SpecificationValidation{}
|> SpecificationValidation.changeset(attrs)
|> Repo.insert()
|> case do
{:ok, validation} -> {:ok, Repo.preload(validation, validation_preloads())}
error -> error
end
end

def get_specification_validation!(id) do
SpecificationValidation
|> Repo.get!(id)
|> Repo.preload(validation_preloads())
end

def update_specification_validation(%SpecificationValidation{} = validation, attrs, role) do
validation
|> SpecificationValidation.changeset(attrs)
|> SpecificationValidation.validate_status_transition(validation.status, role)
|> Repo.update()
|> case do
{:ok, validation} -> {:ok, Repo.preload(validation, validation_preloads())}
error -> error
end
end

def list_validations_for_validator(user) do
from(v in SpecificationValidation, where: v.validator_id == ^user.id, order_by: [desc: v.inserted_at])
|> Repo.all()
|> Repo.preload(validation_preloads())
end

def list_validations_for_supervisor(user) do
from(v in SpecificationValidation, where: v.assigned_by_id == ^user.id, order_by: [desc: v.inserted_at])
|> Repo.all()
|> Repo.preload(validation_preloads())
end

def list_validations_for_specification(specification_id) do
from(v in SpecificationValidation, where: v.specification_id == ^specification_id, order_by: [desc: v.inserted_at])
|> Repo.all()
|> Repo.preload(validation_preloads())
end

def list_specification_validations do
SpecificationValidation
|> Repo.all()
|> Repo.preload(validation_preloads())
end

# Validation Comments

def create_validation_comment(attrs) do
%ValidationComment{}
|> ValidationComment.changeset(attrs)
|> Repo.insert()
|> case do
{:ok, comment} -> {:ok, Repo.preload(comment, [:answer, :specification_validation])}
error -> error
end
end

def get_validation_comment!(id) do
ValidationComment
|> Repo.get!(id)
|> Repo.preload([:answer, :specification_validation])
end

def update_validation_comment(%ValidationComment{} = comment, attrs) do
comment
|> ValidationComment.changeset(attrs)
|> Repo.update()
|> case do
{:ok, comment} -> {:ok, Repo.preload(comment, [:answer, :specification_validation])}
error -> error
end
end

def delete_validation_comment(%ValidationComment{} = comment) do
Repo.delete(comment)
end

def list_validation_comments(validation_id) do
from(c in ValidationComment, where: c.specification_validation_id == ^validation_id)
|> Repo.all()
|> Repo.preload([:answer, :specification_validation])
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
defmodule Registrations.Waydowntown.SpecificationValidation do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset

@primary_key {:id, :binary_id, autogenerate: true}
@schema_prefix "waydowntown"

@valid_statuses ~w(assigned in_progress submitted accepted rejected)
@validator_transitions %{
"assigned" => ["in_progress"],
"in_progress" => ["submitted"]
}
@supervisor_transitions %{
"submitted" => ["accepted", "rejected"]
}

schema "specification_validations" do
field(:status, :string, default: "assigned")
field(:play_mode, :string)
field(:overall_notes, :string)

belongs_to(:specification, Registrations.Waydowntown.Specification, type: :binary_id)
belongs_to(:validator, RegistrationsWeb.User, type: :binary_id, foreign_key: :validator_id)
belongs_to(:assigned_by, RegistrationsWeb.User, type: :binary_id, foreign_key: :assigned_by_id)
belongs_to(:run, Registrations.Waydowntown.Run, type: :binary_id)

has_many(:validation_comments, Registrations.Waydowntown.ValidationComment, on_delete: :delete_all)

timestamps()
end

@doc false
def changeset(validation, attrs) do
validation
|> cast(attrs, [:specification_id, :validator_id, :assigned_by_id, :status, :play_mode, :overall_notes, :run_id])
|> validate_required([:specification_id, :validator_id, :assigned_by_id])
|> validate_inclusion(:status, @valid_statuses)
|> validate_inclusion(:play_mode, ~w(blind with_answers), message: "must be 'blind' or 'with_answers'")
|> unique_constraint([:specification_id, :validator_id])
|> assoc_constraint(:specification)
end

def validate_status_transition(changeset, current_status, role) do
new_status = get_change(changeset, :status)

if new_status do
transitions =
case role do
:validator -> @validator_transitions
:supervisor -> @supervisor_transitions
end

allowed = Map.get(transitions, current_status, [])

if new_status in allowed do
changeset
else
add_error(changeset, :status, "cannot transition from '#{current_status}' to '#{new_status}'")
end
else
changeset
end
end

def valid_statuses, do: @valid_statuses
end
42 changes: 42 additions & 0 deletions registrations/lib/registrations/waydowntown/validation_comment.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Registrations.Waydowntown.ValidationComment do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset

@primary_key {:id, :binary_id, autogenerate: true}
@schema_prefix "waydowntown"

@valid_fields ~w(answer label hint)

schema "validation_comments" do
field(:field, :string)
field(:comment, :string)
field(:suggested_value, :string)

belongs_to(:specification_validation, Registrations.Waydowntown.SpecificationValidation, type: :binary_id)
belongs_to(:answer, Registrations.Waydowntown.Answer, type: :binary_id)

timestamps()
end

@doc false
def changeset(comment, attrs) do
comment
|> cast(attrs, [:specification_validation_id, :answer_id, :field, :comment, :suggested_value])
|> validate_required([:specification_validation_id])
|> validate_inclusion(:field, @valid_fields)
|> validate_has_content()
|> assoc_constraint(:specification_validation)
end

defp validate_has_content(changeset) do
comment = get_field(changeset, :comment)
suggested_value = get_field(changeset, :suggested_value)

if is_nil(comment) and is_nil(suggested_value) do
add_error(changeset, :comment, "at least one of comment or suggested_value is required")
else
changeset
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ defmodule RegistrationsWeb.SessionController do

# FIXME can this be tested? It uses HTTPOnly cookie
def show(conn, params) do
user = conn.assigns[:current_user]
user = Registrations.Repo.preload(user, :user_roles)
conn = assign(conn, :current_user, user)
render(conn, "show.json", %{conn: conn, params: params})
end

Expand Down
Loading
Loading