generated from VLADIMIR/template
up
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
|
||||
"evening_detective_server/internal/modules/roles"
|
||||
"evening_detective_server/proto"
|
||||
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (s *server) DownloadGamePrintMaterials(ctx context.Context, req *proto.GetGameReq) (*httpbody.HttpBody, error) {
|
||||
claims, err := claimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Error(codes.PermissionDenied, "нет доступа к материалам игры")
|
||||
}
|
||||
if req == nil || req.ID <= 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор игры")
|
||||
}
|
||||
data, filename, err := s.gamePrintService.Download(ctx, int(req.ID))
|
||||
if err != nil {
|
||||
return nil, gamePrintError(err)
|
||||
}
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs(
|
||||
"Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}),
|
||||
"X-Content-Type-Options", "nosniff",
|
||||
"Cache-Control", "no-store",
|
||||
)); err != nil {
|
||||
return nil, status.Error(codes.Internal, "не удалось установить заголовки архива")
|
||||
}
|
||||
return &httpbody.HttpBody{
|
||||
ContentType: "application/zip",
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/services/game_print_service"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"evening_detective_server/internal/services/team_service"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func gamePrintError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, team_service.ErrGameNotFound), errors.Is(err, scenarios_service.ErrScenarioNotFound):
|
||||
return status.Error(codes.NotFound, "игра или сценарий не найдены")
|
||||
case errors.Is(err, game_print_service.ErrPackageTooLarge):
|
||||
return status.Error(codes.ResourceExhausted, game_print_service.ErrPackageTooLarge.Error())
|
||||
case errors.Is(err, game_print_service.ErrInvalidPrintText):
|
||||
return status.Error(codes.FailedPrecondition, "текст не помещается на лист или содержит неподдерживаемые символы")
|
||||
case errors.Is(err, file_storage.ErrFileNotFound):
|
||||
return status.Error(codes.FailedPrecondition, "не найден файл улики; проверьте материалы сценария")
|
||||
case errors.Is(err, context.Canceled):
|
||||
return status.Error(codes.Canceled, "скачивание отменено")
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return status.Error(codes.DeadlineExceeded, "превышено время подготовки материалов")
|
||||
default:
|
||||
log.Printf("game print materials: %v", err)
|
||||
return status.Error(codes.Internal, "не удалось подготовить материалы для печати")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/services/game_print_service"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"evening_detective_server/internal/services/team_service"
|
||||
"evening_detective_server/proto"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type printGameStub struct{}
|
||||
|
||||
func (printGameStub) GetGameByID(context.Context, int) (*repos.Game, error) {
|
||||
return &repos.Game{ID: 1, Name: "Дело №1", Format: "offline"}, nil
|
||||
}
|
||||
|
||||
func (printGameStub) GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error) {
|
||||
return &scenarios_service.Scenario{}, nil
|
||||
}
|
||||
|
||||
func (printGameStub) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestDownloadGamePrintMaterialsHeaders(t *testing.T) {
|
||||
for _, role := range []string{"organizer", "author", "admin"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
stub := printGameStub{}
|
||||
s := &server{gamePrintService: game_print_service.NewGamePrintService(stub, stub, stub, nil, nil, "")}
|
||||
stream := &mockServerTransportStream{}
|
||||
ctx := grpc.NewContextWithServerTransportStream(ctxWithClaims(role), stream)
|
||||
body, err := s.DownloadGamePrintMaterials(ctx, &proto.GetGameReq{ID: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.ContentType != "application/zip" || !bytes.HasPrefix(body.Data, []byte("PK")) {
|
||||
t.Fatal("invalid ZIP response")
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"content-disposition": "attachment; filename=game-1-print-materials.zip",
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
} {
|
||||
values := stream.header.Get(key)
|
||||
if len(values) != 1 || values[0] != want {
|
||||
t.Errorf("%s = %v, want %s", key, values, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadGamePrintMaterialsAccess(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
request *proto.GetGameReq
|
||||
want codes.Code
|
||||
}{
|
||||
{"anonymous", context.Background(), &proto.GetGameReq{ID: 1}, codes.Unauthenticated},
|
||||
{"player", ctxWithClaims("user"), &proto.GetGameReq{ID: 1}, codes.PermissionDenied},
|
||||
{"developer", ctxWithClaims("developer"), &proto.GetGameReq{ID: 1}, codes.PermissionDenied},
|
||||
{"organizer invalid ID", ctxWithClaims("organizer"), &proto.GetGameReq{}, codes.InvalidArgument},
|
||||
{"author invalid ID", ctxWithClaims("author"), &proto.GetGameReq{ID: -1}, codes.InvalidArgument},
|
||||
{"admin nil request", ctxWithClaims("admin"), nil, codes.InvalidArgument},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := (&server{}).DownloadGamePrintMaterials(tc.ctx, tc.request)
|
||||
if status.Code(err) != tc.want {
|
||||
t.Fatalf("code = %v, want %v", status.Code(err), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamePrintErrorMapping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
want codes.Code
|
||||
}{
|
||||
{team_service.ErrGameNotFound, codes.NotFound},
|
||||
{scenarios_service.ErrScenarioNotFound, codes.NotFound},
|
||||
{game_print_service.ErrPackageTooLarge, codes.ResourceExhausted},
|
||||
{game_print_service.ErrInvalidPrintText, codes.FailedPrecondition},
|
||||
{file_storage.ErrFileNotFound, codes.FailedPrecondition},
|
||||
{context.Canceled, codes.Canceled},
|
||||
{context.DeadlineExceeded, codes.DeadlineExceeded},
|
||||
{errors.New("private storage detail"), codes.Internal},
|
||||
} {
|
||||
t.Run(tc.err.Error(), func(t *testing.T) {
|
||||
err := gamePrintError(fmt.Errorf("wrapped: %w", tc.err))
|
||||
if status.Code(err) != tc.want {
|
||||
t.Fatalf("code = %v, want %v", status.Code(err), tc.want)
|
||||
}
|
||||
if tc.want == codes.Internal && status.Convert(err).Message() != "не удалось подготовить материалы для печати" {
|
||||
t.Fatal("private error exposed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
"evening_detective_server/internal/services/game_export_service"
|
||||
"evening_detective_server/internal/services/game_import_service"
|
||||
"evening_detective_server/internal/services/game_print_service"
|
||||
"evening_detective_server/internal/services/game_results_package_service"
|
||||
"evening_detective_server/internal/services/game_review_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
@@ -40,6 +41,7 @@ type server struct {
|
||||
gameResultsService *game_results_package_service.GameResultsPackageService
|
||||
gameResultsApplyService *game_results_package_service.GameResultsApplyService
|
||||
gameService *game_service.GameService
|
||||
gamePrintService *game_print_service.GamePrintService
|
||||
gameplayService *gameplay_service.GameplayService
|
||||
answerService *answer_service.AnswerService
|
||||
reviewService *game_review_service.GameReviewService
|
||||
@@ -76,8 +78,10 @@ func NewServer(
|
||||
gameResultsApplyService *game_results_package_service.GameResultsApplyService,
|
||||
devicePresenceService *device_presence_service.DevicePresenceService,
|
||||
wifiService *wifi_service.Service,
|
||||
gamePrintService *game_print_service.GamePrintService,
|
||||
) proto.EveningDetectiveServerServer {
|
||||
return &server{
|
||||
gamePrintService: gamePrintService,
|
||||
devicePresenceService: devicePresenceService,
|
||||
wifiService: wifiService,
|
||||
gameImportService: gameImportService,
|
||||
|
||||
@@ -25,6 +25,11 @@ type Card struct {
|
||||
//go:embed fonts/DejaVuSansCondensed.ttf
|
||||
var font []byte
|
||||
|
||||
// RegisterFont adds the existing Cyrillic print font to a PDF document.
|
||||
func RegisterFont(pdf *fpdf.Fpdf) {
|
||||
pdf.AddUTF8FontFromBytes("teams", "", font)
|
||||
}
|
||||
|
||||
// Generate returns a PDF containing team QR-code cards.
|
||||
func Generate(cards []Card) ([]byte, error) {
|
||||
if len(cards) == 0 {
|
||||
@@ -33,7 +38,7 @@ func Generate(cards []Card) ([]byte, error) {
|
||||
pdf := fpdf.New("P", "mm", "A4", "")
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
pdf.SetCellMargin(0)
|
||||
pdf.AddUTF8FontFromBytes("teams", "", font)
|
||||
RegisterFont(pdf)
|
||||
pdf.SetFillColor(0, 0, 0)
|
||||
const margin, width, height, qrSize = 10.0, 190.0 / 3, 277.0 / 4, 36.0
|
||||
for i, card := range cards {
|
||||
|
||||
@@ -378,7 +378,7 @@ func verifyExportedGameReads(t *testing.T, f serviceFixture, worker *game_export
|
||||
games := game_service.NewGameService(f.games, f.teams, f.scenarios, nil, "", actions_repo.NewActionsRepo(f.pool), applications_repo.NewApplicationsRepo(f.pool))
|
||||
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
|
||||
server := app.NewServer(nil, nil, nil, nil, games, nil, nil, nil, nil, nil, nil,
|
||||
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil, nil, nil)
|
||||
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil, nil, nil, nil)
|
||||
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{UserID: f.userID, Roles: []string{"organizer"}})
|
||||
game, err := server.GetGame(ctx, &proto.GetGameReq{ID: int32(f.gameID)})
|
||||
check(t, err)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package game_print_service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/modules/team_qr_pdf"
|
||||
"evening_detective_server/internal/repos"
|
||||
|
||||
"github.com/go-pdf/fpdf"
|
||||
)
|
||||
|
||||
func newPDF(orientation string) *fpdf.Fpdf {
|
||||
pdf := fpdf.New(orientation, "mm", "A4", "")
|
||||
pdf.SetMargins(15, 15, 15)
|
||||
pdf.SetAutoPageBreak(true, 15)
|
||||
team_qr_pdf.RegisterFont(pdf)
|
||||
pdf.SetFont("teams", "", 11)
|
||||
pdf.SetFooterFunc(func() {
|
||||
pdf.SetY(-12)
|
||||
pdf.SetFont("teams", "", 8)
|
||||
pdf.CellFormat(0, 5, fmt.Sprint(pdf.PageNo()), "", 0, "C", false, 0, "")
|
||||
})
|
||||
pdf.AddPage()
|
||||
return pdf
|
||||
}
|
||||
|
||||
func printText(pdf *fpdf.Fpdf, text string) string {
|
||||
if !utf8.ValidString(text) {
|
||||
pdf.SetError(ErrInvalidPrintText)
|
||||
return ""
|
||||
}
|
||||
for _, r := range text {
|
||||
// shortcut: fpdf supports only BMP text; replace the renderer when emoji printing is required.
|
||||
if r > 0xffff {
|
||||
pdf.SetError(ErrInvalidPrintText)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\t", " ")
|
||||
}
|
||||
|
||||
func heading(pdf *fpdf.Fpdf, text string) {
|
||||
pdf.SetFont("teams", "", 15)
|
||||
pdf.MultiCell(0, 7, printText(pdf, text), "", "L", false)
|
||||
pdf.Ln(3)
|
||||
pdf.SetFont("teams", "", 11)
|
||||
}
|
||||
|
||||
func body(pdf *fpdf.Fpdf, text string) {
|
||||
pdf.MultiCell(0, 6, printText(pdf, text), "", "L", false)
|
||||
pdf.Ln(3)
|
||||
}
|
||||
|
||||
func pdfBytes(pdf *fpdf.Fpdf) ([]byte, error) {
|
||||
var output bytes.Buffer
|
||||
if err := pdf.Output(&output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func textPDF(game, title, text string) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
heading(pdf, title)
|
||||
body(pdf, game)
|
||||
body(pdf, text)
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func rulesPDF(rules string) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
for _, paragraph := range strings.Split(rules, "\n\n") {
|
||||
if strings.HasPrefix(paragraph, "#") {
|
||||
if pdf.GetY() > 245 {
|
||||
pdf.AddPage()
|
||||
}
|
||||
heading(pdf, strings.TrimSpace(strings.TrimLeft(paragraph, "#")))
|
||||
} else {
|
||||
body(pdf, paragraph)
|
||||
}
|
||||
}
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func questionsPDF(game string, questions []*storytelling.Question) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
heading(pdf, "Вопросы дела")
|
||||
body(pdf, game)
|
||||
body(pdf, "Команда: __________________________________________________")
|
||||
textCleaner := cleaner.NewCleaner()
|
||||
for i, question := range questions {
|
||||
if pdf.GetY() > 235 {
|
||||
pdf.AddPage()
|
||||
}
|
||||
body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text)))
|
||||
if pdf.GetY() > 247 {
|
||||
pdf.AddPage()
|
||||
}
|
||||
for range 4 {
|
||||
y := pdf.GetY() + 7
|
||||
pdf.Line(15, y, 195, y)
|
||||
pdf.SetY(y)
|
||||
}
|
||||
pdf.Ln(6)
|
||||
}
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func draftPDF(game, title string) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
heading(pdf, title)
|
||||
body(pdf, game)
|
||||
body(pdf, "Команда: __________________________________________________")
|
||||
pdf.SetDrawColor(180, 180, 180)
|
||||
for y := pdf.GetY() + 8; y < 277; y += 8 {
|
||||
pdf.Line(15, y, 195, y)
|
||||
}
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func answersPDF(game string, story *storytelling.Story) ([]byte, error) {
|
||||
pdf := newPDF("P")
|
||||
heading(pdf, "Для ведущего: ответы и развязка")
|
||||
body(pdf, game)
|
||||
textCleaner := cleaner.NewCleaner()
|
||||
for i, question := range story.Questions {
|
||||
body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text)))
|
||||
body(pdf, "Ответ: "+question.Answer)
|
||||
}
|
||||
if story.Conclusion != nil {
|
||||
heading(pdf, "Развязка")
|
||||
body(pdf, story.Conclusion.Text)
|
||||
}
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func issuancePDF(game string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) {
|
||||
pdf := newPDF("L")
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
const columnsPerPage = 8
|
||||
const teamWidth, clueWidth, lineHeight = 51.0, 27.0, 4.0
|
||||
if len(clues) == 0 {
|
||||
clues = []*storytelling.Application{{Name: "Улика"}}
|
||||
}
|
||||
if len(teams) == 0 {
|
||||
for range 12 {
|
||||
teams = append(teams, &repos.Team{})
|
||||
}
|
||||
}
|
||||
for start := 0; start < len(clues); start += columnsPerPage {
|
||||
end := min(start+columnsPerPage, len(clues))
|
||||
labels := []string{"Команда / улика"}
|
||||
for _, clue := range clues[start:end] {
|
||||
labels = append(labels, clue.Name)
|
||||
}
|
||||
for row := 0; row < len(teams); {
|
||||
if start > 0 || row > 0 {
|
||||
pdf.AddPage()
|
||||
}
|
||||
heading(pdf, "Лист выдачи улик")
|
||||
body(pdf, game)
|
||||
pdf.SetFont("teams", "", 9)
|
||||
widths := []float64{teamWidth}
|
||||
for range labels[1:] {
|
||||
widths = append(widths, clueWidth)
|
||||
}
|
||||
headerHeight := 10.0
|
||||
for i, label := range labels {
|
||||
headerHeight = max(headerHeight, float64(len(pdf.SplitText(printText(pdf, label), widths[i]-4)))*lineHeight+4)
|
||||
}
|
||||
if pdf.GetY()+headerHeight+10 > 195 {
|
||||
return nil, fmt.Errorf("issuance header does not fit: %w", ErrInvalidPrintText)
|
||||
}
|
||||
tableRow(pdf, labels, widths, headerHeight)
|
||||
pageStartRow := row
|
||||
for row < len(teams) {
|
||||
name := printText(pdf, teams[row].Name)
|
||||
height := max(10.0, float64(len(pdf.SplitText(name, teamWidth-4)))*lineHeight+4)
|
||||
if pdf.GetY()+height > 195 {
|
||||
if row == pageStartRow {
|
||||
return nil, fmt.Errorf("team name does not fit: %w", ErrInvalidPrintText)
|
||||
}
|
||||
break
|
||||
}
|
||||
cells := make([]string, len(labels))
|
||||
cells[0] = name
|
||||
tableRow(pdf, cells, widths, height)
|
||||
row++
|
||||
}
|
||||
}
|
||||
}
|
||||
return pdfBytes(pdf)
|
||||
}
|
||||
|
||||
func tableRow(pdf *fpdf.Fpdf, cells []string, widths []float64, height float64) {
|
||||
x, y := 15.0, pdf.GetY()
|
||||
for i, cell := range cells {
|
||||
pdf.Rect(x, y, widths[i], height, "D")
|
||||
pdf.SetXY(x+2, y+2)
|
||||
pdf.MultiCell(widths[i]-4, 4, printText(pdf, cell), "", "L", false)
|
||||
x += widths[i]
|
||||
}
|
||||
pdf.SetXY(15, y+height)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Package game_print_service builds printable game materials.
|
||||
package game_print_service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"evening_detective_server/docs"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"evening_detective_server/internal/services/team_service"
|
||||
)
|
||||
|
||||
const (
|
||||
playerFolder = "Для игрока/"
|
||||
hostFolder = "Для ведущего/"
|
||||
maxPackageSize = 256 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPackageTooLarge = errors.New("материалы для печати превышают 256 МБ")
|
||||
ErrInvalidPrintText = errors.New("текст материалов содержит неподдерживаемые символы")
|
||||
)
|
||||
|
||||
// IGamesReader supplies the game used in a print package.
|
||||
type IGamesReader interface {
|
||||
GetGameByID(context.Context, int) (*repos.Game, error)
|
||||
}
|
||||
|
||||
// IScenariosReader supplies the complete story, including organizer answers.
|
||||
type IScenariosReader interface {
|
||||
GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error)
|
||||
}
|
||||
|
||||
// ITeamsReader supplies participating teams, excluding cancelled registrations.
|
||||
type ITeamsReader interface {
|
||||
GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error)
|
||||
}
|
||||
|
||||
// ITeamQRGenerator uses the same team list as the issuance sheet.
|
||||
type ITeamQRGenerator interface {
|
||||
GenerateTeamQR(context.Context, *repos.Game, []*repos.Team) ([]byte, string, error)
|
||||
}
|
||||
|
||||
// GamePrintService builds an archive without persisting generated documents.
|
||||
type GamePrintService struct {
|
||||
games IGamesReader
|
||||
scenarios IScenariosReader
|
||||
teams ITeamsReader
|
||||
qr ITeamQRGenerator
|
||||
files file_storage.IFileStorage
|
||||
domain string
|
||||
}
|
||||
|
||||
// NewGamePrintService creates the print package service.
|
||||
func NewGamePrintService(games IGamesReader, scenarios IScenariosReader, teams ITeamsReader, qr ITeamQRGenerator, files file_storage.IFileStorage, domain string) *GamePrintService {
|
||||
return &GamePrintService{
|
||||
games: games,
|
||||
scenarios: scenarios,
|
||||
teams: teams,
|
||||
qr: qr,
|
||||
files: files,
|
||||
domain: domain,
|
||||
}
|
||||
}
|
||||
|
||||
// Download builds current player and organizer materials for a game.
|
||||
func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
game, err := s.games.GetGameByID(ctx, gameID)
|
||||
if err != nil {
|
||||
if errors.Is(err, games_repo.ErrGameNotFound) {
|
||||
return nil, "", team_service.ErrGameNotFound
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
scenario, err := s.scenarios.GetFullScenarioByID(ctx, game.ScenarioID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
teams, err := s.teams.GetActiveTeamsByGameID(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
story := scenario.Story
|
||||
if story == nil {
|
||||
story = &storytelling.Story{}
|
||||
}
|
||||
clues := collectClues(story)
|
||||
var output bytes.Buffer
|
||||
archive := zip.NewWriter(&output)
|
||||
defer archive.Close()
|
||||
size := 0
|
||||
add := func(name string, data []byte) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
size += len(data)
|
||||
if size > maxPackageSize {
|
||||
return ErrPackageTooLarge
|
||||
}
|
||||
entry, err := archive.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = entry.Write(data)
|
||||
return err
|
||||
}
|
||||
intro := ""
|
||||
if story.Introduction != nil {
|
||||
intro = cleaner.NewCleaner().ClearText(story.Introduction.Text)
|
||||
}
|
||||
documents := []struct {
|
||||
name string
|
||||
generate func() ([]byte, error)
|
||||
}{
|
||||
{playerFolder + "Правила.pdf", func() ([]byte, error) { return rulesPDF(docs.GameRules(game.Format)) }},
|
||||
{playerFolder + "Введение.pdf", func() ([]byte, error) { return textPDF(game.Name, "Введение", intro) }},
|
||||
{playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(game.Name, story.Questions) }},
|
||||
{playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Список пидарасов") }},
|
||||
{playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Черновик расследования") }},
|
||||
{hostFolder + "Ответы и развязка.pdf", func() ([]byte, error) { return answersPDF(game.Name, story) }},
|
||||
{hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(game.Name, teams, clues) }},
|
||||
}
|
||||
for _, document := range documents {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := document.generate()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("generate %s: %w", document.name, err)
|
||||
}
|
||||
if err := add(document.name, data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if len(teams) > 0 {
|
||||
data, _, err := s.qr.GenerateTeamQR(ctx, game, teams)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("generate team QR: %w", err)
|
||||
}
|
||||
if err := add(hostFolder+"QR команд.pdf", data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if err := add(playerFolder+"Улики/", nil); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var external []string
|
||||
for i, clue := range clues {
|
||||
ref := strings.TrimPrefix(clue.Image, s.domain)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid clue reference: %w", err)
|
||||
}
|
||||
if parsed.IsAbs() || parsed.Host != "" {
|
||||
external = append(external, clue.Name+": "+clue.Image)
|
||||
continue
|
||||
}
|
||||
file, err := s.files.Get(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("load clue %d: %w", i+1, err)
|
||||
}
|
||||
filename := fmt.Sprintf("%03d - %s%s", i+1, safeName(clue.Name), path.Ext(path.Base(ref)))
|
||||
if err := add(playerFolder+"Улики/"+filename, file.Data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if len(external) > 0 {
|
||||
data, err := textPDF(game.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n"))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := add(hostFolder+"Внешние материалы.pdf", data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return output.Bytes(), fmt.Sprintf("game-%d-print-materials.zip", gameID), nil
|
||||
}
|
||||
|
||||
func collectClues(story *storytelling.Story) []*storytelling.Application {
|
||||
var clues []*storytelling.Application
|
||||
seen := make(map[string]bool)
|
||||
for _, place := range story.Places {
|
||||
for _, clue := range place.Applications {
|
||||
key := clue.Image
|
||||
if key == "" {
|
||||
key = "name:" + clue.Name
|
||||
}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
clues = append(clues, clue)
|
||||
}
|
||||
}
|
||||
}
|
||||
return clues
|
||||
}
|
||||
|
||||
func safeName(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
name = strings.Trim(name, " .")
|
||||
if name == "" {
|
||||
return "Улика"
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > 80 {
|
||||
name = string(runes[:80])
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package game_print_service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"evening_detective_server/internal/services/team_service"
|
||||
)
|
||||
|
||||
type printFixture struct {
|
||||
file_storage.IFileStorage
|
||||
game *repos.Game
|
||||
story *storytelling.Story
|
||||
teams []*repos.Team
|
||||
files map[string][]byte
|
||||
gameErr error
|
||||
scenarioErr error
|
||||
teamsErr error
|
||||
fileErr error
|
||||
qrCalls int
|
||||
}
|
||||
|
||||
func (f *printFixture) GetGameByID(context.Context, int) (*repos.Game, error) {
|
||||
return f.game, f.gameErr
|
||||
}
|
||||
|
||||
func (f *printFixture) GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error) {
|
||||
return &scenarios_service.Scenario{Story: f.story}, f.scenarioErr
|
||||
}
|
||||
|
||||
func (f *printFixture) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error) {
|
||||
var teams []*repos.Team
|
||||
for _, team := range f.teams {
|
||||
if team.ParticipationStatus != "cancelled" {
|
||||
teams = append(teams, team)
|
||||
}
|
||||
}
|
||||
return teams, f.teamsErr
|
||||
}
|
||||
|
||||
func (f *printFixture) GenerateTeamQR(ctx context.Context, game *repos.Game, teams []*repos.Team) ([]byte, string, error) {
|
||||
f.qrCalls++
|
||||
return team_service.NewTeamService(nil, nil, nil, "https://game.test", nil).GenerateTeamQR(ctx, game, teams)
|
||||
}
|
||||
|
||||
func (f *printFixture) Get(ctx context.Context, name string) (*file_storage.File, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.fileErr != nil {
|
||||
return nil, f.fileErr
|
||||
}
|
||||
data, ok := f.files[name]
|
||||
if !ok {
|
||||
return nil, file_storage.ErrFileNotFound
|
||||
}
|
||||
return &file_storage.File{Data: data}, nil
|
||||
}
|
||||
|
||||
func newPrintFixture() *printFixture {
|
||||
clue := &storytelling.Application{Name: "Письмо / улика", Image: "https://files.test/clue.pdf"}
|
||||
return &printFixture{
|
||||
game: &repos.Game{ID: 1, Name: "Дело №1", Format: "offline", ScenarioID: 2},
|
||||
story: &storytelling.Story{
|
||||
Introduction: &storytelling.Introduction{Text: "Начало расследования"},
|
||||
Questions: []*storytelling.Question{{Text: "Кто виновен?", Answer: "СЕКРЕТНЫЙ ОТВЕТ"}},
|
||||
Conclusion: &storytelling.Conclusion{Text: "СЕКРЕТНАЯ РАЗВЯЗКА"},
|
||||
Places: []*storytelling.Place{{Applications: []*storytelling.Application{clue, clue}}},
|
||||
},
|
||||
teams: []*repos.Team{
|
||||
{ID: "team-1", Name: "Детективы", Password: "secret", ParticipationStatus: "confirmed"},
|
||||
{ID: "team-2", Name: "Ожидающая команда", Password: "secret", ParticipationStatus: "pending"},
|
||||
{ID: "team-3", Name: "Отменённая команда", Password: "secret", ParticipationStatus: "cancelled"},
|
||||
},
|
||||
files: map[string][]byte{"clue.pdf": []byte("%PDF-original-clue")},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *printFixture) service() *GamePrintService {
|
||||
return NewGamePrintService(f, f, f, f, f, "https://files.test/")
|
||||
}
|
||||
|
||||
func readPrintArchive(t *testing.T, raw []byte) map[string][]byte {
|
||||
t.Helper()
|
||||
archive, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := make(map[string][]byte)
|
||||
for _, entry := range archive.File {
|
||||
if !strings.HasPrefix(entry.Name, playerFolder) && !strings.HasPrefix(entry.Name, hostFolder) {
|
||||
t.Fatalf("unexpected root entry %s", entry.Name)
|
||||
}
|
||||
if _, exists := entries[entry.Name]; exists {
|
||||
t.Fatalf("duplicate entry %s", entry.Name)
|
||||
}
|
||||
reader, err := entry.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries[entry.Name] = data
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func TestDownloadPrintMaterials(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*printFixture)
|
||||
wantQR bool
|
||||
wantExternal bool
|
||||
}{
|
||||
{"complete", func(*printFixture) {}, true, false},
|
||||
{"without teams", func(f *printFixture) { f.teams = nil }, false, false},
|
||||
{"only cancelled teams", func(f *printFixture) { f.teams = f.teams[2:] }, false, false},
|
||||
{"external clue", func(f *printFixture) { f.story.Places[0].Applications[0].Image = "https://external.test/clue.pdf" }, true, true},
|
||||
{"empty story", func(f *printFixture) { f.story = nil }, true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newPrintFixture()
|
||||
tc.change(f)
|
||||
data, name, err := f.service().Download(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "game-1-print-materials.zip" {
|
||||
t.Fatalf("filename = %s", name)
|
||||
}
|
||||
entries := readPrintArchive(t, data)
|
||||
for _, name := range []string{
|
||||
playerFolder + "Правила.pdf", playerFolder + "Введение.pdf", playerFolder + "Вопросы.pdf",
|
||||
playerFolder + "Список пидарасов.pdf", playerFolder + "Черновик расследования.pdf",
|
||||
hostFolder + "Ответы и развязка.pdf", hostFolder + "Лист выдачи улик.pdf",
|
||||
} {
|
||||
if !bytes.HasPrefix(entries[name], []byte("%PDF-")) {
|
||||
t.Errorf("missing PDF %s", name)
|
||||
}
|
||||
}
|
||||
_, hasQR := entries[hostFolder+"QR команд.pdf"]
|
||||
_, hasExternal := entries[hostFolder+"Внешние материалы.pdf"]
|
||||
if hasQR != tc.wantQR || hasExternal != tc.wantExternal {
|
||||
t.Fatalf("QR = %v, external = %v", hasQR, hasExternal)
|
||||
}
|
||||
if !tc.wantQR && f.qrCalls != 0 {
|
||||
t.Fatal("generated QR for an empty team list")
|
||||
}
|
||||
if f.story != nil && !tc.wantExternal {
|
||||
if !bytes.Equal(entries[playerFolder+"Улики/001 - Письмо _ улика.pdf"], f.files["clue.pdf"]) {
|
||||
t.Fatal("original clue was changed or missing")
|
||||
}
|
||||
wantEntries := 9
|
||||
if tc.wantQR {
|
||||
wantEntries++
|
||||
}
|
||||
if len(entries) != wantEntries {
|
||||
t.Fatalf("unexpected entry count, duplicated clues: %d", len(entries))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPrintErrors(t *testing.T) {
|
||||
broken := errors.New("private storage detail")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*printFixture)
|
||||
want error
|
||||
}{
|
||||
{"game not found", func(f *printFixture) { f.gameErr = games_repo.ErrGameNotFound }, team_service.ErrGameNotFound},
|
||||
{"scenario not found", func(f *printFixture) { f.scenarioErr = scenarios_service.ErrScenarioNotFound }, scenarios_service.ErrScenarioNotFound},
|
||||
{"team read error", func(f *printFixture) { f.teamsErr = broken }, broken},
|
||||
{"missing clue", func(f *printFixture) { delete(f.files, "clue.pdf") }, file_storage.ErrFileNotFound},
|
||||
{"file read error", func(f *printFixture) { f.fileErr = broken }, broken},
|
||||
{"unsupported text", func(f *printFixture) { f.game.Name = "Дело 🕵️" }, ErrInvalidPrintText},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newPrintFixture()
|
||||
tc.change(f)
|
||||
data, _, err := f.service().Download(context.Background(), 1)
|
||||
if !errors.Is(err, tc.want) || len(data) != 0 {
|
||||
t.Fatalf("data = %d bytes, error = %v; want %v", len(data), err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuanceSheetPagination(t *testing.T) {
|
||||
for _, tc := range []struct{ teams, clues int }{{0, 0}, {1, 1}, {40, 17}} {
|
||||
t.Run(fmt.Sprintf("%d teams %d clues", tc.teams, tc.clues), func(t *testing.T) {
|
||||
var teams []*repos.Team
|
||||
var clues []*storytelling.Application
|
||||
for i := range tc.teams {
|
||||
teams = append(teams, &repos.Team{Name: fmt.Sprintf("Команда %d с длинным названием", i+1)})
|
||||
}
|
||||
for i := range tc.clues {
|
||||
clues = append(clues, &storytelling.Application{Name: fmt.Sprintf("Улика %d с длинной подписью", i+1)})
|
||||
}
|
||||
data, err := issuancePDF("Дело №1", teams, clues)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pages := bytes.Count(data, []byte("/Type /Page\n"))
|
||||
if pages < 1 || (tc.clues > 8 && pages < 6) {
|
||||
t.Fatalf("page count = %d", pages)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func extractedPDFText(t *testing.T, data []byte) string {
|
||||
t.Helper()
|
||||
command := exec.Command("pdftotext", "-", "-")
|
||||
command.Stdin = bytes.NewReader(data)
|
||||
text, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("extract PDF text: %v: %s", err, text)
|
||||
}
|
||||
return string(text)
|
||||
}
|
||||
|
||||
func TestPrintedContents(t *testing.T) {
|
||||
if _, err := exec.LookPath("pdftotext"); err != nil {
|
||||
t.Skip("pdftotext is needed only for optional rendered-content checks")
|
||||
}
|
||||
for _, format := range []string{"offline", "online"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
f := newPrintFixture()
|
||||
f.game.Format = format
|
||||
data, _, err := f.service().Download(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := readPrintArchive(t, data)
|
||||
for name, data := range entries {
|
||||
if !strings.HasSuffix(name, ".pdf") || strings.Contains(name, "Улики/") {
|
||||
continue
|
||||
}
|
||||
text := extractedPDFText(t, data)
|
||||
if strings.HasPrefix(name, playerFolder) && !strings.Contains(strings.ToLower(text), strings.ToLower(strings.TrimSuffix(path.Base(name), ".pdf"))) {
|
||||
t.Fatalf("missing heading in %s", name)
|
||||
}
|
||||
if strings.HasPrefix(name, playerFolder) && strings.Contains(text, "СЕКРЕТ") {
|
||||
t.Fatalf("organizer secrets in %s", name)
|
||||
}
|
||||
if strings.Contains(name, "Ответы и развязка") && (!strings.Contains(text, "СЕКРЕТНЫЙ ОТВЕТ") || !strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА")) {
|
||||
t.Fatal("missing organizer secrets")
|
||||
}
|
||||
if strings.Contains(name, "QR команд") || strings.Contains(name, "Лист выдачи") {
|
||||
if strings.Contains(text, "Отменённая") || !strings.Contains(text, "Детективы") || !strings.Contains(text, "Ожидающая") {
|
||||
t.Fatalf("wrong participating teams in %s: %s", name, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(name, "Правила") {
|
||||
want := "правила онлайн-игры"
|
||||
if format == "offline" {
|
||||
want = "правила очной игры"
|
||||
}
|
||||
if !strings.Contains(text, want) || strings.Contains(text, "##") {
|
||||
t.Fatalf("wrong printed rules: %s", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintCluesPreserveOriginalFiles(t *testing.T) {
|
||||
for _, extension := range []string{"pdf", "png", "webp", "mp3"} {
|
||||
t.Run(extension, func(t *testing.T) {
|
||||
f := newPrintFixture()
|
||||
original := []byte("original bytes without conversion")
|
||||
f.story.Places[0].Applications[0].Image = "https://files.test/file." + extension
|
||||
f.files["file."+extension] = original
|
||||
data, _, err := f.service().Download(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := readPrintArchive(t, data)
|
||||
if !bytes.Equal(entries[playerFolder+"Улики/001 - Письмо _ улика."+extension], original) {
|
||||
t.Fatal("original bytes were changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHonorsCancellation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want error
|
||||
}{{"cancelled", context.Canceled}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _, err := newPrintFixture().service().Download(ctx, 1)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("error = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintRejectsOversizedPackage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
size int
|
||||
}{{"original exceeds package budget", maxPackageSize}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newPrintFixture()
|
||||
f.files["clue.pdf"] = make([]byte, tc.size)
|
||||
data, _, err := f.service().Download(context.Background(), 1)
|
||||
if !errors.Is(err, ErrPackageTooLarge) || len(data) != 0 {
|
||||
t.Fatalf("data = %d bytes, error = %v", len(data), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,14 @@ func (s *TeamService) DownloadTeamQR(ctx context.Context, id int) ([]byte, strin
|
||||
if len(teams) == 0 {
|
||||
return nil, "", ErrNoCards
|
||||
}
|
||||
return s.GenerateTeamQR(ctx, game, teams)
|
||||
}
|
||||
|
||||
// GenerateTeamQR uses the provided teams so a print package shares one team list.
|
||||
func (s *TeamService) GenerateTeamQR(ctx context.Context, game *repos.Game, teams []*repos.Team) ([]byte, string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cards, filename, err := teamQRData(s.appBaseURL, game, teams)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
Reference in New Issue
Block a user