diff --git a/Project.toml b/Project.toml index d48aa11..ca3c295 100755 --- a/Project.toml +++ b/Project.toml @@ -4,11 +4,16 @@ authors = ["TEC "] version = "0.1.0" [deps] +CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Genie = "c43c736e-a2d1-11e8-161f-af95117fbd1e" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" Inflector = "6d011eab-0732-4556-8808-e463c76bf3b6" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" LoggingExtras = "e6f89c97-d47a-5376-807f-9c37f3926c36" MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d" +SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9" SearchLight = "340e8cb6-72eb-11e8-37ce-c97ebeb32050" SearchLightSQLite = "21a827c4-482a-11ea-3a19-4d2243a4a2c5" diff --git a/app/helpers/ValidationHelper.jl b/app/helpers/ValidationHelper.jl deleted file mode 100644 index 0a23b28..0000000 --- a/app/helpers/ValidationHelper.jl +++ /dev/null @@ -1,17 +0,0 @@ -module ValidationHelper - -using Genie, SearchLight, SearchLight.Validation - -export output_errors - -function output_errors(m::T, field::Symbol)::String where {T<:SearchLight.AbstractModel} - v = ispayload() ? validate(m) : ModelValidator() - - haserrorsfor(v, field) ? - """ -
- $(errors_to_string(v, field, separator = "
\n", uppercase_first = true)) -
""" : "" -end - -end \ No newline at end of file diff --git a/app/helpers/ViewHelper.jl b/app/helpers/ViewHelper.jl deleted file mode 100644 index d6d3f96..0000000 --- a/app/helpers/ViewHelper.jl +++ /dev/null @@ -1,11 +0,0 @@ -module ViewHelper - -using Genie, Genie.Flash, Genie.Router - -export output_flash - -function output_flash(flashtype::String = "danger") :: String - flash_has_message() ? """
$(flash())
""" : "" -end - -end \ No newline at end of file diff --git a/app/layouts/base.jl.html b/app/layouts/base.jl.html index 00d2cb2..1d7e657 100644 --- a/app/layouts/base.jl.html +++ b/app/layouts/base.jl.html @@ -9,7 +9,7 @@ diff --git a/app/resources/results/Results.jl b/app/resources/results/Results.jl index 3efa050..1a360c0 100644 --- a/app/resources/results/Results.jl +++ b/app/resources/results/Results.jl @@ -1,12 +1,203 @@ module Results -import SearchLight: AbstractModel, DbId -import Base: @kwdef +using SearchLight +using Surveys, Dates +using DataFrames, CSV, JSON3, SQLite -export Result +export surveys, questions, responseids, results, + register!, deregister!, save!, clear! -@kwdef mutable struct Result <: AbstractModel - id::DbId = DbId() +const SURVEYS = Dict{SurveyID, String}() +const QUESTIONS = Dict{SurveyID, Dict{Symbol, Dict{Symbol, Any}}}() +const RESPONSES = Dict{SurveyID, Dict{ResponseID, Union{Response, Nothing}}}() + +# --------------------- +# Reading +# --------------------- + +function surveys(;cache::Bool=true) + if !cache || isempty(SURVEYS) + foreach(SearchLight.query("SELECT * from surveys") |> eachrow) do s + SURVEYS[SurveyID(s.id)] = s.name + end + end + SURVEYS +end +surveys(id::SurveyID; cache::Bool=true) = surveys(;cache)[id] + +function questions(survey::SurveyID; cache::Bool=true) + if !haskey(QUESTIONS, survey) || !cache + qns = SearchLight.query("SELECT id, type, prompt, input FROM questions WHERE survey=$survey") + qns.id = Symbol.(qns.id) + qns.type = Vector{Type}(@. eval(Meta.parse(qns.type))) + qns.input = Vector{Type}(@. eval(Meta.parse(qns.input))) + QUESTIONS[survey] = Dict(q.id => Dict(:type => q.type, :prompt => q.prompt, :input => q.input) for q in eachrow(qns)) + end + QUESTIONS[survey] +end + +function responseids(survey::SurveyID; cache::Bool=true) + if !haskey(RESPONSES, survey) || !cache + ids = SearchLight.query("SELECT DISTINCT response FROM results WHERE survey=$survey") |> + r -> ResponseID.(r.response) + if !haskey(RESPONSES, survey) + RESPONSES[survey] = + Dict{ResponseID, Union{Response, Nothing}}(id => nothing for id in ids) + else + foreach(ids) do id + if !haskey(RESPONSES[survey], id) + RESPONSES[survey][id] = nothing + end + end + end + end + Vector{ResponseID}(keys(RESPONSES[survey]) |> collect) +end + +function responseids(survey::SurveyID, type::Symbol; cache::Bool=true) + if type == :all + responseids(surveyid; cache) + elseif type == :complete + SearchLight.query("SELECT id FROM responses WHERE completed IS NOT NULL") |> + r -> ResponseID.(r.id) + elseif type == :incomplete + SearchLight.query("SELECT id FROM responses WHERE completed IS NULL") |> + r -> ResponseID.(r.id) + end +end + +function response(survey::SurveyID, response::ResponseID; cache::Bool=true) + if cache && response ∉ responseids(survey; cache) + responseids(survey, cache=false) + end + @assert response in responseids(survey; cache) + if RESPONSES[survey][response] === nothing + responsedata = SearchLight.query("SELECT question, value FROM results WHERE survey=$survey AND response=$response") + answers = Dict(map(eachrow(responsedata)) do ans + qid = Symbol(ans.question) + anstype = questions(survey)[qid][:type] + value = eval(Meta.parse(ans.value)) + qid => Answer{anstype}(value, nothing) + end) + metadata = SearchLight.query("SELECT started, completed, page FROM responses WHERE survey=$survey AND id=$response") + started = parse(DateTime, metadata.started[1]) + completed = if !ismissing(metadata.completed[1]) + parse(DateTime, metadata.completed[1]) end + RESPONSES[survey][response] = + Response(survey, response, metadata.page[1], answers, + started, completed) + end + RESPONSES[survey][response] +end + +function results(survey::SurveyID; cache::Bool=true, format::Symbol=:DataFrame) + resids = responseids(survey; cache) + res = response.(survey, resids; cache) + qids = keys(questions(survey; cache)) + data = Dict(q => map(r -> r[q].value, res) for q in qids) + DataFrame(data) |> if format == :DataFrame + identity + elseif format == :csv + df -> sprint(CSV.write, df) + elseif format == :tsv + df -> sprint((io, df) -> CSV.write(io, df, delim='\t'), df) + elseif format == :json + df -> sprint(JSON3.pretty, + [Dict([string(col) => df[i, col] + for col in names(df)]) + for i in 1:size(df, 1)]) + elseif format == :sqlite + df -> (mktemp() do path, io + SQLite.load!(df, SQLite.DB(path), "results") + read(path) + end) + end +end + +questions(s::Survey; cache::Bool=true) = questions(s.id; cache) +responseids(s::Survey; cache::Bool=true) = responseids(s.id; cache) +response(s::Survey; cache::Bool=true) = response(s.id; cache) +results(s::Survey; cache::Bool=true) = results(s.id; cache) + +# --------------------- +# Writing +# --------------------- + +function register!(survey::Survey) + qvaltype(::Question{<:Surveys.FormField{T}}) where {T} = T + if survey.id ∉ keys(surveys()) + name = if isnothing(survey.name) "NULL" else + SearchLight.escape_value(survey.name) end + srepr = repr(survey) |> SearchLight.escape_value + SearchLight.query("INSERT INTO surveys (id, name, repr) VALUES ($(survey.id), $name, $srepr)") + for (qid, q) in survey.questions + qid_s = string(qid) |> SearchLight.escape_value + prompt = q.prompt |> SearchLight.escape_value + type = string(qvaltype(q)) |> SearchLight.escape_value + field = string(typeof(q.field)) |> SearchLight.escape_value + SearchLight.query("INSERT INTO questions (survey, id, type, prompt, input) VALUES ($(survey.id), $qid_s, $type, $prompt, $field)") + end + SURVEYS[survey.id] = survey.name + end +end + +function register!(response::Response; cache::Bool=true) + if response.survey ∉ keys(surveys(;cache)) + register!(response.survey) + end + @assert response.id ∉ responseids(response.survey; cache) + SearchLight.query("INSERT INTO responses (survey, id, started, page) VALUES ($(response.survey), $(response.id), '$(string(response.started))', $(response.page))") + for (qid, ans) in response.answers + qid_s = SearchLight.escape_value(string(qid)) + value_s = SearchLight.escape_value(repr(ans.value)) + SearchLight.query("INSERT INTO results (survey, response, question, value) VALUES ($(response.survey), $(response.id), $qid_s, $value_s)") + end + RESPONSES[response.survey][response.id] = nothing +end + +function deregister!(response::Response; cache::Bool=true) + @assert response.id ∈ responseids(response.survey; cache) + SearchLight.query("DELETE FROM responses WHERE survey = $(response.survey) AND id = $(response.id)") + SearchLight.query("DELETE FROM results WHERE survey = $(response.survey) AND response = $(response.id)") + delete!(RESPONSES[response.survey], response.id) +end + +function save!(response::Response, questionsids::Vector{Symbol}=keys(response.answers); cache::Bool=true) + @assert response.id ∈ responseids(response.survey; cache) + if !isnothing(response.completed) + SearchLight.query("UPDATE responses SET completed = '$(string(response.completed))' WHERE survey = $(response.survey) AND id = $(response.id)") + end + SearchLight.query("UPDATE responses SET page = $(response.page) WHERE survey = $(response.survey) AND id = $(response.id)") + for qid in questionsids + qid_s = SearchLight.escape_value(string(qid)) + ans = response.answers[qid] + value_s = SearchLight.escape_value(repr(ans.value)) + SearchLight.query("UPDATE results SET value = $value_s WHERE survey = $(response.survey) AND response = $(response.id) AND question = $qid_s") + end +end + +save!(response::Response, questions::Vector{Question}; cache::Bool=true) = + save!(response, getfield.(questions, :id); cache) + +save!(response::Response, question::Question; cache::Bool=true) = + save!(response, [question.id]; cache) + +save!(response::Response, part::SurveyPart; cache::Bool=true) = + save!(response, part.questions; cache) + +save!(response::Response, survey::Survey; cache::Bool=true) = + save!(response, survey.questions; cache) + +function clear!(survey::Survey) + SearchLight.query("DELETE FROM survey WHERE id = $(survey.id)") + SearchLight.query("DELETE FROM questions WHERE survey = $(survey.id)") + SearchLight.query("DELETE FROM responses WHERE survey = $(survey.id)") + SearchLight.query("DELETE FROM results WHERE survey = $(survey.id)") +end + +function clear!(response::Response) + SearchLight.query("DELETE FROM responses WHERE survey = $(response.survey), id = $(response.id)") + SearchLight.query("DELETE FROM results WHERE survey = $(response.survey), response = $(response.id)") end end diff --git a/app/resources/results/ResultsController.jl b/app/resources/results/ResultsController.jl index e7dc088..6aa2f05 100644 --- a/app/resources/results/ResultsController.jl +++ b/app/resources/results/ResultsController.jl @@ -1,3 +1,44 @@ module ResultsController - # Build something great + +using Genie, Genie.Renderer, Genie.Renderers.Html, HTTP, JSON3, StatsBase + +using Results, Surveys + +function resultsindex(survey::Union{SurveyID, Nothing}=nothing) + if isnothing(survey) + html(:results, :listsurveys, layout=:base; + surveys, questions, responseids) + else + html(:results, :survey, layout=:base; + name=surveys()[survey], id=survey, + questions, sresults=results(survey), + countmap) + end +end + +function resultsfile(survey::SurveyID, format::AbstractString) + @assert survey in keys(surveys()) + if format == "txt" + WebRenderable(sprint(show, results(survey)), :text) |> + Genie.Renderer.respond + elseif format in ("csv", "tsv", "json") + WebRenderable(results(survey, format=Symbol(format)), + if format == "json"; :json else :text end) |> + Genie.Renderer.respond + elseif format == "db" || format == "sqlite" + HTTP.Response(200, ["Content-Type" => "application/octet-stream"], + body = results(survey, format=:sqlite)) + else + error("format $format not recognised") + end +end + +function listsurveys() + WebRenderable(sprint(JSON3.pretty, + [Dict(:id => id, + :name => name) + for (id, name) in Results.surveys()]), + :json) |> Genie.Renderer.respond +end + end diff --git a/app/resources/results/views/listsurveys.jl.html b/app/resources/results/views/listsurveys.jl.html new file mode 100644 index 0000000..d4c8f9b --- /dev/null +++ b/app/resources/results/views/listsurveys.jl.html @@ -0,0 +1,25 @@ +
+

Recorded Surveys

+
+ +
+ + + + + + + + + + + <% + [tr(string(td(a(name, href=string("/results/", id))), + td(code(string(id))), + td(string(length(questions(id)))), + td(string(length(responseids(id)))))) + for (id, name) in surveys()] |> join + %> + +
NameIDQuestionsResponses
+
diff --git a/app/resources/results/views/survey.jl.html b/app/resources/results/views/survey.jl.html new file mode 100644 index 0000000..dd88901 --- /dev/null +++ b/app/resources/results/views/survey.jl.html @@ -0,0 +1,51 @@ + + +
+
+

$(name)

+ + $(size(sresults, 1)) results also available as + Text, + CSV, + TSV, + JSON, + SQLite DB + +
+
+ +
+ <% [ %> +
+ + $(question[:prompt]) + $(question[:type]) + + <% let qres=filter(!ismissing, sresults[!, qid]) + if length(qres) > 0 + let qresexpanded=if qres[1] isa Vector; collect(Iterators.flatten(qres)) else qres end + qresfreq=sort(countmap(qresexpanded) |> collect, by=x->-x[2]) + maxfreq=qresfreq[1][2] %> + + <% [ %> + + + $(res[1]) + + <% for (i, res) in enumerate(qresfreq)] |> join %> + + <% end end end %> +
+ <% for (qid, question) in questions(id)] |> join %> +
diff --git a/app/resources/surveys/SurveysController.jl b/app/resources/surveys/SurveysController.jl new file mode 100644 index 0000000..c32e7c0 --- /dev/null +++ b/app/resources/surveys/SurveysController.jl @@ -0,0 +1,162 @@ +module SurveysController + +using Genie.Router, Genie.Requests, Genie.Renderers.Html, HTTP + +using Results, Surveys, Dates + +const SURVEY = include("../../../config/survey.jl") +const INPROGRESS = Dict{Surveys.ResponseID, Surveys.Response}() + +const UID_ENCBASE = 36 + +const RESUME_COOKIE_MAX_AGE = 60*60*24*2 # two days, in seconds + +donesetup = false +function setup() + global donesetup + if !donesetup + register!(SURVEY) + for rid in responseids(SURVEY.id, :incomplete) + INPROGRESS[rid] = Results.response(SURVEY.id, rid) + end + donesetup = true + end +end + +function index() + setup() + request = Genie.Router.params()[:REQUEST] + responseid = Genie.Cookies.get(request, "response-id") + html(:surveys, :index, layout=:base; + survey=SURVEY, responseid) +end + +function new() + r = Surveys.Response(SURVEY, vcat(responseids(SURVEY), + Vector{Surveys.ResponseID}(keys(INPROGRESS) |> collect))) + INPROGRESS[r.id] = r + register!(r) + uid_str = string(r.id, base=UID_ENCBASE) + Genie.Renderer.redirect(HTTP.URIs.URI(currenturl()).path * "?uid=$uid_str&page=1") +end + +function completed(uid) + res = html(:surveys, :thanks, layout=:base; + uid=string(uid, base=UID_ENCBASE), survey=SURVEY) + Genie.Cookies.set!(res, "response-id", "", Dict{String, Any}("maxage" => -1)) + Genie.Cookies.set!(res, "response-page", 0, Dict{String, Any}("maxage" => -1)) +end + +function serve(forminfo::Dict) + setup() + uid = if haskey(forminfo, :uid) + tryparse(Surveys.ResponseID, forminfo[:uid], base=UID_ENCBASE) + end + if haskey(forminfo, :uid) && !haskey(INPROGRESS, uid) && + uid ∈ Results.responseids(SURVEY.id) + # response has been completed + completed(uid) + elseif !haskey(forminfo, :uid) || !haskey(INPROGRESS, uid) + clear = if haskey(forminfo, :clear) + tryparse(Surveys.ResponseID, forminfo[:clear], base=UID_ENCBASE) + end + if haskey(INPROGRESS, clear) + deregister!(INPROGRESS[clear]) + delete!(INPROGRESS, clear) + end + new() + elseif !haskey(forminfo, :page) + Genie.Renderer.redirect(string(currenturl(), + "&page=", INPROGRESS[uid].page)) + else + uid_str = string(uid, base=UID_ENCBASE) + page = parse(Int, forminfo[:page]) + if page > length(SURVEY) + @info SURVEY => INPROGRESS[uid] + INPROGRESS[uid].completed = now() + save!(INPROGRESS[uid], Symbol[]) + delete!(INPROGRESS, uid) + completed(uid) + else + res = html(:surveys, :survey, layout=:base, + uid=uid_str, survey=SURVEY, + response=INPROGRESS[uid], page=page) + Genie.Cookies.set!(res, "response-id", uid_str, + Dict{String, Any}("maxage" => RESUME_COOKIE_MAX_AGE, + "httponly" => true)) + Genie.Cookies.set!(res, "response-page", INPROGRESS[uid].page, + Dict{String, Any}("maxage" => RESUME_COOKIE_MAX_AGE, + "httponly" => true)) + end + end +end + +function submit(forminfo::Dict; backpage::Bool=false) + uid = parse(Surveys.ResponseID, forminfo[:uid], base=UID_ENCBASE) + page = parse(Int, forminfo[:page]) + data = reduce(function(acc::Dict, datum::Pair) + key, value = datum + if key ∈ (:uid, :page, :debug) + elseif match(r"\[\]$", string(key)) |> !isnothing + pkey = Symbol(replace(string(key), r"\[\]$" => "")) + acc[pkey] = value + elseif match(r"__override$", string(key)) |> !isnothing + pkey = Symbol(replace(string(key), r"__override$" => "")) + acc[pkey] = vcat(value) + else + acc[key] = vcat(value) + end + acc + end, forminfo, init=Dict{Symbol, Any}()) + # Questions on this page with no data are unanswered, but should be updated anyway + for qid in getproperty.(SURVEY[page].questions, :id) + if !haskey(data, qid) + data[qid] = missing + end + end + if forminfo[:debug] != "yes" + uid_str = string(uid, base=UID_ENCBASE) + response = INPROGRESS[uid] + update!(response, SURVEY, data) + @info SURVEY[page] => response + if backpage || isvalid(response, SURVEY[page]) + response.page = if backpage + max(1, page - 1) + else + max(response.page, page + 1) + end + save!(response, SURVEY[page]) + Genie.Renderer.redirect("/survey?uid=$uid_str&page=$(response.page)") + else + save!(response, SURVEY[page]) + Genie.Renderer.redirect("/survey?uid=$uid_str&page=$page") + end + else + io = IOBuffer() + ioc = IOContext(io, :limit => true, :displaysize => (80, 100)) + show(ioc, "text/plain", data) + postdata = String(take!(io)) + html(""" + + + + + + + Emacs User Survey + + + +
Data from page $page, response $(sprint(show, uid))
+
 $(postdata) 
+ + + + """) + end +end + +end diff --git a/app/resources/surveys/views/index.jl.html b/app/resources/surveys/views/index.jl.html new file mode 100644 index 0000000..b79b8b9 --- /dev/null +++ b/app/resources/surveys/views/index.jl.html @@ -0,0 +1,33 @@ +
+
+

$(survey.name)

+ <% if !isnothing(survey.description) %> +

$(survey.description)

+ <% else %> +
+ <% end %> +
+
+ +<% if isnothing(responseid) %> +
+
+ +
+
+<% else %> +
+
+
+ + +
+
+

+
+ + +
+
+<% end %> diff --git a/app/resources/surveys/views/survey.jl.html b/app/resources/surveys/views/survey.jl.html new file mode 100644 index 0000000..e1099a7 --- /dev/null +++ b/app/resources/surveys/views/survey.jl.html @@ -0,0 +1,57 @@ + + +
+
+

$(survey.name)

+

Survey Attempt ID + $(uid) + <% if length(survey) > 1 %> + + Page $(page) / $(length(survey)) + + <% end %> +

+
+ +
+ +
+ <% if !isnothing(survey[page].label) %> +

$(survey[page].label)

+ <% end %> +
+
+ + + $(sprint(show, MIME("text/html"), survey[page] => response)) +
+ <% if page > 1 %> +
+ + +
+ <% else %> + + <% end %> +

+ + + + +
+
diff --git a/app/resources/surveys/views/thanks.jl.html b/app/resources/surveys/views/thanks.jl.html new file mode 100644 index 0000000..2adcaef --- /dev/null +++ b/app/resources/surveys/views/thanks.jl.html @@ -0,0 +1,16 @@ +
+
+

$(survey.name)

+

Survey Attempt + $(uid) + has been recorded +

+
+
+ +
+

Thank you for taking part in the survey!

+
We hope to see you next year 🙂
+

+ Go Home +
diff --git a/config/initializers/searchlight.jl b/config/initializers/searchlight.jl index 64bc579..0cc5b75 100644 --- a/config/initializers/searchlight.jl +++ b/config/initializers/searchlight.jl @@ -1,12 +1,19 @@ using SearchLight try - SearchLight.Configuration.load() + SearchLight.Configuration.load() - if SearchLight.config.db_config_settings["adapter"] !== nothing - eval(Meta.parse("using SearchLight$(SearchLight.config.db_config_settings["adapter"])")) - SearchLight.connect() - end + if SearchLight.config.db_config_settings["adapter"] !== nothing + eval(Meta.parse("using SearchLight$(SearchLight.config.db_config_settings["adapter"])")) + SearchLight.connect() + + if !(SearchLight.config.db_migrations_table_name in + SearchLight.query("SELECT name FROM sqlite_master WHERE type='table'").name) + SearchLight.Migration.create_migrations_table() + SearchLight.Migrations.last_up() # probably a good idea + end + + end catch ex - @error ex -end \ No newline at end of file + @error ex +end diff --git a/config/survey.jl b/config/survey.jl new file mode 100644 index 0000000..cfc2eef --- /dev/null +++ b/config/survey.jl @@ -0,0 +1 @@ +Survey("Emacs User Survey — 2022") diff --git a/db/connection.yml b/db/connection.yml index 760713b..32d8776 100644 --- a/db/connection.yml +++ b/db/connection.yml @@ -2,7 +2,7 @@ env: ENV["GENIE_ENV"] dev: adapter: SQLite - database: db/survey.sqlite + database: db/results.sqlite config: prod: diff --git a/db/migrations/0000000000000000_create_table_results.jl b/db/migrations/0000000000000000_create_table_results.jl new file mode 100644 index 0000000..83a127e --- /dev/null +++ b/db/migrations/0000000000000000_create_table_results.jl @@ -0,0 +1,54 @@ +module CreateTableResults + +import SearchLight.Migrations: create_table, column, columns, primary_key, add_index, drop_table, add_indices + +function up() + create_table(:surveys) do + [ + primary_key(:id), + column(:name, :text), + column(:repr, :text, not_null=true) + ] + end + + create_table(:questions) do + [ + column(:survey, :integer, not_null=true), + column(:id, :text, not_null=true), + column(:type, :text, not_null=true), + column(:prompt, :text, not_null=true), + column(:input, :text, not_null=true) + ] + end + + create_table(:responses) do + [ + column(:survey, :integer, not_null=true), + column(:id, :integer, not_null=true), + column(:started, :text, not_null=true), + column(:completed, :text), + column(:page, :integer, not_null=true), + ] + end + + create_table(:results) do + [ + column(:survey, :integer, not_null=true), + column(:response, :integer, not_null=true), + column(:question, :integer, not_null=true), + column(:value, :text, not_null=true) + ] + end + + add_index(:questions, :survey) + add_index(:results, :survey) + add_indices(:results, [:response, :question]) +end + +function down() + drop_table(:surveys) + drop_table(:questions) + drop_table(:results) +end + +end diff --git a/db/migrations/create_table_results.jl b/db/migrations/create_table_results.jl deleted file mode 100644 index f1c3fae..0000000 --- a/db/migrations/create_table_results.jl +++ /dev/null @@ -1,24 +0,0 @@ -module CreateTableResults - -import SearchLight.Migrations: create_table, column, columns, primary_key, add_index, drop_table, add_indices - -function up() - create_table(:results) do - [ - primary_key() - column(:column_name, :column_type) - columns([ - :column_name => :column_type - ]) - ] - end - - add_index(:results, :column_name) - add_indices(results, :column_name_1, :column_name_2) -end - -function down() - drop_table(:results) -end - -end diff --git a/lib/Surveys.jl b/lib/Surveys.jl new file mode 100644 index 0000000..5c4e499 --- /dev/null +++ b/lib/Surveys.jl @@ -0,0 +1,645 @@ +module Surveys + +using Dates +using Genie.Renderers.Html +import Base: show, isvalid, isempty + +export Survey, SurveyPart, Question, + Answer, Response, update!, clear!, nonempty, + SurveyID, ResponseID, + Checkbox, TextInput, DateInput, NumberInput, IntegerInput, + TextArea, Dropdown, RadioSelect, MultiSelect, RangeSelect + +# --------------------- +# Main Types +# --------------------- + +# Input Field + +abstract type FormField{T} end + +# Question + +struct Question{F <: FormField} + id::Symbol + prompt::AbstractString + field::F + validators::Vector{Function} + postprocessors::Vector{Function} + function Question(id::Symbol, prompt::AbstractString, + field::F, validators::Vector{Function}, + postprocessors::Vector{Function}) where {F<:FormField} + if id in (:uid, :page) + @warn "Question uses a reserved id $id, this is likely to cause issues" + end + postprocessors = map(p -> if p == last; lastsoftfail else p end, postprocessors) + new{F}(id, prompt, field, validators, postprocessors) + end +end + +function nonempty(value::AbstractString) + if ismissing(value) || (isa(value, AbstractString) && isempty(strip(value))) + "Must be answered" + end +end + +function nonempty(values::Vector{<:AbstractString}) + if length(values) == 0 || all(isempty, strip.(values)) + "Must be answered" + end +end + +nonempty(::Bool) = nothing + +lastsoftfail(v::Vector) = if length(v) > 0 + last(v) +else + "" +end + +defaultpostprocessors(::FormField) = Function[last, strip] + +function Question(id::Symbol, prompt::AbstractString, field::FormField; + validators::Union{Function, Vector{<:Function}}=Function[], + postprocessors::Union{Function, Vector{<:Function}}=defaultpostprocessors(field), + mandatory::Bool=true) + fullvalidators = if mandatory + vcat(nonempty, validators) + else + vcat(validators) + end + Question(id, prompt, field, fullvalidators, postprocessors) +end + +function prompttoid(prompt::String) + prompt |> + p -> replace(p, r"[^A-Za-z0-9\s]" => "") |> + p -> replace(p, r"\s+" => "_") |> + lowercase |> + Symbol +end + +Question(prompt::AbstractString, field::FormField; kargs...) = + Question(prompttoid(prompttoid), prompt, field; kargs...) + +# Field-based question constructors + +function (F::Type{<:FormField})(id::Symbol, prompt::AbstractString, args...; + validators::Union{Function, Vector{<:Function}}=Function[], + mandatory::Bool=true, + kwargs...) + Question(id, prompt, F(args...; kwargs...); validators, mandatory) +end +function (F::Type{<:FormField})(prompt::AbstractString, args...; kwargs...) + F(prompttoid(prompt), prompt, args...; kwargs...) +end + +# Survey Part + +struct SurveyPart + label::Union{AbstractString, Nothing} + questions::Vector{Question} +end + +Base.getindex(p::SurveyPart, id::Symbol) = findfirst(q -> q.id == id, p.questions) +Base.getindex(p::SurveyPart, index::Integer) = p.questions[index] + +Base.length(p::SurveyPart) = length(p.questions) + +SurveyPart(label::Union{AbstractString, Nothing}, questions::Question...) = + SurveyPart(label, questions |> collect) +SurveyPart(questions::Question...) = SurveyPart(nothing, questions |> collect) + +# Survey + +const SurveyID = UInt32 + +struct Survey + id::SurveyID + name::AbstractString + description::Union{AbstractString, Nothing} + parts::Vector{Pair{Union{AbstractString, Nothing}, Vector{Symbol}}} + questions::Dict{Symbol, Question} +end + +Base.getindex(s::Survey, id::Symbol) = s.questions[id] +Base.getindex(s::Survey, part::Integer) = + SurveyPart(s.parts[part].first, + getindex.(Ref(s.questions), s.parts[part].second)) + +Base.length(s::Survey) = length(s.parts) + +function Survey(name::AbstractString, + description::Union{AbstractString, Nothing}, + parts::Vector{<:Pair{<:Union{<:AbstractString, Nothing}, <:Vector{Symbol}}}, + questions::Dict{Symbol, Question}) + # Create an id that only depends on: + # 1. Question IDs + # 2. Question Prompts + # 3. Question field types + # Hopefully memhashing a Tuple of Symbols and Strings is somewhat stable, + # I checked this on Julia 1.3 and 1.6 and it looked alright. + id = xor(map(values(questions)) do q + hash((q.id, q.prompt, string(typeof(q.field)))) + end...) |> + h -> xor(reinterpret(SurveyID, [h])...) + Survey(id, name, description, parts, questions) +end + +Survey(name::AbstractString, + description::Union{AbstractString, Nothing}, + parts::SurveyPart...) = + Survey(name, description, + map(p -> p.label => getfield.(p.questions, :id), parts) |> collect, + Dict(q.id => q for q in + Iterators.flatten(getfield.(parts, :questions)))) +Survey(name::AbstractString, parts::SurveyPart...) = + Survey(name, nothing, parts...) + +Survey(name::AbstractString, + description::Union{AbstractString, Nothing}, + questions::Question...) = + Survey(name, description, + [nothing => getfield.(questions, :id) |> collect], + Dict(q.id => q for q in questions)) +Survey(name::AbstractString, questions::Question...) = + Survey(name, nothing, questions...) + +# Answer to a Question + +struct Answer{T} + value::Union{T, Missing} + error::Union{AbstractString, Nothing} +end + +Answer{T}() where {T <: Any} = Answer{T}(missing, nothing) +isempty(a::Answer) = ismissing(a.value) +isempty(a::Answer{<:AbstractString}) = ismissing(a.value) || isempty(a.value) + +# Survey Response + +const ResponseID = UInt32 + +mutable struct Response + survey::SurveyID + id::ResponseID + page::Integer + answers::Dict{Symbol, Answer} + started::DateTime + completed::Union{DateTime, Nothing} +end + +Base.getindex(r::Response, id::Symbol) = r.answers[id] + +# --------------------- +# Handling responses +# --------------------- + +# Response templates + +Answer(::Question{<:FormField{T}}) where {T} = Answer{T}(missing, nothing) + +Response(s::Survey, id::ResponseID=rand(ResponseID)) = + Response(s.id, id, 1, + Dict(q.id => Answer(q) for q in + Iterators.flatten([s[i].questions for i in 1:length(s)])), + now(), nothing) + +function Response(s::Survey, oldids::Vector{ResponseID}) + newid = rand(ResponseID) + while newid in oldids + newid = rand(ResponseID) + end + Response(s, newid) +end + +interpret(::FormField{<:AbstractString}, value::AbstractString) = value +interpret(::FormField{Number}, value::AbstractString) = + something(tryparse(Int64, value), parse(Float64, value)) +interpret(::FormField{T}, value::AbstractString) where {T} = parse(T, value) + +# Response interpretation + +function Answer(q::Question{<:FormField{T}}, value::Union{String, Vector{String}}) where {T} + try + processedvalue = interpret(q.field, ∘(identity, reverse(q.postprocessors)...)(value)) + error = nothing + for validator in q.validators + error = validator(processedvalue) + isnothing(error) || break + end + Answer{T}(processedvalue, error) + catch e + @warn "Answer construction failure" exception=(e, catch_backtrace()) + Answer{T}(missing, first(split(sprint(showerror, e), '\n'))) + end +end + +function Answer(q::Question{<:FormField{T}}, ::Missing) where {T} + if nonempty in q.validators + Answer{T}(missing, "Must be answered") + else + Answer{T}(missing, nothing) + end +end + +# Response updating + +function update!(r::Response, s::Survey, datum::Pair{Symbol, <:Any}) + id, value = datum + if haskey(r.answers, id) && haskey(s.questions, id) + r.answers[id] = Answer(s.questions[id], value) + else + @warn "$id not in response" + end +end + +function update!(r::Response, s::Survey, data::Dict{Symbol, <:Any}) + foreach(data) do datum + update!(r, s, datum) + end +end + +clear!(r::Response, ids::Vector{Symbol}) = + foreach(ids) do id + r.answers[id] = Answer{typeof(r.answers[id])}() + end + +clear!(r::Response, q::Question) = clear!(r, [q.id]) +clear!(r::Response, p::SurveyPart) = clear!(r, keys(p.questions)) +clear!(r::Response, s::Survey) = clear!(r, keys(s.questions)) +clear!(r::Response) = clear!(r, keys(r.answers)) + +# Response validity + +isvalid(a::Answer) = isnothing(a.error) +isvalid(a::Answer, q::Question) = + isvalid(a) && !(isempty(a) && nonempty in q.validators) +isvalid(r::Response, q::Question) = isvalid(r.answers[q.id], q) +isvalid(r::Response, p::SurveyPart) = all(isvalid.(Ref(r), p.questions)) +isvalid(r::Response, s::Survey) = + all(isvalid.(Ref(r), Iterators.flatten([s[i].questions for i in 1:length(s)]))) + +# --------------------- +# General htmlrenderer +# --------------------- + +htmlcontent(::FormField, value) = "" +htmlelement(::FormField) = "?" +htmlattrs(::FormField, value) = [] +htmlvoidelem(::FormField) = false +htmlpostprocess(::FormField, ::Symbol) = identity + +elem(e::AbstractString, content::AbstractString="", attrs::Pair{Symbol,<:Any}...) = + Html.normal_element(content, e, [], attrs...) +elem(e::AbstractString, attrs::Pair{Symbol,<:Any}...) = elem(e, "", attrs...) + +velem(e::AbstractString, attrs::Pair{Symbol,<:Any}...) = + Html.void_element(e, [], Vector{Pair{Symbol,Any}}(collect(attrs))) + +function htmlrender(field::FormField, value::Any, id, mandatory, invalid) + element = htmlelement(field) + attrs = vcat(htmlattrs(field, value), + [:id => string("qn-", id), + :name => id, + Symbol("aria-invalid") => invalid, + :required => mandatory && !isa(field, Checkbox)]) + if htmlvoidelem(field) + velem(element, attrs...) + else + content = htmlcontent(field, value) + elem(element, if ismissing(content) "" else string(content) end, + attrs...) + end |> htmlpostprocess(field, id) +end + +# --------------------- +# Form fields & html rendering +# --------------------- + +# + +struct FormInput{T} <: FormField{T} + FormInput{T}() where {T} = + if T <: Union{Bool, String, Integer, Number, Date} + new() + else + TypeError(:FormInput, Union{Bool, String, Integer, Number, Date}, T) + end +end + +htmlelement(::FormInput) = "input" +htmlvoidelem(::FormInput) = true +htmlattrs(::FormInput, value) = + [:value => if ismissing(value) false else string(value) end] + +# + +interpret(::FormInput{Bool}, value::AbstractString) = value == "yes" +htmlattrs(::FormInput{Bool}, value::Union{Bool, Missing}) = + [:type => "checkbox", :value => "yes", :checked => !ismissing(value) && value === true] +htmlpostprocess(::FormInput{Bool}, id::Symbol) = + s -> string(input(type="hidden", name=id, value="no"), s) + +# + +function htmlattrs(::FormInput{T}, value) where {T <: Union{Date, Number, Integer, String}} + type = Dict(Date => "date", + Number => "number", + Integer => "number", + String => "text")[T] + [:type => type, + :value => if ismissing(value) false else string(value) end] +end + +const Checkbox = FormInput{Bool} +const TextInput = FormInput{String} +const DateInput = FormInput{Date} +const NumberInput = FormInput{Number} +const IntegerInput = FormInput{Integer} + +#