# HG changeset patch # User bsw # Date 1633644563 -7200 # Node ID 5a8a09119865ee72964e47806f30b47ed10e91c1 # Parent aebc3b064f857e5b60d7739c5567d50945aa500f Added survey feature diff -r aebc3b064f85 -r 5a8a09119865 app/main/_filter_view/30_navigation.lua --- a/app/main/_filter_view/30_navigation.lua Thu Sep 30 22:39:46 2021 +0200 +++ b/app/main/_filter_view/30_navigation.lua Fri Oct 08 00:09:23 2021 +0200 @@ -116,6 +116,10 @@ end) end +if request.get_module() ~= "survey" then + execute.view{ module = "survey", view = "_notification" } +end + -- show notifications about things the user should take care of --[[ if app.session.member then diff -r aebc3b064f85 -r 5a8a09119865 app/main/survey/_action/answer.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/app/main/survey/_action/answer.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,73 @@ +local id = param.get("question_id", atom.integer) + +local question = SurveyQuestion:by_id(id) + +local survey = Survey:get_open() + +if question.survey_id ~= survey.id then + slot.put_into("error", _"Internal error 2") + return false +end + +if not question or not question.survey.open then + slot.put_into("error", _"Internal error 3") + return false +end + +local survey_member = SurveyMember:by_pk(question.survey.id, app.session.member_id) +if not survey_member then + return execute.view { module = "index", view = "404" } +end + +local answer_set = survey_member.answer_set +if not answer_set then + return execute.view { module = "index", view = "404" } +end + +local answer = SurveyAnswer:by_pk(answer_set.ident, question.id) +if not answer then + answer = SurveyAnswer:new() + answer.survey_answer_set_ident = answer_set.ident + answer.survey_question_id = question.id +end + +local given_answer = param.get("answer") + +if question.answer_type == "radio" then + if not given_answer then + slot.put_into("error", _"Please choose an option!") + return false + end + local answer_valid = false + for i, answer_option in ipairs(question.answer_options) do + if given_answer == answer_option then + answer_valid = true + end + end + if not answer_valid then + slot.put_into("error", _"Internal error 1") + return false + end +end + +answer.answer = given_answer +answer:save() + +local question +local answers_by_question_id = {} +for i, answer in ipairs(answer_set.answers) do + answers_by_question_id[answer.survey_question_id] = answer +end +for i, q in ipairs(survey.questions) do + if not question and not answers_by_question_id[q.id] then + question = q + end +end + +if not question then + survey_member.survey_answer_set_ident = nil + survey_member.finished = 'now' + survey_member:save() +end + +return true diff -r aebc3b064f85 -r 5a8a09119865 app/main/survey/_action/participate.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/app/main/survey/_action/participate.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,54 @@ +local skip_survey = param.get("skip_survey") + +local survey = Survey:get_open() + +local survey_member = SurveyMember:by_pk(survey.id, app.session.member_id) + +if survey_member and not skip_survey then + return true +end + +local secret_length = 24 +local secret_alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' +local secret_purposes = { "oauth", "_other" } +for idx, purpose in ipairs(secret_purposes) do + secret_purposes[purpose] = idx +end + +local function random_string(length_multiplier) + return multirand.string( + secret_length * (length_multiplier or 1), + secret_alphabet + ) +end + +if not survey_member then + survey_member = SurveyMember:new() + survey_member.survey_id = survey.id + survey_member.member_id = app.session.member_id +end + +if skip_survey then + local answer_set = survey_member.answer_set + if answer_set then + survey_member.survey_answer_set_ident = nil + survey_member:save() + answer_set:destroy() + end + survey_member.rejected = 'now' +else + local answer_set = SurveyAnswerSet:new() + answer_set.ident = random_string() + answer_set.survey_id = survey.id + answer_set:save() + survey_member.survey_answer_set_ident = answer_set.ident +end + +survey_member:save() + +if skip_survey then + return "skip_survey" +end + +return true + diff -r aebc3b064f85 -r 5a8a09119865 app/main/survey/_notification.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/app/main/survey/_notification.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,61 @@ +if not app.session.member then + return +end +local survey = Survey:get_open() + +if not survey then + return +end + +local survey_member = SurveyMember:by_pk(survey.id, app.session.member_id) + +if not survey_member or survey_member.answer_set and not survey_member.finished then + slot.select("motd", function() + ui.container{ attr = { class = "mdl-card mdl-card__fullwidth mdl-shadow--2dp" }, content = function() + ui.container{ attr = { class = "mdl-card__content mdl-card--border" }, content = function() + ui.form{ + module = "survey", action = "participate", + routing = { + ok = { mode = "redirect", module = "survey", view = "participate" }, + error = { mode = "forward", module = "survey", view = "participate" }, + skip_survey = { mode = "redirect", module = "index", view = "index" }, + }, + content = function() + ui.heading{ content = survey.title } + ui.container{ content = function() + slot.put(survey.text) + end } + slot.put("
") + local start_text = _"Start survey" + local cancel_text = _"I don't want to particiapte" + if survey_member then + start_text = _"Continue survey" + cancel_text = _"Cancel survey" + end + ui.tag{ + tag = "input", + attr = { + type = "submit", + class = "mdl-button mdl-js-button mdl-button--raised mdl-button--colored", + value = start_text + }, + content = "" + } + slot.put("   ") + ui.tag{ + tag = "input", + attr = { + type = "submit", + name = "skip_survey", + class = "mdl-button mdl-js-button", + value = cancel_text + }, + content = "" + } + end + } + end } + end } + end) +end + diff -r aebc3b064f85 -r 5a8a09119865 app/main/survey/index.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/app/main/survey/index.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,15 @@ +ui.heading{ level = 1, content = _"Surveys" } + +local surveys = Survey:get_open() + +for i, survey in ipairs(surveys) do + + ui.container{ content = function() + + ui.link{ module = "survey", view = "participate", id = survey.id, content = survey.name } + + ui.container{ content = survey.description } + + end } + +end diff -r aebc3b064f85 -r 5a8a09119865 app/main/survey/participate.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/app/main/survey/participate.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,110 @@ +local survey = Survey:get_open() +if not survey then + return execute.view { module = "index", view = "404" } +end + +local survey_member = SurveyMember:by_pk(survey.id, app.session.member_id) +if not survey_member then + return execute.view { module = "index", view = "404" } +end + +local question + +if survey_member then + local answer_set = survey_member.answer_set + if answer_set then + local answers_by_question_id = {} + for i, answer in ipairs(answer_set.answers) do + answers_by_question_id[answer.survey_question_id] = answer + end + for i, q in ipairs(survey.questions) do + if not question and not answers_by_question_id[q.id] then + question = q + end + end + end +end + +ui.title(_"Survey") +ui.grid{ content = function() + ui.cell_main{ content = function() + + ui.container{ attr = { class = "mdl-card mdl-card__fullwidth mdl-shadow--2dp" }, content = function() + ui.container{ attr = { class = "mdl-card__title mdl-card--border" }, content = function() + ui.heading { attr = { class = "mdl-card__title-text" }, level = 2, content = survey.title } + ui.container{ + content = _( + "This survey closes in #{closing}.", + { closing = format.interval_text(survey.time_left) } + ) + } + end } + ui.container{ attr = { class = "mdl-card__content mdl-card--border" }, content = function() + if survey_member.finished then + ui.container{ content = function() + slot.put(survey.finished_text) + end } + slot.put("
") + ui.link{ + attr = { class = "mdl-button mdl-js-button mdl-button--raised mdl-button--colored" }, + module = "index", view = "index", content = _"Go to start page" + } + return + else + ui.heading{ level = 2, content = question.question } + if question.description then + ui.container{ content = question.description } + end + ui.form{ + module = "survey", action = "answer", + routing = { + ok = { mode = "redirect", module = "survey", view = "participate" }, + error = { mode = "forward", module = "survey", view = "participate" }, + }, + content = function() + ui.field.hidden{ name = "question_id", value = question.id } + if question.answer_type == "radio" then + for i, answer_option in ipairs(question.answer_options) do + ui.container{ content = function() + ui.tag{ tag = "label", attr = { + class = "mdl-radio mdl-js-radio mdl-js-ripple-effect", + ["for"] = "answer_" .. i + }, + content = function() + ui.tag{ + tag = "input", + attr = { + id = "answer_" .. i, + class = "mdl-radio__button", + type = "radio", + name = "answer", + value = answer_option, + checked = param.get("answer") == answer_option and "checked" or nil, + } + } + ui.tag{ + attr = { class = "mdl-radio__label", ['for'] = "answer_" .. i }, + content = answer_option + } + end + } + end } + end + slot.put("
") + ui.tag{ + tag = "input", + attr = { + type = "submit", + class = "mdl-button mdl-js-button mdl-button--raised mdl-button--colored", + value = _"Next step" + }, + content = "" + } + end + end + } + end + end } + end } + end } +end } diff -r aebc3b064f85 -r 5a8a09119865 db/survey.sql --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/db/survey.sql Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,57 @@ +CREATE TABLE survey ( + id SERIAL4 PRIMARY KEY, + title TEXT NOT NULL, + text TEXT NOT NULL, + finished_text TEXT NOT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT now(), + open_from TIMESTAMPTZ NOT NULL, + open_until TIMESTAMPTZ NOT NULL +); + +CREATE TABLE survey_question ( + id SERIAL4 PRIMARY KEY, + survey_id INT4 NOT NULL REFERENCES survey(id), + position INT4 NOT NULL, + question TEXT NOT NULL, + description TEXT, + answer_type TEXT NOT NULL, + answer_options json +); + +CREATE TABLE survey_answer_set ( + ident TEXT NOT NULL PRIMARY KEY, + survey_id INT4 NOT NULL REFERENCES survey(id) +); + +CREATE TABLE survey_answer ( + id SERIAL8 PRIMARY KEY, + survey_answer_set_ident TEXT NOT NULL REFERENCES survey_answer_set(ident) ON DELETE CASCADE, + survey_question_id INT4 NOT NULL REFERENCES survey_question(id), + answer TEXT NOT NULL +); + +CREATE TABLE survey_member ( + id SERIAL4 PRIMARY KEY, + survey_id INT4 NOT NULL REFERENCES survey(id), + member_id INT4 NOT NULL REFERENCES member(id), + survey_answer_set_ident TEXT REFERENCES survey_answer_set(ident), + started TIMESTAMPTZ DEFAULT now(), + finished TIMESTAMPTZ, + rejected TIMESTAMPTZ +); + +INSERT INTO survey (id, title, text, finished_text, open_from, open_until) VALUES ( + 1, + 'Example survey', + 'This is just an example', + 'Done!
You finished it. Thank you very much.', + '2021-10-06 12:00', + '2021-10-14 12:00' +); + +INSERT INTO survey_question (survey_id, position, question, answer_type, answer_options) VALUES + (1, 1, 'What color do you like?', 'radio', '[ "Red", "Green", "Blue" ]'), + (1, 2, 'What form do you like?', 'radio', '[ "Square", "Circle", "Hexagon" ]') +; + + diff -r aebc3b064f85 -r 5a8a09119865 model/survey.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/model/survey.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,35 @@ +Survey = mondelefant.new_class() +Survey.table = 'survey' + +Survey:add_reference{ + mode = '1m', + to = "SurveyQuestion", + this_key = 'id', + that_key = 'survey_id', + ref = 'questions', + back_ref = 'survey', + default_order = 'position' +} + + +local new_selector = Survey.new_selector + +function Survey:new_selector() + local selector = new_selector(self) + selector:add_field("CASE WHEN (open_until NOTNULL AND open_until > now()) THEN open_until - now() ELSE NULL END", "time_left") + return selector +end + +function Survey:get_open() + return self:new_selector() + :add_where("open_from < now() and open_until > now()") + :optional_object_mode() + :exec() +end + +function Survey.object_get:open() + if self.open_from < atom.timestamp:get_current() and self.open_until > atom.timestamp:get_current() then + return true + end + return false +end diff -r aebc3b064f85 -r 5a8a09119865 model/survey_answer.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/model/survey_answer.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,10 @@ +SurveyAnswer = mondelefant.new_class() +SurveyAnswer.table = 'survey_answer' + +function SurveyAnswer:by_pk(survey_answer_set_ident, member_id) + return self:new_selector() + :add_where{ "survey_answer_set_ident = ?", survey_answer_set_ident } + :add_where{ "survey_question_id = ?", question_id } + :optional_object_mode() + :exec() +end diff -r aebc3b064f85 -r 5a8a09119865 model/survey_answer_set.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/model/survey_answer_set.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,12 @@ +SurveyAnswerSet = mondelefant.new_class() +SurveyAnswerSet.table = 'survey_answer_set' +SurveyAnswerSet.primary_key = "ident" + +SurveyAnswerSet:add_reference{ + mode = '1m', + to = "SurveyAnswer", + this_key = 'ident', + that_key = 'survey_answer_set_ident', + ref = 'answers', + back_ref = 'answer_set' +} diff -r aebc3b064f85 -r 5a8a09119865 model/survey_member.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/model/survey_member.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,19 @@ +SurveyMember = mondelefant.new_class() +SurveyMember.table = 'survey_member' + +SurveyMember:add_reference{ + mode = '11', + to = "SurveyAnswerSet", + this_key = 'survey_answer_set_ident', + that_key = 'ident', + ref = 'answer_set', + back_ref = 'member' +} + +function SurveyMember:by_pk(survey_id, member_id) + return self:new_selector() + :add_where{ "survey_id = ?", survey_id } + :add_where{ "member_id = ?", member_id } + :optional_object_mode() + :exec() +end diff -r aebc3b064f85 -r 5a8a09119865 model/survey_question.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/model/survey_question.lua Fri Oct 08 00:09:23 2021 +0200 @@ -0,0 +1,10 @@ +SurveyQuestion = mondelefant.new_class() +SurveyQuestion.table = 'survey_question' + +SurveyQuestion:add_reference{ + mode = 'm1', + to = "Survey", + this_key = 'survey_id', + that_key = 'id', + ref = 'survey', +}