Selaa lähdekoodia

Working on server and cli

andrea 5 kuukautta sitten
vanhempi
commit
142741ab5f

+ 18 - 11
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

+ 1 - 1
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

+ 29 - 0
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)
+}

+ 1 - 0
models/session.go

@@ -0,0 +1 @@
+package models

+ 127 - 49
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)
+type ExamTemplateData struct {
+	*models.Exam
+
+	SessionID string
+}
+
+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
 	}
-	return id
+
+	_, 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 createExamSessionHandler(w http.ResponseWriter, r *http.Request) {
+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>                                                                
-     `))
-
-	err = tmpl.Execute(w, exam)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+	if r.Method == "GET" {
+
+		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 (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() {
-	mux := http.NewServeMux()
-	mux.HandleFunc("/create", createExamSessionHandler)
-	mux.HandleFunc("/", getExamHandler)
+	server, err := NewDefaultServer()
+	if err != nil {
+		panic(err)
+	}
 
-	slog.Info("Probo server started", "at", time.Now())
-	http.ListenAndServe(":8080", mux)
+	log.Println("Probo server started.", "Config", server.config)
+	http.ListenAndServe(":8080", server)
 }

+ 49 - 35
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()
+	s, err := NewDefaultServer()
+	t.Nil(err)
 
-	handler := http.HandlerFunc(createExamSessionHandler)
+	if !t.Failed() {
+		request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload))
+		response := httptest.NewRecorder()
 
-	handler.ServeHTTP(response, request)
+		handler := http.HandlerFunc(s.createExamSessionHandler)
 
-	t.Equal(http.StatusOK, response.Code)
+		handler.ServeHTTP(response, request)
 
-	if !t.Failed() {
-		result := map[string]string{}
+		t.Equal(http.StatusOK, response.Code)
+
+		if !t.Failed() {
+			result := map[string]string{}
 
-		err := json.Unmarshal(response.Body.Bytes(), &result)
-		t.Nil(err)
+			err := json.Unmarshal(response.Body.Bytes(), &result)
+			t.Nil(err)
 
-		path := filepath.Join(Dir, result["id"])
-		_, err = os.Stat(path)
-		defer os.RemoveAll(path)
+			path := filepath.Join(GetDefaultSessionDir(), result["id"])
+			_, err = os.Stat(path)
+			defer os.RemoveAll(path)
 
-		files, err := os.ReadDir(path)
-		t.Nil(err)
+			files, err := os.ReadDir(path)
+			t.Nil(err)
 
-		t.Equal(2, len(files))
+			t.Equal(2, len(files))
 
-		t.Nil(err)
+			t.Nil(err)
+		}
 	}
 }
 
 func (t *serverTestSuite) TestRead() {
 
-	Dir = "testdata"
+	DefaultDataDir = "testdata"
+
+	s, err := NewDefaultServer()
+	t.Nil(err)
+
+	if !t.Failed() {
+		request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload))
+		response := httptest.NewRecorder()
 
-	request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload))
-	response := httptest.NewRecorder()
+		handler := http.HandlerFunc(s.createExamSessionHandler)
 
-	handler := http.HandlerFunc(createExamSessionHandler)
+		handler.ServeHTTP(response, request)
 
-	handler.ServeHTTP(response, request)
+		t.Equal(http.StatusOK, response.Code)
 
-	t.Equal(http.StatusOK, response.Code)
+		if !t.Failed() {
+			result := map[string]string{}
 
-	if !t.Failed() {
-		result := map[string]string{}
+			err := json.Unmarshal(response.Body.Bytes(), &result)
+			t.Nil(err)
 
-		err := json.Unmarshal(response.Body.Bytes(), &result)
-		t.Nil(err)
+			path := filepath.Join(GetDefaultSessionDir(), result["id"])
+			_, err = os.Stat(path)
+			t.Nil(err)
 
-		path := filepath.Join(Dir, result["id"])
-		_, err = os.Stat(path)
-		defer os.RemoveAll(path)
+			if !t.Failed() {
+				defer os.RemoveAll(path)
 
-		request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/%s/%s", result["id"], "111222"), nil)
-		response := httptest.NewRecorder()
+				request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/%s/%s", result["id"], "111222"), nil)
+				response := httptest.NewRecorder()
 
-		handler := http.HandlerFunc(getExamHandler)
+				handler := http.HandlerFunc(s.getExamHandler)
 
-		handler.ServeHTTP(response, request)
+				handler.ServeHTTP(response, request)
 
-		t.Equal(http.StatusOK, response.Code)
+				t.Equal(http.StatusOK, response.Code)
 
+			}
+		}
 	}
 }

+ 144 - 0
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;
+    }
+}

+ 27 - 0
server/templates/exam.tpl

@@ -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 - 0
server/testdata/sessions/README

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

+ 74 - 0
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
+}

+ 5 - 0
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)
+}

+ 25 - 0
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{}
+			},
+		},
+	)
+
+}

+ 1 - 1
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"}}
+{"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"}}

+ 1 - 1
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"}}
+{"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"}}

+ 1 - 1
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"}}
+{"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 - 0
store/response.go

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