diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index f0fb5b5..d41d547 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" "time" + "vkane.cz/tinyquiz/pkg/gameCreator" "vkane.cz/tinyquiz/pkg/model" "vkane.cz/tinyquiz/pkg/model/ent" "vkane.cz/tinyquiz/pkg/rtcomm" @@ -232,3 +233,75 @@ func (app *application) resultsGeneral(w http.ResponseWriter, r *http.Request, p app.render(w, r, "results.page.tmpl.html", td) } + +func (app *application) downloadTemplate(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + w.Header().Set("Content-Type", "text/csv; charset=utf-8; header=absent") + w.Header().Set("Content-Disposition", "attachment; filename=tinyquiz_template.csv") + w.Header().Set("Cache-Control", "max-age=21600" /* 6 hours */) + if err := gameCreator.CreateTemplate(w, 10, 4); err != nil { + app.errorLog.Printf("creating template: %v", err) + return + } +} + +func (app *application) createGame(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + r.Body = http.MaxBytesReader(w, r.Body, 1000000) + // shall never write to temp files thanks to MaxBytesReader + if err := r.ParseMultipartForm(2000000); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + var name = r.PostFormValue("name") + var author = r.PostFormValue("author") + file, _, err := r.FormFile("game") + if err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + if parsedGame, err := gameCreator.Parse(file, 500, 100); err == nil { + if game, err := app.model.CreateGame(parsedGame, name, author, r.Context()); err == nil { + http.Redirect(w, r, "/quiz/"+url.PathEscape(game.ID.String()), http.StatusSeeOther) + return + } else { + app.serverError(w, err) + return + } + } else if errors.Is(err, gameCreator.ErrInvalidSyntax) || errors.Is(err, gameCreator.ErrTooManyQuestions) || errors.Is(err, gameCreator.ErrTooManyChoices) { + app.clientError(w, http.StatusBadRequest) + return + } else { + app.clientError(w, http.StatusBadRequest) + return + } +} + +func (app *application) showGame(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + type gameData struct { + Game *ent.Game + templateData + } + td := &gameData{} + setDefaultTemplateData(&td.templateData) + + var gameUid uuid.UUID + if uid, err := uuid.Parse(params.ByName("gameUid")); err == nil { + gameUid = uid + } else { + app.clientError(w, http.StatusBadRequest) + return + } + + if game, err := app.model.GetGameWithQuestionsAndChoices(gameUid, r.Context()); err == nil { + td.Game = game + w.Header().Set("Cache-Control", "max-age=3600" /* 1 hour */) + app.render(w, r, "game-overview.page.tmpl.html", td) + return + } else if errors.Is(err, model.NoSuchEntity) { + app.clientError(w, http.StatusNotFound) + return + } else { + app.serverError(w, err) + return + } +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 119ea6d..06789d6 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -108,6 +108,9 @@ func main() { mux.POST("/game/:playerUid/rpc/next", app.nextQuestion) mux.POST("/game/:playerUid/answers/:choiceUid", app.answer) mux.GET("/results/:playerUid", app.resultsGeneral) + mux.GET("/template", app.downloadTemplate) + mux.POST("/game", app.createGame) + mux.GET("/quiz/:gameUid", app.showGame) mux.GET("/ws/:playerUid", app.processWebSocket) diff --git a/pkg/gameCreator/gameCreator.go b/pkg/gameCreator/gameCreator.go new file mode 100644 index 0000000..206d165 --- /dev/null +++ b/pkg/gameCreator/gameCreator.go @@ -0,0 +1,120 @@ +package gameCreator + +import ( + "encoding/csv" + "errors" + "io" + "strconv" + "time" +) + +func CreateTemplate(w io.Writer, questions uint64, choicesPerQuestion uint64) (retE error) { + csvW := csv.NewWriter(w) + defer func() { + csvW.Flush() + if err := csvW.Error(); err != nil && retE != nil { + retE = err + } + }() + + for i := uint64(0); i < questions; i++ { + var length string + if i == 0 { + length = "10000" + } + if err := csvW.Write([]string{"Nadpis otázky", length, ""}); err != nil { + return err + } + for j := uint64(0); j < choicesPerQuestion; j++ { + var correct string + if j == 0 { + correct = "1" + } + if err := csvW.Write([]string{"", "Nadpis možnosti", correct}); err != nil { + return err + } + } + } + return nil +} + +type Game struct { + Questions []Question +} + +type Question struct { + Title string + Choices []Choice + Length uint64 +} + +type Choice struct { + Title string + Correct bool +} + +var ErrTooManyQuestions = errors.New("there were questions above the limit") +var ErrTooManyChoices = errors.New("there were choices above the limit") +var ErrInvalidSyntax = errors.New("") + +func Parse(r io.Reader, maxQuestions uint64, maxChoicesPerQuestion uint64) (Game, error) { + var g Game + var csvR = csv.NewReader(r) + csvR.FieldsPerRecord = 3 + csvR.TrimLeadingSpace = true + var questions, choices uint64 + for { + if row, err := csvR.Read(); err == nil { + if row[0] == "" && row[1] == "" && row[2] == "" { + continue + } else if row[0] == "" { + choices++ + if questions == 0 { + return g, ErrInvalidSyntax + } + if choices > maxChoicesPerQuestion { + return g, ErrTooManyChoices + } + var correct bool + if row[2] == "1" { + correct = true + } + g.Questions[len(g.Questions)-1].Choices = append(g.Questions[len(g.Questions)-1].Choices, Choice{ + Title: row[1], + Correct: correct, + }) + } else { + questions++ + choices = 0 + if questions > maxQuestions { + return g, ErrTooManyQuestions + } + var length uint64 + if row[1] != "" { + if l, err := strconv.ParseUint(row[1], 10, 64); err == nil { + length = l + } else { + return g, ErrInvalidSyntax + } + } else { + if questions > 1 { + length = g.Questions[len(g.Questions)-1].Length + } else { + length = uint64((10 * time.Second).Milliseconds()) + } + } + g.Questions = append(g.Questions, Question{ + Title: row[0], + Length: length, + }) + } + } else if err == io.EOF { + break + } else if err == csv.ErrFieldCount { + return g, ErrInvalidSyntax + } else { + return g, err + } + } + return g, nil +} diff --git a/pkg/gameCreator/gameCreator_test.go b/pkg/gameCreator/gameCreator_test.go new file mode 100644 index 0000000..a0badb5 --- /dev/null +++ b/pkg/gameCreator/gameCreator_test.go @@ -0,0 +1,66 @@ +package gameCreator + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +func TestCreateTemplate(t *testing.T) { + const expected = "Nadpis otázky,10000,\n,Nadpis možnosti,1\n,Nadpis možnosti,\n,Nadpis možnosti,\n,Nadpis možnosti,\n" + + "Nadpis otázky,,\n,Nadpis možnosti,1\n,Nadpis možnosti,\n,Nadpis možnosti,\n,Nadpis možnosti,\n" + + "Nadpis otázky,,\n,Nadpis možnosti,1\n,Nadpis možnosti,\n,Nadpis možnosti,\n,Nadpis možnosti,\n" + + "Nadpis otázky,,\n,Nadpis možnosti,1\n,Nadpis možnosti,\n,Nadpis možnosti,\n,Nadpis možnosti,\n" + + "Nadpis otázky,,\n,Nadpis možnosti,1\n,Nadpis možnosti,\n,Nadpis možnosti,\n,Nadpis možnosti,\n" + var actual bytes.Buffer + actual.Grow(len(expected)) + if err := CreateTemplate(&actual, 5, 4); err != nil { + t.Fatalf("Unexpected error returned from CreateTemplate: %v", err) + } + if act := actual.String(); act != expected { + t.Fatalf("Wrong template generated. Expected:\n%s\n\nGot:\n%s\n", expected, act) + } +} +func TestParse(t *testing.T) { + const input = "H2O is,3000,\n,Gasoline,\n,Salt,\n,Water,1\n" + + "π is rational,,\n,Yes,\n,No,1\n" + + "IPv4 address length is,5000,\n,8b,\n,16b,\n,32b,1\n,64b,\n,128b,\n" + var expected = Game{ + Questions: []Question{ + { + Title: "H2O is", + Length: 3000, + Choices: []Choice{ + {Title: "Gasoline"}, + {Title: "Salt"}, + {Title: "Water", Correct: true}, + }, + }, + { + Title: "π is rational", + Length: 3000, + Choices: []Choice{ + {Title: "Yes"}, + {Title: "No", Correct: true}, + }, + }, + { + Title: "IPv4 address length is", + Length: 5000, + Choices: []Choice{ + {Title: "8b"}, + {Title: "16b"}, + {Title: "32b", Correct: true}, + {Title: "64b"}, + {Title: "128b"}, + }, + }, + }, + } + if g, err := Parse(strings.NewReader(input), 10, 10); err == nil && !reflect.DeepEqual(g, expected) { + t.Fatalf("Parse:\n\tActual: %#v\n\tExpected: %#v", g, expected) + } else if err != nil { + t.Fatalf("Unexpected error from Parse: %v", err) + } +} diff --git a/pkg/model/model.go b/pkg/model/model.go index e210d95..60e50a3 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -10,6 +10,7 @@ import ( "sort" "time" "vkane.cz/tinyquiz/pkg/codeGenerator" + "vkane.cz/tinyquiz/pkg/gameCreator" "vkane.cz/tinyquiz/pkg/model/ent" "vkane.cz/tinyquiz/pkg/model/ent/answer" "vkane.cz/tinyquiz/pkg/model/ent/askedquestion" @@ -387,6 +388,68 @@ func (m *Model) GetResults(playerId uuid.UUID, c context.Context) ([]PlayerResul } } +func (m *Model) CreateGame(game gameCreator.Game, name string, author string, c context.Context) (*ent.Game, error) { + tx, err := m.c.BeginTx(c, &sql.TxOptions{ + Isolation: sql.LevelReadUncommitted, + }) + if err != nil { + return nil, err + } + defer tx.Rollback() + + var code []byte + if incremental, err := m.getCodeIncremental(c); err == nil { + if c, err := codeGenerator.GenerateRandomCode(incremental, codeRandomPartLength); err == nil { + code = c + } else { + return nil, err + } + } else { + return nil, err + } + + g, err := tx.Game.Create().SetID(uuid.New()).SetCreated(time.Now()).SetName(name).SetAuthor(author).SetCode(string(code)).Save(c) + if err != nil { + return nil, err + } + + var questions = make([]*ent.QuestionCreate, 0, len(game.Questions)) + var questionIds = make([]uuid.UUID, 0, len(game.Questions)) + var choicesCount uint + for i, q := range game.Questions { + var id = uuid.New() + var questionCreate = tx.Question.Create().SetID(id).SetGame(g).SetDefaultLength(q.Length).SetOrder(i + 1).SetTitle(q.Title) + questions = append(questions, questionCreate) + questionIds = append(questionIds, id) + choicesCount += uint(len(q.Choices)) + } + if _, err := tx.Question.CreateBulk(questions...).Save(c); err != nil { + return nil, err + } + + var choices = make([]*ent.ChoiceCreate, 0, choicesCount) + for i, q := range game.Questions { + for _, c := range q.Choices { + var choiceCreate = tx.Choice.Create().SetID(uuid.New()).SetTitle(c.Title).SetCorrect(c.Correct).SetQuestionID(questionIds[i]) + choices = append(choices, choiceCreate) + } + } + if _, err := tx.Choice.CreateBulk(choices...).Save(c); err != nil { + return nil, err + } + return g, tx.Commit() +} + +func (m *Model) GetGameWithQuestionsAndChoices(gameId uuid.UUID, c context.Context) (*ent.Game, error) { + if game, err := m.c.Game.Query().Where(game.ID(gameId)).WithQuestions(func(q *ent.QuestionQuery) { q.WithChoices() }).Only(c); err == nil { + return game, err + } else if ent.IsNotFound(err) { + return nil, NoSuchEntity + } else { + return nil, err + } +} + const codeRandomPartLength uint8 = 3 func (m *Model) getCodeIncremental(c context.Context) (uint64, error) { diff --git a/ui/html/game-overview.page.tmpl.html b/ui/html/game-overview.page.tmpl.html new file mode 100644 index 0000000..52ea5ef --- /dev/null +++ b/ui/html/game-overview.page.tmpl.html @@ -0,0 +1,31 @@ +{{- template "base" . -}} + +{{- define "additional-css" -}} +{{ end -}} + +{{- define "additional-js" -}} +{{ end -}} + +{{- define "header" }} +