diff --git a/cli/main.go b/cli/main.go index 2692f7e..7b826f6 100644 --- a/cli/main.go +++ b/cli/main.go @@ -4,7 +4,7 @@ import ( "log" "os" - "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/session" "git.andreafazzi.eu/andrea/probo/store/file" "github.com/urfave/cli/v2" ) @@ -36,21 +36,28 @@ func main() { if err != nil { log.Fatalf("An error occurred: %v", err) } - eStore, err := file.NewDefaultExamFileStore() + + session, err := session.NewSession( + "http://localhost:8080/create", + cCtx.Args().First(), + pStore.Storer, + qStore.Storer, + nil, + nil, + ) if err != nil { log.Fatalf("An error occurred: %v", err) } + id, err := session.Push() + if err != nil { + log.Fatalf("An error occurred: %v", err) + } + + log.Printf("Session upload completed with success. URL: https://localhost:8080/%v", id) + for _, p := range pStore.ReadAll() { - e, err := eStore.Create(&models.Exam{ - Name: cCtx.Args().First(), - Participant: p, - Quizzes: qStore.ReadAll(), - }) - if err != nil { - log.Fatalf("An error occurred: %v", err) - } - log.Printf("Created exam %v... in %v", e.ID[:8], eStore.GetPath(e)) + log.Printf("http://localhost:8080/%v/%v", id, p.Token) } return nil diff --git a/go.mod b/go.mod index fef1a2c..2373a0a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.andreafazzi.eu/andrea/probo go 1.21 require ( + github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d github.com/google/uuid v1.3.1 github.com/julienschmidt/httprouter v1.3.0 github.com/sirupsen/logrus v1.8.1 @@ -16,7 +17,6 @@ require ( github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/glebarez/sqlite v1.9.0 // indirect github.com/go-yaml/yaml v2.1.0+incompatible // indirect - github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/kr/pretty v0.2.1 // indirect diff --git a/models/response.go b/models/response.go new file mode 100644 index 0000000..bbdb775 --- /dev/null +++ b/models/response.go @@ -0,0 +1,29 @@ +package models + +import ( + "crypto/sha256" + "encoding/json" + "fmt" +) + +type Response struct { + Meta + QuestionID string + AnswerID string +} + +func (r *Response) String() string { + return fmt.Sprintf("QID: %v, AID:%v", r.QuestionID, r.AnswerID) +} + +func (r *Response) GetHash() string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(r.QuestionID+r.AnswerID))) +} + +func (r *Response) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func (r *Response) Unmarshal(data []byte) error { + return json.Unmarshal(data, r) +} diff --git a/models/session.go b/models/session.go new file mode 100644 index 0000000..2640e7f --- /dev/null +++ b/models/session.go @@ -0,0 +1 @@ +package models diff --git a/server/main.go b/server/main.go index 9123e9a..4a2ffc2 100644 --- a/server/main.go +++ b/server/main.go @@ -2,7 +2,7 @@ package main import ( "encoding/json" - "log/slog" + "log" "math/rand" "net/http" "os" @@ -10,24 +10,89 @@ import ( "strconv" "strings" "text/template" - "time" "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store/file" ) -var Dir = "data" +var ( + DefaultDataDir = "data" + DefaultSessionDir = "sessions" + DefaultTemplateDir = "templates" + DefaultStaticDir = "static" +) + +type Config struct { + SessionDir string + TemplateDir string + StaticDir string +} type ExamSession []*models.Exam -func generateRandomID() string { - id := "" - for i := 0; i < 6; i++ { - id += strconv.Itoa(rand.Intn(9) + 1) - } - return id +type ExamTemplateData struct { + *models.Exam + + SessionID string } -func createExamSessionHandler(w http.ResponseWriter, r *http.Request) { +type Server struct { + config *Config + mux *http.ServeMux + responseStore *file.ResponseFileStore +} + +func GetDefaultSessionDir() string { + return filepath.Join(DefaultDataDir, DefaultSessionDir) +} + +func GetDefaultTemplateDir() string { + return DefaultTemplateDir +} + +func GetDefaultStaticDir() string { + return DefaultStaticDir +} + +func NewServer(config *Config) (*Server, error) { + + _, err := os.Stat(config.SessionDir) + if err != nil { + return nil, err + } + + _, err = os.Stat(config.TemplateDir) + if err != nil { + return nil, err + } + + _, err = os.Stat(config.StaticDir) + if err != nil { + return nil, err + } + + s := &Server{ + config, + http.NewServeMux(), + nil, + } + + s.mux.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir(config.StaticDir)))) + s.mux.HandleFunc("/create", s.createExamSessionHandler) + s.mux.HandleFunc("/", s.getExamHandler) + + return s, nil +} + +func NewDefaultServer() (*Server, error) { + return NewServer(&Config{ + SessionDir: GetDefaultSessionDir(), + TemplateDir: GetDefaultTemplateDir(), + StaticDir: GetDefaultStaticDir(), + }) +} + +func (s *Server) createExamSessionHandler(w http.ResponseWriter, r *http.Request) { var p ExamSession err := json.NewDecoder(r.Body).Decode(&p) if err != nil { @@ -36,7 +101,7 @@ func createExamSessionHandler(w http.ResponseWriter, r *http.Request) { } id := generateRandomID() - path := filepath.Join(Dir, id) + path := filepath.Join(s.config.SessionDir, id) err = os.MkdirAll(path, os.ModePerm) if err != nil { @@ -62,13 +127,13 @@ func createExamSessionHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -func getExamHandler(w http.ResponseWriter, r *http.Request) { +func (s *Server) getExamHandler(w http.ResponseWriter, r *http.Request) { urlParts := strings.Split(r.URL.Path, "/") examID := urlParts[1] token := urlParts[2] - filePath := filepath.Join(Dir, examID, token+".json") + filePath := filepath.Join(s.config.SessionDir, examID, token+".json") data, err := os.ReadFile(filePath) if err != nil { @@ -83,48 +148,61 @@ func getExamHandler(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "text/html") - tmpl := template.Must(template.New("exam").Parse(` - - - - {{.Name}} - - -

{{.Name}}

-

{{.Participant.Firstname}} {{.Participant.Lastname}}

-
- {{range $index, $quiz := .Quizzes}} -

Question {{$index}}:

-

{{$quiz.Question.Text}}

- {{range $answer := $quiz.Answers}} - -
- {{end}} -
- {{end}} - -
- - - `)) + if r.Method == "GET" { - err = tmpl.Execute(w, exam) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + w.Header().Set("Content-Type", "text/html") + + tplData, err := os.ReadFile(filepath.Join(GetDefaultTemplateDir(), "exam.tpl")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + + } + tmpl := template.Must(template.New("exam").Parse(string(tplData))) + + err = tmpl.Execute(w, ExamTemplateData{exam, examID}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + if r.Method == "POST" { + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/html") + if r.FormValue("answer") == exam.Quizzes[0].Correct.ID { + w.Write([]byte("

Corretto!

")) + return + } + w.Write([]byte("

Errato!

")) return } } -func main() { - mux := http.NewServeMux() - mux.HandleFunc("/create", createExamSessionHandler) - mux.HandleFunc("/", getExamHandler) - - slog.Info("Probo server started", "at", time.Now()) - http.ListenAndServe(":8080", mux) +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + +func generateRandomID() string { + id := "" + for i := 0; i < 6; i++ { + id += strconv.Itoa(rand.Intn(9) + 1) + } + return id +} + +func main() { + server, err := NewDefaultServer() + if err != nil { + panic(err) + } + + log.Println("Probo server started.", "Config", server.config) + http.ListenAndServe(":8080", server) } diff --git a/server/server_test.go b/server/server_test.go index a18b276..02a229e 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -197,67 +197,81 @@ func TestRunner(t *testing.T) { func (t *serverTestSuite) TestCreate() { - Dir = "testdata" + DefaultDataDir = "testdata" - request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload)) - response := httptest.NewRecorder() - - handler := http.HandlerFunc(createExamSessionHandler) - - handler.ServeHTTP(response, request) - - t.Equal(http.StatusOK, response.Code) + s, err := NewDefaultServer() + t.Nil(err) if !t.Failed() { - result := map[string]string{} - - err := json.Unmarshal(response.Body.Bytes(), &result) - t.Nil(err) - - path := filepath.Join(Dir, result["id"]) - _, err = os.Stat(path) - defer os.RemoveAll(path) - - files, err := os.ReadDir(path) - t.Nil(err) - - t.Equal(2, len(files)) - - t.Nil(err) - } -} - -func (t *serverTestSuite) TestRead() { - - Dir = "testdata" - - request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload)) - response := httptest.NewRecorder() - - handler := http.HandlerFunc(createExamSessionHandler) - - handler.ServeHTTP(response, request) - - t.Equal(http.StatusOK, response.Code) - - if !t.Failed() { - result := map[string]string{} - - err := json.Unmarshal(response.Body.Bytes(), &result) - t.Nil(err) - - path := filepath.Join(Dir, result["id"]) - _, err = os.Stat(path) - defer os.RemoveAll(path) - - request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/%s/%s", result["id"], "111222"), nil) + request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload)) response := httptest.NewRecorder() - handler := http.HandlerFunc(getExamHandler) + handler := http.HandlerFunc(s.createExamSessionHandler) handler.ServeHTTP(response, request) t.Equal(http.StatusOK, response.Code) + if !t.Failed() { + result := map[string]string{} + + err := json.Unmarshal(response.Body.Bytes(), &result) + t.Nil(err) + + path := filepath.Join(GetDefaultSessionDir(), result["id"]) + _, err = os.Stat(path) + defer os.RemoveAll(path) + + files, err := os.ReadDir(path) + t.Nil(err) + + t.Equal(2, len(files)) + + t.Nil(err) + } + } +} + +func (t *serverTestSuite) TestRead() { + + DefaultDataDir = "testdata" + + s, err := NewDefaultServer() + t.Nil(err) + + if !t.Failed() { + request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload)) + response := httptest.NewRecorder() + + handler := http.HandlerFunc(s.createExamSessionHandler) + + handler.ServeHTTP(response, request) + + t.Equal(http.StatusOK, response.Code) + + if !t.Failed() { + result := map[string]string{} + + err := json.Unmarshal(response.Body.Bytes(), &result) + t.Nil(err) + + path := filepath.Join(GetDefaultSessionDir(), result["id"]) + _, err = os.Stat(path) + t.Nil(err) + + if !t.Failed() { + defer os.RemoveAll(path) + + request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/%s/%s", result["id"], "111222"), nil) + response := httptest.NewRecorder() + + handler := http.HandlerFunc(s.getExamHandler) + + handler.ServeHTTP(response, request) + + t.Equal(http.StatusOK, response.Code) + + } + } } } diff --git a/server/static/css/neat.css b/server/static/css/neat.css new file mode 100644 index 0000000..ed85173 --- /dev/null +++ b/server/static/css/neat.css @@ -0,0 +1,144 @@ +:root { + color-scheme: light dark; + --light: #fff; + --lesslight: #efefef; + --dark: #404040; + --moredark: #000; + border-top: 5px solid var(--dark); + line-height: 1.5em; /* This causes wrapping h1's to collapse too small */ + font-family: sans-serif; + font-size: 16px; +} + +* { + box-sizing: border-box; + color: var(--dark); +} + +button, input { + font-size: 1em; /* Override browser default font shrinking*/ +} + +input { + border: 1px solid var(--dark); + background-color: var(--lesslight); + border-radius: .25em; + padding: .5em; +} + +pre { + background-color: var(--lesslight); + margin: 0.5em 0 0.5em 0; + padding: 0.5em; + overflow: auto; +} + +code { + background-color: var(--lesslight); +} + +body { + background-color: var(--light); + margin: 0; + max-width: 800px; + padding: 0 20px 20px 20px; + margin-left: auto; + margin-right: auto; +} + +img { + max-width: 100%; + height: auto; +} + +button, .button, input[type=submit] { + display: inline-block; + background-color: var(--dark); + color: var(--light); + text-align: center; + padding: .5em; + border-radius: .25em; + text-decoration: none; + border: none; + cursor: pointer; +} + +button:hover, .button:hover, input[type=submit]:hover { + color: var(--lesslight); + background-color: var(--moredark); +} + +/* Add a margin between side-by-side buttons */ +button + button, .button + .button, input[type=submit] + input[type=submit] { + margin-left: 1em; +} + +.center { + display: block; + margin-left: auto; + margin-right: auto; + text-align: center; +} + +.bordered { + border: 3px solid; +} + +.home { + display: inline-block; + background-color: var(--dark); + color: var(--light); + margin-top: 20px; + padding: 5px 10px 5px 10px; + text-decoration: none; + font-weight: bold; +} + + +/* Desktop sizes */ +@media only screen and (min-width: 600px) { + ol.twocol { + column-count: 2; + } + + .row { + display: flex; + flex-direction: row; + padding: 0; + width: 100%; + } + + /* Make everything in a row a column */ + .row > * { + display: block; + flex: 1 1 auto; + max-width: 100%; + width: 100%; + } + + .row > *:not(:last-child) { + margin-right: 10px; + } +} + +/* Dark mode overrides (confusingly inverse) */ +@media (prefers-color-scheme: dark) { + :root { + --light: #222; + --lesslight: #333; + --dark: #eee; + --moredark: #fefefe; + } + /* This fixes an odd blue then white shadow on FF in dark mode */ + *:focus { + outline: var(--light); + box-shadow: 0 0 0 .25em royalblue; + } +} + +/* Printing */ +@media print { + .home { + display: none; + } +} diff --git a/server/templates/exam.tpl b/server/templates/exam.tpl new file mode 100644 index 0000000..aa6071e --- /dev/null +++ b/server/templates/exam.tpl @@ -0,0 +1,27 @@ + + + + + + {{.Name}} + + +

{{.Name}}

+

{{.Participant.Firstname}} {{.Participant.Lastname}}

+
+ {{range $index, $quiz := .Quizzes}} +

Question {{$index}}:

+

{{$quiz.Question.Text}}

+ {{range $answer := $quiz.Answers}} + +
+ {{end}} +
+ {{end}} + +
+ + diff --git a/server/testdata/sessions/README b/server/testdata/sessions/README new file mode 100644 index 0000000..687ddb6 --- /dev/null +++ b/server/testdata/sessions/README @@ -0,0 +1 @@ +Please keep this file in the git tree in order to add the testdata/sessions folder. diff --git a/session/session.go b/session/session.go new file mode 100644 index 0000000..664045e --- /dev/null +++ b/session/session.go @@ -0,0 +1,74 @@ +package session + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + + "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store" +) + +type Session struct { + Name string + + ParticipantStore *store.ParticipantStore + QuizStore *store.QuizStore + + ParticipantFilter map[string]string + QuizFilter map[string]string + ServerURL string + Token int + + examStore *store.ExamStore +} + +func NewSession(url string, name string, pStore *store.ParticipantStore, qStore *store.QuizStore, pFilter map[string]string, qFilter map[string]string) (*Session, error) { + session := new(Session) + + session.ServerURL = url + session.examStore = store.NewStore[*models.Exam]() + + for _, p := range pStore.ReadAll() { + _, err := session.examStore.Create(&models.Exam{ + Name: name, + Participant: p, + Quizzes: qStore.ReadAll(), + }) + if err != nil { + return nil, err + } + } + + return session, nil +} + +func (s *Session) GetExams() []*models.Exam { + return s.examStore.ReadAll() +} + +func (s *Session) Push() (string, error) { + payload, err := json.Marshal(s.examStore.ReadAll()) + if err != nil { + return "", err + } + + response, err := http.Post(s.ServerURL, "application/json", bytes.NewReader(payload)) + if err != nil { + return "", err + } + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + result := map[string]string{} + err = json.Unmarshal(responseBody, &result) + if err != nil { + return "", err + } + + return result["id"], nil +} diff --git a/store/file/defaults.go b/store/file/defaults.go index 1caee9b..d593091 100644 --- a/store/file/defaults.go +++ b/store/file/defaults.go @@ -9,6 +9,7 @@ var ( DefaultParticipantsSubdir = "participants" DefaultGroupsSubdir = "groups" DefaultExamsSubdir = "exams" + DefaultResponsesSubdir = "responses" ) func GetDefaultQuizzesDir() string { @@ -30,3 +31,7 @@ func GetDefaultGroupsDir() string { func GetDefaultExamsDir() string { return filepath.Join(DefaultBaseDir, DefaultExamsSubdir) } + +func GetDefaultResponsesDir() string { + return filepath.Join(DefaultBaseDir, DefaultResponsesSubdir) +} diff --git a/store/file/response.go b/store/file/response.go new file mode 100644 index 0000000..e78b04b --- /dev/null +++ b/store/file/response.go @@ -0,0 +1,25 @@ +package file + +import ( + "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store" +) + +type ResponseFileStore = FileStore[*models.Response, *store.Store[*models.Response]] + +func NewResponseFileStore(config *FileStoreConfig[*models.Response, *store.ResponseStore]) (*ResponseFileStore, error) { + return NewFileStore[*models.Response](config, store.NewStore[*models.Response]()) +} + +func NewDefaultResponseFileStore() (*ResponseFileStore, error) { + return NewResponseFileStore( + &FileStoreConfig[*models.Response, *store.ResponseStore]{ + FilePathConfig: FilePathConfig{GetDefaultResponsesDir(), "response", ".json"}, + IndexDirFunc: DefaultIndexDirFunc[*models.Response, *store.ResponseStore], + CreateEntityFunc: func() *models.Response { + return &models.Response{} + }, + }, + ) + +} diff --git a/store/file/testdata/exams/participants/jack.json b/store/file/testdata/exams/participants/jack.json index 7d48f19..3db70ad 100644 --- a/store/file/testdata/exams/participants/jack.json +++ b/store/file/testdata/exams/participants/jack.json @@ -1 +1 @@ -{"id":"5467","created_at":"2023-12-05T22:00:51.525533451+01:00","updated_at":"2023-12-05T22:00:58.859239024+01:00","Firstname":"Jack","Lastname":"Sparrow","Token":333444,"Attributes":{"class":"2 D LIN"}} \ No newline at end of file +{"id":"5467","created_at":"2023-12-05T22:00:51.525533451+01:00","updated_at":"2023-12-09T19:59:19.383186974+01:00","Firstname":"Jack","Lastname":"Sparrow","Token":333444,"Attributes":{"class":"2 D LIN"}} \ No newline at end of file diff --git a/store/file/testdata/exams/participants/john.json b/store/file/testdata/exams/participants/john.json index c62d3a8..8d1ab35 100644 --- a/store/file/testdata/exams/participants/john.json +++ b/store/file/testdata/exams/participants/john.json @@ -1 +1 @@ -{"id":"1234","created_at":"2023-12-05T22:00:51.525601298+01:00","updated_at":"2023-12-05T22:00:58.859318678+01:00","Firstname":"John","Lastname":"Smith","Token":111222,"Attributes":{"class":"1 D LIN"}} \ No newline at end of file +{"id":"1234","created_at":"2023-12-05T22:00:51.525601298+01:00","updated_at":"2023-12-09T19:59:19.383367508+01:00","Firstname":"John","Lastname":"Smith","Token":111222,"Attributes":{"class":"1 D LIN"}} \ No newline at end of file diff --git a/store/file/testdata/exams/participants/wendy.json b/store/file/testdata/exams/participants/wendy.json index 0b1643b..99fe2f9 100644 --- a/store/file/testdata/exams/participants/wendy.json +++ b/store/file/testdata/exams/participants/wendy.json @@ -1 +1 @@ -{"id":"567812","created_at":"2023-12-05T22:00:51.525667963+01:00","updated_at":"2023-12-05T22:00:58.859375108+01:00","Firstname":"Wendy","Lastname":"Darling","Token":333444,"Attributes":{"class":"2 D LIN"}} \ No newline at end of file +{"id":"567812","created_at":"2023-12-05T22:00:51.525667963+01:00","updated_at":"2023-12-09T19:59:19.383472724+01:00","Firstname":"Wendy","Lastname":"Darling","Token":333444,"Attributes":{"class":"2 D LIN"}} \ No newline at end of file diff --git a/store/response.go b/store/response.go new file mode 100644 index 0000000..72c3999 --- /dev/null +++ b/store/response.go @@ -0,0 +1,5 @@ +package store + +import "git.andreafazzi.eu/andrea/probo/models" + +type ResponseStore = Store[*models.Response]