diff --git a/core/frameworks/utility/list.ex b/core/frameworks/utility/list.ex index 2b3b3ad98..d5213d6db 100644 --- a/core/frameworks/utility/list.ex +++ b/core/frameworks/utility/list.ex @@ -4,6 +4,7 @@ defmodule Frameworks.Utility.List do def append_if(list, nil), do: list def append_if(list, term), do: append(list, term) + def append(list, list2) when is_list(list2), do: list ++ list2 def append(list, closure) when is_function(closure, 0), do: list ++ [closure.()] def append(list, element), do: list ++ [element] diff --git a/core/lib/core/factories.ex b/core/lib/core/factories.ex index 02eb5dbe5..2c60537d4 100644 --- a/core/lib/core/factories.ex +++ b/core/lib/core/factories.ex @@ -61,6 +61,14 @@ defmodule Core.Factories do }) end + def build(:external_user) do + %ExternalSignIn.User{ + external_id: Faker.UUID.v4(), + organisation: Faker.Company.name(), + user: build(:member) + } + end + def build(:admin) do :member |> build(%{ @@ -279,6 +287,10 @@ defmodule Core.Factories do build(:role_assignment, %{role: :owner, principal_id: GreenLight.Principal.id(user)}) end + def build(:participant, %{user: user}) do + build(:role_assignment, %{role: :participant, principal_id: GreenLight.Principal.id(user)}) + end + def build(:auth_node, %{} = attributes) do %Authorization.Node{} |> struct!(attributes) @@ -527,6 +539,11 @@ defmodule Core.Factories do |> struct!(attributes) end + def build(:external_user, %{} = attributes) do + build(:external_user) + |> struct!(attributes) + end + def build(:member, %{} = attributes) do {password, attributes} = Map.pop(attributes, :password) diff --git a/core/lib/external_account.ex b/core/lib/external_sign_in.ex similarity index 100% rename from core/lib/external_account.ex rename to core/lib/external_sign_in.ex diff --git a/core/lib/external_account/user.ex b/core/lib/external_sign_in/user.ex similarity index 100% rename from core/lib/external_account/user.ex rename to core/lib/external_sign_in/user.ex diff --git a/core/priv/gettext/de/LC_MESSAGES/eyra-assignment.po b/core/priv/gettext/de/LC_MESSAGES/eyra-assignment.po index e1fd6a27b..a06e5f59e 100644 --- a/core/priv/gettext/de/LC_MESSAGES/eyra-assignment.po +++ b/core/priv/gettext/de/LC_MESSAGES/eyra-assignment.po @@ -428,3 +428,31 @@ msgstr[1] "" #, elixir-autogen, elixir-format msgid "atom.tag" msgstr "" + +#, elixir-autogen, elixir-format +msgid "export.progress.button" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "monitor.description" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.header.consent" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.header.participant" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.no" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.not.applicable" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.yes" +msgstr "" diff --git a/core/priv/gettext/de/LC_MESSAGES/eyra-enums.po b/core/priv/gettext/de/LC_MESSAGES/eyra-enums.po index 64ba5c750..6e439ee76 100644 --- a/core/priv/gettext/de/LC_MESSAGES/eyra-enums.po +++ b/core/priv/gettext/de/LC_MESSAGES/eyra-enums.po @@ -422,3 +422,19 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "assignment_languages.de" msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.accepted" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.completed" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.pending" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.rejected" +msgstr "" diff --git a/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po b/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po index acd7c3ff0..0be62d9c7 100644 --- a/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po +++ b/core/priv/gettext/en/LC_MESSAGES/eyra-assignment.po @@ -427,3 +427,31 @@ msgstr[1] "%{count} participants" #, elixir-autogen, elixir-format msgid "atom.tag" msgstr "Assignment" + +#, elixir-autogen, elixir-format +msgid "export.progress.button" +msgstr "Progress report" + +#, elixir-autogen, elixir-format +msgid "monitor.description" +msgstr "Here comes a description" + +#, elixir-autogen, elixir-format +msgid "progress.header.consent" +msgstr "Consent" + +#, elixir-autogen, elixir-format +msgid "progress.header.participant" +msgstr "Participant" + +#, elixir-autogen, elixir-format +msgid "progress.no" +msgstr "no" + +#, elixir-autogen, elixir-format +msgid "progress.not.applicable" +msgstr "n/a" + +#, elixir-autogen, elixir-format +msgid "progress.yes" +msgstr "yes" diff --git a/core/priv/gettext/en/LC_MESSAGES/eyra-enums.po b/core/priv/gettext/en/LC_MESSAGES/eyra-enums.po index d3fd9d12e..64d6a79c2 100644 --- a/core/priv/gettext/en/LC_MESSAGES/eyra-enums.po +++ b/core/priv/gettext/en/LC_MESSAGES/eyra-enums.po @@ -421,3 +421,19 @@ msgstr "Creator" #, elixir-autogen, elixir-format, fuzzy msgid "assignment_languages.de" msgstr "German" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.accepted" +msgstr "accepted" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.completed" +msgstr "finished" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.pending" +msgstr "started" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.rejected" +msgstr "rejected" diff --git a/core/priv/gettext/eyra-assignment.pot b/core/priv/gettext/eyra-assignment.pot index e1d2c8a01..28778b880 100644 --- a/core/priv/gettext/eyra-assignment.pot +++ b/core/priv/gettext/eyra-assignment.pot @@ -427,3 +427,31 @@ msgstr[1] "" #, elixir-autogen, elixir-format msgid "atom.tag" msgstr "" + +#, elixir-autogen, elixir-format +msgid "export.progress.button" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "monitor.description" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.header.consent" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.header.participant" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.no" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.not.applicable" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.yes" +msgstr "" diff --git a/core/priv/gettext/eyra-enums.pot b/core/priv/gettext/eyra-enums.pot index 0ee8379a4..4480687cb 100644 --- a/core/priv/gettext/eyra-enums.pot +++ b/core/priv/gettext/eyra-enums.pot @@ -421,3 +421,19 @@ msgstr "" #, elixir-autogen, elixir-format msgid "assignment_languages.de" msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.accepted" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.completed" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.pending" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.rejected" +msgstr "" diff --git a/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po b/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po index dc4b7888b..91d1af64a 100644 --- a/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po +++ b/core/priv/gettext/nl/LC_MESSAGES/eyra-assignment.po @@ -427,3 +427,31 @@ msgstr[1] "" #, elixir-autogen, elixir-format msgid "atom.tag" msgstr "" + +#, elixir-autogen, elixir-format +msgid "export.progress.button" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "monitor.description" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.header.consent" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.header.participant" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.no" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.not.applicable" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "progress.yes" +msgstr "" diff --git a/core/priv/gettext/nl/LC_MESSAGES/eyra-enums.po b/core/priv/gettext/nl/LC_MESSAGES/eyra-enums.po index 4d9f7911b..357e66f51 100644 --- a/core/priv/gettext/nl/LC_MESSAGES/eyra-enums.po +++ b/core/priv/gettext/nl/LC_MESSAGES/eyra-enums.po @@ -421,3 +421,19 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "assignment_languages.de" msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.accepted" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.completed" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.pending" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "crew_task_status.rejected" +msgstr "" diff --git a/core/systems/assignment/_private.ex b/core/systems/assignment/_private.ex index 4999c3349..a249bc9b5 100644 --- a/core/systems/assignment/_private.ex +++ b/core/systems/assignment/_private.ex @@ -26,6 +26,11 @@ defmodule Systems.Assignment.Private do def get_template(:questionnaire), do: %Assignment.TemplateQuestionnaire{id: :questionnaire} + def declined_consent?(assignment, user_ref) do + Monitor.Public.event({assignment, :declined, user_ref}) + |> Monitor.Public.exists?() + end + def log_performance_event( %Assignment.Model{} = assignment, %Crew.TaskModel{} = crew_task, @@ -147,10 +152,18 @@ defmodule Systems.Assignment.Private do ["item=#{item_id}"] end + def task_identifier( + assignment, + workflow_item, + %Crew.MemberModel{id: member_id} + ) do + task_identifier(assignment, workflow_item, member_id) + end + def task_identifier( %{special: :data_donation}, %Workflow.ItemModel{id: item_id}, - %Crew.MemberModel{id: member_id} + member_id ) do ["item=#{item_id}", "member=#{member_id}"] end @@ -158,7 +171,7 @@ defmodule Systems.Assignment.Private do def task_identifier( %{special: :benchmark_challenge}, %Workflow.ItemModel{id: item_id}, - %Crew.MemberModel{id: member_id} + member_id ) do ["item=#{item_id}", "member=#{member_id}"] end @@ -166,7 +179,7 @@ defmodule Systems.Assignment.Private do def task_identifier( %{special: :questionnaire}, %Workflow.ItemModel{id: item_id}, - %Crew.MemberModel{id: member_id} + member_id ) do ["item=#{item_id}", "member=#{member_id}"] end diff --git a/core/systems/assignment/_public.ex b/core/systems/assignment/_public.ex index 64e64e128..4df3a30c2 100644 --- a/core/systems/assignment/_public.ex +++ b/core/systems/assignment/_public.ex @@ -506,6 +506,32 @@ defmodule Systems.Assignment.Public do get!(id) |> cancel(user) end + @doc """ + Lists the participants of the assignment. + Returns a list of maps with the following keys: + * `user_id` + * `public_id` + * `external_id` + * `member_id` + """ + def list_participants(%Assignment.Model{} = assignment) do + participant_query(assignment) + |> Repo.all() + end + + def list_signatures(%Assignment.Model{consent_agreement_id: nil}) do + [] + end + + def list_signatures(%Assignment.Model{} = assignment) do + signature_query(assignment) + |> Repo.all() + end + + def list_tasks(%Assignment.Model{workflow: workflow}) do + Workflow.Public.list_items(workflow) + end + def get_task(tool, identifier) do %{crew: crew} = Assignment.Public.get_by_tool(tool, [:crew]) Crew.Public.get_task(crew, identifier) diff --git a/core/systems/assignment/_queries.ex b/core/systems/assignment/_queries.ex index a103bab00..12a5a5b0a 100644 --- a/core/systems/assignment/_queries.ex +++ b/core/systems/assignment/_queries.ex @@ -6,8 +6,10 @@ defmodule Systems.Assignment.Queries do import Frameworks.Utility.Query, only: [build: 3] alias Systems.Assignment + alias Systems.Crew alias Systems.Account alias Systems.Content + alias Systems.Consent alias Systems.Project def assignment_query() do @@ -58,4 +60,41 @@ defmodule Systems.Assignment.Queries do |> select([assignment: a], a.id) |> distinct(true) end + + def participant_query() do + from(Crew.MemberModel, as: :member) + end + + def participant_query(%Assignment.Model{crew: %{id: id, auth_node_id: auth_node_id}}) do + build(participant_query(), :member, [crew_id == ^id, user: [id != nil]]) + |> join(:left, [user: u], e in ExternalSignIn.User, on: u.id == e.user_id, as: :external_user) + |> join(:inner, [user: u], ra in Core.Authorization.RoleAssignment, + on: ra.principal_id == u.id, + as: :role_assignment + ) + |> where([role_assignment: ra], ra.role == :participant) + |> where([role_assignment: ra], ra.node_id == ^auth_node_id) + |> select([member: m, user: u, external_user: e], %{ + user_id: u.id, + member_id: m.id, + public_id: m.public_id, + external_id: e.external_id + }) + end + + def signature_query() do + from(Consent.SignatureModel, as: :signature) + end + + def signature_query(%Assignment.Model{consent_agreement_id: consent_agreement_id}) + when not is_nil(consent_agreement_id) do + build(signature_query(), :signature, + revision: [ + agreement: [ + id == ^consent_agreement_id + ] + ] + ) + |> select([signature: a], a.user_id) + end end diff --git a/core/systems/assignment/_routes.ex b/core/systems/assignment/_routes.ex index d30dbbf7f..8855f35f2 100644 --- a/core/systems/assignment/_routes.ex +++ b/core/systems/assignment/_routes.ex @@ -8,6 +8,7 @@ defmodule Systems.Assignment.Routes do live("/assignment/:id/content", ContentPage) get("/assignment/:id/invite", Controller, :invite) get("/assignment/:id/apply", Controller, :apply) + get("/assignment/:id/export", Controller, :export) get("/assignment/callback/:item", Controller, :callback) end diff --git a/core/systems/assignment/content_page_builder.ex b/core/systems/assignment/content_page_builder.ex index 4e2c7c698..f6917def3 100644 --- a/core/systems/assignment/content_page_builder.ex +++ b/core/systems/assignment/content_page_builder.ex @@ -300,6 +300,7 @@ defmodule Systems.Assignment.ContentPageBuilder do child = Fabric.prepare_child(fabric, :monitor, Assignment.MonitorView, %{ + assignment: assignment, number_widgets: number_widgets(assignment), progress_widgets: progress_widgets(assignment) }) diff --git a/core/systems/assignment/controller.ex b/core/systems/assignment/controller.ex index 47acc55d4..c68d3b419 100644 --- a/core/systems/assignment/controller.ex +++ b/core/systems/assignment/controller.ex @@ -1,9 +1,22 @@ defmodule Systems.Assignment.Controller do + alias Hex.Solver.Assignment use CoreWeb, :controller + import Frameworks.Utility.List, only: [append: 2, append_if: 3] + import Systems.Assignment.Private, only: [task_identifier: 3, declined_consent?: 2] + + alias Plug.Conn + alias CoreWeb.UI.Timestamp + alias Frameworks.Concept alias Systems.Assignment - alias Systems.Workflow alias Systems.Crew + alias Systems.Workflow + + @progress_header_participant dgettext("eyra-assignment", "progress.header.participant") + @progress_header_consent dgettext("eyra-assignment", "progress.header.consent") + @progress_not_applicable dgettext("eyra-assignment", "progress.not.applicable") + @progress_yes dgettext("eyra-assignment", "progress.yes") + @progress_no dgettext("eyra-assignment", "progress.no") def callback(%{assigns: %{current_user: user}} = conn, %{"item" => item_id}) do %{workflow_id: workflow_id} = item = Workflow.Public.get_item!(String.to_integer(item_id)) @@ -12,7 +25,7 @@ defmodule Systems.Assignment.Controller do assignment = Assignment.Public.get_by(:workflow_id, workflow_id, [:crew]) Crew.Public.get_member(crew, user) - |> then(&Assignment.Private.task_identifier(assignment, item, &1)) + |> then(&task_identifier(assignment, item, &1)) |> then(&Crew.Public.get_task(crew, &1)) |> Crew.Public.complete_task!() @@ -44,6 +57,163 @@ defmodule Systems.Assignment.Controller do end end + def export(%{assigns: %{branch: branch}} = conn, %{"id" => id}) do + if assignment = + Assignment.Public.get!( + String.to_integer(id), + Assignment.Model.preload_graph(:down) + ) do + date = Timestamp.now() |> Timestamp.format_date_short!() + + branch_name = + if branch do + Concept.Branch.name(branch, :self) + else + "assignment_#{id}" + end + + filename = + [branch_name, "progress", date] + |> Enum.join(" ") + |> Slug.slugify(separator: ?_) + + csv_data = progress_csv_data(assignment) + + conn + |> put_resp_content_type("text/csv") + |> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\".csv") + |> send_resp(200, csv_data) + else + service_unavailable(conn) + end + end + + def export(%Conn{} = conn, _, _) do + service_unavailable(conn) + end + + def progress_csv_data(%Assignment.Model{workflow: workflow} = assignment) do + workflow_items = Workflow.Public.list_items(workflow) + signatures = Assignment.Public.list_signatures(assignment) + show_consent? = show_consent?(assignment, signatures) + + headers = progress_headers(workflow_items, show_consent?) + participants = Assignment.Public.list_participants(assignment) + + progress_csv_data( + assignment, + headers, + participants, + workflow_items, + signatures, + show_consent? + ) + end + + def progress_csv_data( + assignment, + headers, + participants, + workflow_items, + signatures, + show_consent? + ) do + participants + |> Enum.map( + &participant_progress( + assignment, + workflow_items, + &1, + participant_id(&1), + consent(&1, signatures, show_consent?) + ) + ) + |> CSV.encode(headers: headers) + |> Enum.to_list() + end + + def progress_headers(workflow_items, show_consent?) do + # define headers to preserve order + [@progress_header_participant] + |> append_if(@progress_header_consent, show_consent?) + |> append(Enum.map(workflow_items, & &1.title)) + end + + defp show_consent?(_assignment, [_ | _] = _signatures), do: true + + defp show_consent?(%Assignment.Model{consent_agreement_id: id}, _signatures) when is_number(id), + do: true + + defp show_consent?(_, _), do: false + + defp consent(%{user_id: user_id}, signatures, show_consent?) do + if show_consent? do + {:include, Enum.any?(signatures, &(&1 == user_id))} + else + :exclude + end + end + + defp participant_id(%{public_id: public_id, external_id: external_id}) do + if external_id do + external_id + else + public_id + end + end + + defp participant_progress( + %Assignment.Model{} = assignment, + workflow_items, + %{user_id: user_id, member_id: member_id}, + participant_id, + consent + ) do + base = + case consent do + {:include, signature?} -> + %{ + @progress_header_participant => "#{participant_id}", + @progress_header_consent => consent_value(assignment, user_id, signature?) + } + + :exclude -> + %{@progress_header_participant => "#{participant_id}"} + end + + workflow_items + |> Enum.map(&task_status(assignment, &1, task_identifier(assignment, &1, member_id))) + |> Enum.reduce(base, fn map, acc -> Map.merge(acc, map) end) + end + + defp consent_value(%Assignment.Model{} = assignment, user_id, signature?) do + cond do + signature? -> + @progress_yes + + declined_consent?(assignment, user_id) -> + @progress_no + + true -> + @progress_not_applicable + end + end + + defp task_status( + %Assignment.Model{crew: crew}, + %Workflow.ItemModel{title: workflow_title}, + task_identifier + ) do + status_value = + case Crew.Public.get_task(crew, task_identifier) do + %{started_at: nil} -> @progress_not_applicable + %{status: status} -> Crew.TaskStatus.translate(status) + _ -> @progress_not_applicable + end + + %{workflow_title => status_value} + end + defp offline?(%{status: status}) do status != :online end diff --git a/core/systems/assignment/monitor_view.ex b/core/systems/assignment/monitor_view.ex index 75eeae153..4df8a01de 100644 --- a/core/systems/assignment/monitor_view.ex +++ b/core/systems/assignment/monitor_view.ex @@ -4,24 +4,56 @@ defmodule Systems.Assignment.MonitorView do alias Frameworks.Pixel.Widget @impl true - def update(%{number_widgets: number_widgets, progress_widgets: progress_widgets}, socket) do + def update( + %{ + assignment: assignment, + number_widgets: number_widgets, + progress_widgets: progress_widgets + }, + socket + ) do { :ok, socket |> assign( + assignment: assignment, number_widgets: number_widgets, progress_widgets: progress_widgets ) + |> update_export_button() } end + defp update_export_button(%{assigns: %{assignment: %{id: id}}} = socket) do + export_button = %{ + action: %{ + type: :http_download, + to: ~p"/assignment/#{id}/export" + }, + face: %{ + type: :label, + label: dgettext("eyra-assignment", "export.progress.button"), + icon: :export + } + } + + assign(socket, export_button: export_button) + end + @impl true def render(assigns) do ~H"""
- <%= dgettext("eyra-assignment", "monitor.title") %> +
+ <%= dgettext("eyra-assignment", "monitor.title") %> +
+ +
+ + <%= dgettext("eyra-assignment", "monitor.description") %> + <.spacing value="M" /> <.spacing value="L" />
<%= for widget <- @number_widgets do %> diff --git a/core/systems/consent/_public.ex b/core/systems/consent/_public.ex index 2915d654b..0d76d9cc5 100644 --- a/core/systems/consent/_public.ex +++ b/core/systems/consent/_public.ex @@ -6,9 +6,8 @@ defmodule Systems.Consent.Public do alias Core.Repo alias Frameworks.Signal - alias Systems.{ - Consent - } + alias Systems.Account + alias Systems.Consent def create_agreement(auth_node) do prepare_agreement(auth_node) @@ -89,11 +88,13 @@ defmodule Systems.Consent.Public do Repo.get!(Consent.RevisionModel, id) |> Repo.preload(preload) end - def has_signature(context, user) do - get_signature(context, user) != nil + def has_signature(context, user_ref) do + get_signature(context, user_ref) != nil end - def get_signature(%Consent.AgreementModel{id: agreement_id}, %Systems.Account.User{id: user_id}) do + def get_signature(%Consent.AgreementModel{id: agreement_id}, user_ref) do + user_id = Account.User.user_id(user_ref) + from(s in Consent.SignatureModel, join: r in Consent.RevisionModel, on: r.id == s.revision_id, @@ -105,7 +106,9 @@ defmodule Systems.Consent.Public do |> List.last() end - def get_signature(%Consent.RevisionModel{id: revision_id}, %Systems.Account.User{id: user_id}) do + def get_signature(%Consent.RevisionModel{id: revision_id}, user_ref) do + user_id = Account.User.user_id(user_ref) + from(s in Consent.SignatureModel, where: s.user_id == ^user_id, where: s.revision_id == ^revision_id diff --git a/core/systems/crew/task_status.ex b/core/systems/crew/task_status.ex index 62b8366f5..b11d887fe 100644 --- a/core/systems/crew/task_status.ex +++ b/core/systems/crew/task_status.ex @@ -1,5 +1,6 @@ defmodule Systems.Crew.TaskStatus do - def values, do: [:pending, :completed, :accepted, :rejected] + use Core.Enums.Base, + {:crew_task_status, [:pending, :completed, :accepted, :rejected]} def finished_states, do: [:completed, :accepted, :rejected] end diff --git a/core/systems/monitor/_public.ex b/core/systems/monitor/_public.ex index 9134d899a..7aebc236e 100644 --- a/core/systems/monitor/_public.ex +++ b/core/systems/monitor/_public.ex @@ -83,4 +83,8 @@ defmodule Systems.Monitor.Public do defdelegate count(event_template), to: Queries defdelegate sum(event_template), to: Queries defdelegate unique(event_template), to: Queries + + def exists?(event) do + count(event) > 0 + end end diff --git a/core/systems/project/branch_plug.ex b/core/systems/project/branch_plug.ex index b689589b1..cdfebf1e0 100644 --- a/core/systems/project/branch_plug.ex +++ b/core/systems/project/branch_plug.ex @@ -3,6 +3,7 @@ defmodule Systems.Project.BranchPlug do alias Frameworks.Concept alias Systems.Project alias Systems.Storage + alias Systems.Assignment @impl true def init(opts), do: opts @@ -14,6 +15,7 @@ defmodule Systems.Project.BranchPlug do end defp branch(["/", "storage", "endpoint", id | _]), do: branch(Storage.Public.get_endpoint!(id)) + defp branch(["/", "assignment", id | _]), do: branch(Assignment.Public.get(id)) defp branch(%{} = leaf) do with false <- Concept.Leaf.impl_for(leaf) == nil, diff --git a/core/test/systems/assignment/_public_test.exs b/core/test/systems/assignment/_public_test.exs index f42d9d315..647ea0ff6 100644 --- a/core/test/systems/assignment/_public_test.exs +++ b/core/test/systems/assignment/_public_test.exs @@ -10,6 +10,120 @@ defmodule Systems.Assignment.PublicTest do alias Core.Factories + test "list_participants?/1 with 1 expired member" do + user = %{id: user_id} = Factories.insert!(:member) + + crew_auth_node = + Factories.build(:auth_node, %{ + role_assignments: [ + Factories.build(:participant, %{user: user}) + ] + }) + + crew = Factories.insert!(:crew, %{auth_node: crew_auth_node}) + + assignment = + Factories.insert!(:assignment, %{ + crew: crew, + special: :data_donation, + status: :online + }) + + %{id: member_id} = Crew.Factories.create_member(crew, user, %{expired: true}) + + assert [ + %{user_id: ^user_id, member_id: ^member_id, public_id: 1, external_id: nil} + ] = Assignment.Public.list_participants(assignment) + end + + test "list_participants?/1 with 1 active member" do + user = %{id: user_id} = Factories.insert!(:member) + + crew_auth_node = + Factories.build(:auth_node, %{ + role_assignments: [ + Factories.build(:participant, %{user: user}) + ] + }) + + crew = Factories.insert!(:crew, %{auth_node: crew_auth_node}) + + assignment = + Factories.insert!(:assignment, %{ + crew: crew, + special: :data_donation, + status: :online + }) + + %{id: member_id} = Crew.Factories.create_member(crew, user) + + assert [ + %{user_id: ^user_id, member_id: ^member_id, public_id: 1, external_id: nil} + ] = Assignment.Public.list_participants(assignment) + end + + test "list_participants?/1 with 1 expired member and 1 active member" do + user1 = %{id: user_1_id} = Factories.insert!(:member) + user2 = %{id: user_2_id} = Factories.insert!(:member) + + crew_auth_node = + Factories.build(:auth_node, %{ + role_assignments: [ + Factories.build(:participant, %{user: user1}), + Factories.build(:participant, %{user: user2}) + ] + }) + + crew = Factories.insert!(:crew, %{auth_node: crew_auth_node}) + + assignment = + Factories.insert!(:assignment, %{ + crew: crew, + special: :data_donation, + status: :online + }) + + %{id: member_1_id} = Crew.Factories.create_member(crew, user1) + %{id: member_2_id} = Crew.Factories.create_member(crew, user2) + + assert [ + %{user_id: ^user_1_id, member_id: ^member_1_id, public_id: 1, external_id: nil}, + %{user_id: ^user_2_id, member_id: ^member_2_id, public_id: 2, external_id: nil} + ] = Assignment.Public.list_participants(assignment) + end + + test "list_participants?/1 with 1 external user" do + external_user = + %{external_id: external_id, user: %{id: user_id}} = Factories.insert!(:external_user) + + crew_auth_node = + Factories.build(:auth_node, %{ + role_assignments: [ + Factories.build(:participant, %{user: external_user.user}) + ] + }) + + crew = Factories.insert!(:crew, %{auth_node: crew_auth_node}) + + assignment = + Factories.insert!(:assignment, %{ + crew: crew, + special: :data_donation, + status: :online + }) + + %{id: member_id} = Crew.Factories.create_member(crew, external_user.user) + + assert [ + %{ + user_id: ^user_id, + member_id: ^member_id, + public_id: 1, + external_id: ^external_id + } + ] = Assignment.Public.list_participants(assignment) + end + test "has_open_spots?/1 true, with 1 expired member" do %{crew: crew} = assignment = Assignment.Factories.create_assignment(31, 1) diff --git a/core/test/systems/assignment/controller_test.exs b/core/test/systems/assignment/controller_test.exs index dbd8cd88c..f7637be96 100644 --- a/core/test/systems/assignment/controller_test.exs +++ b/core/test/systems/assignment/controller_test.exs @@ -2,6 +2,8 @@ defmodule Systems.Assignment.ControllerTest do use CoreWeb.ConnCase, async: true alias Systems.Assignment + alias Systems.Workflow + alias Systems.Monitor describe "invite member" do setup :login_as_member @@ -50,4 +52,211 @@ defmodule Systems.Assignment.ControllerTest do assert response =~ "href=\"/user/signin" end end + + describe "progress report" do + test "progress_headers/1, no tasks + consent" do + assert Assignment.Controller.progress_headers([], true) == [ + "Participant", + "Consent" + ] + end + + test "progress_headers/1, no tasks - consent" do + assert Assignment.Controller.progress_headers([], false) == [ + "Participant" + ] + end + + test "progress_headers/1, 2 tasks + consent" do + workflow = Workflow.Factories.create_workflow(:many_optional) + + workflow_items = + ["Task 1", "Task 2"] + |> Enum.map(&Factories.insert!(:workflow_item, %{workflow: workflow, title: &1})) + + assert [ + "Participant", + "Consent", + "Task 1", + "Task 2" + ] == Assignment.Controller.progress_headers(workflow_items, true) + end + + test "progress_csv_data/6" do + assignment = + %{crew: crew, workflow: workflow} = Assignment.Factories.create_assignment(31, 0) + + workflow_items = + [%{id: item_1_id}, %{id: item_2_id}] = + ["Task 1", "Task 2"] + |> Enum.map(&Factories.insert!(:workflow_item, %{workflow: workflow, title: &1})) + + participants = [ + %{user_id: 1, member_id: 1, public_id: 777, external_id: nil}, + %{user_id: 2, member_id: 2, public_id: 778, external_id: "melle"}, + %{user_id: 3, member_id: 3, public_id: 779, external_id: nil} + ] + + headers = ["Participant", "Consent", "Task 1", "Task 2"] + signatures = [2] + show_consent? = true + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_1_id}", "member=1"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + completed_at: ~N[2024-09-30 19:36:39], + rejected_at: ~N[2024-09-30 19:36:39], + status: :rejected + }) + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_2_id}", "member=1"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + status: :pending + }) + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_1_id}", "member=2"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + completed_at: ~N[2024-09-30 19:36:39], + accepted_at: ~N[2024-09-30 19:36:39], + status: :accepted + }) + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_2_id}", "member=2"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + completed_at: ~N[2024-09-30 19:36:39], + status: :completed + }) + + Monitor.Factories.create_monitor_event_consent_declined(assignment, 3) + + csv_data = + Assignment.Controller.progress_csv_data( + assignment, + headers, + participants, + workflow_items, + signatures, + show_consent? + ) + + assert [ + "Participant,Consent,Task 1,Task 2\r\n", + "777,n/a,rejected,started\r\n", + "melle,yes,accepted,finished\r\n", + "779,no,n/a,n/a\r\n" + ] = csv_data + end + end + + describe "export progress report" do + setup :login_as_member + + test "export/2", %{conn: conn} do + user1 = Factories.insert!(:member) + + %{user: user2} = Factories.insert!(:external_user, %{external_id: "melle"}) + + user3 = Factories.insert!(:member) + + crew_auth_node = + Factories.build(:auth_node, %{ + role_assignments: [ + Factories.build(:participant, %{user: user1}), + Factories.build(:participant, %{user: user2}), + Factories.build(:participant, %{user: user3}) + ] + }) + + crew = Factories.insert!(:crew, %{auth_node: crew_auth_node}) + + consent_agreement = + Factories.insert!(:consent_agreement, %{ + revisions: [ + %{ + source: "Consent agreement v1", + signatures: [ + %{user: user2} + ] + } + ] + }) + + assignment = + %{workflow: workflow} = + Factories.insert!(:assignment, %{ + crew: crew, + special: :data_donation, + status: :online, + consent_agreement: consent_agreement + }) + + [%{id: item_1_id}, %{id: item_2_id}] = + ["Task 1", "Task 2"] + |> Enum.map(&Factories.insert!(:workflow_item, %{workflow: workflow, title: &1})) + + member_1 = Factories.insert!(:crew_member, %{crew: crew, user: user1}) + member_2 = Factories.insert!(:crew_member, %{crew: crew, user: user2}) + _member_3 = Factories.insert!(:crew_member, %{crew: crew, user: user3}) + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_1_id}", "member=#{member_1.id}"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + completed_at: ~N[2024-09-30 19:36:39], + rejected_at: ~N[2024-09-30 19:36:39], + status: :rejected + }) + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_2_id}", "member=#{member_1.id}"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + status: :pending + }) + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_1_id}", "member=#{member_2.id}"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + completed_at: ~N[2024-09-30 19:36:39], + accepted_at: ~N[2024-09-30 19:36:39], + status: :accepted + }) + + Factories.insert!(:crew_task, %{ + identifier: ["item=#{item_2_id}", "member=#{member_2.id}"], + crew: crew, + auth_node: %Core.Authorization.Node{}, + started_at: ~N[2024-09-30 19:36:39], + completed_at: ~N[2024-09-30 19:36:39], + status: :completed + }) + + Monitor.Factories.create_monitor_event_consent_declined(assignment, user3.id) + + response = + conn + |> Plug.Conn.assign(:branch, nil) + |> Assignment.Controller.export(%{"id" => "#{assignment.id}"}) + + assert response.resp_body =~ "Participant,Consent,Task 1,Task 2\r\n" + assert response.resp_body =~ "melle,yes,accepted,finished\r\n" + assert response.resp_body =~ "1,n/a,rejected,started\r\n" + assert response.resp_body =~ "3,no,n/a,n/a\r\n" + end + end end diff --git a/core/test/systems/monitor/factories.ex b/core/test/systems/monitor/factories.ex new file mode 100644 index 000000000..45d8188b5 --- /dev/null +++ b/core/test/systems/monitor/factories.ex @@ -0,0 +1,14 @@ +defmodule Systems.Monitor.Factories do + alias Core.Factories + alias Systems.Monitor + alias Systems.Assignment + alias Systems.Account + + def create_monitor_event_consent_declined(%Assignment.Model{id: assignment_id}, user_ref) do + user_id = Account.User.user_id(user_ref) + + Factories.insert!(:monitor_event, %{ + identifier: ["assignment=#{assignment_id}", "topic=declined", "user=#{user_id}"] + }) + end +end