Commit 89c98de531159f7a0335d9f821e7d8649c943bd7

Authored by Rachel Lee
Exists in master

Merge branch 'master' of https://git.ucsd.edu/110swag/flashy-backend

Conflicts:
	flashcards/tests/test_api.py
	flashcards/views.py

Showing 7 changed files Inline Diff

Flashy requires Python 2. Srsly 1 1 Flashy requires Python 2. Srsly
2 2
All of these commands should be run from this directory (the one containing README.md) 3 3 All of these commands should be run from this directory (the one containing README.md)
4 4
5 Virtualenv for Windows creates a dir inexplicably named scripts rather than bin. So substitute venv/bin for venv/scripts if you are on Windows.
6
Install virtualenv before continuing. This is most easily accomplished with: 5 7 Install virtualenv before continuing. This is most easily accomplished with:
6 8
pip install virtualenv 7 9 pip install virtualenv
8 10
Set up the environment by running: 9 11 Set up the environment by running:
10 12
scripts/setup.sh 11 13 scripts/setup.sh
12 14
If you get errors about a module not being found, make sure you are working in the virtualenv. To enter the venv: 13 15 If you get errors about a module not being found, make sure you are working in the virtualenv. To enter the venv:
14 16
. venv/bin/activate 15 17 . venv/bin/activate
16 18
flashcards/models.py View file @ 89c98de
from django.contrib.auth.models import AbstractUser, UserManager 1 1 from django.contrib.auth.models import AbstractUser, UserManager
from django.core.exceptions import PermissionDenied 2 2 from django.core.exceptions import PermissionDenied
from django.db.models import * 3 3 from django.db.models import *
from django.utils.timezone import now 4 4 from django.utils.timezone import now
from simple_email_confirmation import SimpleEmailConfirmationUserMixin 5 5 from simple_email_confirmation import SimpleEmailConfirmationUserMixin
from fields import MaskField 6 6 from fields import MaskField
from datetime import datetime 7 7 from datetime import datetime
8 8
# Hack to fix AbstractUser before subclassing it 9 9 # Hack to fix AbstractUser before subclassing it
AbstractUser._meta.get_field('email')._unique = True 10 10 AbstractUser._meta.get_field('email')._unique = True
AbstractUser._meta.get_field('username')._unique = False 11 11 AbstractUser._meta.get_field('username')._unique = False
12 12
13 13
class EmailOnlyUserManager(UserManager): 14 14 class EmailOnlyUserManager(UserManager):
""" 15 15 """
A tiny extension of Django's UserManager which correctly creates users 16 16 A tiny extension of Django's UserManager which correctly creates users
without usernames (using emails instead). 17 17 without usernames (using emails instead).
""" 18 18 """
19 19
def _create_user(self, email, password, is_staff, is_superuser, **extra_fields): 20 20 def _create_user(self, email, password, is_staff, is_superuser, **extra_fields):
""" 21 21 """
Creates and saves a User with the given email and password. 22 22 Creates and saves a User with the given email and password.
""" 23 23 """
email = self.normalize_email(email) 24 24 email = self.normalize_email(email)
user = self.model(email=email, 25 25 user = self.model(email=email,
is_staff=is_staff, is_active=True, 26 26 is_staff=is_staff, is_active=True,
is_superuser=is_superuser, 27 27 is_superuser=is_superuser,
date_joined=now(), **extra_fields) 28 28 date_joined=now(), **extra_fields)
user.set_password(password) 29 29 user.set_password(password)
user.save(using=self._db) 30 30 user.save(using=self._db)
return user 31 31 return user
32 32
def create_user(self, email, password=None, **extra_fields): 33 33 def create_user(self, email, password=None, **extra_fields):
return self._create_user(email, password, False, False, **extra_fields) 34 34 return self._create_user(email, password, False, False, **extra_fields)
35 35
def create_superuser(self, email, password, **extra_fields): 36 36 def create_superuser(self, email, password, **extra_fields):
return self._create_user(email, password, True, True, **extra_fields) 37 37 return self._create_user(email, password, True, True, **extra_fields)
38 38
39 39
class User(AbstractUser, SimpleEmailConfirmationUserMixin): 40 40 class User(AbstractUser, SimpleEmailConfirmationUserMixin):
""" 41 41 """
An extension of Django's default user model. 42 42 An extension of Django's default user model.
We use email as the username field, and include enrolled sections here 43 43 We use email as the username field, and include enrolled sections here
""" 44 44 """
objects = EmailOnlyUserManager() 45 45 objects = EmailOnlyUserManager()
USERNAME_FIELD = 'email' 46 46 USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] 47 47 REQUIRED_FIELDS = []
sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in") 48 48 sections = ManyToManyField('Section', help_text="The sections which the user is enrolled in")
49 49
def is_in_section(self, section): 50 50 def is_in_section(self, section):
return section in self.sections.all() 51 51 return self.sections.filter(pk=section.pk).exists()
52 52
def pull(self, flashcard): 53 53 def pull(self, flashcard):
if not self.is_in_section(flashcard.section): 54 54 if not self.is_in_section(flashcard.section):
raise ValueError("User not in the section this flashcard belongs to") 55 55 raise ValueError("User not in the section this flashcard belongs to")
user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard) 56 56 user_card = UserFlashcard.objects.create(user=self, flashcard=flashcard)
user_card.pulled = datetime.now() 57 57 user_card.pulled = datetime.now()
user_card.save() 58 58 user_card.save()
59 59
60 def get_deck(self, section):
61 if not self.is_in_section(section):
62 raise ObjectDoesNotExist("User not enrolled in section")
63 return Flashcard.objects.all().filter(userflashcard__user=self).filter(section=section)
60 64
65
class UserFlashcard(Model): 61 66 class UserFlashcard(Model):
""" 62 67 """
Represents the relationship between a user and a flashcard by: 63 68 Represents the relationship between a user and a flashcard by:
1. A user has a flashcard in their deck 64 69 1. A user has a flashcard in their deck
2. A user used to have a flashcard in their deck 65 70 2. A user used to have a flashcard in their deck
3. A user has a flashcard hidden from them 66 71 3. A user has a flashcard hidden from them
""" 67 72 """
user = ForeignKey('User') 68 73 user = ForeignKey('User')
mask = MaskField(max_length=255, null=True, blank=True, default=None, 69 74 mask = MaskField(max_length=255, null=True, blank=True, default=None,
help_text="The user-specific mask on the card") 70 75 help_text="The user-specific mask on the card")
pulled = DateTimeField(blank=True, null=True, default=None, help_text="When the user pulled the card") 71 76 pulled = DateTimeField(blank=True, null=True, default=None, help_text="When the user pulled the card")
flashcard = ForeignKey('Flashcard') 72 77 flashcard = ForeignKey('Flashcard')
73 78
class Meta: 74 79 class Meta:
# There can be at most one UserFlashcard for each User and Flashcard 75 80 # There can be at most one UserFlashcard for each User and Flashcard
unique_together = (('user', 'flashcard'),) 76 81 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 77 82 index_together = ["user", "flashcard"]
# By default, order by most recently pulled 78 83 # By default, order by most recently pulled
ordering = ['-pulled'] 79 84 ordering = ['-pulled']
80 85
81 86
class FlashcardHide(Model): 82 87 class FlashcardHide(Model):
""" 83 88 """
Represents the property of a flashcard being hidden by a user. 84 89 Represents the property of a flashcard being hidden by a user.
Each instance of this class represents a single user hiding a single flashcard. 85 90 Each instance of this class represents a single user hiding a single flashcard.
If reason is null, the flashcard was just hidden. 86 91 If reason is null, the flashcard was just hidden.
If reason is not null, the flashcard was reported, and reason is the reason why it was reported. 87 92 If reason is not null, the flashcard was reported, and reason is the reason why it was reported.
""" 88 93 """
user = ForeignKey('User') 89 94 user = ForeignKey('User')
flashcard = ForeignKey('Flashcard') 90 95 flashcard = ForeignKey('Flashcard')
reason = CharField(max_length=255, blank=True, null=True) 91 96 reason = CharField(max_length=255, blank=True, null=True)
hidden = DateTimeField(auto_now_add=True) 92 97 hidden = DateTimeField(auto_now_add=True)
93 98
class Meta: 94 99 class Meta:
# There can only be one FlashcardHide object for each User and Flashcard 95 100 # There can only be one FlashcardHide object for each User and Flashcard
unique_together = (('user', 'flashcard'),) 96 101 unique_together = (('user', 'flashcard'),)
index_together = ["user", "flashcard"] 97 102 index_together = ["user", "flashcard"]
98 103
99 104
class Flashcard(Model): 100 105 class Flashcard(Model):
text = CharField(max_length=255, help_text='The text on the card') 101 106 text = CharField(max_length=255, help_text='The text on the card')
section = ForeignKey('Section', help_text='The section with which the card is associated') 102 107 section = ForeignKey('Section', help_text='The section with which the card is associated')
pushed = DateTimeField(auto_now_add=True, help_text="When the card was first pushed") 103 108 pushed = DateTimeField(auto_now_add=True, help_text="When the card was first pushed")
material_date = DateTimeField(default=now, help_text="The date with which the card is associated") 104 109 material_date = DateTimeField(default=now, help_text="The date with which the card is associated")
previous = ForeignKey('Flashcard', null=True, blank=True, 105 110 previous = ForeignKey('Flashcard', null=True, blank=True,
help_text="The previous version of this card, if one exists") 106 111 help_text="The previous version of this card, if one exists")
author = ForeignKey(User) 107 112 author = ForeignKey(User)
is_hidden = BooleanField(default=False) 108 113 is_hidden = BooleanField(default=False)
hide_reason = CharField(blank=True, max_length=255, help_text="Reason for hiding this card") 109 114 hide_reason = CharField(blank=True, max_length=255, help_text="Reason for hiding this card")
mask = MaskField(max_length=255, null=True, blank=True, help_text="The mask on the card") 110 115 mask = MaskField(max_length=255, null=True, blank=True, help_text="The mask on the card")
111 116
class Meta: 112 117 class Meta:
# By default, order by most recently pushed 113 118 # By default, order by most recently pushed
ordering = ['-pushed'] 114 119 ordering = ['-pushed']
115 120
def is_hidden_from(self, user): 116 121 def is_hidden_from(self, user):
""" 117 122 """
A card can be hidden globally, but if a user has the card in their deck, 118 123 A card can be hidden globally, but if a user has the card in their deck,
this visibility overrides a global hide. 119 124 this visibility overrides a global hide.
:param user: 120 125 :param user:
:return: Whether the card is hidden from the user. 121 126 :return: Whether the card is hidden from the user.
""" 122 127 """
result = user.userflashcard_set.filter(flashcard=self) 123 128 result = user.userflashcard_set.filter(flashcard=self)
if not result.exists(): return self.is_hidden 124 129 if not result.exists(): return self.is_hidden
return result[0].is_hidden() 125 130 return result[0].is_hidden()
126 131
def edit(self, user, new_flashcard): 127 132 def edit(self, user, new_flashcard):
""" 128 133 """
Creates a new flashcard if a new flashcard should be created when the given user edits this flashcard. 129 134 Creates a new flashcard if a new flashcard should be created when the given user edits this flashcard.
Sets up everything correctly so this object, when saved, will result in the appropriate changes. 130 135 Sets up everything correctly so this object, when saved, will result in the appropriate changes.
:param user: The user editing this card. 131 136 :param user: The user editing this card.
:param new_flashcard: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys. 132 137 :param new_flashcard: The new information, namely a dict containg 'material_date', 'text', and 'mask' keys.
""" 133 138 """
if not user.is_in_section(self.section): 134 139 if not user.is_in_section(self.section):
raise PermissionDenied("You don't have the permission to edit this card") 135 140 raise PermissionDenied("You don't have the permission to edit this card")
136 141
# content_changed is True iff either material_date or text were changed 137 142 # content_changed is True iff either material_date or text were changed
content_changed = False 138 143 content_changed = False
# create_new is True iff the user editing this card is the author of this card 139 144 # create_new is True iff the user editing this card is the author of this card
# and there are no other users with this card in their decks 140 145 # and there are no other users with this card in their decks
create_new = user != self.author or \ 141 146 create_new = user != self.author or \
UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists() 142 147 UserFlashcard.objects.filter(flashcard=self).exclude(user=user).exists()
143 148
if 'material_date' in new_flashcard and self.material_date != new_flashcard['material_date']: 144 149 if 'material_date' in new_flashcard and self.material_date != new_flashcard['material_date']:
content_changed |= True 145 150 content_changed |= True
self.material_date = new_flashcard['material_date'] 146 151 self.material_date = new_flashcard['material_date']
if 'text' in new_flashcard and self.text != new_flashcard['text']: 147 152 if 'text' in new_flashcard and self.text != new_flashcard['text']:
content_changed |= True 148 153 content_changed |= True
self.text = new_flashcard['text'] 149 154 self.text = new_flashcard['text']
if create_new and content_changed: 150 155 if create_new and content_changed:
self.pk = None 151 156 self.pk = None
if 'mask' in new_flashcard: 152 157 if 'mask' in new_flashcard:
self.mask = new_flashcard['mask'] 153 158 self.mask = new_flashcard['mask']
159 self.save()
154 160
@classmethod 155 161 @classmethod
def cards_visible_to(cls, user): 156 162 def cards_visible_to(cls, user):
""" 157 163 """
:param user: 158 164 :param user:
:return: A queryset with all cards that should be visible to a user. 159 165 :return: A queryset with all cards that should be visible to a user.
""" 160 166 """
return cls.objects.filter(is_hidden=False).exclude(userflashcard__user=user, userflashcard__pulled=None) 161 167 return cls.objects.filter(is_hidden=False).exclude(flashcardhide__user=user)
162 168
163 169
class UserFlashcardQuiz(Model): 164 170 class UserFlashcardQuiz(Model):
""" 165 171 """
An event of a user being quizzed on a flashcard. 166 172 An event of a user being quizzed on a flashcard.
""" 167 173 """
user_flashcard = ForeignKey(UserFlashcard) 168 174 user_flashcard = ForeignKey(UserFlashcard)
when = DateTimeField(auto_now=True) 169 175 when = DateTimeField(auto_now=True)
blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked") 170 176 blanked_word = CharField(max_length=8, blank=True, help_text="The character range which was blanked")
response = CharField(max_length=255, blank=True, null=True, help_text="The user's response") 171 177 response = CharField(max_length=255, blank=True, null=True, help_text="The user's response")
correct = NullBooleanField(help_text="The user's self-evaluation of their response") 172 178 correct = NullBooleanField(help_text="The user's self-evaluation of their response")
173 179
def status(self): 174 180 def status(self):
""" 175 181 """
There are three stages of a quiz object: 176 182 There are three stages of a quiz object:
1. the user has been shown the card 177 183 1. the user has been shown the card
2. the user has answered the card 178 184 2. the user has answered the card
3. the user has self-evaluated their response's correctness 179 185 3. the user has self-evaluated their response's correctness
180 186
:return: string (evaluated, answered, viewed) 181 187 :return: string (evaluated, answered, viewed)
""" 182 188 """
if self.correct is not None: return "evaluated" 183 189 if self.correct is not None: return "evaluated"
if self.response: return "answered" 184 190 if self.response: return "answered"
return "viewed" 185 191 return "viewed"
186 192
187 193
class Section(Model): 188 194 class Section(Model):
""" 189 195 """
A UCSD course taught by an instructor during a quarter. 190 196 A UCSD course taught by an instructor during a quarter.
We use the term "section" to avoid collision with the builtin keyword "class" 191 197 We use the term "section" to avoid collision with the builtin keyword "class"
We index gratuitously to support autofill and because this is primarily read-only 192 198 We index gratuitously to support autofill and because this is primarily read-only
""" 193 199 """
department = CharField(db_index=True, max_length=50) 194 200 department = CharField(db_index=True, max_length=50)
department_abbreviation = CharField(db_index=True, max_length=10) 195 201 department_abbreviation = CharField(db_index=True, max_length=10)
course_num = CharField(db_index=True, max_length=6) 196 202 course_num = CharField(db_index=True, max_length=6)
course_title = CharField(db_index=True, max_length=50) 197 203 course_title = CharField(db_index=True, max_length=50)
instructor = CharField(db_index=True, max_length=100) 198 204 instructor = CharField(db_index=True, max_length=100)
quarter = CharField(db_index=True, max_length=4) 199 205 quarter = CharField(db_index=True, max_length=4)
200 206
@classmethod 201 207 @classmethod
def search(cls, terms): 202 208 def search(cls, terms):
""" 203 209 """
Search all fields of all sections for a particular set of terms 204 210 Search all fields of all sections for a particular set of terms
A matching section must match at least one field on each term 205 211 A matching section must match at least one field on each term
:param terms:iterable 206 212 :param terms:iterable
:return: Matching QuerySet ordered by department and course number 207 213 :return: Matching QuerySet ordered by department and course number
""" 208 214 """
final_q = Q() 209 215 final_q = Q()
for term in terms: 210 216 for term in terms:
q = Q(department__icontains=term) 211 217 q = Q(department__icontains=term)
q |= Q(department_abbreviation__icontains=term) 212 218 q |= Q(department_abbreviation__icontains=term)
q |= Q(course_title__icontains=term) 213 219 q |= Q(course_title__icontains=term)
q |= Q(course_num__icontains=term) 214 220 q |= Q(course_num__icontains=term)
q |= Q(instructor__icontains=term) 215 221 q |= Q(instructor__icontains=term)
final_q &= q 216 222 final_q &= q
qs = cls.objects.filter(final_q) 217 223 qs = cls.objects.filter(final_q)
# Have the database cast the course number to an integer so it will sort properly 218 224 # Have the database cast the course number to an integer so it will sort properly
# ECE 35 should rank before ECE 135 even though '135' < '35' lexicographically 219 225 # ECE 35 should rank before ECE 135 even though '135' < '35' lexicographically
qs = qs.extra(select={'course_num_int': "CAST(rtrim(course_num, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') AS INTEGER)"}) 220 226 qs = qs.extra(select={'course_num_int': "CAST(rtrim(course_num, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') AS INTEGER)"})
qs = qs.order_by('department_abbreviation', 'course_num_int') 221 227 qs = qs.order_by('department_abbreviation', 'course_num_int')
return qs 222 228 return qs
223 229
@property 224 230 @property
def is_whitelisted(self): 225 231 def is_whitelisted(self):
""" 226 232 """
:return: whether a whitelist exists for this section 227 233 :return: whether a whitelist exists for this section
""" 228 234 """
return self.whitelist.exists() 229 235 return self.whitelist.exists()
230 236
def is_user_on_whitelist(self, user): 231 237 def is_user_on_whitelist(self, user):
""" 232 238 """
:return: whether the user is on the waitlist for this section 233 239 :return: whether the user is on the waitlist for this section
""" 234 240 """
return self.whitelist.filter(email=user.email).exists() 235 241 return self.whitelist.filter(email=user.email).exists()
236 242
class Meta: 237 243 class Meta:
ordering = ['-course_title'] 238 244 ordering = ['-course_title']
239 245
@property 240 246 @property
def lecture_times(self): 241 247 def lecture_times(self):
lecture_periods = self.lectureperiod_set.all() 242 248 lecture_periods = self.lectureperiod_set.all()
if not lecture_periods.exists(): return '' 243 249 if not lecture_periods.exists(): return ''
flashcards/serializers.py View file @ 89c98de
from django.utils.datetime_safe import datetime 1 1 from django.utils.datetime_safe import datetime
from django.utils.timezone import now 2 2 from django.utils.timezone import now
import pytz 3 3 import pytz
from flashcards.models import Section, LecturePeriod, User, Flashcard 4 4 from flashcards.models import Section, LecturePeriod, User, Flashcard
from flashcards.validators import FlashcardMask, OverlapIntervalException 5 5 from flashcards.validators import FlashcardMask, OverlapIntervalException
from rest_framework import serializers 6 6 from rest_framework import serializers
from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField, DateTimeField 7 7 from rest_framework.fields import EmailField, BooleanField, CharField, IntegerField, DateTimeField
from rest_framework.relations import HyperlinkedRelatedField 8 8 from rest_framework.relations import HyperlinkedRelatedField
from rest_framework.serializers import ModelSerializer, Serializer 9 9 from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.validators import UniqueValidator 10 10 from rest_framework.validators import UniqueValidator
from json import dumps, loads 11 11 from json import dumps, loads
12 12
13 13
class EmailSerializer(Serializer): 14 14 class EmailSerializer(Serializer):
email = EmailField(required=True) 15 15 email = EmailField(required=True)
16 16
17 17
class EmailPasswordSerializer(EmailSerializer): 18 18 class EmailPasswordSerializer(EmailSerializer):
password = CharField(required=True) 19 19 password = CharField(required=True)
20 20
21 21
class RegistrationSerializer(EmailPasswordSerializer): 22 22 class RegistrationSerializer(EmailPasswordSerializer):
email = EmailField(required=True, validators=[UniqueValidator(queryset=User.objects.all())]) 23 23 email = EmailField(required=True, validators=[UniqueValidator(queryset=User.objects.all())])
24 24
25 25
class PasswordResetRequestSerializer(EmailSerializer): 26 26 class PasswordResetRequestSerializer(EmailSerializer):
def validate_email(self, value): 27 27 def validate_email(self, value):
try: 28 28 try:
User.objects.get(email=value) 29 29 User.objects.get(email=value)
return value 30 30 return value
except User.DoesNotExist: 31 31 except User.DoesNotExist:
raise serializers.ValidationError('No user exists with that email') 32 32 raise serializers.ValidationError('No user exists with that email')
33 33
34 34
class PasswordResetSerializer(Serializer): 35 35 class PasswordResetSerializer(Serializer):
new_password = CharField(required=True, allow_blank=False) 36 36 new_password = CharField(required=True, allow_blank=False)
uid = IntegerField(required=True) 37 37 uid = IntegerField(required=True)
token = CharField(required=True) 38 38 token = CharField(required=True)
39 39
def validate_uid(self, value): 40 40 def validate_uid(self, value):
try: 41 41 try:
User.objects.get(id=value) 42 42 User.objects.get(id=value)
return value 43 43 return value
except User.DoesNotExist: 44 44 except User.DoesNotExist:
raise serializers.ValidationError('Could not verify reset token') 45 45 raise serializers.ValidationError('Could not verify reset token')
46 46
47 47
class UserUpdateSerializer(Serializer): 48 48 class UserUpdateSerializer(Serializer):
old_password = CharField(required=False) 49 49 old_password = CharField(required=False)
new_password = CharField(required=False, allow_blank=False) 50 50 new_password = CharField(required=False, allow_blank=False)
confirmation_key = CharField(required=False) 51 51 confirmation_key = CharField(required=False)
# reset_token = CharField(required=False) 52 52 # reset_token = CharField(required=False)
53 53
def validate(self, data): 54 54 def validate(self, data):
if 'new_password' in data and 'old_password' not in data: 55 55 if 'new_password' in data and 'old_password' not in data:
raise serializers.ValidationError('old_password is required to set a new_password') 56 56 raise serializers.ValidationError('old_password is required to set a new_password')
return data 57 57 return data
58 58
59 59
class Password(Serializer): 60 60 class Password(Serializer):
email = EmailField(required=True) 61 61 email = EmailField(required=True)
password = CharField(required=True) 62 62 password = CharField(required=True)
63 63
64 64
class LecturePeriodSerializer(ModelSerializer): 65 65 class LecturePeriodSerializer(ModelSerializer):
class Meta: 66 66 class Meta:
model = LecturePeriod 67 67 model = LecturePeriod
exclude = 'id', 'section' 68 68 exclude = 'id', 'section'
69 69
70 70
class SectionSerializer(ModelSerializer): 71 71 class SectionSerializer(ModelSerializer):
lectures = LecturePeriodSerializer(source='lectureperiod_set', many=True, read_only=True) 72 72 lectures = LecturePeriodSerializer(source='lectureperiod_set', many=True, read_only=True)
lecture_times = CharField() 73 73 lecture_times = CharField()
short_name = CharField() 74 74 short_name = CharField()
long_name = CharField() 75 75 long_name = CharField()
76 76
class Meta: 77 77 class Meta:
model = Section 78 78 model = Section
79 79
80 80
class UserSerializer(ModelSerializer): 81 81 class UserSerializer(ModelSerializer):
email = EmailField(required=False) 82 82 email = EmailField(required=False)
sections = HyperlinkedRelatedField(queryset=Section.objects.all(), many=True, view_name='section-detail') 83 83 sections = HyperlinkedRelatedField(queryset=Section.objects.all(), many=True, view_name='section-detail')
is_confirmed = BooleanField() 84 84 is_confirmed = BooleanField()
85 85
class Meta: 86 86 class Meta:
model = User 87 87 model = User
fields = ("sections", "email", "is_confirmed", "last_login", "date_joined") 88 88 fields = ("sections", "email", "is_confirmed", "last_login", "date_joined")
89 89
90 90
class MaskFieldSerializer(serializers.Field): 91 91 class MaskFieldSerializer(serializers.Field):
default_error_messages = { 92 92 default_error_messages = {
'max_length': 'Ensure this field has no more than {max_length} characters.', 93 93 'max_length': 'Ensure this field has no more than {max_length} characters.',
'interval': 'Ensure this field has valid intervals.', 94 94 'interval': 'Ensure this field has valid intervals.',
'overlap': 'Ensure this field does not have overlapping intervals.' 95 95 'overlap': 'Ensure this field does not have overlapping intervals.'
} 96 96 }
97 97
def to_representation(self, value): 98 98 def to_representation(self, value):
return dumps(list(self._make_mask(value))) 99 99 return dumps(list(self._make_mask(value)))
100 100
def to_internal_value(self, value): 101 101 def to_internal_value(self, value):
return self._make_mask(loads(value)) 102 102 return self._make_mask(loads(value))
103 103
def _make_mask(self, data): 104 104 def _make_mask(self, data):
try: 105 105 try:
mask = FlashcardMask(data) 106 106 mask = FlashcardMask(data)
except ValueError: 107 107 except ValueError:
raise serializers.ValidationError("Invalid JSON for MaskField") 108 108 raise serializers.ValidationError("Invalid JSON for MaskField")
except TypeError: 109 109 except TypeError:
raise serializers.ValidationError("Invalid data for MaskField.") 110 110 raise serializers.ValidationError("Invalid data for MaskField.")
except OverlapIntervalException: 111 111 except OverlapIntervalException:
raise serializers.ValidationError("Invalid intervals for MaskField data.") 112 112 raise serializers.ValidationError("Invalid intervals for MaskField data.")
if len(mask) > 32: 113 113 if len(mask) > 32:
raise serializers.ValidationError("Too many intervals in the mask.") 114 114 raise serializers.ValidationError("Too many intervals in the mask.")
return mask 115 115 return mask
116 116
117 117
class FlashcardSerializer(ModelSerializer): 118 118 class FlashcardSerializer(ModelSerializer):
is_hidden = BooleanField(read_only=True) 119 119 is_hidden = BooleanField(read_only=True)
hide_reason = CharField(read_only=True) 120 120 hide_reason = CharField(read_only=True)
material_date = DateTimeField(default=now) 121 121 material_date = DateTimeField(default=now)
mask = MaskFieldSerializer() 122 122 mask = MaskFieldSerializer()
123 123
def validate_material_date(self, value): 124 124 def validate_material_date(self, value):
utc = pytz.UTC 125 125 utc = pytz.UTC
# TODO: make this dynamic 126 126 # TODO: make this dynamic
quarter_start = utc.localize(datetime(2015, 3, 15)) 127 127 quarter_start = utc.localize(datetime(2015, 3, 15))
quarter_end = utc.localize(datetime(2015, 6, 15)) 128 128 quarter_end = utc.localize(datetime(2015, 6, 15))
129 129
if quarter_start <= value <= quarter_end: 130 130 if quarter_start <= value <= quarter_end:
return value 131 131 return value
else: 132 132 else:
raise serializers.ValidationError("Material date is outside allowed range for this quarter") 133 133 raise serializers.ValidationError("Material date is outside allowed range for this quarter")
134 134
def validate_previous(self, value): 135 135 def validate_previous(self, value):
if value is None: 136 136 if value is None:
return value 137 137 return value
if Flashcard.objects.filter(pk=value).count() > 0: 138 138 if Flashcard.objects.filter(pk=value).count() > 0:
return value 139 139 return value
raise serializers.ValidationError("Invalid previous Flashcard object") 140 140 raise serializers.ValidationError("Invalid previous Flashcard object")
flashcards/tests/test_api.py View file @ 89c98de
from django.core import mail 1 1 from django.core import mail
from flashcards.models import * 2 2 from flashcards.models import *
from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK, HTTP_403_FORBIDDEN 3 3 from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED, HTTP_200_OK, HTTP_403_FORBIDDEN
from rest_framework.test import APITestCase 4 4 from rest_framework.test import APITestCase
from re import search 5 5 from re import search
from django.utils.timezone import now 6 6 from django.utils.timezone import now
7 7
8 8
class LoginTests(APITestCase): 9 9 class LoginTests(APITestCase):
fixtures = ['testusers'] 10 10 fixtures = ['testusers']
11 11
def test_login(self): 12 12 def test_login(self):
url = '/api/login' 13 13 url = '/api/login'
data = {'email': 'none@none.com', 'password': '1234'} 14 14 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 15 15 response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, HTTP_200_OK) 16 16 self.assertEqual(response.status_code, HTTP_200_OK)
17 17
data = {'email': 'none@none.com', 'password': '4321'} 18 18 data = {'email': 'none@none.com', 'password': '4321'}
response = self.client.post(url, data, format='json') 19 19 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Invalid email or password', status_code=403) 20 20 self.assertContains(response, 'Invalid email or password', status_code=403)
21 21
data = {'email': 'bad@none.com', 'password': '1234'} 22 22 data = {'email': 'bad@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 23 23 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Invalid email or password', status_code=403) 24 24 self.assertContains(response, 'Invalid email or password', status_code=403)
25 25
data = {'password': '4321'} 26 26 data = {'password': '4321'}
response = self.client.post(url, data, format='json') 27 27 response = self.client.post(url, data, format='json')
self.assertContains(response, 'email', status_code=400) 28 28 self.assertContains(response, 'email', status_code=400)
29 29
data = {'email': 'none@none.com'} 30 30 data = {'email': 'none@none.com'}
response = self.client.post(url, data, format='json') 31 31 response = self.client.post(url, data, format='json')
self.assertContains(response, 'password', status_code=400) 32 32 self.assertContains(response, 'password', status_code=400)
33 33
user = User.objects.get(email="none@none.com") 34 34 user = User.objects.get(email="none@none.com")
user.is_active = False 35 35 user.is_active = False
user.save() 36 36 user.save()
37 37
data = {'email': 'none@none.com', 'password': '1234'} 38 38 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 39 39 response = self.client.post(url, data, format='json')
self.assertContains(response, 'Account is disabled', status_code=403) 40 40 self.assertContains(response, 'Account is disabled', status_code=403)
41 41
def test_logout(self): 42 42 def test_logout(self):
self.client.login(email='none@none.com', password='1234') 43 43 self.client.login(email='none@none.com', password='1234')
response = self.client.post('/api/logout') 44 44 response = self.client.post('/api/logout')
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) 45 45 self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
46 46
# since we're not logged in, we should get a 403 response 47 47 # since we're not logged in, we should get a 403 response
response = self.client.get('/api/me', format='json') 48 48 response = self.client.get('/api/me', format='json')
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) 49 49 self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
50 50
51 51
class PasswordResetTest(APITestCase): 52 52 class PasswordResetTest(APITestCase):
fixtures = ['testusers'] 53 53 fixtures = ['testusers']
54 54
def test_reset_password(self): 55 55 def test_reset_password(self):
# submit the request to reset the password 56 56 # submit the request to reset the password
url = '/api/request_password_reset' 57 57 url = '/api/request_password_reset'
post_data = {'email': 'none@none.com'} 58 58 post_data = {'email': 'none@none.com'}
self.client.post(url, post_data, format='json') 59 59 self.client.post(url, post_data, format='json')
self.assertEqual(len(mail.outbox), 1) 60 60 self.assertEqual(len(mail.outbox), 1)
self.assertIn('reset your password', mail.outbox[0].body) 61 61 self.assertIn('reset your password', mail.outbox[0].body)
62 62
# capture the reset token from the email 63 63 # capture the reset token from the email
capture = search('https://flashy.cards/app/reset_password/(\d+)/(.*)', 64 64 capture = search('https://flashy.cards/app/reset_password/(\d+)/(.*)',
mail.outbox[0].body) 65 65 mail.outbox[0].body)
patch_data = {'new_password': '4321'} 66 66 patch_data = {'new_password': '4321'}
patch_data['uid'] = capture.group(1) 67 67 patch_data['uid'] = capture.group(1)
reset_token = capture.group(2) 68 68 reset_token = capture.group(2)
69 69
# try to reset the password with the wrong reset token 70 70 # try to reset the password with the wrong reset token
patch_data['token'] = 'wrong_token' 71 71 patch_data['token'] = 'wrong_token'
url = '/api/reset_password' 72 72 url = '/api/reset_password'
response = self.client.post(url, patch_data, format='json') 73 73 response = self.client.post(url, patch_data, format='json')
self.assertContains(response, 'Could not verify reset token', status_code=400) 74 74 self.assertContains(response, 'Could not verify reset token', status_code=400)
75 75
# try to reset the password with the correct token 76 76 # try to reset the password with the correct token
patch_data['token'] = reset_token 77 77 patch_data['token'] = reset_token
response = self.client.post(url, patch_data, format='json') 78 78 response = self.client.post(url, patch_data, format='json')
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) 79 79 self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
user = User.objects.get(id=patch_data['uid']) 80 80 user = User.objects.get(id=patch_data['uid'])
assert user.check_password(patch_data['new_password']) 81 81 assert user.check_password(patch_data['new_password'])
82 82
83 83
class RegistrationTest(APITestCase): 84 84 class RegistrationTest(APITestCase):
def test_create_account(self): 85 85 def test_create_account(self):
url = '/api/register' 86 86 url = '/api/register'
87 87
# missing password 88 88 # missing password
data = {'email': 'none@none.com'} 89 89 data = {'email': 'none@none.com'}
response = self.client.post(url, data, format='json') 90 90 response = self.client.post(url, data, format='json')
self.assertContains(response, 'password', status_code=400) 91 91 self.assertContains(response, 'password', status_code=400)
92 92
# missing email 93 93 # missing email
data = {'password': '1234'} 94 94 data = {'password': '1234'}
response = self.client.post(url, data, format='json') 95 95 response = self.client.post(url, data, format='json')
self.assertContains(response, 'email', status_code=400) 96 96 self.assertContains(response, 'email', status_code=400)
97 97
# create a user 98 98 # create a user
data = {'email': 'none@none.com', 'password': '1234'} 99 99 data = {'email': 'none@none.com', 'password': '1234'}
response = self.client.post(url, data, format='json') 100 100 response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, HTTP_201_CREATED) 101 101 self.assertEqual(response.status_code, HTTP_201_CREATED)
102 102
# user should not be confirmed 103 103 # user should not be confirmed
user = User.objects.get(email="none@none.com") 104 104 user = User.objects.get(email="none@none.com")
self.assertFalse(user.is_confirmed) 105 105 self.assertFalse(user.is_confirmed)
106 106
# check that the confirmation key was sent 107 107 # check that the confirmation key was sent
self.assertEqual(len(mail.outbox), 1) 108 108 self.assertEqual(len(mail.outbox), 1)
self.assertIn(user.confirmation_key, mail.outbox[0].body) 109 109 self.assertIn(user.confirmation_key, mail.outbox[0].body)
110 110
# log the user out 111 111 # log the user out
self.client.logout() 112 112 self.client.logout()
113 113
# log the user in with their registered credentials 114 114 # log the user in with their registered credentials
self.client.login(email='none@none.com', password='1234') 115 115 self.client.login(email='none@none.com', password='1234')
116 116
# try activating with an invalid key 117 117 # try activating with an invalid key
118 118
url = '/api/me' 119 119 url = '/api/me'
response = self.client.patch(url, {'confirmation_key': 'NOT A KEY'}) 120 120 response = self.client.patch(url, {'confirmation_key': 'NOT A KEY'})
self.assertContains(response, 'confirmation_key is invalid', status_code=400) 121 121 self.assertContains(response, 'confirmation_key is invalid', status_code=400)
122 122
# try activating with the valid key 123 123 # try activating with the valid key
response = self.client.patch(url, {'confirmation_key': user.confirmation_key}) 124 124 response = self.client.patch(url, {'confirmation_key': user.confirmation_key})
self.assertTrue(response.data['is_confirmed']) 125 125 self.assertTrue(response.data['is_confirmed'])
126 126
127 127
class ProfileViewTest(APITestCase): 128 128 class ProfileViewTest(APITestCase):
fixtures = ['testusers'] 129 129 fixtures = ['testusers']
130 130
def test_get_me(self): 131 131 def test_get_me(self):
url = '/api/me' 132 132 url = '/api/me'
response = self.client.get(url, format='json') 133 133 response = self.client.get(url, format='json')
# since we're not logged in, we shouldn't be able to see this 134 134 # since we're not logged in, we shouldn't be able to see this
self.assertEqual(response.status_code, 403) 135 135 self.assertEqual(response.status_code, 403)
136 136
self.client.login(email='none@none.com', password='1234') 137 137 self.client.login(email='none@none.com', password='1234')
response = self.client.get(url, format='json') 138 138 response = self.client.get(url, format='json')
self.assertEqual(response.status_code, HTTP_200_OK) 139 139 self.assertEqual(response.status_code, HTTP_200_OK)
140 140
141 141
class PasswordChangeTest(APITestCase): 142 142 class PasswordChangeTest(APITestCase):
fixtures = ['testusers'] 143 143 fixtures = ['testusers']
144 144
def test_change_password(self): 145 145 def test_change_password(self):
url = '/api/me' 146 146 url = '/api/me'
user = User.objects.get(email='none@none.com') 147 147 user = User.objects.get(email='none@none.com')
self.assertTrue(user.check_password('1234')) 148 148 self.assertTrue(user.check_password('1234'))
149 149
response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json') 150 150 response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json')
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) 151 151 self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
152 152
self.client.login(email='none@none.com', password='1234') 153 153 self.client.login(email='none@none.com', password='1234')
response = self.client.patch(url, {'new_password': '4321'}, format='json') 154 154 response = self.client.patch(url, {'new_password': '4321'}, format='json')
self.assertContains(response, 'old_password is required', status_code=400) 155 155 self.assertContains(response, 'old_password is required', status_code=400)
156 156
response = self.client.patch(url, {'new_password': '4321', 'old_password': '4321'}, format='json') 157 157 response = self.client.patch(url, {'new_password': '4321', 'old_password': '4321'}, format='json')
self.assertContains(response, 'old_password is incorrect', status_code=400) 158 158 self.assertContains(response, 'old_password is incorrect', status_code=400)
159 159
response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json') 160 160 response = self.client.patch(url, {'new_password': '4321', 'old_password': '1234'}, format='json')
self.assertEqual(response.status_code, 200) 161 161 self.assertEqual(response.status_code, 200)
user = User.objects.get(email='none@none.com') 162 162 user = User.objects.get(email='none@none.com')
163 163
self.assertFalse(user.check_password('1234')) 164 164 self.assertFalse(user.check_password('1234'))
self.assertTrue(user.check_password('4321')) 165 165 self.assertTrue(user.check_password('4321'))
166 166
167 167
class DeleteUserTest(APITestCase): 168 168 class DeleteUserTest(APITestCase):
fixtures = ['testusers'] 169 169 fixtures = ['testusers']
170 170
def test_delete_user(self): 171 171 def test_delete_user(self):
url = '/api/me' 172 172 url = '/api/me'
user = User.objects.get(email='none@none.com') 173 173 user = User.objects.get(email='none@none.com')
174 174
self.client.login(email='none@none.com', password='1234') 175 175 self.client.login(email='none@none.com', password='1234')
self.client.delete(url) 176 176 self.client.delete(url)
self.assertFalse(User.objects.filter(email='none@none.com').exists()) 177 177 self.assertFalse(User.objects.filter(email='none@none.com').exists())
178 178
179 179
class FlashcardDetailTest(APITestCase): 180 180 class FlashcardDetailTest(APITestCase):
fixtures = ['testusers', 'testsections'] 181 181 fixtures = ['testusers', 'testsections']
182 182
def setUp(self): 183 183 def setUp(self):
section = Section.objects.get(pk=1) 184 184 section = Section.objects.get(pk=1)
user = User.objects.get(email='none@none.com') 185 185 user = User.objects.get(email='none@none.com')
186 186
self.flashcard = Flashcard(text="jason", section=section, material_date=now(), author=user) 187 187 self.flashcard = Flashcard(text="jason", section=section, material_date=now(), author=user)
self.flashcard.save() 188 188 self.flashcard.save()
189 189
def test_get_flashcard(self): 190 190 def test_get_flashcard(self):
self.client.login(email='none@none.com', password='1234') 191 191 self.client.login(email='none@none.com', password='1234')
response = self.client.get("/api/flashcards/%d/" % self.flashcard.id, format="json") 192 192 response = self.client.get("/api/flashcards/%d/" % self.flashcard.id, format="json")
self.assertEqual(response.status_code, HTTP_200_OK) 193 193 self.assertEqual(response.status_code, HTTP_200_OK)
self.assertEqual(response.data["text"], "jason") 194 194 self.assertEqual(response.data["text"], "jason")
195 195
196 196
class SectionViewSetTest(APITestCase): 197 197 class SectionViewSetTest(APITestCase):
fixtures = ['testusers', 'testsections'] 198 198 fixtures = ['testusers', 'testsections']
199 199
def setUp(self): 200 200 def setUp(self):
self.client.login(email='none@none.com', password='1234') 201 201 self.client.login(email='none@none.com', password='1234')
self.user = User.objects.get(email='none@none.com') 202 202 self.user = User.objects.get(email='none@none.com')
self.flashcard = Flashcard(text="jason", section=Section.objects.get(pk=1), material_date=now(), author=self.user) 203 203 self.flashcard = Flashcard(text="jason", section=Section.objects.get(pk=1), material_date=now(), author=self.user)
self.flashcard.save() 204 204 self.flashcard.save()
205 self.section = Section.objects.get(pk=1)
205 206
def test_list_sections(self): 206 207 def test_list_sections(self):
response = self.client.get("/api/sections/", format="json") 207 208 response = self.client.get("/api/sections/", format="json")
flashcards/tests/test_models.py View file @ 89c98de
from datetime import datetime 1 1 from datetime import datetime
2 2
from django.test import TestCase 3 3 from django.test import TestCase
from flashcards.models import User, Section, Flashcard 4 4 from flashcards.models import User, Section, Flashcard, UserFlashcard
from flashcards.validators import OverlapIntervalException 5 5 from flashcards.validators import FlashcardMask, OverlapIntervalException
6 6
7 7
class RegistrationTests(TestCase): 8 8 class RegistrationTests(TestCase):
def setUp(self): 9 9 def setUp(self):
User.objects.create_user(email="none@none.com", password="1234") 10 10 User.objects.create_user(email="none@none.com", password="1234")
11 11
def test_email_confirmation(self): 12 12 def test_email_confirmation(self):
user = User.objects.get(email="none@none.com") 13 13 user = User.objects.get(email="none@none.com")
self.assertFalse(user.is_confirmed) 14 14 self.assertFalse(user.is_confirmed)
user.confirm_email(user.confirmation_key) 15 15 user.confirm_email(user.confirmation_key)
self.assertTrue(user.is_confirmed) 16 16 self.assertTrue(user.is_confirmed)
17 17
18 18
class UserTests(TestCase): 19 19 class UserTests(TestCase):
def setUp(self): 20 20 def setUp(self):
User.objects.create_user(email="none@none.com", password="1234") 21 21 User.objects.create_user(email="none@none.com", password="1234")
Section.objects.create(department='dept', 22 22 Section.objects.create(department='dept',
course_num='101a', 23 23 course_num='101a',
course_title='how 2 test', 24 24 course_title='how 2 test',
instructor='George Lucas', 25 25 instructor='George Lucas',
quarter='SP15') 26 26 quarter='SP15')
27 27
def test_section_list(self): 28 28 def test_section_list(self):
section = Section.objects.get(course_num='101a') 29 29 section = Section.objects.get(course_num='101a')
user = User.objects.get(email="none@none.com") 30 30 user = User.objects.get(email="none@none.com")
self.assertNotIn(section, user.sections.all()) 31 31 self.assertNotIn(section, user.sections.all())
user.sections.add(section) 32 32 user.sections.add(section)
self.assertIn(section, user.sections.all()) 33 33 self.assertIn(section, user.sections.all())
user.sections.add(section) 34 34 user.sections.add(section)
self.assertEqual(user.sections.count(), 1) 35 35 self.assertEqual(user.sections.count(), 1)
user.sections.remove(section) 36 36 user.sections.remove(section)
self.assertEqual(user.sections.count(), 0) 37 37 self.assertEqual(user.sections.count(), 0)
38 38
39 39
40 class FlashcardMaskTest(TestCase):
41 def test_iterable(self):
42 try:
43 FlashcardMask(1)
44 except TypeError as te:
45 self.assertEqual(te.message, "Interval not a valid iterable")
46 try:
47 FlashcardMask([1, 2, 4])
48 except TypeError as te:
49 self.assertEqual(te.message, "Interval not a valid iterable")
50
51 def test_interval(self):
52 try:
53 FlashcardMask([[1, 2, 3], [1]])
54 except TypeError as te:
55 self.assertEqual(te.message, "Intervals must have exactly 2 elements, begin and end")
56 try:
57 FlashcardMask([[1, 2], [1, 2, 4]])
58 except TypeError as te:
59 self.assertEqual(te.message, "Intervals must have exactly 2 elements, begin and end")
60 try:
61 FlashcardMask(([1, 2], [1]))
62 except TypeError as te:
63 self.assertEqual(te.message, "Intervals must have exactly 2 elements, begin and end")
64 try:
65 FlashcardMask("[1,2,3]")
66 except TypeError as te:
67 self.assertEqual(te.message, "Intervals must have exactly 2 elements, begin and end")
68
69 def test_overlap(self):
70 try:
71 FlashcardMask({(1, 2), (2, 5)})
72 except OverlapIntervalException as oie:
73 self.assertEqual(oie.message, "Invalid interval offsets in the mask")
74 try:
75 FlashcardMask({(1, 20), (12, 15)})
76 except OverlapIntervalException as oie:
77 self.assertEqual(oie.message, "Invalid interval offsets in the mask")
78 try:
79 FlashcardMask({(2, 1), (5, 2)})
80 except OverlapIntervalException as oie:
81 self.assertEqual(oie.message, "Invalid interval offsets in the mask")
82
83
84
class FlashcardTests(TestCase): 40 85 class FlashcardTests(TestCase):
def setUp(self): 41 86 def setUp(self):
user = User.objects.create_user(email="none@none.com", password="1234") 42
section = Section.objects.create(department='dept', 43 87 section = Section.objects.create(department='dept',
course_num='101a', 44 88 course_num='101a',
course_title='how 2 test', 45 89 course_title='how 2 test',
instructor='George Lucas', 46 90 instructor='George Lucas',
quarter='SP15') 47 91 quarter='SP15')
Flashcard.objects.create(text="This is the text of the Flashcard", 48 92 user = User.objects.create_user(email="none@none.com", password="1234")
section=section, 49 93 user.sections.add(section)
author=user, 50 94 flashcard = Flashcard.objects.create(text="This is the text of the Flashcard",
material_date=datetime.now(), 51 95 section=section,
previous=None, 52 96 author=user,
mask={(24,34), (0, 4)}) 53 97 material_date=datetime.now(),
98 previous=None,
99 mask={(24,34), (0, 4)})
100 user.save()
101 section.save()
102 flashcard.save()
54 103
def test_mask_field(self): 55 104 def test_flashcard_edit(self):
user = User.objects.get(email="none@none.com") 56 105 user = User.objects.get(email="none@none.com")
106 user2 = User.objects.create_user(email="wow@wow.com", password="wow")
section = Section.objects.get(course_title='how 2 test') 57 107 section = Section.objects.get(course_title='how 2 test')
108 user2.sections.add(section)
109 user2.save()
110 flashcard = Flashcard.objects.filter(author=user).get(text="This is the text of the Flashcard")
111 pk_backup = flashcard.pk
112 self.assertTrue(user.is_in_section(section))
113 flashcard.edit(user, {})
114 self.assertIsNotNone(flashcard.pk)
115 UserFlashcard.objects.create(user=user2, flashcard=flashcard).save()
116 flashcard.edit(user2, {'text': 'This is the new text'})
117 self.assertNotEqual(flashcard.pk, pk_backup)
118
119 def test_mask_field(self):
120 user = User.objects.get(email="none@none.com")
flashcard = Flashcard.objects.filter(author=user).get(text="This is the text of the Flashcard") 58 121 flashcard = Flashcard.objects.filter(author=user).get(text="This is the text of the Flashcard")
self.assertTrue(isinstance(flashcard.mask, set)) 59 122 self.assertTrue(isinstance(flashcard.mask, set))
self.assertTrue(all([isinstance(interval, tuple) for interval in flashcard.mask])) 60 123 self.assertTrue(all([isinstance(interval, tuple) for interval in flashcard.mask]))
blank1, blank2 = sorted(list(flashcard.mask)) 61 124 blank1, blank2 = sorted(list(flashcard.mask))
self.assertEqual(flashcard.text[slice(*blank1)], 'This') 62 125 self.assertEqual(flashcard.text[slice(*blank1)], 'This')
self.assertEqual(flashcard.text[slice(*blank2)], 'Flashcard') 63 126 self.assertEqual(flashcard.text[slice(*blank2)], 'Flashcard')
try: 64 127 try:
flashcard.mask = {(10, 34), (0, 14)} 65 128 flashcard.mask = {(10, 34), (0, 14)}
flashcard.save() 66 129 flashcard.save()
self.fail() 67 130 self.fail()
except OverlapIntervalException: 68 131 except OverlapIntervalException:
flashcards/validators.py View file @ 89c98de
from collections import Iterable 1 1 from collections import Iterable
2 2
3 3
class FlashcardMask(set): 4 4 class FlashcardMask(set):
def __init__(self, iterable, *args, **kwargs): 5 5 def __init__(self, iterable, *args, **kwargs):
6 self._iterable_check(iterable)
iterable = map(tuple, iterable) 6 7 iterable = map(tuple, iterable)
super(FlashcardMask, self).__init__(iterable, *args, **kwargs) 7 8 super(FlashcardMask, self).__init__(iterable, *args, **kwargs)
self._iterable_check() 8
self._interval_check() 9 9 self._interval_check()
self._overlap_check() 10 10 self._overlap_check()
11 11
def max_offset(self): 12 12 def max_offset(self):
return self._end 13 13 return self._end
14 14
def _iterable_check(self): 15 15 def _iterable_check(self, iterable):
if not all([isinstance(i, Iterable) for i in self]): 16 16 if not isinstance(iterable, Iterable) or not all([isinstance(i, Iterable) for i in iterable]):
raise TypeError("Interval not a valid iterable") 17 17 raise TypeError("Interval not a valid iterable")
18 18
def _interval_check(self): 19 19 def _interval_check(self):
if not all([len(i) == 2 for i in self]): 20 20 if not all([len(i) == 2 for i in self]):
raise TypeError("Intervals must have exactly 2 elements, begin and end") 21 21 raise TypeError("Intervals must have exactly 2 elements, begin and end")
22 22
def _overlap_check(self): 23 23 def _overlap_check(self):
p_beg, p_end = -1, -1 24 24 p_beg, p_end = -1, -1
for interval in sorted(self): 25 25 for interval in sorted(self):
beg, end = map(int, interval) 26 26 beg, end = map(int, interval)
if not (0 <= beg <= 255) or not (0 <= end <= 255) or not (beg <= end) or not (beg > p_end): 27 27 if not (0 <= beg <= 255) or not (0 <= end <= 255) or not (beg <= end) or not (beg > p_end):
raise OverlapIntervalException((beg, end), "Invalid interval offsets in the mask") 28 28 raise OverlapIntervalException((beg, end), "Invalid interval offsets in the mask")
p_beg, p_end = beg, end 29 29 p_beg, p_end = beg, end
self._end = end 30 30 self._end = p_end
31 31
32 32
class OverlapIntervalException(Exception): 33 33 class OverlapIntervalException(Exception):
def __init__(self, interval, reason): 34 34 def __init__(self, interval, message):
self.interval = interval 35 35 self.interval = interval
self.reason = reason 36 36 self.message = message
flashcards/views.py View file @ 89c98de
from django.contrib import auth 1 1 from django.contrib import auth
from flashcards.api import StandardResultsSetPagination 2 2 from flashcards.api import StandardResultsSetPagination
from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcard 3 3 from flashcards.models import Section, User, Flashcard, FlashcardHide, UserFlashcard
from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \ 4 4 from flashcards.serializers import SectionSerializer, UserUpdateSerializer, RegistrationSerializer, UserSerializer, \
PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer, \ 5 5 PasswordResetSerializer, PasswordResetRequestSerializer, EmailPasswordSerializer, FlashcardSerializer, \
FlashcardUpdateSerializer 6 6 FlashcardUpdateSerializer
from rest_framework.decorators import detail_route, permission_classes, api_view, list_route 7 7 from rest_framework.decorators import detail_route, permission_classes, api_view, list_route
from rest_framework.generics import ListAPIView, GenericAPIView 8 8 from rest_framework.generics import ListAPIView, GenericAPIView
from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin 9 9 from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin
from rest_framework.permissions import IsAuthenticated 10 10 from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet 11 11 from rest_framework.viewsets import ReadOnlyModelViewSet, GenericViewSet
from django.core.mail import send_mail 12 12 from django.core.mail import send_mail
from django.contrib.auth import authenticate 13 13 from django.contrib.auth import authenticate
from django.contrib.auth.tokens import default_token_generator 14 14 from django.contrib.auth.tokens import default_token_generator
from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED 15 15 from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_201_CREATED
from rest_framework.response import Response 16 16 from rest_framework.response import Response
from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied 17 17 from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated, ValidationError, PermissionDenied
from simple_email_confirmation import EmailAddress 18 18 from simple_email_confirmation import EmailAddress
from datetime import datetime 19
20 19
21 20
class SectionViewSet(ReadOnlyModelViewSet): 22 21 class SectionViewSet(ReadOnlyModelViewSet):
queryset = Section.objects.all() 23 22 queryset = Section.objects.all()
serializer_class = SectionSerializer 24 23 serializer_class = SectionSerializer
pagination_class = StandardResultsSetPagination 25 24 pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated] 26 25 permission_classes = [IsAuthenticated]
27 26
@detail_route(methods=['get'], permission_classes=[IsAuthenticated]) 28 27 @detail_route(methods=['get'], permission_classes=[IsAuthenticated])
def flashcards(self, request, pk): 29 28 def flashcards(self, request, pk):
""" 30 29 """
Gets flashcards for a section, excluding hidden cards. 31 30 Gets flashcards for a section, excluding hidden cards.
Returned in strictly chronological order (material date). 32 31 Returned in strictly chronological order (material date).
""" 33 32 """
33 <<<<<<< HEAD
flashcards = Flashcard.cards_visible_to(request.user).filter( \ 34 34 flashcards = Flashcard.cards_visible_to(request.user).filter( \
section=self.get_object()).all() 35 35 section=self.get_object()).all()
36 36
37 =======
38 flashcards = Flashcard.cards_visible_to(request.user).filter(section=self.get_object())
39 >>>>>>> 41819dd7ec2f22a04db83459c33e31c2b7d16d99
return Response(FlashcardSerializer(flashcards, many=True).data) 37 40 return Response(FlashcardSerializer(flashcards, many=True).data)
38 41
@detail_route(methods=['post'], permission_classes=[IsAuthenticated]) 39 42 @detail_route(methods=['post'], permission_classes=[IsAuthenticated])
def enroll(self, request, pk): 40 43 def enroll(self, request, pk):
""" 41 44 """
Add the current user to a specified section 42 45 Add the current user to a specified section
If the class has a whitelist, but the user is not on the whitelist, the request will fail. 43 46 If the class has a whitelist, but the user is not on the whitelist, the request will fail.
--- 44 47 ---
omit_serializer: true 45 48 omit_serializer: true
parameters: 46 49 parameters:
- fake: None 47 50 - fake: None
parameters_strategy: 48 51 parameters_strategy:
form: replace 49 52 form: replace
""" 50 53 """
section = self.get_object() 51 54 section = self.get_object()
if request.user.sections.filter(pk=section.pk).exists(): 52 55 if request.user.sections.filter(pk=section.pk).exists():
raise ValidationError("You are already in this section.") 53 56 raise ValidationError("You are already in this section.")
if section.is_whitelisted and not section.is_user_on_whitelist(request.user): 54 57 if section.is_whitelisted and not section.is_user_on_whitelist(request.user):
raise PermissionDenied("You must be on the whitelist to add this section.") 55 58 raise PermissionDenied("You must be on the whitelist to add this section.")
request.user.sections.add(section) 56 59 request.user.sections.add(section)
return Response(status=HTTP_204_NO_CONTENT) 57 60 return Response(status=HTTP_204_NO_CONTENT)
58 61
@detail_route(methods=['post'], permission_classes=[IsAuthenticated]) 59 62 @detail_route(methods=['post'], permission_classes=[IsAuthenticated])
def drop(self, request, pk): 60 63 def drop(self, request, pk):
""" 61 64 """
Remove the current user from a specified section 62 65 Remove the current user from a specified section
If the user is not in the class, the request will fail. 63 66 If the user is not in the class, the request will fail.
--- 64 67 ---
omit_serializer: true 65 68 omit_serializer: true
parameters: 66 69 parameters:
- fake: None 67 70 - fake: None
parameters_strategy: 68 71 parameters_strategy:
form: replace 69 72 form: replace
""" 70 73 """
section = self.get_object() 71 74 section = self.get_object()
if not section.user_set.filter(pk=request.user.pk).exists(): 72 75 if not section.user_set.filter(pk=request.user.pk).exists():
raise ValidationError("You are not in the section.") 73 76 raise ValidationError("You are not in the section.")
section.user_set.remove(request.user) 74 77 section.user_set.remove(request.user)
return Response(status=HTTP_204_NO_CONTENT) 75 78 return Response(status=HTTP_204_NO_CONTENT)
76 79
@list_route(methods=['get'], permission_classes=[IsAuthenticated]) 77 80 @list_route(methods=['get'], permission_classes=[IsAuthenticated])
def search(self, request): 78 81 def search(self, request):
query = request.GET.get('q', None) 79 82 query = request.GET.get('q', None)
if not query: return Response('[]') 80 83 if not query: return Response('[]')
qs = Section.search(query.split(' '))[:20] 81 84 qs = Section.search(query.split(' '))[:20]
serializer = SectionSerializer(qs, many=True) 82 85 serializer = SectionSerializer(qs, many=True)
return Response(serializer.data) 83 86 return Response(serializer.data)
84 87
@detail_route(methods=['get'], permission_classes=[IsAuthenticated]) 85 88 @detail_route(methods=['get'], permission_classes=[IsAuthenticated])
def deck(self, request, pk): 86 89 def deck(self, request, pk):
""" 87 90 """
Gets the contents of a user's deck for a given section. 88 91 Gets the contents of a user's deck for a given section.
""" 89 92 """
qs = Flashcard.objects.all() 90 93 qs = request.user.get_deck(self.get_object())
qs = qs.filter(userflashcard__user=request.user) 91
qs = qs.filter(section = self.get_object()) 92
serializer = FlashcardSerializer(qs, many=True) 93 94 serializer = FlashcardSerializer(qs, many=True)
return Response(serializer.data) 94 95 return Response(serializer.data)
95 96
97 @detail_route(methods=['get'], permission_classes=[IsAuthenticated])
98 def ordered_deck(self, request, pk):
99 """
100 Get a chronological order by material_date of flashcards for a section.
101 This excludes hidden card.
102 """
103 qs = request.user.get_deck(self.get_object()).order_by('-material_date')
104 serializer = FlashcardSerializer(qs, many=True)
105 return Response(serializer.data)
96 106
107
class UserSectionListView(ListAPIView): 97 108 class UserSectionListView(ListAPIView):
serializer_class = SectionSerializer 98 109 serializer_class = SectionSerializer
permission_classes = [IsAuthenticated] 99 110 permission_classes = [IsAuthenticated]
100 111
def get_queryset(self): 101 112 def get_queryset(self):
return self.request.user.sections.all() 102 113 return self.request.user.sections.all()
103 114
def paginate_queryset(self, queryset): return None 104 115 def paginate_queryset(self, queryset): return None
105 116
106 117
class UserDetail(GenericAPIView): 107 118 class UserDetail(GenericAPIView):
serializer_class = UserSerializer 108 119 serializer_class = UserSerializer
permission_classes = [IsAuthenticated] 109 120 permission_classes = [IsAuthenticated]
110 121
def get_queryset(self): 111 122 def get_queryset(self):
return User.objects.all() 112 123 return User.objects.all()
113 124
def patch(self, request, format=None): 114 125 def patch(self, request, format=None):
""" 115 126 """
Updates the user's password, or verifies their email address 116 127 Updates the user's password, or verifies their email address
--- 117 128 ---
request_serializer: UserUpdateSerializer 118 129 request_serializer: UserUpdateSerializer
response_serializer: UserSerializer 119 130 response_serializer: UserSerializer
""" 120 131 """
data = UserUpdateSerializer(data=request.data, context={'user': request.user}) 121 132 data = UserUpdateSerializer(data=request.data, context={'user': request.user})
data.is_valid(raise_exception=True) 122 133 data.is_valid(raise_exception=True)
data = data.validated_data 123 134 data = data.validated_data
124 135
if 'new_password' in data: 125 136 if 'new_password' in data:
if not request.user.check_password(data['old_password']): 126 137 if not request.user.check_password(data['old_password']):
raise ValidationError('old_password is incorrect') 127 138 raise ValidationError('old_password is incorrect')
request.user.set_password(data['new_password']) 128 139 request.user.set_password(data['new_password'])
request.user.save() 129 140 request.user.save()
130 141
if 'confirmation_key' in data: 131 142 if 'confirmation_key' in data:
try: 132 143 try:
request.user.confirm_email(data['confirmation_key']) 133 144 request.user.confirm_email(data['confirmation_key'])
except EmailAddress.DoesNotExist: 134 145 except EmailAddress.DoesNotExist:
raise ValidationError('confirmation_key is invalid') 135 146 raise ValidationError('confirmation_key is invalid')
136 147
return Response(UserSerializer(request.user).data) 137 148 return Response(UserSerializer(request.user).data)
138 149
def get(self, request, format=None): 139 150 def get(self, request, format=None):
""" 140 151 """
Return data about the user 141 152 Return data about the user
--- 142 153 ---
response_serializer: UserSerializer 143 154 response_serializer: UserSerializer
""" 144 155 """
serializer = UserSerializer(request.user, context={'request': request}) 145 156 serializer = UserSerializer(request.user, context={'request': request})
return Response(serializer.data) 146 157 return Response(serializer.data)
147 158
def delete(self, request): 148 159 def delete(self, request):
""" 149 160 """
Irrevocably delete the user and their data 150 161 Irrevocably delete the user and their data
151 162
Yes, really 152 163 Yes, really
""" 153 164 """
request.user.delete() 154 165 request.user.delete()
return Response(status=HTTP_204_NO_CONTENT) 155 166 return Response(status=HTTP_204_NO_CONTENT)
156 167
157 168
@api_view(['POST']) 158 169 @api_view(['POST'])
def register(request, format=None): 159 170 def register(request, format=None):
""" 160 171 """
Register a new user 161 172 Register a new user
--- 162 173 ---
request_serializer: EmailPasswordSerializer 163 174 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 164 175 response_serializer: UserSerializer
""" 165 176 """
data = RegistrationSerializer(data=request.data) 166 177 data = RegistrationSerializer(data=request.data)
data.is_valid(raise_exception=True) 167 178 data.is_valid(raise_exception=True)
168 179
User.objects.create_user(**data.validated_data) 169 180 User.objects.create_user(**data.validated_data)
user = authenticate(**data.validated_data) 170 181 user = authenticate(**data.validated_data)
auth.login(request, user) 171 182 auth.login(request, user)
172 183
body = ''' 173 184 body = '''
Visit the following link to confirm your email address: 174 185 Visit the following link to confirm your email address:
https://flashy.cards/app/verify_email/%s 175 186 https://flashy.cards/app/verify_email/%s
176 187
If you did not register for Flashy, no action is required. 177 188 If you did not register for Flashy, no action is required.
''' 178 189 '''
179 190
assert send_mail("Flashy email verification", 180 191 assert send_mail("Flashy email verification",
body % user.confirmation_key, 181 192 body % user.confirmation_key,
"noreply@flashy.cards", 182 193 "noreply@flashy.cards",
[user.email]) 183 194 [user.email])
184 195
return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED) 185 196 return Response(UserSerializer(request.user).data, status=HTTP_201_CREATED)
186 197
187 198
@api_view(['POST']) 188 199 @api_view(['POST'])
def login(request): 189 200 def login(request):
""" 190 201 """
Authenticates user and returns user data if valid. 191 202 Authenticates user and returns user data if valid.
--- 192 203 ---
request_serializer: EmailPasswordSerializer 193 204 request_serializer: EmailPasswordSerializer
response_serializer: UserSerializer 194 205 response_serializer: UserSerializer
""" 195 206 """
196 207
data = EmailPasswordSerializer(data=request.data) 197 208 data = EmailPasswordSerializer(data=request.data)
data.is_valid(raise_exception=True) 198 209 data.is_valid(raise_exception=True)
user = authenticate(**data.validated_data) 199 210 user = authenticate(**data.validated_data)
200 211
if user is None: 201 212 if user is None:
raise AuthenticationFailed('Invalid email or password') 202 213 raise AuthenticationFailed('Invalid email or password')
if not user.is_active: 203 214 if not user.is_active:
raise NotAuthenticated('Account is disabled') 204 215 raise NotAuthenticated('Account is disabled')
auth.login(request, user) 205 216 auth.login(request, user)
return Response(UserSerializer(request.user).data) 206 217 return Response(UserSerializer(request.user).data)
207 218
208 219
@api_view(['POST']) 209 220 @api_view(['POST'])
@permission_classes((IsAuthenticated, )) 210 221 @permission_classes((IsAuthenticated, ))
def logout(request, format=None): 211 222 def logout(request, format=None):
""" 212 223 """
Logs the authenticated user out. 213 224 Logs the authenticated user out.
""" 214 225 """
auth.logout(request) 215 226 auth.logout(request)
return Response(status=HTTP_204_NO_CONTENT) 216 227 return Response(status=HTTP_204_NO_CONTENT)
217 228
218 229
@api_view(['POST']) 219 230 @api_view(['POST'])
def request_password_reset(request, format=None): 220 231 def request_password_reset(request, format=None):
""" 221 232 """
Send a password reset token/link to the provided email. 222 233 Send a password reset token/link to the provided email.
--- 223 234 ---
request_serializer: PasswordResetRequestSerializer 224 235 request_serializer: PasswordResetRequestSerializer
""" 225 236 """
data = PasswordResetRequestSerializer(data=request.data) 226 237 data = PasswordResetRequestSerializer(data=request.data)
data.is_valid(raise_exception=True) 227 238 data.is_valid(raise_exception=True)
user = User.objects.get(email=data['email'].value) 228 239 user = User.objects.get(email=data['email'].value)
token = default_token_generator.make_token(user) 229 240 token = default_token_generator.make_token(user)
230 241
body = ''' 231 242 body = '''
Visit the following link to reset your password: 232 243 Visit the following link to reset your password:
https://flashy.cards/app/reset_password/%d/%s 233 244 https://flashy.cards/app/reset_password/%d/%s
234 245
If you did not request a password reset, no action is required. 235 246 If you did not request a password reset, no action is required.
''' 236 247 '''
237 248
send_mail("Flashy password reset", 238 249 send_mail("Flashy password reset",
body % (user.pk, token), 239 250 body % (user.pk, token),
"noreply@flashy.cards", 240 251 "noreply@flashy.cards",
[user.email]) 241 252 [user.email])
242 253
return Response(status=HTTP_204_NO_CONTENT) 243 254 return Response(status=HTTP_204_NO_CONTENT)
244 255
245 256
@api_view(['POST']) 246 257 @api_view(['POST'])
def reset_password(request, format=None): 247 258 def reset_password(request, format=None):
""" 248 259 """
Updates user's password to new password if token is valid. 249 260 Updates user's password to new password if token is valid.
--- 250 261 ---
request_serializer: PasswordResetSerializer 251 262 request_serializer: PasswordResetSerializer
""" 252 263 """
data = PasswordResetSerializer(data=request.data) 253 264 data = PasswordResetSerializer(data=request.data)
data.is_valid(raise_exception=True) 254 265 data.is_valid(raise_exception=True)
255 266
user = User.objects.get(id=data['uid'].value) 256 267 user = User.objects.get(id=data['uid'].value)
# Check token validity. 257 268 # Check token validity.
258 269
if default_token_generator.check_token(user, data['token'].value): 259 270 if default_token_generator.check_token(user, data['token'].value):
user.set_password(data['new_password'].value) 260 271 user.set_password(data['new_password'].value)
user.save() 261 272 user.save()
else: 262 273 else:
raise ValidationError('Could not verify reset token') 263 274 raise ValidationError('Could not verify reset token')
return Response(status=HTTP_204_NO_CONTENT) 264 275 return Response(status=HTTP_204_NO_CONTENT)
265 276
266 277
class FlashcardViewSet(GenericViewSet, CreateModelMixin, RetrieveModelMixin): 267 278 class FlashcardViewSet(GenericViewSet, CreateModelMixin, RetrieveModelMixin):
queryset = Flashcard.objects.all() 268 279 queryset = Flashcard.objects.all()
serializer_class = FlashcardSerializer 269 280 serializer_class = FlashcardSerializer
permission_classes = [IsAuthenticated] 270 281 permission_classes = [IsAuthenticated]
271 282
# Override create in CreateModelMixin 272 283 # Override create in CreateModelMixin
def create(self, request, *args, **kwargs): 273 284 def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) 274 285 serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) 275 286 serializer.is_valid(raise_exception=True)
serializer.validated_data['author'] = request.user 276 287 serializer.validated_data['author'] = request.user
self.perform_create(serializer) 277 288 self.perform_create(serializer)
headers = self.get_success_headers(serializer.data) 278 289 headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=HTTP_201_CREATED, headers=headers) 279 290 return Response(serializer.data, status=HTTP_201_CREATED, headers=headers)
280 291
@detail_route(methods=['post'], permission_classes=[IsAuthenticated]) 281 292 @detail_route(methods=['post'], permission_classes=[IsAuthenticated])
def report(self, request, pk): 282 293 def report(self, request, pk):