diff --git a/client/client.go b/client/client.go index 23238a0..ea1c4ff 100644 --- a/client/client.go +++ b/client/client.go @@ -22,10 +22,10 @@ type Collection struct { } type Participant struct { - Firstname string `json:"firstname"` - Lastname string `json:"lastname"` - Class string `json:"class"` - Token uint `json:"token"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Token uint `json:"token"` + Attributes map[string]string `json:"attributes"` } type Exam struct { diff --git a/go.mod b/go.mod index fc7a31d..6525cfd 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,21 @@ module git.andreafazzi.eu/andrea/probo -go 1.17 +go 1.21 -require github.com/sirupsen/logrus v1.8.1 +require ( + github.com/google/uuid v1.3.1 + github.com/julienschmidt/httprouter v1.3.0 + github.com/sirupsen/logrus v1.8.1 + gorm.io/gorm v1.25.5 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect 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/google/uuid v1.3.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kr/pretty v0.2.1 // indirect github.com/kr/text v0.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -20,7 +23,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.13.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gorm.io/gorm v1.25.5 // indirect modernc.org/libc v1.24.1 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.7.2 // indirect diff --git a/hasher/sha256/sha256.go b/hasher/sha256/sha256.go index 169c639..706acae 100644 --- a/hasher/sha256/sha256.go +++ b/hasher/sha256/sha256.go @@ -52,3 +52,4 @@ func (h *Default256Hasher) Calculate(hashes []string) string { return h.hashFn(strings.Join(orderedHashes, "")) } +- diff --git a/models/group.go b/models/group.go new file mode 100644 index 0000000..ac6a1b1 --- /dev/null +++ b/models/group.go @@ -0,0 +1,6 @@ +package models + +type Group struct { + Name string + Participants []*Participant +} diff --git a/models/participant.go b/models/participant.go index 0ca90b4..1ad1f3f 100644 --- a/models/participant.go +++ b/models/participant.go @@ -5,4 +5,8 @@ type Participant struct { Firstname string Lastname string + + Token uint + + Attributes map[string]string } diff --git a/store/file/collection.go b/store/file/collection.go new file mode 100644 index 0000000..b1cdb38 --- /dev/null +++ b/store/file/collection.go @@ -0,0 +1,160 @@ +package file + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/models" +) + +func (s *FileProboCollectorStore) GetCollectionsDir() string { + return s.collectionsDir +} + +func (s *FileProboCollectorStore) GetCollectionPath(collection *models.Collection) (string, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + path, ok := s.collectionsPaths[collection.ID] + if !ok { + return "", errors.New(fmt.Sprintf("Path not found for collection ID %v", collection.ID)) + } + return path, nil +} + +func (s *FileProboCollectorStore) SetCollectionPath(collection *models.Collection, path string) string { + s.lock.Lock() + defer s.lock.Unlock() + + s.collectionsPaths[collection.ID] = path + + return path +} + +func (s *FileProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) { + return s.memoryStore.ReadAllCollections() +} + +func (s *FileProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) { + collection, err := s.memoryStore.CreateCollection(r) + if err != nil { + return nil, err + } + + err = s.createOrUpdateCollectionFile(collection) + if err != nil { + return nil, err + } + + return s.memoryStore.ReadCollectionByID(collection.ID) +} + +func (s *FileProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, error) { + collection, _, err := s.memoryStore.UpdateCollection(r, id) + if err != nil { + return nil, err + } + + err = s.createOrUpdateCollectionFile(collection) + if err != nil { + return nil, err + } + + return s.memoryStore.ReadCollectionByID(collection.ID) +} + +func (s *FileProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) { + collection, err := s.memoryStore.DeleteCollection(&client.DeleteCollectionRequest{ID: r.ID}) + if err != nil { + return nil, err + } + + path, err := s.GetCollectionPath(collection) + if err != nil { + return nil, err + } + + err = os.Remove(path) + if err != nil { + return nil, err + } + + return collection, nil +} + +func (s *FileProboCollectorStore) reindexCollections() error { + files, err := os.ReadDir(s.collectionsDir) + if err != nil { + return err + } + + jsonFiles := make([]fs.DirEntry, 0) + + for _, file := range files { + filename := file.Name() + if !file.IsDir() && strings.HasSuffix(filename, ".json") { + jsonFiles = append(jsonFiles, file) + } + } + + for _, file := range jsonFiles { + filename := file.Name() + fullPath := filepath.Join(s.collectionsDir, filename) + + content, err := os.ReadFile(fullPath) + if err != nil { + return err + } + + var clientCollection *client.Collection + + err = json.Unmarshal(content, &clientCollection) + if err != nil { + return err + } + + collection, err := s.memoryStore.CreateCollection(&client.CreateUpdateCollectionRequest{ + Collection: clientCollection, + }) + if err != nil { + return err + } + + s.SetCollectionPath(collection, fullPath) + } + + return nil +} + +func (s *FileProboCollectorStore) createOrUpdateCollectionFile(collection *models.Collection) error { + json, err := json.Marshal(collection) + if err != nil { + return err + } + + fn, _ := s.GetCollectionPath(collection) + if fn == "" { + fn = filepath.Join(s.collectionsDir, fmt.Sprintf("collection_%v.%s", collection.ID, "json")) + } + + file, err := os.Create(fn) + if err != nil { + return err + } + defer file.Close() + + _, err = file.Write([]byte(json)) + if err != nil { + return err + } + + s.SetCollectionPath(collection, fn) + + return nil +} diff --git a/store/file/quiz.go b/store/file/quiz.go new file mode 100644 index 0000000..545770e --- /dev/null +++ b/store/file/quiz.go @@ -0,0 +1,494 @@ +package file + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "time" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/models" + "github.com/go-yaml/yaml" +) + +func (s *FileProboCollectorStore) GetQuizzesDir() string { + return s.quizzesDir +} + +func (s *FileProboCollectorStore) SetQuizPath(quiz *models.Quiz, path string) string { + s.lock.Lock() + defer s.lock.Unlock() + + s.quizzesPaths[quiz.ID] = path + + return path +} + +func (s *FileProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { + return s.memoryStore.ReadAllQuizzes() +} + +func (s *FileProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) { + quiz, err := s.memoryStore.CreateQuiz(r) + if err != nil { + return nil, err + } + + err = s.createOrUpdateMarkdownFile(quiz) + if err != nil { + return nil, err + } + + err = s.Reindex() + if err != nil { + return nil, err + } + + return s.memoryStore.ReadQuizByHash(quiz.Hash) +} + +func (s *FileProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, error) { + quiz, updated, err := s.memoryStore.UpdateQuiz(r, id) + if err != nil { + return nil, err + } + + if updated { // Update and re-index only if quiz hash is changed + err = s.createOrUpdateMarkdownFile(quiz) + if err != nil { + return nil, err + } + + err = s.Reindex() + if err != nil { + return nil, err + } + } + return s.memoryStore.ReadQuizByHash(quiz.Hash) +} + +func (s *FileProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) { + quiz, err := s.memoryStore.DeleteQuiz(&client.DeleteQuizRequest{ID: r.ID}) + if err != nil { + return nil, err + } + + path, err := s.GetQuizPath(quiz) + if err != nil { + return nil, err + } + + err = os.Remove(path) + if err != nil { + return nil, err + } + + err = s.Reindex() + if err != nil { + return nil, err + } + + return quiz, nil +} + +func (s *FileProboCollectorStore) GetQuizPath(quiz *models.Quiz) (string, error) { + if quiz == nil { + return "", errors.New("Quiz object passed as argument is nil!") + } + + s.lock.RLock() + defer s.lock.RUnlock() + + path, ok := s.quizzesPaths[quiz.ID] + if !ok { + return "", errors.New(fmt.Sprintf("Path not found for quiz ID %v", quiz.ID)) + } + return path, nil +} + +func MarkdownFromQuiz(quiz *models.Quiz) (string, error) { + if quiz.Question == nil { + return "", errors.New("Quiz should contain a question but it wasn't provided.") + } + + if len(quiz.Answers) < 2 { + return "", errors.New("Quiz should contain at least 2 answers but none was provided.") + } + + if quiz.Correct == nil { + return "", errors.New("Quiz should contain a correct answer but not was provided.") + } + + correctAnswer := "* " + quiz.Correct.Text + var otherAnswers string + + for _, answer := range quiz.Answers { + if quiz.Correct.ID != answer.ID { + otherAnswers += "* " + answer.Text + "\n" + } + } + + markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers + + return markdown, nil +} + +func QuizFromMarkdown(markdown string) (*client.Quiz, *models.Meta, error) { + meta, remainingMarkdown, err := parseMetaHeaderFromMarkdown(markdown) + if err != nil { + return nil, nil, err + } + + lines := strings.Split(remainingMarkdown, "\n") + + questionText := "" + answers := []*client.Answer{} + + for _, line := range lines { + if strings.HasPrefix(line, "*") { + answerText := strings.TrimPrefix(line, "* ") + correct := len(answers) == 0 + answer := &client.Answer{Text: answerText, Correct: correct} + answers = append(answers, answer) + } else { + if questionText != "" { + questionText += "\n" + } + questionText += line + } + } + + questionText = strings.TrimRight(questionText, "\n") + + if questionText == "" { + return nil, nil, fmt.Errorf("Question text should not be empty.") + } + + if len(answers) < 2 { + return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers)) + } + + question := &client.Question{Text: questionText} + quiz := &client.Quiz{Question: question, Answers: answers} + + return quiz, meta, nil +} + +func (s *FileProboCollectorStore) ReadMetaHeaderFromFile(filename string) (*models.Meta, error) { + data, err := os.ReadFile(path.Join(s.quizzesDir, filename)) + if err != nil { + return nil, err + } + meta, _, err := parseMetaHeaderFromMarkdown(string(data)) + if err != nil { + return nil, err + } + return meta, nil +} + +func (s *FileProboCollectorStore) WriteMetaHeaderToFile(filename string, meta *models.Meta) (*models.Meta, error) { + readMeta, err := s.ReadMetaHeaderFromFile(filename) + if err != nil { + return nil, err + } + if readMeta == nil { + _, err := writeMetaHeader(path.Join(s.quizzesDir, filename), meta) + if err != nil { + return nil, err + } + } + + return meta, nil +} + +func (s *FileProboCollectorStore) reindexQuizzes() error { + files, err := os.ReadDir(s.quizzesDir) + if err != nil { + return err + } + + markdownFiles := make([]fs.DirEntry, 0) + + for _, file := range files { + filename := file.Name() + if !file.IsDir() && strings.HasSuffix(filename, ".md") { + markdownFiles = append(markdownFiles, file) + } + } + + if len(markdownFiles) == 0 { + return fmt.Errorf("The directory is empty.") + } + + for _, file := range markdownFiles { + filename := file.Name() + fullPath := filepath.Join(s.quizzesDir, filename) + + content, err := os.ReadFile(fullPath) + if err != nil { + return err + } + quiz, meta, err := QuizFromMarkdown(string(content)) + if err != nil { + return err + } + q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{ + Quiz: quiz, + Meta: meta, + }) + if err != nil { + return err + } + + if meta == nil { + s.WriteMetaHeaderToFile(filename, &models.Meta{ + ID: q.ID, + CreatedAt: time.Now(), + }) + } + s.SetQuizPath(q, fullPath) + } + + return nil +} + +func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.Meta, error) { + file, err := os.Open(path.Join(s.quizzesDir, filename)) + if err != nil { + return nil, err + } + defer file.Close() + + var buffer bytes.Buffer + + reader := bufio.NewReader(file) + + var meta models.Meta + var line string + var sb strings.Builder + for { + line, err = reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + + if strings.TrimSpace(line) == "---" { + break + } + } + + for { + line, err = reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + + if strings.TrimSpace(line) == "---" { + break + } + + sb.WriteString(line) + } + + err = yaml.Unmarshal([]byte(sb.String()), &meta) + if err != nil { + return nil, err + } + + _, err = io.Copy(&buffer, reader) + if err != nil { + return nil, err + } + + file, err = os.Create(path.Join(s.quizzesDir, filename)) + if err != nil { + return nil, err + } + defer file.Close() + + _, err = io.Copy(file, &buffer) + if err != nil { + return nil, err + } + + return &meta, nil +} + +func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error { + markdown, err := MarkdownFromQuiz(quiz) + if err != nil { + return err + } + + fn, _ := s.GetQuizPath(quiz) + if fn == "" { + fn = filepath.Join(s.quizzesDir, fmt.Sprintf("quiz_%v.%s", quiz.ID, "md")) + } + + file, err := os.Create(fn) + if err != nil { + return err + } + + defer file.Close() + + markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta) + if err != nil { + return err + } + + _, err = file.Write([]byte(markdownWithMetaHeader)) + if err != nil { + return err + } + + s.SetQuizPath(quiz, fn) + + return nil +} + +func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) { + var buffer bytes.Buffer + + header, err := yaml.Marshal(meta) + if err != nil { + return "", err + } + _, err = buffer.WriteString("---\n" + string(header) + "---\n") + if err != nil { + return "", err + } + + _, err = buffer.WriteString(content) + if err != nil { + return "", err + } + + return buffer.String(), nil +} + +func parseMetaHeaderFromMarkdown(markdown string) (*models.Meta, string, error) { + reader := strings.NewReader(markdown) + var sb strings.Builder + var line string + var err error + for { + line, err = readLine(reader) + if err != nil { + if err == io.EOF { + break + } + return nil, "", err + } + + if strings.TrimSpace(line) == "---" { + break + } + } + + for { + line, err = readLine(reader) + if err != nil { + if err == io.EOF { + break + } + return nil, "", err + } + + if strings.TrimSpace(line) == "---" { + break + } + + sb.WriteString(line) + } + + if sb.String() == "" { + return nil, markdown, nil + } + + var meta models.Meta + err = yaml.Unmarshal([]byte(sb.String()), &meta) + if err != nil { + return nil, markdown, err + } + + remainingMarkdown := markdown[strings.Index(markdown, "---\n"+sb.String()+"---\n")+len("---\n"+sb.String()+"---\n"):] + + return &meta, remainingMarkdown, nil +} + +func readLine(reader *strings.Reader) (string, error) { + var sb strings.Builder + for { + r, _, err := reader.ReadRune() + if err != nil { + if err == io.EOF { + return sb.String(), io.EOF + } + return "", err + } + + sb.WriteRune(r) + if r == '\n' { + break + } + } + + return sb.String(), nil +} + +func writeMetaHeader(filename string, meta *models.Meta) (*models.Meta, error) { + // Apri il file in lettura + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + // Crea un buffer in memoria + var buffer bytes.Buffer + + // Scrivi l'intestazione YAML nel buffer + header, err := yaml.Marshal(meta) + if err != nil { + return nil, err + } + _, err = buffer.WriteString("---\n" + string(header) + "---\n") + if err != nil { + return nil, err + } + + // Copia il contenuto del file originale nel buffer + _, err = io.Copy(&buffer, file) + if err != nil { + return nil, err + } + + // Riapri il file in scrittura + file, err = os.Create(filename) + if err != nil { + return nil, err + } + defer file.Close() + + // Scrivi il contenuto del buffer nel file + _, err = io.Copy(file, &buffer) + if err != nil { + return nil, err + } + + return meta, nil +} diff --git a/store/file/testdata/quizzes/quiz_5.md b/store/file/testdata/quizzes/quiz_5.md index 5a30fad..f073325 100644 --- a/store/file/testdata/quizzes/quiz_5.md +++ b/store/file/testdata/quizzes/quiz_5.md @@ -1,6 +1,6 @@ --- -id: 9243c924-5a91-4fa1-9cf3-db9ce1e19ca4 -created_at: !!timestamp 2023-10-28T20:49:22.688075744+02:00 +id: b7ec3eb9-55e1-47c6-8652-3a27fe90bc0f +created_at: !!timestamp 2023-10-31T10:02:02.869395215+01:00 updated_at: !!timestamp 0001-01-01T00:00:00Z --- This quiz is initially without metadata. diff --git a/store/memory/collection.go b/store/memory/collection.go new file mode 100644 index 0000000..9bf2a29 --- /dev/null +++ b/store/memory/collection.go @@ -0,0 +1,124 @@ +package memory + +import ( + "errors" + "fmt" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/models" + "github.com/google/uuid" +) + +func (s *MemoryProboCollectorStore) getCollectionFromID(id string) *models.Collection { + s.lock.RLock() + defer s.lock.RUnlock() + + collection, ok := s.collections[id] + if ok { + return collection + } + + return nil +} + +func (s *MemoryProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) { + result := make([]*models.Collection, 0) + for id := range s.collections { + if collection := s.getCollectionFromID(id); collection != nil { + result = append(result, collection) + } + } + return result, nil +} + +func (s *MemoryProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) { + q, _, err := s.createOrUpdateCollection(r, "") + return q, err +} + +func (s *MemoryProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) { + return s.createOrUpdateCollection(r, id) +} + +func (s *MemoryProboCollectorStore) ReadCollectionByID(id string) (*models.Collection, error) { + if id == "" { + return nil, errors.New("ID should not be an empty string!") + } + collection := s.getCollectionFromID(id) + if collection == nil { + return nil, fmt.Errorf("Collection ID %v not found in the store", id) + } + return collection, nil +} + +func (s *MemoryProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) { + return s.deleteCollection(r.ID) +} + +func (s *MemoryProboCollectorStore) deleteCollection(id string) (*models.Collection, error) { + s.lock.Lock() + defer s.lock.Unlock() + + collection := s.collections[id] + if collection == nil { + return nil, fmt.Errorf("Trying to delete a collection that doesn't exist in memory (ID: %v)", id) + } + + delete(s.collections, id) + + return collection, nil +} + +func (s *MemoryProboCollectorStore) createOrUpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) { + var collection *models.Collection + + if r.Collection == nil { + return nil, false, errors.New("A request was made passing a nil collection object") + } + + if id != "" { // we're updating a collection + collection = s.getCollectionFromID(id) + if collection == nil { // Quiz is not present in the store + return nil, false, fmt.Errorf("Collection ID %v doesn't exist in the store!", id) + } + } else { + id = uuid.New().String() + collection = new(models.Collection) + } + + collection.Name = r.Collection.Name + collection.Query = r.Collection.Query + + collection.Quizzes = s.query(collection.Query) + + return s.createCollectionFromID(id, collection), true, nil +} + +func (s *MemoryProboCollectorStore) query(query string) []*models.Quiz { + s.lock.Lock() + defer s.lock.Unlock() + + result := make([]*models.Quiz, 0) + + for _, quiz := range s.quizzes { + for _, tag := range quiz.Tags { + if query == tag.Name { + result = append(result, quiz) + break + } + } + } + + return result +} + +func (s *MemoryProboCollectorStore) createCollectionFromID(id string, collection *models.Collection) *models.Collection { + s.lock.Lock() + defer s.lock.Unlock() + + collection.ID = id + + s.collections[id] = collection + + return collection +} diff --git a/store/memory/collection_test.go b/store/memory/collection_test.go new file mode 100644 index 0000000..dc2c7a5 --- /dev/null +++ b/store/memory/collection_test.go @@ -0,0 +1,98 @@ +package memory + +import ( + "fmt" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher/sha256" + "github.com/remogatto/prettytest" +) + +type collectionTestSuite struct { + prettytest.Suite +} + +func (t *collectionTestSuite) TestUpdateCollection() { + store := NewMemoryProboCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ) + + quiz_1, _ := store.CreateQuiz( + &client.CreateUpdateQuizRequest{ + Quiz: &client.Quiz{ + Question: &client.Question{Text: "Question text with #tag1."}, + Answers: []*client.Answer{ + {Text: "Answer 1", Correct: true}, + {Text: "Answer 2", Correct: false}, + {Text: "Answer 3", Correct: false}, + {Text: "Answer 4", Correct: false}, + }, + }, + }) + + quiz_2, _ := store.CreateQuiz( + &client.CreateUpdateQuizRequest{ + Quiz: &client.Quiz{ + Question: &client.Question{Text: "Another question text with #tag1."}, + Answers: []*client.Answer{ + {Text: "Answer 1", Correct: true}, + {Text: "Answer 2", Correct: false}, + {Text: "Answer 3", Correct: false}, + {Text: "Answer 4", Correct: false}, + }, + }, + }) + + collection, _ := store.CreateCollection( + &client.CreateUpdateCollectionRequest{ + Collection: &client.Collection{ + Name: "MyCollection", + }, + }) + + updatedCollection, updated, err := store.UpdateCollection( + &client.CreateUpdateCollectionRequest{ + Collection: &client.Collection{ + Name: "MyUpdatedCollection", + Query: "#tag1", + }, + }, collection.ID) + + t.Nil(err, fmt.Sprintf("The update returned an error: %v", err)) + + if !t.Failed() { + t.True(updated) + t.Equal("MyUpdatedCollection", updatedCollection.Name) + t.True(len(updatedCollection.Quizzes) == 2) + if !t.Failed() { + count := 0 + for _, q := range updatedCollection.Quizzes { + if quiz_1.ID == q.ID || quiz_2.ID == q.ID { + count++ + } + } + t.Equal(2, count) + } + } +} + +func (t *collectionTestSuite) TestDeleteCollection() { + store := NewMemoryProboCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ) + collection, _ := store.CreateCollection( + &client.CreateUpdateCollectionRequest{ + Collection: &client.Collection{ + Name: "Collection to be deleted", + Query: "#tag1", + }, + }) + + deletedCollection, err := store.DeleteCollection(&client.DeleteCollectionRequest{ID: collection.ID}) + + t.Equal(collection.ID, deletedCollection.ID, "Returned deleted collection ID should be equal to the request") + t.Nil(err, fmt.Sprintf("The update returned an error: %v", err)) + + _, err = store.ReadCollectionByID(deletedCollection.ID) + t.True(err != nil, "Reading a non existent quiz should return an error") +} diff --git a/store/memory/memory.go b/store/memory/memory.go index 5672f3f..866414c 100644 --- a/store/memory/memory.go +++ b/store/memory/memory.go @@ -2,19 +2,20 @@ package memory import ( "errors" - "fmt" - "strings" "sync" - "git.andreafazzi.eu/andrea/probo/client" "git.andreafazzi.eu/andrea/probo/hasher" "git.andreafazzi.eu/andrea/probo/models" - "github.com/google/uuid" ) type MemoryProboCollectorStore struct { - quizzes map[string]*models.Quiz - collections map[string]*models.Collection + + // IDs maps + quizzes map[string]*models.Quiz + collections map[string]*models.Collection + participants map[string]*models.Participant + + // Hashes maps questionsHashes map[string]*models.Question answersHashes map[string]*models.Answer quizzesHashes map[string]*models.Quiz @@ -36,181 +37,17 @@ func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorSto s.quizzes = make(map[string]*models.Quiz) s.collections = make(map[string]*models.Collection) + s.participants = make(map[string]*models.Participant) return s } -func (s *MemoryProboCollectorStore) getQuizFromHash(hash string) *models.Quiz { - s.lock.RLock() - defer s.lock.RUnlock() - - quiz, ok := s.quizzesHashes[hash] - if ok { - return quiz +func Create[T any](s *MemoryProboCollectorStore, elem *T) (*T, error) { + if elem == nil { + return nil, errors.New("A creation request was made passing a nil element") } - return nil -} - -func (s *MemoryProboCollectorStore) getQuizFromID(id string) *models.Quiz { - s.lock.RLock() - defer s.lock.RUnlock() - - quiz, ok := s.quizzes[id] - if ok { - return quiz - } - - return nil -} - -func (s *MemoryProboCollectorStore) getCollectionFromID(id string) *models.Collection { - s.lock.RLock() - defer s.lock.RUnlock() - - collection, ok := s.collections[id] - if ok { - return collection - } - - return nil -} - -func (s *MemoryProboCollectorStore) getQuestionFromHash(hash string) *models.Question { - s.lock.RLock() - defer s.lock.RUnlock() - - question, ok := s.questionsHashes[hash] - if ok { - return question - } - - return nil -} - -func (s *MemoryProboCollectorStore) getAnswerFromHash(hash string) *models.Answer { - s.lock.RLock() - defer s.lock.RUnlock() - - answer, ok := s.answersHashes[hash] - if ok { - return answer - } - - return nil -} - -func (s *MemoryProboCollectorStore) createQuizFromHash(id string, hash string, quiz *models.Quiz) *models.Quiz { - s.lock.Lock() - defer s.lock.Unlock() - - quiz.ID = id - quiz.Hash = hash - - s.quizzesHashes[hash] = quiz - s.quizzes[id] = quiz - - return quiz -} - -func (s *MemoryProboCollectorStore) createQuestionFromHash(hash string, question *models.Question) *models.Question { - s.lock.Lock() - defer s.lock.Unlock() - - s.questionsHashes[hash] = question - - return question -} - -func (s *MemoryProboCollectorStore) createAnswerFromHash(hash string, answer *models.Answer) *models.Answer { - s.lock.Lock() - defer s.lock.Unlock() - - s.answersHashes[hash] = answer - - return answer -} - -func (s *MemoryProboCollectorStore) deleteQuiz(id string) (*models.Quiz, error) { - s.lock.Lock() - defer s.lock.Unlock() - - quiz := s.quizzes[id] - if quiz == nil { - return nil, fmt.Errorf("Trying to delete a quiz that doesn't exist in memory (ID: %v)", id) - } - - delete(s.quizzes, id) - delete(s.quizzesHashes, quiz.Hash) - - return quiz, nil -} - -func (s *MemoryProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { - result := make([]*models.Quiz, 0) - for id := range s.quizzes { - if quiz := s.getQuizFromID(id); quiz != nil { - result = append(result, quiz) - } - } - return result, nil -} - -func (s *MemoryProboCollectorStore) ReadQuizByID(id string) (*models.Quiz, error) { - quiz := s.getQuizFromID(id) - if quiz == nil { - return nil, fmt.Errorf("Quiz with ID %s was not found in the store.", id) - } - return quiz, nil -} - -func (s *MemoryProboCollectorStore) ReadQuizByHash(hash string) (*models.Quiz, error) { - quiz := s.getQuizFromHash(hash) - if quiz == nil { - return nil, fmt.Errorf("Quiz with hash %s was not found in the store.", hash) - } - return quiz, nil -} - -func (s *MemoryProboCollectorStore) CalculateQuizHash(quiz *client.Quiz) string { - hashes := s.hasher.QuizHashes(quiz) - return hashes[len(hashes)-1] -} - -func (s *MemoryProboCollectorStore) parseTextForTags(text string, tags *[]*models.Tag) string { - - // Trim the following chars - trimChars := "*:.,/\\@()[]{}<>" - - // Split the text into words - words := strings.Fields(text) - - for _, word := range words { - // If the word starts with '#', it is considered as a tag - if strings.HasPrefix(word, "#") { - // Check if the tag already exists in the tags slice - exists := false - for _, tag := range *tags { - if tag.Name == word { - exists = true - break - } - } - - // If the tag does not exist in the tags slice, add it - if !exists { - *tags = append(*tags, &models.Tag{Name: strings.TrimRight(word, trimChars)}) - } - } - } - - return text -} - -func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) { - if r.Quiz == nil { - return nil, false, errors.New("A request was made passing a nil quiz object") - } + // Check for duplicates hashes := s.hasher.QuizHashes(r.Quiz) quizHash := hashes[len(hashes)-1] @@ -219,172 +56,4 @@ func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQui return quiz, false, nil } - if id != "" { // we're updating a quiz - quiz = s.getQuizFromID(id) - if quiz == nil { // Quiz is not present in the store - return nil, false, fmt.Errorf("Quiz ID %v doesn't exist in the store!", id) - } - } else { - if r.Meta != nil { - if r.Meta.ID != "" { - id = r.Meta.ID - } else { - id = uuid.New().String() - } - } else { - id = uuid.New().String() - } - quiz = new(models.Quiz) - } - - if quiz.Tags == nil { - quiz.Tags = make([]*models.Tag, 0) - } - - questionHash := hashes[0] - q := s.getQuestionFromHash(questionHash) - if q == nil { // if the question is not in the store then we should add it - q = s.createQuestionFromHash(questionHash, &models.Question{ - Meta: models.Meta{ID: uuid.New().String()}, - Text: s.parseTextForTags(r.Quiz.Question.Text, &quiz.Tags), - }) - } - - // Populate Question field - quiz.Question = q - - // Reset answer slice - quiz.Answers = make([]*models.Answer, 0) - - for i, answer := range r.Quiz.Answers { - answerHash := hashes[i+1] - a := s.getAnswerFromHash(answerHash) - if a == nil { // if the answer is not in the store add it - a = s.createAnswerFromHash(answerHash, &models.Answer{ - ID: uuid.New().String(), - Text: s.parseTextForTags(answer.Text, &quiz.Tags), - }) - } - if answer.Correct { - quiz.Correct = a - } - quiz.Answers = append(quiz.Answers, a) - } - - return s.createQuizFromHash(id, quizHash, quiz), true, nil -} - -func (s *MemoryProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) { - q, _, err := s.createOrUpdateQuiz(r, "") - return q, err -} - -func (s *MemoryProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) { - return s.createOrUpdateQuiz(r, id) -} - -func (s *MemoryProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) { - return s.deleteQuiz(r.ID) -} - -func (s *MemoryProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) { - result := make([]*models.Collection, 0) - for id := range s.collections { - if collection := s.getCollectionFromID(id); collection != nil { - result = append(result, collection) - } - } - return result, nil -} - -func (s *MemoryProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) { - q, _, err := s.createOrUpdateCollection(r, "") - return q, err -} - -func (s *MemoryProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) { - return s.createOrUpdateCollection(r, id) -} - -func (s *MemoryProboCollectorStore) ReadCollectionByID(id string) (*models.Collection, error) { - if id == "" { - return nil, errors.New("ID should not be an empty string!") - } - collection := s.getCollectionFromID(id) - if collection == nil { - return nil, fmt.Errorf("Collection ID %v not found in the store", id) - } - return collection, nil -} - -func (s *MemoryProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) { - return s.deleteCollection(r.ID) -} - -func (s *MemoryProboCollectorStore) deleteCollection(id string) (*models.Collection, error) { - s.lock.Lock() - defer s.lock.Unlock() - - collection := s.collections[id] - if collection == nil { - return nil, fmt.Errorf("Trying to delete a collection that doesn't exist in memory (ID: %v)", id) - } - - delete(s.collections, id) - - return collection, nil -} - -func (s *MemoryProboCollectorStore) createOrUpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) { - var collection *models.Collection - - if r.Collection == nil { - return nil, false, errors.New("A request was made passing a nil collection object") - } - - if id != "" { // we're updating a collection - collection = s.getCollectionFromID(id) - if collection == nil { // Quiz is not present in the store - return nil, false, fmt.Errorf("Collection ID %v doesn't exist in the store!", id) - } - } else { - id = uuid.New().String() - collection = new(models.Collection) - } - - collection.Name = r.Collection.Name - collection.Query = r.Collection.Query - - collection.Quizzes = s.query(collection.Query) - - return s.createCollectionFromID(id, collection), true, nil -} - -func (s *MemoryProboCollectorStore) query(query string) []*models.Quiz { - s.lock.Lock() - defer s.lock.Unlock() - - result := make([]*models.Quiz, 0) - - for _, quiz := range s.quizzes { - for _, tag := range quiz.Tags { - if query == tag.Name { - result = append(result, quiz) - break - } - } - } - - return result -} - -func (s *MemoryProboCollectorStore) createCollectionFromID(id string, collection *models.Collection) *models.Collection { - s.lock.Lock() - defer s.lock.Unlock() - - collection.ID = id - - s.collections[id] = collection - - return collection } diff --git a/store/memory/memory_test.go b/store/memory/memory_test.go index 201d8ad..5ec27ae 100644 --- a/store/memory/memory_test.go +++ b/store/memory/memory_test.go @@ -19,6 +19,7 @@ func TestRunner(t *testing.T) { t, new(testSuite), new(collectionTestSuite), + new(participantTestSuite), ) } diff --git a/store/memory/participant.go b/store/memory/participant.go new file mode 100644 index 0000000..12ec80b --- /dev/null +++ b/store/memory/participant.go @@ -0,0 +1,108 @@ +package memory + +import ( + "errors" + "fmt" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/models" + "github.com/google/uuid" +) + +func (s *MemoryProboCollectorStore) ReadAllParticipants() ([]*models.Participant, error) { + result := make([]*models.Participant, 0) + for id := range s.participants { + if participant := s.getParticipantFromID(id); participant != nil { + result = append(result, participant) + } + } + return result, nil +} + +func (s *MemoryProboCollectorStore) getParticipantFromID(id string) *models.Participant { + s.lock.RLock() + defer s.lock.RUnlock() + + participant, ok := s.participants[id] + if ok { + return participant + } + + return nil +} + +func (s *MemoryProboCollectorStore) CreateParticipant(r *client.CreateUpdateParticipantRequest) (*models.Participant, error) { + q, _, err := s.createOrUpdateParticipant(r, "") + return q, err +} + +func (s *MemoryProboCollectorStore) UpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error) { + return s.createOrUpdateParticipant(r, id) +} + +func (s *MemoryProboCollectorStore) ReadParticipantByID(id string) (*models.Participant, error) { + if id == "" { + return nil, errors.New("ID should not be an empty string!") + } + participant := s.getParticipantFromID(id) + if participant == nil { + return nil, fmt.Errorf("Participant ID %v not found in the store", id) + } + return participant, nil +} + +func (s *MemoryProboCollectorStore) DeleteParticipant(r *client.DeleteParticipantRequest) (*models.Participant, error) { + return s.deleteParticipant(r.ID) +} + +func (s *MemoryProboCollectorStore) deleteParticipant(id string) (*models.Participant, error) { + s.lock.Lock() + defer s.lock.Unlock() + + participant := s.participants[id] + if participant == nil { + return nil, fmt.Errorf("Trying to delete a participant that doesn't exist in memory (ID: %v)", id) + } + + delete(s.participants, id) + + return participant, nil +} + +func (s *MemoryProboCollectorStore) createOrUpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error) { + var participant *models.Participant + + if r.Participant == nil { + return nil, false, errors.New("A request was made passing a nil participant object") + } + + if id != "" { // we're updating a participant + participant = s.getParticipantFromID(id) + if participant == nil { // Participant is not present in the store + return nil, false, fmt.Errorf("Participant ID %v doesn't exist in the store!", id) + } + } else { + id = uuid.New().String() + participant = new(models.Participant) + } + + participant.Attributes = make(map[string]string) + + participant.Firstname = r.Participant.Firstname + participant.Lastname = r.Participant.Lastname + participant.Token = r.Participant.Token + participant.Attributes = r.Participant.Attributes + + return s.createParticipantFromID(id, participant), true, nil +} + +func (s *MemoryProboCollectorStore) createParticipantFromID(id string, participant *models.Participant) *models.Participant { + s.lock.Lock() + defer s.lock.Unlock() + + participant.ID = id + + s.participants[id] = participant + + return participant +} diff --git a/store/memory/participant_test.go b/store/memory/participant_test.go new file mode 100644 index 0000000..2b460d8 --- /dev/null +++ b/store/memory/participant_test.go @@ -0,0 +1,62 @@ +package memory + +import ( + "fmt" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher/sha256" + "github.com/remogatto/prettytest" +) + +type participantTestSuite struct { + prettytest.Suite +} + +func (t *participantTestSuite) TestUpdateParticipant() { + store := NewMemoryProboCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ) + + participant, _ := store.CreateParticipant(&client.CreateUpdateParticipantRequest{ + Participant: &client.Participant{ + Firstname: "John", + Lastname: "Doe", + Token: 1234, + }, + }) + + updatedParticipant, updated, err := store.UpdateParticipant(&client.CreateUpdateParticipantRequest{ + Participant: &client.Participant{ + Firstname: "Jack", + Lastname: "Smith", + }, + }, participant.ID) + + t.Nil(err, fmt.Sprintf("The update returned an error: %v", err)) + + if !t.Failed() { + t.True(updated) + t.Equal("Jack", updatedParticipant.Firstname) + } +} + +func (t *participantTestSuite) TestDeleteParticipant() { + store := NewMemoryProboCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ) + participant, _ := store.CreateParticipant( + &client.CreateUpdateParticipantRequest{ + Participant: &client.Participant{ + Firstname: "Jack", + Lastname: "Smith", + }, + }) + + deletedParticipant, err := store.DeleteParticipant(&client.DeleteParticipantRequest{ID: participant.ID}) + + t.Equal(participant.ID, deletedParticipant.ID, "Returned deleted participant ID should be equal to the request") + t.Nil(err, fmt.Sprintf("The update returned an error: %v", err)) + + _, err = store.ReadParticipantByID(deletedParticipant.ID) + t.True(err != nil, "Reading a non existent participant should return an error") +} diff --git a/store/memory/quiz.go b/store/memory/quiz.go new file mode 100644 index 0000000..36e9635 --- /dev/null +++ b/store/memory/quiz.go @@ -0,0 +1,246 @@ +package memory + +import ( + "errors" + "fmt" + "strings" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/models" + "github.com/google/uuid" +) + +func (s *MemoryProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { + result := make([]*models.Quiz, 0) + for id := range s.quizzes { + if quiz := s.getQuizFromID(id); quiz != nil { + result = append(result, quiz) + } + } + return result, nil +} + +func (s *MemoryProboCollectorStore) ReadQuizByID(id string) (*models.Quiz, error) { + quiz := s.getQuizFromID(id) + if quiz == nil { + return nil, fmt.Errorf("Quiz with ID %s was not found in the store.", id) + } + return quiz, nil +} + +func (s *MemoryProboCollectorStore) ReadQuizByHash(hash string) (*models.Quiz, error) { + quiz := s.getQuizFromHash(hash) + if quiz == nil { + return nil, fmt.Errorf("Quiz with hash %s was not found in the store.", hash) + } + return quiz, nil +} + +func (s *MemoryProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) { + q, _, err := s.createOrUpdateQuiz(r, "") + return q, err +} + +func (s *MemoryProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) { + return s.createOrUpdateQuiz(r, id) +} + +func (s *MemoryProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) { + return s.deleteQuiz(r.ID) +} + +func (s *MemoryProboCollectorStore) CalculateQuizHash(quiz *client.Quiz) string { + hashes := s.hasher.QuizHashes(quiz) + return hashes[len(hashes)-1] +} + +func (s *MemoryProboCollectorStore) parseTextForTags(text string, tags *[]*models.Tag) string { + + // Trim the following chars + trimChars := "*:.,/\\@()[]{}<>" + + // Split the text into words + words := strings.Fields(text) + + for _, word := range words { + // If the word starts with '#', it is considered as a tag + if strings.HasPrefix(word, "#") { + // Check if the tag already exists in the tags slice + exists := false + for _, tag := range *tags { + if tag.Name == word { + exists = true + break + } + } + + // If the tag does not exist in the tags slice, add it + if !exists { + *tags = append(*tags, &models.Tag{Name: strings.TrimRight(word, trimChars)}) + } + } + } + + return text +} + +func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) { + if r.Quiz == nil { + return nil, false, errors.New("A request was made passing a nil quiz object") + } + hashes := s.hasher.QuizHashes(r.Quiz) + quizHash := hashes[len(hashes)-1] + + quiz := s.getQuizFromHash(quizHash) + if quiz != nil { // Quiz is already present in the store + return quiz, false, nil + } + + if id != "" { // we're updating a quiz + quiz = s.getQuizFromID(id) + if quiz == nil { // Quiz is not present in the store + return nil, false, fmt.Errorf("Quiz ID %v doesn't exist in the store!", id) + } + } else { + if r.Meta != nil { + if r.Meta.ID != "" { + id = r.Meta.ID + } else { + id = uuid.New().String() + } + } else { + id = uuid.New().String() + } + quiz = new(models.Quiz) + } + + if quiz.Tags == nil { + quiz.Tags = make([]*models.Tag, 0) + } + + questionHash := hashes[0] + q := s.getQuestionFromHash(questionHash) + if q == nil { // if the question is not in the store then we should add it + q = s.createQuestionFromHash(questionHash, &models.Question{ + Meta: models.Meta{ID: uuid.New().String()}, + Text: s.parseTextForTags(r.Quiz.Question.Text, &quiz.Tags), + }) + } + + // Populate Question field + quiz.Question = q + + // Reset answer slice + quiz.Answers = make([]*models.Answer, 0) + + for i, answer := range r.Quiz.Answers { + answerHash := hashes[i+1] + a := s.getAnswerFromHash(answerHash) + if a == nil { // if the answer is not in the store add it + a = s.createAnswerFromHash(answerHash, &models.Answer{ + ID: uuid.New().String(), + Text: s.parseTextForTags(answer.Text, &quiz.Tags), + }) + } + if answer.Correct { + quiz.Correct = a + } + quiz.Answers = append(quiz.Answers, a) + } + + return s.createQuizFromHash(id, quizHash, quiz), true, nil +} + +func (s *MemoryProboCollectorStore) getQuizFromHash(hash string) *models.Quiz { + s.lock.RLock() + defer s.lock.RUnlock() + + quiz, ok := s.quizzesHashes[hash] + if ok { + return quiz + } + + return nil +} + +func (s *MemoryProboCollectorStore) getQuizFromID(id string) *models.Quiz { + s.lock.RLock() + defer s.lock.RUnlock() + + quiz, ok := s.quizzes[id] + if ok { + return quiz + } + + return nil +} + +func (s *MemoryProboCollectorStore) getQuestionFromHash(hash string) *models.Question { + s.lock.RLock() + defer s.lock.RUnlock() + + question, ok := s.questionsHashes[hash] + if ok { + return question + } + + return nil +} + +func (s *MemoryProboCollectorStore) getAnswerFromHash(hash string) *models.Answer { + s.lock.RLock() + defer s.lock.RUnlock() + + answer, ok := s.answersHashes[hash] + if ok { + return answer + } + + return nil +} + +func (s *MemoryProboCollectorStore) createQuizFromHash(id string, hash string, quiz *models.Quiz) *models.Quiz { + s.lock.Lock() + defer s.lock.Unlock() + + quiz.ID = id + quiz.Hash = hash + + s.quizzesHashes[hash] = quiz + s.quizzes[id] = quiz + + return quiz +} + +func (s *MemoryProboCollectorStore) createQuestionFromHash(hash string, question *models.Question) *models.Question { + s.lock.Lock() + defer s.lock.Unlock() + + s.questionsHashes[hash] = question + + return question +} + +func (s *MemoryProboCollectorStore) createAnswerFromHash(hash string, answer *models.Answer) *models.Answer { + s.lock.Lock() + defer s.lock.Unlock() + + s.answersHashes[hash] = answer + + return answer +} + +func (s *MemoryProboCollectorStore) deleteQuiz(id string) (*models.Quiz, error) { + s.lock.Lock() + defer s.lock.Unlock() + + quiz := s.quizzes[id] + if quiz == nil { + return nil, fmt.Errorf("Trying to delete a quiz that doesn't exist in memory (ID: %v)", id) + } + + delete(s.quizzes, id) + delete(s.quizzesHashes, quiz.Hash) + + return quiz, nil +}