Commit 1890b5a76b6f9c26b60fd2c674ad453f19a98b5d
Exists in
master
Resolved conflict
Showing 9 changed files Side-by-side Diff
flashcards/admin.py
View file @
1890b5a
1 | 1 | from django.contrib import admin |
2 | 2 | from django.contrib.auth.admin import UserAdmin |
3 | 3 | from flashcards.models import Flashcard, UserFlashcard, Section, \ |
4 | - LecturePeriod, User, UserFlashcardQuiz | |
4 | + LecturePeriod, User, UserFlashcardQuiz, WhitelistedAddress, FlashcardHide | |
5 | 5 | |
6 | 6 | admin.site.register([ |
7 | 7 | Flashcard, |
8 | 8 | UserFlashcard, |
9 | 9 | UserFlashcardQuiz, |
10 | 10 | Section, |
11 | - LecturePeriod | |
11 | + LecturePeriod, | |
12 | + FlashcardHide, | |
13 | + WhitelistedAddress | |
12 | 14 | ]) |
13 | 15 | admin.site.register(User, UserAdmin) |
flashcards/models.py
View file @
1890b5a
1 | 1 | from math import log1p |
2 | 2 | from math import exp |
3 | +from math import e | |
3 | 4 | |
4 | 5 | from django.contrib.auth.models import AbstractUser, UserManager |
5 | 6 | from django.contrib.auth.tokens import default_token_generator |
6 | 7 | |
7 | 8 | |
8 | 9 | |
... | ... | @@ -10,15 +11,17 @@ |
10 | 11 | from django.core.validators import MinLengthValidator |
11 | 12 | from django.db import IntegrityError |
12 | 13 | from django.db.models import * |
13 | -from django.utils.log import getLogger | |
14 | 14 | from django.utils.timezone import now, make_aware |
15 | -from flashy.settings import QUARTER_START, QUARTER_END | |
15 | +from flashy.settings import QUARTER_START | |
16 | 16 | from simple_email_confirmation import SimpleEmailConfirmationUserMixin |
17 | 17 | from fields import MaskField |
18 | 18 | from cached_property import cached_property |
19 | 19 | from flashy.settings import IN_PRODUCTION |
20 | -from math import e | |
21 | 20 | |
21 | + | |
22 | + | |
23 | + | |
24 | + | |
22 | 25 | # Hack to fix AbstractUser before subclassing it |
23 | 26 | |
24 | 27 | AbstractUser._meta.get_field('email')._unique = True |
25 | 28 | |
26 | 29 | |
... | ... | @@ -42,21 +45,12 @@ |
42 | 45 | date_joined=now(), **extra_fields) |
43 | 46 | user.set_password(password) |
44 | 47 | user.save(using=self._db) |
48 | + user.send_confirmation_email() | |
45 | 49 | return user |
46 | 50 | |
47 | 51 | def create_user(self, email, password=None, **extra_fields): |
48 | 52 | user = self._create_user(email, password, False, False, **extra_fields) |
49 | - body = ''' | |
50 | - Visit the following link to confirm your email address: | |
51 | - https://flashy.cards/app/verifyemail/%s | |
52 | 53 | |
53 | - If you did not register for Flashy, no action is required. | |
54 | - ''' | |
55 | - | |
56 | - assert send_mail("Flashy email verification", | |
57 | - body % user.confirmation_key, | |
58 | - "noreply@flashy.cards", | |
59 | - [user.email]) | |
60 | 54 | return user |
61 | 55 | |
62 | 56 | def create_superuser(self, email, password, **extra_fields): |
... | ... | @@ -67,7 +61,6 @@ |
67 | 61 | pass |
68 | 62 | |
69 | 63 | |
70 | - | |
71 | 64 | class FlashcardNotInDeckException(Exception): |
72 | 65 | pass |
73 | 66 | |
... | ... | @@ -83,6 +76,15 @@ |
83 | 76 | sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in") |
84 | 77 | confirmed_email = BooleanField(default=False) |
85 | 78 | |
79 | + def send_confirmation_email(self): | |
80 | + body = ''' | |
81 | + Visit the following link to confirm your email address: | |
82 | + https://flashy.cards/app/verifyemail/%s | |
83 | + | |
84 | + If you did not register for Flashy, no action is required. | |
85 | + ''' | |
86 | + send_mail("Flashy email verification", body % self.confirmation_key, "noreply@flashy.cards", [self.email]) | |
87 | + | |
86 | 88 | def is_in_section(self, section): |
87 | 89 | return self.sections.filter(pk=section.pk).exists() |
88 | 90 | |
... | ... | @@ -107,6 +109,7 @@ |
107 | 109 | |
108 | 110 | try: |
109 | 111 | import flashcards.notifications |
112 | + | |
110 | 113 | user_card = UserFlashcard.objects.get(user=self, flashcard=flashcard) |
111 | 114 | user_card.delete() |
112 | 115 | flashcards.notifications.notify_score_change(flashcard) |
... | ... | @@ -140,7 +143,7 @@ |
140 | 143 | self.save() |
141 | 144 | |
142 | 145 | def by_retention(self, sections, material_date_begin, material_date_end): |
143 | - section_pks = map(lambda i: i['pk'], sections.values('pk')) | |
146 | + section_pks = sections.values_list('pk') | |
144 | 147 | user_flashcard_filter = UserFlashcard.objects.filter( |
145 | 148 | user=self, flashcard__section__pk__in=section_pks, |
146 | 149 | flashcard__material_date__gte=material_date_begin, |
... | ... | @@ -158,7 +161,7 @@ |
158 | 161 | output_field=FloatField() |
159 | 162 | ), |
160 | 163 | retention_score=Case( |
161 | - default=Value(e, output_field=FloatField()) ** (F('days_since')*(-0.1/(F('study_count')+1))), | |
164 | + default=Value(e, output_field=FloatField()) ** (F('days_since') * (-0.1 / (F('study_count') + 1))), | |
162 | 165 | output_field=FloatField() |
163 | 166 | ) |
164 | 167 | ).order_by('retention_score') |
... | ... | @@ -413,6 +416,9 @@ |
413 | 416 | :return: whether the user is on the waitlist for this section |
414 | 417 | """ |
415 | 418 | return self.whitelist.filter(email=user.email).exists() |
419 | + | |
420 | + def is_user_enrolled(self, user): | |
421 | + return self.user_set.filter(pk=user.pk).exists() | |
416 | 422 | |
417 | 423 | def enroll(self, user): |
418 | 424 | if user.sections.filter(pk=self.pk).exists(): |
flashcards/notifications.py
View file @
1890b5a
... | ... | @@ -26,4 +26,11 @@ |
26 | 26 | {'event_type': 'pull_card', 'flashcard': serializers.FlashcardSerializer(flashcard).data}) |
27 | 27 | message = RedisMessage(ws_message) |
28 | 28 | redis_publisher.publish_message(message) |
29 | + | |
30 | +def notify_deck_new_card(flashcard): | |
31 | + redis_publisher = RedisPublisher(facility='deck/%d' % flashcard.section_id, broadcast=True) | |
32 | + ws_message = JSONRenderer().render( | |
33 | + {'event_type': 'new_card', 'flashcard': serializers.FlashcardSerializer(flashcard).data}) | |
34 | + message = RedisMessage(ws_message) | |
35 | + redis_publisher.publish_message(message) |
flashcards/serializers.py
View file @
1890b5a
1 | -from json import dumps, loads | |
1 | +from json import loads | |
2 | +from collections import Iterable | |
2 | 3 | |
3 | 4 | from django.utils.datetime_safe import datetime |
4 | 5 | from django.utils.timezone import now |
5 | -from flashcards.models import Section, LecturePeriod, User, Flashcard, UserFlashcard, UserFlashcardQuiz | |
6 | +from flashcards.models import Section, LecturePeriod, User, Flashcard, UserFlashcardQuiz | |
6 | 7 | from flashcards.validators import FlashcardMask, OverlapIntervalException |
7 | 8 | from rest_framework import serializers |
8 | 9 | from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField, DateTimeField, empty, \ |
... | ... | @@ -10,7 +11,6 @@ |
10 | 11 | from rest_framework.serializers import ModelSerializer, Serializer, PrimaryKeyRelatedField, ListField |
11 | 12 | from rest_framework.validators import UniqueValidator |
12 | 13 | from flashy.settings import QUARTER_END, QUARTER_START |
13 | -from collections import Iterable | |
14 | 14 | |
15 | 15 | |
16 | 16 | class EmailSerializer(Serializer): |
17 | 17 | |
... | ... | @@ -46,11 +46,12 @@ |
46 | 46 | except User.DoesNotExist: |
47 | 47 | raise serializers.ValidationError('Could not verify reset token') |
48 | 48 | |
49 | +class EmailVerificationSerializer(Serializer): | |
50 | + confirmation_key = CharField() | |
49 | 51 | |
50 | 52 | class UserUpdateSerializer(Serializer): |
51 | 53 | old_password = CharField(required=False) |
52 | - new_password = CharField(required=False, allow_blank=False) | |
53 | - confirmation_key = CharField(required=False) | |
54 | + new_password = CharField(required=False, allow_blank=False)\ | |
54 | 55 | # reset_token = CharField(required=False) |
55 | 56 | |
56 | 57 | def validate(self, data): |
... | ... | @@ -58,12 +59,6 @@ |
58 | 59 | raise serializers.ValidationError('old_password is required to set a new_password') |
59 | 60 | return data |
60 | 61 | |
61 | - | |
62 | -class Password(Serializer): | |
63 | - email = EmailField(required=True) | |
64 | - password = CharField(required=True) | |
65 | - | |
66 | - | |
67 | 62 | class LecturePeriodSerializer(ModelSerializer): |
68 | 63 | class Meta: |
69 | 64 | model = LecturePeriod |
70 | 65 | |
... | ... | @@ -74,9 +69,20 @@ |
74 | 69 | lecture_times = CharField() |
75 | 70 | short_name = CharField() |
76 | 71 | long_name = CharField() |
72 | + can_enroll = SerializerMethodField() | |
73 | + is_enrolled = SerializerMethodField() | |
77 | 74 | |
78 | 75 | class Meta: |
79 | 76 | model = Section |
77 | + | |
78 | + def get_can_enroll(self, obj): | |
79 | + if 'user' not in self.context: return False | |
80 | + if not obj.is_whitelisted: return True | |
81 | + return obj.is_user_on_whitelist(self.context['user']) | |
82 | + | |
83 | + def get_is_enrolled(self, obj): | |
84 | + if 'user' not in self.context: return False | |
85 | + return obj.is_user_enrolled(self.context['user']) | |
80 | 86 | |
81 | 87 | |
82 | 88 | class DeepSectionSerializer(SectionSerializer): |
flashcards/tests/test_api.py
View file @
1890b5a
... | ... | @@ -120,13 +120,13 @@ |
120 | 120 | |
121 | 121 | # try activating with an invalid key |
122 | 122 | |
123 | - url = '/api/me/' | |
124 | - response = self.client.patch(url, {'confirmation_key': 'NOT A KEY'}) | |
123 | + url = '/api/verify_email/' | |
124 | + response = self.client.post(url, {'confirmation_key': 'NOT A KEY'}) | |
125 | 125 | self.assertContains(response, 'confirmation_key is invalid', status_code=400) |
126 | 126 | |
127 | 127 | # try activating with the valid key |
128 | - response = self.client.patch(url, {'confirmation_key': user.confirmation_key}) | |
129 | - self.assertTrue(response.data['is_confirmed']) | |
128 | + response = self.client.post(url, {'confirmation_key': user.confirmation_key}) | |
129 | + self.assertContains(response, '', status_code=204) | |
130 | 130 | |
131 | 131 | |
132 | 132 | class ProfileViewTest(APITestCase): |
flashcards/views.py
View file @
1890b5a
... | ... | @@ -4,13 +4,13 @@ |
4 | 4 | from django.utils.log import getLogger |
5 | 5 | from flashcards.api import StandardResultsSetPagination, IsEnrolledInAssociatedSection, IsFlashcardReviewer, \ |
6 | 6 | IsAuthenticatedAndConfirmed |
7 | -from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcard, UserFlashcardQuiz, \ | |
8 | - FlashcardAlreadyPulledException, FlashcardNotInDeckException, Now, interval_days | |
7 | +from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcardQuiz, \ | |
8 | + FlashcardAlreadyPulledException, FlashcardNotInDeckException | |
9 | 9 | from flashcards.notifications import notify_new_card |
10 | 10 | from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \ |
11 | 11 | PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer, \ |
12 | 12 | FlashcardUpdateSerializer, QuizRequestSerializer, QuizResponseSerializer, \ |
13 | - QuizAnswerRequestSerializer, DeepSectionSerializer, FeedRequestSerializer | |
13 | + QuizAnswerRequestSerializer, DeepSectionSerializer, EmailVerificationSerializer, FeedRequestSerializer | |
14 | 14 | from rest_framework.decorators import detail_route, permission_classes, api_view, list_route |
15 | 15 | from rest_framework.generics import ListAPIView, GenericAPIView |
16 | 16 | from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin |
... | ... | @@ -23,6 +23,7 @@ |
23 | 23 | from rest_framework.response import Response |
24 | 24 | from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied |
25 | 25 | from simple_email_confirmation import EmailAddress |
26 | +from simple_email_confirmation.models import EmailAddressManager | |
26 | 27 | |
27 | 28 | |
28 | 29 | def log_event(request, event=''): |
... | ... | @@ -102,7 +103,7 @@ |
102 | 103 | query = request.GET.get('q', None) |
103 | 104 | if not query: return Response('[]') |
104 | 105 | qs = Section.search(query.split(' '))[:20] |
105 | - data = SectionSerializer(qs, many=True).data | |
106 | + data = SectionSerializer(qs, many=True, context={'user': request.user}).data | |
106 | 107 | log_event(request, query) |
107 | 108 | return Response(data) |
108 | 109 | |
... | ... | @@ -149,7 +150,7 @@ |
149 | 150 | |
150 | 151 | def patch(self, request, format=None): |
151 | 152 | """ |
152 | - Updates the user's password, or verifies their email address | |
153 | + Updates the user's password | |
153 | 154 | --- |
154 | 155 | request_serializer: UserUpdateSerializer |
155 | 156 | response_serializer: UserSerializer |
... | ... | @@ -165,13 +166,6 @@ |
165 | 166 | request.user.save() |
166 | 167 | log_event(request, 'change password') |
167 | 168 | |
168 | - if 'confirmation_key' in data: | |
169 | - try: | |
170 | - request.user.confirm_email(data['confirmation_key']) | |
171 | - log_event(request, 'confirm email') | |
172 | - except EmailAddress.DoesNotExist: | |
173 | - raise ValidationError('confirmation_key is invalid') | |
174 | - | |
175 | 169 | return Response(UserSerializer(request.user).data) |
176 | 170 | |
177 | 171 | def get(self, request, format=None): |
... | ... | @@ -195,6 +189,32 @@ |
195 | 189 | |
196 | 190 | |
197 | 191 | @api_view(['POST']) |
192 | +@permission_classes([IsAuthenticated]) | |
193 | +def resend_confirmation_email(request): | |
194 | + "Resends a confirmation email to a user" | |
195 | + request.user.send_confirmation_email() | |
196 | + return Response(status=HTTP_204_NO_CONTENT) | |
197 | + | |
198 | + | |
199 | +@api_view(['POST']) | |
200 | +@permission_classes([IsAuthenticated]) | |
201 | +def verify_email(request): | |
202 | + """ | |
203 | + Accepts a user's email confirmation_key to verify their email address | |
204 | + --- | |
205 | + request_serializer: EmailVerificationSerializer | |
206 | + """ | |
207 | + try: | |
208 | + data = EmailVerificationSerializer(data=request.data) | |
209 | + data.is_valid(raise_exception=True) | |
210 | + email = EmailAddress.objects.confirm(data.validated_data['confirmation_key']) | |
211 | + log_event(request, 'confirm email' + str(email)) | |
212 | + return Response(status=HTTP_204_NO_CONTENT) | |
213 | + except EmailAddress.DoesNotExist: | |
214 | + raise ValidationError('confirmation_key is invalid') | |
215 | + | |
216 | + | |
217 | +@api_view(['POST']) | |
198 | 218 | def register(request, format=None): |
199 | 219 | """ |
200 | 220 | Register a new user |
201 | 221 | |
... | ... | @@ -395,8 +415,8 @@ |
395 | 415 | serializer = QuizRequestSerializer(data=request.data) |
396 | 416 | serializer.is_valid(raise_exception=True) |
397 | 417 | data = serializer.validated_data |
398 | - | |
399 | 418 | user_flashcard = request.user.by_retention(**data).first() |
419 | + | |
400 | 420 | mask = user_flashcard.get_mask().get_random_blank() |
401 | 421 | blanked_word = "" |
402 | 422 | if mask: |
flashy/settings.py
View file @
1890b5a
flashy/urls.py
View file @
1890b5a
1 | 1 | from django.conf.urls import include, url |
2 | 2 | from django.contrib import admin |
3 | 3 | from flashcards.views import SectionViewSet, UserDetail, FlashcardViewSet, UserSectionListView, request_password_reset, \ |
4 | - reset_password, logout, login, register, UserFlashcardQuizViewSet | |
4 | + reset_password, logout, login, register, UserFlashcardQuizViewSet, resend_confirmation_email, verify_email | |
5 | 5 | from flashy.frontend_serve import serve_with_default |
6 | 6 | from flashy.settings import DEBUG, IN_PRODUCTION |
7 | 7 | from rest_framework.routers import DefaultRouter |
... | ... | @@ -19,6 +19,8 @@ |
19 | 19 | url(r'^api/login/$', login), |
20 | 20 | url(r'^api/logout/$', logout), |
21 | 21 | url(r'^api/me/sections/', UserSectionListView.as_view()), |
22 | + url(r'^api/resend_confirmation_email/', resend_confirmation_email), | |
23 | + url(r'^api/verify_email/', verify_email), | |
22 | 24 | url(r'^api/request_password_reset/', request_password_reset), |
23 | 25 | url(r'^api/reset_password/', reset_password), |
24 | 26 | url(r'^api/', include(router.urls)), |