[WIP]: initial version of question repository

This commit is contained in:
Dawid Wysokiński 2021-02-28 17:54:40 +01:00
parent 17c0d6c4e5
commit ff9e6289ee
16 changed files with 547 additions and 37 deletions

1
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/gin-contrib/cors v1.3.1
github.com/gin-gonic/gin v1.6.3
github.com/go-pg/pg/v10 v10.7.7
github.com/google/uuid v1.2.0
github.com/gosimple/slug v1.9.0
github.com/joho/godotenv v1.3.0
github.com/pkg/errors v0.9.1

2
go.sum
View File

@ -64,6 +64,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

View File

@ -30,33 +30,54 @@ type Question struct {
QualificationID int `pg:",unique:group_1,on_delete:CASCADE" json:"qualificationID" xml:"qualificationID" gqlgen:"qualificationID"`
Qualification *Qualification `pg:"rel:has-one" json:"qualification" xml:"qualification" gqlgen:"qualification"`
CreatedAt time.Time `json:"createdAt,omitempty" pg:"default:now()" xml:"createdAt" gqlgen:"createdAt"`
UpdatedAt time.Time `pg:"default:now()" json:"updatedAt" xml:"updatedAt" gqlgen:"updatedAt"`
}
func (q *Question) BeforeInsert(ctx context.Context) (context.Context, error) {
q.CreatedAt = time.Now()
q.UpdatedAt = time.Now()
return ctx, nil
}
type QuestionInput struct {
Content *string `json:"content" xml:"content" gqlgen:"content"`
From *string `json:"from" xml:"from" gqlgen:"from"`
Explanation *string `json:"explanation" xml:"explanation" gqlgen:"explanation"`
CorrectAnswer *Answer `json:"correctAnswer" xml:"correctAnswer" gqlgen:"correctAnswer"`
AnswerA *Answer `gqlgen:"answerA" json:"answerA" xml:"answerA"`
AnswerB *Answer `gqlgen:"answerB" json:"answerB" xml:"answerB"`
AnswerC *Answer `gqlgen:"answerC" json:"answerC" xml:"answerC"`
AnswerD *Answer `gqlgen:"answerD" json:"answerD" xml:"answerD"`
QualificationID *int `gqlgen:"qualificationID" json:"qualificationID" xml:"qualificationID"`
Image *graphql.Upload `json:"image" xml:"image" gqlgen:"image"`
AnswerAImage *graphql.Upload `json:"answerAImage" gqlgen:"answerAImage" xml:"answerAImage"`
AnswerAImagePath *string `json:"answerAImagePath" gqlgen:"answerAImagePath" xml:"answerAImagePath"`
AnswerBImage *graphql.Upload `json:"answerBImage" gqlgen:"answerBImage" xml:"answerBImage"`
AnswerBImagePath *string `json:"answerBImagePath" gqlgen:"answerBImagePath" xml:"answerBImagePath"`
AnswerCImage *graphql.Upload `json:"answerCImage" gqlgen:"answerCImage" xml:"answerCImage"`
AnswerCImagePath *string `json:"answerCImagePath" gqlgen:"answerCImagePath" xml:"answerCImagePath"`
AnswerDImage *graphql.Upload `json:"answerDImage" gqlgen:"answerDImage" xml:"answerDImage"`
AnswerDImagePath *string `json:"answerDImagePath" gqlgen:"answerDImagePath" xml:"answerDImagePath"`
Content *string `json:"content" xml:"content" gqlgen:"content"`
From *string `json:"from" xml:"from" gqlgen:"from"`
Explanation *string `json:"explanation" xml:"explanation" gqlgen:"explanation"`
CorrectAnswer *Answer `json:"correctAnswer" xml:"correctAnswer" gqlgen:"correctAnswer"`
AnswerA *Answer `gqlgen:"answerA" json:"answerA" xml:"answerA"`
AnswerB *Answer `gqlgen:"answerB" json:"answerB" xml:"answerB"`
AnswerC *Answer `gqlgen:"answerC" json:"answerC" xml:"answerC"`
AnswerD *Answer `gqlgen:"answerD" json:"answerD" xml:"answerD"`
QualificationID *int `gqlgen:"qualificationID" json:"qualificationID" xml:"qualificationID"`
Image *graphql.Upload `json:"image" xml:"image" gqlgen:"image"`
DeleteImage *bool `json:"deleteImage" xml:"deleteImage" gqlgen:"deleteImage"`
AnswerAImage *graphql.Upload `json:"answerAImage" gqlgen:"answerAImage" xml:"answerAImage"`
DeleteAnswerAImage *bool `json:"deleteAnswerAImage" xml:"deleteAnswerAImage" gqlgen:"deleteAnswerAImage"`
AnswerBImage *graphql.Upload `json:"answerBImage" gqlgen:"answerBImage" xml:"answerBImage"`
DeleteAnswerBImage *bool `json:"deleteAnswerBImage" xml:"deleteAnswerBImage" gqlgen:"deleteAnswerBImage"`
AnswerCImage *graphql.Upload `json:"answerCImage" gqlgen:"answerCImage" xml:"answerCImage"`
DeleteAnswerCImage *bool `json:"deleteAnswerCImage" xml:"deleteAnswerCImage" gqlgen:"deleteAnswerCImage"`
AnswerDImage *graphql.Upload `json:"answerDImage" gqlgen:"answerDImage" xml:"answerDImage"`
DeleteAnswerDImage *bool `json:"deleteAnswerDImage" xml:"deleteAnswerDImage" gqlgen:"deleteAnswerDImage"`
}
func (input *QuestionInput) IsEmpty() bool {
return input == nil &&
input.Content == nil &&
input.From == nil &&
input.Explanation == nil &&
input.CorrectAnswer == nil &&
input.AnswerA == nil &&
input.AnswerAImage == nil &&
input.AnswerB == nil &&
input.AnswerBImage == nil &&
input.AnswerC == nil &&
input.AnswerCImage == nil &&
input.AnswerD == nil &&
input.AnswerDImage == nil &&
input.Image == nil &&
input.QualificationID == nil
}
func (input *QuestionInput) ToQuestion() *Question {
@ -76,33 +97,55 @@ func (input *QuestionInput) ToQuestion() *Question {
if input.AnswerA != nil {
q.AnswerA = *input.AnswerA
}
if input.AnswerAImagePath != nil {
q.AnswerAImage = *input.AnswerAImagePath
}
if input.AnswerB != nil {
q.AnswerB = *input.AnswerB
}
if input.AnswerBImagePath != nil {
q.AnswerBImage = *input.AnswerBImagePath
}
if input.AnswerC != nil {
q.AnswerC = *input.AnswerC
}
if input.AnswerCImagePath != nil {
q.AnswerCImage = *input.AnswerCImagePath
}
if input.AnswerD != nil {
q.AnswerD = *input.AnswerD
}
if input.AnswerDImagePath != nil {
q.AnswerDImage = *input.AnswerDImagePath
}
if input.QualificationID != nil {
q.QualificationID = *input.QualificationID
}
return q
}
func (input *QuestionInput) ApplyUpdate(q *orm.Query) (*orm.Query, error) {
if !input.IsEmpty() {
if input.Content != nil {
q.Set("content = ?", *input.Content)
}
if input.From != nil {
q.Set("from = ?", *input.From)
}
if input.Explanation != nil {
q.Set("explanation = ?", *input.Explanation)
}
if input.CorrectAnswer != nil {
q.Set("correct_answer = ?", *input.CorrectAnswer)
}
if input.AnswerA != nil {
q.Set("answer_a = ?", *input.AnswerA)
}
if input.AnswerB != nil {
q.Set("answer_b = ?", *input.AnswerB)
}
if input.AnswerC != nil {
q.Set("answer_c = ?", *input.AnswerC)
}
if input.AnswerD != nil {
q.Set("answer_d = ?", *input.AnswerD)
}
if input.QualificationID != nil {
q.Set("qualification_id = ?", *input.QualificationID)
}
}
return q, nil
}
type QuestionFilter struct {
ID []int `gqlgen:"id" json:"id" xml:"id"`
IDNEQ []int `gqlgen:"idNEQ" json:"idNEQ" xml:"idNEQ"`

View File

@ -0,0 +1,5 @@
package question
const (
DefaultLimit = 100
)

View File

@ -0,0 +1,28 @@
package question
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type FetchConfig struct {
Filter *models.QuestionFilter
Offset int
Limit int
Sort []string
Count bool
}
type GenerateTestConfig struct {
Qualifications []int
Limit int
}
type Repository interface {
Store(ctx context.Context, input *models.QuestionInput) (*models.Question, error)
FindByIDAndUpdate(ctx context.Context, id int, input *models.QuestionInput) (*models.Question, error)
Delete(ctx context.Context, f *models.QuestionFilter) ([]*models.Question, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.Question, int, error)
GenerateTest(ctx context.Context, cfg *GenerateTestConfig) ([]*models.Question, error)
}

View File

@ -0,0 +1,123 @@
package repository
import (
"path/filepath"
"github.com/99designs/gqlgen/graphql"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/pkg/filestorage"
"github.com/zdam-egzamin-zawodowy/backend/pkg/utils"
)
func saveImages(storage filestorage.FileStorage, destination *models.Question, input *models.QuestionInput) {
images := [...]*graphql.Upload{
input.Image,
input.AnswerAImage,
input.AnswerBImage,
input.AnswerCImage,
input.AnswerDImage,
}
filenames := [...]string{
destination.Image,
destination.AnswerAImage,
destination.AnswerBImage,
destination.AnswerCImage,
destination.AnswerDImage,
}
for index, file := range images {
if file != nil {
generated := false
if filenames[index] == "" {
generated = true
filenames[index] = utils.GenerateFilename(filepath.Ext(file.Filename))
}
err := storage.Put(file.File, filenames[index])
if err != nil && generated {
filenames[index] = ""
}
}
}
destination.Image = filenames[0]
destination.AnswerAImage = filenames[1]
destination.AnswerBImage = filenames[2]
destination.AnswerCImage = filenames[3]
destination.AnswerDImage = filenames[4]
}
func deleteImages(storage filestorage.FileStorage, filenames []string) {
for _, filename := range filenames {
storage.Remove(filename)
}
}
func deleteImagesBasedOnInput(storage filestorage.FileStorage, question *models.Question, input *models.QuestionInput) {
filenames := []string{}
if input.DeleteImage != nil &&
*input.DeleteImage &&
input.Image == nil &&
question.Image != "" {
filenames = append(filenames, question.Image)
question.Image = ""
}
if input.DeleteAnswerAImage != nil &&
*input.DeleteAnswerAImage &&
input.AnswerAImage == nil &&
question.AnswerAImage != "" {
filenames = append(filenames, question.AnswerAImage)
question.AnswerAImage = ""
}
if input.DeleteAnswerBImage != nil &&
*input.DeleteAnswerBImage &&
input.AnswerBImage == nil &&
question.AnswerBImage != "" {
filenames = append(filenames, question.AnswerBImage)
question.AnswerBImage = ""
}
if input.DeleteAnswerCImage != nil &&
*input.DeleteAnswerCImage &&
input.AnswerCImage == nil &&
question.AnswerCImage != "" {
filenames = append(filenames, question.AnswerCImage)
question.AnswerCImage = ""
}
if input.DeleteAnswerDImage != nil &&
*input.DeleteAnswerDImage &&
input.AnswerDImage == nil &&
question.AnswerDImage != "" {
filenames = append(filenames, question.AnswerDImage)
question.AnswerDImage = ""
}
deleteImages(storage, filenames)
}
func getAllFilenamesAndDeleteImages(storage filestorage.FileStorage, questions []*models.Question) {
filenames := []string{}
for _, question := range questions {
if question.Image != "" {
filenames = append(filenames, question.Image)
}
if question.AnswerAImage != "" {
filenames = append(filenames, question.AnswerAImage)
}
if question.AnswerBImage != "" {
filenames = append(filenames, question.AnswerBImage)
}
if question.AnswerCImage != "" {
filenames = append(filenames, question.AnswerCImage)
}
if question.AnswerDImage != "" {
filenames = append(filenames, question.AnswerDImage)
}
}
deleteImages(storage, filenames)
}

View File

@ -0,0 +1,9 @@
package repository
const (
messageSimilarRecordExists = "Istnieje już podobne pytanie."
messageFailedToSaveModel = "Wystąpił błąd podczas zapisywania pytania, prosimy spróbować później."
messageFailedToDeleteModel = "Wystąpił błąd podczas usuwania pytania, prosimy spróbować później."
messageFailedToFetchModel = "Wystąpił błąd podczas pobierania pytań, prosimy spróbować później."
messageItemNotFound = "Nie znaleziono pytania."
)

View File

@ -0,0 +1,129 @@
package repository
import (
"context"
"fmt"
"strings"
"time"
"github.com/zdam-egzamin-zawodowy/backend/pkg/filestorage"
errorutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/error"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
"github.com/go-pg/pg/v10"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/question"
)
type pgRepository struct {
*pg.DB
fileStorage filestorage.FileStorage
}
type PGRepositoryConfig struct {
DB *pg.DB
FileStorage filestorage.FileStorage
}
func NewPGRepository(cfg *PGRepositoryConfig) (question.Repository, error) {
if cfg == nil || cfg.DB == nil || cfg.FileStorage == nil {
return nil, fmt.Errorf("question/pg_repository: *pg.DB and filestorage.FileStorage are required")
}
return &pgRepository{
cfg.DB,
cfg.FileStorage,
}, nil
}
func (repo *pgRepository) Store(ctx context.Context, input *models.QuestionInput) (*models.Question, error) {
item := input.ToQuestion()
if _, err := repo.
Model(item).
Context(ctx).
Returning("*").
Insert(); err != nil {
if strings.Contains(err.Error(), "questions_from_content_correct_answer_qualification_id_key") {
return nil, errorutils.Wrap(err, messageSimilarRecordExists)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
saveImages(repo.fileStorage, item, input)
return item, nil
}
func (repo *pgRepository) FindByIDAndUpdate(ctx context.Context, id int, input *models.QuestionInput) (*models.Question, error) {
item := &models.Question{}
baseQuery := repo.
Model(item).
Context(ctx).
Returning("*").
Where(sqlutils.BuildConditionEquals(sqlutils.AddAliasToColumnName("id", "question")), id).
Set("updated_at = ?", time.Now())
if _, err := baseQuery.
Clone().
Apply(input.ApplyUpdate).
Update(); err != nil && err != pg.ErrNoRows {
if strings.Contains(err.Error(), "questions_from_content_correct_answer_qualification_id_key") {
return nil, errorutils.Wrap(err, messageSimilarRecordExists)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
saveImages(repo.fileStorage, item, input)
deleteImagesBasedOnInput(repo.fileStorage, item, input)
if _, err := baseQuery.
Clone().
Set("image = ?", item.Image).
Set("answer_a_image = ?", item.AnswerAImage).
Set("answer_b_image = ?", item.AnswerBImage).
Set("answer_c_image = ?", item.AnswerCImage).
Set("answer_d_image = ?", item.AnswerDImage).
Update(); err != nil && err != pg.ErrNoRows {
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
return item, nil
}
func (repo *pgRepository) Delete(ctx context.Context, f *models.QuestionFilter) ([]*models.Question, error) {
items := []*models.Question{}
if _, err := repo.
Model(&items).
Context(ctx).
Returning("*").
Apply(f.Where).
Delete(); err != nil && err != pg.ErrNoRows {
return nil, errorutils.Wrap(err, messageFailedToDeleteModel)
}
go getAllFilenamesAndDeleteImages(repo.fileStorage, items)
return items, nil
}
func (repo *pgRepository) Fetch(ctx context.Context, cfg *question.FetchConfig) ([]*models.Question, int, error) {
var err error
items := []*models.Question{}
total := 0
query := repo.
Model(&items).
Context(ctx).
Limit(cfg.Limit).
Offset(cfg.Offset).
Apply(cfg.Filter.Where)
if cfg.Count {
total, err = query.SelectAndCount()
} else {
err = query.Select()
}
if err != nil && err != pg.ErrNoRows {
return nil, 0, errorutils.Wrap(err, messageFailedToFetchModel)
}
return items, total, nil
}

View File

@ -0,0 +1,15 @@
package question
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type Usecase interface {
Store(ctx context.Context, input *models.QuestionInput) (*models.Question, error)
UpdateOne(ctx context.Context, id int, input *models.QuestionInput) (*models.Question, error)
Delete(ctx context.Context, f *models.QuestionFilter) ([]*models.Question, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.Question, int, error)
GetByID(ctx context.Context, id int) (*models.Question, error)
}

View File

@ -0,0 +1,9 @@
package usecase
const (
messageInvalidID = "Niepoprawne ID."
messageItemNotFound = "Nie znaleziono zawodu."
messageEmptyPayload = "Nie wprowadzono jakichkolwiek danych."
messageNameIsRequired = "Nazwa zawodu jest wymagana."
messageNameIsTooLong = "Nazwa zawodu może się składać z maksymalnie %d znaków."
)

View File

@ -0,0 +1,129 @@
package usecase
import (
"context"
"fmt"
"strings"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/profession"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
)
type usecase struct {
professionRepository profession.Repository
}
type Config struct {
ProfessionRepository profession.Repository
}
func New(cfg *Config) (profession.Usecase, error) {
if cfg == nil || cfg.ProfessionRepository == nil {
return nil, fmt.Errorf("profession/usecase: ProfessionRepository is required")
}
return &usecase{
cfg.ProfessionRepository,
}, nil
}
func (ucase *usecase) Store(ctx context.Context, input *models.ProfessionInput) (*models.Profession, error) {
if err := ucase.validateInput(input, validateOptions{false}); err != nil {
return nil, err
}
return ucase.professionRepository.Store(ctx, input)
}
func (ucase *usecase) UpdateOne(ctx context.Context, id int, input *models.ProfessionInput) (*models.Profession, error) {
if id <= 0 {
return nil, fmt.Errorf(messageInvalidID)
}
if err := ucase.validateInput(input, validateOptions{true}); err != nil {
return nil, err
}
items, err := ucase.professionRepository.UpdateMany(ctx,
&models.ProfessionFilter{
ID: []int{id},
},
input)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageItemNotFound)
}
return items[0], nil
}
func (ucase *usecase) Delete(ctx context.Context, f *models.ProfessionFilter) ([]*models.Profession, error) {
return ucase.professionRepository.Delete(ctx, f)
}
func (ucase *usecase) Fetch(ctx context.Context, cfg *profession.FetchConfig) ([]*models.Profession, int, error) {
if cfg == nil {
cfg = &profession.FetchConfig{
Limit: profession.DefaultLimit,
Count: true,
}
}
cfg.Sort = sqlutils.SanitizeSortExpressions(cfg.Sort)
return ucase.professionRepository.Fetch(ctx, cfg)
}
func (ucase *usecase) GetByID(ctx context.Context, id int) (*models.Profession, error) {
items, _, err := ucase.Fetch(ctx, &profession.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.ProfessionFilter{
ID: []int{id},
},
})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageItemNotFound)
}
return items[0], nil
}
func (ucase *usecase) GetBySlug(ctx context.Context, slug string) (*models.Profession, error) {
items, _, err := ucase.Fetch(ctx, &profession.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.ProfessionFilter{
Slug: []string{slug},
},
})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageItemNotFound)
}
return items[0], nil
}
type validateOptions struct {
nameCanBeNil bool
}
func (ucase *usecase) validateInput(input *models.ProfessionInput, opts validateOptions) error {
if input.IsEmpty() {
return fmt.Errorf(messageEmptyPayload)
}
if input.Name != nil {
trimmedName := strings.TrimSpace(*input.Name)
input.Name = &trimmedName
if trimmedName == "" {
return fmt.Errorf(messageNameIsRequired)
} else if len(trimmedName) > profession.MaxNameLength {
return fmt.Errorf(messageNameIsTooLong, profession.MaxNameLength)
}
} else if !opts.nameCanBeNil {
return fmt.Errorf(messageNameIsRequired)
}
return nil
}

View File

@ -1,11 +1,14 @@
package filestorage
import "os"
type Config struct {
BasePath string
}
func getDefaultConfig() *Config {
path, _ := os.Getwd()
return &Config{
BasePath: "./",
BasePath: path,
}
}

View File

@ -36,11 +36,11 @@ func (storage *fileStorage) Put(file io.Reader, filename string) error {
return nil
}
func (storage *fileStorage) Delete(filename string) error {
func (storage *fileStorage) Remove(filename string) error {
fullPath := path.Join(storage.basePath, filename)
err := os.Remove(fullPath)
if err != nil {
return errors.Wrap(err, "FileStorage.Delete")
return errors.Wrap(err, "FileStorage.Remove")
}
return nil
}

View File

@ -4,5 +4,5 @@ import "io"
type FileStorage interface {
Put(file io.Reader, filename string) error
Delete(filename string) error
Remove(filename string) error
}

View File

@ -0,0 +1,14 @@
package utils
import (
"strings"
"github.com/google/uuid"
)
func GenerateFilename(ext string) string {
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return uuid.New().String() + ext
}

View File

@ -5,9 +5,9 @@ import (
"strings"
)
func AddAliasToColumnName(column, prefix string) string {
if prefix != "" && !strings.HasPrefix(column, prefix+".") {
column = WrapStringInDoubleQuotes(prefix) + "." + WrapStringInDoubleQuotes(column)
func AddAliasToColumnName(column, alias string) string {
if alias != "" && !strings.HasPrefix(column, alias+".") {
column = WrapStringInDoubleQuotes(alias) + "." + WrapStringInDoubleQuotes(column)
} else {
column = WrapStringInDoubleQuotes(column)
}