Working on server and cli

This commit is contained in:
andrea 2023-12-11 09:32:50 +01:00
parent 1c0119b342
commit 142741ab5f
16 changed files with 528 additions and 118 deletions

View file

@ -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

2
go.mod
View file

@ -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

29
models/response.go Normal file
View file

@ -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)
}

1
models/session.go Normal file
View file

@ -0,0 +1 @@
package models

View file

@ -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(`
<!DOCTYPE html>
<html>
<head>
<title>{{.Name}}</title>
</head>
<body>
<h1>{{.Name}}</h1>
<h2>{{.Participant.Firstname}} {{.Participant.Lastname}}</h2>
<form>
{{range $index, $quiz := .Quizzes}}
<h3>Question {{$index}}:</h3>
<p>{{$quiz.Question.Text}}</p>
{{range $answer := $quiz.Answers}}
<input type="radio"
id="{{$answer.ID}}" name="$answer.ID"
value="{{$answer.Text}}">
<label
for="{{$answer.ID}}">{{$answer.Text}}</label><br>
{{end}}
<br>
{{end}}
<input type="submit" value="Invia">
</form>
</body>
</html>
`))
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("<p>Corretto!</p>"))
return
}
w.Write([]byte("<p>Errato!</p>"))
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)
}

View file

@ -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)
}
}
}
}

144
server/static/css/neat.css Normal file
View file

@ -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;
}
}

27
server/templates/exam.tpl Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="/static/css/neat.css">
<title>{{.Name}}</title>
</head>
<body>
<h1>{{.Name}}</h1>
<h2>{{.Participant.Firstname}} {{.Participant.Lastname}}</h2>
<form action="/{{.SessionID}}/{{.Participant.Token}}" method="post">
{{range $index, $quiz := .Quizzes}}
<h3>Question {{$index}}:</h3>
<p>{{$quiz.Question.Text}}</p>
{{range $answer := $quiz.Answers}}
<input type="radio"
id="{{$answer.ID}}" name="answer"
value="{{$answer.ID}}">
<label
for="{{$answer.ID}}">{{$answer.Text}}</label><br>
{{end}}
<br>
{{end}}
<button type="submit">Invia</button>
</form>
</body>
</html>

1
server/testdata/sessions/README vendored Normal file
View file

@ -0,0 +1 @@
Please keep this file in the git tree in order to add the testdata/sessions folder.

74
session/session.go Normal file
View file

@ -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
}

View file

@ -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)
}

25
store/file/response.go Normal file
View file

@ -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{}
},
},
)
}

View file

@ -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"}}
{"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"}}

View file

@ -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"}}
{"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"}}

View file

@ -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"}}
{"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"}}

5
store/response.go Normal file
View file

@ -0,0 +1,5 @@
package store
import "git.andreafazzi.eu/andrea/probo/models"
type ResponseStore = Store[*models.Response]