258 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			Go
		
	
			
		
		
	
	
			258 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			Go
		
	
package api
 | 
						|
 | 
						|
import (
 | 
						|
	"bytes"
 | 
						|
	"context"
 | 
						|
	"errors"
 | 
						|
	"log"
 | 
						|
	"sort"
 | 
						|
	"time"
 | 
						|
 | 
						|
	codequestpb "github.com/hhhapz/codequest/api/v1"
 | 
						|
	"github.com/hhhapz/codequest/models"
 | 
						|
	"github.com/hhhapz/codequest/question"
 | 
						|
	"google.golang.org/grpc/codes"
 | 
						|
	"google.golang.org/grpc/status"
 | 
						|
)
 | 
						|
 | 
						|
var (
 | 
						|
	Start time.Time
 | 
						|
	End   time.Time
 | 
						|
)
 | 
						|
 | 
						|
func active() bool {
 | 
						|
	now := time.Now()
 | 
						|
	return Start.Before(now) && End.After(now)
 | 
						|
}
 | 
						|
 | 
						|
type QuestService struct {
 | 
						|
	codequestpb.UnsafeQuestServiceServer
 | 
						|
 | 
						|
	questStore    QuestStore
 | 
						|
	userStore     UserStore
 | 
						|
	cooldownStore CooldownStore
 | 
						|
}
 | 
						|
 | 
						|
func (qs *QuestService) Questions(ctx context.Context, req *codequestpb.QuestionsRequest) (*codequestpb.QuestionsResponse, error) {
 | 
						|
	u := User(ctx)
 | 
						|
 | 
						|
	questions, err := qs.questStore.Questions(ctx, u)
 | 
						|
	switch {
 | 
						|
	case errors.As(err, &models.UserError{}):
 | 
						|
		return nil, status.Error(codes.InvalidArgument, err.Error())
 | 
						|
	case err != nil:
 | 
						|
		return nil, status.Errorf(codes.Internal, "could not get questions: internal error")
 | 
						|
	}
 | 
						|
 | 
						|
	if !active() {
 | 
						|
		questions = nil
 | 
						|
	}
 | 
						|
 | 
						|
	res := new(codequestpb.QuestionsResponse)
 | 
						|
 | 
						|
	for _, q := range questions {
 | 
						|
		res.Questions = append(res.Questions, &codequestpb.Question{
 | 
						|
			ID:         q.Question.ID,
 | 
						|
			Title:      q.Name,
 | 
						|
			Difficulty: codequestpb.Difficulty(q.Level),
 | 
						|
			Part1: &codequestpb.PartData{
 | 
						|
				Completed:   q.Part1.Completed,
 | 
						|
				PointsWorth: int32(q.Part1.PointsWorth),
 | 
						|
			},
 | 
						|
			Part2: &codequestpb.PartData{
 | 
						|
				Completed:   q.Part2.Completed,
 | 
						|
				PointsWorth: int32(q.Part2.PointsWorth),
 | 
						|
			},
 | 
						|
		})
 | 
						|
	}
 | 
						|
 | 
						|
	q := res.Questions
 | 
						|
	sort.Slice(q, func(i, j int) bool {
 | 
						|
		var iSolved, jSolved int
 | 
						|
		switch {
 | 
						|
		case q[i].Part2.Completed:
 | 
						|
			iSolved = 2
 | 
						|
		case q[i].Part1.Completed:
 | 
						|
			iSolved = 1
 | 
						|
		}
 | 
						|
		switch {
 | 
						|
		case q[j].Part2.Completed:
 | 
						|
			jSolved = 2
 | 
						|
		case q[j].Part1.Completed:
 | 
						|
			jSolved = 1
 | 
						|
		}
 | 
						|
 | 
						|
		switch {
 | 
						|
		// if progress on questions is the same, sort alphabetically
 | 
						|
		case iSolved == jSolved:
 | 
						|
			return q[i].Text < q[j].Text
 | 
						|
 | 
						|
		// if both parts completed, always put at the end
 | 
						|
		case iSolved == 2:
 | 
						|
			return false
 | 
						|
		case jSolved == 2:
 | 
						|
			return true
 | 
						|
 | 
						|
		// show partway completed ones before those not started
 | 
						|
		case iSolved == 1:
 | 
						|
			return true
 | 
						|
		case jSolved == 1:
 | 
						|
			return false
 | 
						|
		}
 | 
						|
		return false
 | 
						|
	})
 | 
						|
 | 
						|
	return res, nil
 | 
						|
}
 | 
						|
 | 
						|
func (qs *QuestService) QuestionByID(ctx context.Context, req *codequestpb.QuestionByIDRequest) (*codequestpb.Question, error) {
 | 
						|
	u := User(ctx)
 | 
						|
 | 
						|
	if !active() {
 | 
						|
		return nil, status.Errorf(codes.NotFound, "the contest is not active")
 | 
						|
	}
 | 
						|
 | 
						|
	question, err := qs.questStore.Question(ctx, u, req.ID)
 | 
						|
	switch {
 | 
						|
	case errors.As(err, &models.UserError{}):
 | 
						|
		return nil, status.Error(codes.InvalidArgument, err.Error())
 | 
						|
	case err != nil:
 | 
						|
		log.Printf("could not fetch question(%q): %v", req.ID, err)
 | 
						|
		return nil, status.Errorf(codes.Internal, "could not get questions: internal error")
 | 
						|
	}
 | 
						|
 | 
						|
	content := new(bytes.Buffer)
 | 
						|
	err = question.Text.Execute(content, question)
 | 
						|
	if err != nil {
 | 
						|
		log.Printf("could gen write question %s template: %v", req.ID, err)
 | 
						|
		return nil, status.Errorf(codes.Internal, "could not get question: internal error")
 | 
						|
	}
 | 
						|
 | 
						|
	q := &codequestpb.Question{
 | 
						|
		ID:    question.Question.ID,
 | 
						|
		Title: question.Name,
 | 
						|
		Text:  content.String(),
 | 
						|
		Part1: &codequestpb.PartData{
 | 
						|
			Completed:   question.Part1.Completed,
 | 
						|
			PointsWorth: int32(question.Part1.PointsWorth),
 | 
						|
		},
 | 
						|
		Part2: &codequestpb.PartData{
 | 
						|
			Completed:   question.Part2.Completed,
 | 
						|
			PointsWorth: int32(question.Part2.PointsWorth),
 | 
						|
		},
 | 
						|
	}
 | 
						|
 | 
						|
	return q, nil
 | 
						|
}
 | 
						|
 | 
						|
func (qs *QuestService) QuestionInput(ctx context.Context, req *codequestpb.QuestionInputRequest) (*codequestpb.QuestionInput, error) {
 | 
						|
	u := User(ctx)
 | 
						|
 | 
						|
	if !active() {
 | 
						|
		return nil, status.Errorf(codes.NotFound, "the contest is not active")
 | 
						|
	}
 | 
						|
 | 
						|
	question, err := qs.questStore.Question(ctx, u, req.ID)
 | 
						|
	switch {
 | 
						|
	case errors.As(err, &models.UserError{}):
 | 
						|
		return nil, status.Error(codes.InvalidArgument, err.Error())
 | 
						|
	case err != nil:
 | 
						|
		log.Printf("could not get question %s: %v", req.ID, err)
 | 
						|
		return nil, status.Errorf(codes.Internal, "could not get question: internal error")
 | 
						|
	}
 | 
						|
 | 
						|
	inp := question.Question.Generate(u)
 | 
						|
	return &codequestpb.QuestionInput{
 | 
						|
		ID:    question.Question.ID,
 | 
						|
		Input: inp,
 | 
						|
	}, nil
 | 
						|
}
 | 
						|
 | 
						|
func (qs *QuestService) Submit(ctx context.Context, req *codequestpb.SubmitRequest) (*codequestpb.SubmitResponse, error) {
 | 
						|
	u := User(ctx)
 | 
						|
 | 
						|
	if !active() {
 | 
						|
		return nil, status.Errorf(codes.NotFound, "the contest is not active")
 | 
						|
	}
 | 
						|
 | 
						|
	if req.Part != 1 && req.Part != 2 {
 | 
						|
		return nil, status.Errorf(codes.NotFound, "invalid part number")
 | 
						|
	}
 | 
						|
 | 
						|
	q, err := qs.questStore.Question(ctx, u, req.ID)
 | 
						|
	switch {
 | 
						|
	case errors.As(err, &models.UserError{}):
 | 
						|
		return nil, status.Error(codes.InvalidArgument, err.Error())
 | 
						|
	case err != nil:
 | 
						|
		return nil, status.Errorf(codes.Internal, "could not get questions: internal error")
 | 
						|
	}
 | 
						|
 | 
						|
	completed, attempts, err := qs.questStore.Submissions(ctx, u, req.ID, question.Part(req.Part))
 | 
						|
	switch {
 | 
						|
	case errors.As(err, &models.UserError{}):
 | 
						|
		return nil, status.Errorf(codes.InvalidArgument, "%v", err)
 | 
						|
	case err != nil:
 | 
						|
		log.Printf("could not get submissions: %v", err)
 | 
						|
		return nil, status.Errorf(codes.Internal, "could not get submissions: internal error")
 | 
						|
	}
 | 
						|
 | 
						|
	if completed {
 | 
						|
		return nil, status.Errorf(codes.AlreadyExists, "question %s (part %d) already completed", req.ID, req.Part)
 | 
						|
	}
 | 
						|
 | 
						|
	// TODO: cooldowns
 | 
						|
	t := qs.cooldownStore.Check(u.ID, q.Question.ID)
 | 
						|
	if t != 0 {
 | 
						|
		return nil, status.Errorf(
 | 
						|
			codes.FailedPrecondition,
 | 
						|
			"you must wait %s before trying this question again", t.Truncate(time.Second),
 | 
						|
		)
 | 
						|
	}
 | 
						|
 | 
						|
	ok := q.Question.Validate(u, question.Part(req.Part), req.Body.Answer)
 | 
						|
 | 
						|
	var points int
 | 
						|
	if ok {
 | 
						|
		points = 5000 - (attempts * 100)
 | 
						|
		if points < 2000 {
 | 
						|
			points = 2000
 | 
						|
		}
 | 
						|
	} else {
 | 
						|
		qs.cooldownStore.NewAttempt(u.ID, q.Question.ID, attempts)
 | 
						|
	}
 | 
						|
 | 
						|
	qa := &models.QuestionAttempt{
 | 
						|
		UserID:        u.ID,
 | 
						|
		QuestionID:    q.Question.ID,
 | 
						|
		QuestionPart:  int(req.Part),
 | 
						|
		Correct:       ok,
 | 
						|
		PointsAwarded: points,
 | 
						|
		Answer:        req.Body.Answer,
 | 
						|
		Code:          req.Body.Code,
 | 
						|
		SubmittedAt:   models.NewTime(time.Now()),
 | 
						|
	}
 | 
						|
	qs.questStore.AddSubmission(ctx, qa)
 | 
						|
 | 
						|
	res := &codequestpb.SubmitResponse{
 | 
						|
		Correct: qa.Correct,
 | 
						|
		Points:  int32(qa.PointsAwarded),
 | 
						|
	}
 | 
						|
	return res, nil
 | 
						|
}
 | 
						|
 | 
						|
func (qs *QuestService) Leaderboard(ctx context.Context, req *codequestpb.LeaderboardRequest) (*codequestpb.LeaderboardResponse, error) {
 | 
						|
	entries, err := qs.questStore.Leaderboard(ctx)
 | 
						|
	if err != nil {
 | 
						|
		log.Printf("could not get leaderboard: %v", err)
 | 
						|
		return nil, status.Errorf(codes.Internal, "could not get leaderboard: internal error")
 | 
						|
	}
 | 
						|
	var lbr codequestpb.LeaderboardResponse
 | 
						|
	for _, le := range entries {
 | 
						|
		lbr.Leaderboard = append(lbr.Leaderboard, &codequestpb.LeaderboardEntry{
 | 
						|
			Username: le.Name,
 | 
						|
			Points:   int32(le.Points),
 | 
						|
		})
 | 
						|
	}
 | 
						|
	return &lbr, nil
 | 
						|
}
 |