Skip to content

Commit

Permalink
Merge pull request #27 from TeoTN/release/2.2.0
Browse files Browse the repository at this point in the history
v2.2.0
  • Loading branch information
TeoTN authored Nov 2, 2017
2 parents 9207695 + 1150d12 commit dc82461
Show file tree
Hide file tree
Showing 20 changed files with 420 additions and 20 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,4 @@ ENV/
# tfoosball/db.sqlite3
dump.rdb

tfoosball/emails.log
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
language: python
python:
- "3.5"
- "3.6"

env:
Expand Down
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
release: python manage.py migrate
web: gunicorn tfoosball.wsgi --log-file -
16 changes: 16 additions & 0 deletions api/emailing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.core.mail import send_mail


def send_invitation(email, activation_code):
send_mail(
subject='[Invitation] Rethink the way you play table football!',
message='''
Hi!
You have been invited to TFoosball. Join us here: https://tfoosball.herokuapp.com/accept/{0}/
The service is not active yet, thank you for your patience.
'''.format(activation_code),
# html_message='Here is the message.',
from_email='[email protected]',
recipient_list=[email],
fail_silently=False,
)
20 changes: 19 additions & 1 deletion api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ def has_permission(self, request, view):
accessed_team = view.kwargs.get('team', None)
if not accessed_team:
return True
return request.user.member_set.filter(team__id=accessed_team).count() > 0
return request.user.member_set.filter(team__id=accessed_team).exists()

def has_object_permission(self, request, view, obj):
accessed_team = view.kwargs.get('team', None)
if not accessed_team or request.user.is_staff:
return True
return request.user.member_set.filter(team__id=accessed_team).exists()


class MemberPermissions(permissions.BasePermission):
Expand Down Expand Up @@ -36,3 +42,15 @@ def has_permission(self, request, view):
is_admin = request.user.member_set.filter(team__id=team, is_team_admin=True).count() > 0
is_owner = request.user.member_set.filter(team__id=team, id=member_id).count() > 0
return is_admin or is_owner or self.allow_accepting(request, view)


class IsMatchOwner(permissions.BasePermission):
def has_permission(self, request, view):
accessed_team = view.kwargs.get('team', None)
if not accessed_team:
return True
return request.user.member_set.filter(team__id=accessed_team).exists()

def has_object_permission(self, request, view, obj):
owners = [u.pk for u in obj.users]
return request.user.member_set.filter(pk__in=owners).count() > 0
4 changes: 3 additions & 1 deletion api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class MemberSerializer(serializers.ModelSerializer):
email = serializers.CharField(source='player.email', read_only=True)
first_name = serializers.CharField(source='player.first_name', read_only=True)
last_name = serializers.CharField(source='player.last_name', read_only=True)
whats_new_version = serializers.IntegerField(source='player.whats_new_version', read_only=True)
user_id = serializers.IntegerField(source='player.pk', read_only=True)

def get_att_ratio(self, obj):
return obj.att_ratio
Expand All @@ -50,7 +52,7 @@ class Meta:
fields = (
'id', 'username', 'email', 'first_name', 'last_name', 'exp', 'played', 'att_ratio', 'def_ratio',
'win_ratio', 'win_streak', 'lose_streak', 'curr_lose_streak', 'curr_win_streak', 'lowest_exp',
'highest_exp', 'exp_history', 'is_accepted',
'highest_exp', 'exp_history', 'is_accepted', 'hidden', 'whats_new_version', 'user_id',
)


Expand Down
83 changes: 83 additions & 0 deletions api/tests/test_team_invite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from django.test import TestCase
from rest_framework import status
from rest_framework.test import force_authenticate, APIRequestFactory
from api.views import TeamViewSet
from tfoosball.models import Team, Player, Member, PlayerPlaceholder

factory = APIRequestFactory()


class TeamInviteTestCase(TestCase):
fixtures = ['teams.json', 'players.json', 'members.json']

def setUp(self):
self.dev_team = Team.objects.get(name='Developer Team')
self.member_player = Player.objects.get(username='pflores6')
self.non_member_player = Player.objects.get(username='phawkins1')
self.url = '/api/teams/{0}/invite/'.format(self.dev_team.pk)

def test_request_by_non_member(self):
request = factory.post(self.url)
force_authenticate(request, user=self.non_member_player)
view = TeamViewSet.as_view({'post': 'invite'})
response = view(request, team=self.dev_team.pk)
response.render()
self.assertEqual(
response.status_code, status.HTTP_403_FORBIDDEN,
'expected HTTP 403 when non member sends invitation'
)

def test_request_with_missing_email(self):
request = factory.post(self.url)
force_authenticate(request, user=self.member_player)
view = TeamViewSet.as_view({'post': 'invite'})
response = view(request, pk=self.dev_team.pk)
response.render()
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, 'expected HTTP 400 on no email')

def test_invite_already_existing_member(self):
email = '[email protected]'
request = factory.post(self.url, data={'email': email, 'username': 'LFJ'})
force_authenticate(request, user=self.member_player)
view = TeamViewSet.as_view({'post': 'invite'})
response = view(request, pk=self.dev_team.pk)
response.render()
self.assertEqual(
response.status_code, status.HTTP_409_CONFLICT,
'expected HTTP 409 when inviting existing member'
)

def test_invite_existing_player(self):
email = self.non_member_player.email
username = 'PHS'
request = factory.post(self.url, data={'email': email, 'username': username})
force_authenticate(request, user=self.member_player)
view = TeamViewSet.as_view({'post': 'invite'})
response = view(request, pk=self.dev_team.pk)
response.render()
member = Member.objects.filter(team=self.dev_team, username=username)
self.assertEqual(member.count(), 1, 'Member should have been created')
self.assertEqual(member[0].player.id, self.non_member_player.id, 'Member should have player assigned')
self.assertNotEqual(member[0].activation_code, '', 'Activation code should be set')
self.assertEqual(
response.status_code, status.HTTP_201_CREATED,
'expected HTTP 201 - Created'
)

def test_invite_not_existing_player(self):
email = '[email protected]'
username = 'NEX'
request = factory.post(self.url, data={'email': email, 'username': username})
force_authenticate(request, user=self.member_player)
view = TeamViewSet.as_view({'post': 'invite'})
response = view(request, pk=self.dev_team.pk)
response.render()
member = Member.objects.filter(team=self.dev_team, username=username)
placeholder = PlayerPlaceholder.objects.filter(member=member, email=email)
self.assertEqual(member.count(), 1, 'Member should have been created')
self.assertTrue(placeholder.exists(), 'Player placeholder should have been created')
self.assertNotEqual(member[0].activation_code, '', 'Activation code should be set')
self.assertEqual(
response.status_code, status.HTTP_201_CREATED,
'expected HTTP 201 - Created'
)
18 changes: 18 additions & 0 deletions api/tests/test_team_matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class TeamMatchesEndpointTestCase(TestCase):

def setUp(self):
self.admin_user = Player.objects.get(username='admin')
self.user = Player.objects.get(username='pflores6')
self.user2 = Player.objects.get(username='blewis0')
self.dev_team = Team.objects.get(domain='dev')
self.fields = (
'id',
Expand Down Expand Up @@ -72,3 +74,19 @@ def test_count_points(self):
response_data = json.loads(str(response.content, encoding='utf8'))
self.assertEqual(response.status_code, status.HTTP_200_OK, 'expected HTTP 200')
self.assertEqual(response_data, {"red": 26, "blue": 26}, 'expected correct match points')

def test_allow_delete_match(self):
request = factory.delete('/api/teams/{0}/matches/{1}/'.format(self.dev_team.id, 9))
force_authenticate(request, user=self.user)
view = MatchViewSet.as_view({'delete': 'retrieve'})
response = view(request, parent_lookup_team=str(self.dev_team.id), pk=9)
response.render()
self.assertEqual(response.status_code, status.HTTP_200_OK, 'expected HTTP 200')

def test_deny_delete_match(self):
request = factory.delete('/api/teams/{0}/matches/{1}/'.format(self.dev_team.id, 9))
force_authenticate(request, user=self.user2)
view = MatchViewSet.as_view({'delete': 'retrieve'})
response = view(request, parent_lookup_team=str(self.dev_team.id), pk=9)
response.render()
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, 'expected HTTP 403')
74 changes: 71 additions & 3 deletions api/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from smtplib import SMTPException
from uuid import uuid4

from django.core.exceptions import ValidationError
from django.core.signing import BadSignature, SignatureExpired
from django.shortcuts import get_object_or_404
from django.forms.models import model_to_dict
from django.db.models import F
Expand All @@ -7,9 +12,18 @@
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework_extensions.mixins import NestedViewSetMixin

from api.emailing import send_invitation
from tfoosball.models import Member, Match, Player, Team
from .serializers import MatchSerializer, MemberSerializer, TeamSerializer, PlayerSerializer
from .permissions import MemberPermissions
from .permissions import MemberPermissions, AccessOwnTeamOnly, IsMatchOwner


def displayable(message):
return {
'shouldDisplay': True,
'message': message,
}


class StandardPagination(PageNumberPagination):
Expand Down Expand Up @@ -91,6 +105,56 @@ def join(self, request):
status=status.HTTP_409_CONFLICT
)

@list_route(methods=['post'])
def accept(self, request):
activation_code = request.data.get('activation_code', None)
if not activation_code:
return Response(displayable('Unable to activate user'), status=status.HTTP_400_BAD_REQUEST)
print(activation_code)
email, team_name, token, token2 = activation_code.split(':')
if request.user.email != email:
return Response(displayable('Unable to activate user'), status=status.HTTP_400_BAD_REQUEST)
try:
member = Member.objects.get(activation_code=activation_code)
member.activate()
except (ValidationError, BadSignature):
return Response(displayable('Unable to activate user'), status=status.HTTP_400_BAD_REQUEST)
except SignatureExpired:
return Response(
displayable('Activation code has expired after 48 hours'),
status=status.HTTP_400_BAD_REQUEST
)
return Response(displayable('User activated'), status=status.HTTP_201_CREATED)

@detail_route(methods=['post'], permission_classes=[AccessOwnTeamOnly])
def invite(self, request, pk=None):
username = request.data.get('username', f'user-{str(uuid4())[:8]}') # TODO Better default username
email = request.data.get('email', None)
team = Team.objects.get(pk=pk)
if not email:
return Response(displayable('You haven\'t provided an email'), status=status.HTTP_400_BAD_REQUEST)
is_member = team.member_set.filter(player__email=email).exists()
if is_member:
return Response(
displayable('User of email {0} is already a member of {1}'.format(email, team.name)),
status=status.HTTP_409_CONFLICT
)
member, placeholder = Member.create_member(username, email, pk, is_accepted=True, hidden=True)
activation_code = member.generate_activation_code()
try:
send_invitation(email, activation_code)
except SMTPException:
return Response(
'Unknown error while sending an invitation, please try again.',
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# TODO Revert member
else:
return Response(
displayable('Invitation was sent to {0}'.format(email)),
status=status.HTTP_201_CREATED
)


class MemberViewSet(NestedViewSetMixin, ModelViewSet):
serializer_class = MemberSerializer
Expand All @@ -101,7 +165,10 @@ def get_queryset(self):
team = self.kwargs.get('parent_lookup_team', None)
is_accepted = self.request.query_params.get('is_accepted', True)
pk = self.kwargs.get('pk', None)
if pk:
username = self.request.query_params.get('username', None)
if team and username:
return Member.objects.filter(team__id=team, username=username)
if pk or username:
return Member.objects.all()
if team:
return Member.objects.filter(
Expand All @@ -116,6 +183,7 @@ class MatchViewSet(ModelViewSet):
serializer_class = MatchSerializer
allowed_methods = [u'GET', u'POST', u'PUT', u'PATCH', u'DELETE', u'OPTIONS']
pagination_class = StandardPagination
permission_classes = [IsMatchOwner]

def get_queryset(self):
queryset = Match.objects.all()
Expand Down Expand Up @@ -158,7 +226,7 @@ def get_queryset(self):
queryset = Player.objects.all()
prefix = self.request.query_params.get('email_prefix', None)
if prefix:
queryset = queryset.filter(email__istartswith=prefix)
queryset = queryset.filter(email__istartswith=prefix)[:5]
return queryset

@detail_route(methods=['post'])
Expand Down
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
appdirs==1.4.3
defusedxml==0.5.0
dj-database-url==0.4.1
Django==1.10.6
Django==1.11.7
django-allauth==0.30.0
django-cors-headers==2.0.0
django-cors-middleware==1.3.1
django-filter==1.0.1
django-filter==1.1.0
django-rest-auth==0.9.1
django-websocket-redis==0.4.7
djangorestframework==3.5.4
djangorestframework==3.7.1
drf-extensions==0.3.1
flake8==3.3.0
#gevent==1.1.2
Expand Down
2 changes: 1 addition & 1 deletion runtime.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
python-3.5.2
python-3.6.2
10 changes: 8 additions & 2 deletions tfoosball/dev_settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from tfoosball.common_settings import * # NOQA
from tfoosball.common_settings import * # NOQA
from tfoosball.common_settings import BASE_DIR
import os


# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '7d!g5l*3nm1=2s@&%11d+jz_$#ii2bugj+9ynhq&cfl0r%pnn)'
Expand All @@ -13,7 +16,7 @@
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), # NOQA
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}

Expand Down Expand Up @@ -53,3 +56,6 @@
}
},
}

EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
EMAIL_FILE_PATH = os.path.join(BASE_DIR, 'emails.log')
20 changes: 20 additions & 0 deletions tfoosball/migrations/0029_member_activation_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-06-28 14:43
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tfoosball', '0028_auto_20170423_1833'),
]

operations = [
migrations.AddField(
model_name='member',
name='activation_code',
field=models.CharField(default='', max_length=28),
),
]
20 changes: 20 additions & 0 deletions tfoosball/migrations/0030_auto_20170628_1653.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-06-28 14:53
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tfoosball', '0029_member_activation_code'),
]

operations = [
migrations.AlterField(
model_name='member',
name='activation_code',
field=models.CharField(default='', max_length=40),
),
]
Loading

0 comments on commit dc82461

Please sign in to comment.