Added stable version for stable branch

This commit is contained in:
Alexander Karpov 2022-07-02 14:24:06 +03:00
parent ba0c8499e0
commit b739665d48
40 changed files with 1712 additions and 0 deletions

133
.gitignore vendored Normal file
View File

@ -0,0 +1,133 @@
.idea
static/
media/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

37
README.md Normal file
View File

@ -0,0 +1,37 @@
# chess_rpg_backend
Backend for chess rpg game
### installation
```shell
$ python3 manage.py makemigrations & python3 manage.py migrate
$ docker run -p 6379:6379 -d redis:5
```
### run
```shell
$ python3 manage.py runserver 0.0.0.0:8000
```
### Описание команд сокетов
```python
# подключиние к очереди(ws://room/)
{
"type": "connect",
"deck_id": int
}
# коннект к комнате (сообщение от сервера)
{
"type": "INFO",
"opponent_score": int,
"coordinates" : [(x: int, y: int, type: str, model_url: url, your: bool), ...],
"opponent_online": true,
"first": bool
}
# состояние оппонента в комнате(сообщение от сервера)
{
"type": "INFO",
"message": "opponent is online" / "opponent is offline"
}
```

View File

18
chess_backend/asgi.py Normal file
View File

@ -0,0 +1,18 @@
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
import room.routing
from room.middleware import HeaderAuthMiddleware
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chess_backend.settings")
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": HeaderAuthMiddleware(
URLRouter(room.routing.websocket_urlpatterns)
),
}
)

104
chess_backend/settings.py Normal file
View File

@ -0,0 +1,104 @@
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-%_8sy196w4hzo9^cp9(@r=i+amh47r4mxfhq_(ok&=c(@%bhmk"
TOKEN_EXP = 2678400 # 31 day
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.messages",
# Packages
"rest_framework",
"channels",
# Apps
"game",
"room",
]
if DEBUG:
INSTALLED_APPS.append("django.contrib.staticfiles")
INSTALLED_APPS.append("drf_yasg")
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "chess_backend.urls"
ASGI_APPLICATION = "chess_backend.asgi.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
},
},
}
WSGI_APPLICATION = "chess_backend.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
LANGUAGES = [
("en-us", "English"),
("ru", "Russian"),
]
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
USE_TZ = True
MEDIA_URL = "/media/"
STATIC_URL = "/static/"
if DEBUG:
MEDIA_ROOT = os.path.join(BASE_DIR, "media/")
STATIC_ROOT = os.path.join(BASE_DIR, "static/")
else:
MEDIA_ROOT = "/var/www/media/"
STATIC_ROOT = "/var/www/static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": "debug.log",
},
},
"loggers": {
"django": {
"handlers": ["file"],
"level": "DEBUG",
"propagate": True,
},
},
}

46
chess_backend/urls.py Normal file
View File

@ -0,0 +1,46 @@
from django.conf import settings
from django.conf.urls.static import static
from django.template.defaulttags import url
from django.urls import path, include, re_path
# openapi schema
from rest_framework import permissions
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
schema_view = get_schema_view(
openapi.Info(
title="Snippets API",
default_version="v1",
description="Test description",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="contact@snippets.local"),
license=openapi.License(name="BSD License"),
),
public=True,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [path("api/", include("game.urls"))] + static(
settings.MEDIA_URL, document_root=settings.MEDIA_ROOT
)
if settings.DEBUG:
urlpatterns += [
re_path(
r"^swagger(?P<format>\.json|\.yaml)$",
schema_view.without_ui(cache_timeout=0),
name="schema-json",
),
re_path(
r"^swagger/$",
schema_view.with_ui("swagger", cache_timeout=0),
name="schema-swagger-ui",
),
re_path(
r"^redoc/$",
schema_view.with_ui("redoc", cache_timeout=0),
name="schema-redoc",
),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

16
chess_backend/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for chess_backend project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chess_backend.settings')
application = get_wsgi_application()

0
common/__init__.py Normal file
View File

14
common/generators.py Normal file
View File

@ -0,0 +1,14 @@
import string
import secrets
from random import randint
def generate_charset(length: int):
return "".join(
secrets.choice(string.digits + string.ascii_letters) for _ in range(length)
)
def gen_ton():
return int("".join([str(randint(0, 9)) for _ in range(48)]))

22
common/tests.py Normal file
View File

@ -0,0 +1,22 @@
from rest_framework.test import APITestCase
from django.urls import reverse
from rest_framework import status
class GetBasicTest(APITestCase):
def __init__(self, url):
super().__init__()
self._url = reverse(url)
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = reverse(value)
def test_accessibility(self):
"""Test if POST request is possible"""
response = self.client.post(self._url)
self.assertNotEqual(response, status.HTTP_404_NOT_FOUND)

0
game/__init__.py Normal file
View File

6
game/admin.py Normal file
View File

@ -0,0 +1,6 @@
from django.contrib import admin
# Register your models here.
from game.models import HeroImageSet
admin.site.register(HeroImageSet)

0
game/api/__init__.py Normal file
View File

0
game/api/v1/__init__.py Normal file
View File

139
game/api/v1/serializers.py Normal file
View File

@ -0,0 +1,139 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from game.models import Hero, Player, HeroInDeck, Deck, PlayerAuthSession
from game.services.jwt import read_jwt
class CreateHeroSerializer(serializers.ModelSerializer):
class Meta:
model = Hero
fields = (
"type",
"health",
"attack",
"speed",
)
class GetHeroSerializer(serializers.ModelSerializer):
class Meta:
model = Hero
fields = (
"added",
"type",
"idle_img",
"attack_img",
"die_img",
"health",
"attack",
"speed",
)
class ListHeroSerializer(serializers.ModelSerializer):
class Meta:
model = Hero
fields = (
"uuid",
"type",
"idle_img",
"attack_img",
"die_img",
"health",
"attack",
"speed",
)
class CreatePlayerSerializer(serializers.ModelSerializer):
class Meta:
model = Player
fields = ("ton_wallet", "name")
class CreateDeckSerializer(serializers.ModelSerializer):
hero_ids = serializers.ListSerializer(
child=serializers.UUIDField(), min_length=16, max_length=16
)
class Meta:
model = Deck
fields = ("hero_ids",)
def validate_hero_ids(self, value):
if len(set(value)) != 16:
raise ValidationError("Some of the uuids are not unique")
for x in value:
if not (hero := Hero.objects.filter(uuid=x)):
raise ValidationError(f"Hero with uuid {x} doesn't exist")
if hero.first().player.id != self.context["request"].user.id:
raise ValidationError(
f"Attempt to manipulate player with id {hero.first().player.id} hero"
)
if self.context["request"].method in ["POST"]:
if deck := HeroInDeck.objects.filter(hero=hero.first()):
raise ValidationError(
f"Hero with uuid {x} is already in deck with id {deck.first().deck.id}"
)
return value
def create(self, validated_data):
deck = Deck.objects.create(player=self.context["request"].user)
for x in validated_data["hero_ids"]:
HeroInDeck.objects.create(hero_id=x, deck=deck)
return deck
def update(self, instance, validated_data):
for x in instance.get_heroes():
HeroInDeck.objects.get(hero=x).delete()
for x in validated_data["hero_ids"]:
HeroInDeck.objects.create(hero_id=x, deck=instance)
return instance
class GetPlayerSerializer(serializers.ModelSerializer):
class Meta:
model = Player
fields = ("id", "name")
class GetDeckSerializer(serializers.ModelSerializer):
player = GetPlayerSerializer()
heroes = ListHeroSerializer(many=True)
class Meta:
model = Deck
fields = ("player", "heroes")
class ObtainTokenPairSerializer(serializers.Serializer):
refresh_token = serializers.CharField(max_length=300)
def __init__(self, instance=None, data=None, **kwargs):
super().__init__(instance, data, **kwargs)
self.player_id = None
def validate_refresh_token(self, value):
payload = read_jwt(value)
if not payload:
raise ValidationError("Token is incorrect or expired")
if "jit" not in payload:
raise ValidationError("Token is incorrect")
jit = payload["jit"]
try:
session = PlayerAuthSession.objects.get(jit=jit)
except PlayerAuthSession.DoesNotExist:
return ValidationError("Incorrect user session")
self.player_id = session.player.id
return value

169
game/api/v1/views.py Normal file
View File

@ -0,0 +1,169 @@
from rest_framework import status
from rest_framework.generics import GenericAPIView, UpdateAPIView
from rest_framework.mixins import (
CreateModelMixin,
RetrieveModelMixin,
ListModelMixin,
DestroyModelMixin,
UpdateModelMixin,
)
from rest_framework.response import Response
from game.authentication import PlayerAuthentication
from game.models import Hero, Deck
from game.api.v1.serializers import (
CreateHeroSerializer,
GetHeroSerializer,
CreatePlayerSerializer,
ListHeroSerializer,
CreateDeckSerializer,
GetDeckSerializer,
ObtainTokenPairSerializer,
)
from game.services.jwt import sign_jwt
class ListCreateHeroView(GenericAPIView, CreateModelMixin, ListModelMixin):
authentication_classes = (PlayerAuthentication,)
def perform_create(self, serializer):
return serializer.save()
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
return Response({"uuid": instance.uuid}, status=status.HTTP_201_CREATED)
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def get_serializer_class(self):
if self.request.method == "GET":
return ListHeroSerializer
else:
return CreateHeroSerializer
def get_queryset(self):
return Hero.objects.filter(player_id=self.request.user.id)
class RetrieveHeroView(RetrieveModelMixin, UpdateAPIView, GenericAPIView):
serializer_class = GetHeroSerializer
lookup_field = "uuid"
queryset = Hero.objects.all()
def get_authenticators(self):
if self.request.method != "GET":
self.authentication_classes = [PlayerAuthentication]
return [auth() for auth in self.authentication_classes]
def get(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
def put(self, request, *args, **kwargs):
return self.update(request, *args, **kwargs)
def patch(self, request, *args, **kwargs):
return self.partial_update(request, *args, **kwargs)
class PlayerCreateView(GenericAPIView, CreateModelMixin):
serializer_class = CreatePlayerSerializer
def perform_create(self, serializer):
return serializer.save()
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
access_jwt = instance.get_access_token()
refresh_jwt = instance.get_refresh_token()
return Response(
{
"access_token": access_jwt,
"refresh_token": refresh_jwt,
"deck_id": instance.get_last_deck().id,
},
status=status.HTTP_201_CREATED,
)
class DeckCreateView(GenericAPIView, CreateModelMixin):
serializer_class = CreateDeckSerializer
authentication_classes = (PlayerAuthentication,)
def perform_create(self, serializer):
return serializer.save(player=self.request.user)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
heroes_list = ListHeroSerializer(instance.get_heroes(), many=True)
heroes_list.data["deck_id"] = instance.id
return Response(heroes_list.data, status=status.HTTP_201_CREATED)
class RetireUpdateDeleteDeckView(
RetrieveHeroView, DestroyModelMixin, UpdateModelMixin, GenericAPIView
):
lookup_field = "id"
queryset = Deck.objects.all()
def get_serializer_class(self):
if self.request.method == "GET":
return GetDeckSerializer
else:
return CreateDeckSerializer
def get(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
def perform_update(self, serializer):
return serializer.update(self.get_object(), self.request.data)
def put(self, request, *args, **kwargs):
if not self._check_user_identity(kwargs["id"]):
return Response(
"Attempt to change another user's deck",
status=status.HTTP_403_FORBIDDEN,
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_update(serializer)
heroes_list = ListHeroSerializer(instance.get_heroes(), many=True)
return Response(heroes_list.data, status=status.HTTP_200_OK)
def delete(self, request, *args, **kwargs):
if not self._check_user_identity(kwargs["id"]):
return Response(
"Attempt to delete another user's deck",
status=status.HTTP_403_FORBIDDEN,
)
self.destroy(request, *args, **kwargs)
return Response(
f"Destroyed deck with id {kwargs['id']}", status=status.HTTP_200_OK
)
def _check_user_identity(self, deck_id) -> bool:
return deck_id in list(
Deck.objects.filter(player_id=self.request.user.id).values_list(
"id", flat=True
)
)
class RefreshAuthKey(GenericAPIView):
serializer_class = ObtainTokenPairSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
access_jwt = sign_jwt(
{"id": serializer.player_id, "type": "access"}, t_life=3600
)
return Response({"access_token": access_jwt}, status=status.HTTP_200_OK)

6
game/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class GameConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'game'

35
game/authentication.py Normal file
View File

@ -0,0 +1,35 @@
from jwt import DecodeError
from rest_framework import authentication
from rest_framework import exceptions
from .models import Player
from .services.jwt import read_jwt
class PlayerAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
if "Authorization" not in request.headers or not (
token := request.headers["Authorization"]
):
raise exceptions.AuthenticationFailed("No credentials provided.")
try:
t = read_jwt(token)
except DecodeError:
raise exceptions.AuthenticationFailed("Token is incorrect")
if not t:
raise exceptions.AuthenticationFailed("Token is incorrect of expired")
if "id" not in t and "type" not in t:
raise exceptions.AuthenticationFailed("No user data")
if t["type"] != "access":
raise exceptions.AuthenticationFailed("Incorrect token type")
try:
user = Player.objects.get(id=int(t["id"]))
except Player.DoesNotExist:
raise exceptions.AuthenticationFailed("No such user")
return user, None

View File

@ -0,0 +1,57 @@
# Generated by Django 4.0.5 on 2022-06-04 14:16
import django.core.validators
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Hero',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('added', models.DateTimeField(auto_now_add=True)),
('type', models.CharField(choices=[('WIZARD', 'wizard'), ('ARCHER', 'archer'), ('WARRIOR', 'warrior')], max_length=7)),
('idle_img', models.ImageField(upload_to='uploads/idle')),
('attack_img', models.ImageField(upload_to='uploads/attack')),
('die_img', models.ImageField(upload_to='uploads/die')),
('health', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
('speed', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
],
options={
'verbose_name': 'hero',
'verbose_name_plural': 'heroes',
'ordering': ['-added'],
},
),
migrations.CreateModel(
name='Player',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ton_wallet', models.CharField(max_length=50, verbose_name='TON wallet')),
('name', models.CharField(blank=True, max_length=100)),
('added', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'player',
'verbose_name_plural': 'players',
'ordering': ['-added'],
},
),
migrations.AddIndex(
model_name='player',
index=models.Index(fields=['ton_wallet'], name='game_player_ton_wal_47dd93_idx'),
),
migrations.AddIndex(
model_name='hero',
index=models.Index(fields=['uuid'], name='game_hero_uuid_ada5d9_idx'),
),
]

View File

216
game/models.py Normal file
View File

@ -0,0 +1,216 @@
import random
import uuid
from django.core.validators import (
MinValueValidator,
MaxValueValidator,
MinLengthValidator,
MaxLengthValidator,
)
from django.db import models
from common.generators import generate_charset
from game.services.jwt import sign_jwt
class HeroTypes(models.TextChoices):
wizard = "WIZARD", "wizard"
archer = "ARCHER", "archer"
warrior = "WARRIOR", "warrior"
king = "KING", "king"
class Player(models.Model):
"""base model to handle and store users"""
ton_wallet = models.CharField(
verbose_name="TON wallet",
validators=[MinLengthValidator(48), MaxLengthValidator(48)],
max_length=48,
unique=True,
)
name = models.CharField(max_length=100, blank=True)
created = models.DateTimeField(auto_now_add=True)
def save(
self, force_insert=False, force_update=False, using=None, update_fields=None
):
"""saves user and creates deck for him with 16 heroes"""
super(Player, self).save()
PlayerAuthSession.objects.create(player=self)
deck = Deck.objects.create(player=self)
types = (
["KING"]
+ ["ARCHER" for _ in range(4)]
+ ["WARRIOR" for _ in range(6)]
+ ["WIZARD" for _ in range(2)]
+ [random.choice(HeroTypes.choices[:3])[0] for _ in range(3)]
)
for t in types:
hero = Hero()
hero.player = self
hero.type = t
hero.health = random.randint(0, 10)
hero.attack = random.randint(0, 10)
hero.speed = random.randint(0, 10)
hero.save()
HeroInDeck.objects.create(deck=deck, hero=hero)
def get_last_deck(self):
return Deck.objects.filter(player=self).last()
def get_auth_session(self):
return PlayerAuthSession.objects.get(player=self).jit
def get_refresh_token(self):
return sign_jwt({"jit": self.get_auth_session(), "type": "refresh"})
def get_access_token(self):
return sign_jwt({"id": self.id, "type": "access"}, t_life=3600)
def __str__(self):
return self.name
class Meta:
indexes = [models.Index(fields=["ton_wallet"])]
ordering = ["-created"]
db_table = "player"
verbose_name = "player"
verbose_name_plural = "players"
class Hero(models.Model):
"""Model to store heroes and their stats, connected to player"""
uuid = models.UUIDField(
default=uuid.uuid4, editable=False, unique=True, primary_key=True
)
player = models.ForeignKey(
Player,
on_delete=models.CASCADE,
related_name="heroes",
related_query_name="hero",
)
added = models.DateTimeField(auto_now_add=True)
type = models.CharField(blank=False, choices=HeroTypes.choices, max_length=7)
model = models.ForeignKey("HeroModelSet", on_delete=models.CASCADE)
health = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)], blank=False
)
attack = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)], blank=False
)
speed = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)], blank=False
)
def idle_img(self):
return self.idle_img_f.image.url
def attack_img(self):
return self.attack_img_f.image.url
def die_img(self):
return self.die_img_f.image.url
def __str__(self):
return f"{self.type} {self.player.name}"
def save(
self, force_insert=False, force_update=False, using=None, update_fields=None
):
self.idle_img_f = random.choice(
[x for x in HeroModelSet.objects.filter(hero_type=self.type)]
)
self.attack_img_f = random.choice(
[x for x in HeroModelSet.objects.filter(hero_type=self.type)]
)
self.die_img_f = random.choice(
[x for x in HeroModelSet.objects.filter(hero_type=self.type)]
)
super(Hero, self).save()
class Meta:
indexes = [models.Index(fields=["uuid"])]
ordering = ["-added"]
db_table = "hero"
verbose_name = "hero"
verbose_name_plural = "heroes"
class HeroModelSet(models.Model):
hero_type = models.CharField(blank=False, choices=HeroTypes.choices, max_length=7)
model = models.ImageField(upload_to="uploads/")
def __str__(self):
return f"{self.hero_type} model file"
class Deck(models.Model):
player = models.ForeignKey(
Player,
on_delete=models.CASCADE,
related_name="decks",
related_query_name="deck",
)
def __str__(self):
return f"{self.player.name}'s deck"
def get_heroes(self):
return [x.hero for x in HeroInDeck.objects.filter(deck=self)]
def heroes(self):
# added for better DRF view
return self.get_heroes()
def score(self):
return sum([x.attack + x.health + x.speed for x in self.get_heroes()])
class Meta:
db_table = "deck"
verbose_name = "deck"
verbose_name_plural = "decks"
class HeroInDeck(models.Model):
deck = models.ForeignKey(
Deck,
on_delete=models.CASCADE,
related_name="hero_in_deck",
related_query_name="heroes",
)
hero = models.OneToOneField(
Hero,
on_delete=models.CASCADE,
related_name="hero_in_deck",
related_query_name="decks",
)
class Meta:
db_table = "hero_in_deck"
verbose_name = "Hero in deck"
verbose_name_plural = "Heroes in decks"
class PlayerAuthSession(models.Model):
player = models.OneToOneField(
Player, unique_for_month=True, on_delete=models.CASCADE
)
jit = models.CharField(max_length=30)
def save(
self, force_insert=False, force_update=False, using=None, update_fields=None
):
self.jit = generate_charset(30)
super(PlayerAuthSession, self).save(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
)

0
game/permissions.py Normal file
View File

View File

45
game/services/jwt.py Normal file
View File

@ -0,0 +1,45 @@
import jwt
import pytz
from datetime import datetime
from django.conf import settings
from jwt import ExpiredSignatureError, InvalidSignatureError
TIMEZONE = pytz.timezone("Europe/Moscow")
def sign_jwt(data: dict, t_life: None | int = None) -> str:
"""generate and sign jwt with iat and exp using data from settings"""
iat = int(datetime.now(tz=TIMEZONE).timestamp())
exp = iat + settings.TOKEN_EXP if not t_life else iat + t_life
payload = {"iat": iat, "exp": exp}
for nm, el in data.items():
if nm not in ["iat", "exp"]:
payload[nm] = el
secret = settings.SECRET_KEY
token = jwt.encode(payload=payload, key=secret)
return token
def read_jwt(token: str) -> dict | bool:
"""reads jwt, validates it and return payload if correct"""
header_data = jwt.get_unverified_header(token)
secret = settings.SECRET_KEY
try:
payload = jwt.decode(token, key=secret, algorithms=[header_data["alg"]])
except ExpiredSignatureError as e:
return False
except InvalidSignatureError as e:
return False
if "exp" not in payload:
return False
if int(datetime.now(tz=TIMEZONE).timestamp()) > payload["exp"]:
return False
payload.pop("iat", None)
payload.pop("exp", None)
return payload

0
game/tests.py Normal file
View File

21
game/urls.py Normal file
View File

@ -0,0 +1,21 @@
from django.urls import path
from game.api.v1.views import (
ListCreateHeroView,
RetrieveHeroView,
PlayerCreateView,
DeckCreateView,
RetireUpdateDeleteDeckView,
RefreshAuthKey,
)
urlpatterns = [
path("v1/hero/", ListCreateHeroView.as_view(), name="hero_api_create"),
path("v1/hero/<uuid:uuid>", RetrieveHeroView.as_view(), name="hero_api_retrieve"),
path("v1/player/refresh", RefreshAuthKey.as_view(), name="player_create_api"),
path("v1/player/", PlayerCreateView.as_view(), name="player_create_api"),
path("v1/deck/", DeckCreateView.as_view(), name="deck_create_api"),
path(
"v1/deck/<int:id>", RetireUpdateDeleteDeckView.as_view(), name="deck_retire_api"
),
]

22
manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chess_backend.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

46
requirements.txt Normal file
View File

@ -0,0 +1,46 @@
asgiref==3.5.2
asttokens==2.0.5
attrs==21.4.0
autobahn==22.5.1
Automat==20.2.0
backcall==0.2.0
cffi==1.15.0
channels==3.0.4
constantly==15.1.0
cryptography==37.0.2
daphne==3.0.2
decorator==5.1.1
Django==4.0.5
djangorestframework==3.13.1
executing==0.8.3
hyperlink==21.0.0
idna==3.3
incremental==21.3.0
ipython==8.4.0
jedi==0.18.1
matplotlib-inline==0.1.3
parso==0.8.3
pexpect==4.8.0
pickleshare==0.7.5
Pillow==9.1.1
prompt-toolkit==3.0.29
ptyprocess==0.7.0
pure-eval==0.2.2
pyasn1==0.4.8
pyasn1-modules==0.2.8
pycparser==2.21
Pygments==2.12.0
PyJWT==2.4.0
pyOpenSSL==22.0.0
pytz==2022.1
service-identity==21.1.0
six==1.16.0
sqlparse==0.4.2
stack-data==0.2.0
traitlets==5.2.2.post1
Twisted==22.4.0
txaio==22.2.1
typing_extensions==4.2.0
wcwidth==0.2.5
zope.interface==5.4.0
channels_redis

0
room/__init__.py Normal file
View File

6
room/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class RoomConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'room'

397
room/consumers.py Normal file
View File

@ -0,0 +1,397 @@
import json
from asgiref.sync import sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.layers import get_channel_layer
from game.models import Deck
from room.models import PlayerInQueue, Room, PlayerInRoom, GameState
from room.services.room_create import create_room
channel_layer = get_channel_layer()
class QueueConsumer(AsyncWebsocketConsumer):
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.room_group_name = None
async def connect(self):
self.room_group_name = "queue"
await self.accept()
await self.check_origin()
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
async def disconnect(self, close_code):
await self.delete_user_in_queue()
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
# Receive message from WebSocket
async def receive(self, text_data):
data = None
try:
data = json.loads(text_data)
except ValueError:
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "data is not JSON serializable"}
)
)
if data:
# TODO move to external function/class
if "type" not in data:
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "incorrect data typing"}
)
)
else:
if data["type"] == "connect":
if "deck_id" not in data:
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "deck id is not provided"}
)
)
else:
deck = None
# validate deck and check user originality
try:
deck_id = int(data["deck_id"])
deck = await self.check_user_deck(deck_id)
except ValueError:
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "deck id is incorrect"}
)
)
if deck:
# add to que, start finding players
await self.queue_connector(deck)
await self.send(
text_data=json.dumps(
{
"type": "INFO",
"message": f"added to queue deck with score {self.scope['score']}",
}
)
)
opponent = await self.find_user_by_score()
if not opponent:
await self.send(
text_data=json.dumps(
{
"type": "INFO",
"message": "no user found, awaiting in queue",
}
)
)
else:
# add to group and send message that opponent found to players
room = await create_room(
deck_id_1=self.scope["deck"],
player_id_1=self.scope["player"],
player_score_1=self.scope["score"],
deck_id_2=opponent[2],
player_id_2=opponent[3],
player_score_2=opponent[1],
)
await self.channel_layer.send(
opponent[0],
{
"type": "info",
"message": f"user found, with score {self.scope['score']}",
"room": room,
},
)
await self.send(
text_data=json.dumps(
{
"type": "INFO",
"message": f"user found, with score {opponent[1]}",
"room": room,
}
)
)
else:
await self.send(
text_data=json.dumps(
{
"type": "ERROR",
"message": "such deck doesn't exist",
}
)
)
@sync_to_async
def delete_user_in_queue(self):
try:
PlayerInQueue.objects.get(player_id=self.scope["player"]).delete()
except PlayerInQueue.DoesNotExist:
return False
@sync_to_async
def find_user_by_score(self):
s_min = self.scope["score"] * 0.95
s_max = self.scope["score"] * 1.05
for el in PlayerInQueue.objects.all():
if el.player_id != self.scope["player"]:
if s_min <= el.score <= s_max:
return el.channel_name, el.score, el.deck.id, el.player.id
return False
@sync_to_async
def check_user_deck(self, deck_id: int):
try:
deck = Deck.objects.get(id=deck_id)
if deck.player.id != self.scope["player"]:
return False
return deck
except Deck.DoesNotExist:
return False
@sync_to_async
def queue_connector(self, deck):
try:
queue = PlayerInQueue.objects.get(player_id=self.scope["player"])
queue.score = deck.score()
queue.channel_name = self.channel_name
queue.save()
except PlayerInQueue.DoesNotExist:
queue = PlayerInQueue.objects.create(
player_id=self.scope["player"],
deck=deck,
score=deck.score(),
channel_name=self.channel_name,
)
self.scope["queue"] = queue.id
self.scope["deck"] = deck.id
self.scope["score"] = queue.score
async def info(self, event):
message = event["message"]
msg = {"type": "INFO", "message": message}
if "room" in event:
msg["room"] = event["room"]
await self.send(text_data=json.dumps(msg))
async def check_origin(self):
if not self.scope["player"]:
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "token is incorrect or expired"}
)
)
await self.close()
class RoomConsumer(AsyncWebsocketConsumer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.room_group_name = None
self.room_name = None
async def connect(self):
await self.accept()
await self.check_origin()
if not await self.connect_to_room():
await self.close()
else:
message, round = await self.get_state()
await self.send(
json.dumps(
{
"type": "INFO",
"opponent_score": self.scope["opponent_score"],
"opponent_deck": self.scope["opponent_deck"],
"opponent_online": self.scope["opponent_online"],
"first": self.scope["first"],
"state": message,
"round": round,
},
)
)
if "opponent_channel" in self.scope and self.scope["opponent_channel"]:
await self.channel_layer.send(
self.scope["opponent_channel"],
{
"type": "channel",
"channel": self.channel_name,
},
)
await self.channel_layer.send(
self.scope["opponent_channel"],
{"type": "connection_info", "online": True},
)
# Join room group
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
@sync_to_async
def get_state(self):
state = self.scope["player_in_room"].get_state()
return state.message, state.round
@sync_to_async
def connect_to_room(self):
slug = self.scope["url_route"]["kwargs"]["room_name"]
self.room_name = slug
self.room_group_name = f"room_{slug}"
room = Room.objects.filter(slug=slug)
if not room:
return False
self.scope["room"] = room
# check if player can be in a room
p_ids = [x.player.id for x in room.first().players.all()]
if self.scope["player"] not in p_ids:
return False
# add player info to scope
player = PlayerInRoom.objects.get(player_id=self.scope["player"])
self.scope["player_in_room"] = player
self.scope["first"] = player.first
self.scope["score"] = player.score
self.scope["deck"] = player.deck.id
p_ids.remove(player.player.id)
opponent = PlayerInRoom.objects.get(player_id=p_ids[0])
self.scope["opponent"] = opponent.player.id
self.scope["opponent_channel"] = opponent.channel_name
self.scope["opponent_score"] = opponent.score
self.scope["opponent_deck"] = opponent.deck.id
self.scope["opponent_first"] = opponent.first
self.scope["opponent_online"] = opponent.online
player.online = True
player.channel_name = self.channel_name
player.save(update_fields=["online", "channel_name"])
return True
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
await self.disconnect_player()
if "opponent_channel" in self.scope and self.scope["opponent_channel"]:
await self.channel_layer.send(
self.scope["opponent_channel"],
{"type": "connection_info", "online": False},
)
@sync_to_async
def disconnect_player(self):
if "player_in_room" in self.scope:
self.scope["player_in_room"].online = False
self.scope["player_in_room"].channel_name = None
self.scope["player_in_room"].save(update_fields=["online", "channel_name"])
# Receive message from WebSocket
async def receive(self, text_data):
data = None
try:
data = json.loads(text_data)
except ValueError:
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "data is not JSON serializable"}
)
)
if data:
if data["type"] == "start":
if not await self.start(data):
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "opponent is offline"}
)
)
async def start(self, data):
if self.scope["opponent_channel"] and self.scope["opponent_online"]:
await self.channel_layer.send(
self.scope["opponent_channel"],
{
"type": "info",
"message": "opponent is ready to start",
},
)
return True
return False
# info type group message handler
async def info(self, event):
message = event["message"]
msg = {"type": "INFO", "message": message}
if "opponent_score" in event:
msg["opponent_score"] = event["opponent_score"]
if "opponent_deck" in event:
msg["opponent_deck"] = event["opponent_deck"]
if "opponent_online" in event:
msg["opponent_online"] = event["opponent_online"]
if "first" in event:
msg["first"] = event["first"]
if "state" in event:
msg["state"] = event["state"]
if "round" in event:
msg["round"] = event["round"]
await self.send(text_data=json.dumps(msg))
# Receive message from room group
async def chat_message(self, event):
message = event["message"]
# Send message to WebSocket
await self.send(text_data=json.dumps({"lot": message}))
async def channel(self, event):
channel = event["channel"]
self.scope["opponent_channel"] = channel
async def connection_info(self, event):
status = event["online"]
await self.send(
text_data=json.dumps(
{
"type": "INFO",
"message": "opponent is online"
if status
else "opponent is offline",
}
)
)
self.scope["opponent_online"] = status
async def check_origin(self):
if not self.scope["player"]:
await self.send(
text_data=json.dumps(
{"type": "ERROR", "message": "token is incorrect or expired"}
)
)
await self.close()

33
room/middleware.py Normal file
View File

@ -0,0 +1,33 @@
from channels.db import database_sync_to_async
from django.core.exceptions import PermissionDenied
from game.models import Player
from game.services.jwt import read_jwt
@database_sync_to_async
def get_player(headers):
# WARNING headers type is bytes
if b"authorization" not in headers or not headers[b"authorization"]:
return False
jwt = headers[b"authorization"].decode()
payload = read_jwt(jwt)
if not payload or "id" not in payload:
return False
return payload["id"]
class HeaderAuthMiddleware:
"""Custom middleware to read user auth token from string."""
def __init__(self, app):
# Store the ASGI application we were passed
self.app = app
async def __call__(self, scope, receive, send):
scope["player"] = await get_player(dict(scope["headers"]))
return await self.app(scope, receive, send)

View File

@ -0,0 +1,15 @@
# Generated by Django 4.0.5 on 2022-06-23 22:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
]

View File

50
room/models.py Normal file
View File

@ -0,0 +1,50 @@
from django.db import models
# Create your models here.
from game.models import Player, Deck
class PlayerInQueue(models.Model):
# TODO use redis for storing
player = models.OneToOneField(Player, unique=True, on_delete=models.CASCADE)
channel_name = models.CharField(max_length=50, blank=False)
deck = models.ForeignKey(Deck, on_delete=models.CASCADE)
score = models.IntegerField()
def __str__(self):
return f"{self.player.name} in que with score {self.score}"
class Room(models.Model):
slug = models.SlugField(max_length=16, unique=True)
created = models.DateTimeField(auto_now_add=True)
ended = models.BooleanField(default=False)
def __str__(self):
return f"room with slug {self.slug}"
class PlayerInRoom(models.Model):
player = models.OneToOneField(Player, unique=True, on_delete=models.CASCADE)
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="players")
first = models.BooleanField()
score = models.IntegerField(blank=False)
deck = models.ForeignKey(Deck, on_delete=models.CASCADE, related_name="decks")
online = models.BooleanField(default=False)
channel_name = models.CharField(max_length=50, blank=True, null=True)
def get_state(self):
return GameState.objects.filter(player=self.player, room=self.room).last()
def __str__(self):
return f"{self.player.name} in room {self.room.slug}"
class GameState(models.Model):
room = models.ForeignKey(Room, on_delete=models.CASCADE)
player = models.ForeignKey(Player, on_delete=models.CASCADE)
round = models.IntegerField(blank=False)
message = models.CharField(max_length=100, blank=False)
class Meta:
unique_together = ["room", "player", "round"]

8
room/routing.py Normal file
View File

@ -0,0 +1,8 @@
from django.urls import path
from . import consumers
websocket_urlpatterns = [
path("room/", consumers.QueueConsumer.as_asgi()),
path("room/<str:room_name>", consumers.RoomConsumer.as_asgi()),
]

View File

View File

@ -0,0 +1,45 @@
from asgiref.sync import sync_to_async
from random import randint
from common.generators import generate_charset
from game.models import Player
from room.models import Room, PlayerInRoom, GameState
@sync_to_async
def create_room(
deck_id_1: int,
player_id_1: int,
player_score_1: int,
deck_id_2: int,
player_id_2: int,
player_score_2: int,
) -> str:
room = Room.objects.create(slug=generate_charset(16))
player_1 = Player.objects.get(id=player_id_1)
player_2 = Player.objects.get(id=player_id_2)
first_player = randint(1, 2)
PlayerInRoom.objects.create(
player=player_1,
room=room,
score=player_score_1,
deck_id=deck_id_1,
first=first_player == 1,
)
PlayerInRoom.objects.create(
player=player_2,
room=room,
score=player_score_2,
deck_id=deck_id_2,
first=first_player == 2,
)
GameState.objects.create(
room=room, player=player_1, round=0, message="Game started"
)
GameState.objects.create(
room=room, player=player_2, round=0, message="Game started"
)
return room.slug

3
room/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
room/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.