diff --git a/README.md b/README.md index 021268b..cd3e73a 100644 --- a/README.md +++ b/README.md @@ -14,23 +14,22 @@ fans. This project consists of the following main components: -* `core` - This is the [Django][django] app that powers the project's back end. +- `core` - This is the [Django][django] app that powers the project's back end. This is where the models are defined. All functionality relating to the database lives here. -* `adjudicator` - In diplomacy, at the end of every turn the orders that each +- `adjudicator` - In diplomacy, at the end of every turn the orders that each player has submitted are interpreted and the board is updated. `adjudicator` is a python package which interprets an in-game turn and returns the outcome of the orders. The outcome is then interpreted by `core` and the state of the game is updated accordingly. -* `service` - This is a [Django Rest Framework][DRF] app that provides the API +- `service` - This is a [Django Rest Framework][drf] app that provides the API through which the `client` application can interact with `core`. -* `client` - This is [React JS][reactjs] app that acts as the front end of the +- `client` - This is [React JS][reactjs] app that acts as the front end of the project. The client app is contained in a [separate repo][client]. - ## Getting started These instructions will get you started with a copy of the project up on your @@ -50,14 +49,14 @@ development. Follow the docs to get Docker and Docker Compose installed. Run the following commands from the root directory to create local copies of configuration files: -* Run `cp project/settings/local.example.py project/settings/local.py` -* Run `cp docker-compose.override.example.yml docker-compose.override.yml` +- Run `cp project/settings/local.example.py project/settings/local.py` +- Run `cp docker-compose.override.example.yml docker-compose.override.yml` #### Bring up local copy -* Run `docker-compose up` to bring up the project (You can run detached by +- Run `docker-compose up` to bring up the project (You can run detached by adding `-d` flag) -* Once the containers are up you can run commands from inside the docker +- Once the containers are up you can run commands from inside the docker service container by running `docker exec -it diplomacy_diplomacy.service_1` and then running whatever command you like. @@ -66,10 +65,12 @@ configuration files: To load the fixtures run `make reset_db && make dev_fixtures && make superuser` from the root directory (outside container). This resets the database, builds the fixtures in `fixtures/dev` and creates a superuser with the following credentials: + ``` Username: admin Pw: admin ``` + You can sign into the client and the service using these credentials. ### Non-docker set up @@ -78,15 +79,18 @@ You can sign into the client and the service using these credentials. #### Prerequisites -Ensure that you have installed `virtualenv` and `make`. +Ensure that you have installed `virtualenv`, `rabbitmq`, and `make`. #### Virtual environent Create a virtual environment: + ``` python -m virtualenv venv ``` + Activate the virtual environment: + ``` # Unix source venv/bin/activate @@ -97,6 +101,7 @@ venv\Scripts\activate.bat ``` Install requirements: + ``` pip install --user -r requirements.txt -r dev_requirements.txt ``` @@ -105,6 +110,7 @@ pip install --user -r requirements.txt -r dev_requirements.txt Run the following command from the root directory to create local copies of configuration files: + ``` # Unix cp project/settings/local.example.py project/settings/local.py @@ -118,6 +124,7 @@ Open the `local.py` file and edit uncomment each section labeled "non Docker set #### Bring up development server Run the development server on port 8082. This is what the client expects during deevelopment. + ``` # Unix python ./manage.py runserver localhost:8082 @@ -130,12 +137,20 @@ python .\manage.py runserver localhost:8082 To load the fixtures run `make fixtures_local` from the root directory. This builds the fixtures in `fixtures/dev` and creates a superuser with the following credentials: + ``` Username: admin Pw: admin ``` + You can sign into the client and the service using these credentials. +#### Bring up rabbitmq worker + +You must bring up a rabbitmq instance to enable celery tasks. This is required by +`models.TurnManager.new` which create a `models.TurnEnd` instance. + +On windows you can install rabbitmq using `choco install rabbitmq`. Once installed, a rabbitmq instance is automatically started on the port specified in `settings/local.example.py`. ## Running the tests @@ -148,13 +163,12 @@ root. If there are code style problems they will be displayed. ## Test Coverage -To generate a test coverage report test coverage, run `coverage run manage.py -test` from within the container. Then run `coverage report` to see the results. +To generate a test coverage report test coverage, run `coverage run manage.py test` from within the container. Then run `coverage report` to see the results. [play diplomacy]: https://www.playdiplomacy.com/ [backstabbr]: https://www.backstabbr.com/ [django]: https://www.djangoproject.com/ -[DRF]: https://www.django-rest-framework.org/ +[drf]: https://www.django-rest-framework.org/ [reactjs]: https://www.reactjs.org/ [client]: https://www.github.com/samjhayes/diplomacy-client/ [docker]: https://docs.docker.com/ diff --git a/accounts/api/serializers.py b/accounts/api/serializers.py index 5dc1851..c6de350 100644 --- a/accounts/api/serializers.py +++ b/accounts/api/serializers.py @@ -17,7 +17,10 @@ class RegisterSerializer(serializers.ModelSerializer): class Meta: model = User fields = ('id', 'username', 'email', 'password') - extra_kwargs = {'password': {'write_only': True}} + extra_kwargs = { + 'email': {'required': True}, + 'password': {'write_only': True} + } def create(self, validated_data): user = User.objects.create_user( @@ -27,18 +30,31 @@ def create(self, validated_data): ) return user + def validate_password(self, password): + email = self.initial_data.get('email') + username = self.initial_data.get('username') + temp_user = User(email=email, username=username) + validate_password(password, temp_user) + class LoginSerializer(serializers.Serializer): - username = serializers.CharField() + email = serializers.EmailField(required=False) + username = serializers.CharField(required=False) password = serializers.CharField() def validate(self, data): + email = data.get('email') + username = data.get('username') + if not (email or username): + raise serializers.ValidationError( + 'Must provide either email or username. Please try again.' + ) user = authenticate(**data) if user and user.is_active: return user raise serializers.ValidationError( - 'The username or password you entered do not match an account. ' - 'Please try again.' + 'The {} or password you entered do not match an account. ' + 'Please try again.'.format('username' if username else 'email') ) diff --git a/accounts/api/views.py b/accounts/api/views.py index b4a06ac..70108d4 100644 --- a/accounts/api/views.py +++ b/accounts/api/views.py @@ -1,3 +1,5 @@ +import re + from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from knox.models import AuthToken @@ -7,6 +9,9 @@ from . import serializers +EMAIL_REGEX = r'^(\w|\.|\_|\-)+[@](\w|\_|\-|\.)+[.]\w{2,3}$' + + class UserAPIView(generics.RetrieveAPIView): serializer_class = serializers.UserSerializer permission_classes = [ @@ -33,13 +38,28 @@ def post(self, request, *args, **kwargs): class LoginAPIView(generics.GenericAPIView): serializer_class = serializers.LoginSerializer + @staticmethod + def is_using_email(request): + username = request.data.get('username') + return username and re.search(EMAIL_REGEX, username) + def post(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + + # Allow username to be username or email + using_email = self.is_using_email(request) + login_kwarg = 'email' if using_email else 'username' + data = { + login_kwarg: request.data.get('username'), + 'password': request.data.get('password') + } + + serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) user = serializer.validated_data + user_data = serializers.UserSerializer(user).data return Response({ - "user": serializers.UserSerializer(user, context=self.get_serializer_context()).data, - "token": AuthToken.objects.create(user)[1] + 'user': user_data, + 'token': AuthToken.objects.create(user)[1] }) diff --git a/accounts/tests/test_views.py b/accounts/tests/test_views.py index d1261be..e1aa09c 100644 --- a/accounts/tests/test_views.py +++ b/accounts/tests/test_views.py @@ -1,12 +1,21 @@ import json + from django.contrib.auth.models import User from django.urls import reverse +from parameterized import parameterized from rest_framework.test import APITestCase -USERNAME = 'username' +USERNAME = 'johnpooch' +EMAIL = 'email@email.com' PASSWORD = 'secret123password' +FIELD_IS_REQUIRED_ERROR = 'This field is required.' + + +def get_errors(response): + return json.loads(response.content.decode()) + class TestChangePassword(APITestCase): @@ -21,9 +30,6 @@ def setUp(self): 'new_password_confirm': 'NewPassword1234', } - def get_errors(self, response): - return json.loads(response.content.decode()) - def test_get(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 405) @@ -36,14 +42,14 @@ def test_unauthorized(self): def test_passwords_not_matching(self): self.data['new_password_confirm'] = 'NotMatchingPassword1234' response = self.client.put(self.url, data=self.data) - errors = self.get_errors(response) + errors = get_errors(response) self.assertEqual(response.status_code, 400) self.assertEqual(errors, {'new_password_confirm': ['Password fields didn\'t match']}) def test_incorrect_password(self): self.data['current_password'] = 'WrongPassword1234' response = self.client.put(self.url, data=self.data) - errors = self.get_errors(response) + errors = get_errors(response) self.assertEqual(response.status_code, 400) self.assertEqual(errors, {'current_password': ['Password is not correct']}) @@ -51,7 +57,7 @@ def test_invalid_password(self): self.data['new_password'] = 'password' self.data['new_password_confirm'] = 'password' response = self.client.put(self.url, data=self.data) - errors = self.get_errors(response) + errors = get_errors(response) self.assertEqual(response.status_code, 400) self.assertEqual(errors, {'new_password': ['This password is too common.']}) @@ -60,3 +66,124 @@ def test_updates_password(self): self.assertEqual(response.status_code, 200) self.user.refresh_from_db() self.assertTrue(self.user.check_password(self.data['new_password'])) + + +class TestLogin(APITestCase): + + url = reverse('login') + + def setUp(self): + self.user = User.objects.create_user( + USERNAME, + email=EMAIL, + password=PASSWORD + ) + self.data = { + 'username': USERNAME, + 'password': PASSWORD, + } + + def test_login_using_username_and_password(self): + response = self.client.post(self.url, data=self.data) + self.assertEqual(response.status_code, 200) + + def test_login_using_email_and_password(self): + self.data['username'] = EMAIL + response = self.client.post(self.url, data=self.data) + self.assertEqual(response.status_code, 200) + + def test_login_no_email_or_username(self): + del self.data['username'] + response = self.client.post(self.url, data=self.data) + self.assertEqual(response.status_code, 400) + + def test_login_bad_username(self): + self.data['username'] = 'badusername' + response = self.client.post(self.url, data=self.data) + self.assertEqual(response.status_code, 400) + errors = get_errors(response) + self.assertEqual(errors, {'non_field_errors': ['The username or password you entered do not match an account. Please try again.']}) + + def test_login_bad_email(self): + self.data['username'] = 'bademail@fakeemail.com' + response = self.client.post(self.url, data=self.data) + self.assertEqual(response.status_code, 400) + errors = get_errors(response) + self.assertEqual(errors, {'non_field_errors': ['The email or password you entered do not match an account. Please try again.']}) + + +class TestRegister(APITestCase): + + url = reverse('register') + param_list = ['username', 'email', 'password'] + + def setUp(self): + self.data = { + 'username': USERNAME, + 'email': EMAIL, + 'password': PASSWORD, + } + + @parameterized.expand([ + ['password'], + ['username'], + ['email'], + ]) + def test_params_not_provided(self, param): + del self.data[param] + + response = self.client.post(self.url, data=self.data) + errors = get_errors(response) + + self.assertEqual(response.status_code, 400) + self.assertEqual(errors, {param: [FIELD_IS_REQUIRED_ERROR]}) + + def test_email_already_registered(self): + User.objects.create_user('some username', email=EMAIL, password=PASSWORD) + + response = self.client.post(self.url, data=self.data) + errors = get_errors(response) + + self.assertEqual(response.status_code, 400) + self.assertEqual(errors, {'email': ['user with this email address already exists.']}) + + def test_username_already_registered(self): + User.objects.create_user(USERNAME, email='someemail@email.com', password=PASSWORD) + + response = self.client.post(self.url, data=self.data) + errors = get_errors(response) + + self.assertEqual(response.status_code, 400) + self.assertEqual(errors, {'username': ['A user with that username already exists.']}) + + def test_password_same_as_username(self): + self.data['password'] = USERNAME + response = self.client.post(self.url, data=self.data) + errors = get_errors(response) + + self.assertEqual(response.status_code, 400) + self.assertEqual(errors, {'password': ['The password is too similar to the username.']}) + + def test_password_entirely_numeric(self): + self.data['password'] = '41269684126968' + response = self.client.post(self.url, data=self.data) + errors = get_errors(response) + + self.assertEqual(response.status_code, 400) + self.assertEqual(errors, {'password': ['This password is entirely numeric.']}) + + def test_password_too_short(self): + self.data['password'] = 'aljfksh' + response = self.client.post(self.url, data=self.data) + errors = get_errors(response) + + self.assertEqual(response.status_code, 400) + self.assertEqual(errors, {'password': ['This password is too short. It must contain at least 8 characters.']}) + + def test_password_too_common(self): + self.data['password'] = 'Password1234' + response = self.client.post(self.url, data=self.data) + errors = get_errors(response) + + self.assertEqual(response.status_code, 400) + self.assertEqual(errors, {'password': ['This password is too common.']}) diff --git a/core/backends.py b/core/backends.py new file mode 100644 index 0000000..8a64baa --- /dev/null +++ b/core/backends.py @@ -0,0 +1,19 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +class EmailBackend(ModelBackend): + + def authenticate(self, *args, email=None, **kwargs): + """ + Override default authenticate method to allow email to be passed + instead of username. + """ + UserModel = get_user_model() + if email: + try: + user = UserModel.objects.get(email=email) + kwargs['username'] = user.username + except UserModel.DoesNotExist: + pass + return super().authenticate(*args, **kwargs) diff --git a/core/factories.py b/core/factories.py index 88458cc..d160906 100644 --- a/core/factories.py +++ b/core/factories.py @@ -203,10 +203,6 @@ def territories(self, create, count, **kwargs): initial_piece_type=territory['fields'].get('initial_piece_type'), ) ) - # - # - # if not create: - # self._prefetched_objects_cache = {'territories': territories} class StandardTurnFactory(DjangoModelFactory): diff --git a/core/migrations/0004_auto_20210422_1309.py b/core/migrations/0004_auto_20210422_1309.py new file mode 100644 index 0000000..4a6dbe2 --- /dev/null +++ b/core/migrations/0004_auto_20210422_1309.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.7 on 2021-04-22 12:09 + +from django.db import migrations + + +def title_case_territory_names(apps, schema_editor): + Territory = apps.get_model('core', 'Territory') + for territory in Territory.objects.all(): + territory.name = territory.name.title() + territory.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_auto_20210409_0949'), + ] + + operations = [ + migrations.RunPython( + title_case_territory_names, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/core/tests/test_game.py b/core/tests/test_game.py index 38f7ca8..94026a0 100644 --- a/core/tests/test_game.py +++ b/core/tests/test_game.py @@ -54,7 +54,7 @@ def test_create_territory_states(self): self.game.create_initial_territory_states() territory_states = turn.territorystates.all() england = models.Nation.objects.get(variant=self.game.variant, name='England') - london_state = turn.territorystates.get(territory__name='london') + london_state = turn.territorystates.get(territory__name='London') self.assertEqual(len(territory_states), 75) self.assertEqual(london_state.controlled_by, england) @@ -92,7 +92,7 @@ def test_initialize(self): territory_states = current_turn.territorystates.all() england = models.Nation.objects.get(variant=self.game.variant, name='England') - london_state = current_turn.territorystates.get(territory__name='london') + london_state = current_turn.territorystates.get(territory__name='London') self.assertEqual(len(territory_states), 75) self.assertEqual(london_state.controlled_by, england) diff --git a/core/tests/test_retreat_and_disband.py b/core/tests/test_retreat_and_disband.py index e40e659..9105878 100644 --- a/core/tests/test_retreat_and_disband.py +++ b/core/tests/test_retreat_and_disband.py @@ -54,7 +54,7 @@ def test_pieces_to_order_equal_to_num_pieces_which_must_retreat(self): def test_pieces_which_are_disbanded_are_removed_from_the_game(self): france = self.variant.nations.get(name='France') - paris = self.variant.territories.get(name='paris') + paris = self.variant.territories.get(name='Paris') nation_state = models.NationState.objects.create( turn=self.retreat_turn, nation=france, diff --git a/core/tests/test_serializers.py b/core/tests/test_serializers.py index 0d0904c..118e789 100644 --- a/core/tests/test_serializers.py +++ b/core/tests/test_serializers.py @@ -23,8 +23,8 @@ def setUp(self): self.user = factories.UserFactory() self.england = self.variant.nations.get(name='England') self.france = self.variant.nations.get(name='France') - self.london = self.variant.territories.get(name='london') - self.paris = self.variant.territories.get(name='paris') + self.london = self.variant.territories.get(name='London') + self.paris = self.variant.territories.get(name='Paris') self.game = models.Game.objects.create( name='Test game', variant=self.variant, diff --git a/core/tests/test_turn.py b/core/tests/test_turn.py index 4cf9dc4..7bb4dbe 100644 --- a/core/tests/test_turn.py +++ b/core/tests/test_turn.py @@ -76,11 +76,8 @@ def test_ready_to_process_build(self): nation=france, user=self.user ) - territory = models.Territory.objects.create( - variant=self.variant, + territory = models.Territory.objects.get( name='Marseilles', - nationality=france, - supply_center=True, ) models.TerritoryState.objects.create( turn=build_turn, diff --git a/project/settings/base.py b/project/settings/base.py index ebcd104..fc7b863 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -144,7 +144,7 @@ } AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', + 'core.backends.EmailBackend', ) # Static files (CSS, JavaScript, Images) diff --git a/project/settings/local.example.py b/project/settings/local.example.py index 1afdb30..3737609 100644 --- a/project/settings/local.example.py +++ b/project/settings/local.example.py @@ -44,6 +44,9 @@ CLIENT_URL = 'http://localhost:8000' +# Tested on windows +CELERY_BROKER_URL = 'amqp://guest:guest@localhost:5672' + SESSION_EXPIRE_AT_BROWSER_CLOSE = True SESSION_SAVE_EVERY_REQUEST = True SESSION_COOKIE_AGE = 86400 # sec diff --git a/requirements.txt b/requirements.txt index b736b92..dc06cb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,6 @@ lxml marshmallow>=3.9.1,<4.0 mysqlclient gunicorn +parameterized psycopg2-binary whitenoise diff --git a/service/decorators.py b/service/decorators.py new file mode 100644 index 0000000..9780f77 --- /dev/null +++ b/service/decorators.py @@ -0,0 +1,14 @@ +from urllib.parse import urlencode + +from django.http import QueryDict + +from service.utils.cases import deep_snake_case_transform + + +def convert_query_params_to_snake_case(func): + def func_wrapper(request, *args, **kwargs): + updated_params = deep_snake_case_transform(request.GET) + querystring = urlencode(updated_params) + request.GET = QueryDict(querystring) + return func(request, *args, **kwargs) + return func_wrapper diff --git a/service/serializers.py b/service/serializers.py index 2087556..bb2aa36 100644 --- a/service/serializers.py +++ b/service/serializers.py @@ -423,6 +423,7 @@ class TurnSerializer(serializers.ModelSerializer): next_turn = serializers.SerializerMethodField() previous_turn = serializers.SerializerMethodField() draws = DrawSerializer(many=True) + turn_end = serializers.SerializerMethodField() class Meta: model = models.Turn @@ -442,7 +443,7 @@ class Meta: 'nation_states', 'orders', 'draws', - 'turnend', + 'turn_end', ) def get_next_turn(self, obj): @@ -453,6 +454,11 @@ def get_previous_turn(self, obj): turn = models.Turn.get_previous(obj) return getattr(turn, 'id', None) + def get_turn_end(self, turn): + if turn.turn_end: + return turn.turn_end.datetime + return None + class ListNationStatesSerializer(serializers.ModelSerializer): diff --git a/service/tests/test_views.py b/service/tests/test_views.py index 8e07a0b..a801289 100644 --- a/service/tests/test_views.py +++ b/service/tests/test_views.py @@ -28,15 +28,16 @@ class BaseTestCase(APITestCase, DiplomacyTestCaseMixin): pass -class TestGetGames(BaseTestCase): +class TestListGames(BaseTestCase): max_diff = None def setUp(self): - user = factories.UserFactory() - self.client.force_authenticate(user=user) + self.user = factories.UserFactory() + self.variant = models.Variant.objects.get(id='standard') + self.client.force_authenticate(user=self.user) - def test_get_all_games_unauthenticated(self): + def test_list_games_unauthenticated(self): """ Cannot get all games if not authenticated. """ @@ -45,6 +46,23 @@ def test_get_all_games_unauthenticated(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_list_games_converts_camel_to_snake_query_params(self): + self.game = models.Game.objects.create( + status=GameStatus.PENDING, + variant=self.variant, + name='Test Game', + created_by=self.user, + num_players=7 + ) + + url = reverse('list-games') + '?numPlayers=1' + response = self.client.get(url) + self.assertEqual(response.data, []) + + url = reverse('list-games') + '?numPlayers=7' + response = self.client.get(url) + self.assertEqual(len(response.data), 1) + class TestGetCreateGame(BaseTestCase): diff --git a/service/views.py b/service/views.py index 10cb41b..78b3178 100644 --- a/service/views.py +++ b/service/views.py @@ -1,14 +1,16 @@ from django.db.models import Prefetch -from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator from rest_framework import exceptions, filters, generics, status, views from rest_framework.response import Response from core import models from core.models.base import DrawStatus, GameStatus, SurrenderStatus from service import serializers -from service.permissions import IsAuthenticated +from service.decorators import convert_query_params_to_snake_case from service.mixins import CamelCase +from service.permissions import IsAuthenticated # NOTE this could possibly be replaced by using options @@ -46,6 +48,7 @@ def get_user_nation_state(self): ) +@method_decorator(convert_query_params_to_snake_case, 'dispatch') class ListGames(CamelCase, generics.ListAPIView): permission_classes = [IsAuthenticated]