new version

This commit is contained in:
Александр Кирюхин 2023-11-19 22:54:54 +03:00
parent 3833ada27c
commit 439ba77812
17 changed files with 409 additions and 46 deletions

100
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,100 @@
FROM ubuntu
SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
# Create system wide environment as root
## Supports linux/amd64 or linux/arm64
ARG TARGETPLATFORM
## Ensure installation does not prompt for input
ARG DEBIAN_FRONTEND=noninteractive
## Install basic tools
RUN apt-get -yq update && \
apt-get -yq upgrade && \
apt-get -yq install \
git \
vim \
tmux \
curl \
wget \
build-essential \
cmake \
gcc \
shellcheck \
unzip \
tree \
software-properties-common \
jq \
gettext-base \
uuid-runtime \
postgresql-client \
sqlite3 \
pandoc \
texlive \
texlive-latex-extra \
wkhtmltopdf \
htop
## Install latest go
RUN GO_VERSION="$(git ls-remote https://github.com/golang/go | grep -oE "refs/tags/go[0-9]+\.[0-9]+(\.[0-9])?$" | sed 's|refs/tags/go||g' | sort --version-sort | tail -n 1)" && \
ARCH=$(basename "${TARGETPLATFORM}") && \
curl -fsSL "https://dl.google.com/go/go${GO_VERSION}.linux-${ARCH}.tar.gz" | tar -xz -C /usr/local
# Customize environment for nonroot user
ARG USERNAME=nonroot
ENV HOME=/home/nonroot
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd --gid "${USER_GID}" "${USERNAME}" && \
useradd --uid "${USER_UID}" --gid "${USER_GID}" --create-home "${USERNAME}" && \
apt-get update && \
apt-get -yq install sudo && \
echo "${USERNAME}" ALL=\(root\) NOPASSWD:ALL > "/etc/sudoers.d/${USERNAME}" && \
chmod 0440 "/etc/sudoers.d/${USERNAME}" && \
usermod -aG docker "${USERNAME}"
USER "${USERNAME}:${USERNAME}"
## Delete default configs
RUN rm "${HOME}/.profile" "${HOME}/.bashrc" && \
touch "${HOME}/.bashrc"
## Install latest node
RUN curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh" | PROFILE="${HOME}/.bashrc" bash && \
source "${HOME}/.bashrc" && \
nvm install node && \
nvm use node
## Install go packages
### Note: coc-go installs gopls when using vim - instead of coc-go, just install gopls so it is ready on container startup.
### Other coc-* extensions behave much better.
ENV GOROOT="/usr/local/go"
ENV GOPATH="${HOME}/go"
ENV PATH="${GOROOT}/bin:${GOPATH}/bin:${PATH}"
RUN go install golang.org/x/tools/gopls@latest && \
go install github.com/go-delve/delve/cmd/dlv@latest
## Customize tmux
RUN echo "set-option -g default-command /bin/bash" > "${HOME}/.tmux.conf" && \
echo "set-option -g mouse on" >> "${HOME}/.tmux.conf"
## Customize bashrc
RUN echo "export TERM=xterm-color" >> "${HOME}/.bashrc" && \
sudo cat /root/.bashrc >> "${HOME}/.bashrc" && \
echo "set -o vi" >> "${HOME}/.bashrc" && \
echo "alias tmux='tmux -u'" >> "${HOME}/.bashrc" && \
echo "export PATH=${PATH}" >> "${HOME}/.bashrc" && \
echo "nvm use node > /dev/null 2>&1" >> "${HOME}/.bashrc"
## Customize .profile
RUN sudo cat /root/.profile >> "${HOME}/.profile"
## Cleanup
RUN sudo apt-get clean && \
sudo rm -rf /var/lib/apt/lists/*
# Mimic VSCode's workspace
RUN mkdir -p "${HOME}/workspaces/dev-container"
WORKDIR "${HOME}/workspaces/dev-container"

View file

@ -0,0 +1,25 @@
{
"name": "Dev Dockerfile",
"dockerFile": "Dockerfile",
"settings": {
"remoteUser": "nonroot",
"files.eol": "\n",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "bash",
"icon": "terminal-bash"
}
},
"terminal.integrated.defaultProfile.linux": "bash",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[go]": {
"editor.defaultFormatter": null
}
},
"extensions": [
"golang.go",
"ms-azuretools.vscode-docker",
"esbenp.prettier-vscode"
]
}

View file

@ -1,6 +0,0 @@
on: [push]
jobs:
test:
runs-on: docker
steps:
- run: echo All Good

View file

@ -1,4 +1,4 @@
pipeline: steps:
build: build:
image: plugins/docker image: plugins/docker
settings: settings:

View file

@ -168,7 +168,13 @@ paths:
$ref: '#/components/responses/teamResponse' $ref: '#/components/responses/teamResponse'
404: 404:
$ref: '#/components/responses/errorResponse' $ref: '#/components/responses/errorResponse'
/admin/games:
get:
security:
- cookieAuth: [creator, admin]
responses:
200:
$ref: "#/components/responses/gameAdminList"
# Game routes # Game routes
/games: /games:
@ -263,6 +269,16 @@ components:
- description - description
- startAt - startAt
- teams - teams
gameAdminListItem:
type: object
properties:
id:
type: integer
title:
type: string
createdAt:
type: string
required: [ id, title, createdAt ]
requestBodies: requestBodies:
login: login:
required: true required: true
@ -371,6 +387,14 @@ components:
'application/json': 'application/json':
schema: schema:
$ref: "#/components/schemas/gameView" $ref: "#/components/schemas/gameView"
gameAdminList:
description: ''
content:
'application/json':
schema:
type: array
items:
$ref: "#/components/schemas/gameAdminListItem"
securitySchemes: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey

View file

@ -21,6 +21,9 @@ import (
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
// (GET /admin/games)
GetAdminGames(ctx echo.Context) error
// (GET /games) // (GET /games)
GetGames(ctx echo.Context) error GetGames(ctx echo.Context) error
@ -66,6 +69,17 @@ type ServerInterfaceWrapper struct {
Handler ServerInterface Handler ServerInterface
} }
// GetAdminGames converts echo context to params.
func (w *ServerInterfaceWrapper) GetAdminGames(ctx echo.Context) error {
var err error
ctx.Set(CookieAuthScopes, []string{"creator", "admin"})
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetAdminGames(ctx)
return err
}
// GetGames converts echo context to params. // GetGames converts echo context to params.
func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error { func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error {
var err error var err error
@ -279,6 +293,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
Handler: si, Handler: si,
} }
router.GET(baseURL+"/admin/games", wrapper.GetAdminGames)
router.GET(baseURL+"/games", wrapper.GetGames) router.GET(baseURL+"/games", wrapper.GetGames)
router.GET(baseURL+"/teams", wrapper.GetTeams) router.GET(baseURL+"/teams", wrapper.GetTeams)
router.POST(baseURL+"/teams", wrapper.PostTeams) router.POST(baseURL+"/teams", wrapper.PostTeams)
@ -298,24 +313,24 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"tlEaAAA=", "LJrHfOUtj4hXt8vrCdlsz7ZG3TGgH7wu1nrLMpZi19msm/8CAAD//7N+kRk/HAAA",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View file

@ -21,6 +21,13 @@ const (
Member UserTeamRole = "member" Member UserTeamRole = "member"
) )
// GameAdminListItem defines model for gameAdminListItem.
type GameAdminListItem struct {
CreatedAt string `json:"createdAt"`
Id int `json:"id"`
Title string `json:"title"`
}
// GameView defines model for gameView. // GameView defines model for gameView.
type GameView struct { type GameView struct {
Description string `json:"description"` Description string `json:"description"`
@ -77,6 +84,9 @@ type ErrorResponse struct {
Message string `json:"message"` Message string `json:"message"`
} }
// GameAdminList defines model for gameAdminList.
type GameAdminList = []GameAdminListItem
// GameListResponse defines model for gameListResponse. // GameListResponse defines model for gameListResponse.
type GameListResponse = []GameView type GameListResponse = []GameView

View file

@ -11,6 +11,9 @@ import Teams from './pages/Teams'
import { UserProvider } from './store/user' import { UserProvider } from './store/user'
import { ajax } from './utils/fetch' import { ajax } from './utils/fetch'
import TeamNew from './pages/TeamNew' import TeamNew from './pages/TeamNew'
import Admin from './pages/Admin'
import AdminGame from './pages/AdminGame'
const router = createBrowserRouter( const router = createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
<Route <Route
@ -40,6 +43,19 @@ const router = createBrowserRouter(
element={<Auth><Team /></Auth>} element={<Auth><Team /></Auth>}
loader={({ params }) => ajax(`/api/teams/${params.teamId}`)} loader={({ params }) => ajax(`/api/teams/${params.teamId}`)}
/> />
<Route
path="admin"
element={<Auth role="creator"><Admin /></Auth>}
loader={() => ajax(`/api/admin/games`)}
/>
<Route
path="admin/games/new"
element={<Auth role="creator"><AdminGame /></Auth>}
loader={() => ({
title: "Новая игра",
tasks: []
})}
/>
<Route path="*" element={<NoMatch />} /> <Route path="*" element={<NoMatch />} />
</Route> </Route>
) )

View file

@ -0,0 +1,31 @@
import { Link, useLoaderData } from "react-router-dom";
import { Button, Table } from 'react-bootstrap';
export default () => {
const games = useLoaderData();
if (!games) {
return null
}
return (<>
<h1>Управление играми <Button to="/admin/games/new" as={Link}>Создать игру</Button> </h1>
<Table>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Создана</th>
</tr>
</thead>
<tbody>
{games.map(game => (
<tr key={game.id}>
<td>{game.id}</td>
<td>{game.title}</td>
<td>{team.createdAt}</td>
</tr>
))}
</tbody>
</Table>
</>);
}

View file

@ -0,0 +1,102 @@
import { useLoaderData } from "react-router-dom";
import { Col, Row, Form, Button, Card } from 'react-bootstrap';
import { useEffect, useState } from "react";
export default () => {
const loadedGame = useLoaderData();
const [game, setGame] = useState(loadedGame);
const [error, setError] = useState(null);
if (!game) {
return null
}
const submit = (e) => {
e.preventDefault();
console.log(game)
}
return (<>
<h1>Игра "{game.title}"</h1>
<Form onSubmit={submit}>
<div className="col-lg-10 px-0">
{error ? (<div className="alert alert-danger" role="alert">{error}</div>) : null}
<Form.Group as={Row} className="mb-3" controlId="title">
<Form.Label column sm="4">Название игры</Form.Label>
<Col sm="8">
<Form.Control
name="title"
type="text"
value={game.title}
onChange={e => setGame({ ...game, title: e.target.value })}
/>
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3" controlId="startAt">
<Form.Label column sm="4">Начало в</Form.Label>
<Col sm="8">
<Form.Control
name="startAt"
type="datetime-local"
value={game.startAt}
/>
</Col>
</Form.Group>
<Form.Group as={Row} className="mb-3" controlId="description">
<Form.Label column sm="4">Описание</Form.Label>
<Col sm="8">
<Form.Control
name="description"
as="textarea"
rows={5}
value={game.description}
onChange={e => setGame({ ...game, description: e.target.value })}
/>
</Col>
</Form.Group>
<div>
<h2>
Задания
<Button onClick={() => setGame({ ...game, tasks: [...game.tasks, { title: "Задание" }] })}>
Добавить задание
</Button>
</h2>
{game.tasks.map((task, idx) =>
<Task
key={idx}
id={idx}
task={task}
setTask={task => {
const newTasks = game.tasks;
newTasks[idx] = task;
setGame({...game, tasks: newTasks});
}}
/>
)}
</div>
<Button type="submit" size="lg">Сохранить</Button>
</div>
</Form>
</>);
}
const Task = ({id, task, setTask}) => (
<Card>
<Card.Body>
<Card.Title>
Задание #{id + 1}
</Card.Title>
<Form.Group as={Row} className="mb-3" controlId={`task[${id}].text`}>
<Form.Label column sm="4">Задание</Form.Label>
<Col sm="8">
<Form.Control
name={`task[${id}].text`}
as="textarea"
rows={5}
value={task.text}
onChange={e => setTask({ ...task, text: e.target.value })}
/>
</Col>
</Form.Group>
</Card.Body>
</Card>
)

View file

@ -19,6 +19,6 @@ export const useRole = () => {
const { user } = UserProvider.useContainer(); const { user } = UserProvider.useContainer();
return { return {
hasRole: (role) => user && !!roleHierarchy[user.role][role] hasRole: (role) => user && user.role && !!roleHierarchy[user.role][role]
} }
} }

View file

@ -136,6 +136,9 @@ func main() {
Engine: &controller.Engine{ Engine: &controller.Engine{
GameService: gameService, GameService: gameService,
}, },
Admin: &controller.Admin{
GameService: gameService,
},
} }
codegen := e.Group("") codegen := e.Group("")
@ -167,4 +170,5 @@ type serverRouter struct {
*controller.User *controller.User
*controller.Team *controller.Team
*controller.Engine *controller.Engine
*controller.Admin
} }

32
pkg/controller/admin.go Normal file
View file

@ -0,0 +1,32 @@
package controller
import (
"net/http"
"github.com/labstack/echo/v4"
"gitrepo.ru/neonxp/nquest/api"
"gitrepo.ru/neonxp/nquest/pkg/contextlib"
"gitrepo.ru/neonxp/nquest/pkg/service"
)
type Admin struct {
GameService *service.Game
}
func (a *Admin) GetAdminGames(ctx echo.Context) error {
user := contextlib.GetUser(ctx)
games, err := a.GameService.ListByAuthor(ctx.Request().Context(), user)
if err != nil {
return err
}
result := make(api.GameAdminList, 0, len(games))
for _, g := range games {
result = append(result, api.GameAdminListItem{
Id: int(g.ID),
Title: g.Title,
CreatedAt: g.CreatedAt.Format("02.01.06 15:04"),
})
}
return ctx.JSON(http.StatusOK, result)
}

View file

@ -43,7 +43,7 @@ func (g *Game) GetGames(ctx echo.Context) error {
Id: int(game.ID), Id: int(game.ID),
Title: game.Title, Title: game.Title,
Description: game.Description, Description: game.Description,
StartAt: game.StartAt.Format("02.01.06"), StartAt: game.StartAt.Format("02.01.06 15:04"),
Teams: teams, Teams: teams,
}) })
} }

View file

@ -42,12 +42,7 @@ func (u *User) PostUserLogin(c echo.Context) error {
} }
} }
return c.JSON(http.StatusOK, &api.UserResponse{ return mapUser(c, user)
Id: int(user.ID),
Username: user.Username,
Email: user.Email,
Team: api.MapUserTeam(user.Team),
})
} }
func (u *User) PostUserRegister(c echo.Context) error { func (u *User) PostUserRegister(c echo.Context) error {
@ -77,12 +72,7 @@ func (u *User) PostUserRegister(c echo.Context) error {
}) })
} }
return c.JSON(http.StatusOK, &api.UserResponse{ return mapUser(c, user)
Id: int(user.ID),
Username: user.Username,
Email: user.Email,
Team: api.MapUserTeam(user.Team),
})
} }
func (u *User) PostUserLogout(c echo.Context) error { func (u *User) PostUserLogout(c echo.Context) error {
@ -102,6 +92,10 @@ func (u *User) GetUser(c echo.Context) error {
}) })
} }
return mapUser(c, user)
}
func mapUser(c echo.Context, user *models.User) error {
return c.JSON(http.StatusOK, &api.UserResponse{ return c.JSON(http.StatusOK, &api.UserResponse{
Id: int(user.ID), Id: int(user.ID),
Username: user.Username, Username: user.Username,

View file

@ -15,6 +15,7 @@ type Game struct {
Tasks []*Task `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` Tasks []*Task `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
FirstTask *Task `gorm:"foreignKey:ID"` FirstTask *Task `gorm:"foreignKey:ID"`
FirstTaskID uint FirstTaskID uint
Authors []*User `gorm:"many2many:game_authors"`
} }
type TeamAtGame struct { type TeamAtGame struct {

View file

@ -42,7 +42,7 @@ func (gs *Game) List(ctx context.Context) ([]*models.Game, error) {
WithContext(ctx). WithContext(ctx).
Preload("Teams"). Preload("Teams").
Preload("Teams.Team"). Preload("Teams.Team").
Order("start_at ASC"). Order("start_at DESC").
Find(&games, "visible = true"). Find(&games, "visible = true").
Limit(20). Limit(20).
Error Error
@ -129,3 +129,18 @@ func (gs *Game) GetState(ctx context.Context, game *models.Game, team *models.Te
return gamepass, nil return gamepass, nil
} }
func (gs *Game) ListByAuthor(ctx context.Context, author *models.User) ([]*models.Game, error) {
games := make([]*models.Game, 0)
return games, gs.DB.
WithContext(ctx).
Model(&models.Game{}).
Preload("Teams").
Preload("Teams.Team").
Preload("Authors", gs.DB.Where("id = ?", author.ID)).
Order("created_at DESC").
Find(&games).
Limit(20).
Error
}