├── api ├── __init__.py ├── serializer_fields.py ├── validators.py ├── signals.py ├── permissions.py ├── urls.py ├── filters.py ├── serializers.py └── views.py ├── logs └── __init__.py ├── kw_webapp ├── tests │ ├── __init__.py │ ├── test_meaningsynonym_api.py │ ├── test_views.py │ ├── utils.py │ ├── sample_api_responses.py │ ├── test_models.py │ └── test_tasks.py ├── migrations │ ├── __init__.py │ ├── 0013_merge.py │ ├── 0025_merge_20170509_1806.py │ ├── 0027_merge_20171117_1738.py │ ├── 0014_auto_20160525_1403.py │ ├── 0008_auto_20160123_1330.py │ ├── 0034_auto_20180219_1419.py │ ├── 0004_profile_follow_me.py │ ├── 0012_profile_auto_expand_answer_on_success.py │ ├── 0012_profile_last_wanikani_sync_date.py │ ├── 0019_level_partial.py │ ├── 0007_profile_only_review_burned.py │ ├── 0009_userspecific_wanikani_burned.py │ ├── 0021_userspecific_critical.py │ ├── 0016_profile_last_visit.py │ ├── 0026_auto_20170815_0813.py │ ├── 0015_auto_20160605_1254.py │ ├── 0035_auto_20180219_1425.py │ ├── 0030_auto_20171210_1846.py │ ├── 0031_auto_20171213_1513.py │ ├── 0023_auto_20170319_1335.py │ ├── 0024_auto_20170319_1549.py │ ├── 0020_frequentlyaskedquestion.py │ ├── 0011_vacation_settings.py │ ├── 0005_auto_20160114_1358.py │ ├── 0006_auto_20160121_1638.py │ ├── 0024_auto_20170509_1348.py │ ├── 0029_auto_20171125_1321.py │ ├── 0022_auto_20170319_1310.py │ ├── 0037_auto_20180321_1551.py │ ├── 0032_auto_20171218_0826.py │ ├── 0010_auto_20160207_2013.py │ ├── 0036_auto_20180222_1919.py │ ├── 0028_report.py │ ├── 0003_auto_20160110_1715.py │ ├── 0026_auto_20171117_1231.py │ ├── 0033_auto_20180119_1214.py │ ├── 0018_auto_20161211_1701.py │ ├── 0017_auto_20161125_0916.py │ ├── 0002_auto_20160110_1624.py │ └── 0001_initial.py ├── __init__.py ├── wanikani │ ├── __init__.py │ ├── constants.py │ ├── exceptions.py │ └── wanikani_api_handler.py ├── templates │ └── contact_form │ │ ├── contact_form_subject.txt │ │ └── contact_form.txt ├── admin.py ├── apps.py ├── signals.py ├── forms.py ├── renderers.py ├── backends.py ├── middleware.py ├── constants.py ├── models.py └── utils.py ├── KW ├── __init__.py ├── .env ├── celery.py ├── wsgi.py ├── urls.py ├── LoggingMiddleware.py └── settings.py ├── manage.py ├── .gitattributes ├── .travis.yml ├── requirements.txt ├── .gitignore └── README.md /api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /kw_webapp/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /kw_webapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /kw_webapp/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "kw_webapp.apps.KaniwaniConfig" -------------------------------------------------------------------------------- /kw_webapp/wanikani/__init__.py: -------------------------------------------------------------------------------- 1 | from .wanikani_api_handler import make_api_call 2 | -------------------------------------------------------------------------------- /KW/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ['celery_app'] 4 | -------------------------------------------------------------------------------- /kw_webapp/templates/contact_form/contact_form_subject.txt: -------------------------------------------------------------------------------- 1 | KaniWani User is contacting you! -------------------------------------------------------------------------------- /kw_webapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from kw_webapp.models import Announcement 3 | # Register your models here. 4 | 5 | admin.site.register(Announcement) 6 | -------------------------------------------------------------------------------- /kw_webapp/templates/contact_form/contact_form.txt: -------------------------------------------------------------------------------- 1 | username: {{user.username}} 2 | registered email: {{user.email}} 3 | 4 | Name: {{ name }} 5 | Email: {{ email }} 6 | 7 | ------------------------- 8 | {{ body|safe}} -------------------------------------------------------------------------------- /kw_webapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | class KaniwaniConfig(AppConfig): 4 | name = "kw_webapp" 5 | verbose_name = "KaniWani" 6 | 7 | def ready(self): 8 | import kw_webapp.signals 9 | import api.signals 10 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "KW.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /kw_webapp/wanikani/constants.py: -------------------------------------------------------------------------------- 1 | INVALID_WK_API_ERROR = "user_not_found" 2 | INVALID_ARGUMENTS_ERROR = "invalid_arguments" 3 | 4 | WANIKANI_ROOT_URL = 'https://www.wanikani.com/api/user/{}' 5 | 6 | USER_INFO_URL = WANIKANI_ROOT_URL + "/user-information" 7 | VOCABULARY_URL = WANIKANI_ROOT_URL + "/vocabulary/{}" 8 | -------------------------------------------------------------------------------- /KW/.env: -------------------------------------------------------------------------------- 1 | DEBUG=on 2 | LOGLEVEL=INFO 3 | SECRET_KEY=dummy 4 | CORS_ORIGIN_WHITELIST=localhost:3000,http://localhost:3000/,http://127.0.0.1:3000,127.0.0.1:3000,http://96.126.101.77:3000,96.126.101.77:3000,www.kaniwani.com,https://www.kaniwani.com,https://kaniwani.com,kaniwani.com 5 | REDIS_URL=rediscache://127.0.0.1:6379/0 6 | SECRET_KEY=donteventryitdonteventryitdonteventryitdonteventryit -------------------------------------------------------------------------------- /kw_webapp/signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.signals import user_logged_in 2 | from kw_webapp.tasks import sync_with_wk 3 | 4 | 5 | def sync_unlocks_with_wk(sender, **kwargs): 6 | if kwargs['user']: 7 | user = kwargs['user'] 8 | sync_with_wk.delay(user.id, full_sync=user.profile.follow_me) 9 | 10 | 11 | user_logged_in.connect(sync_unlocks_with_wk) 12 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0013_merge.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0012_profile_auto_expand_answer_on_success'), 11 | ('kw_webapp', '0012_profile_last_wanikani_sync_date'), 12 | ] 13 | 14 | operations = [ 15 | ] 16 | -------------------------------------------------------------------------------- /kw_webapp/forms.py: -------------------------------------------------------------------------------- 1 | from contact_form.forms import ContactForm 2 | 3 | from KW import settings 4 | 5 | class UserContactCustomForm(ContactForm): 6 | 7 | # Jam the originating User into the recipient list so we can reply-all to them. 8 | def recipient_list(self): 9 | recipients = [mail_tuple[1] for mail_tuple in settings.MANAGERS] 10 | recipients.append(self.cleaned_data['email']) 11 | return recipients 12 | -------------------------------------------------------------------------------- /KW/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | from celery import Celery 3 | from django.conf import settings 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', "KW.settings") 6 | 7 | app = Celery('KW') 8 | app.config_from_object('django.conf:settings', namespace="CELERY") 9 | 10 | app.autodiscover_tasks() 11 | app.log.setup() 12 | 13 | 14 | @app.task(bind=True) 15 | def debug_task(self): 16 | 17 | print("Request: {0!r}".format(self.request)) 18 | -------------------------------------------------------------------------------- /kw_webapp/wanikani/exceptions.py: -------------------------------------------------------------------------------- 1 | from . import constants 2 | 3 | 4 | class WanikaniAPIException(Exception): 5 | pass 6 | 7 | 8 | class InvalidWaniKaniKey(WanikaniAPIException): 9 | pass 10 | 11 | 12 | class InvalidArguments(WanikaniAPIException): 13 | pass 14 | 15 | 16 | ExceptionSelector = { 17 | constants.INVALID_WK_API_ERROR: InvalidWaniKaniKey, 18 | constants.INVALID_ARGUMENTS_ERROR: InvalidArguments 19 | } 20 | -------------------------------------------------------------------------------- /api/serializer_fields.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.reverse import reverse 3 | 4 | 5 | class VocabularyByLevelHyperlinkedField(serializers.HyperlinkedRelatedField): 6 | view_name = 'api:vocabulary-list' 7 | 8 | def get_url(self, obj, view_name, request, format): 9 | result = "{}?level={}".format( 10 | reverse(view_name), 11 | obj 12 | ) 13 | return result 14 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0025_merge_20170509_1806.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-05-09 22:06 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0024_auto_20170509_1348'), 12 | ('kw_webapp', '0024_auto_20170319_1549'), 13 | ] 14 | 15 | operations = [ 16 | ] 17 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0027_merge_20171117_1738.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2017-11-17 22:38 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0026_auto_20171117_1231'), 12 | ('kw_webapp', '0026_auto_20170815_0813'), 13 | ] 14 | 15 | operations = [ 16 | ] 17 | -------------------------------------------------------------------------------- /KW/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for KW project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | from django.core.wsgi import get_wsgi_application 12 | from raven.contrib.django.raven_compat.middleware.wsgi import Sentry 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "KW.settings") 15 | application = Sentry(get_wsgi_application()) 16 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0014_auto_20160525_1403.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0013_merge'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='profile', 16 | name='api_valid', 17 | field=models.BooleanField(default=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0008_auto_20160123_1330.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0007_profile_only_review_burned'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name='userspecific', 16 | old_name='burnt', 17 | new_name='burned', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0034_auto_20180219_1419.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.10 on 2018-02-19 19:19 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('kw_webapp', '0033_auto_20180119_1214'), 13 | ] 14 | 15 | operations = [ 16 | # WE have moved this stuff into migration 29, as it was actually needed there. 17 | ] 18 | -------------------------------------------------------------------------------- /kw_webapp/renderers.py: -------------------------------------------------------------------------------- 1 | 2 | from rest_framework import renderers 3 | 4 | 5 | class FallbackJSONRenderer(renderers.JSONRenderer): 6 | ''' 7 | In order for all endpoints to return JSON, we use this renderer to automatically fill the an empty JSON response in 8 | cases where it would normally be null. 9 | ''' 10 | def render(self, data, accepted_media_type=None, renderer_context=None): 11 | if data is None: 12 | data = {"detail": "none"} 13 | return super().render(data, accepted_media_type, renderer_context) -------------------------------------------------------------------------------- /kw_webapp/migrations/0004_profile_follow_me.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0003_auto_20160110_1715'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='profile', 16 | name='follow_me', 17 | field=models.BooleanField(default=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0012_profile_auto_expand_answer_on_success.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0011_vacation_settings'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='profile', 16 | name='auto_expand_answer_on_success', 17 | field=models.BooleanField(default=False), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0012_profile_last_wanikani_sync_date.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0011_vacation_settings'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='profile', 16 | name='last_wanikani_sync_date', 17 | field=models.DateTimeField(auto_now_add=True, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0019_level_partial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.11 on 2016-12-26 18:00 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0018_auto_20161211_1701'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='level', 17 | name='partial', 18 | field=models.BooleanField(default=False), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0007_profile_only_review_burned.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0006_auto_20160121_1638'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='profile', 16 | name='only_review_burned', 17 | field=models.BooleanField(default=False), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0009_userspecific_wanikani_burned.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0008_auto_20160123_1330'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='userspecific', 16 | name='wanikani_burned', 17 | field=models.BooleanField(default=False), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0021_userspecific_critical.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.11 on 2017-01-02 14:36 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0020_frequentlyaskedquestion'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='userspecific', 17 | name='critical', 18 | field=models.BooleanField(default=False), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0016_profile_last_visit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-11-23 21:25 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0015_auto_20160605_1254'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='profile', 17 | name='last_visit', 18 | field=models.DateTimeField(auto_now_add=True, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0026_auto_20170815_0813.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.3 on 2017-08-15 12:13 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0025_merge_20170509_1806'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='userspecific', 17 | name='last_studied', 18 | field=models.DateTimeField(blank=True, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0015_auto_20160605_1254.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | import django.core.validators 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0014_auto_20160525_1403'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='reading', 17 | name='level', 18 | field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(60)], null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0035_auto_20180219_1425.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.10 on 2018-02-19 19:25 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('kw_webapp', '0034_auto_20180219_1419'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='report', 18 | name='reading', 19 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='kw_webapp.Reading'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0030_auto_20171210_1846.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2017-12-10 23:46 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0029_auto_20171125_1321'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterUniqueTogether( 16 | name='reading', 17 | unique_together=set([('character', 'kana')]), 18 | ), 19 | migrations.AlterUniqueTogether( 20 | name='userspecific', 21 | unique_together=set([('vocabulary', 'user')]), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0031_auto_20171213_1513.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2017-12-13 20:13 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0030_auto_20171210_1846'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterUniqueTogether( 16 | name='answersynonym', 17 | unique_together=set([('character', 'kana', 'review')]), 18 | ), 19 | migrations.AlterUniqueTogether( 20 | name='meaningsynonym', 21 | unique_together=set([('text', 'review')]), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0023_auto_20170319_1335.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-03-19 17:35 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0022_auto_20170319_1310'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='profile', 17 | name='minimum_wk_srs_level_to_review', 18 | field=models.CharField(choices=[('APPRENTICE', 'apprentice'), ('GURU', 'guru'), ('MASTER', 'master'), ('ENLIGHTENED', 'enlightened'), ('BURNED', 'burned')], default='APPRENTICE', max_length=20), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0024_auto_20170319_1549.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-03-19 19:49 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0023_auto_20170319_1335'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='profile', 17 | name='minimum_wk_srs_level_to_review', 18 | field=models.CharField(choices=[('APPRENTICE', 'Apprentice'), ('GURU', 'Guru'), ('MASTER', 'Master'), ('ENLIGHTENED', 'Enlightened'), ('BURNED', 'Burned')], default='APPRENTICE', max_length=20), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /kw_webapp/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | 4 | class EmailOrUsernameAuthenticationBackend: 5 | def authenticate(self, username=None, password=None): 6 | if '@' in username: 7 | kwargs = {'email': username} 8 | else: 9 | kwargs = {'username': username} 10 | 11 | try: 12 | user = User.objects.get(**kwargs) 13 | if user.check_password(password): 14 | return user 15 | else: 16 | return None 17 | 18 | except User.DoesNotExist: 19 | return None 20 | 21 | def get_user(self, user_id=None): 22 | try: 23 | return User.objects.get(pk=user_id) 24 | except User.DoesNotExist: 25 | return None 26 | -------------------------------------------------------------------------------- /api/validators.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from rest_framework import serializers 3 | 4 | from kw_webapp.tasks import build_user_information_api_string 5 | 6 | 7 | class WanikaniApiKeyValidator(object): 8 | 9 | def __init__(self): 10 | self.failure_message = "This API key appears to be invalid" 11 | 12 | def __call__(self, value): 13 | api_string = build_user_information_api_string(value) 14 | r = requests.get(api_string) 15 | if r.status_code == 200: 16 | json_data = r.json() 17 | # WK Seems to often change what their failure state is, lets check instead for positive state. 18 | if "user_information" in json_data.keys(): 19 | return value 20 | 21 | raise serializers.ValidationError(self.failure_message) 22 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0020_frequentlyaskedquestion.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.11 on 2017-01-01 20:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0019_level_partial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='FrequentlyAskedQuestion', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('question', models.CharField(max_length=10000)), 20 | ('answer', models.CharField(max_length=10000)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/signals.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.models.signals import post_save 3 | from django.dispatch import receiver 4 | from djoser.signals import user_registered 5 | from rest_framework.authtoken.models import Token 6 | from kw_webapp.tasks import sync_with_wk 7 | 8 | import logging 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @receiver(post_save, sender=settings.AUTH_USER_MODEL) 13 | def create_auth_token(sender, instance=None, created=False, **kwargs): 14 | if created: 15 | Token.objects.create(user=instance) 16 | 17 | 18 | def sync_unlocks_with_wk(sender, **kwargs): 19 | if kwargs['user']: 20 | user = kwargs['user'] 21 | sync_with_wk(user.id, full_sync=user.profile.follow_me) 22 | 23 | 24 | user_registered.connect(sync_unlocks_with_wk) 25 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0011_vacation_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', "0010_auto_20160207_2013"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='profile', 16 | name='on_vacation', 17 | field=models.BooleanField(default=False), 18 | preserve_default=True, 19 | ), 20 | migrations.AddField( 21 | model_name='profile', 22 | name='vacation_date', 23 | field=models.DateTimeField(null=True, blank=True, default=None), 24 | preserve_default=True, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0005_auto_20160114_1358.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0004_profile_follow_me'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='profile', 16 | name='auto_advance_on_success', 17 | field=models.BooleanField(default=False), 18 | preserve_default=True, 19 | ), 20 | migrations.AddField( 21 | model_name='profile', 22 | name='auto_expand_answer_on_failure', 23 | field=models.BooleanField(default=False), 24 | preserve_default=True, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0006_auto_20160121_1638.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0005_auto_20160114_1358'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='userspecific', 16 | name='wanikani_srs', 17 | field=models.CharField(default='unknown', max_length=255), 18 | preserve_default=True, 19 | ), 20 | migrations.AddField( 21 | model_name='userspecific', 22 | name='wanikani_srs_numeric', 23 | field=models.IntegerField(default=0), 24 | preserve_default=True, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /KW/urls.py: -------------------------------------------------------------------------------- 1 | from contact_form.views import ContactFormView 2 | from django.conf.urls import include, url 3 | 4 | from django.contrib import admin 5 | from django.views.generic import TemplateView, RedirectView 6 | from django.contrib.auth import views as auth_views 7 | from rest_framework.documentation import include_docs_urls 8 | 9 | import kw_webapp 10 | from KW import settings 11 | 12 | admin.autodiscover() 13 | 14 | urlpatterns = ( 15 | url(r'^$', RedirectView.as_view(url="/docs/")), 16 | url(r'^docs/', include_docs_urls(title='Kaniwani Docs')), 17 | url(r'^admin/', include(admin.site.urls)), 18 | url(r'^api/v1/', include('api.urls', namespace='api')), 19 | ) 20 | 21 | if settings.DEBUG: 22 | import debug_toolbar 23 | 24 | urlpatterns += ( 25 | url(r'^__debug__/', include(debug_toolbar.urls)), 26 | ) 27 | -------------------------------------------------------------------------------- /kw_webapp/middleware.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import now 2 | from django.utils import deprecation 3 | from kw_webapp.models import Profile 4 | from kw_webapp.tasks import past_time 5 | 6 | 7 | class SetLastVisitMiddleware(deprecation.MiddlewareMixin): 8 | """ 9 | A middleware class which will update a last_visit field in the profile once an hour. 10 | """ 11 | buffer_hours = 1 12 | 13 | def process_response(self, request, response): 14 | if hasattr(request, 'user') and request.user.is_authenticated() and self.should_update(request.user): 15 | Profile.objects.filter(pk=request.user.profile.pk).update(last_visit=now()) 16 | return response 17 | 18 | def should_update(self, user): 19 | return user.profile.last_visit is None or user.profile.last_visit <= past_time(self.buffer_hours) 20 | -------------------------------------------------------------------------------- /KW/LoggingMiddleware.py: -------------------------------------------------------------------------------- 1 | from django.utils.deprecation import MiddlewareMixin 2 | from rest_framework_tracking.mixins import LoggingMixin 3 | import logging 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class ExceptionLoggingMiddleware(MiddlewareMixin): 8 | def process_exception(self, request, exception): 9 | import traceback 10 | print(traceback.format_exc()) 11 | 12 | def handle_log(self): 13 | self.log['response'] = None 14 | logger.info(self.log) 15 | 16 | 17 | class RequestAndResponseLoggingMixin(LoggingMixin): 18 | pass 19 | 20 | 21 | class RequestLoggingMixin(LoggingMixin): 22 | def should_log(self, request, response): 23 | return response.status_code // 100 == 2 or response.status_code // 300 == 3 24 | 25 | def handle_log(self): 26 | logger.info("{}".format(self.log)) 27 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0024_auto_20170509_1348.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-05-09 17:48 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0023_auto_20170319_1335'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='level', 17 | name='partial', 18 | ), 19 | migrations.AlterField( 20 | model_name='profile', 21 | name='minimum_wk_srs_level_to_review', 22 | field=models.CharField(choices=[('APPRENTICE', 'Apprentice'), ('GURU', 'Guru'), ('MASTER', 'Master'), ('ENLIGHTENED', 'Enlightened'), ('BURNED', 'Burned')], default='APPRENTICE', max_length=20), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0029_auto_20171125_1321.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2017-11-25 18:21 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('kw_webapp', '0028_report'), 14 | ] 15 | 16 | operations = [ 17 | migrations.RemoveField( 18 | model_name='report', 19 | name='vocabulary', 20 | ), 21 | migrations.AddField( 22 | model_name='report', 23 | name='reading', 24 | field=models.ForeignKey(default=0, on_delete=models.deletion.CASCADE, to='kw_webapp.Reading'), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | services: 3 | - redis-server 4 | python: 5 | - 3.5 6 | - 3.4 7 | install: 8 | - pip install -r requirements.txt 9 | script: 10 | - python manage.py test 11 | notifications: 12 | slack: 13 | secure: Z0cFsVLbBOczW8aLXfJvnV1kJ1cZFO4sIrpeJyiqdnwFKmf5DM9I765smFgDNY2I1Z9mksue3ULBTLJyl6wpp9rFIUwW2V4pb1VUL68RXIltsE8L5QUYHN0JPP5k6Y51gTCDqrdKIvRgBXji77OsFN9ZOS7VOrUmEWKuSU+Wm+15nLgJBduC6S0ZCX8lOWJYzvkbGiybsJn8Vua19C+M57zNZekVOeNUH0JeLfeFE9jL7p6qhoOUL4SvWA+8xo/IKU27o/5DElt9yVNSZZb/XQ0x7TCa/0uKoDlrEWTjcmIltBNnfnPLRdah2CCQEYpnc0um2NJdqDuWgVfbVXzd73wZMgmxqaRCfOlgG9uhILypI0e/heF6S6hUQxoVh3VovFl9rsFAq57hS3+VhQUx9lGa2HulMHaVVUUbf/AeMA8dlNDUJIQnwb9cL0sp10SGmDLAfH93sGhiMuMb7dVJF18lI+2VX115ts6f+Zn7Jt960H0a0BJtVjoA5x+pE9VY2wA4WpoLcnWi0f4CAKiJbXL9lPxJbS9/zELEMGCZ1D1EvgPi/tOXs47ooGj3ri5VJVMJ/dhC2JVT1FHYMupCIIRMI+9niwWpKz08Mb/Ibj0g4HxpG/snkvHpYClF1zYAPWdmWe7L7yomMpyRWMuMoUD4NW8H35ylXIk22INtKJI= 14 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0022_auto_20170319_1310.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-03-19 17:10 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import kw_webapp.constants 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('kw_webapp', '0021_userspecific_critical'), 13 | ] 14 | 15 | operations = [ 16 | migrations.RemoveField( 17 | model_name='profile', 18 | name='only_review_burned', 19 | ), 20 | migrations.AddField( 21 | model_name='profile', 22 | name='minimum_wk_srs_level_to_review', 23 | field=models.CharField(choices=[('APPRENTICE', 'apprentice'), ('GURU', 'guru'), ('MASTER', 'master'), ('ENLIGHTENED', 'enlightened'), ('BURNED', 'burned')], default='APPRENTICE', max_length=20), 24 | 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0037_auto_20180321_1551.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.11 on 2018-03-21 19:51 3 | from __future__ import unicode_literals 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('kw_webapp', '0036_auto_20180222_1919'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='profile', 18 | name='info_detail_level_on_failure', 19 | field=models.PositiveIntegerField(default=0, validators=[django.core.validators.MaxValueValidator(2)]), 20 | ), 21 | migrations.AddField( 22 | model_name='profile', 23 | name='info_detail_level_on_success', 24 | field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(2)]), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0032_auto_20171218_0826.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2017-12-18 13:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('kw_webapp', '0031_auto_20171213_1513'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='answersynonym', 18 | name='review', 19 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reading_synonyms', to='kw_webapp.UserSpecific'), 20 | ), 21 | migrations.AlterField( 22 | model_name='meaningsynonym', 23 | name='review', 24 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='meaning_synonyms', to='kw_webapp.UserSpecific'), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0010_auto_20160207_2013.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0009_userspecific_wanikani_burned'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='AnswerSynonym', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)), 18 | ('character', models.CharField(max_length=255, null=True)), 19 | ('kana', models.CharField(max_length=255)), 20 | ('review', models.ForeignKey(to='kw_webapp.UserSpecific', null=True)), 21 | ], 22 | options={ 23 | }, 24 | bases=(models.Model,), 25 | ), 26 | migrations.RenameModel( 27 | old_name='Synonym', 28 | new_name='MeaningSynonym', 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0036_auto_20180222_1919.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.10 on 2018-02-23 00:19 3 | from __future__ import unicode_literals 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('kw_webapp', '0035_auto_20180219_1425'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='profile', 18 | name='auto_expand_answer_on_success', 19 | field=models.BooleanField(default=True), 20 | ), 21 | migrations.AlterField( 22 | model_name='profile', 23 | name='kanji_svg_draw_speed', 24 | field=models.PositiveIntegerField(default=8, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)]), 25 | ), 26 | migrations.AlterField( 27 | model_name='profile', 28 | name='show_kanji_svg_grid', 29 | field=models.BooleanField(default=True), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0028_report.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2017-11-20 01:06 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('kw_webapp', '0027_merge_20171117_1738'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Report', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('reason', models.CharField(max_length=1000)), 24 | ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ('vocabulary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kw_webapp.Vocabulary')), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | amqp==2.2.2 2 | anyjson==0.3.3 3 | Babel==2.5.3 4 | backports-abc==0.5 5 | billiard==3.5.0.3 6 | celery==4.1.0 7 | certifi==2017.4.17 8 | chardet==3.0.3 9 | colorama==0.3.7 10 | contextlib2==0.5.4 11 | cookies==2.2.1 12 | coreapi==2.3.1 13 | coreschema==0.0.4 14 | decorator==4.0.10 15 | Django==1.11.11 16 | django-contact-form==1.5 17 | django-cors-middleware==1.3.1 18 | django-debug-toolbar==1.9.1 19 | django-environ==0.4.4 20 | django-extensions==1.9.9 21 | django-filter==1.0.4 22 | django-guardian==1.4.9 23 | django-redis-cache==1.7.1 24 | django-templated-mail==1.1.1 25 | djangorestframework==3.7.7 26 | djangorestframework-jwt==1.11.0 27 | djoser==1.1.5 28 | drf-tracking==1.4.0 29 | drfdocs==0.0.11 30 | flower==0.9.2 31 | gunicorn==19.7.1 32 | httpie==0.9.6 33 | idna==2.5 34 | itypes==1.1.0 35 | Jinja2==2.9.6 36 | kombu==4.1.0 37 | Markdown==2.6.8 38 | MarkupSafe==1.0 39 | psycopg2==2.7.3.1 40 | pydotplus==2.0.2 41 | Pygments==2.2.0 42 | PyJWT==1.5.3 43 | pyparsing==2.2.0 44 | pytz==2018.3 45 | raven==6.6.0 46 | redis==2.10.5 47 | requests==2.12.1 48 | responses==0.5.1 49 | simplejson==3.10.0 50 | six==1.11.0 51 | sqlparse==0.2.4 52 | tornado==4.5.3 53 | typing==3.6.4 54 | uritemplate==3.0.0 55 | urllib3==1.21.1 56 | vine==1.1.4 57 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0003_auto_20160110_1715.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('kw_webapp', '0002_auto_20160110_1624'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='profile', 16 | name='join_date', 17 | field=models.DateField(null=True, auto_now_add=True), 18 | preserve_default=True, 19 | ), 20 | migrations.AddField( 21 | model_name='profile', 22 | name='title', 23 | field=models.CharField(null=True, max_length=255, default='Turtles'), 24 | preserve_default=True, 25 | ), 26 | migrations.AlterField( 27 | model_name='profile', 28 | name='twitter', 29 | field=models.CharField(null=True, max_length=255, default='N/A'), 30 | preserve_default=True, 31 | ), 32 | migrations.AlterField( 33 | model_name='profile', 34 | name='website', 35 | field=models.CharField(null=True, max_length=255, default='N/A'), 36 | preserve_default=True, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /kw_webapp/wanikani/wanikani_api_handler.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | from . import constants 4 | from . import exceptions 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def _has_no_errors(response): 10 | return response and "error" not in response.json() and response.status_code == 200 11 | 12 | 13 | def _has_invalid_key_error(response): 14 | response = response.json() 15 | error_details = response['error'] 16 | return error_details['code'] == constants.INVALID_WK_API_ERROR 17 | 18 | 19 | def _get_error(response): 20 | response = response.json() 21 | error_details = response['error'] 22 | error_code = error_details['code'] 23 | error_message = error_details['message'] 24 | 25 | if error_code in exceptions.ExceptionSelector: 26 | error = exceptions.ExceptionSelector[error_code] 27 | return error(error_message) 28 | else: 29 | return exceptions.WanikaniAPIException(error_details['message']) 30 | 31 | 32 | def make_api_call(api_url): 33 | response = requests.get(api_url) 34 | if response.status_code == 200: 35 | if _has_no_errors(response): 36 | return response.json() 37 | else: 38 | raise _get_error(response) 39 | 40 | if response.status_code == 401: 41 | raise exceptions.InvalidWaniKaniKey("Got a 401 from Wanikani!") 42 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0026_auto_20171117_1231.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2017-11-17 17:31 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0025_merge_20170509_1806'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='PartOfSpeech', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('part', models.CharField(max_length=30)), 20 | ], 21 | ), 22 | migrations.RemoveField( 23 | model_name='reading', 24 | name='jlpt', 25 | ), 26 | migrations.AddField( 27 | model_name='reading', 28 | name='furigana', 29 | field=models.CharField(max_length=100, null=True), 30 | ), 31 | migrations.AddField( 32 | model_name='reading', 33 | name='pitch', 34 | field=models.CharField(max_length=100, null=True), 35 | ), 36 | migrations.AddField( 37 | model_name='reading', 38 | name='parts_of_speech', 39 | field=models.ManyToManyField(to='kw_webapp.PartOfSpeech'), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /api/permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | from rest_framework import permissions 3 | from rest_framework.permissions import IsAdminUser, SAFE_METHODS, IsAuthenticated 4 | 5 | 6 | # Allows admin users the ability to do anything, everybody else just gets GET/HEAD/OPTIONS 7 | class IsAdminOrReadOnly(IsAdminUser): 8 | def has_permission(self, request, view): 9 | is_admin = super(IsAdminOrReadOnly, self).has_permission(request, view) 10 | return is_admin or request.method in SAFE_METHODS 11 | 12 | 13 | class IsMeOrAdmin(IsAdminUser): 14 | """ 15 | Object-level permission to ensure the requesting user only has access to their own profile. 16 | """ 17 | 18 | def has_object_permission(self, request, view, obj): 19 | is_admin = super(IsMeOrAdmin, self).has_object_permission(request, view, obj) 20 | return request.user == obj or is_admin 21 | 22 | 23 | class IsAuthenticatedOrCreating(IsAuthenticated): 24 | def has_permission(self, request, view): 25 | is_authenticated = super().has_permission(request, view) 26 | return is_authenticated or request.method == 'POST' 27 | 28 | 29 | class IsAdminOrAuthenticatedAndCreating(IsAuthenticated): 30 | def has_permission(self, request, view): 31 | is_authenticated = super().has_permission(request, view) 32 | return (is_authenticated and request.method in ['POST', 'PUT']) or request.user.is_staff 33 | 34 | -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from rest_framework.routers import DefaultRouter 3 | from rest_framework_jwt import views as jwtviews 4 | from api.views import ReviewViewSet, VocabularyViewSet, ReadingViewSet, LevelViewSet, ReadingSynonymViewSet, \ 5 | FrequentlyAskedQuestionViewSet, AnnouncementViewSet, UserViewSet, ContactViewSet, ProfileViewSet, ReportViewSet, \ 6 | MeaningSynonymViewSet 7 | 8 | router = DefaultRouter() 9 | router.register(r'review', ReviewViewSet, base_name="review") 10 | router.register(r'profile', ProfileViewSet, base_name='profile') 11 | router.register(r'vocabulary', VocabularyViewSet, base_name="vocabulary") 12 | router.register(r'report', ReportViewSet, base_name="report") 13 | router.register(r'reading', ReadingViewSet, base_name="reading") 14 | router.register(r'level', LevelViewSet, base_name="level") 15 | router.register(r'synonym/reading', ReadingSynonymViewSet, base_name="reading-synonym") 16 | router.register(r'synonym/meaning', MeaningSynonymViewSet, base_name="meaning-synonym") 17 | router.register(r'faq', FrequentlyAskedQuestionViewSet, base_name='faq') 18 | router.register(r'announcement', AnnouncementViewSet, base_name='announcement') 19 | router.register(r'user', UserViewSet, base_name='user') 20 | router.register(r'contact', ContactViewSet, base_name='contact') 21 | 22 | urlpatterns = router.urls + [ 23 | url(r'^auth/login/', jwtviews.obtain_jwt_token), 24 | url(r'^auth/', include('djoser.urls.base', namespace="auth")) 25 | ] 26 | 27 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0033_auto_20180119_1214.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.3 on 2018-01-19 17:14 3 | from __future__ import unicode_literals 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('kw_webapp', '0032_auto_20171218_0826'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='profile', 18 | name='auto_advance_on_success_delay_milliseconds', 19 | field=models.PositiveIntegerField(default=1000), 20 | ), 21 | migrations.AddField( 22 | model_name='profile', 23 | name='kanji_svg_draw_speed', 24 | field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)]), 25 | ), 26 | migrations.AddField( 27 | model_name='profile', 28 | name='show_kanji_svg_grid', 29 | field=models.BooleanField(default=False), 30 | ), 31 | migrations.AddField( 32 | model_name='profile', 33 | name='show_kanji_svg_stroke_order', 34 | field=models.BooleanField(default=False), 35 | ), 36 | migrations.AddField( 37 | model_name='profile', 38 | name='use_eijiro_pro_link', 39 | field=models.BooleanField(default=False), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0018_auto_20161211_1701.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-12-11 22:01 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('kw_webapp', '0017_auto_20161125_0916'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='answersynonym', 19 | name='review', 20 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answer_synonyms', to='kw_webapp.UserSpecific'), 21 | ), 22 | migrations.AlterField( 23 | model_name='profile', 24 | name='user', 25 | field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL), 26 | ), 27 | migrations.AlterField( 28 | model_name='reading', 29 | name='vocabulary', 30 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='readings', to='kw_webapp.Vocabulary'), 31 | ), 32 | migrations.AlterField( 33 | model_name='userspecific', 34 | name='user', 35 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0017_auto_20161125_0916.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-11-25 14:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0016_profile_last_visit'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Tag', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=255, unique=True)), 20 | ], 21 | ), 22 | migrations.AddField( 23 | model_name='reading', 24 | name='common', 25 | field=models.NullBooleanField(), 26 | ), 27 | migrations.AddField( 28 | model_name='reading', 29 | name='jlpt', 30 | field=models.CharField(max_length=20, null=True), 31 | ), 32 | migrations.AddField( 33 | model_name='reading', 34 | name='sentence_en', 35 | field=models.CharField(max_length=1000, null=True), 36 | ), 37 | migrations.AddField( 38 | model_name='reading', 39 | name='sentence_ja', 40 | field=models.CharField(max_length=1000, null=True), 41 | ), 42 | migrations.AddField( 43 | model_name='userspecific', 44 | name='notes', 45 | field=models.CharField(blank=True, max_length=500, null=True), 46 | ), 47 | migrations.AddField( 48 | model_name='reading', 49 | name='tags', 50 | field=models.ManyToManyField(to='kw_webapp.Tag'), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # local dev database 6 | db.sqlite3 7 | 8 | # C extensions 9 | *.so 10 | 11 | .idea 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | bin/ 17 | build/ 18 | develop-eggs/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # Installer logs 30 | pip-log.txt 31 | pip-delete-this-directory.txt 32 | 33 | # Unit test / coverage reports 34 | htmlcov/ 35 | .tox/ 36 | .coverage 37 | .cache 38 | nosetests.xml 39 | coverage.xml 40 | 41 | # Translations 42 | *.mo 43 | 44 | # Mr Developer 45 | .mr.developer.cfg 46 | .project 47 | .pydevproject 48 | 49 | # Rope 50 | .ropeproject 51 | 52 | # Django stuff: 53 | *.log* 54 | logs/*.log* 55 | *.pot 56 | KW/secrets.py 57 | celerybeat* 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | 63 | # ========================= 64 | # Operating System Files 65 | # ========================= 66 | 67 | # OSX 68 | # ========================= 69 | 70 | .DS_Store 71 | .AppleDouble 72 | .LSOverride 73 | 74 | # Icon must ends with two \r. 75 | Icon 76 | 77 | 78 | # Thumbnails 79 | ._* 80 | 81 | # Files that might appear on external disk 82 | .Spotlight-V100 83 | .Trashes 84 | 85 | # Windows 86 | # ========================= 87 | 88 | # Windows image file caches 89 | Thumbs.db 90 | ehthumbs.db 91 | 92 | # Folder config file 93 | Desktop.ini 94 | 95 | # Recycle Bin used on file shares 96 | $RECYCLE.BIN/ 97 | 98 | # Windows Installer files 99 | *.cab 100 | *.msi 101 | *.msm 102 | *.msp 103 | 104 | 105 | # Front end 106 | # ========================= 107 | node_modules 108 | .sass-cache 109 | .DS_Store 110 | dist 111 | 112 | # Ignore all local backup files 113 | *.bak 114 | 115 | # One time import files 116 | ROLLING_UPDATES.json 117 | outfile.txt 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://travis-ci.org/Kaniwani/KW-Backend.svg) 2 | [![Deployment status from DeployBot](https://kaniwani.deploybot.com/badge/66802254069768/57929.svg)](http://deploybot.com) 3 | 4 | # KW 5 | KaniWani 6 | 7 | ### Getting started: 8 | #### Backend 9 | Since we're using Django, a fair bit of setup is required to get a development environment up and running. Here are all the tools you need. 10 | 11 | 1. Python 3. [You can get it from activestate](http://www.activestate.com/activepython/downloads) 12 | 2. If you want to use the distributed messaging queue for tasks, [Install a redis server](http://redis.io/) This is only necessary if you want to use the periodic features(for example having the SRS run every 15 minutes). 13 | 3. Install Pycharm (or use whatever editor you like). 14 | 4. Clone the repository wherever you like. 15 | 5. Move the **secrets.py** file into the same directory as the **settings.py** file. 16 | 6. Fire up pycharm and open the parent KW directory. 17 | 7. After a bit, there should be a prompt to install a list of requirements, hit yes and let the installation go. It'll give you a popup when it is done. 18 | 7b. If this doesn't happen, and you know your way around the terminal, try *pip install -r requirements.txt* 19 | 8. Delete the db.sqlite3 file 20 | 9. hit Ctrl + alt + r . This will open up a manage.py command window. 21 | 10. execute the command **makemigrations** 22 | 11. It may prompt you to create a superuser, do so. 23 | 12. Ctrl + alt + r again. 24 | 13. This time execute **migrate**. The database is now built, but not yet populated. 25 | 14. Ctrl + alt + r again. 26 | 15. Execute the command **shell**. This brings you to application shell. 27 | 16. Execute this: 28 | 29 | ```python 30 | from kw_webapp.tasks import repopulate 31 | repopulate() 32 | ``` 33 | Chances are your system will spit a bunch of errors at you. Ignore them and wait. Eventually they will stop. 34 | 35 | 17. (subject to change!) Now you need to import supplemental data that isn't synced from WK. Execute the following in the **shell**: 36 | ```python 37 | from kw_webapp.utils one_time_import_jisho_new_format 38 | one_time_import_jisho_new_format("wk_vocab_import.json") 39 | ``` 40 | 41 | 18. Ctrl + alt + r one last time. Type in the command **runserver --noreload** 42 | 43 | If all went well, it will start a server at 127.0.0.1:8000 44 | 45 | -------------------------------------------------------------------------------- /kw_webapp/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import re 4 | 5 | from collections import OrderedDict 6 | from datetime import timedelta 7 | 8 | # Check these values here: https://cdn.wanikani.com/assets/guide/srs-visualization-4580afac174836361bdc3d3758bd6c7f.png 9 | SRS_TIMES = { 10 | # STREAK : HOURS_UNTIL_NEXT_REVIEW 11 | 0: 4, # Apprentice 12 | 1: 4, # Apprentice (4 hours) 13 | 2: 8, # Apprentice (8 hours) 14 | 3: 24, # Apprentice (1 day) 15 | 4: 72, # Apprentice -> Guru (3 days) 16 | 5: 168, # Guru (1 week) 17 | 6: 336, # Guru -> Master (2 weeks) 18 | 7: 720, # Master -> Enlightened (1 month) 19 | 8: 2880, # Enlightened -> Burned (4 months) 20 | } 21 | 22 | 23 | class KwSrsLevel(Enum): 24 | UNTRAINED = "Untrained" 25 | APPRENTICE = "Apprentice" 26 | GURU = "Guru" 27 | MASTER = "Master" 28 | ENLIGHTENED = "Enlightened" 29 | BURNED = "Burned" 30 | 31 | @classmethod 32 | def choices(cls): 33 | return ((level.name, level.value) for level in KwSrsLevel) 34 | 35 | 36 | class WkSrsLevel(Enum): 37 | APPRENTICE = "Apprentice" 38 | GURU = "Guru" 39 | MASTER = "Master" 40 | ENLIGHTENED = "Enlightened" 41 | BURNED = "Burned" 42 | 43 | @classmethod 44 | def choices(cls): 45 | return ((level.name, level.value) for level in WkSrsLevel) 46 | 47 | # Internal SRS levels. Level 0 for us is lesson, whereas WK does not expose lessons at all. 48 | KANIWANI_SRS_LEVELS = OrderedDict() 49 | KANIWANI_SRS_LEVELS[KwSrsLevel.UNTRAINED.name] = [0] 50 | KANIWANI_SRS_LEVELS[KwSrsLevel.APPRENTICE.name] = [1, 2, 3, 4] 51 | KANIWANI_SRS_LEVELS[KwSrsLevel.GURU.name] = [5, 6] 52 | KANIWANI_SRS_LEVELS[KwSrsLevel.MASTER.name] = [7] 53 | KANIWANI_SRS_LEVELS[KwSrsLevel.ENLIGHTENED.name] = [8] 54 | KANIWANI_SRS_LEVELS[KwSrsLevel.BURNED.name] = [9] 55 | 56 | STREAK_TO_SRS_LEVEL_MAP_KW = OrderedDict() 57 | STREAK_TO_SRS_LEVEL_MAP_KW[0] = KwSrsLevel.UNTRAINED 58 | STREAK_TO_SRS_LEVEL_MAP_KW[1] = KwSrsLevel.APPRENTICE 59 | STREAK_TO_SRS_LEVEL_MAP_KW[2] = KwSrsLevel.APPRENTICE 60 | STREAK_TO_SRS_LEVEL_MAP_KW[3] = KwSrsLevel.APPRENTICE 61 | STREAK_TO_SRS_LEVEL_MAP_KW[4] = KwSrsLevel.APPRENTICE 62 | STREAK_TO_SRS_LEVEL_MAP_KW[5] = KwSrsLevel.GURU 63 | STREAK_TO_SRS_LEVEL_MAP_KW[6] = KwSrsLevel.GURU 64 | STREAK_TO_SRS_LEVEL_MAP_KW[7] = KwSrsLevel.MASTER 65 | STREAK_TO_SRS_LEVEL_MAP_KW[8] = KwSrsLevel.ENLIGHTENED 66 | STREAK_TO_SRS_LEVEL_MAP_KW[9] = KwSrsLevel.BURNED 67 | 68 | 69 | # The level arrangement I believe to be exposed by WK API. 70 | WANIKANI_SRS_LEVELS = OrderedDict() 71 | WANIKANI_SRS_LEVELS[WkSrsLevel.APPRENTICE.name] = [0, 1, 2, 3, 4] 72 | WANIKANI_SRS_LEVELS[WkSrsLevel.GURU.name] = [5, 6] 73 | WANIKANI_SRS_LEVELS[WkSrsLevel.MASTER.name] = [7] 74 | WANIKANI_SRS_LEVELS[WkSrsLevel.ENLIGHTENED.name] = [8] 75 | WANIKANI_SRS_LEVELS[WkSrsLevel.BURNED.name] = [9] 76 | 77 | REVIEW_ROUNDING_TIME = timedelta(minutes=15) 78 | 79 | LEVEL_MIN = 1 80 | LEVEL_MAX = 60 81 | API_KEY = "0158f285fa5e1254b84355ce92ccfa99" 82 | 83 | MIN_SVG_DRAW_SPEED = 1 84 | MAX_SVG_DRAW_SPEED = 10 85 | MAX_REVIEW_DETAIL_LEVEL = 2 86 | 87 | MINIMUM_ATTEMPT_COUNT_FOR_CRITICALITY = 4 88 | CRITICALITY_THRESHOLD = 0.75 89 | # NOTE: we no longer display user's WK twitter/webpage bio info 90 | # No plans to do so in the future 91 | # Can safely remove these, associated tests, and model data for twitter/webpage 92 | TWITTER_USERNAME_REGEX = re.compile("[a-zA-Z0-9_]+") 93 | HTTP_S_REGEX = re.compile("https?://") 94 | -------------------------------------------------------------------------------- /api/filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.db.models import Q 4 | from django_filters import rest_framework as filters 5 | from environ import environ 6 | 7 | from kw_webapp.models import Vocabulary, UserSpecific 8 | 9 | import KW.settings 10 | 11 | 12 | def whole_word_regex(value): 13 | # Gross hack to handle this until I fix it: 14 | # https://stackoverflow.com/questions/14997536/whole-word-match-only-in-django-query 15 | if KW.settings.DB_ENGINE == 'sqlite3': 16 | return r"\b" + re.escape(value) + r"\b" 17 | else: 18 | return r"\y" + re.escape(value) + r"\y" 19 | 20 | 21 | def filter_level_for_vocab(queryset, name, value): 22 | if value: 23 | return queryset.filter(readings__level=value).distinct() 24 | 25 | 26 | def filter_level_for_review(queryset, name, value): 27 | if value: 28 | return queryset.filter(vocabulary__readings__level=value).distinct() 29 | 30 | 31 | def filter_meaning_contains(queryset, name, value): 32 | if value: 33 | a = whole_word_regex(value) 34 | return queryset.filter(meaning__regex=whole_word_regex(value)) 35 | 36 | 37 | def filter_meaning_contains_for_review(queryset, name, value): 38 | if value: 39 | a = whole_word_regex(value) 40 | return queryset.filter(vocabulary__meaning__regex=whole_word_regex(value)) 41 | 42 | 43 | def filter_vocabulary_parts_of_speech(queryset, name, value): 44 | if value: 45 | return queryset.filter(readings__parts_of_speech__part=value) 46 | 47 | 48 | def filter_srs_level(queryset, name, value): 49 | if value: 50 | return queryset.filter() 51 | 52 | 53 | def filter_reading_contains(queryset, name, value): 54 | ''' 55 | Filter function return any vocab wherein the reading kana or kanji contain the requested characters 56 | ''' 57 | if value: 58 | return queryset.filter(Q(readings__kana__contains=value) | Q(readings__character__contains=value)).distinct() 59 | 60 | 61 | def filter_reading_contains_for_review(queryset, name, value): 62 | ''' 63 | Filter function return any reviews wherein the vocabulary reading kana or kanji contain the requested characters 64 | ''' 65 | if value: 66 | return queryset.filter(Q(vocabulary__readings__kana__contains=value) | Q(vocabulary__readings__character__contains=value)).distinct() 67 | 68 | 69 | class VocabularyFilter(filters.FilterSet): 70 | level = filters.NumberFilter(method=filter_level_for_vocab) 71 | meaning_contains = filters.CharFilter(method=filter_meaning_contains) 72 | reading_contains = filters.CharFilter(method=filter_reading_contains) 73 | part_of_speech = filters.CharFilter(method=filter_vocabulary_parts_of_speech) 74 | 75 | class Meta: 76 | model = Vocabulary 77 | fields = '__all__' 78 | 79 | 80 | def filter_tag_multi(queryset, name, value): 81 | return queryset.filter(vocabulary__readings__parts_of_speech__part__iexact=value) 82 | 83 | 84 | class ReviewFilter(filters.FilterSet): 85 | level = filters.NumberFilter(method=filter_level_for_review) 86 | meaning_contains = filters.CharFilter(method=filter_meaning_contains_for_review) 87 | reading_contains = filters.CharFilter(method=filter_reading_contains_for_review) 88 | srs_level = filters.NumberFilter(name='streak', lookup_expr='exact') 89 | srs_level_lt = filters.NumberFilter(name='streak', lookup_expr='lt') 90 | srs_level_gt = filters.NumberFilter(name='streak', lookup_expr='gt') 91 | part_of_speech = filters.CharFilter(method=filter_tag_multi) 92 | 93 | class Meta: 94 | model = UserSpecific 95 | fields = ('srs_level', 'srs_level_gt', 'srs_level_lt', 'part_of_speech', 'wanikani_burned') 96 | -------------------------------------------------------------------------------- /kw_webapp/tests/test_meaningsynonym_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pprint 3 | from datetime import timedelta 4 | from time import sleep 5 | from unittest import mock 6 | 7 | from django.utils import timezone 8 | from rest_framework.renderers import JSONRenderer 9 | from rest_framework.reverse import reverse, reverse_lazy 10 | from rest_framework.test import APITestCase 11 | 12 | from kw_webapp.constants import WkSrsLevel, WANIKANI_SRS_LEVELS 13 | from kw_webapp.models import Level, Report, Announcement 14 | from kw_webapp.tests.utils import create_user, create_profile, create_vocab, create_reading, create_review, \ 15 | create_review_for_specific_time 16 | from kw_webapp.utils import one_time_orphaned_level_clear 17 | 18 | 19 | class TestMeaningSynonymApi(APITestCase): 20 | def setUp(self): 21 | self.user = create_user("Tadgh") 22 | create_profile(self.user, "any_key", 5) 23 | self.vocabulary = create_vocab("radioactive bat") 24 | self.reading = create_reading(self.vocabulary, "ねこ", "猫", 5) 25 | self.review = create_review(self.vocabulary, self.user) 26 | 27 | def test_user_can_CRUD_all_their_own_synonyms(self): 28 | self.client.force_login(self.user) 29 | 30 | # Create 31 | synonym = { 32 | 'review': self.review.id, 33 | 'text': "My fancy synonym" 34 | } 35 | response = self.client.post(reverse("api:meaning-synonym-list"), data=synonym) 36 | self.assertEqual(response.status_code, 201) 37 | 38 | # Read 39 | self.assertEqual(self.review.synonyms_string(), "My fancy synonym") 40 | response = self.client.get(reverse("api:meaning-synonym-list")) 41 | self.assertEqual(response.status_code, 200) 42 | data = response.data 43 | synonym = data['results'][0] 44 | self.assertEqual(len(data['results']), 1) 45 | 46 | # Update 47 | synonym['text'] = "A different fancy synonym" 48 | response = self.client.put(reverse("api:meaning-synonym-detail", args=(synonym["id"],)), data=synonym) 49 | 50 | self.assertEqual(response.status_code, 200) 51 | # Double check update worked... 52 | self.review.refresh_from_db() 53 | self.assertEqual(self.review.synonyms_string(), "A different fancy synonym") 54 | self.assertEqual(len(self.review.synonyms_list()), 1) 55 | 56 | # Delete 57 | self.client.delete(reverse("api:meaning-synonym-detail", args=(synonym["id"],))) 58 | self.review.refresh_from_db() 59 | self.assertEqual(len(self.review.synonyms_list()), 0) 60 | 61 | def test_another_user_cannot_CRUD_the_users_synonyms(self): 62 | self.client.force_login(self.user) 63 | sneaky_user = create_user("sneakster") 64 | create_profile(sneaky_user, "any key", 5) 65 | 66 | # Lets have the client create their own synonym. 67 | synonym = { 68 | 'review': self.review.id, 69 | 'text': "My fancy synonym" 70 | } 71 | response = self.client.post(reverse("api:meaning-synonym-list"), data=synonym) 72 | self.assertEqual(response.status_code, 201) 73 | 74 | # Make sure that the sneaky user CANNOT read it. 75 | self.client.force_login(sneaky_user) 76 | synonym_id = self.review.meaning_synonyms.first().id 77 | response = self.client.get(reverse("api:meaning-synonym-detail", args=(synonym_id,))) 78 | self.assertEqual(response.status_code, 404) 79 | 80 | response = self.client.delete(reverse("api:meaning-synonym-detail", args=(synonym_id,))) 81 | self.assertEqual(response.status_code, 404) 82 | 83 | response = self.client.put(reverse("api:meaning-synonym-detail", args=(synonym_id,))) 84 | self.assertEqual(response.status_code, 404) 85 | -------------------------------------------------------------------------------- /kw_webapp/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from unittest import mock 3 | 4 | import responses 5 | from django.http import HttpResponseForbidden 6 | from django.test import TestCase, Client 7 | from django.utils import timezone 8 | from rest_framework.reverse import reverse 9 | 10 | from kw_webapp.tasks import build_API_sync_string_for_user_for_levels 11 | from kw_webapp.tests import sample_api_responses 12 | from kw_webapp.tests.utils import create_user, create_review, create_profile, create_reading 13 | from kw_webapp.tests.utils import create_vocab 14 | 15 | 16 | class TestViews(TestCase): 17 | def setUp(self): 18 | self.user = create_user("user1") 19 | self.user.set_password("password") 20 | self.user.save() 21 | create_profile(self.user, "some_key", 5) 22 | # create a piece of vocab with one reading. 23 | self.vocabulary = create_vocab("radioactive bat") 24 | self.cat_reading = create_reading(self.vocabulary, "ねこ", "猫", 5) 25 | 26 | # setup a review with two synonyms 27 | self.review = create_review(self.vocabulary, self.user) 28 | 29 | self.client = Client() 30 | self.client.login(username="user1", password="password") 31 | 32 | @responses.activate 33 | def test_sync_now_endpoint_returns_correct_json(self): 34 | responses.add(responses.GET, 35 | "https://www.wanikani.com/api/user/{}/user-information".format(self.user.profile.api_key), 36 | json=sample_api_responses.user_information_response_with_higher_level, 37 | status=200, 38 | content_type="application/json") 39 | 40 | responses.add(responses.GET, build_API_sync_string_for_user_for_levels(self.user, [5, 17]), 41 | json=sample_api_responses.single_vocab_response, 42 | status=200, 43 | content_type='application/json') 44 | 45 | test = build_API_sync_string_for_user_for_levels(self.user, [5, 17]) 46 | response = self.client.post(reverse("api:user-sync"), data={"full_sync": "true"}) 47 | 48 | correct_response = { 49 | "new_review_count": 0, 50 | "profile_sync_succeeded": True, 51 | "new_synonym_count": 0 52 | } 53 | 54 | self.assertJSONEqual(str(response.content, encoding='utf8'), correct_response) 55 | 56 | def test_removing_synonym_removes_synonym(self): 57 | dummy_kana = "whatever" 58 | dummy_characters = "somechar" 59 | synonym, created = self.review.add_answer_synonym(dummy_kana, dummy_characters) 60 | 61 | self.client.delete(reverse("api:reading-synonym-detail", args=(synonym.id,))) 62 | 63 | self.review.refresh_from_db() 64 | 65 | self.assertListEqual(self.review.reading_synonyms_list(), []) 66 | 67 | def test_reviewing_that_does_not_need_to_be_reviewed_fails(self): 68 | self.review.needs_review = False 69 | self.review.save() 70 | 71 | response = self.client.post(reverse("api:review-correct", args=(self.review.id,)), data={'wrong_before': 'false'}) 72 | self.assertEqual(response.status_code, 403) 73 | self.assertIsNotNone(response.data['detail']) 74 | 75 | response = self.client.post(reverse("api:review-incorrect", args=(self.review.id,))) 76 | self.assertEqual(response.status_code, 403) 77 | self.assertIsNotNone(response.data['detail']) 78 | 79 | def test_sending_contact_email_returns_json_response(self): 80 | self.client.force_login(self.user) 81 | 82 | response = self.client.post(reverse("api:contact-list"), data={ 83 | "name": "test", 84 | "email": "test@test.com", 85 | "body": "test", 86 | }) 87 | json = response.content 88 | self.assertIsNotNone(json) 89 | -------------------------------------------------------------------------------- /kw_webapp/tests/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import responses 4 | from django.contrib.auth.models import User 5 | 6 | from kw_webapp.constants import API_KEY 7 | from kw_webapp.models import Vocabulary, Reading, UserSpecific, Profile 8 | from kw_webapp.tasks import build_user_information_api_string, build_API_sync_string_for_user_for_levels 9 | from kw_webapp.tests import sample_api_responses 10 | 11 | 12 | def create_user(username): 13 | u = User.objects.create(username=username) 14 | u.set_password(username) 15 | u.save() 16 | return u 17 | 18 | 19 | def create_review(vocabulary, user): 20 | u = UserSpecific.objects.create(vocabulary=vocabulary, user=user) 21 | u.streak = 1 22 | u.save() 23 | return u 24 | 25 | 26 | def create_lesson(vocabulary, user): 27 | u = UserSpecific.objects.create(vocabulary=vocabulary, user=user) 28 | u.streak = 0 29 | u.save() 30 | return u 31 | 32 | 33 | def create_profile(user, api_key, level): 34 | p = Profile.objects.create(user=user, api_key=api_key, level=level) 35 | p.unlocked_levels.create(level=level) 36 | return p 37 | 38 | 39 | def create_vocab(meaning): 40 | v = Vocabulary.objects.create(meaning=meaning) 41 | return v 42 | 43 | 44 | def create_reading(vocab, reading, character, level): 45 | r = Reading.objects.create(vocabulary=vocab, 46 | kana=reading, level=level, character=character) 47 | return r 48 | 49 | 50 | def create_review_for_specific_time(user, meaning, time_to_review): 51 | timed_review = create_review(create_vocab(meaning), user) 52 | timed_review.needs_review = False 53 | timed_review.streak = 1 54 | timed_review.last_studied = time_to_review + timedelta(hours=-6) 55 | timed_review.next_review_date = time_to_review 56 | timed_review.save() 57 | return timed_review 58 | 59 | 60 | def build_test_api_string_for_merging(): 61 | api_call = "https://www.wanikani.com/api/user/{}/vocabulary/TEST".format(API_KEY) 62 | return api_call 63 | 64 | 65 | def mock_vocab_list_response_with_single_vocabulary(user): 66 | responses.add(responses.GET, build_API_sync_string_for_user_for_levels(user, user.profile.level), 67 | json=sample_api_responses.single_vocab_response, 68 | status=200, 69 | content_type='application/json') 70 | 71 | 72 | def mock_user_info_response_with_higher_level(api_key): 73 | responses.add(responses.GET, build_user_information_api_string(api_key), 74 | json=sample_api_responses.user_information_response_with_higher_level, 75 | status=200, 76 | content_type='application/json') 77 | 78 | 79 | def mock_user_info_response(api_key): 80 | responses.add(responses.GET, build_user_information_api_string(api_key), 81 | json=sample_api_responses.user_information_response, 82 | status=200, 83 | content_type='application/json') 84 | 85 | 86 | def mock_invalid_api_user_info_response(api_key): 87 | responses.add(responses.GET, build_user_information_api_string(api_key), 88 | json={"Nothing":"Nothing"}, 89 | status=200, 90 | content_type='application/json') 91 | 92 | 93 | def mock_vocab_list_response_with_single_vocabulary_with_four_synonyms(user): 94 | responses.add(responses.GET, build_API_sync_string_for_user_for_levels(user, [user.profile.level, ]), 95 | json=sample_api_responses.single_vocab_response_with_4_meaning_synonyms, 96 | status=200, 97 | content_type='application/json') 98 | 99 | 100 | def mock_vocab_list_response_with_single_vocabulary_with_changed_meaning(user): 101 | responses.add(responses.GET, build_API_sync_string_for_user_for_levels(user, [user.profile.level, ]), 102 | json=sample_api_responses.single_vocab_response_with_changed_meaning, 103 | status=200, 104 | content_type='application/json') 105 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0002_auto_20160110_1624.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.core.validators 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('kw_webapp', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Synonym', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', auto_created=True, primary_key=True, serialize=False)), 19 | ('text', models.CharField(max_length=255)), 20 | ('review', models.ForeignKey(null=True, to='kw_webapp.UserSpecific')), 21 | ], 22 | options={ 23 | }, 24 | bases=(models.Model,), 25 | ), 26 | migrations.RemoveField( 27 | model_name='userspecific', 28 | name='synonyms', 29 | ), 30 | migrations.AddField( 31 | model_name='profile', 32 | name='about', 33 | field=models.CharField(default='', max_length=255), 34 | preserve_default=True, 35 | ), 36 | migrations.AddField( 37 | model_name='profile', 38 | name='api_valid', 39 | field=models.BooleanField(default=False), 40 | preserve_default=True, 41 | ), 42 | migrations.AddField( 43 | model_name='profile', 44 | name='join_date', 45 | field=models.DateField(auto_now_add=True), 46 | preserve_default=True, 47 | ), 48 | migrations.AddField( 49 | model_name='profile', 50 | name='posts_count', 51 | field=models.PositiveIntegerField(default=0), 52 | preserve_default=True, 53 | ), 54 | migrations.AddField( 55 | model_name='profile', 56 | name='title', 57 | field=models.CharField(default='Turtles', max_length=255), 58 | preserve_default=True, 59 | ), 60 | migrations.AddField( 61 | model_name='profile', 62 | name='topics_count', 63 | field=models.PositiveIntegerField(default=0), 64 | preserve_default=True, 65 | ), 66 | migrations.AddField( 67 | model_name='profile', 68 | name='twitter', 69 | field=models.CharField(default='N/A', max_length=255), 70 | preserve_default=True, 71 | ), 72 | migrations.AddField( 73 | model_name='profile', 74 | name='website', 75 | field=models.CharField(default='N/A', max_length=255), 76 | preserve_default=True, 77 | ), 78 | migrations.AddField( 79 | model_name='userspecific', 80 | name='hidden', 81 | field=models.BooleanField(default=False), 82 | preserve_default=True, 83 | ), 84 | migrations.AlterField( 85 | model_name='announcement', 86 | name='pub_date', 87 | field=models.DateTimeField(null=True, auto_now_add=True, verbose_name='Date Published'), 88 | preserve_default=True, 89 | ), 90 | migrations.AlterField( 91 | model_name='level', 92 | name='level', 93 | field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(60)]), 94 | preserve_default=True, 95 | ), 96 | migrations.AlterField( 97 | model_name='profile', 98 | name='level', 99 | field=models.PositiveIntegerField(null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(60)]), 100 | preserve_default=True, 101 | ), 102 | migrations.AlterField( 103 | model_name='reading', 104 | name='level', 105 | field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(60)]), 106 | preserve_default=True, 107 | ), 108 | ] 109 | -------------------------------------------------------------------------------- /kw_webapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.utils.timezone import utc 6 | import datetime 7 | import django.utils.timezone 8 | from django.conf import settings 9 | import django.core.validators 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Announcement', 21 | fields=[ 22 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 23 | ('title', models.CharField(max_length=255)), 24 | ('body', models.TextField()), 25 | ('pub_date', models.DateTimeField(default=datetime.datetime(2016, 1, 10, 21, 15, 22, 895830, tzinfo=utc), verbose_name='Date Published', null=True)), 26 | ('creator', models.ForeignKey(to=settings.AUTH_USER_MODEL)), 27 | ], 28 | options={ 29 | }, 30 | bases=(models.Model,), 31 | ), 32 | migrations.CreateModel( 33 | name='Level', 34 | fields=[ 35 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 36 | ('level', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(50)])), 37 | ], 38 | options={ 39 | }, 40 | bases=(models.Model,), 41 | ), 42 | migrations.CreateModel( 43 | name='Profile', 44 | fields=[ 45 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 46 | ('api_key', models.CharField(max_length=255)), 47 | ('gravatar', models.CharField(max_length=255)), 48 | ('level', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(50)], null=True)), 49 | ('unlocked_levels', models.ManyToManyField(to='kw_webapp.Level')), 50 | ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), 51 | ], 52 | options={ 53 | }, 54 | bases=(models.Model,), 55 | ), 56 | migrations.CreateModel( 57 | name='Reading', 58 | fields=[ 59 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 60 | ('character', models.CharField(max_length=255)), 61 | ('kana', models.CharField(max_length=255)), 62 | ('level', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(50)])), 63 | ], 64 | options={ 65 | }, 66 | bases=(models.Model,), 67 | ), 68 | migrations.CreateModel( 69 | name='UserSpecific', 70 | fields=[ 71 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 72 | ('synonyms', models.CharField(default=None, blank=True, null=True, max_length=255)), 73 | ('correct', models.PositiveIntegerField(default=0)), 74 | ('incorrect', models.PositiveIntegerField(default=0)), 75 | ('streak', models.PositiveIntegerField(default=0)), 76 | ('last_studied', models.DateTimeField(auto_now_add=True)), 77 | ('needs_review', models.BooleanField(default=True)), 78 | ('unlock_date', models.DateTimeField(default=django.utils.timezone.now, blank=True)), 79 | ('next_review_date', models.DateTimeField(default=django.utils.timezone.now, blank=True, null=True)), 80 | ('burnt', models.BooleanField(default=False)), 81 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), 82 | ], 83 | options={ 84 | }, 85 | bases=(models.Model,), 86 | ), 87 | migrations.CreateModel( 88 | name='Vocabulary', 89 | fields=[ 90 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 91 | ('meaning', models.CharField(max_length=255)), 92 | ], 93 | options={ 94 | }, 95 | bases=(models.Model,), 96 | ), 97 | migrations.AddField( 98 | model_name='userspecific', 99 | name='vocabulary', 100 | field=models.ForeignKey(to='kw_webapp.Vocabulary'), 101 | preserve_default=True, 102 | ), 103 | migrations.AddField( 104 | model_name='reading', 105 | name='vocabulary', 106 | field=models.ForeignKey(to='kw_webapp.Vocabulary'), 107 | preserve_default=True, 108 | ), 109 | ] 110 | -------------------------------------------------------------------------------- /KW/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for KW project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.6/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.6/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | from datetime import timedelta 13 | 14 | import os 15 | from collections import namedtuple 16 | 17 | import environ 18 | import raven 19 | from celery.schedules import crontab 20 | from django.core.urlresolvers import reverse_lazy 21 | from django.utils.log import DEFAULT_LOGGING 22 | 23 | root = environ.Path(__file__) - 2 24 | log_root = root.path("logs") 25 | 26 | env = environ.Env(DEBUG=(bool, False)) 27 | environ.Env.read_env(root.path("KW").file(".env")) 28 | 29 | LOGLEVEL = env("LOGLEVEL", default="INFO").upper() 30 | 31 | # This allows the /docs/ endpoints to correctly build urls. 32 | USE_X_FORWARDED_HOST = True 33 | MY_TIME_ZONE = 'America/New_York' 34 | 35 | LOGGING = { 36 | 'version': 1, 37 | 'disable_existing_loggers': False, 38 | 'formatters': { 39 | 'console': { 40 | 'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s' 41 | }, 42 | 'request': { 43 | 'format': '%(asctime)s %(name)-12s %(levelname)-8s REQUEST: %(message)s' 44 | }, 45 | 'django.server': DEFAULT_LOGGING['formatters']['django.server'], 46 | }, 47 | 'filters': { 48 | 'require_debug_true': { 49 | '()': 'django.utils.log.RequireDebugTrue', 50 | }, 51 | }, 52 | 'handlers': { 53 | 'console': { 54 | 'formatter': 'console', 55 | 'class': 'logging.StreamHandler' 56 | }, 57 | 'sentry': { 58 | 'formatter': 'console', 59 | 'level': 'WARNING', 60 | 'filters': ['require_debug_true'], 61 | 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler' 62 | }, 63 | 'app_log': { 64 | 'formatter': 'console', 65 | 'level': LOGLEVEL, 66 | 'class': 'logging.handlers.TimedRotatingFileHandler', 67 | 'filename': log_root("kaniwani.log"), 68 | 'when': 'midnight', 69 | 'backupCount': '30', 70 | }, 71 | 'request_log': { 72 | 'formatter': 'request', 73 | 'level': 'INFO', 74 | 'class': 'logging.handlers.TimedRotatingFileHandler', 75 | 'filename': log_root("requests.log"), 76 | 'when': 'midnight', 77 | 'backupCount': '5', 78 | }, 79 | 'django.server': DEFAULT_LOGGING['handlers']['django.server'], 80 | }, 81 | 'loggers': { 82 | # ROOT LOGGER 83 | '': { 84 | 'level': LOGLEVEL, 85 | 'handlers': ['console', 'sentry'] 86 | }, 87 | # For anything in the 'api' directory. e.g. api.views, api.tasks, etc. 88 | 'api': { 89 | 'level': LOGLEVEL, 90 | 'handlers': ['console', 'app_log', 'sentry'], 91 | 'propagate': False 92 | }, 93 | 'kw_webapp': { 94 | 'level': LOGLEVEL, 95 | 'handlers': ['console', 'app_log', 'sentry'], 96 | 'propagate': False 97 | }, 98 | # Used for drf-tracking which logs all request/response info. For later shipping to ELK 99 | 'KW.LoggingMiddleware': { 100 | 'level': LOGLEVEL, 101 | 'handlers': ['request_log'], 102 | 'propagate': False 103 | }, 104 | 'celery': { 105 | 'handlers': ['sentry', 'console'], 106 | 'level': LOGLEVEL, 107 | 'propagate': False 108 | }, 109 | 'django.server': DEFAULT_LOGGING['loggers']['django.server'], 110 | }, 111 | } 112 | 113 | CELERY_RESULT_BACKEND = env.cache_url("REDIS_URL")["LOCATION"] 114 | CELERYD_HIJACK_ROOT_LOGGER = False 115 | CELERY_BROKER_URL = 'redis://localhost:6379/0' 116 | CELERY_ACCEPT_CONTENT = ['application/json'] 117 | CELERY_TASK_SERIALIZER = 'json' 118 | CELERY_RESULTS_SERIALIZER = 'json' 119 | CELERY_TIMEZONE = MY_TIME_ZONE 120 | CELERY_BEAT_SCHEDULE = { 121 | 'all_user_srs_every_hour': { 122 | 'task': 'kw_webapp.tasks.all_srs', 123 | 'schedule': crontab(minute="*/15") 124 | }, 125 | 'update_users_unlocked_vocab': { 126 | 'task': 'kw_webapp.tasks.sync_all_users_to_wk', 127 | 'schedule': timedelta(hours=12), 128 | 'options': {'queue': 'long_running_sync'} 129 | } 130 | } 131 | 132 | # Quick-start development settings - unsuitable for production 133 | # See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ 134 | 135 | SECRET_KEY = env("SECRET_KEY") 136 | DEBUG = env("DEBUG") 137 | 138 | ALLOWED_HOSTS = ['127.0.0.1', 'localhost', 'www.kaniwani.com', '.kaniwani.com'] 139 | 140 | CORS_ORIGIN_WHITELIST = env.list("CORS_ORIGIN_WHITELIST") 141 | 142 | CORS_ALLOW_CREDENTIALS = True 143 | 144 | LOGIN_URL = "/api/v1/auth/login/" 145 | 146 | INSTALLED_APPS = ( 147 | 'django.contrib.contenttypes', 148 | 'kw_webapp.apps.KaniwaniConfig', 149 | 'django.contrib.admin', 150 | 'django.contrib.auth', 151 | 'django.contrib.humanize', 152 | 'django.contrib.sessions', 153 | 'django.contrib.sites', 154 | 'django.contrib.messages', 155 | 'django.contrib.staticfiles', 156 | 'rest_framework', 157 | 'debug_toolbar', 158 | 'rest_framework.authtoken', 159 | 'corsheaders', 160 | 'djoser', 161 | 'raven.contrib.django.raven_compat', 162 | 'rest_framework_tracking' 163 | ) 164 | 165 | MIDDLEWARE = [ 166 | 'corsheaders.middleware.CorsMiddleware', 167 | 'django.contrib.sessions.middleware.SessionMiddleware', 168 | 'django.middleware.common.CommonMiddleware', 169 | 'django.middleware.csrf.CsrfViewMiddleware', 170 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 171 | 'django.contrib.messages.middleware.MessageMiddleware', 172 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 173 | 'django.middleware.gzip.GZipMiddleware', 174 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 175 | 'kw_webapp.middleware.SetLastVisitMiddleware' 176 | ] 177 | 178 | if DEBUG: 179 | MIDDLEWARE += [ 180 | 'KW.LoggingMiddleware.ExceptionLoggingMiddleware', 181 | ] 182 | 183 | REST_FRAMEWORK = { 184 | 'DEFAULT_PERMISSION_CLASSES': [ 185 | 'rest_framework.permissions.IsAuthenticatedOrReadOnly', 186 | ], 187 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 188 | 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', 189 | 'rest_framework.authentication.SessionAuthentication' 190 | ], 191 | 'DEFAULT_RENDERER_CLASSES': [ 192 | # Simple overridden class which will dump empty JSON into the response if we find that the content is empty. 193 | 'kw_webapp.renderers.FallbackJSONRenderer' 194 | ], 195 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 196 | 'PAGE_SIZE': 100, 197 | 'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',) 198 | } 199 | 200 | CACHES = { 201 | 'default': env.cache("REDIS_URL", default="rediscache://127.0.0.1:6379/0") 202 | } 203 | 204 | ROOT_URLCONF = 'KW.urls' 205 | 206 | WSGI_APPLICATION = 'KW.wsgi.application' 207 | 208 | # EMAIL BACKEND SETTINGS 209 | EMAIL_CONFIG = env.email_url('EMAIL_URL', default="dummymail://") 210 | vars().update(EMAIL_CONFIG) 211 | 212 | TIME_ZONE = MY_TIME_ZONE 213 | SITE_ID = 1 214 | 215 | DATABASES = { 216 | 'default': env.db(default="sqlite://db.sqlite3") 217 | } 218 | 219 | DB_ENGINE = DATABASES['default']['ENGINE'].split(".")[-1] 220 | 221 | LANGUAGE_CODE = 'en-us' 222 | USE_I18N = True 223 | USE_L10N = True 224 | USE_TZ = True 225 | 226 | # Static files (CSS, JavaScript, Images) 227 | # https://docs.djangoproject.com/en/1.6/howto/static-files/ 228 | 229 | STATIC_URL = '/static/' 230 | STATIC_ROOT = "/var/www/kaniwani.com/static" 231 | 232 | # Security stuff 233 | CSRF_COOKIE_SECURE = True 234 | X_FRAME_OPTIONS = "DENY" 235 | SESSION_COOKIE_SECURE = True 236 | 237 | 238 | INTERNAL_IPS = ('127.0.0.1',) 239 | 240 | TEMPLATES = [ 241 | { 242 | "BACKEND": "django.template.backends.django.DjangoTemplates", 243 | "DIRS": [ 244 | root("templates"), 245 | root("kw_webapp/templates/kw_webapp") 246 | ], 247 | "APP_DIRS": True, 248 | "OPTIONS": { 249 | "context_processors": [ 250 | 'django.contrib.auth.context_processors.auth', 251 | 'django.template.context_processors.request', 252 | ], 253 | "debug": DEBUG 254 | } 255 | } 256 | ] 257 | 258 | MANAGERS = [("Gary", "tadgh@cs.toronto.edu",), ("Duncan", "duncan.bay@gmail.com")] 259 | DEFAULT_FROM_EMAIL = "gary@kaniwani.com" 260 | 261 | JWT_AUTH = { 262 | 'JWT_VERIFY_EXPIRATION': False 263 | } 264 | 265 | AUTHENTICATION_BACKENDS = [ 266 | 'kw_webapp.backends.EmailOrUsernameAuthenticationBackend', 267 | 'django.contrib.auth.backends.ModelBackend' 268 | ] 269 | 270 | DJOSER = { 271 | 'SERIALIZERS': { 272 | "user_create": 'api.serializers.RegistrationSerializer' 273 | }, 274 | 'PASSWORD_RESET_CONFIRM_URL': "password-reset/{uid}/{token}", 275 | } 276 | 277 | RAVEN_CONFIG = { 278 | 'dsn': env("RAVEN_DSN"), 279 | 'release': env("RELEASE", default="UNKNOWN") 280 | } if not DEBUG else {} 281 | -------------------------------------------------------------------------------- /kw_webapp/tests/sample_api_responses.py: -------------------------------------------------------------------------------- 1 | single_vocab_response = { 2 | "user_information": { 3 | "username": "Tadgh11", 4 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 5 | "level": 16, 6 | "title": "Turtles", 7 | "about": "", 8 | "website": "http://www.kaniwani.com", 9 | "twitter": "@Tadgh11", 10 | "topics_count": 1, 11 | "posts_count": 81, 12 | "creation_date": 1373371374, 13 | "vacation_date": None 14 | }, 15 | "requested_information": [{ 16 | "character": "猫", 17 | "kana": "ねこ", 18 | "meaning": "radioactive bat", 19 | "level": 16, 20 | "user_specific": { 21 | "srs": "apprentice", 22 | "srs_numeric": 3, 23 | "unlocked_date": 1448398437, 24 | "available_date": 1448586000, 25 | "burned": False, 26 | "burned_date": 0, 27 | "meaning_correct": 0, 28 | "meaning_incorrect": 0, 29 | "meaning_max_streak": 0, 30 | "meaning_current_streak": 0, 31 | "reading_correct": 0, 32 | "reading_incorrect": 0, 33 | "reading_max_streak": 0, 34 | "reading_current_streak": 0, 35 | "meaning_note": None, 36 | "user_synonyms": [], 37 | "reading_note": None 38 | } 39 | }] 40 | } 41 | 42 | single_vocab_existing_meaning_and_should_now_merge = { 43 | "user_information": { 44 | "username": "Tadgh11", 45 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 46 | "level": 16, 47 | "title": "Turtles", 48 | "about": "", 49 | "website": "http://www.kaniwani.com", 50 | "twitter": "@Tadgh11", 51 | "topics_count": 1, 52 | "posts_count": 81, 53 | "creation_date": 1373371374, 54 | "vacation_date": None 55 | }, 56 | "requested_information": [{ 57 | "character": "犬", 58 | "kana": "ねこ", 59 | "meaning": "dog, woofer, pupper", 60 | "level": 5, 61 | "user_specific": { 62 | "srs": "apprentice", 63 | "srs_numeric": 3, 64 | "unlocked_date": 1448398437, 65 | "available_date": 1448586000, 66 | "burned": False, 67 | "burned_date": 0, 68 | "meaning_correct": 0, 69 | "meaning_incorrect": 0, 70 | "meaning_max_streak": 0, 71 | "meaning_current_streak": 0, 72 | "reading_correct": 0, 73 | "reading_incorrect": 0, 74 | "reading_max_streak": 0, 75 | "reading_current_streak": 0, 76 | "meaning_note": None, 77 | "user_synonyms": [], 78 | "reading_note": None 79 | } 80 | }] 81 | } 82 | 83 | single_vocab_new_meaning_and_should_now_merge = { 84 | "user_information": { 85 | "username": "Tadgh11", 86 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 87 | "level": 16, 88 | "title": "Turtles", 89 | "about": "", 90 | "website": "http://www.kaniwani.com", 91 | "twitter": "@Tadgh11", 92 | "topics_count": 1, 93 | "posts_count": 81, 94 | "creation_date": 1373371374, 95 | "vacation_date": None 96 | }, 97 | "requested_information": [{ 98 | "character": "猫", 99 | "kana": "ねこ", 100 | "meaning": "DOGGO", #was previously radioactive bat. Has now changed, and should be aglomerated with original DOGGO 101 | "level": 16, 102 | "user_specific": { 103 | "srs": "apprentice", 104 | "srs_numeric": 3, 105 | "unlocked_date": 1448398437, 106 | "available_date": 1448586000, 107 | "burned": False, 108 | "burned_date": 0, 109 | "meaning_correct": 0, 110 | "meaning_incorrect": 0, 111 | "meaning_max_streak": 0, 112 | "meaning_current_streak": 0, 113 | "reading_correct": 0, 114 | "reading_incorrect": 0, 115 | "reading_max_streak": 0, 116 | "reading_current_streak": 0, 117 | "meaning_note": None, 118 | "user_synonyms": [], 119 | "reading_note": None 120 | } 121 | }] 122 | } 123 | 124 | added_meaning_to_conglomerate_vocab_sample_response = { 125 | "user_information": { 126 | "username": "Tadgh11", 127 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 128 | "level": 16, 129 | "title": "Turtles", 130 | "about": "", 131 | "website": "http://www.kaniwani.com", 132 | "twitter": "@Tadgh11", 133 | "topics_count": 1, 134 | "posts_count": 81, 135 | "creation_date": 1373371374, 136 | "vacation_date": None 137 | }, 138 | "requested_information": [{ 139 | "character": "工作", 140 | "kana": "ねこ", 141 | "meaning": "construction, handicraft", 142 | "level": 2, 143 | "user_specific": { 144 | "srs": "burned", 145 | "srs_numeric": 9, 146 | "unlocked_date": 1448398437, 147 | "available_date": 1448586000, 148 | "burned": True, 149 | "burned_date": 0, 150 | "meaning_correct": 9, 151 | "meaning_incorrect": 0, 152 | "meaning_max_streak": 9, 153 | "meaning_current_streak": 0, 154 | "reading_correct": 0, 155 | "reading_incorrect": 0, 156 | "reading_max_streak": 0, 157 | "reading_current_streak": 0, 158 | "meaning_note": None, 159 | "user_synonyms": [], 160 | "reading_note": None 161 | } 162 | }] 163 | } 164 | 165 | user_information_response_with_higher_level = { 166 | "user_information": { 167 | "username": "Tadgh11", 168 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 169 | "level": 17, 170 | "title": "Turtles", 171 | "about": "", 172 | "website": "http://www.kaniwani.com", 173 | "twitter": "@Tadgh11", 174 | "topics_count": 1, 175 | "posts_count": 81, 176 | "creation_date": 1373371374, 177 | "vacation_date": None 178 | } 179 | } 180 | 181 | user_information_response = { 182 | "user_information": { 183 | "username": "Tadgh11", 184 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 185 | "level": 5, 186 | "title": "Turtles", 187 | "about": "", 188 | "website": "http://www.kaniwani.com", 189 | "twitter": "@Tadgh11", 190 | "topics_count": 1, 191 | "posts_count": 81, 192 | "creation_date": 1373371374, 193 | "vacation_date": None 194 | } 195 | } 196 | 197 | single_vocab_requested_information = {"character": "bleh", "kana": "bleh", "meaning": "two", "level": 1, 198 | "user_specific": {"srs": "burned", "srs_numeric": 9, "unlocked_date": 1382674360, 199 | "available_date": 1398364200, "burned": True, 200 | "burned_date": 1398364287, 201 | "meaning_correct": 8, "meaning_incorrect": 0, 202 | "meaning_max_streak": 8, 203 | "meaning_current_streak": 8, "reading_correct": 8, 204 | "reading_incorrect": 0, 205 | "reading_max_streak": 8, "reading_current_streak": 8, 206 | "meaning_note": None, 207 | "user_synonyms": None, "reading_note": None}} 208 | 209 | 210 | single_vocab_response_with_4_meaning_synonyms = { 211 | "user_information": { 212 | "username": "Tadgh11", 213 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 214 | "level": 16, 215 | "title": "Turtles", 216 | "about": "", 217 | "website": "http://www.kaniwani.com", 218 | "twitter": "@Tadgh11", 219 | "topics_count": 1, 220 | "posts_count": 81, 221 | "creation_date": 1373371374, 222 | "vacation_date": None 223 | }, 224 | "requested_information": [{ 225 | "character": "猫", 226 | "kana": "ねこ", 227 | "meaning": "radioactive bat", 228 | "level": 16, 229 | "user_specific": { 230 | "srs": "apprentice", 231 | "srs_numeric": 3, 232 | "unlocked_date": 1448398437, 233 | "available_date": 1448586000, 234 | "burned": False, 235 | "burned_date": 0, 236 | "meaning_correct": 0, 237 | "meaning_incorrect": 0, 238 | "meaning_max_streak": 0, 239 | "meaning_current_streak": 0, 240 | "reading_correct": 0, 241 | "reading_incorrect": 0, 242 | "reading_max_streak": 0, 243 | "reading_current_streak": 0, 244 | "meaning_note": None, 245 | "user_synonyms": [ 246 | "synonym_1", 247 | "synonym_2", 248 | "synonym_3", 249 | "synonym_4", 250 | ], 251 | "reading_note": None 252 | } 253 | }] 254 | } 255 | 256 | single_vocab_response_with_changed_meaning = { 257 | "user_information": { 258 | "username": "Tadgh11", 259 | "gravatar": "a9453be85d2e722fd7e3b3424a38be30", 260 | "level": 16, 261 | "title": "Turtles", 262 | "about": "", 263 | "website": "http://www.kaniwani.com", 264 | "twitter": "@Tadgh11", 265 | "topics_count": 1, 266 | "posts_count": 81, 267 | "creation_date": 1373371374, 268 | "vacation_date": None 269 | }, 270 | "requested_information": [{ 271 | "character": "猫", 272 | "kana": "ねこ", 273 | "meaning": "radioactive bat, added meaning, new meaning.", 274 | "level": 16, 275 | "user_specific": { 276 | "srs": "apprentice", 277 | "srs_numeric": 3, 278 | "unlocked_date": 1448398437, 279 | "available_date": 1448586000, 280 | "burned": False, 281 | "burned_date": 0, 282 | "meaning_correct": 0, 283 | "meaning_incorrect": 0, 284 | "meaning_max_streak": 0, 285 | "meaning_current_streak": 0, 286 | "reading_correct": 0, 287 | "reading_incorrect": 0, 288 | "reading_max_streak": 0, 289 | "reading_current_streak": 0, 290 | "meaning_note": None, 291 | "user_synonyms": [ 292 | "synonym_1", 293 | "synonym_2", 294 | "synonym_3", 295 | "synonym_4", 296 | ], 297 | "reading_note": None 298 | } 299 | }] 300 | } 301 | -------------------------------------------------------------------------------- /kw_webapp/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from datetime import timedelta 4 | from django.core.exceptions import ValidationError 5 | from django.db import IntegrityError 6 | from django.http import HttpResponseForbidden 7 | from django.test import Client 8 | from django.utils import timezone 9 | from rest_framework.reverse import reverse 10 | from rest_framework.test import APITestCase 11 | 12 | from kw_webapp import constants 13 | from kw_webapp.models import MeaningSynonym, UserSpecific, Profile, Tag 14 | from kw_webapp.tests.utils import create_user, create_review, create_reading, create_profile 15 | from kw_webapp.tests.utils import create_vocab 16 | 17 | 18 | class TestModels(APITestCase): 19 | def setUp(self): 20 | self.user = create_user("Tadgh") 21 | self.user.set_password("password") 22 | create_profile(self.user, "any key", 1) 23 | self.user.save() 24 | self.vocabulary = create_vocab("cat") 25 | self.review = create_review(self.vocabulary, self.user) 26 | self.review.meaning_synonyms.get_or_create(text="minou") 27 | 28 | # default state of a test is a user that has a single review, and the review has a single synonym added. 29 | 30 | def test_toggling_review_hidden_ownership_fails_on_wrong_user(self): 31 | user2 = create_user("eve") 32 | user2.set_password("im_a_hacker") 33 | create_profile(user2, "any_key", 1) 34 | user2.save() 35 | relevant_review_id = UserSpecific.objects.get(user=self.user, vocabulary=self.vocabulary).id 36 | 37 | self.client.force_login(user2) 38 | response = self.client.post(reverse("api:review-hide", args=(relevant_review_id,))) 39 | self.assertIsInstance(response, HttpResponseForbidden) 40 | 41 | def test_toggling_review_hidden_ownership_works(self): 42 | relevant_review_id = UserSpecific.objects.get(user=self.user, vocabulary=self.vocabulary).id 43 | before_toggle_hidden = UserSpecific.objects.get(id=relevant_review_id).hidden 44 | 45 | if self.client.login(username=self.user.username, password="password"): 46 | response = self.client.post(path="/kw/togglevocab/", data={"review_id": relevant_review_id}) 47 | print(response.content) 48 | else: 49 | self.fail("Couldn't log in!?") 50 | 51 | after_toggle_hidden = UserSpecific.objects.get(id=relevant_review_id) 52 | self.assertNotEqual(before_toggle_hidden, after_toggle_hidden) 53 | 54 | def test_adding_synonym_works(self): 55 | self.review.meaning_synonyms.get_or_create(text="une petite chatte") 56 | self.assertEqual(2, len(self.review.meaning_synonyms.all())) 57 | 58 | def test_removing_synonym_by_lookup_works(self): 59 | remove_text = "minou" 60 | self.review.remove_synonym(remove_text) 61 | self.assertNotIn(remove_text, self.review.synonyms_list()) 62 | 63 | def test_removing_nonexistent_synonym_fails(self): 64 | remove_text = "un chien" 65 | self.assertRaises(MeaningSynonym.DoesNotExist, self.review.remove_synonym, remove_text) 66 | 67 | def test_removing_synonym_by_object_works(self): 68 | synonym, created = self.review.meaning_synonyms.get_or_create(text="minou") 69 | self.review.meaning_synonyms.remove(synonym) 70 | 71 | def test_reading_clean_fails_with_invalid_levels_too_high(self): 72 | v = create_vocab("cat") 73 | r = create_reading(v, "ねこ", "ねこ", 61) 74 | 75 | self.assertRaises(ValidationError, r.clean_fields) 76 | 77 | def test_reading_clean_fails_with_invalid_levels_too_low(self): 78 | v = create_vocab("cat") 79 | r = create_reading(v, "ねこ", "ねこ", 0) 80 | 81 | self.assertRaises(ValidationError, r.clean_fields) 82 | 83 | def test_vocab_number_readings_is_correct(self): 84 | r = create_reading(self.vocabulary, "ねこ", "ねこ", 2) 85 | r = create_reading(self.vocabulary, "ねこな", "猫", 1) 86 | self.assertEqual(self.vocabulary.reading_count(), 2) 87 | 88 | def test_available_readings_returns_only_readings_youve_unlocked(self): 89 | v = create_vocab("cat") 90 | r = create_reading(v, "ねこ", "ねこ", 5) 91 | r = create_reading(v, "ねこな", "猫", 1) 92 | 93 | self.assertTrue(len(v.available_readings(2)) == 1) 94 | 95 | def test_synonym_adding(self): 96 | self.review.meaning_synonyms.get_or_create(text="kitty") 97 | 98 | self.assertIn("kitty", self.review.synonyms_string()) 99 | 100 | def test_get_all_readings_returns_original_and_added_readings(self): 101 | self.vocabulary.readings.create(kana="what", character="ars", level=5) 102 | self.review.reading_synonyms.create(kana="shwoop", character="fwoop") 103 | 104 | expected = list(chain(self.vocabulary.readings.all(), self.review.reading_synonyms.all())) 105 | 106 | self.assertListEqual(expected, self.review.get_all_readings()) 107 | 108 | def test_setting_twitter_account_correctly_prepends_at_symbol(self): 109 | non_prepended_account_name = "Tadgh" 110 | self.user.profile.set_twitter_account(non_prepended_account_name) 111 | 112 | users_profile = Profile.objects.get(user=self.user) 113 | self.assertEqual(users_profile.twitter, "@Tadgh") 114 | 115 | def test_setting_twitter_account_works_when_input_is_already_valid(self): 116 | account_name = "@Tadgh" 117 | self.user.profile.set_twitter_account(account_name) 118 | 119 | users_profile = Profile.objects.get(user=self.user) 120 | 121 | self.assertEqual(users_profile.twitter, "@Tadgh") 122 | 123 | def test_setting_an_invalid_twitter_handle_does_not_modify_model_instance(self): 124 | invalid_account_name = "!!" 125 | old_twitter = self.user.profile.twitter 126 | 127 | self.user.profile.set_twitter_account(invalid_account_name) 128 | 129 | users_profile = Profile.objects.get(user=self.user) 130 | 131 | self.assertEqual(users_profile.twitter, old_twitter) 132 | 133 | def test_setting_a_blank_twitter_handle_does_not_modify_model_instance(self): 134 | invalid_account_name = "@" 135 | old_twitter = self.user.profile.twitter 136 | 137 | self.user.profile.set_twitter_account(invalid_account_name) 138 | 139 | users_profile = Profile.objects.get(user=self.user) 140 | 141 | self.assertEqual(users_profile.twitter, old_twitter) 142 | 143 | def test_setting_valid_profile_website_modifies_model(self): 144 | valid_site = "www.kaniwani.com" 145 | 146 | self.user.profile.set_website(valid_site) 147 | 148 | users_profile = Profile.objects.get(user=self.user) 149 | 150 | self.assertEqual(users_profile.website, valid_site) 151 | 152 | def test_setting_website_with_http_prepended_gets_it_stripped(self): 153 | http_prepended_valid_site = "http://https://www.kaniwani.com" 154 | 155 | self.user.profile.set_website(http_prepended_valid_site) 156 | 157 | users_profile = Profile.objects.get(user=self.user) 158 | 159 | self.assertEqual(users_profile.website, "www.kaniwani.com") 160 | 161 | def test_protocol_only_strings_are_rejected_when_setting_website(self): 162 | invalid_url = "http://" 163 | old_url = self.user.profile.website 164 | 165 | self.user.profile.set_website(invalid_url) 166 | 167 | users_profile = Profile.objects.get(user=self.user) 168 | self.assertEqual(users_profile.website, old_url) 169 | 170 | def test_website_setting_on_None_site(self): 171 | invalid_url = None 172 | old_url = self.user.profile.website 173 | 174 | self.user.profile.set_website(invalid_url) 175 | 176 | users_profile = Profile.objects.get(user=self.user) 177 | self.assertEqual(users_profile.website, old_url) 178 | 179 | def test_setting_twitter_on_none_twitter(self): 180 | twitter_handle = None 181 | old_twitter = self.user.profile.twitter 182 | 183 | self.user.profile.set_twitter_account(twitter_handle) 184 | 185 | users_profile = Profile.objects.get(user=self.user) 186 | self.assertEqual(old_twitter, users_profile.twitter) 187 | 188 | def test_answering_a_review_correctly_rounds_next_review_date_up_to_interval(self): 189 | self.review.next_review_date = self.review.next_review_date.replace(minute=17) 190 | self.review.last_studied = self.review.next_review_date.replace(minute=17) 191 | self.review.answered_correctly(first_try=True) 192 | self.review.refresh_from_db() 193 | 194 | self.assertEqual(self.review.next_review_date.minute % (constants.REVIEW_ROUNDING_TIME.total_seconds() / 60), 0) 195 | self.assertEqual( 196 | self.review.next_review_date.hour % (constants.REVIEW_ROUNDING_TIME.total_seconds() / (60 * 60)), 0) 197 | self.assertEqual(self.review.next_review_date.second % constants.REVIEW_ROUNDING_TIME.total_seconds(), 0) 198 | 199 | def test_rounding_a_review_time_only_goes_up(self): 200 | self.review.next_review_date = self.review.next_review_date.replace(minute=17) 201 | self.review.last_studied = self.review.next_review_date.replace(minute=17) 202 | self.review._round_review_time_up() 203 | self.review.refresh_from_db() 204 | 205 | self.assertEqual(self.review.next_review_date.minute % (constants.REVIEW_ROUNDING_TIME.total_seconds() / 60), 0) 206 | self.assertEqual( 207 | self.review.next_review_date.hour % (constants.REVIEW_ROUNDING_TIME.total_seconds() / (60 * 60)), 0) 208 | self.assertEqual(self.review.next_review_date.second % constants.REVIEW_ROUNDING_TIME.total_seconds(), 0) 209 | 210 | def test_rounding_up_a_review_rounds_up_last_studied_date(self): 211 | self.review.last_studied = timezone.now() 212 | self.review.last_studied = self.review.last_studied.replace(minute=17) 213 | self.review._round_review_time_up() 214 | 215 | self.assertEqual(self.review.last_studied.minute % (constants.REVIEW_ROUNDING_TIME.total_seconds() / 60), 0) 216 | 217 | def test_default_review_times_are_not_rounded(self): 218 | rounded_time = self.review.next_review_date 219 | new_vocab = create_review(create_vocab("fresh"), self.user) 220 | 221 | self.assertNotEqual(rounded_time, new_vocab.next_review_date) 222 | 223 | def test_handle_wanikani_level_up_correctly_levels_up(self): 224 | old_level = self.user.profile.level 225 | 226 | self.user.profile.handle_wanikani_level_change(self.user.profile.level + 1) 227 | self.user.refresh_from_db() 228 | 229 | self.assertEqual(self.user.profile.level, old_level + 1) 230 | 231 | def test_updating_next_review_date_based_on_last_studied_works(self): 232 | current_time = timezone.now() 233 | self.review.last_studied = current_time 234 | self.review.streak = 4 235 | delta_hours = constants.SRS_TIMES[4] 236 | future_time = current_time + timedelta(hours=delta_hours) 237 | 238 | self.review.save() 239 | 240 | self.review.set_next_review_time_based_on_last_studied() 241 | 242 | self.review.refresh_from_db() 243 | 244 | self.assertTrue(self.review.next_review_date - future_time < timedelta(minutes=15)) 245 | 246 | def test_tag_search_works(self): 247 | vocab = create_vocab("spicy meatball") 248 | vocab2 = create_vocab("spicy pizza") 249 | 250 | reading = create_reading(vocab, "SOME_READING", "SOME_CHARACTER", 5) 251 | reading2 = create_reading(vocab2, "SOME_OTHER_READING", "SOME_OTHER_CHARACTER", 5) 252 | 253 | spicy_tag = Tag.objects.create(name='spicy') 254 | 255 | reading.tags.add(spicy_tag) 256 | reading2.tags.add(spicy_tag) 257 | 258 | reading.save() 259 | reading2.save() 260 | 261 | spicy_tag.refresh_from_db() 262 | spicy_vocab = spicy_tag.get_all_vocabulary() 263 | 264 | self.assertTrue(spicy_vocab.count() == 2) 265 | 266 | def test_vocabulary_that_has_multiple_readings_with_same_tag_appears_only_once(self): 267 | vocab = create_vocab("spicy meatball") 268 | 269 | reading = create_reading(vocab, "SOME_READING", "SOME_CHARACTER", 5) 270 | reading2 = create_reading(vocab, "SOME_OTHER_READING", "SOME_OTHER_CHARACTER", 5) 271 | 272 | spicy_tag = Tag.objects.create(name='spicy') 273 | 274 | reading.tags.add(spicy_tag) 275 | reading2.tags.add(spicy_tag) 276 | 277 | reading.save() 278 | reading2.save() 279 | 280 | spicy_tag.refresh_from_db() 281 | spicy_vocab = spicy_tag.get_all_vocabulary() 282 | 283 | self.assertEqual(spicy_vocab.count(), 1) 284 | 285 | def test_adding_notes_to_reviews_works(self): 286 | self.assertTrue(self.review.notes is None) 287 | 288 | self.review.notes = "This is a note for my review!" 289 | self.review.save() 290 | 291 | self.assertTrue(self.review.notes is not None) 292 | 293 | def test_tag_names_are_unique(self): 294 | original_tag = Tag.objects.create(name='S P I C Y') 295 | self.assertRaises(IntegrityError, Tag.objects.create, name='S P I C Y') 296 | 297 | def test_setting_criticality_of_review(self): 298 | self.review.correct = 1 299 | self.review.incorrect = 2 300 | self.review.save() 301 | self.review.refresh_from_db() 302 | 303 | self.assertFalse(self.review.critical) 304 | 305 | self.review.answered_incorrectly() 306 | 307 | self.assertTrue(self.review.critical) 308 | 309 | def test_critical_not_set_when_below_attempt_threshold(self): 310 | self.review.correct = 0 311 | self.review.incorrect = 1 312 | self.review.save() 313 | self.review.refresh_from_db() 314 | 315 | self.assertFalse(self.review.critical) 316 | 317 | # Brings total attempt count to 2 318 | self.review.answered_incorrectly() 319 | 320 | self.assertFalse(self.review.critical) 321 | 322 | def test_review_correctly_comes_out_of_critical_once_guru(self): 323 | self.review.correct = 1 324 | self.review.incorrect = 3 325 | self.review.critical = True 326 | self.review.save() 327 | self.review.refresh_from_db() 328 | 329 | self.assertTrue(self.review.critical) 330 | 331 | self.review.answered_correctly() 332 | 333 | self.review.refresh_from_db() 334 | self.assertFalse(self.review.critical) 335 | 336 | def test_newly_created_user_specific_has_null_last_studied_date(self): 337 | review = create_review(create_vocab("test"), self.user) 338 | self.assertIsNone(review.last_studied) 339 | -------------------------------------------------------------------------------- /api/serializers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from collections import OrderedDict 3 | 4 | import requests 5 | from django.contrib.auth.models import User 6 | from django.db.models import Q, Count, TimeField 7 | from django.utils import timezone 8 | from django.db.models.functions import TruncHour, TruncDate 9 | from rest_framework import serializers 10 | 11 | from api import serializer_fields 12 | from api.validators import WanikaniApiKeyValidator 13 | from kw_webapp.constants import KwSrsLevel, KANIWANI_SRS_LEVELS, STREAK_TO_SRS_LEVEL_MAP_KW 14 | from kw_webapp.models import Profile, Vocabulary, UserSpecific, Reading, Level, Tag, AnswerSynonym, \ 15 | FrequentlyAskedQuestion, Announcement, Report, MeaningSynonym 16 | from kw_webapp.tasks import get_users_lessons, get_users_current_reviews, get_users_future_reviews, get_users_reviews, \ 17 | build_upcoming_srs_for_user 18 | 19 | import logging 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class ReportCountSerializer(serializers.BaseSerializer): 24 | """ 25 | Serializer which aggregates report counts by vocabulary. 26 | """ 27 | 28 | def to_representation(self, obj): 29 | return Report.objects.values("reading").annotate(report_count=Count("reading")).order_by("-report_count") 30 | 31 | 32 | class SrsCountSerializer(serializers.BaseSerializer): 33 | """ 34 | Serializer for simply showing SRS counts, e.g., how many apprentice items a user has, 35 | how many guru, etc. 36 | """ 37 | 38 | def to_representation(self, user): 39 | all_reviews = get_users_reviews(user) 40 | ordered_srs_counts = OrderedDict.fromkeys([level.name.lower() for level in KwSrsLevel]) 41 | for level in KwSrsLevel: 42 | ordered_srs_counts[level.name.lower()] = all_reviews.filter( 43 | streak__in=KANIWANI_SRS_LEVELS[level.name]).count() 44 | return ordered_srs_counts 45 | 46 | 47 | class SimpleUpcomingReviewSerializer(serializers.BaseSerializer): 48 | """ 49 | Serializer containing information about upcoming reviews, without any relevant srs information. 50 | """ 51 | 52 | def to_representation(self, user): 53 | return build_upcoming_srs_for_user(user) 54 | 55 | 56 | class DetailedUpcomingReviewCountSerializer(serializers.BaseSerializer): 57 | """ 58 | Serializer for counting reviews on an hourly basis for the next 24 hours 59 | """ 60 | 61 | def to_representation(self, user): 62 | now = timezone.now() 63 | one_day_from_now = now + datetime.timedelta(hours=24) 64 | 65 | reviews = get_users_reviews(user).filter(next_review_date__range=(now, one_day_from_now)) \ 66 | .annotate(hour=TruncHour('next_review_date', tzinfo=timezone.utc)) \ 67 | .annotate(date=TruncDate('next_review_date', tzinfo=timezone.utc)) \ 68 | .values("streak", "date", "hour") \ 69 | .annotate(review_count=Count('id')).order_by("date", "hour") 70 | expected_hour = now.hour 71 | hours = [hour % 24 for hour in range(expected_hour, expected_hour + 24)] 72 | 73 | retval = OrderedDict.fromkeys(hours) 74 | 75 | for key in retval.keys(): 76 | retval[key] = OrderedDict.fromkeys([level.name for level in KwSrsLevel], 0) 77 | 78 | for review in reviews: 79 | found_hour = review['hour'].hour 80 | while found_hour != expected_hour: 81 | expected_hour = (expected_hour + 1) % 24 82 | streak = review['streak'] 83 | srs_level = STREAK_TO_SRS_LEVEL_MAP_KW[streak].name 84 | retval[expected_hour][srs_level] += review["review_count"] 85 | 86 | real_retval = [[count for srs_level, count in hourly_count.items()] for hour, hourly_count in retval.items()] 87 | return real_retval 88 | 89 | 90 | class ReviewCountSerializer(serializers.BaseSerializer): 91 | 92 | def to_representation(self, user): 93 | return { 94 | "reviews_count": self.get_reviews_count(user), 95 | "lessons_count": self.get_lessons_count(user), 96 | } 97 | 98 | def get_reviews_count(self, obj): 99 | return get_users_current_reviews(obj).count() 100 | 101 | def get_lessons_count(self, obj): 102 | return get_users_lessons(obj).count() 103 | 104 | 105 | class ProfileSerializer(serializers.ModelSerializer): 106 | name = serializers.ReadOnlyField(source='user.username') 107 | next_review_date = serializers.SerializerMethodField() 108 | unlocked_levels = serializers.StringRelatedField(many=True, read_only=True) 109 | reviews_within_hour_count = serializers.SerializerMethodField() 110 | reviews_within_day_count = serializers.SerializerMethodField() 111 | srs_counts = SrsCountSerializer(source='user', many=False, read_only=True) 112 | # upcoming_reviews = DetailedUpcomingReviewCountSerializer(source='user', many=False, read_only=True) 113 | upcoming_reviews = SimpleUpcomingReviewSerializer(source='user', many=False, read_only=True) 114 | join_date = serializers.SerializerMethodField() 115 | api_key = serializers.CharField(max_length=32, validators=[WanikaniApiKeyValidator(), ]) 116 | 117 | 118 | class Meta: 119 | model = Profile 120 | fields = ('id', 'name', 'api_key', 'api_valid', 121 | 'level', 'follow_me', 'auto_advance_on_success', 'unlocked_levels', 'last_wanikani_sync_date', 122 | 'auto_expand_answer_on_success', 'auto_expand_answer_on_failure', 'on_vacation', 'vacation_date', 123 | 'reviews_within_day_count', 'reviews_within_hour_count', 'srs_counts', 124 | 'minimum_wk_srs_level_to_review', 'upcoming_reviews', 'next_review_date', 'join_date', 125 | 'auto_advance_on_success_delay_milliseconds', 'use_eijiro_pro_link', 'show_kanji_svg_stroke_order', 126 | 'show_kanji_svg_grid', 'kanji_svg_draw_speed', 'info_detail_level_on_success', 127 | 'info_detail_level_on_failure') 128 | 129 | read_only_fields = ('id', 'name', 'api_valid', 'level', 130 | 'unlocked_levels', 'vacation_date', 'reviews_within_day_count', 131 | 'reviews_within_hour_count', 'srs_counts', 132 | 'next_review_date', 'last_wanikani_sync_date', 'join_date') 133 | 134 | def get_join_date(self, obj): 135 | """ 136 | So this is a hack. By default the modelserializer expects a datefield, but a fewww users have datetimefields as their join_date, 137 | due to an old version of the model. Eventually we should fix those users but for now this methodfield does the trick. 138 | """ 139 | return obj.join_date 140 | 141 | def save(self, **kwargs): 142 | return super().save(**kwargs) 143 | 144 | def get_next_review_date(self, obj): 145 | user = obj.user 146 | if self.get_reviews_count(obj) == 0: 147 | reviews = get_users_future_reviews(user) 148 | if reviews: 149 | next_review_date = reviews[0].next_review_date 150 | return next_review_date 151 | 152 | def get_reviews_count(self, obj): 153 | return get_users_current_reviews(obj.user).count() 154 | 155 | def get_reviews_within_hour_count(self, obj): 156 | return get_users_future_reviews(obj.user, 157 | time_limit=datetime.timedelta(hours=1)).count() 158 | 159 | def get_reviews_within_day_count(self, obj): 160 | return get_users_future_reviews(obj.user, time_limit=datetime.timedelta(hours=24)).count() 161 | 162 | 163 | class RegistrationSerializer(serializers.ModelSerializer): 164 | api_key = serializers.CharField(write_only=True, max_length=32, validators=[WanikaniApiKeyValidator(), ]) 165 | password = serializers.CharField(write_only=True, 166 | style={'input_type': 'password'}) 167 | 168 | class Meta: 169 | model = User 170 | fields = ('api_key', 'password', 'username', 'email') 171 | 172 | def validate_password(self, value): 173 | if len(value) < 4: 174 | raise serializers.ValidationError("Password is not long enough!") 175 | return value 176 | 177 | def validate_email(self, value): 178 | try: 179 | User.objects.get(email=value) 180 | except User.DoesNotExist: 181 | return value 182 | else: 183 | raise serializers.ValidationError("Email is already in use!") 184 | 185 | def validate_username(self, value): 186 | try: 187 | User.objects.get(username=value) 188 | except User.DoesNotExist: 189 | return value 190 | else: 191 | raise serializers.ValidationError("Username is already in use!") 192 | 193 | def create(self, validated_data): 194 | preexisting_users = User.objects.filter(Q(username=validated_data.get('username')) | 195 | Q(email=validated_data.get('email'))) 196 | 197 | if preexisting_users.count() > 0: 198 | raise serializers.ValidationError("Username or email already in use!") 199 | 200 | api_key = validated_data.pop('api_key', None) 201 | 202 | user = User.objects.create(**validated_data) 203 | user.set_password(validated_data.get('password')) 204 | user.save() 205 | Profile.objects.create(user=user, api_key=api_key, level=1) 206 | return user 207 | 208 | 209 | class UserSerializer(serializers.ModelSerializer): 210 | profile = ProfileSerializer(many=False, read_only=True) 211 | api_key = serializers.CharField(write_only=True, max_length=32, validators=[WanikaniApiKeyValidator(), ]) 212 | password = serializers.CharField(write_only=True) 213 | 214 | class Meta: 215 | model = User 216 | fields = ('api_key', 'password', 'username', 'email', 'profile') 217 | read_only_fields = ('id', 'last_login', 'is_active', 'date_joined', 'is_staff', 'is_superuser', 'profile') 218 | 219 | def validate_password(self, value): 220 | if len(value) < 4: 221 | raise serializers.ValidationError("Password is not long enough!") 222 | return value 223 | 224 | def validate_email(self, value): 225 | try: 226 | User.objects.get(email=value) 227 | except User.DoesNotExist: 228 | return value 229 | else: 230 | raise serializers.ValidationError("Email is already in use!") 231 | 232 | def validate_username(self, value): 233 | try: 234 | User.objects.get(username=value) 235 | except User.DoesNotExist: 236 | return value 237 | else: 238 | raise serializers.ValidationError("Username is already in use!") 239 | 240 | def create(self, validated_data): 241 | preexisting_users = User.objects.filter(Q(username=validated_data.get('username')) | 242 | Q(email=validated_data.get('email'))) 243 | 244 | if preexisting_users.count() > 0: 245 | raise serializers.ValidationError("Username or email already in use!") 246 | 247 | api_key = validated_data.pop('api_key', None) 248 | 249 | user = User.objects.create(**validated_data) 250 | user.set_password(validated_data.get('password')) 251 | Profile.objects.create(user=user, api_key=api_key, level=1) 252 | 253 | def update(self, instance, validated_data): 254 | profile_data = validated_data.pop("profile") 255 | profile_serializer = ProfileSerializer(data=profile_data) 256 | profile_serializer.save() 257 | instance.save() 258 | 259 | 260 | class TagSerializer(serializers.ModelSerializer): 261 | class Meta: 262 | model = Tag 263 | fields = ('name',) 264 | 265 | 266 | class ReadingSerializer(serializers.ModelSerializer): 267 | parts_of_speech = serializers.StringRelatedField(many=True, read_only=True) 268 | 269 | class Meta: 270 | model = Reading 271 | fields = ('id', 'character', 'kana', 'level', 'sentence_en', 'sentence_ja', 272 | 'common', "furigana", "pitch", "parts_of_speech") 273 | 274 | 275 | class VocabularySerializer(serializers.ModelSerializer): 276 | def __init__(self, *args, **kwargs): 277 | super(VocabularySerializer, self).__init__(*args, **kwargs) 278 | # If this is part of the review response, simply omit the review field, reducing DB calls. 279 | if 'nested_in_review' in self.context: 280 | self.fields.pop('review') 281 | self.fields.pop('is_reviewable') 282 | 283 | readings = ReadingSerializer(many=True, read_only=True) 284 | review = serializers.SerializerMethodField() 285 | is_reviewable = serializers.SerializerMethodField() 286 | 287 | class Meta: 288 | model = Vocabulary 289 | fields = ('id', 'meaning', 'readings', 'review', 'is_reviewable') 290 | 291 | # Grab the ID of the related review for this particular user. 292 | def get_review(self, obj): 293 | if 'request' in self.context: 294 | try: 295 | return UserSpecific.objects.get(user=self.context['request'].user, vocabulary=obj).id 296 | except UserSpecific.DoesNotExist: 297 | return None 298 | return None 299 | 300 | def get_is_reviewable(self, obj): 301 | if 'request' in self.context: 302 | try: 303 | minimum_level_to_review = self.context['request'].user.profile.get_minimum_wk_srs_threshold_for_review() 304 | return UserSpecific.objects.filter(user=self.context['request'].user, 305 | vocabulary=obj, 306 | wanikani_srs_numeric__gte=minimum_level_to_review).count() > 0 307 | except UserSpecific.DoesNotExist: 308 | return None 309 | return None 310 | 311 | 312 | class ReportSerializer(serializers.ModelSerializer): 313 | class Meta: 314 | model = Report 315 | fields = '__all__' 316 | read_only_fields = ('created_by', 'created_at') 317 | 318 | 319 | class ReportListSerializer(ReportSerializer): 320 | reading = ReadingSerializer(many=False, read_only=True) 321 | 322 | 323 | class HyperlinkedVocabularySerializer(VocabularySerializer): 324 | readings = serializers.HyperlinkedRelatedField(many=True, read_only=True, view_name='api:reading-detail') 325 | 326 | class Meta(VocabularySerializer.Meta): 327 | pass 328 | 329 | 330 | class MeaningSynonymSerializer(serializers.ModelSerializer): 331 | class Meta: 332 | model = MeaningSynonym 333 | fields = '__all__' 334 | 335 | def validate(self, data): 336 | review = data['review'] 337 | if review.user != self.context['request'].user: 338 | raise serializers.ValidationError("Can not make a synonym for a review that is not yours!") 339 | return data 340 | 341 | 342 | class ReadingSynonymSerializer(serializers.ModelSerializer): 343 | class Meta: 344 | model = AnswerSynonym 345 | fields = '__all__' 346 | 347 | def validate(self, data): 348 | review = data['review'] 349 | if review.user != self.context['request'].user: 350 | raise serializers.ValidationError("Can not make a synonym for a review that is not yours!") 351 | return data 352 | 353 | def create(self, validated_data): 354 | return super().create(validated_data) 355 | 356 | def is_valid(self, raise_exception=False): 357 | return super().is_valid(True) 358 | 359 | 360 | class ReviewSerializer(serializers.ModelSerializer): 361 | vocabulary = VocabularySerializer(many=False, read_only=True, context={'nested_in_review': True}) 362 | reading_synonyms = ReadingSynonymSerializer(many=True, read_only=True) 363 | meaning_synonyms = MeaningSynonymSerializer(many=True, read_only=True) 364 | 365 | class Meta: 366 | model = UserSpecific 367 | fields = '__all__' 368 | 369 | read_only_fields = ('id', 'vocabulary', 'correct', 'incorrect', 'streak', 370 | 'user', 'needs_review', 'last_studied', 371 | 'unlock_date', 'wanikani_srs', 'reading_synonyms', 'meaning_synonyms', 372 | 'wanikani_srs_numeric', 'wanikani_burned', 'burned', 'critical') 373 | 374 | 375 | class StubbedReviewSerializer(ReviewSerializer): 376 | class Meta(ReviewSerializer.Meta): 377 | fields = ('id', 'vocabulary', 'correct', 'incorrect', 'streak', 'notes', 'reading_synonyms', 'meaning_synonyms') 378 | 379 | 380 | class LevelSerializer(serializers.Serializer): 381 | level = serializers.IntegerField(read_only=True) 382 | unlocked = serializers.BooleanField(read_only=True) 383 | vocabulary_count = serializers.IntegerField(read_only=True) 384 | vocabulary_url = serializer_fields.VocabularyByLevelHyperlinkedField(read_only=True) 385 | lock_url = serializers.CharField(read_only=True) 386 | fully_unlocked = serializers.BooleanField(read_only=True) 387 | unlock_url = serializers.CharField(read_only=True) 388 | 389 | 390 | class FrequentlyAskedQuestionSerializer(serializers.ModelSerializer): 391 | class Meta: 392 | model = FrequentlyAskedQuestion 393 | fields = '__all__' 394 | 395 | 396 | class AnnouncementSerializer(serializers.ModelSerializer): 397 | creator = serializers.ReadOnlyField(source='creator.username') 398 | 399 | class Meta: 400 | model = Announcement 401 | fields = ('title', 'body', 'pub_date', 'creator') 402 | 403 | 404 | class ContactSerializer(serializers.Serializer): 405 | name = serializers.CharField(max_length=100) 406 | email = serializers.CharField(max_length=200) 407 | body = serializers.CharField(max_length=1000) 408 | -------------------------------------------------------------------------------- /kw_webapp/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from itertools import chain 3 | 4 | from datetime import timedelta 5 | 6 | from django.db import models 7 | from django.core.validators import MinValueValidator, MaxValueValidator 8 | from django.contrib.auth.models import User 9 | from django.db.models import Count 10 | from django.utils import timezone 11 | 12 | from kw_webapp import constants 13 | from kw_webapp.constants import TWITTER_USERNAME_REGEX, HTTP_S_REGEX, WkSrsLevel, WANIKANI_SRS_LEVELS 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Announcement(models.Model): 19 | title = models.CharField(max_length=255) 20 | body = models.TextField() 21 | pub_date = models.DateTimeField('Date Published', auto_now_add=True, null=True) 22 | creator = models.ForeignKey(User) 23 | 24 | def __str__(self): 25 | return self.title 26 | 27 | 28 | class FrequentlyAskedQuestion(models.Model): 29 | question = models.CharField(max_length=10000) 30 | answer = models.CharField(max_length=10000) 31 | 32 | 33 | class Level(models.Model): 34 | level = models.PositiveIntegerField(validators=[ 35 | MinValueValidator(constants.LEVEL_MIN), 36 | MaxValueValidator(constants.LEVEL_MAX), 37 | ]) 38 | 39 | def __str__(self): 40 | return str(self.level) 41 | 42 | 43 | class Profile(models.Model): 44 | user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE) 45 | api_key = models.CharField(max_length=255) 46 | api_valid = models.BooleanField(default=True) 47 | gravatar = models.CharField(max_length=255) 48 | about = models.CharField(max_length=255, default="") 49 | website = models.CharField(max_length=255, default="N/A", null=True) 50 | twitter = models.CharField(max_length=255, default="N/A", null=True) 51 | topics_count = models.PositiveIntegerField(default=0) 52 | posts_count = models.PositiveIntegerField(default=0) 53 | title = models.CharField(max_length=255, default="Turtles", null=True) 54 | join_date = models.DateField(auto_now_add=True, null=True) 55 | last_wanikani_sync_date = models.DateTimeField(auto_now_add=True, null=True) 56 | last_visit = models.DateTimeField(null=True, auto_now_add=True) 57 | level = models.PositiveIntegerField(null=True, validators=[ 58 | MinValueValidator(constants.LEVEL_MIN), 59 | MaxValueValidator(constants.LEVEL_MAX), 60 | ]) 61 | minimum_wk_srs_level_to_review = models.CharField(max_length=20, choices=WkSrsLevel.choices(), 62 | default=WkSrsLevel.APPRENTICE.name) 63 | 64 | # General user-changeable settings 65 | unlocked_levels = models.ManyToManyField(Level) 66 | follow_me = models.BooleanField(default=True) 67 | show_kanji_svg_stroke_order = models.BooleanField(default=False) 68 | show_kanji_svg_grid = models.BooleanField(default=True) 69 | kanji_svg_draw_speed = models.PositiveIntegerField(default=8, validators=[ 70 | MinValueValidator(constants.MIN_SVG_DRAW_SPEED), 71 | MaxValueValidator(constants.MAX_SVG_DRAW_SPEED) 72 | ]) 73 | 74 | # On Success/Failure of review 75 | auto_advance_on_success = models.BooleanField(default=False) 76 | auto_advance_on_success_delay_milliseconds = models.PositiveIntegerField(default=1000) 77 | auto_expand_answer_on_success = models.BooleanField(default=True) 78 | auto_expand_answer_on_failure = models.BooleanField(default=False) 79 | info_detail_level_on_success = models.PositiveIntegerField(default=1, validators=[ 80 | MaxValueValidator(constants.MAX_REVIEW_DETAIL_LEVEL) 81 | ]) 82 | info_detail_level_on_failure = models.PositiveIntegerField(default=0, validators=[ 83 | MaxValueValidator(constants.MAX_REVIEW_DETAIL_LEVEL) 84 | ]) 85 | 86 | # External Site settings 87 | use_eijiro_pro_link = models.BooleanField(default=False) 88 | 89 | # Vacation Settings 90 | on_vacation = models.BooleanField(default=False) 91 | vacation_date = models.DateTimeField(default=None, null=True, blank=True) 92 | 93 | def get_minimum_wk_srs_threshold_for_review(self): 94 | minimum_wk_srs = self.minimum_wk_srs_level_to_review 95 | minimum_streak = WANIKANI_SRS_LEVELS[minimum_wk_srs][0] 96 | return minimum_streak 97 | 98 | def set_twitter_account(self, twitter_account): 99 | if not twitter_account: 100 | return 101 | 102 | if twitter_account.startswith("@") and TWITTER_USERNAME_REGEX.match(twitter_account[1:]): 103 | self.twitter = twitter_account 104 | elif TWITTER_USERNAME_REGEX.match(twitter_account): 105 | self.twitter = "@{}".format(twitter_account) 106 | else: 107 | logger.warning("WK returned a funky twitter account name: {}, for user:{} ".format(twitter_account, 108 | self.user.username)) 109 | 110 | self.save() 111 | 112 | def set_website(self, website_url): 113 | if website_url: 114 | fixed_site = HTTP_S_REGEX.sub("", website_url) 115 | if fixed_site: 116 | self.website = fixed_site 117 | self.save() 118 | 119 | def unlocked_levels_list(self): 120 | x = self.unlocked_levels.values_list('level') 121 | x = [x[0] for x in x] 122 | return x 123 | 124 | def handle_wanikani_level_change(self, new_level): 125 | self.level = new_level 126 | self.save() 127 | 128 | def __str__(self): 129 | return "{} -- {} -- {} -- {}".format(self.user.username, self.api_key, self.level, self.unlocked_levels_list()) 130 | 131 | 132 | class Vocabulary(models.Model): 133 | meaning = models.CharField(max_length=255) 134 | 135 | def reading_count(self): 136 | return self.readings.all().count() 137 | 138 | def available_readings(self, level): 139 | return self.readings.filter(level__lte=level) 140 | 141 | def get_absolute_url(self): 142 | return "https://www.wanikani.com/vocabulary/{}/".format(self.readings.all()[0]) 143 | 144 | def __str__(self): 145 | return self.meaning 146 | 147 | 148 | class Tag(models.Model): 149 | """ 150 | A model meant to handle tagging readings. 151 | """ 152 | name = models.CharField(max_length=255, unique=True) 153 | 154 | def get_all_vocabulary(self): 155 | return Vocabulary.objects.filter(readings__tags__id=self.id).distinct() 156 | 157 | def __str__(self): 158 | return self.name 159 | 160 | 161 | class PartOfSpeech(models.Model): 162 | part = models.CharField(max_length=30) 163 | 164 | def __str__(self): 165 | return str(self.part) 166 | 167 | 168 | 169 | 170 | class Reading(models.Model): 171 | vocabulary = models.ForeignKey(Vocabulary, related_name='readings', on_delete=models.CASCADE) 172 | character = models.CharField(max_length=255) 173 | kana = models.CharField(max_length=255) 174 | level = models.PositiveIntegerField(null=True, validators=[ 175 | MinValueValidator(constants.LEVEL_MIN), 176 | MaxValueValidator(constants.LEVEL_MAX), 177 | ]) 178 | 179 | # JISHO information 180 | sentence_en = models.CharField(max_length=1000, null=True) 181 | sentence_ja = models.CharField(max_length=1000, null=True) 182 | common = models.NullBooleanField() 183 | tags = models.ManyToManyField(Tag) 184 | furigana = models.CharField(max_length=100, null=True) 185 | pitch = models.CharField(max_length=100, null=True) 186 | parts_of_speech = models.ManyToManyField(PartOfSpeech) 187 | 188 | class Meta: 189 | unique_together = ('character', 'kana') 190 | 191 | def __str__(self): 192 | return "{} - {} - {} - {}".format(self.vocabulary.meaning, self.kana, self.character, self.level) 193 | 194 | 195 | class Report(models.Model): 196 | # TODO start here makemigrations and modify all usages of vocabulary in report. 197 | created_by = models.ForeignKey(User) 198 | created_at = models.DateTimeField(auto_now_add=True) 199 | reading = models.ForeignKey(Reading, on_delete=models.CASCADE, related_name="reports") 200 | reason = models.CharField(max_length=1000) 201 | 202 | def __str__(self): 203 | return "Report: reading [{}]: {}, by user [{}] at {}".format(self.reading_id, 204 | self.reason, 205 | self.created_by_id, 206 | self.created_at) 207 | 208 | class UserSpecific(models.Model): 209 | vocabulary = models.ForeignKey(Vocabulary) 210 | user = models.ForeignKey(User, related_name='reviews', on_delete=models.CASCADE) 211 | correct = models.PositiveIntegerField(default=0) 212 | incorrect = models.PositiveIntegerField(default=0) 213 | streak = models.PositiveIntegerField(default=0) 214 | last_studied = models.DateTimeField(blank=True, null=True) 215 | needs_review = models.BooleanField(default=True) 216 | unlock_date = models.DateTimeField(default=timezone.now, blank=True) 217 | next_review_date = models.DateTimeField(default=timezone.now, null=True, blank=True) 218 | burned = models.BooleanField(default=False) 219 | hidden = models.BooleanField(default=False) 220 | wanikani_srs = models.CharField(max_length=255, default="unknown") 221 | wanikani_srs_numeric = models.IntegerField(default=0) 222 | wanikani_burned = models.BooleanField(default=False) 223 | notes = models.CharField(max_length=500, editable=True, blank=True, null=True) 224 | critical = models.BooleanField(default=False) 225 | 226 | class Meta: 227 | unique_together = ('vocabulary', 'user') 228 | 229 | def answered_correctly(self, first_try=True): 230 | # This is a check to see if it is a "lesson" object. 231 | if self.streak == 0: 232 | self.streak += 1 233 | elif first_try: 234 | self.correct += 1 235 | self.streak += 1 236 | if self.streak >= constants.WANIKANI_SRS_LEVELS[WkSrsLevel.BURNED.name][0]: 237 | self.burned = True 238 | 239 | self.needs_review = False 240 | self.last_studied = timezone.now() 241 | self.set_next_review_time() 242 | self.set_criticality() 243 | self.save() 244 | return self 245 | 246 | def answered_incorrectly(self): 247 | """ 248 | Helper function to correctly decrement streak value and increase count of incorrect. 249 | If user is nearing burned status, they get doubly-decremented. 250 | """ 251 | self.incorrect += 1 252 | # If user is about to burn, drop them two levels. 253 | if self.streak == 7: 254 | self.streak -= 2 255 | # streak of 0 indicates "Lesson" and we don't want users dropping down to lesson. 256 | elif self.streak > 1: 257 | self.streak -= 1 258 | 259 | self.streak = max(0, self.streak) 260 | self.save() 261 | self.set_criticality() 262 | return self 263 | 264 | def set_criticality(self): 265 | if self.is_critical(): 266 | self.critical = True 267 | else: 268 | self.critical = False 269 | 270 | def _can_be_critical(self): 271 | return self.correct + self.incorrect >= constants.MINIMUM_ATTEMPT_COUNT_FOR_CRITICALITY 272 | 273 | def _breaks_threshold(self): 274 | return float(self.incorrect) / float(self.correct + self.incorrect) >= constants.CRITICALITY_THRESHOLD 275 | 276 | def is_critical(self): 277 | if self._can_be_critical() and self._breaks_threshold(): 278 | return True 279 | else: 280 | return False 281 | 282 | def get_all_readings(self): 283 | return list(chain(self.vocabulary.readings.all(), self.reading_synonyms.all())) 284 | 285 | def can_be_managed_by(self, user): 286 | return self.user == user or user.is_superuser 287 | 288 | def synonyms_list(self): 289 | return [synonym.text for synonym in self.meaning_synonyms.all()] 290 | 291 | def synonyms_string(self): 292 | return ", ".join([synonym.text for synonym in self.meaning_synonyms.all()]) 293 | 294 | def remove_synonym(self, text): 295 | MeaningSynonym.objects.get(text=text).delete() 296 | 297 | def reading_synonyms_list(self): 298 | return [synonym.kana for synonym in self.reading_synonyms.all()] 299 | 300 | def add_answer_synonym(self, kana, character): 301 | synonym, created = self.reading_synonyms.get_or_create(kana=kana, character=character) 302 | return synonym, created 303 | 304 | def add_meaning_synonym(self, text): 305 | synonym, created = self.meaning_synonyms.get_or_create(text=text) 306 | return synonym, created 307 | 308 | def set_next_review_time(self): 309 | if self.streak not in constants.SRS_TIMES.keys(): 310 | self.next_review_date = None 311 | else: 312 | self.next_review_date = timezone.now() + timedelta(hours=constants.SRS_TIMES[self.streak]) 313 | self._round_next_review_date() 314 | self.save() 315 | 316 | def set_next_review_time_based_on_last_studied(self): 317 | self.next_review_date = self.last_studied + timedelta(hours=constants.SRS_TIMES[self.streak]) 318 | self._round_review_time_up() 319 | self.save() 320 | 321 | def bring_review_out_of_vacation(self, vacation_duration): 322 | self.last_studied = self.last_studied + vacation_duration 323 | if self.streak in constants.SRS_TIMES.keys(): 324 | self.next_review_date = self.last_studied + timezone.timedelta(hours=constants.SRS_TIMES[self.streak]) 325 | self.round_times() 326 | else: 327 | self.next_review_date = None 328 | 329 | self.save() 330 | 331 | def round_times(self): 332 | if self.streak in constants.SRS_TIMES.keys(): 333 | self._round_review_time_up() 334 | self._round_last_studied_up() 335 | 336 | def reset(self): 337 | # During a reset, we bring them down to the lowest review level, _not_ lesson level. 338 | self.streak = 1 339 | self.last_studied = None 340 | self.next_review_date = timezone.now() 341 | self.correct = 1 342 | self.incorrect = 0 343 | self.burned = False 344 | self.needs_review = True 345 | self.save() 346 | 347 | def _round_last_studied_up(self): 348 | original_date = self.last_studied 349 | round_to = constants.REVIEW_ROUNDING_TIME.total_seconds() 350 | seconds = ( 351 | self.last_studied - self.last_studied.min.replace(tzinfo=self.last_studied.tzinfo)).seconds 352 | rounding = (seconds + round_to) // round_to * round_to 353 | self.last_studied = self.last_studied + timedelta(0, rounding - seconds, 0) 354 | 355 | logger.debug( 356 | "Updating Last Studied Time for user {} for review {}. Went from {} to {}, a rounding of {:.1f} minutes" 357 | .format(self.user, 358 | self.vocabulary.meaning, 359 | original_date.strftime("%H:%M:%S"), 360 | self.last_studied.strftime("%H:%M:%S"), 361 | (self.last_studied - original_date).total_seconds() / 60)) 362 | self.save() 363 | 364 | def _round_next_review_date(self): 365 | round_to = constants.REVIEW_ROUNDING_TIME.total_seconds() 366 | seconds = ( 367 | self.next_review_date - self.next_review_date.min.replace(tzinfo=self.next_review_date.tzinfo)).seconds 368 | rounding = (seconds + round_to) // round_to * round_to 369 | self.next_review_date = self.next_review_date + timedelta(0, rounding - seconds, 0) 370 | self.save() 371 | 372 | def _round_last_studied_date(self): 373 | round_to = constants.REVIEW_ROUNDING_TIME.total_seconds() 374 | seconds = (self.last_studied - self.last_studied.min.replace(tzinfo=self.last_studied.tzinfo)).seconds 375 | rounding = (seconds + round_to) // round_to * round_to 376 | self.last_studied = self.last_studied + timedelta(0, rounding - seconds, 0) 377 | self.save() 378 | 379 | def _round_review_time_up(self): 380 | self._round_next_review_date() 381 | self._round_last_studied_date() 382 | 383 | def __str__(self): 384 | return "{} - {} - {} - c:{} - i:{} - s:{} - ls:{} - nr:{} - uld:{}".format(self.id, 385 | self.vocabulary.meaning, 386 | self.user.username, 387 | self.correct, 388 | self.incorrect, 389 | self.streak, 390 | self.last_studied, 391 | self.needs_review, 392 | self.unlock_date) 393 | 394 | 395 | class AnswerSynonym(models.Model): 396 | character = models.CharField(max_length=255, null=True) 397 | kana = models.CharField(max_length=255, null=False) 398 | review = models.ForeignKey(UserSpecific, related_name='reading_synonyms', null=True) 399 | 400 | class Meta: 401 | unique_together = ('character', 'kana', 'review') 402 | 403 | def __str__(self): 404 | return "{} - {} - {} - SYNONYM".format(self.review.vocabulary.meaning, self.kana, self.character) 405 | 406 | def as_dict(self): 407 | return { 408 | "id": self.id, 409 | "kana": self.kana, 410 | "character": self.character, 411 | "review_id": self.review.id 412 | } 413 | 414 | 415 | class MeaningSynonym(models.Model): 416 | text = models.CharField(max_length=255, blank=False, null=False) 417 | review = models.ForeignKey(UserSpecific, related_name="meaning_synonyms", null=True) 418 | 419 | def __str__(self): 420 | return self.text 421 | 422 | class Meta: 423 | unique_together = ('text', 'review') -------------------------------------------------------------------------------- /kw_webapp/tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | import re 2 | from copy import deepcopy 3 | 4 | import responses 5 | import time 6 | from django.test import TestCase 7 | from django.utils import timezone 8 | from kw_webapp import constants 9 | from kw_webapp.models import Vocabulary, UserSpecific, MeaningSynonym, AnswerSynonym 10 | from kw_webapp.tasks import create_new_vocabulary, past_time, all_srs, associate_vocab_to_user, \ 11 | build_API_sync_string_for_user, sync_unlocked_vocab_with_wk, \ 12 | lock_level_for_user, unlock_all_possible_levels_for_user, build_API_sync_string_for_user_for_levels, \ 13 | user_returns_from_vacation, get_users_future_reviews, sync_all_users_to_wk, \ 14 | reset_user, get_users_current_reviews, reset_levels, get_users_lessons, get_vocab_by_kanji, \ 15 | build_user_information_api_string, get_level_pages 16 | from kw_webapp.tests import sample_api_responses 17 | from kw_webapp.tests.sample_api_responses import single_vocab_requested_information 18 | from kw_webapp.tests.utils import create_review, create_vocab, create_user, create_profile, create_reading, \ 19 | create_review_for_specific_time, mock_vocab_list_response_with_single_vocabulary, mock_user_info_response 20 | from kw_webapp.utils import generate_user_stats, one_time_merge_level 21 | 22 | 23 | class TestTasks(TestCase): 24 | 25 | def setUp(self): 26 | self.user = create_user("Tadgh") 27 | create_profile(self.user, "any_key", 5) 28 | self.vocabulary = create_vocab("radioactive bat") 29 | self.reading = create_reading(self.vocabulary, "ねこ", "猫", 2) 30 | self.review = create_review(self.vocabulary, self.user) 31 | self._vocab_api_regex = re.compile("https://www\.wanikani\.com/api/user/.*") 32 | 33 | def testLevelPageCreator(self): 34 | flat_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 35 | pages = get_level_pages(flat_list) 36 | self.assertEqual(len(pages), 2) 37 | self.assertListEqual(pages[0], [1, 2, 3, 4, 5]) 38 | self.assertListEqual(pages[1], [6, 7, 8, 9, 10]) 39 | 40 | def test_userspecifics_needing_review_are_flagged(self): 41 | self.review.needs_review = False 42 | self.review.last_studied = past_time(5) 43 | self.review.save() 44 | all_srs() 45 | review = UserSpecific.objects.get(pk=self.review.id) 46 | self.assertTrue(review.needs_review) 47 | 48 | def test_associate_vocab_to_user_successfully_creates_review(self): 49 | new_vocab = create_vocab("dishwasher") 50 | 51 | review, created = associate_vocab_to_user(new_vocab, self.user) 52 | 53 | self.assertTrue(review.needs_review is True) 54 | self.assertTrue(created) 55 | 56 | def test_building_api_string_adds_correct_levels(self): 57 | self.user.profile.unlocked_levels.get_or_create(level=5) 58 | self.user.profile.unlocked_levels.get_or_create(level=3) 59 | self.user.profile.unlocked_levels.get_or_create(level=1) 60 | self.user.profile.save() 61 | 62 | api_call = build_API_sync_string_for_user(self.user) 63 | correct_string = "https://www.wanikani.com/api/user/any_key/vocabulary/5,3,1" 64 | 65 | self.assertEqual(correct_string, api_call) 66 | 67 | def test_locking_level_removes_all_reviews_at_that_level(self): 68 | self.vocabulary.readings.create(level=5, kana="猫", character="whatever") 69 | self.vocabulary.readings.create(level=5, kana="猫二", character="whatever2") 70 | 71 | lock_level_for_user(5, self.user) 72 | 73 | available_reviews = UserSpecific.objects.filter(user=self.user, vocabulary__readings__level=5).all() 74 | self.assertFalse(available_reviews) 75 | 76 | def test_locking_level_removes_level_from_unlocked_list(self): 77 | self.user.profile.unlocked_levels.get_or_create(level=7) 78 | self.user.profile.unlocked_levels.get_or_create(level=6) 79 | self.vocabulary.readings.create(level=6, kana="猫二", character="whatever2") 80 | 81 | lock_level_for_user(6, self.user) 82 | self.assertListEqual(self.user.profile.unlocked_levels_list(), [5, 7]) 83 | 84 | def test_create_new_vocab_based_on_json_works(self): 85 | vocab = create_new_vocabulary(single_vocab_requested_information) 86 | self.assertIsInstance(vocab, Vocabulary) 87 | 88 | @responses.activate 89 | def test_creating_new_synonyms_on_sync(self): 90 | resp_body = deepcopy(sample_api_responses.single_vocab_response) 91 | resp_body["requested_information"][0]["user_specific"]["user_synonyms"] = ["kitten", "large rat"] 92 | 93 | responses.add(responses.GET, self._vocab_api_regex, 94 | json=resp_body, 95 | status=200, 96 | content_type='application/json') 97 | 98 | sync_unlocked_vocab_with_wk(self.user) 99 | self.assertListEqual(self.review.synonyms_list(), ["kitten", "large rat"]) 100 | 101 | def test_building_unlock_all_string_works(self): 102 | sample_level = constants.LEVEL_MAX 103 | api_string = build_API_sync_string_for_user_for_levels(self.user, 104 | [level for level in range(1, sample_level + 1)]) 105 | 106 | expected = ",".join([str(level) for level in range(1, sample_level + 1)]) 107 | 108 | self.assertTrue(expected in api_string) 109 | 110 | @responses.activate 111 | def test_unlock_all_unlocks_all(self): 112 | self.user.profile.api_valid = True 113 | self.user.profile.save() 114 | resp_body = sample_api_responses.single_vocab_response 115 | level_list = [level for level in range(1, self.user.profile.level + 1)] 116 | responses.add(responses.GET, self._vocab_api_regex, 117 | json=resp_body, 118 | status=200, 119 | content_type='application/json') 120 | 121 | checked_levels, unlocked_now_count, total_unlocked_count, locked_count = unlock_all_possible_levels_for_user( 122 | self.user) 123 | 124 | self.assertListEqual(level_list, checked_levels) 125 | self.assertEqual(total_unlocked_count, 1) 126 | 127 | @responses.activate 128 | def test_syncing_vocabulary_pulls_srs_level_successfully(self): 129 | resp_body = sample_api_responses.single_vocab_response 130 | responses.add(responses.GET, self._vocab_api_regex, 131 | json=resp_body, 132 | status=200, 133 | content_type='application/json') 134 | 135 | sync_unlocked_vocab_with_wk(self.user) 136 | newly_synced_review = UserSpecific.objects.get(user=self.user, vocabulary__meaning=self.vocabulary.meaning) 137 | 138 | self.assertEqual(newly_synced_review.wanikani_srs, "apprentice") 139 | self.assertEqual(newly_synced_review.wanikani_srs_numeric, 3) 140 | 141 | def test_user_returns_from_vacation_correctly_increments_review_timestamps(self): 142 | now = timezone.now() 143 | two_hours_ago = now - timezone.timedelta(hours=2) 144 | two_hours_from_now = now + timezone.timedelta(hours=2) 145 | four_hours_from_now = now + timezone.timedelta(hours=4) 146 | 147 | self.user.profile.on_vacation = True 148 | 149 | # Create review that should be reviewed never again, but got reviewed 2 hours ago. 150 | review = create_review(create_vocab("wazoop"), self.user) 151 | review.burned = True 152 | review.next_review_date = None 153 | review.last_studied = two_hours_ago 154 | review.save() 155 | 156 | self.user.profile.vacation_date = two_hours_ago 157 | self.user.profile.save() 158 | self.review.last_studied = two_hours_ago 159 | self.review.next_review_date = two_hours_from_now 160 | 161 | self.review.save() 162 | previously_studied = self.review.last_studied 163 | 164 | user_returns_from_vacation(self.user) 165 | 166 | self.review.refresh_from_db() 167 | self.assertNotEqual(self.review.last_studied, previously_studied) 168 | 169 | self.assertAlmostEqual(self.review.next_review_date, four_hours_from_now, delta=timezone.timedelta(minutes=15)) 170 | self.assertAlmostEqual(self.review.last_studied, now, delta=timezone.timedelta(minutes=15)) 171 | self.assertAlmostEqual(review.last_studied, two_hours_ago, delta=timezone.timedelta(minutes=15)) 172 | self.assertAlmostEqual(review.next_review_date, None) 173 | 174 | def test_users_who_are_on_vacation_are_ignored_by_all_srs_algorithm(self): 175 | self.review.last_studied = past_time(10) 176 | self.review.streak = 1 177 | self.review.needs_review = False 178 | self.review.save() 179 | 180 | reviews_affected = all_srs() 181 | self.assertEqual(reviews_affected, 1) 182 | 183 | self.review.last_studied = past_time(10) 184 | self.review.streak = 1 185 | self.review.needs_review = False 186 | self.review.save() 187 | 188 | self.user.profile.on_vacation = True 189 | self.user.profile.save() 190 | 191 | reviews_affected = all_srs() 192 | self.assertEqual(reviews_affected, 0) 193 | 194 | def test_returning_review_count_that_is_time_delimited_functions_correctly(self): 195 | new_review = create_review(create_vocab("arbitrary word"), self.user) 196 | new_review.needs_review = False 197 | more_than_24_hours_from_now = timezone.now() + timezone.timedelta(hours=25) 198 | new_review.next_review_date = more_than_24_hours_from_now 199 | new_review.save() 200 | self.review.next_review_date = timezone.now() 201 | self.review.needs_review = False 202 | self.review.save() 203 | 204 | future_reviews = get_users_future_reviews(self.user, time_limit=timezone.timedelta(hours=24)) 205 | 206 | self.assertEqual(future_reviews.count(), 1) 207 | 208 | def test_returning_future_review_count_with_invalid_time_limit_returns_empty_queryset(self): 209 | self.review.next_review_date = timezone.now() 210 | self.review.needs_review = False 211 | self.review.save() 212 | 213 | future_reviews = get_users_future_reviews(self.user, time_limit=timezone.timedelta(hours=-1)) 214 | 215 | self.assertEqual(future_reviews.count(), 0) 216 | 217 | def test_returning_future_review_count_with_incorrect_argument_type_falls_back_to_default(self): 218 | self.review.next_review_date = timezone.now() 219 | self.review.needs_review = False 220 | self.review.save() 221 | 222 | future_reviews = get_users_future_reviews(self.user, time_limit="this is not a timedelta") 223 | 224 | self.assertGreater(future_reviews.count(), 0) 225 | 226 | def test_update_all_users_only_gets_active_users(self): 227 | user2 = create_user("sup") 228 | create_profile(user2, "any_key", 5) 229 | user2.profile.last_visit = past_time(24 * 6) 230 | self.user.profile.last_visit = past_time(24 * 8) 231 | user2.profile.save() 232 | self.user.profile.save() 233 | 234 | affected_count = sync_all_users_to_wk() 235 | self.assertEqual(affected_count, 1) 236 | 237 | @responses.activate 238 | def test_when_reading_level_changes_on_wanikani_we_catch_that_change_and_comply(self): 239 | resp_body = sample_api_responses.single_vocab_response 240 | 241 | # Mock response so that the level changes on our default vocab. 242 | responses.add(responses.GET, self._vocab_api_regex, 243 | json=sample_api_responses.single_vocab_response, 244 | status=200, 245 | content_type='application/json') 246 | 247 | sync_unlocked_vocab_with_wk(self.user) 248 | 249 | vocabulary = Vocabulary.objects.get(meaning="radioactive bat") 250 | 251 | self.assertEqual(vocabulary.readings.count(), 1) 252 | 253 | @responses.activate 254 | def test_when_wanikani_changes_meaning_no_duplicate_is_created(self): 255 | resp_body = deepcopy(sample_api_responses.single_vocab_response) 256 | resp_body['requested_information'][0]['meaning'] = "NOT radioactive bat" 257 | 258 | # Mock response so that the level changes on our default vocab. 259 | responses.add(responses.GET, build_API_sync_string_for_user_for_levels(self.user, [self.user.profile.level, ]), 260 | json=resp_body, 261 | status=200, 262 | content_type='application/json') 263 | 264 | sync_unlocked_vocab_with_wk(self.user) 265 | 266 | # Will fail if 2 vocab exist with same kanji. 267 | vocabulary = get_vocab_by_kanji("猫") 268 | 269 | @responses.activate 270 | def test_one_time_script_for_vocabulary_merging_works(self): 271 | # Merger should: 272 | # 1) Pull entire Wanikani vocabulary set. 273 | # 2) For each vocabulary, check kanji. 274 | 275 | # Option A: 276 | 277 | # 3) If multiple vocab that have a reading with that kanji are returned, Create *one* new vocab for that kanji, 278 | # with current info from API. 279 | 280 | # 3.5) Make sure to copy over the various metadata on the reading we have previously pulled (sentences etc) 281 | 282 | # 4) Find all Reviews that point to any of the previous vocabulary objects. 283 | 284 | # 5) Find maximum of all the reviews when grouped by user. Which has highest SRS, etc. This will be the user's 285 | # original vocab. Probably best to confirm by checking creation date. 286 | 287 | # 6) Point the review's Vocabulary to the newly created vocabulary object from step 3. 288 | 289 | # 7) Delete all other Vocabulary that are now out of date. This should cascade deletion 290 | # down to the other reviews. 291 | 292 | # Option B: 3) If only one vocab is found for a particular kanji, we have successfully *not* created 293 | # duplicates, meaning the WK vocab has never changed meaning. 4) We do not have to do anything here. Woohoo! 294 | 295 | # Create two vocab, identical kanji, different meanings. 296 | v1 = create_vocab("dog") # < -- vestigial vocab. 297 | v2 = create_vocab("dog, woofer, pupper") # < -- real, current vocab. 298 | create_reading(v1, "doggo1", "犬", 5) 299 | create_reading(v2, "doggo2", "犬", 5) 300 | 301 | # Make it so that review 1 has overall better SRS score for the user. 302 | review_1 = create_review(v1, self.user) 303 | review_1.streak = 4 304 | review_1.correct = 4 305 | review_1.incorrect = 2 306 | review_1.save() 307 | 308 | review_2 = create_review(v2, self.user) 309 | review_2.streak = 2 310 | review_2.correct = 4 311 | review_2.incorrect = 3 312 | review_2.save() 313 | 314 | MeaningSynonym.objects.create(review=review_1, text="flimflammer") 315 | MeaningSynonym.objects.create(review=review_2, text="shazwopper") 316 | AnswerSynonym.objects.create(review=review_1, character="CHS1", kana="KS1") 317 | AnswerSynonym.objects.create(review=review_2, character="CHS2", kana="KS2") 318 | 319 | # Assign another user an old version of the vocab. 320 | user2 = create_user("asdf") 321 | review_3 = create_review(v1, user2) 322 | review_3.streak = 5 323 | review_3.correct = 5 324 | review_3.incorrect = 0 325 | review_3.save() 326 | 327 | # User now has two different vocab, each with their own meaning, however kanji are identical. 328 | 329 | # Pull fake "current" vocab. this response, wherein we fetch the data from WK, and it turns out we already 330 | # have a local vocabulary with an identical meaning (i.e., we have already stored the correct and 331 | # currently active vocabulary. 332 | responses.add(responses.GET, "https://www.wanikani.com/api/user/{}/vocabulary/{}".format(constants.API_KEY, self.user.profile.level), 333 | json=sample_api_responses.single_vocab_existing_meaning_and_should_now_merge, 334 | status=200, 335 | content_type='application/json') 336 | 337 | old_vocab = Vocabulary.objects.filter(readings__character="犬") 338 | self.assertEqual(old_vocab.count(), 2) 339 | 340 | generate_user_stats(self.user) 341 | one_time_merge_level(self.user.profile.level) 342 | generate_user_stats(self.user) 343 | 344 | new_vocab = Vocabulary.objects.filter(readings__character="犬") 345 | self.assertEqual(new_vocab.count(), 1) 346 | 347 | new_review = UserSpecific.objects.filter(user=self.user, vocabulary__readings__character="犬") 348 | self.assertEqual(new_review.count(), 1) 349 | new_review = new_review[0] 350 | self.assertEqual(new_review.streak, review_1.streak) 351 | self.assertEqual(new_review.correct, review_1.correct) 352 | self.assertEqual(new_review.incorrect, review_1.incorrect) 353 | self.assertEqual(new_review.next_review_date, review_1.next_review_date) 354 | self.assertEqual(new_review.last_studied, review_1.last_studied) 355 | 356 | # Should have smashed together all the synonyms too. 357 | self.assertEqual(len(new_review.synonyms_list()), 2) 358 | self.assertEqual(len(new_review.reading_synonyms.all()), 2) 359 | 360 | second_users_reviews = UserSpecific.objects.filter(user=user2) 361 | self.assertEqual(second_users_reviews.count(), 1) 362 | user_two_review = second_users_reviews[0] 363 | self.assertEqual(user_two_review.streak, 5) 364 | self.assertTrue(user_two_review.vocabulary.meaning == "dog, woofer, pupper") 365 | 366 | 367 | def test_when_user_resets_their_account_all_unlocked_levels_are_removed_except_current_wk_level(self): 368 | self.user.profile.unlocked_levels.get_or_create(level=1) 369 | self.user.profile.unlocked_levels.get_or_create(level=2) 370 | self.user.profile.unlocked_levels.get_or_create(level=3) 371 | self.user.profile.unlocked_levels.get_or_create(level=4) 372 | self.user.refresh_from_db() 373 | self.assertListEqual(self.user.profile.unlocked_levels_list(), [5, 1, 2, 3, 4]) 374 | reset_levels(self.user, 1) 375 | self.user.refresh_from_db() 376 | self.assertListEqual(self.user.profile.unlocked_levels_list(), []) 377 | 378 | @responses.activate 379 | def test_when_user_resets_their_account_we_remove_all_reviews_and_then_unlock_their_current_level(self): 380 | self.user.profile.unlocked_levels.get_or_create(level=1) 381 | new_review = create_review(create_vocab("arbitrary word"), self.user) 382 | new_review.needs_review = True 383 | new_review.save() 384 | self.assertEqual(get_users_current_reviews(self.user).count(), 2) 385 | 386 | mock_vocab_list_response_with_single_vocabulary(self.user) 387 | mock_user_info_response(self.user.profile.api_key) 388 | 389 | reset_user(self.user, 1) 390 | 391 | self.user.refresh_from_db() 392 | self.user.profile.refresh_from_db() 393 | self.assertEqual(get_users_lessons(self.user).count(), 0) 394 | self.assertEqual(self.user.profile.level, 5) 395 | 396 | -------------------------------------------------------------------------------- /kw_webapp/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import requests 4 | from django.contrib.auth.models import User 5 | from django.db.models import Count 6 | from django.utils import timezone 7 | from rest_framework.authtoken.models import Token 8 | 9 | from kw_webapp import constants 10 | from kw_webapp.models import UserSpecific, Profile, Reading, Tag, Vocabulary, MeaningSynonym, AnswerSynonym, \ 11 | PartOfSpeech, Level, logger 12 | from kw_webapp.tasks import create_new_vocabulary, \ 13 | has_multiple_kanji, import_vocabulary_from_json 14 | from kw_webapp.wanikani import make_api_call 15 | from kw_webapp.tasks import unlock_eligible_vocab_from_levels 16 | from kw_webapp.tests.utils import create_review, create_review_for_specific_time 17 | 18 | 19 | def wipe_all_reviews_for_user(user): 20 | reviews = UserSpecific.objects.filter(user=user) 21 | reviews.delete() 22 | if len(reviews) > 0: 23 | raise ValueError 24 | else: 25 | print("deleted all reviews for " + user.username) 26 | 27 | 28 | def reset_reviews_for_user(user): 29 | reviews = UserSpecific.objects.filter(user=user) 30 | reviews.update(needs_review=False) 31 | reviews.update(last_studied=timezone.now()) 32 | 33 | 34 | def flag_all_reviews_for_user(user, needed): 35 | reviews = UserSpecific.objects.filter(user=user) 36 | reviews.update(needs_review=needed) 37 | 38 | 39 | def reset_unlocked_levels_for_user(user): 40 | p = Profile.objects.get(user=user) 41 | p.unlocked_levels.clear() 42 | p.unlocked_levels.get_or_create(level=p.level) 43 | 44 | 45 | def reset_user(user): 46 | wipe_all_reviews_for_user(user) 47 | reset_unlocked_levels_for_user(user) 48 | 49 | 50 | def create_profile_for_user(user): 51 | p = Profile(user=user, api_key="INVALID_KEY", level=1, api_valid=False) 52 | p.save() 53 | return p 54 | 55 | 56 | def correct_next_review_dates(): 57 | us = UserSpecific.objects.all() 58 | i = 0 59 | for u in us: 60 | u.set_next_review_time_based_on_last_studied() 61 | print(i, u) 62 | 63 | 64 | 65 | def one_time_merge_level(level, user=None): 66 | api_call = "https://www.wanikani.com/api/user/{}/vocabulary/{}".format(constants.API_KEY, level) 67 | response = make_api_call(api_call) 68 | vocab_list = response['requested_information'] 69 | print("Vocab found:{}".format(len(vocab_list))) 70 | 71 | for vocabulary_json in vocab_list: 72 | print("**************************************************************") 73 | print("Analyzing vocab with kanji:[{}]\tCanonical meaning is:[{}]".format(vocabulary_json['character'], 74 | vocabulary_json['meaning'])) 75 | found_vocabulary = Vocabulary.objects.filter(readings__character=vocabulary_json['character']) 76 | print("found [{}] vocabulary on the server with kanji [{}]".format(found_vocabulary.count(), 77 | vocabulary_json['character'])) 78 | if found_vocabulary.count() == 1 and found_vocabulary[0].meaning == vocabulary_json['meaning']: 79 | print("No conflict found. Precisely 1 vocab on server, and meaning matches.") 80 | elif found_vocabulary.count() > 1: 81 | print("Conflict found. Precisely [{}] vocab on server for meaning [{}].".format(found_vocabulary.count(), 82 | vocabulary_json['meaning'])) 83 | handle_merger(vocabulary_json, found_vocabulary) 84 | elif found_vocabulary.count() == 0: 85 | create_new_vocabulary(vocabulary_json) 86 | else: 87 | print("No conflict, but meaning has changed. Changing meaning!") 88 | to_be_edited = found_vocabulary[0] 89 | to_be_edited.meaning = vocabulary_json['meaning'] 90 | to_be_edited.save() 91 | 92 | 93 | def one_time_merger(user=None): 94 | for level in range(1, 61): 95 | one_time_merge_level(level, user=None) 96 | 97 | def create_new_review_and_merge_existing(vocabulary, found_vocabulary): 98 | print("New vocabulary id is:[{}]".format(vocabulary.id)) 99 | print("Old vocabulary items had meanings:[{}] ".format( 100 | " -> ".join(found_vocabulary.exclude(id=vocabulary.id).values_list('meaning', flat=True)))) 101 | print("Old vocabulary items had ids:[{}] ".format( 102 | " -> ".join([str(a) for a in found_vocabulary.exclude(id=vocabulary.id).values_list('id', flat=True)]))) 103 | ids = found_vocabulary.values_list('id').exclude(id=vocabulary.id) 104 | for user in User.objects.all(): 105 | old_reviews = UserSpecific.objects.filter(user=user, vocabulary__in=ids) 106 | if old_reviews.count() > 0: 107 | print("User [{}] had [{}] reviews which used one of the now merged vocab.".format(user.username, 108 | old_reviews.count())) 109 | print("Giving them a review for our new vocabulary object...") 110 | new_review = UserSpecific.objects.create(vocabulary=vocabulary, user=user) 111 | # Go over all the reviews which were duplicates, and pick the best one as the new accurate one. 112 | for old_review in old_reviews: 113 | if old_review.streak > new_review.streak: 114 | print("Old review [{}] has new highest streak: [{}]".format(old_review.id, old_review.streak)) 115 | copy_review_data(new_review, old_review) 116 | else: 117 | print("Old review [{}] has lower streak than current maximum.: [{}] < [{}]".format(old_review.id, 118 | old_review.streak, 119 | new_review.streak)) 120 | if new_review.notes is None: 121 | new_review.notes = old_review.notes 122 | else: 123 | new_review.notes = new_review.notes + ", " + old_review.notes 124 | 125 | # slap all the synonyms found onto the new review. 126 | MeaningSynonym.objects.filter(review=old_review).update(review=new_review) 127 | AnswerSynonym.objects.filter(review=old_review).update(review=new_review) 128 | 129 | new_review.save() 130 | 131 | def generate_user_stats(user): 132 | reviews = UserSpecific.objects.filter(user=user) 133 | kanji_review_map = {} 134 | for review in reviews: 135 | for reading in review.vocabulary.readings.all(): 136 | if reading.character in kanji_review_map.keys(): 137 | kanji_review_map[reading.character].append(review) 138 | else: 139 | kanji_review_map[reading.character] = [] 140 | kanji_review_map[reading.character].append(review) 141 | 142 | print("Printing all duplicates for user.") 143 | for kanji, reviews in kanji_review_map.items(): 144 | if len(reviews) > 1: 145 | print("***" + kanji + "***") 146 | for review in reviews: 147 | print(review) 148 | print("Finished printing duplicates") 149 | 150 | 151 | def handle_merger(vocabulary_json, found_vocabulary): 152 | ids_to_delete = found_vocabulary.values_list('id', flat=True) 153 | ids_to_delete_list = list(ids_to_delete) 154 | vocabulary = create_new_vocabulary(vocabulary_json) 155 | create_new_review_and_merge_existing(vocabulary, found_vocabulary) 156 | Vocabulary.objects.filter(pk__in=ids_to_delete_list).exclude(id=vocabulary.id).delete() 157 | 158 | def blow_away_duplicate_reviews_for_all_users(): 159 | users = User.objects.filter(profile__isnull=False) 160 | for user in users: 161 | blow_away_duplicate_reviews_for_user(user) 162 | 163 | def blow_away_duplicate_reviews_for_user(user): 164 | dupe_revs = UserSpecific.objects.filter(user=user)\ 165 | .values("vocabulary")\ 166 | .annotate(num_reviews=Count("vocabulary")).filter( 167 | num_reviews__gt=1) 168 | 169 | if dupe_revs.count() > 0: 170 | print("Duplicate reviews found for user: ".format(dupe_revs.count())) 171 | vocabulary_ids = [] 172 | for dupe_rev in dupe_revs: 173 | vocabulary_ids.append(dupe_rev['vocabulary']) 174 | 175 | print("Here are the vocabulary IDs we are gonna check: {}".format(vocabulary_ids)) 176 | for voc_id in vocabulary_ids: 177 | review_id_to_save = UserSpecific.objects.filter(vocabulary__id=voc_id, user=user).values_list("id", flat=True)[0] 178 | UserSpecific.objects.filter(vocabulary__id=voc_id, user=user).exclude(pk=int(review_id_to_save)).delete() 179 | new_reviews = UserSpecific.objects.filter(vocabulary__id=voc_id, user=user) 180 | print("New review count: {}".format(new_reviews.count())) 181 | assert(new_reviews.count() == 1) 182 | 183 | 184 | def one_time_import_jisho(json_file_path): 185 | import json 186 | with open(json_file_path) as file: 187 | with open("outfile.txt", 'w') as outfile: 188 | parsed_json = json.load(file) 189 | 190 | for vocabulary_json in parsed_json: 191 | try: 192 | related_reading = Reading.objects.get(character=vocabulary_json["ja"]["characters"]) 193 | outfile.write(merge_with_model(related_reading, vocabulary_json)) 194 | except Reading.DoesNotExist: 195 | pass 196 | except Reading.MultipleObjectsReturned: 197 | readings = Reading.objects.filter(character=vocabulary_json["ja"]["characters"]) 198 | print("FOUND MULTIPLE READINGS") 199 | for reading in readings: 200 | print(reading.vocabulary.meaning, reading.character, reading.kana, reading.level) 201 | merge_with_model(reading, vocabulary_json) 202 | 203 | def one_time_import_jisho_new_format(json_file_path): 204 | import json 205 | no_local_vocab = [] 206 | with open(json_file_path) as file: 207 | with open("outfile.txt", 'w') as outfile: 208 | parsed_json = json.load(file) 209 | 210 | for vocabulary_json in parsed_json: 211 | try: 212 | related_reading = Reading.objects.get(character=vocabulary_json["character"]) 213 | outfile.write(merge_with_model(related_reading, vocabulary_json)) 214 | except Reading.DoesNotExist: 215 | no_local_vocab.append(vocabulary_json) 216 | pass 217 | except Reading.MultipleObjectsReturned: 218 | readings = Reading.objects.filter(character=vocabulary_json["character"]) 219 | print("FOUND MULTIPLE READINGS") 220 | for reading in readings: 221 | if reading.kana == vocabulary_json["reading"]: 222 | print(reading.vocabulary.meaning, reading.character, reading.kana, reading.level) 223 | merge_with_model(reading, vocabulary_json) 224 | 225 | unfilled_vocabulary = Vocabulary.objects.exclude(readings__sentence_en__isnull=False) 226 | if unfilled_vocabulary.count() == 0: 227 | print("No missing information!") 228 | else: 229 | print("Missing some info!") 230 | for vocab in unfilled_vocabulary: 231 | print(vocab) 232 | print("Found no local vocabulary for: ") 233 | print(no_local_vocab) 234 | 235 | 236 | def merge_with_model(related_reading, vocabulary_json): 237 | if related_reading.kana != vocabulary_json['reading']: 238 | print("Not the primary reading, skipping: {}".format(related_reading.kana)) 239 | else: 240 | print("Found primary Reading: {}".format(related_reading.kana)) 241 | retval = "******\nWorkin on related reading...{},{}".format(related_reading.character, related_reading.id) 242 | retval += str(vocabulary_json) 243 | 244 | if "common" in vocabulary_json: 245 | related_reading.common = vocabulary_json["common"] 246 | else: 247 | retval += "NO COMMON?!" 248 | 249 | related_reading.isPrimary = True 250 | 251 | if "furi" in vocabulary_json: 252 | related_reading.furigana = vocabulary_json["furi"] 253 | 254 | if "pitch" in vocabulary_json: 255 | if len(vocabulary_json["pitch"]) > 0: 256 | string_pitch = ",".join([str(pitch) for pitch in vocabulary_json["pitch"]]) 257 | related_reading.pitch = string_pitch 258 | 259 | if "partOfSpeech" in vocabulary_json: 260 | related_reading.parts_of_speech.clear() 261 | for pos in vocabulary_json["partOfSpeech"]: 262 | part = PartOfSpeech.objects.get_or_create(part=pos)[0] 263 | if part not in related_reading.parts_of_speech.all(): 264 | related_reading.parts_of_speech.add(part) 265 | 266 | if "sentenceEn" in vocabulary_json: 267 | related_reading.sentence_en = vocabulary_json["sentenceEn"] 268 | 269 | if "sentenceJa" in vocabulary_json: 270 | related_reading.sentence_ja = vocabulary_json["sentenceJa"] 271 | 272 | related_reading.save() 273 | retval += "Finished with reading [{}]! Tags:{},".format(related_reading.id, related_reading.tags.count()) 274 | print(retval) 275 | return retval 276 | 277 | 278 | def associate_tags(reading, tag): 279 | print("associating [{}] to reading {}".format(tag, reading.vocabulary.meaning)) 280 | tag_obj, created = Tag.objects.get_or_create(name=tag) 281 | reading.tags.add(tag_obj) 282 | 283 | 284 | def create_tokens_for_all_users(): 285 | for user in User.objects.all(): 286 | Token.objects.get_or_create(user=user) 287 | 288 | 289 | def create_various_future_reviews_for_user(user): 290 | now = timezone.now() 291 | now = now.replace(minute=59) 292 | for i in range(0, 24): 293 | for j in range(0,20): 294 | review = create_review_for_specific_time(user, str(i) + "-" + str(j), now+timezone.timedelta(hours=i)) 295 | 296 | review.streak = random.randint(1,8) 297 | review.save() 298 | review.refresh_from_db() 299 | print(review) 300 | 301 | def survey_conglomerated_vocabulary(): 302 | count = 0 303 | for vocab in Vocabulary.objects.all(): 304 | if has_multiple_kanji(vocab): 305 | print("Found item with multiple Kanji:[{}]".format(vocab.meaning)) 306 | print("\n".join(reading.kana + ": " + reading.character for reading in vocab.readings.all())) 307 | count += 1 308 | 309 | print("total count:{}".format(count)) 310 | 311 | 312 | def find_all_duplicates(): 313 | all_vocab = Vocabulary.objects.all() 314 | kanji_review_map = {} 315 | for vocab in all_vocab: 316 | for reading in vocab.readings.all(): 317 | if reading.character in kanji_review_map.keys(): 318 | kanji_review_map[reading.character].append(vocab) 319 | else: 320 | kanji_review_map[reading.character] = [] 321 | kanji_review_map[reading.character].append(vocab) 322 | 323 | print("Printing all duplicates for all vocab.") 324 | duplicate_count = 0 325 | for kanji, vocabs in kanji_review_map.items(): 326 | if len(vocabs) > 1: 327 | duplicate_count += 1 328 | print("***" + kanji + "***") 329 | for vocab in vocabs: 330 | 331 | print(vocab) 332 | print("Finished printing duplicates: found {}".format(duplicate_count)) 333 | 334 | 335 | def copy_review_data(new_review, old_review): 336 | print("Copying review data from [{}] -> [{}]".format(old_review.id, new_review.id)) 337 | new_review.streak = old_review.streak 338 | new_review.incorrect = old_review.incorrect 339 | new_review.correct = old_review.correct 340 | new_review.next_review_date = old_review.next_review_date 341 | new_review.last_studied = old_review.last_studied 342 | new_review.burned = old_review.burned 343 | new_review.needs_review = old_review.needs_review 344 | new_review.wanikani_srs = old_review.wanikani_srs 345 | new_review.wanikani_srs_numeric = old_review.wanikani_srs_numeric 346 | new_review.wanikani_burned = old_review.wanikani_burned 347 | new_review.critical = old_review.critical 348 | new_review.unlock_date = old_review.unlock_date 349 | 350 | 351 | def one_time_orphaned_level_clear(): 352 | levels = Level.objects.filter(profile=None) 353 | levels.delete() 354 | 355 | def repopulate(): 356 | ''' 357 | A task that uses my personal API key in order to re-sync the database. Koichi often decides to switch things around 358 | on a level-per-level basis, or add synonyms, or change which readings are allowed. This method attempts to synchronize 359 | our data sets. 360 | 361 | :return: 362 | ''' 363 | url = "https://www.wanikani.com/api/user/" + constants.API_KEY + "/vocabulary/{}" 364 | logger.info("Starting DB Repopulation from WaniKani") 365 | for level in range(constants.LEVEL_MIN, constants.LEVEL_MAX + 1): 366 | json_data = make_api_call(url.format(level)) 367 | vocabulary_list = json_data['requested_information'] 368 | for vocabulary in vocabulary_list: 369 | import_vocabulary_from_json(vocabulary) 370 | 371 | 372 | def clear_duplicate_meaning_synonyms_from_reviews(): 373 | # Fetch all reviews wherein there are duplicate meaning synonyms. 374 | reviews = UserSpecific.objects.values('id', 'meaning_synonyms__text').annotate(Count('meaning_synonyms__text')).filter(meaning_synonyms__text__count__gt=1) 375 | review_list = list(reviews) 376 | review_list = set([review['id'] for review in review_list]) 377 | 378 | for review_id in review_list: 379 | seen_synonyms = set() 380 | synonyms = MeaningSynonym.objects.filter(review=review_id) 381 | for synonym in synonyms: 382 | if synonym.text in seen_synonyms: 383 | print("[{}]Deleted element{}".format(review_id, synonym.text)) 384 | synonym.delete() 385 | else: 386 | print("[{}]First time seeing element {}".format(review_id, synonym.text)) 387 | seen_synonyms.add(synonym.text) 388 | 389 | 390 | def clear_duplicate_answer_synonyms_from_reviews(): 391 | # Fetch all reviews wherein there are duplicate meaning synonyms. 392 | reviews = UserSpecific.objects.values('id', 'reading_synonyms__kana', 'reading_synonyms__character').annotate(Count('reading_synonyms__kana')).filter(reading_synonyms__kana__count__gt=1) 393 | review_list = list(reviews) 394 | review_list = set([review['id'] for review in review_list]) 395 | 396 | for review_id in review_list: 397 | seen_synonyms = set() 398 | synonyms = AnswerSynonym.objects.filter(review=review_id) 399 | for synonym in synonyms: 400 | if synonym.kana + "_" + synonym.character in seen_synonyms: 401 | print("[{}]Deleted element: {}".format(review_id, synonym.kana + "_" + synonym.character)) 402 | synonym.delete() 403 | else: 404 | print("[{}]First time seeing element: {}".format(review_id, synonym.kana + "_" + synonym.character)) 405 | seen_synonyms.add(synonym.kana + "_" + synonym.character) 406 | 407 | -------------------------------------------------------------------------------- /api/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.http import HttpResponseForbidden, HttpResponseBadRequest 3 | from rest_framework import generics, filters 4 | from rest_framework import mixins 5 | from rest_framework import status 6 | from rest_framework import viewsets 7 | from rest_framework.decorators import list_route, detail_route, permission_classes 8 | from rest_framework.exceptions import PermissionDenied 9 | from rest_framework.generics import get_object_or_404 10 | from rest_framework.permissions import IsAuthenticated, IsAdminUser 11 | from rest_framework.response import Response 12 | from rest_framework.reverse import reverse_lazy 13 | 14 | from api.filters import VocabularyFilter, ReviewFilter 15 | from api.permissions import IsAdminOrReadOnly, IsAuthenticatedOrCreating, IsAdminOrAuthenticatedAndCreating 16 | from api.serializers import ReviewSerializer, VocabularySerializer, StubbedReviewSerializer, \ 17 | HyperlinkedVocabularySerializer, ReadingSerializer, LevelSerializer, ReadingSynonymSerializer, \ 18 | FrequentlyAskedQuestionSerializer, AnnouncementSerializer, UserSerializer, ContactSerializer, ProfileSerializer, \ 19 | ReportSerializer, ReportCountSerializer, ReportListSerializer, MeaningSynonymSerializer, RegistrationSerializer, \ 20 | ReviewCountSerializer 21 | from kw_webapp import constants 22 | from kw_webapp.forms import UserContactCustomForm 23 | from kw_webapp.models import Vocabulary, UserSpecific, Reading, Level, AnswerSynonym, FrequentlyAskedQuestion, \ 24 | Announcement, Profile, Report, MeaningSynonym 25 | from kw_webapp.tasks import get_users_current_reviews, unlock_eligible_vocab_from_levels, lock_level_for_user, \ 26 | get_users_critical_reviews, sync_with_wk, all_srs, sync_user_profile_with_wk, user_returns_from_vacation, \ 27 | user_begins_vacation, follow_user, reset_user, get_users_lessons 28 | 29 | 30 | from KW.LoggingMiddleware import RequestLoggingMixin 31 | import logging 32 | logger = logging.getLogger(__name__) 33 | 34 | 35 | class ListRetrieveUpdateViewSet(mixins.ListModelMixin, 36 | mixins.UpdateModelMixin, 37 | mixins.RetrieveModelMixin, 38 | viewsets.GenericViewSet): 39 | """ 40 | A viewset that provides `List`, `Update`, and `Retrieve` actions. 41 | Must override: .queryset, .serializer_class 42 | """ 43 | pass 44 | 45 | 46 | class ReadingViewSet(RequestLoggingMixin, viewsets.ReadOnlyModelViewSet): 47 | """ 48 | For internal use fetching readings specifically. 49 | """ 50 | queryset = Reading.objects.all() 51 | serializer_class = ReadingSerializer 52 | 53 | 54 | class ReadingSynonymViewSet(RequestLoggingMixin, viewsets.ModelViewSet): 55 | serializer_class = ReadingSynonymSerializer 56 | 57 | def get_queryset(self): 58 | return AnswerSynonym.objects.filter(review__user=self.request.user) 59 | 60 | 61 | class MeaningSynonymViewSet(RequestLoggingMixin, viewsets.ModelViewSet): 62 | serializer_class = MeaningSynonymSerializer 63 | 64 | def get_queryset(self): 65 | return MeaningSynonym.objects.filter(review__user=self.request.user) 66 | 67 | 68 | class LevelViewSet(RequestLoggingMixin, viewsets.ReadOnlyModelViewSet): 69 | """ 70 | Return a list of all levels and related information. 71 | 72 | unlock: 73 | Unlock the given level for a particular user. This will add all the vocabulary of that level to their review queue immediately 74 | 75 | lock: 76 | Lock the given level for a particular user. This will wipe away ALL related SRS information for these vocabulary as well. 77 | """ 78 | queryset = Level.objects.all() 79 | serializer_class = LevelSerializer 80 | 81 | def get_object(self): 82 | level = int(self.kwargs['pk']) 83 | return self._serialize_level(level, self.request) 84 | 85 | def _serialize_level(self, level, request): 86 | unlocked = True if level in request.user.profile.unlocked_levels_list() else False 87 | level_obj = request.user.profile.unlocked_levels.get(level=level) if unlocked else None 88 | 89 | pre_serialized_dict = {'level': level, 90 | 'unlocked': unlocked, 91 | 'vocabulary_count': Vocabulary.objects.filter(readings__level=level).distinct().count(), 92 | 'vocabulary_url': level} 93 | if level <= request.user.profile.level: 94 | pre_serialized_dict['lock_url'] = self._build_lock_url(level) 95 | pre_serialized_dict['unlock_url'] = self._build_unlock_url(level) 96 | 97 | return pre_serialized_dict 98 | 99 | def list(self, request, *args, **kwargs): 100 | level_dicts = [] 101 | for level in range(constants.LEVEL_MIN, constants.LEVEL_MAX + 1): 102 | level_dicts.append(self._serialize_level(level, request)) 103 | 104 | serializer = LevelSerializer(level_dicts, many=True, context={'request': request}) 105 | return Response(serializer.data) 106 | 107 | def _build_lock_url(self, level): 108 | return reverse_lazy('api:level-lock', args=(level,)) 109 | 110 | def _build_unlock_url(self, level): 111 | return reverse_lazy('api:level-unlock', args=(level,)) 112 | 113 | @detail_route(methods=['POST']) 114 | def unlock(self, request, pk=None): 115 | user = self.request.user 116 | requested_level = pk 117 | if int(requested_level) > user.profile.level: 118 | return Response(status=status.HTTP_403_FORBIDDEN) 119 | 120 | unlocked_this_request, total_unlocked, locked = unlock_eligible_vocab_from_levels(user, requested_level) 121 | user.profile.unlocked_levels.get_or_create(level=requested_level) 122 | 123 | return Response(dict(unlocked_now=unlocked_this_request, 124 | total_unlocked=total_unlocked, 125 | locked=locked)) 126 | 127 | @detail_route(methods=['POST']) 128 | def lock(self, request, pk=None): 129 | requested_level = pk 130 | if request.user.profile.level == int(requested_level): 131 | request.user.profile.follow_me = False 132 | request.user.profile.save() 133 | removed_count = lock_level_for_user(requested_level, request.user) 134 | 135 | return Response({"locked": removed_count}) 136 | 137 | 138 | class VocabularyViewSet(RequestLoggingMixin, viewsets.ReadOnlyModelViewSet): 139 | """ 140 | Endpoint for fetching specific vocabulary. You can pass parameter `hyperlink=true` to receive the vocabulary with 141 | hyperlinked readings (for increased performance), or else they will be inline 142 | """ 143 | filter_class = VocabularyFilter 144 | queryset = Vocabulary.objects.all() 145 | 146 | def get_serializer_class(self): 147 | if self.request.query_params.get('hyperlink', 'false') == 'true': 148 | return HyperlinkedVocabularySerializer 149 | else: 150 | return VocabularySerializer 151 | 152 | 153 | class ReportViewSet(RequestLoggingMixin, viewsets.ModelViewSet): 154 | filter_fields = ('created_by', 'reading') 155 | serializer_class = ReportSerializer 156 | permission_classes = (IsAdminOrAuthenticatedAndCreating,) 157 | 158 | @list_route(methods=["GET"]) 159 | def counts(self, request): 160 | serializer = ReportCountSerializer(Report.objects.all()) 161 | return Response(serializer.data) 162 | 163 | def get_queryset(self): 164 | if self.request.user.is_staff: 165 | return Report.objects.all() 166 | else: 167 | return Report.objects.filter(created_by=self.request.user) 168 | 169 | def create(self, request, *args, **kwargs): 170 | """ 171 | Create a new report, or if an identical report already exists, update the existing one. 172 | """ 173 | try: 174 | reading_id = request.data["reading"] 175 | existing_report = Report.objects.get(reading__id=reading_id, created_by=request.user) 176 | logger.info("User {} is updating their report on reading {}".format(request.user.username, request.data["reading"])) 177 | serializer = ReportSerializer(existing_report, data=request.data, partial=True) 178 | serializer.is_valid(raise_exception=True) 179 | serializer.save() 180 | return Response(serializer.data) 181 | except Report.DoesNotExist: 182 | logger.info("User {} is creating report on reading {}".format(request.user.username, request.data["reading"])) 183 | serializer = ReportSerializer(data=request.data) 184 | serializer.is_valid(raise_exception=True) 185 | serializer.save(created_by=self.request.user) 186 | return Response(serializer.data) 187 | 188 | def get_serializer_class(self): 189 | if self.action == "list": 190 | return ReportListSerializer 191 | return super().get_serializer_class() 192 | 193 | def destroy(self, request, *args, **kwargs): 194 | return super().destroy(request, *args, **kwargs) 195 | 196 | 197 | class ReviewViewSet(RequestLoggingMixin, ListRetrieveUpdateViewSet): 198 | """ 199 | lesson: 200 | Get all of user's lessons. 201 | 202 | current: 203 | Get all of user's reviews which currently need to be done. 204 | 205 | critical: 206 | Return a list of *critical* items, which the user has often gotten incorrect. 207 | 208 | correct: 209 | POSTing here will indicate that the user has successfully answered the review. 210 | 211 | incorrect: 212 | POSTing here will indicate that the user has incorrectly answered the review. 213 | 214 | hide: 215 | No longer include this item in the SRS algorithm and review queue. 216 | 217 | unhide: 218 | include this item in the SRS algorithm and review queue. 219 | 220 | reset: 221 | Reset all SRS information relating to this review. 222 | """ 223 | serializer_class = ReviewSerializer 224 | filter_class = ReviewFilter 225 | permission_classes = (IsAuthenticated,) 226 | 227 | @list_route(methods=['GET']) 228 | def lesson(self, request): 229 | lessons = get_users_lessons(request.user) 230 | page = self.paginate_queryset(lessons) 231 | if page is not None: 232 | serializer = StubbedReviewSerializer(page, many=True) 233 | return self.get_paginated_response(serializer.data) 234 | 235 | serializer = StubbedReviewSerializer(lessons, many=True) 236 | return Response(serializer.data) 237 | 238 | @list_route(methods=['GET']) 239 | def current(self, request): 240 | reviews = get_users_current_reviews(request.user) 241 | page = self.paginate_queryset(reviews) 242 | 243 | if page is not None: 244 | serializer = StubbedReviewSerializer(page, many=True) 245 | return self.get_paginated_response(serializer.data) 246 | 247 | serializer = StubbedReviewSerializer(reviews, many=True) 248 | return Response(serializer.data) 249 | 250 | @list_route(methods=['GET']) 251 | def critical(self, request): 252 | critical_reviews = get_users_critical_reviews(request.user) 253 | page = self.paginate_queryset(critical_reviews) 254 | 255 | if page is not None: 256 | serializer = ReviewSerializer(page, many=True) 257 | return self.get_paginated_response(serializer.data) 258 | 259 | serializer = ReviewSerializer(critical_reviews, many=True) 260 | return Response(serializer.data) 261 | 262 | def _correct_on_first_try(self, request): 263 | if "wrong_before" not in request.data: 264 | return True 265 | if request.data["wrong_before"] is False: 266 | return True 267 | if request.data["wrong_before"] == "false": 268 | return True 269 | 270 | return False 271 | 272 | @detail_route(methods=['POST']) 273 | def correct(self, request, pk=None): 274 | review = get_object_or_404(UserSpecific, pk=pk) 275 | if not review.can_be_managed_by(request.user) or not review.needs_review: 276 | raise PermissionDenied("You can't review a review that doesn't need to be reviewed! ٩(ఠ益ఠ)۶") 277 | 278 | was_correct_on_first_try = self._correct_on_first_try(request) 279 | review = review.answered_correctly(was_correct_on_first_try) 280 | serializer = self.get_serializer(review, many=False) 281 | return Response(serializer.data) 282 | 283 | @detail_route(methods=['POST']) 284 | def incorrect(self, request, pk=None): 285 | review = get_object_or_404(UserSpecific, pk=pk) 286 | if not review.can_be_managed_by(request.user) or not review.needs_review: 287 | raise PermissionDenied("You can't review a review that doesn't need to be reviewed! ٩(ఠ益ఠ)۶") 288 | review = review.answered_incorrectly() 289 | serializer = self.get_serializer(review, many=False) 290 | return Response(serializer.data) 291 | 292 | @detail_route(methods=['POST']) 293 | def hide(self, request, pk=None): 294 | return self._set_hidden(request, True, pk) 295 | 296 | @detail_route(methods=['POST']) 297 | def unhide(self, request, pk=None): 298 | return self._set_hidden(request, False, pk) 299 | 300 | @list_route(methods=['GET']) 301 | def counts(self, request): 302 | user = request.user 303 | serializer = ReviewCountSerializer(user) 304 | return Response(serializer.data) 305 | 306 | @detail_route(methods=['POST']) 307 | def reset(self, request, pk=None): 308 | review = get_object_or_404(UserSpecific, pk=pk) 309 | review.reset() 310 | return Response(status=status.HTTP_204_NO_CONTENT) 311 | 312 | def _set_hidden(self, request, should_hide, pk=None): 313 | review = get_object_or_404(UserSpecific, pk=pk) 314 | if not review.can_be_managed_by(request.user): 315 | return HttpResponseForbidden("You can't modify that object!") 316 | 317 | review.hidden = should_hide 318 | review.save() 319 | return Response(status=status.HTTP_204_NO_CONTENT) 320 | 321 | def get_queryset(self): 322 | return UserSpecific.objects.filter(user=self.request.user, 323 | wanikani_srs_numeric__gte=self.request.user.profile.get_minimum_wk_srs_threshold_for_review()) 324 | 325 | 326 | class FrequentlyAskedQuestionViewSet(RequestLoggingMixin, viewsets.ModelViewSet): 327 | """ 328 | Frequently Asked Questions that uses will have read access to. 329 | """ 330 | permission_classes = (IsAdminOrReadOnly,) 331 | serializer_class = FrequentlyAskedQuestionSerializer 332 | queryset = FrequentlyAskedQuestion.objects.all() 333 | 334 | 335 | class AnnouncementViewSet(RequestLoggingMixin, viewsets.ModelViewSet): 336 | """ 337 | Announcements that users will see upon entering the website. 338 | """ 339 | permission_classes = (IsAdminOrReadOnly,) 340 | serializer_class = AnnouncementSerializer 341 | queryset = Announcement.objects.all().order_by('-pub_date') 342 | 343 | def perform_create(self, serializer): 344 | serializer.save(creator=self.request.user) 345 | 346 | 347 | class UserViewSet(RequestLoggingMixin, viewsets.GenericViewSet, generics.ListCreateAPIView): 348 | """ 349 | Endpoint for user and internally nested profiles. Used primarily for updating user profiles, and creation of users. 350 | 351 | me: 352 | Standard endpoint to retrieve current user based on their authentication provided in the request. This is also where 353 | we PUT changes to the nested profile. 354 | 355 | sync: 356 | Force a sync to the Wanikani server. 357 | 358 | srs: 359 | Force an SRS run (typically runs every 15 minutes anyhow). 360 | 361 | reset: 362 | Reset a user's account. Removes all reviews, re-locks all levels. Immediately runs unlock on current level afterwards. 363 | """ 364 | permission_classes = (IsAuthenticatedOrCreating,) 365 | serializer_class = UserSerializer 366 | 367 | def get_queryset(self): 368 | if self.request.user.is_staff: 369 | return User.objects.all() 370 | 371 | return User.objects.filter(pk=self.request.user.id) 372 | 373 | @list_route(methods=["GET"]) 374 | def me(self, request): 375 | user = request.user 376 | serializer = self.get_serializer(user, many=False) 377 | return Response(serializer.data) 378 | 379 | @list_route(methods=['POST']) 380 | def sync(self, request): 381 | should_full_sync = False 382 | if 'full_sync' in request.data: 383 | should_full_sync = request.data['full_sync'] == 'true' 384 | 385 | profile_sync_succeeded, new_review_count, new_synonym_count = sync_with_wk(request.user.id, should_full_sync) 386 | return Response({"profile_sync_succeeded": profile_sync_succeeded, 387 | "new_review_count": new_review_count, 388 | "new_synonym_count": new_synonym_count}) 389 | 390 | @list_route(methods=['POST']) 391 | def srs(self, request): 392 | all_srs(request.user) 393 | new_review_count = get_users_current_reviews(request.user).count() 394 | return Response({'review_count': new_review_count}) 395 | 396 | @list_route(methods=['POST']) 397 | def reset(self, request): 398 | reset_to_level = int(request.data['level']) if 'level' in request.data else None 399 | if reset_to_level is None: 400 | return HttpResponseBadRequest("You must pass a level to reset to.") 401 | 402 | reset_user(request.user, reset_to_level) 403 | return Response({"message": "Your account has been reset"}) 404 | 405 | 406 | class ProfileViewSet(RequestLoggingMixin, ListRetrieveUpdateViewSet, viewsets.GenericViewSet): 407 | """ 408 | Profile model view set, for INTERNAL TESTING USE ONLY. 409 | """ 410 | permission_classes = (IsAuthenticated,) 411 | serializer_class = ProfileSerializer 412 | 413 | def get_queryset(self): 414 | return Profile.objects.filter(user=self.request.user) 415 | 416 | def perform_update(self, serializer): 417 | 418 | serializer = self._update_calculated_fields(serializer) 419 | instance = serializer.save() 420 | return Response(instance) 421 | 422 | def _update_calculated_fields(self, serializer): 423 | old_instance = serializer.instance 424 | 425 | user = old_instance.user 426 | 427 | if old_instance.on_vacation and not serializer.validated_data.get('on_vacation'): 428 | user_returns_from_vacation(user) 429 | 430 | if not old_instance.on_vacation and serializer.validated_data.get('on_vacation'): 431 | user_begins_vacation(user) 432 | 433 | if not old_instance.follow_me and serializer.validated_data.get('follow_me'): 434 | follow_user(user) 435 | 436 | # Since if we have gotten this far, we know that API key is valid, we set it here. 437 | api_validated = serializer.validated_data.get('api_key', None) 438 | if api_validated: 439 | serializer.validated_data['api_valid'] = True 440 | 441 | return serializer 442 | 443 | 444 | class ContactViewSet(RequestLoggingMixin, generics.CreateAPIView, viewsets.GenericViewSet): 445 | """ 446 | Endpoint for contacting the developers. POSTing to this endpoint will send us an email. 447 | """ 448 | permission_classes = (IsAuthenticated,) 449 | serializer_class = ContactSerializer 450 | 451 | def create(self, request): 452 | serializer = self.get_serializer(data=request.data) 453 | serializer.is_valid(raise_exception=True) 454 | form = UserContactCustomForm(data=serializer.data, request=self.request) 455 | 456 | if not form.is_valid(): 457 | return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) 458 | form.save() 459 | return Response(status=status.HTTP_202_ACCEPTED) 460 | # return Response({"detail": "Successfully sent contact email"}, status=status.HTTP_202_ACCEPTED) 461 | 462 | 463 | --------------------------------------------------------------------------------