From 733f3e599260662c6fd65f96d55677195b8f5f57 Mon Sep 17 00:00:00 2001 From: Michel Wilhelm Date: Thu, 5 Nov 2020 23:29:35 -0300 Subject: [PATCH] Basic game creating API (#2) * Adding the base of our API * Little file and lint adjustments * Adding the lint command to Makefile * Adding the Minesweeper logic for game creation * Adding some tests for the Minesweeper algorithm * Adding some tools command to Makefile like pre-commit and pip-tools * Adding test help text to Makefile * all new user is_staff=True, for development for now * Now we can get the data from specific game Adding game status Adding game status Fixing game models --- .pre-commit-config.yaml | 19 +++++ Makefile | 26 +++++- README.md | 1 - api/__init__.py | 0 api/apps.py | 5 ++ api/resources/__init__.py | 0 api/resources/game.py | 30 +++++++ api/resources/main.py | 8 ++ api/serializers/__init__.py | 4 + api/serializers/game.py | 9 ++ api/tests.py | 0 api/urls.py | 13 +++ app/settings.py | 30 ++++--- app/urls.py | 27 +++--- core/apps.py | 2 +- core/migrations/0001_initial.py | 98 +++++++++++++++++----- core/migrations/0002_auto_20201105_0303.py | 27 ++++++ core/migrations/0003_auto_20201106_0152.py | 18 ++++ core/models.py | 2 +- core/tests.py | 3 - core/views.py | 3 - docker-compose.yml | 22 +++++ game/__init__.py | 0 game/admin.py | 0 game/apps.py | 7 ++ game/game.py | 89 ++++++++++++++++++++ game/migrations/0001_initial.py | 65 ++++++++++++++ game/migrations/0002_auto_20201106_0225.py | 68 +++++++++++++++ game/migrations/__init__.py | 0 game/models.py | 65 ++++++++++++++ game/tests/__init__.py | 0 game/tests/test_game.py | 86 +++++++++++++++++++ internal/__init__.py | 3 + internal/utils.py | 6 ++ requirements.txt | 21 ++++- 35 files changed, 690 insertions(+), 67 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 api/__init__.py create mode 100644 api/apps.py create mode 100644 api/resources/__init__.py create mode 100644 api/resources/game.py create mode 100644 api/resources/main.py create mode 100644 api/serializers/__init__.py create mode 100644 api/serializers/game.py create mode 100644 api/tests.py create mode 100644 api/urls.py create mode 100644 core/migrations/0002_auto_20201105_0303.py create mode 100644 core/migrations/0003_auto_20201106_0152.py delete mode 100644 core/views.py create mode 100644 game/__init__.py create mode 100644 game/admin.py create mode 100644 game/apps.py create mode 100644 game/game.py create mode 100644 game/migrations/0001_initial.py create mode 100644 game/migrations/0002_auto_20201106_0225.py create mode 100644 game/migrations/__init__.py create mode 100644 game/models.py create mode 100644 game/tests/__init__.py create mode 100644 game/tests/test_game.py create mode 100644 internal/__init__.py create mode 100644 internal/utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8ad441f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/ambv/black + rev: stable + hooks: + - id: black + language_version: python3.8 + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 diff --git a/Makefile b/Makefile index 9861ac6..479b3fa 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,14 @@ help: @echo "―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――" @echo "ℹ️ Available commands ℹ️" @echo "―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――" - @echo "⭐️ help : Show this message" - @echo "⭐️ clean : Removes all python cache and temporary files" - @echo "⭐️ run : Runs the application using docker-compose" + @echo "⭐️ help : Show this message" + @echo "⭐️ clean : Removes all python cache and temporary files" + @echo "⭐️ run : Runs the application using docker-compose" + @echo "⭐️ lint : Lint the source using flake8 codestyle" + @echo "⭐️ test : Runs the tests using Docker" + @echo "⭐️ pre-commit-install : Install the pre-commit hook" + @echo "⭐️ pre-commit-run : Runs the standalone pre-commit routine for checking files" + @echo "⭐️ update-requirements : Using pip-compile(from pip-tools), update the requirements.txt with fixed version of used libraries" @echo "―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――" clean: @@ -18,3 +23,18 @@ clean: run: @docker-compose up + +lint: + @flake8 . + +test: clean + @docker-compose run --rm app-test python manage.py test + +pre-commit-install: + @pre-commit install + +pre-commit-run: + @pre-commit run --all-files + +update-requirements: + @pip-compile requirements.txt diff --git a/README.md b/README.md index 33e1c09..94a7257 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ # Minesweeper (backend) - diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..14b89a8 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "api" diff --git a/api/resources/__init__.py b/api/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/resources/game.py b/api/resources/game.py new file mode 100644 index 0000000..c51ac25 --- /dev/null +++ b/api/resources/game.py @@ -0,0 +1,30 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from game.models import Game +from ..serializers import GameSerializer + + +class GameResource(APIView): + def post(self, request): + """ Creates a new game """ + + serializer = GameSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class GameSingleResource(APIView): + def get(self, request, game_id): + """ Returns a game serialized or not found """ + + try: + game = Game.objects.get(pk=game_id) + except Game.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = GameSerializer(game) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/api/resources/main.py b/api/resources/main.py new file mode 100644 index 0000000..fbb46ae --- /dev/null +++ b/api/resources/main.py @@ -0,0 +1,8 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + + +class MainResource(APIView): + def get(self, request): + return Response({"message": "Welcome to the game!"}, status=status.HTTP_200_OK) diff --git a/api/serializers/__init__.py b/api/serializers/__init__.py new file mode 100644 index 0000000..964a753 --- /dev/null +++ b/api/serializers/__init__.py @@ -0,0 +1,4 @@ +from .game import GameSerializer + + +__all__ = ["GameSerializer"] diff --git a/api/serializers/game.py b/api/serializers/game.py new file mode 100644 index 0000000..6b61620 --- /dev/null +++ b/api/serializers/game.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from game.models import Game + + +class GameSerializer(serializers.ModelSerializer): + class Meta: + model = Game + fields = "__all__" diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..a1ff5ac --- /dev/null +++ b/api/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from .resources.main import MainResource +from .resources.game import GameResource, GameSingleResource + + +app_name = "api" + +urlpatterns = [ + path("games/", GameSingleResource.as_view(), name="games_single"), + path("games", GameResource.as_view(), name="games"), + path("", MainResource.as_view(), name="main"), +] diff --git a/app/settings.py b/app/settings.py index cb66b0c..88aa958 100644 --- a/app/settings.py +++ b/app/settings.py @@ -16,6 +16,7 @@ ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "127.0.0.1,localhost").split(",") # Application definition INSTALLED_APPS = [ + "rest_framework", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -23,6 +24,8 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "core", + "api", + "game", ] MIDDLEWARE = [ @@ -68,9 +71,7 @@ DATABASES = { "PASSWORD": os.getenv("DB_PASS"), "HOST": os.getenv("DB_HOST"), "PORT": os.getenv("DB_PORT"), - "OPTIONS": { - "charset": "utf8mb4", - }, + "OPTIONS": {"charset": "utf8mb4"}, } } @@ -80,17 +81,11 @@ DATABASES = { AUTH_PASSWORD_VALIDATORS = [ { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] @@ -112,3 +107,12 @@ USE_TZ = True # https://docs.djangoproject.com/en/3.1/howto/static-files/ STATIC_URL = "/static/" + +# Some Django Rest Framework parameters +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.TokenAuthentication" + ], + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), +} diff --git a/app/urls.py b/app/urls.py index cf094ce..9e6d4f6 100644 --- a/app/urls.py +++ b/app/urls.py @@ -1,21 +1,14 @@ -"""app URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/3.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin -from django.urls import path +from django.urls import path, include +from django.conf import settings + urlpatterns = [ - path("admin/", admin.site.urls), + path("", include("api.urls")), ] + +# We need this only for development purpose +if settings.DEBUG is True: + urlpatterns += [ + path("admin/", admin.site.urls), + ] diff --git a/core/apps.py b/core/apps.py index 26f78a8..5ef1d60 100644 --- a/core/apps.py +++ b/core/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class CoreConfig(AppConfig): - name = 'core' + name = "core" diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index cfa9dea..a6e0c4b 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -9,34 +9,90 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('email', models.EmailField(max_length=254, unique=True, verbose_name='E-mail')), - ('first_name', models.CharField(blank=True, max_length=30, verbose_name='First name')), - ('last_name', models.CharField(blank=True, max_length=30, verbose_name='Last name')), - ('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='date joined')), - ('is_active', models.BooleanField(default=True, verbose_name='User active?')), - ('is_staff', models.BooleanField(default=False, verbose_name='Staff?')), - ('is_superuser', models.BooleanField(default=False, verbose_name='Superuser?')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "email", + models.EmailField( + max_length=254, unique=True, verbose_name="E-mail" + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="First name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=30, verbose_name="Last name" + ), + ), + ( + "date_joined", + models.DateTimeField(auto_now_add=True, verbose_name="date joined"), + ), + ( + "is_active", + models.BooleanField(default=True, verbose_name="User active?"), + ), + ("is_staff", models.BooleanField(default=False, verbose_name="Staff?")), + ( + "is_superuser", + models.BooleanField(default=False, verbose_name="Superuser?"), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A" + "user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'User', - 'verbose_name_plural': 'Users', - 'db_table': 'users', - 'ordering': ['first_name', 'last_name'], + "verbose_name": "User", + "verbose_name_plural": "Users", + "db_table": "users", + "ordering": ["first_name", "last_name"], }, - managers=[ - ('objects', core.managers.UserManager()), - ], + managers=[("objects", core.managers.UserManager())], ), ] diff --git a/core/migrations/0002_auto_20201105_0303.py b/core/migrations/0002_auto_20201105_0303.py new file mode 100644 index 0000000..4f32afb --- /dev/null +++ b/core/migrations/0002_auto_20201105_0303.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.3 on 2020-11-05 03:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. " + "A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ] diff --git a/core/migrations/0003_auto_20201106_0152.py b/core/migrations/0003_auto_20201106_0152.py new file mode 100644 index 0000000..c27911e --- /dev/null +++ b/core/migrations/0003_auto_20201106_0152.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2020-11-06 01:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_auto_20201105_0303"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="is_staff", + field=models.BooleanField(default=True, verbose_name="Staff?"), + ), + ] diff --git a/core/models.py b/core/models.py index c89b937..d66bf1a 100644 --- a/core/models.py +++ b/core/models.py @@ -14,7 +14,7 @@ class User(AbstractBaseUser, PermissionsMixin): date_joined = models.DateTimeField("date joined", auto_now_add=True) is_active = models.BooleanField("User active?", default=True) - is_staff = models.BooleanField("Staff?", default=False) + is_staff = models.BooleanField("Staff?", default=True) is_superuser = models.BooleanField("Superuser?", default=False) objects = UserManager() diff --git a/core/tests.py b/core/tests.py index 7ce503c..e69de29 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/core/views.py b/core/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/core/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/docker-compose.yml b/docker-compose.yml index 9ac3b41..c52e0ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,28 @@ services: - 8000:8000 links: - db + app-test: + build: . + container_name: mines-app-test + volumes: + - ./commands:/commands + - .:/app + environment: + - DB_USER=root + - DB_PASS=minesweeper + - DB_HOST=db + - DB_NAME=minesweeper + - DB_PORT=3306 + - DEBUG=1 + links: + - db-test + db-test: + image: mysql:8 + container_name: mines-db-test + command: --default-authentication-plugin=mysql_native_password + environment: + - MYSQL_ROOT_PASSWORD=minesweeper + - MYSQL_DATABASE=minesweeper db: image: mysql:8 container_name: mines-db diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/admin.py b/game/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/game/apps.py b/game/apps.py new file mode 100644 index 0000000..6198c24 --- /dev/null +++ b/game/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GameConfig(AppConfig): + name = "game" + verbose_name = "Game" + verbose_name_plural = "Games" diff --git a/game/game.py b/game/game.py new file mode 100644 index 0000000..ed5f5c2 --- /dev/null +++ b/game/game.py @@ -0,0 +1,89 @@ +import random + + +class Minesweeper: + board = [] + + def __init__(self, rows=10, cols=10, mines=5): + self.rows = rows + self.cols = cols + self.mines = mines + + def create_board(self): + """ Creating the board cells with 0 as default value """ + self.board = [[0 for col in range(self.cols)] for row in range(self.rows)] + + def put_mine(self): + """Put a single mine on the board. + The mine have a -1 value just for reference + """ + mine_position_row = random.randrange(0, self.rows) + mine_position_col = random.randrange(0, self.cols) + + if self.is_mine(mine_position_row, mine_position_col): + self.put_mine() + + self.board[mine_position_row][mine_position_col] = -1 + return mine_position_row, mine_position_col + + def put_mines(self): + """ Put the desired amount of mines on the board """ + for mine in range(1, self.mines + 1): + mine_position_row, mine_position_col = self.put_mine() + + self.create_mine_points(mine_position_row, mine_position_col) + + def create_mine_points(self, mine_position_row, mine_position_col): + """Populate the board with points that sorrounds the mine. + The reference used is the mine that was already placed""" + + # North + self.increment_safe_point(mine_position_row - 1, mine_position_col) + + # North-east + self.increment_safe_point(mine_position_row - 1, mine_position_col + 1) + + # East + self.increment_safe_point(mine_position_row, mine_position_col + 1) + + # South-east + self.increment_safe_point(mine_position_row + 1, mine_position_col + 1) + + # South + self.increment_safe_point(mine_position_row + 1, mine_position_col) + + # South-west + self.increment_safe_point(mine_position_row + 1, mine_position_col - 1) + + # West + self.increment_safe_point(mine_position_row, mine_position_col - 1) + + # North-west + self.increment_safe_point(mine_position_row - 1, mine_position_col - 1) + + def is_mine(self, row, col): + """ Checks whether the given location is a mine or not """ + try: + return self.board[row][col] == -1 + except IndexError: + return False + + def is_point_in_board(self, row, col): + """ Checks whether the location is inside board """ + if row in range(0, self.rows) and col in range(0, self.cols): + return True + return False + + def increment_safe_point(self, row, col): + """ Creates the mine's pontuation frame """ + + # Ignores if the point whether not in the board + if not self.is_point_in_board(row, col): + return + + # Verify if the position have a mine on it + if self.is_mine(row, col): + return + + # Increment the value of the position becaus is close to some mine + self.board[row][col] += 1 diff --git a/game/migrations/0001_initial.py b/game/migrations/0001_initial.py new file mode 100644 index 0000000..7779294 --- /dev/null +++ b/game/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 3.1.3 on 2020-11-05 03:03 + +from django.db import migrations, models +import django_mysql.models +import internal.utils + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Game", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Creation date" + ), + ), + ( + "modified_at", + models.DateTimeField(auto_now=True, verbose_name="Last update"), + ), + ( + "rows", + models.PositiveIntegerField(default=10, verbose_name="Board rows"), + ), + ( + "cols", + models.PositiveIntegerField(default=10, verbose_name="Board cols"), + ), + ( + "mines", + models.PositiveIntegerField( + default=5, verbose_name="Mines on board" + ), + ), + ( + "board", + django_mysql.models.JSONField( + default=internal.utils.empty_list, + verbose_name="Generated board", + ), + ), + ], + options={ + "verbose_name": "Game", + "verbose_name_plural": "Games", + "db_table": "games", + }, + ), + ] diff --git a/game/migrations/0002_auto_20201106_0225.py b/game/migrations/0002_auto_20201106_0225.py new file mode 100644 index 0000000..d7a854f --- /dev/null +++ b/game/migrations/0002_auto_20201106_0225.py @@ -0,0 +1,68 @@ +# Generated by Django 3.1.3 on 2020-11-06 02:25 + +from django.db import migrations, models +import django_mysql.models +import game.models +import internal.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ("game", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="status", + field=models.IntegerField( + choices=[(0, "NOT_PLAYED"), (1, "PLAYING"), (2, "FINISHED")], + default=game.models.GameStatuses["NOT_PLAYED"], + help_text="Actual game status", + ), + ), + migrations.AddField( + model_name="game", + name="win", + field=models.BooleanField( + blank=True, + default=None, + help_text="Did the user win the game?", + null=True, + verbose_name="Win?", + ), + ), + migrations.AlterField( + model_name="game", + name="board", + field=django_mysql.models.JSONField( + default=internal.utils.empty_list, + help_text="Whe generated board game", + verbose_name="Generated board", + ), + ), + migrations.AlterField( + model_name="game", + name="cols", + field=models.PositiveIntegerField( + default=10, help_text="Board's total columns", verbose_name="Board cols" + ), + ), + migrations.AlterField( + model_name="game", + name="mines", + field=models.PositiveIntegerField( + default=5, + help_text="Board's total placed mines", + verbose_name="Mines on board", + ), + ), + migrations.AlterField( + model_name="game", + name="rows", + field=models.PositiveIntegerField( + default=10, help_text="Board's total rows", verbose_name="Board rows" + ), + ), + ] diff --git a/game/migrations/__init__.py b/game/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/models.py b/game/models.py new file mode 100644 index 0000000..81c30d3 --- /dev/null +++ b/game/models.py @@ -0,0 +1,65 @@ +from enum import IntEnum +from django.db import models +from django_mysql.models import JSONField + +from internal.utils import empty_list +from .game import Minesweeper + + +class GameStatuses(IntEnum): + """ Enum was used as choices of Game.status because explicit is better than implicit """ + + NOT_PLAYED = 0 + PLAYING = 1 + FINISHED = 2 + + @classmethod + def choices(cls): + return [(key.value, key.name) for key in cls] + + +class Game(models.Model): + created_at = models.DateTimeField("Creation date", auto_now_add=True) + modified_at = models.DateTimeField("Last update", auto_now=True) + + rows = models.PositiveIntegerField( + "Board rows", default=10, help_text="Board's total rows" + ) + cols = models.PositiveIntegerField( + "Board cols", default=10, help_text="Board's total columns" + ) + mines = models.PositiveIntegerField( + "Mines on board", default=5, help_text="Board's total placed mines" + ) + + board = JSONField( + "Generated board", default=empty_list, help_text="Whe generated board game" + ) + win = models.BooleanField( + "Win?", + default=None, + null=True, + blank=True, + help_text="Did the user win the game?", + ) + status = models.IntegerField( + choices=GameStatuses.choices(), + default=GameStatuses.NOT_PLAYED, + help_text="Actual game status", + ) + + def save(self, *args, **kwargs): + """ If the board was not defined, we create a new as default """ + + if not self.board: + ms = Minesweeper(self.rows, self.cols, self.mines) + ms.create_board() + ms.put_mines() + self.board = ms.board + + super(Game, self).save(*args, **kwargs) + + class Meta: + verbose_name = "Game" + verbose_name_plural = "Games" + db_table = "games" diff --git a/game/tests/__init__.py b/game/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/tests/test_game.py b/game/tests/test_game.py new file mode 100644 index 0000000..4f25ae4 --- /dev/null +++ b/game/tests/test_game.py @@ -0,0 +1,86 @@ +from django.test import TestCase + +from ..game import Minesweeper + + +class MinesweeperTestCase(TestCase): + def test_board_2x3x1(self): + ms = Minesweeper(2, 3) + ms.create_board() + + expected_board = [[0, 0, 0], [0, 0, 0]] + self.assertEqual(ms.board, expected_board) + + def test_the_board_should_have_8_mines(self): + expected_mines = 8 + ms = Minesweeper(10, 10, expected_mines) + ms.create_board() + ms.put_mines() + + located_mines = 0 + + for row in ms.board: + located_mines += row.count(-1) + + self.assertEqual(located_mines, expected_mines) + + def test_is_mine_on_0x1(self): + ms = Minesweeper(2, 2, 1) + ms.board = [[0, -1], [0, 0]] + + self.assertEqual(ms.is_mine(0, 1), True) + + def test_is_not_mine_on_1x1(self): + ms = Minesweeper(2, 2, 1) + ms.board = [[1, -1], [-1, 1]] + + self.assertEqual(ms.is_mine(1, 1), False) + + def test_is_point_is_not_on_the_board(self): + ms = Minesweeper(2, 2, 1) + ms.board = [[0, 0], [0, 0]] + + self.assertEqual(ms.is_point_in_board(2, 1), False) + + def test_is_point_is_on_the_board(self): + ms = Minesweeper(2, 2, 1) + ms.board = [[0, 0], [0, 0]] + + self.assertEqual(ms.is_point_in_board(1, 1), True) + + def test_pontuation_creation_around_one_mine(self): + ms = Minesweeper(3, 3, 1) + ms.board = [ + [0, 0, 0], + [0, -1, 0], + [0, 0, 0], + ] + + expected_board = [ + [1, 1, 1], + [1, -1, 1], + [1, 1, 1], + ] + + ms.create_mine_points(1, 1) + + self.assertEqual(ms.board, expected_board) + + def test_pontuation_creation_around_two_mines(self): + ms = Minesweeper(3, 3, 1) + ms.board = [ + [0, 0, 0], + [0, -1, -1], + [0, 0, 0], + ] + + expected_board = [ + [1, 2, 2], + [1, -1, -1], + [1, 2, 2], + ] + + ms.create_mine_points(1, 1) + ms.create_mine_points(1, 2) + + self.assertEqual(ms.board, expected_board) diff --git a/internal/__init__.py b/internal/__init__.py new file mode 100644 index 0000000..5c58bbd --- /dev/null +++ b/internal/__init__.py @@ -0,0 +1,3 @@ +from .utils import empty_list, empty_object + +__all__ = ["empty_list", "empty_object"] diff --git a/internal/utils.py b/internal/utils.py new file mode 100644 index 0000000..060d22d --- /dev/null +++ b/internal/utils.py @@ -0,0 +1,6 @@ +def empty_object(): + return {} + + +def empty_list(): + return [] diff --git a/requirements.txt b/requirements.txt index 72dada4..7a42880 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,33 +4,46 @@ # # pip-compile requirements.txt # -appdirs==1.4.4 # via -r requirements.txt, black +appdirs==1.4.4 # via -r requirements.txt, black, virtualenv asgiref==3.3.0 # via -r requirements.txt, django black==20.8b1 # via -r requirements.txt +cfgv==3.2.0 # via pre-commit click==7.1.2 # via -r requirements.txt, black +distlib==0.3.1 # via virtualenv django-dbml==0.3.5 # via -r requirements.txt django-mysql==3.9.0 # via -r requirements.txt django==3.1.3 # via -r requirements.txt, django-mysql, djangorestframework djangorestframework==3.12.1 # via -r requirements.txt +filelock==3.0.12 # via virtualenv +flake8==3.8.4 # via -r requirements.txt gevent==20.9.0 # via -r requirements.txt greenlet==0.4.17 # via -r requirements.txt, gevent +identify==1.5.9 # via pre-commit +importlib-metadata==2.0.0 # via -r requirements.txt, flake8, pre-commit, virtualenv +mccabe==0.6.1 # via -r requirements.txt, flake8 mypy-extensions==0.4.3 # via -r requirements.txt, black mysqlclient==2.0.1 # via -r requirements.txt +nodeenv==1.5.0 # via pre-commit pathspec==0.8.0 # via -r requirements.txt, black pendulum==2.1.2 # via -r requirements.txt +pre-commit==2.8.2 # via -r requirements.txt +pycodestyle==2.6.0 # via -r requirements.txt, flake8 +pyflakes==2.2.0 # via -r requirements.txt, flake8 python-dateutil==2.8.1 # via -r requirements.txt, pendulum pytz==2020.4 # via -r requirements.txt, django pytzdata==2020.1 # via -r requirements.txt, pendulum +pyyaml==5.3.1 # via pre-commit regex==2020.10.28 # via -r requirements.txt, black -six==1.15.0 # via -r requirements.txt, python-dateutil +six==1.15.0 # via -r requirements.txt, python-dateutil, virtualenv sqlparse==0.4.1 # via -r requirements.txt, django -toml==0.10.2 # via -r requirements.txt, black +toml==0.10.2 # via -r requirements.txt, black, pre-commit typed-ast==1.4.1 # via -r requirements.txt, black typing-extensions==3.7.4.3 # via -r requirements.txt, black uwsgi==2.0.19.1 # via -r requirements.txt +virtualenv==20.1.0 # via pre-commit +zipp==3.4.0 # via -r requirements.txt, importlib-metadata zope.event==4.5.0 # via -r requirements.txt, gevent zope.interface==5.1.2 # via -r requirements.txt, gevent -flake8 # The following packages are considered to be unsafe in a requirements file: # setuptools