Big refactoring

This commit is contained in:
Marcus Kammer 2024-07-09 20:23:19 +02:00
parent 739244d21c
commit ea0b1bef43
Signed by: marcuskammer
GPG key ID: C374817BE285268F
23 changed files with 389 additions and 448 deletions

View file

@ -2,38 +2,19 @@
(defsystem "dev.metalisp.survey" (defsystem "dev.metalisp.survey"
:description "A simple survey" :description "A simple survey"
:version "0.1.13" :version "0.2.0"
:author "Marcus Kammer <marcus.kammer@metalisp.dev" :author "Marcus Kammer <marcus.kammer@metalisp.dev"
:source-control "git@git.sr.ht:~marcuskammer/dev.metalisp.survey" :source-control "git@git.sr.ht:~marcuskammer/dev.metalisp.survey"
:licence "MIT" :licence "MIT"
:depends-on :depends-on
("local-time" "hunchentoot" "dev.metalisp.sbt") ("local-time" "hunchentoot" "dev.metalisp.sbt")
:components :components
((:module "src" ((:module "src"
:components :components
((:file "package") ((:file "fileops")
(:file "fileops") (:file "navbar")
(:file "app") (:file "app")
(:file "survey") (:file "survey")
(:file "questionnaire")))
(:module "src/views"
:components
((:file "package")
(:file "_navbar")
(:file "main")
(:file "questionnaire") (:file "questionnaire")
(:file "new-survey")
(:file "survey")
(:file "surveys") (:file "surveys")
(:file "questionnaire-submit"))) (:file "new-survey")))))
(:module "src/handlers"
:components
((:file "package")
(:file "main")
(:file "new-survey")
(:file "survey")
(:file "surveys")
(:file "questionnaire")))))

View file

@ -1,9 +1,66 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- ;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey) (defpackage ml-survey/app
(:use #:cl)
(:export #:extract-from
#:*html-lang*
#:generate-uuid
#:split-uri
#:today+now
#:*app*
#:start))
(in-package #:ml-survey/app)
(defparameter *html-lang* "en") (defparameter *html-lang* "en")
(defparameter *use-cdn* nil)
(defparameter *url-key-map*
'((:survey-id . 1)
(:language . 2)
(:questionnaire . 3)))
(defun split-uri (uri)
(check-type uri string)
(remove-if #'string-empty-p
(uiop:split-string uri :separator "/")))
(defun extract-from (url key)
(let* ((parts (split-uri url))
(index (cdr (assoc key *url-key-map*))))
(when (and parts index)
(nth index parts))))
(defun today ()
"Return today's date formatted as ISO-8601."
(local-time:format-timestring nil
(local-time:now)
:format '(:year "-" (:month 2) "-" (:day 2))))
(defun now ()
"Return current time formatted as ISO-8601."
(local-time:format-timestring nil
(local-time:now)
:format '((:hour 2) ":" (:min 2) ":" (:sec 2))))
(defun today+now ()
(format nil "~a ~a" (today) (now)))
(defun generate-uuid ()
(parse-integer (format nil "~A~A~A"
(sb-posix:getpid)
(get-universal-time)
(random 1000000))))
(defun generate-random-id (length)
(let ((charset "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"))
(coerce (loop repeat length
collect (char charset (random (length charset))))
'string)))
(defun string-empty-p (string) (= (length string) 0))
(defun set-default-directory (directory) (defun set-default-directory (directory)
(setf *default-pathname-defaults* (truename (merge-pathnames directory)))) (setf *default-pathname-defaults* (truename (merge-pathnames directory))))
@ -31,9 +88,9 @@
(defvar *app* (create-server 'app (defvar *app* (create-server 'app
8080 8080
:document-root :document-root
(public-dir) (ml-survey/fileops:public-dir)
:access-log-destination :access-log-destination
(access-log-file))) (ml-survey/fileops:access-log-file)))
(defun start () (defun start ()
(start-server *app*)) (start-server *app*))

View file

@ -1,6 +1,21 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- ;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey) (defpackage ml-survey/fileops
(:use #:cl)
(:export #:public-dir
#:access-log-file
#:read-from-file
#:write-to-file
#:ensure-questionnaires-dir
#:ensure-data-file-exist
#:ensure-surveys-dir
#:make-db-file
#:extract-lang-and-filename
#:questionnaires-list-files
#:questionnaires-dir
#:make-surveys-db-file))
(in-package #:ml-survey/fileops)
(defun data-dir () (defun data-dir ()
"Construct and return the directory path for storing data within the "Construct and return the directory path for storing data within the
@ -35,6 +50,19 @@ path."
(defun questionnaires-list-files () (defun questionnaires-list-files ()
(uiop:directory* (format nil "~a*/*.lisp" (questionnaires-dir)))) (uiop:directory* (format nil "~a*/*.lisp" (questionnaires-dir))))
(defun find-next-directory (dir-list target)
(let ((index (position target dir-list :test #'string=)))
(when index
(nth (1+ index) dir-list))))
(defun extract-lang-and-filename (path &optional (target-dir "questionnaires"))
(let* ((directory (pathname-directory path))
(name (pathname-name path))
(lang (find-next-directory directory target-dir)))
(if lang
(format nil "/~A/~A" lang name)
(format nil "/~A" name))))
(defun ensure-file-exist (pathname) (defun ensure-file-exist (pathname)
"Ensure that a file specified by PATHNAME exists, create it if it doesn't." "Ensure that a file specified by PATHNAME exists, create it if it doesn't."
(unless (uiop:file-exists-p pathname) (unless (uiop:file-exists-p pathname)
@ -86,3 +114,6 @@ within the data directory."
(ensure-directories-exist (public-dir)) (ensure-directories-exist (public-dir))
(format t "~%App Data Directory: ~a~%" (data-dir)) (format t "~%App Data Directory: ~a~%" (data-dir))
(defun make-surveys-db-file ()
(make-db-file "surveys-db.lisp"))

View file

@ -1,48 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/handlers)
(defparameter *url-key-map*
'((:survey-id . 1)
(:language . 2)
(:questionnaire . 3)))
(defun split-uri (uri)
(check-type uri string)
(remove-if #'string-empty-p
(uiop:split-string uri :separator "/")))
(defun extract-from (url key)
(let* ((parts (split-uri url))
(index (cdr (assoc key *url-key-map*))))
(when (and parts index)
(nth index parts))))
(defun today ()
"Return today's date formatted as ISO-8601."
(local-time:format-timestring nil
(local-time:now)
:format '(:year "-" (:month 2) "-" (:day 2))))
(defun now ()
"Return current time formatted as ISO-8601."
(local-time:format-timestring nil
(local-time:now)
:format '((:hour 2) ":" (:min 2) ":" (:sec 2))))
(defun today+now ()
(format nil "~a ~a" (today) (now)))
(defun generate-uuid ()
(parse-integer (format nil "~A~A~A"
(sb-posix:getpid)
(get-universal-time)
(random 1000000))))
(defun generate-random-id (length)
(let ((charset "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"))
(coerce (loop repeat length
collect (char charset (random (length charset))))
'string)))
(defun string-empty-p (string) (= (length string) 0))

View file

@ -1,19 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/handlers)
(defun process-new-survey-get ()
(ml-survey/views:new-survey))
(defun process-new-survey-post (request)
(let ((post-params (post-parameters* request))
(uid (generate-uuid))
(stored-surveys (read-from-file (make-surveys-db-file))))
(write-to-file (make-surveys-db-file) (push (list uid post-params) stored-surveys))
(ml-survey/views:new-survey uid)))
(define-easy-handler (new-survey :uri "/new-survey") nil
(cond ((eq (hunchentoot:request-method*) :get)
(process-new-survey-get))
((eq (hunchentoot:request-method*) :post)
(process-new-survey-post *request*))))

View file

@ -1,19 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(defpackage ml-survey/handlers
(:use #:cl)
(:import-from #:spinneret
#:*html*)
(:import-from #:hunchentoot
#:define-easy-handler
#:post-parameters*
#:content-type*
#:request-uri
#:request-uri*
#:*request*)
(:import-from #:ml-survey
#:ensure-data-dir
#:ensure-data-file-exist
#:write-to-file
#:read-from-file
#:make-surveys-db-file))

View file

@ -1,43 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/handlers)
(defun questionnaire-uri-p (uri)
"Check if the request URI matches the pattern '/survey/<numeric>/lang/type'"
(let ((parts (split-uri uri)))
(and (= (length parts) 4)
(string= (first parts) "survey")
(every #'digit-char-p (second parts))
(= 2 (length (third parts))))))
(defun questionnaire-uri (request)
(questionnaire-uri-p (request-uri request)))
(defun process-questionnaire-get (lang questionnaire)
(check-type lang string)
(setf ml-survey:*html-lang* lang)
(ml-survey/views:questionnaire questionnaire))
(defun process-questionnaire-post (request survey questionnaire)
(let* ((post-params (post-parameters* request))
(survey-id (ml-survey:survey-id survey))
(questionnaire-id (generate-uuid))
(questionnaire-data-file (ensure-data-file-exist survey-id
questionnaire-id)))
(write-to-file questionnaire-data-file
(list :type questionnaire
:timestamp (today+now)
:post-data post-params))
(ml-survey/views:questionnaire-submit)))
(define-easy-handler (questionnaire :uri #'questionnaire-uri) nil
(let ((s (make-instance 'ml-survey:survey
:id (extract-from (request-uri*) :survey-id)))
(language (extract-from (request-uri*) :language))
(questionnaire (extract-from (request-uri*) :questionnaire)))
(cond ((eq (hunchentoot:request-method*) :get)
(process-questionnaire-get language questionnaire))
((eq (hunchentoot:request-method*) :post)
(process-questionnaire-post *request* s questionnaire)))))

View file

@ -1,37 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/handlers)
(defun list-of-categorized-results (result-objs)
"Categorize results into different lists based on their type.
Apply special calculation for results of type 'sus'."
(let ((categorized-results (list :sus nil)))
(dolist (result result-objs categorized-results)
(let ((type (intern (string-upcase (ml-survey::questionnaire-result-type result)) :keyword))
(data (ml-survey::questionnaire-result-post-data result))
(timestamp (ml-survey::questionnaire-result-timestamp result)))
(cond
((eq type :sus)
(setf (getf categorized-results :sus)
(cons (ml-survey:sus-calc (cons timestamp data))
(getf categorized-results :sus))))
(t
(setf (getf categorized-results type)
(cons (cons timestamp (mapcar #'cdr data))
(getf categorized-results type)))))))))
(defun survey-uri-p (uri)
(let ((parts (split-uri uri)))
(and (= (length parts) 2)
(string= (first parts) "survey")
(every #'digit-char-p (second parts)))))
(defun survey-uri (request)
(survey-uri-p (request-uri request)))
(define-easy-handler (survey :uri #'survey-uri) ()
(let* ((s (make-instance 'ml-survey:survey
:id (extract-from (request-uri*) :survey-id)))
(result-objs (mapcar #'ml-survey:questionnaire-result-from-file
(ml-survey:survey-data-dir-files s))))
(ml-survey/views:survey s (list-of-categorized-results result-objs))))

View file

@ -1,11 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/handlers)
(define-easy-handler (surveys :uri "/") nil
(let* ((survey-db (read-from-file (make-surveys-db-file)))
(list-of-surveys (mapcar (lambda (x)
(make-instance 'ml-survey:survey
:id (format nil "~a" (first x))))
survey-db)))
(ml-survey/views:surveys list-of-surveys)))

View file

@ -1,6 +1,10 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- ;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/views) (defpackage ml-survey/navbar
(:use :cl)
(:export #:navbar-en))
(in-package :ml-survey/navbar)
(defmacro navbar-brand (src width) (defmacro navbar-brand (src width)
`(spinneret:with-html `(spinneret:with-html

View file

@ -1,12 +1,24 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- ;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/views) (defpackage ml-survey/new-survey
(:use :cl)
(:import-from #:hunchentoot
#:define-easy-handler)
(:import-from #:dev.metalisp.sbt
#:with-page
#:body-header))
(defun new-survey (&optional survey-id) (in-package :ml-survey/new-survey)
(defun list-questionnaires ()
(mapcar #'ml-survey/fileops:extract-lang-and-filename
(ml-survey/fileops:questionnaires-list-files)))
(defun view (&optional survey-id)
"Generates the view to create a new survey." "Generates the view to create a new survey."
(let ((questionnaires (ml-survey:list-questionnaires))) (let ((questionnaires (list-questionnaires)))
(with-page (:title "New Survey") (with-page (:title "New Survey")
(body-header "New Survey" (navbar-en)) (body-header "New Survey" (ml-survey/navbar:navbar-en))
(:main :class "container" (:main :class "container"
:id "main-content" :id "main-content"
@ -16,7 +28,7 @@
(:div :class "alert alert-warning" (:div :class "alert alert-warning"
:role "alert" :role "alert"
(format nil "There are no questionnaires available.~% (format nil "There are no questionnaires available.~%
The folder: ~a is empty." (ml-survey:questionnaires-dir)))) The folder: ~a is empty." (ml-survey/fileops:questionnaires-dir))))
;; When a new survey was created, show the user an info message. ;; When a new survey was created, show the user an info message.
(when survey-id (when survey-id
@ -63,3 +75,20 @@
(:button :type"Submit" (:button :type"Submit"
:class "btn btn-primary" :class "btn btn-primary"
"Create Survey")))))) "Create Survey"))))))
(defun process-new-survey-get ()
(view))
(defun process-new-survey-post (request)
(let ((post-params (hunchentoot:post-parameters* request))
(uid (ml-survey/app:generate-uuid))
(stored-surveys (ml-survey/fileops:read-from-file (ml-survey/fileops:make-surveys-db-file))))
(ml-survey/fileops:write-to-file (ml-survey/fileops:make-surveys-db-file)
(push (list uid post-params) stored-surveys))
(view uid)))
(define-easy-handler (new-survey-handler :uri "/new-survey") nil
(cond ((eq (hunchentoot:request-method*) :get)
(process-new-survey-get))
((eq (hunchentoot:request-method*) :post)
(process-new-survey-post hunchentoot:*request*))))

View file

@ -1,36 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(defpackage ml-survey
(:use #:cl)
(:import-from #:hunchentoot
#:easy-acceptor)
(:export
#:main
#:*html-lang*
#:survey
#:survey-id
#:survey-data-dir-p
#:survey-data-dir-files
#:survey-html
#:survey-properties
#:survey-properties-title
#:survey-properties-description
#:ensure-data-dir
#:ensure-data-file-exist
#:ensure-questionnaires-dir
#:questionnaires-dir
#:questionnaires-list-files
#:extract-lang-and-filename
#:list-questionnaires
#:questionnaire-result
#:questionnaire-result-from-file
#:sus-calc
#:write-to-file
#:read-from-file
#:make-surveys-db-file
#:start
#:*app*
#:set-default-directory
#:start-server
#:stop-server
#:restart-server))

View file

@ -1,57 +1,106 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- ;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey) (defpackage ml-survey/questionnaire
(:use :cl)
(:import-from #:hunchentoot
#:define-easy-handler)
(:import-from #:dev.metalisp.sbt
#:find-l10n
#:*l10n*
#:*use-cdn*
#:with-page
#:body-header
#:body-main)
(:import-from #:dev.metalisp.sbt/btn
#:btn
#:btn-primary)
(:import-from #:dev.metalisp.sbt/form
#:multi-form)
(:import-from #:dev.metalisp.sbt/utility
#:spacing))
(defun find-next-directory (dir-list target) (in-package :ml-survey/questionnaire)
(let ((index (position target dir-list :test #'string=)))
(when index
(nth (1+ index) dir-list))))
(defun extract-lang-and-filename (path &optional (target-dir "questionnaires")) (defun load-form (lang questionnaire)
(let* ((directory (pathname-directory path)) "Load a Lisp file containing form definitions."
(name (pathname-name path)) (check-type lang string)
(lang (find-next-directory directory target-dir))) (check-type questionnaire string)
(if lang (let* ((form-path (uiop:merge-pathnames* (format nil "~a/~a.lisp" lang questionnaire)
(format nil "/~A/~A" lang name) (ml-survey/fileops:ensure-questionnaires-dir))))
(format nil "/~A" name)))) (unless (probe-file form-path)
(error "Form file ~A does not exist." form-path))
(load form-path))
nil)
(defun list-questionnaires () (defmacro with-form (&body body)
(mapcar #'extract-lang-and-filename (questionnaires-list-files))) "Create a standardized HTML form wrapped in a <main> tag with a pre-defined
class and structure, using the Spinneret library. The form is designed to be
used within a web application served by Hunchentoot, utilizing common layout
and localization practices. The macro automatically sets the forms action to
the current request URI and expects certain functions and variables to be
available in its environment for full functionality."
`(spinneret:with-html
(:main :id "main-content"
:class "container my-5"
(:p "Please fill out the following forms and press the submit button.")
;; action is defined as hunchentoot:request-uri* function
(:form :action (hunchentoot:request-uri*)
:method "post"
:class (spacing :property "m" :side "y" :size 5)
,@body
(btn-primary (:type "submit")
(find-l10n "submit" ml-survey/app:*html-lang* *l10n*))))))
(defun extract-numbers (results) (defun view (questionnaire)
"Extract numbers from a questionnaire RESULTS list. (with-page (:title "SUS Form" :add-js-urls ("/app.js"))
Returns a list of integers." (body-header "System Usability Form")
(check-type results list) (with-form (load-form ml-survey/app:*html-lang* questionnaire))))
(mapcar (lambda (x)
(parse-integer (remove-if (complement #'digit-char-p)
(cdr x)))) results))
(defun sus-calc-score (results) (defun view-submit ()
(check-type results list) (with-page (:title "Confirmation")
(let ((counter 0)) (body-header "Confirmation")
(mapcar (lambda (x) (:main :id "main-content"
(setq counter (1+ counter)) :class "container"
(if (evenp counter) (:div :class "alert alert-info"
(- 5 x) :role "alert"
(1- x))) "Thank you for filling out the questionnaire."))))
results)))
(defun sus-calc-score-per-row (results) (defun questionnaire-uri-p (uri)
(check-type results list) "Check if the request URI matches the pattern '/survey/<numeric>/lang/type'"
(reverse (cons (* (apply #'+ (sus-calc-score results)) 2.5) (reverse results)))) (let ((parts (ml-survey/app:split-uri uri)))
(and (= (length parts) 4)
(string= (first parts) "survey")
(every #'digit-char-p (second parts))
(= 2 (length (third parts))))))
(defun sus-calc (files) (defun questionnaire-uri (request)
(check-type files list) (questionnaire-uri-p (hunchentoot:request-uri request)))
(cons (car files) (sus-calc-score-per-row (extract-numbers (cdr files)))))
(defstruct questionnaire-result (defun process-questionnaire-get (lang questionnaire)
type (check-type lang string)
timestamp (setf ml-survey/app:*html-lang* lang)
post-data) (view questionnaire))
(defun questionnaire-result-from-file (filename) (defun process-questionnaire-post (request survey questionnaire)
(check-type filename (or string pathname)) (let* ((post-params (hunchentoot:post-parameters* request))
(let ((data (read-from-file filename))) (survey-id (ml-survey/survey:survey-id survey))
(make-questionnaire-result :type (getf data :type) (questionnaire-id (ml-survey/app:generate-uuid))
:timestamp (getf data :timestamp) (questionnaire-data-file (ml-survey/fileops:ensure-data-file-exist survey-id
:post-data (getf data :post-data)))) questionnaire-id)))
(ml-survey/fileops:write-to-file questionnaire-data-file
(list :type questionnaire
:timestamp (ml-survey/app:today+now)
:post-data post-params))
(view-submit)))
(define-easy-handler (questionnaire-handler :uri #'questionnaire-uri) nil
(let ((s (make-instance 'ml-survey/survey:survey
:id (ml-survey/app:extract-from (hunchentoot:request-uri*) :survey-id)))
(language (ml-survey/app:extract-from (hunchentoot:request-uri*) :language))
(questionnaire (ml-survey/app:extract-from (hunchentoot:request-uri*) :questionnaire)))
(cond ((eq (hunchentoot:request-method*) :get)
(process-questionnaire-get language questionnaire))
((eq (hunchentoot:request-method*) :post)
(process-questionnaire-post hunchentoot:*request* s questionnaire)))))

View file

@ -1,6 +1,4 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- (in-package :ml-survey/questionnaire)
(in-package :ml-survey/views)
(multi-form (multi-form
(:ask "Ich denke, dass ich dieses System häufig nutzen möchte." (:ask "Ich denke, dass ich dieses System häufig nutzen möchte."

View file

@ -1,6 +1,4 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- (in-package :ml-survey/questionnaire)
(in-package :ml-survey/views)
(multi-form (multi-form
(:ask "What is your age range?" (:ask "What is your age range?"

View file

@ -1,6 +1,4 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- (in-package :ml-survey/questionnaire)
(in-package :ml-survey/views)
(multi-form (multi-form
(:ask "I would like to use this system frequently." (:ask "I would like to use this system frequently."

View file

@ -1,9 +1,18 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- ;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey) (defpackage ml-survey/survey
(:use #:cl)
(:import-from #:hunchentoot
#:define-easy-handler)
(:import-from #:dev.metalisp.sbt
#:with-page
#:body-header)
(:export #:survey-id
#:survey
#:survey-properties-title
#:survey-properties-description))
(defun make-surveys-db-file () (in-package #:ml-survey/survey)
(make-db-file "surveys-db.lisp"))
(defclass survey () (defclass survey ()
((id :initarg :id :reader survey-id) ((id :initarg :id :reader survey-id)
@ -14,9 +23,9 @@
(with-slots (id data-dir properties) survey (with-slots (id data-dir properties) survey
(setf data-dir (uiop:merge-pathnames* (setf data-dir (uiop:merge-pathnames*
(format nil "~a/" id) (format nil "~a/" id)
(ensure-surveys-dir))) (ml-survey/fileops:ensure-surveys-dir)))
(setf properties (first (rest (assoc (parse-integer id) (setf properties (first (rest (assoc (parse-integer id)
(read-from-file (make-surveys-db-file)))))))) (ml-survey/fileops:read-from-file (ml-survey/fileops:make-surveys-db-file))))))))
(defgeneric survey-id-p (survey) (defgeneric survey-id-p (survey)
(:documentation "Check if the survey ID is present in the surveys database.")) (:documentation "Check if the survey ID is present in the surveys database."))
@ -34,7 +43,7 @@
(:documentation "Get description property.")) (:documentation "Get description property."))
(defmethod survey-id-p ((survey survey)) (defmethod survey-id-p ((survey survey))
(let ((ids (mapcar #'car (read-from-file (make-surveys-db-file))))) (let ((ids (mapcar #'car (read-from-file (ml-survey/fileops:make-surveys-db-file)))))
(if (member (parse-integer (survey-id survey)) ids) t nil))) (if (member (parse-integer (survey-id survey)) ids) t nil)))
(defmethod survey-data-dir-files ((survey survey)) (defmethod survey-data-dir-files ((survey survey))
@ -62,3 +71,121 @@
(:dd (:a :href (build-questionnaire-link (survey-id survey) value) (:dd (:a :href (build-questionnaire-link (survey-id survey) value)
(format nil "Open Questionnaire ~a" value)))) (format nil "Open Questionnaire ~a" value))))
(t (:dd value))))))) (t (:dd value)))))))
(defun results-not-null (results)
(some (lambda (x) (and (listp x) (not (null x)))) results))
(defun view (survey &optional results)
"Generates the view to show the survey created."
(check-type survey survey)
(let ((results-not-null (results-not-null results))
(sus-results (getf results :sus)))
(with-page (:title "Survey Details" :add-js-urls ("/app.js"))
(body-header "Survey Details" (ml-survey/navbar:navbar-en))
(:main :id "main-content"
:class "container"
(:p (format nil "ID: ~a" (survey-id survey)))
(:h2 :class "py-3" "Properties")
(survey-html survey)
(when results-not-null
(:h2 :class "py-3" "Questionnaire Results")
(if sus-results
(let ((count-answers (length (cdr (car sus-results)))))
(:h3 :class "py-1" "SUS")
(:table :class "table table-hover"
(:caption "Questionnaire results table")
(:thead
(:tr
(:th :scope "col" "Time")
(loop for header from 1 below count-answers
do (:th :scope "col" (format nil "Q ~a" header)))
(:th :scope "col" "SUS Score")))
(:tbody
(loop for row in sus-results
do (:tr (mapcar (lambda (data) (:td data)) row)))))))
(loop for (type data) on results by #'cddr unless (eq type :sus)
do (progn (:h3 :class "py-1" (format nil "~a" type))
(loop for row in data
do (:ul :class "list-group py-3"
(loop for data in row
for i from 0
do (:li :class (if (zerop i)
"list-group-item active"
"list-group-item")
data)))))))))))
(defun extract-numbers (results)
"Extract numbers from a questionnaire RESULTS list.
Returns a list of integers."
(check-type results list)
(mapcar (lambda (x)
(parse-integer (remove-if (complement #'digit-char-p)
(cdr x)))) results))
(defun sus-calc-score (results)
(check-type results list)
(let ((counter 0))
(mapcar (lambda (x)
(setq counter (1+ counter))
(if (evenp counter)
(- 5 x)
(1- x)))
results)))
(defun sus-calc-score-per-row (results)
(check-type results list)
(reverse (cons (* (apply #'+ (sus-calc-score results)) 2.5) (reverse results))))
(defun sus-calc (files)
(check-type files list)
(cons (car files) (sus-calc-score-per-row (extract-numbers (cdr files)))))
(defstruct questionnaire-result
type
timestamp
post-data)
(defun questionnaire-result-from-file (filename)
(check-type filename (or string pathname))
(let ((data (ml-survey/fileops:read-from-file filename)))
(make-questionnaire-result :type (getf data :type)
:timestamp (getf data :timestamp)
:post-data (getf data :post-data))))
(defun list-of-categorized-results (result-objs)
"Categorize results into different lists based on their type.
Apply special calculation for results of type 'sus'."
(let ((categorized-results (list :sus nil)))
(dolist (result result-objs categorized-results)
(let ((type (intern (string-upcase (questionnaire-result-type result)) :keyword))
(data (questionnaire-result-post-data result))
(timestamp (questionnaire-result-timestamp result)))
(cond
((eq type :sus)
(setf (getf categorized-results :sus)
(cons (sus-calc (cons timestamp data))
(getf categorized-results :sus))))
(t
(setf (getf categorized-results type)
(cons (cons timestamp (mapcar #'cdr data))
(getf categorized-results type)))))))))
(defun survey-uri-p (uri)
(let ((parts (ml-survey/app:split-uri uri)))
(and (= (length parts) 2)
(string= (first parts) "survey")
(every #'digit-char-p (second parts)))))
(defun survey-uri (request)
(survey-uri-p (hunchentoot:request-uri request)))
(define-easy-handler (survey-handler :uri #'survey-uri) ()
(let* ((s (make-instance 'survey
:id (ml-survey/app:extract-from (hunchentoot:request-uri*) :survey-id)))
(result-objs (mapcar 'questionnaire-result-from-file
(survey-data-dir-files s))))
(view s (list-of-categorized-results result-objs))))

View file

@ -1,21 +1,29 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*- ;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/views) (defpackage ml-survey/surveys
(:use :cl)
(:import-from #:hunchentoot
#:define-easy-handler)
(:import-from #:dev.metalisp.sbt
#:body-header
#:with-page))
(in-package :ml-survey/surveys)
(defun surveys-p (list) (defun surveys-p (list)
"Check if all elements in `lst` are instances of the class `survey`." "Check if all elements in `lst` are instances of the class `survey`."
(every (lambda (item) (typep item 'ml-survey:survey)) list)) (every (lambda (item) (typep item 'ml-survey/survey:survey)) list))
(deftype surveys-list () (deftype surveys-list ()
'(and list (satisfies surveys-p))) '(and list (satisfies surveys-p)))
(defun surveys (surveys) (defun view (surveys)
"Generates the view to show all surveys available. "Generates the view to show all surveys available.
SURVEYS: List of survey objects." SURVEYS: List of survey objects."
(check-type surveys surveys-list) (check-type surveys surveys-list)
(with-page (:title "Surveys" :add-js-urls ("/app.js")) (with-page (:title "Surveys" :add-js-urls ("/app.js"))
(body-header "Surveys" (navbar-en)) (body-header "Surveys" (ml-survey/navbar:navbar-en))
(:main :id "main-content" (:main :id "main-content"
:class "container" :class "container"
(:div :class "btn-toolbar my-3" (:div :class "btn-toolbar my-3"
@ -27,9 +35,9 @@ SURVEYS: List of survey objects."
(:h2 :class "mb-3" "Overview") (:h2 :class "mb-3" "Overview")
(:ol :class "list-group list-group-numbered" (:ol :class "list-group list-group-numbered"
(loop for survey in surveys (loop for survey in surveys
for title = (ml-survey:survey-properties-title survey) for title = (ml-survey/survey:survey-properties-title survey)
for description = (ml-survey:survey-properties-description survey) for description = (ml-survey/survey:survey-properties-description survey)
for id = (ml-survey:survey-id survey) do for id = (ml-survey/survey:survey-id survey) do
(:li :class "list-group-item d-flex justify-content-between align-items-start" (:li :class "list-group-item d-flex justify-content-between align-items-start"
(:div :class "ms-2 me-auto" (:div :class "ms-2 me-auto"
(:a :class "fw-bold clearfix" (:a :class "fw-bold clearfix"
@ -38,3 +46,11 @@ SURVEYS: List of survey objects."
(if description (if description
(:span description) (:span description)
nil))))))))) nil)))))))))
(define-easy-handler (surveys-handler :uri "/") nil
(let* ((survey-db (ml-survey/fileops:read-from-file (ml-survey/fileops:make-surveys-db-file)))
(list-of-surveys (mapcar (lambda (x)
(make-instance 'ml-survey/survey:survey
:id (format nil "~a" (first x))))
survey-db)))
(view list-of-surveys)))

View file

@ -1,5 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/views)
(defparameter *use-cdn* nil)

View file

@ -1,30 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(defpackage ml-survey/views
(:use #:cl)
(:import-from #:hunchentoot
#:request-uri*)
(:import-from #:spinneret
#:*html*)
(:import-from #:dev.metalisp.sbt
#:find-l10n
#:*l10n*
#:*use-cdn*
#:with-page
#:body-header
#:body-main)
(:import-from #:dev.metalisp.sbt/btn
#:btn
#:btn-primary)
(:import-from #:dev.metalisp.sbt/form
#:multi-form)
(:import-from #:dev.metalisp.sbt/utility
#:spacing)
(:export #:index
#:imprint
#:new-survey
#:surveys
#:create-survey
#:survey
#:questionnaire-submit
#:questionnaire))

View file

@ -1,12 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/views)
(defun questionnaire-submit ()
(with-page (:title "Confirmation")
(body-header "Confirmation")
(:main :id "main-content"
:class "container"
(:div :class "alert alert-info"
:role "alert"
"Thank you for filling out the questionnaire."))))

View file

@ -1,38 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/views)
(defun load-form (lang questionnaire)
"Load a Lisp file containing form definitions."
(check-type lang string)
(check-type questionnaire string)
(let* ((form-path (uiop:merge-pathnames* (format nil "~a/~a.lisp" lang questionnaire)
(ml-survey:ensure-questionnaires-dir))))
(unless (probe-file form-path)
(error "Form file ~A does not exist." form-path))
(load form-path))
nil)
(defmacro with-form (&body body)
"Create a standardized HTML form wrapped in a <main> tag with a pre-defined
class and structure, using the Spinneret library. The form is designed to be
used within a web application served by Hunchentoot, utilizing common layout
and localization practices. The macro automatically sets the forms action to
the current request URI and expects certain functions and variables to be
available in its environment for full functionality."
`(spinneret:with-html
(:main :id "main-content"
:class "container my-5"
(:p "Please fill out the following forms and press the submit button.")
;; action is defined as hunchentoot:request-uri* function
(:form :action (request-uri*)
:method "post"
:class (spacing :property "m" :side "y" :size 5)
,@body
(btn-primary (:type "submit")
(find-l10n "submit" ml-survey:*html-lang* *l10n*))))))
(defun questionnaire (questionnaire)
(with-page (:title "SUS Form" :add-js-urls ("/app.js"))
(body-header "System Usability Form")
(with-form (load-form ml-survey:*html-lang* questionnaire))))

View file

@ -1,49 +0,0 @@
;;;; -*- mode: common-lisp; coding: utf-8; -*-
(in-package :ml-survey/views)
(defun results-not-null (results)
(some (lambda (x) (and (listp x) (not (null x)))) results))
(defun survey (survey &optional results)
"Generates the view to show the survey created."
(check-type survey ml-survey:survey)
(let ((results-not-null (results-not-null results))
(sus-results (getf results :sus)))
(with-page (:title "Survey Details" :add-js-urls ("/app.js"))
(body-header "Survey Details" (navbar-en))
(:main :id "main-content"
:class "container"
(:p (format nil "ID: ~a" (ml-survey:survey-id survey)))
(:h2 :class "py-3" "Properties")
(ml-survey:survey-html survey)
(when results-not-null
(:h2 :class "py-3" "Questionnaire Results")
(if sus-results
(let ((count-answers (length (cdr (car sus-results)))))
(:h3 :class "py-1" "SUS")
(:table :class "table table-hover"
(:caption "Questionnaire results table")
(:thead
(:tr
(:th :scope "col" "Time")
(loop for header from 1 below count-answers
do (:th :scope "col" (format nil "Q ~a" header)))
(:th :scope "col" "SUS Score")))
(:tbody
(loop for row in sus-results
do (:tr (mapcar (lambda (data) (:td data)) row)))))))
(loop for (type data) on results by #'cddr unless (eq type :sus)
do (progn (:h3 :class "py-1" (format nil "~a" type))
(loop for row in data
do (:ul :class "list-group py-3"
(loop for data in row
for i from 0
do (:li :class (if (zerop i)
"list-group-item active"
"list-group-item")
data)))))))))))