Implement creating new games
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"vkane.cz/tinyquiz/pkg/gameCreator"
|
||||||
"vkane.cz/tinyquiz/pkg/model"
|
"vkane.cz/tinyquiz/pkg/model"
|
||||||
"vkane.cz/tinyquiz/pkg/model/ent"
|
"vkane.cz/tinyquiz/pkg/model/ent"
|
||||||
"vkane.cz/tinyquiz/pkg/rtcomm"
|
"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)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ func main() {
|
|||||||
mux.POST("/game/:playerUid/rpc/next", app.nextQuestion)
|
mux.POST("/game/:playerUid/rpc/next", app.nextQuestion)
|
||||||
mux.POST("/game/:playerUid/answers/:choiceUid", app.answer)
|
mux.POST("/game/:playerUid/answers/:choiceUid", app.answer)
|
||||||
mux.GET("/results/:playerUid", app.resultsGeneral)
|
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)
|
mux.GET("/ws/:playerUid", app.processWebSocket)
|
||||||
|
|
||||||
|
|||||||
120
pkg/gameCreator/gameCreator.go
Normal file
120
pkg/gameCreator/gameCreator.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
66
pkg/gameCreator/gameCreator_test.go
Normal file
66
pkg/gameCreator/gameCreator_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
"vkane.cz/tinyquiz/pkg/codeGenerator"
|
"vkane.cz/tinyquiz/pkg/codeGenerator"
|
||||||
|
"vkane.cz/tinyquiz/pkg/gameCreator"
|
||||||
"vkane.cz/tinyquiz/pkg/model/ent"
|
"vkane.cz/tinyquiz/pkg/model/ent"
|
||||||
"vkane.cz/tinyquiz/pkg/model/ent/answer"
|
"vkane.cz/tinyquiz/pkg/model/ent/answer"
|
||||||
"vkane.cz/tinyquiz/pkg/model/ent/askedquestion"
|
"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
|
const codeRandomPartLength uint8 = 3
|
||||||
|
|
||||||
func (m *Model) getCodeIncremental(c context.Context) (uint64, error) {
|
func (m *Model) getCodeIncremental(c context.Context) (uint64, error) {
|
||||||
|
|||||||
31
ui/html/game-overview.page.tmpl.html
Normal file
31
ui/html/game-overview.page.tmpl.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{{- template "base" . -}}
|
||||||
|
|
||||||
|
{{- define "additional-css" -}}
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{- define "additional-js" -}}
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{- define "header" }}
|
||||||
|
<h1>{{ .Game.Name }} ({{ .Game.Code }})</h1>
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{- define "main" }}
|
||||||
|
<dl>
|
||||||
|
<dt>Vytvořena</dt>
|
||||||
|
<dd>{{ .Game.Created.Format "2006.01.02 15:04:05" }}</dd>
|
||||||
|
<dt>Autor</dt>
|
||||||
|
<dd>{{ .Game.Author }}</dd>
|
||||||
|
</dl>
|
||||||
|
<ol>
|
||||||
|
{{ range .Game.Edges.Questions -}}
|
||||||
|
<li>{{ .Title }} ({{ .DefaultLength }}ms)
|
||||||
|
<ul>
|
||||||
|
{{ range .Edges.Choices -}}
|
||||||
|
<li>{{ .Title }}{{ if .Correct }} (správně){{ end }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ol>
|
||||||
|
{{ end -}}
|
||||||
@@ -38,9 +38,11 @@
|
|||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h1>Vytvořit nový kvíz</h1>
|
<h1>Vytvořit nový kvíz</h1>
|
||||||
<form id="new" method="get" action="/new">
|
<p>Stáhnout <a href="/template" download>šablonu nového kvízu</a></p>
|
||||||
<label>Jméno kvízu: <input type="text" name="quiz" placeholder="Jméno kvízu"></label>
|
<form id="new" enctype="multipart/form-data" method="post" action="/game">
|
||||||
|
<label>Jméno kvízu: <input type="text" name="name" placeholder="Jméno kvízu"></label>
|
||||||
<label>Jméno autora: <input type="text" name="author" placeholder="Jméno"></label>
|
<label>Jméno autora: <input type="text" name="author" placeholder="Jméno"></label>
|
||||||
|
<label>Kvíz: <input type="file" name="game" accept="text/csv"></label>
|
||||||
<input type="submit" value="Vytvořit">
|
<input type="submit" value="Vytvořit">
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user