├── eggtimer ├── __init__.py ├── static │ ├── img │ │ ├── logo.png │ │ └── favicon.ico │ ├── js │ │ ├── all_fields_required.js │ │ └── base.js │ └── css │ │ └── base.css ├── templates │ ├── account │ │ ├── email │ │ │ ├── email_confirmation_subject.txt │ │ │ └── email_confirmation_message.txt │ │ ├── password_reset_from_key_done.html │ │ ├── password_reset_done.html │ │ ├── email_confirm.html │ │ ├── password_change.html │ │ ├── signup.html │ │ ├── password_reset.html │ │ ├── password_reset_from_key.html │ │ └── login.html │ ├── 500.html │ ├── 404.html │ ├── email │ │ ├── base.txt │ │ └── base.html │ ├── socialaccount │ │ ├── authentication_error.html │ │ ├── login_cancelled.html │ │ ├── snippets │ │ │ └── provider_list.html │ │ ├── signup.html │ │ └── connections.html │ └── base.html ├── urls.py ├── wsgi.py └── settings.py ├── periods ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── fix_timezone_for_period_data.py │ │ ├── email_active_users.py │ │ └── notify_upcoming_period.py ├── migrations │ ├── __init__.py │ ├── 0003_auto_20150222_1233.py │ ├── 0013_auto_20160718_2332.py │ ├── 0008_flowevent_cramps.py │ ├── 0007_auto_20150301_1456.py │ ├── 0010_statistics_all_time_average_cycle_length.py │ ├── 0009_user_timezone.py │ ├── 0006_auto_20150222_2351.py │ ├── 0002_auto_20150207_2125.py │ ├── 0015_aerisdata.py │ ├── 0011_populate_averages.py │ ├── 0012_auto_20151024_2225.py │ ├── 0004_flowevent.py │ ├── 0014_auto_20161026_1742.py │ ├── 0005_auto_20150222_1302.py │ └── 0001_initial.py ├── tests │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── test_fix_timezone_for_period_data.py │ │ │ ├── test_email_active_users.py │ │ │ └── test_notify_upcoming_period.py │ ├── __init__.py │ ├── test_helpers.py │ ├── factories.py │ ├── test_email_sender.py │ ├── test_serializers.py │ ├── static │ │ └── periods │ │ │ └── js │ │ │ └── test_calendar.js │ ├── test_models.py │ └── test_views.py ├── templates │ └── periods │ │ ├── flowevent_form.html │ │ ├── email │ │ ├── ovulating.html │ │ ├── ovulating.txt │ │ ├── expected_now.html │ │ ├── expected_now.txt │ │ ├── expected_in.txt │ │ ├── expected_in.html │ │ ├── expected_ago.txt │ │ ├── expected_ago.html │ │ └── notification.txt │ │ ├── flowevent_list.html │ │ ├── user_form.html │ │ ├── calendar.html │ │ ├── api_info.html │ │ └── statistics.html ├── static │ └── periods │ │ ├── img │ │ ├── new_moon.png │ │ ├── full_moon.png │ │ ├── first_quarter.png │ │ └── last_quarter.png │ │ ├── js │ │ ├── api_info.js │ │ ├── profile.js │ │ ├── statistics.js │ │ └── calendar.js │ │ └── css │ │ └── calendar.css ├── helpers.py ├── forms.py ├── middleware.py ├── email_sender.py ├── serializers.py ├── admin.py ├── urls.py ├── views.py └── models.py ├── runtime.txt ├── .bowerrc ├── requirements ├── extensions.txt ├── production.txt ├── development.txt └── common.txt ├── setup.cfg ├── Procfile ├── .coveragerc ├── requirements.txt ├── .env.sample ├── manage.py ├── .gitignore ├── config ├── eggtimer.service └── eggtimer ├── scripts ├── deploy_to_heroku.sh └── deploy_on_digital_ocean.sh ├── bower.json ├── package.json ├── selenium ├── selenium_settings.py ├── test_signup.py └── base_test.py ├── LICENSE.txt ├── .circleci └── config.yml └── README.md /eggtimer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /periods/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.6.8 2 | -------------------------------------------------------------------------------- /periods/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /periods/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /periods/tests/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /periods/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /periods/tests/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /periods/tests/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'jessamyn' 2 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "registry": "https://registry.bower.io" 3 | } 4 | -------------------------------------------------------------------------------- /requirements/extensions.txt: -------------------------------------------------------------------------------- 1 | django-extensions==1.7.3 2 | pygraphviz==1.3.1 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | exclude = *migrations*, venv/* 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: newrelic-admin run-program gunicorn eggtimer.wsgi:application -b 0.0.0.0:$PORT -w 5 2 | -------------------------------------------------------------------------------- /eggtimer/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessamynsmith/eggtimer-server/HEAD/eggtimer/static/img/logo.png -------------------------------------------------------------------------------- /eggtimer/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessamynsmith/eggtimer-server/HEAD/eggtimer/static/img/favicon.ico -------------------------------------------------------------------------------- /periods/templates/periods/flowevent_form.html: -------------------------------------------------------------------------------- 1 | {% load bootstrap %} 2 |
5 | -------------------------------------------------------------------------------- /periods/static/periods/img/new_moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessamynsmith/eggtimer-server/HEAD/periods/static/periods/img/new_moon.png -------------------------------------------------------------------------------- /periods/static/periods/js/api_info.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $('#id_birth_date').attr('placeholder', 'YYYY-MM-DD'); 3 | }); 4 | -------------------------------------------------------------------------------- /periods/static/periods/js/profile.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $('#id_birth_date').attr('placeholder', 'YYYY-MM-DD'); 3 | }); 4 | -------------------------------------------------------------------------------- /periods/static/periods/img/full_moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessamynsmith/eggtimer-server/HEAD/periods/static/periods/img/full_moon.png -------------------------------------------------------------------------------- /periods/static/periods/img/first_quarter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessamynsmith/eggtimer-server/HEAD/periods/static/periods/img/first_quarter.png -------------------------------------------------------------------------------- /periods/static/periods/img/last_quarter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessamynsmith/eggtimer-server/HEAD/periods/static/periods/img/last_quarter.png -------------------------------------------------------------------------------- /eggtimer/static/js/all_fields_required.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $('input[type!="checkbox"]').attr('required', 'required'); 3 | setRequiredLabels(); 4 | }); 5 | -------------------------------------------------------------------------------- /periods/templates/periods/email/ovulating.html: -------------------------------------------------------------------------------- 1 | {% extends "email/base.html" %} 2 | 3 | {% block content %} 4 | You are probably ovulating today, {{ today }}! 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /periods/templates/periods/email/ovulating.txt: -------------------------------------------------------------------------------- 1 | {% extends "email/base.txt" %} 2 | 3 | {% block content %} 4 | You are probably ovulating today, {{ today }}! 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /periods/templates/periods/email/expected_now.html: -------------------------------------------------------------------------------- 1 | {% extends "email/base.html" %} 2 | 3 | {% block content %} 4 | You should be getting your period today, {{ expected_date}}! 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /periods/templates/periods/email/expected_now.txt: -------------------------------------------------------------------------------- 1 | {% extends "email/base.txt" %} 2 | 3 | {% block content %} 4 | You should be getting your period today, {{ expected_date}}! 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | -r common.txt 2 | gevent==22.10.2 3 | greenlet==2.0.1 4 | gunicorn==20.1.0 5 | newrelic==2.54.0.41 6 | whitenoise==3.2.2 7 | zope.event==4.6 8 | zope.interface==5.5.2 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = eggtimer, periods 3 | branch = True 4 | omit = *tests* 5 | */migrations/* 6 | *__init__.py 7 | *settings* 8 | venv/* 9 | plugins = 10 | django_coverage_plugin 11 | -------------------------------------------------------------------------------- /periods/templates/periods/email/expected_in.txt: -------------------------------------------------------------------------------- 1 | {% extends "email/base.txt" %} 2 | 3 | {% block content %} 4 | You should be getting your period in {{ expected_in }} {{ day }}, on {{ expected_date}}. 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Default to installing production dependencies automatically 2 | # TODO update pipwrap to behave correctly for multiple requirements files, get rid of requirements dir 3 | -r requirements/common.txt 4 | -------------------------------------------------------------------------------- /periods/templates/periods/email/expected_in.html: -------------------------------------------------------------------------------- 1 | {% extends "email/base.html" %} 2 | 3 | {% block content %} 4 | You should be getting your period in {{ expected_in }} {{ day }}, on {{ expected_date}}. 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /eggtimer/templates/account/email/email_confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% autoescape off %} 3 | {% blocktrans with site_name=current_site.name %}Welcome to {{ site_name }}{% endblocktrans %} 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /periods/templates/periods/email/expected_ago.txt: -------------------------------------------------------------------------------- 1 | {% extends "email/base.txt" %} 2 | 3 | {% block content %} 4 | You should have gotten your period {{ expected_in }} {{ day }} ago, on {{ expected_date}}. 5 | Did you forget to add your last period? 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /eggtimer/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |Sorry, this page is currently unavailable.
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /eggtimer/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Page not found{% endblock %} 4 | 5 | {% block content %} 6 |Sorry, but the requested page could not be found.
10 |{% trans "An error occurred while attempting to login via your social network account." %}
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /eggtimer/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 5 | 6 | {% block content %} 7 |{% trans 'Your password is now changed.' %} 9 | {% trans 'Sign in here.' %} 10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /periods/middleware.py: -------------------------------------------------------------------------------- 1 | class AddAuthTokenMiddleware(object): 2 | """ 3 | Adds auth_token cookie to response 4 | """ 5 | def process_response(self, request, response): 6 | if hasattr(request, 'user') and request.user and request.user.is_authenticated(): 7 | auth_token = request.user.auth_token 8 | if auth_token: 9 | response.set_cookie('auth_token', auth_token) 10 | return response 11 | -------------------------------------------------------------------------------- /periods/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from periods import helpers 4 | 5 | 6 | class TestGetFullDomain(TestCase): 7 | 8 | def test_http(self): 9 | result = helpers.get_full_domain() 10 | self.assertEqual('http://example.com', result) 11 | 12 | def test_https(self): 13 | with self.settings(SECURE_SSL_REDIRECT=True): 14 | result = helpers.get_full_domain() 15 | self.assertEqual('https://example.com', result) 16 | -------------------------------------------------------------------------------- /periods/migrations/0003_auto_20150222_1233.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 | ('periods', '0002_auto_20150207_2125'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='statistics', 16 | options={'verbose_name_plural': 'statistics'}, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /periods/templates/periods/flowevent_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load bootstrap %} 3 | 4 | {% block title %}Periods for {{ user.get_full_name }}{% endblock %} 5 | 6 | {% block content %} 7 |*Note: all times in UTC
9 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /periods/migrations/0013_auto_20160718_2332.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 | ('periods', '0012_auto_20151024_2225'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name='user', 16 | old_name='timezone', 17 | new_name='_timezone', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | var 14 | sdist 15 | develop-eggs 16 | .installed.cfg 17 | lib 18 | lib64 19 | 20 | # Installer logs 21 | pip-log.txt 22 | 23 | # Unit test / coverage reports 24 | .coverage 25 | coverage.* 26 | .tox 27 | nosetests.xml 28 | 29 | *.sql* 30 | 31 | .idea 32 | .idea/* 33 | 34 | venv*/* 35 | 36 | .env 37 | 38 | .python-version 39 | 40 | node_modules/* 41 | bower_components/* 42 | -------------------------------------------------------------------------------- /eggtimer/templates/socialaccount/login_cancelled.html: -------------------------------------------------------------------------------- 1 | {% extends "socialaccount/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Login Cancelled" %}{% endblock %} 6 | 7 | {% block content %} 8 | 9 |{% blocktrans %}You decided to cancel logging in to our site using one of your existing accounts. If this was a mistake, please proceed to sign in.{% endblocktrans %}
14 | 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /periods/email_sender.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail import EmailMultiAlternatives 3 | from email.utils import formataddr 4 | 5 | 6 | def send(recipient, subject, text_body, html_body): 7 | recipients = [formataddr((recipient.get_full_name(), recipient.email))] 8 | msg = EmailMultiAlternatives(subject, text_body, to=recipients, 9 | reply_to=settings.REPLY_TO) 10 | if html_body: 11 | msg.attach_alternative(html_body, "text/html") 12 | msg.send() 13 | return True 14 | -------------------------------------------------------------------------------- /eggtimer/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 7 | 8 | {% block content %} 9 |{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /periods/migrations/0008_flowevent_cramps.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 | ('periods', '0007_auto_20150301_1456'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='flowevent', 16 | name='cramps', 17 | field=models.IntegerField(blank=True, default=None, null=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /periods/migrations/0007_auto_20150301_1456.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 | ('periods', '0006_auto_20150222_2351'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='flowevent', 16 | name='comment', 17 | field=models.CharField(max_length=250, blank=True, null=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /periods/migrations/0010_statistics_all_time_average_cycle_length.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 | ('periods', '0009_user_timezone'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='statistics', 16 | name='all_time_average_cycle_length', 17 | field=models.IntegerField(default=28), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /eggtimer/static/js/base.js: -------------------------------------------------------------------------------- 1 | var addFormStyles = function() { 2 | $('select,input[type="text"],input[type="datetime"],input[type="date"],input[type="time"],input[type="number"]').addClass('form-control').addClass('my-form-control'); 3 | $('label').addClass('control-label').addClass('my-control-label'); 4 | }; 5 | 6 | 7 | var setRequiredLabels = function(ids) { 8 | $('label').removeClass('required'); 9 | $('input,textarea,select').filter('[required]:visible').parent().parent().find("label").addClass("required"); 10 | }; 11 | 12 | 13 | $(document).ready(function() { 14 | addFormStyles(); 15 | }); 16 | -------------------------------------------------------------------------------- /periods/migrations/0009_user_timezone.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import timezone_field.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('periods', '0008_flowevent_cramps'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='user', 17 | name='timezone', 18 | field=timezone_field.fields.TimeZoneField(default='America/New_York'), 19 | preserve_default=True, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /eggtimer/templates/account/email/email_confirmation_message.txt: -------------------------------------------------------------------------------- 1 | {% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans %} 2 | Hello {{ user_display }},{% endblocktrans %} 3 | 4 | {% blocktrans with site_name=current_site.name %}Welcome to {{ site_name }}, an open-source menstrual tracking app! 5 | 6 | Please confirm your email address at: {{ activate_url }} 7 | 8 | If you have any questions please reply to this email. 9 | 10 | Cheers! 11 | {{ site_name }} 12 | {% endblocktrans %}{% endautoescape %} 13 | 14 | Found a bug? Have a feature request? Please let us know: https://github.com/jessamynsmith/eggtimer-server/issues 15 | -------------------------------------------------------------------------------- /periods/migrations/0006_auto_20150222_2351.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 | ('periods', '0005_auto_20150222_1302'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterUniqueTogether( 15 | name='period', 16 | unique_together=None, 17 | ), 18 | migrations.RemoveField( 19 | model_name='period', 20 | name='user', 21 | ), 22 | migrations.DeleteModel( 23 | name='Period', 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /config/eggtimer.service: -------------------------------------------------------------------------------- 1 | # /etc/systemd/system/eggtimer.service 2 | 3 | [Unit] 4 | Description=Gunicorn daemon for eggtimer 5 | Before=nginx.service 6 | After=network.target 7 | 8 | [Service] 9 | WorkingDirectory=/home/django/eggtimer 10 | ExecStart=/home/django/eggtimer/venv/bin/gunicorn --access-logfile - \ 11 | --capture-output --enable-stdio-inheritance \ 12 | --error-logfile /home/django/log/eggtimer.log --name=eggtimer \ 13 | --pythonpath=/home/django/eggtimer --bind unix:/home/django/eggtimer.socket \ 14 | --config /etc/gunicorn.d/gunicorn.py eggtimer.wsgi:application 15 | Restart=always 16 | SyslogIdentifier=eggtimer 17 | User=django 18 | Group=django 19 | 20 | 21 | [Install] 22 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /scripts/deploy_to_heroku.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script will quit on the first error that is encountered. 4 | set -e 5 | 6 | CIRCLE=$1 7 | 8 | DEPLOY_DATE=`date "+%FT%T%z"` 9 | SECRET=$(openssl rand -base64 58 | tr '\n' '_') 10 | 11 | heroku config:set --app=eggtimer \ 12 | NEW_RELIC_APP_NAME='eggtimer' \ 13 | ADMIN_EMAIL="egg.timer.app@gmail.com" \ 14 | ADMIN_NAME="egg timer" \ 15 | DJANGO_SETTINGS_MODULE=eggtimer.settings \ 16 | DJANGO_SECRET_KEY="$SECRET" \ 17 | DEPLOY_DATE="$DEPLOY_DATE" \ 18 | > /dev/null 19 | 20 | if [ $CIRCLE ] 21 | then 22 | echo "Push is handled by circle heroku orb" 23 | else 24 | git push heroku master 25 | fi 26 | 27 | heroku run python manage.py migrate --noinput --app eggtimer 28 | -------------------------------------------------------------------------------- /requirements/common.txt: -------------------------------------------------------------------------------- 1 | defusedxml==0.4.1 2 | dj-database-url==0.3.0 3 | Django==1.10.5 4 | django-allauth==0.27.0 5 | django-bootstrap-form==3.2 6 | django-cors-headers==1.1.0 7 | django-custom-user==0.5 8 | django-enumfield==2.0.1 9 | django-extra-views==0.8.0 10 | django-filter==0.15.3 11 | django-floppyforms==1.7.0 12 | django-jsonview==1.2.0 13 | django-settings-context-processor==0.2 14 | django-timezone-field==2.0 15 | djangorestframework==3.4.6 16 | funcsigs==1.0.2 17 | mimeparse==0.1.3 18 | oauthlib==1.1.2 19 | psycopg2-binary>=2.8,<2.9 20 | python-dotenv 21 | python-mimeparse==0.1.4 22 | python-openid==2.2.5 23 | python3-openid==3.0.10 24 | pytz==2016.10 25 | requests==2.20.0 26 | requests-oauthlib==0.6.2 27 | six==1.10.0 28 | -------------------------------------------------------------------------------- /periods/static/periods/css/calendar.css: -------------------------------------------------------------------------------- 1 | .day-count { 2 | position: absolute; 3 | top: 0; 4 | margin: 0; 5 | padding: 2px; 6 | color: #700a0a; 7 | } 8 | 9 | .moon { 10 | background-color: white; 11 | background-position: center; 12 | background-size: 70%; 13 | background-repeat: no-repeat; 14 | } 15 | 16 | .new_moon { 17 | background-image: url('/static/periods/img/new_moon.png'); 18 | } 19 | 20 | .last_quarter { 21 | background-image: url('/static/periods/img/last_quarter.png'); 22 | } 23 | 24 | .full_moon { 25 | background-image: url('/static/periods/img/full_moon.png'); 26 | } 27 | 28 | .first_quarter { 29 | background-image: url('/static/periods/img/first_quarter.png'); 30 | } 31 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eggtimer", 3 | "authors": [ 4 | "Jessamyn Smithgood day
') 24 | 25 | self.assertEqual(True, result) 26 | mock_send.assert_called_once_with() 27 | -------------------------------------------------------------------------------- /periods/templates/periods/email/notification.txt: -------------------------------------------------------------------------------- 1 | Hello {{ full_name }}, 2 | 3 | This is an important notification about the data in your eggtimer account. 4 | 5 | Until now, eggtimer has been storing all data in Eastern time. As you may already be aware, 6 | this creates issues for users in other timezones. I am going to update the application so all 7 | data is stored in UTC. This may affect your data! 8 | 9 | If you are in Eastern time, your data will be migrated correctly, and you need do nothing. 10 | 11 | If you have been using eggtimer from another timezone, you have two options: 12 | 1) Before July 14, edit your user profile to select your timezone. When the data migration is 13 | performed, I will use the timezone on your profile. 14 | 2) Do nothing, and your data will be migrated as if it is in Eastern time. This will likely 15 | result in a time shift when you view your events. If desired, you can then edit events yourself. 16 | 17 | I apologize for the inconvenience. 18 | 19 | Sincerely, 20 | {{ admin_name }} 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Jessamyn Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /eggtimer/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for periodtracker project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | from django.core.wsgi import get_wsgi_application 19 | from whitenoise import WhiteNoise 20 | 21 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eggtimer.settings") 22 | 23 | # This application object is used by any WSGI server configured to use this 24 | # file. This includes Django's development server, if the WSGI_APPLICATION 25 | # setting points here. 26 | 27 | application = WhiteNoise(get_wsgi_application()) 28 | -------------------------------------------------------------------------------- /eggtimer/templates/account/email_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account bootstrap %} 5 | 6 | {% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %} 7 | 8 | 9 | {% block content %} 10 |{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}
17 | 18 | 24 | 25 | {% else %} 26 | 27 | {% url 'account_email' as email_url %} 28 | 29 |{% blocktrans %}This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request.{% endblocktrans %}
30 | 31 | {% endif %} 32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /periods/migrations/0004_flowevent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('periods', '0003_auto_20150222_1233'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='FlowEvent', 17 | fields=[ 18 | ('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)), 19 | ('timestamp', models.DateTimeField()), 20 | ('first_day', models.BooleanField(default=False)), 21 | ('level', models.IntegerField(default=2)), 22 | ('color', models.IntegerField(default=2)), 23 | ('clots', models.IntegerField(null=True, default=None, blank=True)), 24 | ('comment', models.TextField(max_length=250, null=True, blank=True)), 25 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='flow_events', null=True)), 26 | ], 27 | options={ 28 | }, 29 | bases=(models.Model,), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /periods/templates/periods/calendar.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}Periods for {{ user.get_full_name }}{% endblock %} 5 | 6 | {% block extra_head %} 7 | 9 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 | 14 | {% endblock %} 15 | 16 | {% block extra_js %} 17 | 18 | 19 | 20 | 21 | 22 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /eggtimer/templates/account/password_change.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n static bootstrap static %} 4 | 5 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 6 | 7 | {% block content %} 8 | 9 |{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}
12 | 13 | 14 | 41 | 42 | {% else %} 43 |{% trans 'You currently have no social network accounts connected to this account.' %}
44 | {% endif %} 45 | 46 |{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}
20 |39 | {% blocktrans with admin_email=ADMINS.0.1 %}Please 40 | contact us if you have any 41 | trouble resetting your 42 | password.{% endblocktrans %} 43 |
44 |{% blocktrans %}The password reset link was invalid, possibly because it has 24 | already been 25 | used. Please request a new password reset 26 | .{% endblocktrans %}
27 |{% trans 'Your password is now changed.' %}
44 | {% endif %} 45 | {% endif %} 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /eggtimer/templates/email/base.html: -------------------------------------------------------------------------------- 1 |
6 |
7 | egg timer
15 | Hello {{ full_name }},
16 |
17 | {% block content %}{% endblock %}
18 |
19 | Cheers!
20 |
21 | {{ admin_name }}
22 |
| Cycle Statistics | 24 ||
|---|---|
| Total Number: | 27 |{{ first_days|length }} | 28 |
| Most Recent: | 31 |{{ first_days|last }} | 32 |
| First: | 35 |{{ first_days|first }} | 36 |
| Cycle Length Statistics | 42 ||
|---|---|
| Average (Last 6 Months): | 45 |{{ user.statistics.average_cycle_length }} | 46 |
| Average (All Time): | 49 |{{ user.statistics.all_time_average_cycle_length }} | 50 |
| Minimum: | 53 |{{ user.statistics.cycle_length_minimum|default:"" }} | 54 |
| Maximum: | 57 |{{ user.statistics.cycle_length_maximum|default:"" }} | 58 |
| Mean: | 61 |{{ user.statistics.cycle_length_mean|default:"" }} | 62 |
| Median: | 65 |{{ user.statistics.cycle_length_median|default:"" }} | 66 |
| Mode: | 69 |{{ user.statistics.cycle_length_mode|default:"" }} | 70 |
| Standard Deviation: | 73 |{{ user.statistics.cycle_length_standard_deviation|default:"" }} | 74 |
" + currentDay + "
"); 68 | currentDay += 1; 69 | } 70 | }); 71 | }; 72 | 73 | getDefaultDate = function(moment, queryString) { 74 | var startDate = null; 75 | var endDate = null; 76 | var defaultDate = null; 77 | if (queryString && queryString.length) { 78 | var queries = queryString.substring(1).split("&"); 79 | for (var i = 0; i < queries.length; i++) { 80 | var parts = queries[i].split('='); 81 | if (parts[0] === "start") { 82 | startDate = moment(parts[1]); 83 | } 84 | if (parts[0] === "end") { 85 | endDate = moment(parts[1]); 86 | } 87 | } 88 | if (startDate && endDate) { 89 | defaultDate = startDate + (endDate - startDate) / 2; 90 | } 91 | } 92 | return defaultDate; 93 | }; 94 | 95 | doAjax = function(url, method, itemId, data) { 96 | console.log("Calling " + method + " on item " + itemId + " ..."); 97 | if (itemId !== null) { 98 | url += itemId + '/'; 99 | } 100 | $.ajax({ 101 | url: url, 102 | contentType: 'application/json', 103 | type: method, 104 | data: JSON.stringify(data), 105 | beforeSend: function(jqXHR, settings) { 106 | jqXHR.setRequestHeader("X-CSRFToken", Cookies.get('csrftoken')); 107 | }, 108 | success: function(data, textStatus, jqXHR) { 109 | console.log(method + " on " + itemId + " succeeded"); 110 | $('#id_calendar').fullCalendar('refetchEvents'); 111 | } 112 | }); 113 | }; 114 | 115 | editEvent = function(action, timezone, periodsUrl, flowEventUrl, itemId, itemDate) { 116 | var method = 'POST'; 117 | var buttons = []; 118 | if (action === 'Update') { 119 | method = 'PUT'; 120 | buttons.push({ 121 | id: 'btn-delete', 122 | label: 'Delete', 123 | cssClass: 'btn-warning', 124 | action: function(dialogRef) { 125 | BootstrapDialog.confirm('Are you sure you want to delete this event?', function(result) { 126 | if (result) { 127 | doAjax(periodsUrl, 'DELETE', itemId, {}); 128 | dialogRef.close(); 129 | } 130 | }); 131 | } 132 | }); 133 | } 134 | buttons.push({ 135 | id: 'btn-cancel', 136 | label: 'Cancel', 137 | cssClass: 'btn-cancel', 138 | autospin: false, 139 | action: function(dialogRef) { 140 | dialogRef.close(); 141 | } 142 | }, { 143 | id: 'btn-ok', 144 | label: action, 145 | cssClass: 'btn-primary', 146 | action: function(dialogRef) { 147 | var data = $("#id_period_form").serializeJSON(); 148 | // drf doesn't recognize 'on' 149 | data.first_day = data.first_day == 'on'; 150 | // Must convert timestamp to UTC since that is what server is expecting 151 | var localTimestamp = moment(data.timestamp).tz(timezone); 152 | data.timestamp = localTimestamp.tz('UTC').format(); 153 | doAjax(periodsUrl, method, itemId, data); 154 | dialogRef.close(); 155 | } 156 | }); 157 | BootstrapDialog.show({ 158 | title: action + ' event', 159 | message: function(dialog) { 160 | var message = ''; 161 | var data = {}; 162 | if (itemId) { 163 | flowEventUrl += itemId + '/'; 164 | } 165 | if (itemDate) { 166 | data.timestamp = itemDate.format(); 167 | } 168 | console.log("Getting flow event form from url " + flowEventUrl + 169 | " with data " + JSON.stringify(data)); 170 | $.ajax({ 171 | url: flowEventUrl, 172 | data: data, 173 | dataType: 'html', 174 | async: false, 175 | success: function(doc) { 176 | message = $('').append($(doc)); 177 | } 178 | }); 179 | return message; 180 | }, 181 | onshown: function(dialog) { 182 | addFormStyles(); 183 | }, 184 | closable: true, 185 | buttons: buttons 186 | }); 187 | }; 188 | 189 | var makeMoonPhaseEvents = function(responseData, moment, timezone) { 190 | var events = []; 191 | for (var i = 0; i < responseData.length; i++) { 192 | var event = { 193 | title: responseData[i].phase, 194 | start: timezoneDate(moment, timezone, responseData[i].date), 195 | allDay: true, 196 | className: 'moon ' + responseData[i].phase.toLowerCase().replace(' ', '_'), 197 | rendering: 'background' 198 | }; 199 | events.push(event); 200 | } 201 | return events; 202 | }; 203 | 204 | var initializeCalendar = function(periodsUrl, statisticsUrl, flowEventUrl, aerisUrl, timezone) { 205 | $('#id_calendar').fullCalendar({ 206 | timezone: timezone, 207 | defaultDate: getDefaultDate(moment, window.location.search), 208 | height: function() { 209 | return window.innerHeight - $('.my-navbar').outerHeight() - 210 | parseInt($('.content').css('marginTop')) - 211 | $('.footer').outerHeight() - 212 | 12; // Don't know where these extra pixels come from 213 | }, 214 | events: function(start, end, timezone, callback) { 215 | var startDate = formatMomentDate(start); 216 | var endDate = formatMomentDate(end); 217 | var data = { 218 | min_timestamp: startDate, 219 | max_timestamp: endDate 220 | }; 221 | $.getJSON(periodsUrl, data, function(periodData) { 222 | var newUrl = window.location.protocol + "//" + window.location.host + 223 | window.location.pathname + "?start=" + startDate + "&end=" + endDate; 224 | window.history.pushState({path: newUrl}, '', newUrl); 225 | $.getJSON(statisticsUrl, {min_timestamp: startDate}, function(statisticsData) { 226 | var events = makeEvents(moment, timezone, periodData.concat(statisticsData.predicted_events)); 227 | addDayCounts(events.periodStartDates, moment(statisticsData.first_date), 228 | statisticsData.first_day); 229 | // TODO Fetch and add these events later, after calendar has rendered 230 | $.getJSON(aerisUrl, data, function(aerisData) { 231 | if (aerisData.error) { 232 | console.log('aeris: ' + JSON.stringify(aerisData.error)); 233 | } else { 234 | if (aerisData.phasedata) { 235 | var moonPhaseEvents = makeMoonPhaseEvents(aerisData.phasedata, moment, timezone); 236 | console.log('moonPhaseEvents', moonPhaseEvents); 237 | events.events = events.events.concat(moonPhaseEvents); 238 | } 239 | } 240 | callback(events.events); 241 | }); 242 | }); 243 | }); 244 | }, 245 | dayClick: function(date, jsEvent, view) { 246 | var dayMoment = date; 247 | var now = moment().tz(timezone); 248 | if (dayMoment.date() == now.date()) { 249 | // If the entry is for the current day, use current time 250 | dayMoment = now; 251 | } else { 252 | // Convert to user's timezone while preserving exact time 253 | dayMoment = dayMoment.tz(timezone); 254 | dayMoment = dayMoment.add(-dayMoment.utcOffset(), 'minutes'); 255 | } 256 | editEvent('Add', timezone, periodsUrl, flowEventUrl, null, dayMoment); 257 | }, 258 | eventClick: function(event, jsEvent, view) { 259 | if (!event.itemId) { 260 | // This can happen if the user clicks on a projected event 261 | return; 262 | } 263 | editEvent('Update', timezone, periodsUrl, flowEventUrl, event.itemId, null); 264 | } 265 | }); 266 | }; 267 | -------------------------------------------------------------------------------- /periods/views.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | import datetime 3 | import itertools 4 | import math 5 | import pytz 6 | 7 | from django.conf import settings 8 | from django.contrib import auth 9 | from django.contrib.auth.mixins import LoginRequiredMixin 10 | from django.core.urlresolvers import reverse 11 | from django.http import HttpResponseRedirect 12 | from django.utils.dateparse import parse_datetime 13 | from django.views.generic import CreateView, TemplateView, UpdateView 14 | 15 | from extra_views import ModelFormSetView 16 | from jsonview.views import JsonView 17 | from rest_framework import permissions, status, viewsets 18 | from rest_framework.authtoken.models import Token 19 | from rest_framework.response import Response 20 | from rest_framework.views import APIView 21 | 22 | from periods import forms as period_forms, models as period_models, serializers 23 | 24 | 25 | class FlowEventViewSet(viewsets.ModelViewSet): 26 | serializer_class = serializers.FlowEventSerializer 27 | filter_class = serializers.FlowEventFilter 28 | 29 | def get_queryset(self): 30 | return period_models.FlowEvent.objects.filter(user=self.request.user) 31 | 32 | def perform_create(self, serializer): 33 | serializer.save(user=self.request.user) 34 | 35 | 36 | class StatisticsViewSet(viewsets.ModelViewSet): 37 | serializer_class = serializers.StatisticsSerializer 38 | 39 | def get_queryset(self): 40 | return period_models.Statistics.objects.filter(user=self.request.user) 41 | 42 | def list(self, request, *args, **kwargs): 43 | # Only return a single statistics object, for the authenticated user 44 | min_timestamp = request.query_params.get('min_timestamp') 45 | try: 46 | min_timestamp = datetime.datetime.strptime(min_timestamp, settings.API_DATE_FORMAT) 47 | min_timestamp = pytz.timezone(request.user.timezone.zone).localize(min_timestamp) 48 | except TypeError: 49 | min_timestamp = period_models.today() 50 | queryset = self.filter_queryset(self.get_queryset()) 51 | instance = queryset[0] 52 | instance.set_start_date_and_day(min_timestamp) 53 | serializer = self.get_serializer(instance) 54 | return Response(serializer.data) 55 | 56 | 57 | class ApiAuthenticateView(APIView): 58 | http_method_names = ['post'] 59 | permission_classes = [permissions.AllowAny] 60 | 61 | def post(self, request, *args, **kwargs): 62 | user = None 63 | error = '' 64 | status_code = status.HTTP_400_BAD_REQUEST 65 | 66 | try: 67 | email = request.data['email'] 68 | password = request.data['password'] 69 | user = auth.authenticate(username=email, password=password) 70 | if not user: 71 | status_code = status.HTTP_401_UNAUTHORIZED 72 | error = "Invalid credentials" 73 | except KeyError as e: 74 | error = "Missing required field '%s'" % e.args[0] 75 | 76 | if user: 77 | return Response({'token': user.auth_token.key}) 78 | 79 | return Response({'error': error}, status=status_code) 80 | 81 | 82 | class AerisView(LoginRequiredMixin, JsonView): 83 | def get_context_data(self, **kwargs): 84 | context = super(AerisView, self).get_context_data(**kwargs) 85 | from_date = self.request.GET.get('min_timestamp') 86 | to_date = self.request.GET.get('max_timestamp') 87 | data = period_models.AerisData.get_for_date(from_date, to_date) 88 | if data: 89 | context.update(data) 90 | return context 91 | 92 | 93 | class FlowEventMixin(LoginRequiredMixin): 94 | model = period_models.FlowEvent 95 | form_class = period_forms.PeriodForm 96 | 97 | def convert_to_user_timezone(self, timestamp): 98 | user_timezone = pytz.timezone(self.request.user.timezone.zone) 99 | return timestamp.astimezone(user_timezone) 100 | 101 | def set_to_utc(self, timestamp): 102 | return timestamp.replace(tzinfo=pytz.utc) 103 | 104 | def get_timestamp(self): 105 | # e.g. ?timestamp=2015-08-19T08:31:24-07:00 106 | timestamp = self.request.GET.get('timestamp') 107 | try: 108 | timestamp = parse_datetime(timestamp) 109 | except TypeError as e: 110 | print("Could not parse date: %s" % e) 111 | if not timestamp: 112 | timestamp = self.convert_to_user_timezone(period_models.today()) 113 | timestamp = self.set_to_utc(timestamp) 114 | return timestamp 115 | 116 | 117 | class FlowEventCreateView(FlowEventMixin, CreateView): 118 | def is_first_day(self, timestamp): 119 | yesterday = timestamp - datetime.timedelta(days=1) 120 | yesterday_start = yesterday.replace(hour=0, minute=0, second=0) 121 | yesterday_events = period_models.FlowEvent.objects.filter( 122 | timestamp__gte=yesterday_start, timestamp__lte=timestamp) 123 | if yesterday_events.count(): 124 | return False 125 | return True 126 | 127 | def get_initial(self): 128 | timestamp = self.get_timestamp() 129 | initial = { 130 | 'timestamp': timestamp, 131 | 'first_day': self.is_first_day(timestamp) 132 | } 133 | return initial 134 | 135 | 136 | class FlowEventUpdateView(FlowEventMixin, UpdateView): 137 | def get_object(self, queryset=None): 138 | obj = super(FlowEventUpdateView, self).get_object(queryset) 139 | obj.timestamp = self.set_to_utc(self.convert_to_user_timezone(obj.timestamp)) 140 | return obj 141 | 142 | 143 | class FlowEventFormSetView(LoginRequiredMixin, ModelFormSetView): 144 | model = period_models.FlowEvent 145 | exclude = ['user'] 146 | extra = 10 147 | 148 | def get_queryset(self): 149 | queryset = self.model.objects.filter(user=self.request.user).order_by('timestamp') 150 | return queryset 151 | 152 | 153 | class CalendarView(LoginRequiredMixin, TemplateView): 154 | template_name = 'periods/calendar.html' 155 | 156 | def get_context_data(self, **kwargs): 157 | context = super(CalendarView, self).get_context_data(**kwargs) 158 | context['periods_url'] = self.request.build_absolute_uri(reverse('periods-list')) 159 | context['statistics_url'] = self.request.build_absolute_uri(reverse('statistics-list')) 160 | context['flow_event_url'] = self.request.build_absolute_uri(reverse('flow_event_create')) 161 | context['aeris_url'] = self.request.build_absolute_uri(reverse('aeris')) 162 | return context 163 | 164 | 165 | class CycleLengthFrequencyView(LoginRequiredMixin, JsonView): 166 | def get_context_data(self, **kwargs): 167 | context = super(CycleLengthFrequencyView, self).get_context_data(**kwargs) 168 | cycle_lengths = self.request.user.get_cycle_lengths() 169 | cycles = [] 170 | if cycle_lengths: 171 | cycle_counter = Counter(cycle_lengths) 172 | cycles = list(zip(cycle_counter.keys(), cycle_counter.values())) 173 | context['cycles'] = cycles 174 | return context 175 | 176 | 177 | class CycleLengthHistoryView(LoginRequiredMixin, JsonView): 178 | def get_context_data(self, **kwargs): 179 | context = super(CycleLengthHistoryView, self).get_context_data(**kwargs) 180 | cycle_lengths = self.request.user.get_cycle_lengths() 181 | cycles = [] 182 | if cycle_lengths: 183 | first_days = list(self.request.user.first_days().values_list('timestamp', flat=True)) 184 | cycles = list(zip( 185 | [x.strftime(settings.API_DATE_FORMAT) for x in first_days], cycle_lengths)) 186 | context['cycles'] = cycles 187 | return context 188 | 189 | 190 | def _get_level(start_date, today, cycle_length): 191 | cycle_length_seconds = datetime.timedelta(days=cycle_length).total_seconds() 192 | current_phase = (today - start_date).total_seconds() / cycle_length_seconds 193 | # Standard sine starts at 0, with maximum of 1, minimum of -1, and period of 2pi 194 | # Our graph starts at 0, with maximum of 100, minimum of 0, and period of cycle_length 195 | # -0.5 radians shifts the graph 1/4 period to the right 196 | # +1 shifts the graph up 1 unit 197 | # *50 takes the max from 2 to 100 198 | return round(50 * (math.sin(math.pi * (2 * current_phase - 0.5)) + 1)) 199 | 200 | 201 | def _generate_cycles(start_date, today, end_date, cycle_length): 202 | current_date = start_date 203 | increment = datetime.timedelta(days=(cycle_length / 2.0)) 204 | values = itertools.cycle([0, 100]) 205 | cycles = [] 206 | while current_date < today: 207 | cycles.append([current_date, next(values)]) 208 | current_date += increment 209 | cycles.append([today, _get_level(start_date, today, cycle_length)]) 210 | while current_date < end_date: 211 | cycles.append([current_date, next(values)]) 212 | current_date += increment 213 | cycles.append([end_date, _get_level(start_date, end_date, cycle_length)]) 214 | return cycles 215 | 216 | 217 | class QigongCycleView(LoginRequiredMixin, JsonView): 218 | def get_context_data(self, **kwargs): 219 | context = super(QigongCycleView, self).get_context_data(**kwargs) 220 | today = period_models.today() 221 | end_date = period_models.today() + datetime.timedelta(days=14) 222 | user = self.request.user 223 | if user.birth_date: 224 | context['physical'] = _generate_cycles(user.birth_date, today, end_date, 23) 225 | context['emotional'] = _generate_cycles(user.birth_date, today, end_date, 28) 226 | context['intellectual'] = _generate_cycles(user.birth_date, today, end_date, 33) 227 | return context 228 | 229 | 230 | class StatisticsView(LoginRequiredMixin, TemplateView): 231 | template_name = 'periods/statistics.html' 232 | 233 | def get_context_data(self, **kwargs): 234 | context = super(StatisticsView, self).get_context_data(**kwargs) 235 | first_days = list(self.request.user.first_days().values_list('timestamp', flat=True)) 236 | graph_types = [ 237 | ['cycle_length_frequency', 'Cycle Length Frequency'], 238 | ['cycle_length_history', 'Cycle Length History'], 239 | ['qigong_cycles', 'Qigong Cycles'] 240 | ] 241 | # TODO days of bleeding, what else? 242 | context['first_days'] = first_days 243 | context['graph_types'] = graph_types 244 | return context 245 | 246 | 247 | # TODO give user option to delete account 248 | # TODO allow user to change email address? 249 | class ProfileUpdateView(LoginRequiredMixin, UpdateView): 250 | fields = ['first_name', 'last_name', 'send_emails', '_timezone', 'birth_date', 251 | 'luteal_phase_length'] 252 | 253 | def get_object(self, *args, **kwargs): 254 | return self.request.user 255 | 256 | def get_success_url(self): 257 | return reverse('user_profile') 258 | 259 | 260 | class ApiInfoView(LoginRequiredMixin, TemplateView): 261 | template_name = 'periods/api_info.html' 262 | 263 | def get_context_data(self, **kwargs): 264 | context = super(ApiInfoView, self).get_context_data(**kwargs) 265 | context['periods_url'] = self.request.build_absolute_uri(reverse('periods-list')) 266 | return context 267 | 268 | 269 | class RegenerateKeyView(LoginRequiredMixin, UpdateView): 270 | http_method_names = ['post'] 271 | 272 | def post(self, request, *args, **kwargs): 273 | Token.objects.filter(user=request.user).delete() 274 | Token.objects.create(user=request.user) 275 | 276 | return HttpResponseRedirect(reverse('api_info')) 277 | -------------------------------------------------------------------------------- /periods/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | import statistics 4 | 5 | from custom_user.models import AbstractEmailUser 6 | from django.conf import settings 7 | from django.contrib.auth.models import Group, Permission 8 | from django.contrib.postgres.fields import JSONField 9 | from django.core.cache import cache 10 | from django.db import models 11 | from django.db.models import signals 12 | from django.utils.translation import ugettext_lazy as _ 13 | from django_enumfield import enum 14 | 15 | from rest_framework.authtoken.models import Token 16 | from timezone_field import TimeZoneField 17 | import requests 18 | 19 | 20 | def today(): 21 | # Create helper method to allow mocking during tests 22 | return datetime.datetime.now(pytz.utc) 23 | 24 | 25 | # TODO break user out into user app 26 | class User(AbstractEmailUser): 27 | first_name = models.CharField(_('first name'), max_length=30, blank=True) 28 | last_name = models.CharField(_('last name'), max_length=30, blank=True) 29 | 30 | # TODO enter birth_date in user-specified timezone 31 | # Ugly workaround to prevent Django allauth from trying and failing to serialize timezone 32 | _timezone = TimeZoneField(default='America/New_York') 33 | send_emails = models.BooleanField(_('send emails'), default=True) 34 | birth_date = models.DateTimeField(_('birth date'), null=True, blank=True) 35 | luteal_phase_length = models.IntegerField(_('luteal phase length'), default=14) 36 | 37 | # Convenience method so we can use timezone rather than _timezone in most code 38 | @property 39 | def timezone(self): 40 | return self._timezone 41 | 42 | def first_days(self): 43 | return self.flow_events.filter(first_day=True).order_by('timestamp') 44 | 45 | def cycle_count(self): 46 | return self.first_days().count() 47 | 48 | def get_previous_period(self, previous_to): 49 | previous_periods = self.first_days().filter(timestamp__lte=previous_to) 50 | previous_periods = previous_periods.order_by('-timestamp') 51 | return previous_periods.first() 52 | 53 | def get_next_period(self, after=None): 54 | next_periods = self.first_days() 55 | if after: 56 | next_periods = next_periods.filter(timestamp__gte=after) 57 | next_periods = next_periods.order_by('timestamp') 58 | return next_periods.first() 59 | 60 | def get_cache_key(self, data_type): 61 | return 'user-%s-%s' % (self.pk, data_type) 62 | 63 | def get_cycle_lengths(self): 64 | key = self.get_cache_key('cycle_lengths') 65 | cycle_lengths = cache.get(key) 66 | if not cycle_lengths: 67 | cycle_lengths = [] 68 | first_days = self.first_days() 69 | if first_days.exists(): 70 | for i in range(1, self.cycle_count()): 71 | duration = first_days[i].timestamp.date() - first_days[i-1].timestamp.date() 72 | cycle_lengths.append(duration.days) 73 | cache.set(key, cycle_lengths) 74 | return cycle_lengths 75 | 76 | def get_sorted_cycle_lengths(self): 77 | key = self.get_cache_key('sorted_cycle_lengths') 78 | sorted_cycle_lengths = cache.get(key) 79 | if not sorted_cycle_lengths: 80 | sorted_cycle_lengths = sorted(self.get_cycle_lengths()) 81 | cache.set(key, sorted_cycle_lengths) 82 | return sorted_cycle_lengths 83 | 84 | def get_full_name(self): 85 | full_name = '%s %s' % (self.first_name, self.last_name) 86 | full_name = full_name.strip() 87 | if not full_name: 88 | full_name = self.email 89 | return full_name 90 | 91 | def get_short_name(self): 92 | short_name = self.first_name 93 | if not short_name: 94 | short_name = self.email 95 | return short_name 96 | 97 | def __str__(self): 98 | return "%s (%s)" % (self.get_full_name(), self.email) 99 | 100 | 101 | class LabelChoicesEnum(enum.Enum): 102 | @classmethod 103 | def choices(cls, blank=False): 104 | choices = super(LabelChoicesEnum, cls).choices(blank=blank) 105 | 106 | # Update choices to contain Enum labels rather than constant names 107 | for i in range(len(choices)): 108 | choices[i] = (choices[i][0], choices[i][1].label) 109 | 110 | return choices 111 | 112 | 113 | class FlowLevel(LabelChoicesEnum): 114 | SPOTTING = 0 115 | LIGHT = 1 116 | MEDIUM = 2 117 | HEAVY = 3 118 | VERY_HEAVY = 4 119 | 120 | __labels__ = { 121 | SPOTTING: _("Spotting"), 122 | LIGHT: _("Light"), 123 | MEDIUM: _("Medium"), 124 | HEAVY: _("Heavy"), 125 | VERY_HEAVY: _("Very Heavy"), 126 | } 127 | 128 | 129 | class FlowColor(LabelChoicesEnum): 130 | PINK = 0 131 | LIGHT_RED = 1 132 | RED = 2 133 | DARK_RED = 3 134 | BROWN = 4 135 | BLACK = 5 136 | 137 | __labels__ = { 138 | PINK: _("Pink"), 139 | LIGHT_RED: _("Light Red"), 140 | RED: _("Red"), 141 | DARK_RED: _("Dark Red"), 142 | BROWN: _("Brown"), 143 | BLACK: _("Black"), 144 | } 145 | 146 | 147 | class ClotSize(LabelChoicesEnum): 148 | SMALL = 0 149 | MEDIUM = 1 150 | LARGE = 2 151 | 152 | __labels__ = { 153 | SMALL: _("Small"), 154 | MEDIUM: _("Medium"), 155 | LARGE: _("Large"), 156 | } 157 | 158 | 159 | class CrampLevel(LabelChoicesEnum): 160 | SLIGHT = 0 161 | MODERATE = 1 162 | SEVERE = 2 163 | 164 | __labels__ = { 165 | SLIGHT: _("Slight"), 166 | MODERATE: _("Moderate"), 167 | SEVERE: _("Severe"), 168 | } 169 | 170 | 171 | class FlowEvent(models.Model): 172 | user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='flow_events', null=True) 173 | timestamp = models.DateTimeField() 174 | first_day = models.BooleanField(default=False) 175 | level = enum.EnumField(FlowLevel, default=FlowLevel.MEDIUM) 176 | color = enum.EnumField(FlowColor, default=FlowColor.RED) 177 | clots = enum.EnumField(ClotSize, default=None, null=True, blank=True) 178 | cramps = enum.EnumField(CrampLevel, default=None, null=True, blank=True) 179 | comment = models.CharField(max_length=250, null=True, blank=True) 180 | 181 | def __str__(self): 182 | return "%s %s (%s)" % (self.user.get_full_name(), FlowLevel.label(self.level), 183 | self.timestamp) 184 | 185 | 186 | class Statistics(models.Model): 187 | 188 | class Meta: 189 | verbose_name_plural = "statistics" 190 | 191 | user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='statistics', null=True) 192 | average_cycle_length = models.IntegerField(default=28) 193 | all_time_average_cycle_length = models.IntegerField(default=28) 194 | 195 | def _get_ordinal_value(self, index): 196 | value = None 197 | # Could cache this as performance optimization 198 | sorted_cycle_lengths = self.user.get_sorted_cycle_lengths() 199 | if len(sorted_cycle_lengths) >= 1: 200 | value = sorted_cycle_lengths[index] 201 | return value 202 | 203 | @property 204 | def cycle_length_minimum(self): 205 | return self._get_ordinal_value(0) 206 | 207 | @property 208 | def cycle_length_maximum(self): 209 | return self._get_ordinal_value(-1) 210 | 211 | def _get_statistics_value(self, method_name, num_values_required=1): 212 | value = None 213 | cycle_lengths = self.user.get_cycle_lengths() 214 | if len(cycle_lengths) >= num_values_required: 215 | value = method_name(cycle_lengths) 216 | return value 217 | 218 | @property 219 | def cycle_length_mean(self): 220 | mean = self._get_statistics_value(statistics.mean) 221 | if mean: 222 | mean = round(float(mean), 1) 223 | return mean 224 | 225 | @property 226 | def cycle_length_median(self): 227 | return self._get_statistics_value(statistics.median) 228 | 229 | @property 230 | def cycle_length_mode(self): 231 | try: 232 | return self._get_statistics_value(statistics.mode) 233 | except statistics.StatisticsError: 234 | return None 235 | 236 | @property 237 | def cycle_length_standard_deviation(self): 238 | std_dev = self._get_statistics_value(statistics.stdev, 2) 239 | if std_dev: 240 | std_dev = round(std_dev, 3) 241 | return std_dev 242 | 243 | @property 244 | def current_cycle_length(self): 245 | current_cycle = -1 246 | today_date = today() 247 | previous_period = self.user.get_previous_period(previous_to=today_date) 248 | if previous_period: 249 | current_cycle = (today_date.date() - previous_period.timestamp.date()).days 250 | return current_cycle 251 | 252 | @property 253 | def first_date(self): 254 | if not hasattr(self, '_first_date'): 255 | self._first_date = '' 256 | return self._first_date 257 | 258 | @property 259 | def first_day(self): 260 | if not hasattr(self, '_first_day'): 261 | self._first_day = '' 262 | return self._first_day 263 | 264 | def set_start_date_and_day(self, min_timestamp): 265 | previous_period = self.user.get_previous_period(min_timestamp) 266 | next_period = self.user.get_next_period(min_timestamp) 267 | if previous_period: 268 | self._first_date = min_timestamp.date() 269 | self._first_day = (self._first_date - previous_period.timestamp.date()).days + 1 270 | elif next_period: 271 | self._first_date = next_period.timestamp.date() 272 | self._first_day = 1 273 | 274 | @property 275 | def predicted_events(self): 276 | events = [] 277 | today_date = today() 278 | previous_period = self.user.get_previous_period(previous_to=today_date) 279 | if previous_period: 280 | for i in range(1, 4): 281 | ovulation_date = (previous_period.timestamp + datetime.timedelta( 282 | days=i*self.average_cycle_length - self.user.luteal_phase_length)).date() 283 | events.append({'timestamp': ovulation_date, 'type': 'projected ovulation'}) 284 | period_date = (previous_period.timestamp + datetime.timedelta( 285 | days=i*self.average_cycle_length)).date() 286 | events.append({'timestamp': period_date, 'type': 'projected period'}) 287 | return events 288 | 289 | def __str__(self): 290 | return "%s (%s)" % (self.user.get_full_name(), self.user.email) 291 | 292 | 293 | class AerisData(models.Model): 294 | # Called AerisData for historical reasons; now pulling data from US Navy API 295 | # http://aa.usno.navy.mil/data/docs/api.php#phase 296 | to_date = models.DateField(unique=True) 297 | data = JSONField() 298 | 299 | @staticmethod 300 | def get_from_server(from_date): 301 | moon_phase_url = '{}/moon/phase'.format(settings.MOON_PHASE_URL) 302 | from_date_obj = datetime.datetime.strptime(from_date, settings.API_DATE_FORMAT) 303 | from_date_us_format = from_date_obj.strftime(settings.US_DATE_FORMAT) 304 | params = { 305 | 'nump': 8, 306 | 'date': from_date_us_format, 307 | } 308 | try: 309 | result = requests.get(moon_phase_url, params) 310 | result = result.json() 311 | except requests.exceptions.ConnectionError: 312 | result = {'error': 'Unable to reach Moon Phase API'} 313 | return result 314 | 315 | @classmethod 316 | def get_for_date(cls, from_date, to_date): 317 | existing = cls.objects.filter(to_date=to_date) 318 | if existing.count() > 0: 319 | data = existing[0].data 320 | else: 321 | data = cls.get_from_server(from_date) 322 | if data and not data['error']: 323 | cls.objects.create(to_date=to_date, data=data) 324 | return data 325 | 326 | 327 | def create_auth_token(sender, instance=None, created=False, **kwargs): 328 | if created: 329 | Token.objects.create(user=instance) 330 | 331 | 332 | def add_to_permissions_group(sender, instance, **kwargs): 333 | try: 334 | group = Group.objects.get(name='users') 335 | except Group.DoesNotExist: 336 | group = Group(name='users') 337 | group.save() 338 | group.permissions.add(*Permission.objects.filter(codename__endswith='_flowevent').all()) 339 | group.user_set.add(instance) 340 | group.save() 341 | 342 | 343 | def create_statistics(sender, instance, **kwargs): 344 | if not hasattr(instance, 'statistics'): 345 | stats = Statistics(user=instance) 346 | stats.save() 347 | 348 | 349 | def update_statistics(sender, instance, **kwargs): 350 | try: 351 | stats = Statistics.objects.get(user=instance.user) 352 | except (Statistics.DoesNotExist, User.DoesNotExist): 353 | # There may not be statistics, for example when deleting a user 354 | return 355 | 356 | cache.delete(instance.user.get_cache_key('cycle_lengths')) 357 | cache.delete(instance.user.get_cache_key('sorted_cycle_lengths')) 358 | 359 | cycle_lengths = instance.user.get_cycle_lengths() 360 | # Calculate average (if possible) and update statistics object 361 | if len(cycle_lengths) > 0: 362 | recent_cycle_lengths = cycle_lengths[-6:] 363 | avg = sum(recent_cycle_lengths) / len(recent_cycle_lengths) 364 | stats.average_cycle_length = int(round(avg)) 365 | avg = sum(cycle_lengths) / len(cycle_lengths) 366 | stats.all_time_average_cycle_length = int(round(avg)) 367 | stats.save() 368 | 369 | 370 | signals.post_save.connect(create_auth_token, sender=settings.AUTH_USER_MODEL) 371 | signals.post_save.connect(add_to_permissions_group, sender=settings.AUTH_USER_MODEL) 372 | signals.post_save.connect(create_statistics, sender=settings.AUTH_USER_MODEL) 373 | 374 | signals.post_save.connect(update_statistics, sender=FlowEvent) 375 | signals.post_delete.connect(update_statistics, sender=FlowEvent) 376 | -------------------------------------------------------------------------------- /periods/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | import re 4 | import requests 5 | 6 | from django.conf import settings 7 | from django.contrib.auth import models as auth_models 8 | from django.test import TestCase 9 | from mock import MagicMock, patch 10 | 11 | from periods import models as period_models 12 | from periods.tests.factories import FlowEventFactory, UserFactory 13 | 14 | TIMEZONE = pytz.timezone("US/Eastern") 15 | 16 | 17 | class TestUser(TestCase): 18 | def setUp(self): 19 | self.user = UserFactory() 20 | self.basic_user = UserFactory.build(first_name='') 21 | 22 | self.period = FlowEventFactory() 23 | FlowEventFactory(user=self.period.user, 24 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 2, 27))) 25 | FlowEventFactory(user=self.period.user, 26 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 3, 24))) 27 | 28 | def test_get_cycle_lengths_no_data(self): 29 | self.assertEqual([], self.basic_user.get_cycle_lengths()) 30 | 31 | def test_get_cycle_lengths(self): 32 | self.assertEqual([27, 25], self.period.user.get_cycle_lengths()) 33 | 34 | def test_get_sorted_cycle_lengths_no_data(self): 35 | self.assertEqual([], self.basic_user.get_sorted_cycle_lengths()) 36 | 37 | def test_get_sorted_cycle_lengths(self): 38 | self.assertEqual([25, 27], self.period.user.get_sorted_cycle_lengths()) 39 | 40 | def test_get_full_name_email(self): 41 | self.assertTrue(re.match(r'user_[\d]+@example.com', '%s' % self.basic_user.get_full_name())) 42 | 43 | def test_get_full_name(self): 44 | self.assertEqual(u'Jessamyn', '%s' % self.user.get_full_name()) 45 | 46 | def test_get_short_name_email(self): 47 | self.assertTrue(re.match(r'user_[\d]+@example.com', 48 | '%s' % self.basic_user.get_short_name())) 49 | 50 | def test_get_short_name(self): 51 | self.assertEqual(u'Jessamyn', '%s' % self.user.get_short_name()) 52 | 53 | def test_str(self): 54 | self.assertTrue(re.match(r'user_[\d]+@example.com \(user_[\d]+@example.com\)', 55 | '%s' % self.basic_user)) 56 | 57 | 58 | class TestFlowEvent(TestCase): 59 | def setUp(self): 60 | self.period = FlowEventFactory() 61 | 62 | def test_str(self): 63 | self.assertEqual('Jessamyn Medium (2014-01-31 17:00:00+00:00)', '%s' % self.period) 64 | 65 | 66 | class TestStatistics(TestCase): 67 | def setUp(self): 68 | self.user = UserFactory() 69 | self.period = FlowEventFactory() 70 | 71 | def test_str(self): 72 | stats = period_models.Statistics.objects.filter(user=self.user).first() 73 | 74 | self.assertEqual(u'Jessamyn (', ('%s' % stats)[:10]) 75 | 76 | def test_with_average(self): 77 | FlowEventFactory(user=self.period.user, 78 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 2, 15))) 79 | FlowEventFactory(user=self.period.user, 80 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 3, 15))) 81 | FlowEventFactory(user=self.period.user, 82 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 4, 10))) 83 | 84 | stats = period_models.Statistics.objects.filter(user=self.period.user).first() 85 | 86 | self.assertEqual(u'Jessamyn (', ('%s' % stats)[:10]) 87 | self.assertEqual(23, stats.average_cycle_length) 88 | self.assertEqual(15, stats.cycle_length_minimum) 89 | self.assertEqual(28, stats.cycle_length_maximum) 90 | self.assertEqual(23, stats.cycle_length_mean) 91 | self.assertEqual(26, stats.cycle_length_median) 92 | self.assertEqual(None, stats.cycle_length_mode) 93 | self.assertEqual(7.0, stats.cycle_length_standard_deviation) 94 | expected_events = [{'timestamp': datetime.date(2014, 4, 19), 'type': 'projected ovulation'}, 95 | {'timestamp': datetime.date(2014, 5, 3), 'type': 'projected period'}, 96 | {'timestamp': datetime.date(2014, 5, 12), 'type': 'projected ovulation'}, 97 | {'timestamp': datetime.date(2014, 5, 26), 'type': 'projected period'}, 98 | {'timestamp': datetime.date(2014, 6, 4), 'type': 'projected ovulation'}, 99 | {'timestamp': datetime.date(2014, 6, 18), 'type': 'projected period'}] 100 | self.assertEqual(expected_events, stats.predicted_events) 101 | 102 | def test_current_cycle_length_no_periods(self): 103 | stats = period_models.Statistics.objects.filter(user=self.user).first() 104 | 105 | self.assertEqual(-1, stats.current_cycle_length) 106 | self.assertEqual(28, stats.average_cycle_length) 107 | self.assertEqual(None, stats.cycle_length_minimum) 108 | self.assertEqual(None, stats.cycle_length_maximum) 109 | self.assertEqual(None, stats.cycle_length_mean) 110 | self.assertEqual(None, stats.cycle_length_median) 111 | self.assertEqual(None, stats.cycle_length_mode) 112 | self.assertEqual(None, stats.cycle_length_standard_deviation) 113 | self.assertEqual([], stats.predicted_events) 114 | 115 | def test_set_start_date_and_day_no_periods(self): 116 | stats = period_models.Statistics.objects.filter(user=self.user).first() 117 | min_timestamp = TIMEZONE.localize(datetime.datetime(2014, 2, 12)) 118 | 119 | stats.set_start_date_and_day(min_timestamp) 120 | 121 | self.assertEqual('', stats.first_date) 122 | self.assertEqual('', stats.first_day) 123 | 124 | def test_set_start_date_and_day_previous_exists(self): 125 | stats = period_models.Statistics.objects.filter(user=self.period.user).first() 126 | min_timestamp = TIMEZONE.localize(datetime.datetime(2014, 2, 12)) 127 | 128 | stats.set_start_date_and_day(min_timestamp) 129 | 130 | self.assertEqual(datetime.date(2014, 2, 12), stats.first_date) 131 | self.assertEqual(13, stats.first_day) 132 | 133 | def test_set_start_date_and_day_next_exists(self): 134 | stats = period_models.Statistics.objects.filter(user=self.period.user).first() 135 | min_timestamp = TIMEZONE.localize(datetime.datetime(2014, 1, 12)) 136 | 137 | stats.set_start_date_and_day(min_timestamp) 138 | 139 | self.assertEqual(datetime.date(2014, 1, 31), stats.first_date) 140 | self.assertEqual(1, stats.first_day) 141 | 142 | 143 | class TestAerisData(TestCase): 144 | AERIS_DATA = {'error': None, 'response': [ 145 | {'timestamp': 1475280794, 'dateTimeISO': '2016-10-01T00:13:14+00:00', 'code': 0, 146 | 'name': 'new moon'}, 147 | {'timestamp': 1475987714, 'dateTimeISO': '2016-10-09T04:35:14+00:00', 'code': 1, 148 | 'name': 'first quarter'}, 149 | {'timestamp': 1476591907, 'dateTimeISO': '2016-10-16T04:25:07+00:00', 'code': 2, 150 | 'name': 'full moon'}, 151 | {'timestamp': 1477163774, 'dateTimeISO': '2016-10-22T19:16:14+00:00', 'code': 3, 152 | 'name': 'last quarter'}, 153 | {'timestamp': 1477849193, 'dateTimeISO': '2016-10-30T17:39:53+00:00', 'code': 0, 154 | 'name': 'new moon'}], 'success': True} 155 | 156 | def setUp(self): 157 | self.from_date = datetime.datetime(2016, 9, 25).strftime(settings.API_DATE_FORMAT) 158 | 159 | @patch('requests.get') 160 | def test_get_from_server(self, mock_get): 161 | mock_get.return_value = MagicMock(json=lambda: self.AERIS_DATA) 162 | 163 | result = period_models.AerisData.get_from_server(self.from_date) 164 | 165 | self.assertEqual(self.AERIS_DATA, result) 166 | 167 | @patch('requests.get') 168 | def test_get_from_server_error(self, mock_get): 169 | mock_get.side_effect = requests.exceptions.ConnectionError() 170 | 171 | result = period_models.AerisData.get_from_server(self.from_date) 172 | 173 | self.assertEqual({'error': 'Unable to reach Moon Phase API'}, result) 174 | 175 | @patch('requests.get') 176 | def test_get_for_date_not_cached_request_failure(self, mock_get): 177 | mock_get.return_value = MagicMock(json=lambda: {}) 178 | to_date = datetime.datetime(2016, 11, 6) 179 | num_previous = period_models.AerisData.objects.count() 180 | 181 | result = period_models.AerisData.get_for_date(self.from_date, to_date) 182 | 183 | self.assertEqual({}, result) 184 | num_current = period_models.AerisData.objects.count() 185 | self.assertEqual(num_previous, num_current) 186 | 187 | @patch('requests.get') 188 | def test_get_for_date_not_cached_request_success(self, mock_get): 189 | mock_get.return_value = MagicMock(json=lambda: self.AERIS_DATA) 190 | to_date = datetime.datetime(2016, 11, 6) 191 | num_previous = period_models.AerisData.objects.count() 192 | 193 | result = period_models.AerisData.get_for_date(self.from_date, to_date) 194 | 195 | self.assertEqual(self.AERIS_DATA, result) 196 | num_current = period_models.AerisData.objects.count() 197 | self.assertEqual(num_previous + 1, num_current) 198 | 199 | @patch('requests.get') 200 | def test_get_for_date_cached(self, mock_get): 201 | from_date = datetime.datetime(2016, 9, 25) 202 | to_date = datetime.datetime(2016, 11, 6) 203 | period_models.AerisData.objects.create(to_date=to_date, data=self.AERIS_DATA) 204 | num_previous = period_models.AerisData.objects.count() 205 | 206 | result = period_models.AerisData.get_for_date(from_date, to_date) 207 | 208 | self.assertEqual(self.AERIS_DATA, result) 209 | num_current = period_models.AerisData.objects.count() 210 | self.assertEqual(num_previous, num_current) 211 | self.assertEqual(0, mock_get.call_count) 212 | 213 | 214 | class TestSignals(TestCase): 215 | maxDiff = None 216 | 217 | def setUp(self): 218 | self.user = UserFactory() 219 | self.period = FlowEventFactory() 220 | 221 | def test_add_to_permissions_group_group_does_not_exist(self): 222 | self.user.groups.all().delete() 223 | 224 | period_models.add_to_permissions_group(period_models.User, self.user) 225 | 226 | groups = self.user.groups.all() 227 | self.assertEqual(1, groups.count()) 228 | self.assertEqual(3, groups.first().permissions.count()) 229 | for permission in groups.first().permissions.all(): 230 | self.assertEqual('_flowevent', permission.codename[-10:]) 231 | 232 | def test_add_to_permissions_group_group_exists(self): 233 | user = period_models.User(email='jane@jane.com', 234 | last_login=TIMEZONE.localize(datetime.datetime(2015, 2, 27))) 235 | user.save() 236 | user.groups.all().delete() 237 | auth_models.Group(name='users').save() 238 | 239 | period_models.add_to_permissions_group(period_models.User, user) 240 | 241 | groups = user.groups.all() 242 | self.assertEqual(1, groups.count()) 243 | self.assertEqual(0, groups.first().permissions.count()) 244 | 245 | @patch('periods.models.Statistics.save') 246 | def test_update_statistics_deleted_user(self, mock_save): 247 | self.period.user.delete() 248 | pre_update_call_count = mock_save.call_count 249 | 250 | period_models.update_statistics(period_models.FlowEvent, self.period) 251 | 252 | self.assertEqual(pre_update_call_count, mock_save.call_count) 253 | 254 | @patch('periods.models.today') 255 | def test_update_statistics_none_existing(self, mock_today): 256 | mock_today.return_value = pytz.utc.localize(datetime.datetime(2014, 4, 5)) 257 | period = FlowEventFactory(user=self.period.user, 258 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 2, 27))) 259 | 260 | period_models.update_statistics(period_models.FlowEvent, period) 261 | 262 | stats = period_models.Statistics.objects.get(user=self.period.user) 263 | self.assertEqual(27, stats.average_cycle_length) 264 | self.assertEqual(27, stats.all_time_average_cycle_length) 265 | self.assertEqual(37, stats.current_cycle_length) 266 | expected_events = [{'timestamp': datetime.date(2014, 3, 12), 'type': 'projected ovulation'}, 267 | {'timestamp': datetime.date(2014, 3, 26), 'type': 'projected period'}, 268 | {'timestamp': datetime.date(2014, 4, 8), 'type': 'projected ovulation'}, 269 | {'timestamp': datetime.date(2014, 4, 22), 'type': 'projected period'}, 270 | {'timestamp': datetime.date(2014, 5, 5), 'type': 'projected ovulation'}, 271 | {'timestamp': datetime.date(2014, 5, 19), 'type': 'projected period'}] 272 | self.assertEqual(expected_events, stats.predicted_events) 273 | 274 | @patch('periods.models.today') 275 | def test_update_statistics_periods_exist(self, mock_today): 276 | mock_today.return_value = TIMEZONE.localize(datetime.datetime(2014, 4, 5)) 277 | FlowEventFactory(user=self.period.user, 278 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 2, 14))) 279 | period = FlowEventFactory(user=self.period.user, 280 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 2, 28))) 281 | FlowEventFactory(user=self.period.user, 282 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 3, 14))) 283 | 284 | period_models.update_statistics(period_models.FlowEvent, period) 285 | 286 | stats = period_models.Statistics.objects.get(user=self.period.user) 287 | self.assertEqual(14, stats.average_cycle_length) 288 | self.assertEqual(14, stats.all_time_average_cycle_length) 289 | self.assertEqual(22, stats.current_cycle_length) 290 | expected_events = [{'timestamp': datetime.date(2014, 3, 14), 'type': 'projected ovulation'}, 291 | {'timestamp': datetime.date(2014, 3, 28), 'type': 'projected period'}, 292 | {'timestamp': datetime.date(2014, 3, 28), 'type': 'projected ovulation'}, 293 | {'timestamp': datetime.date(2014, 4, 11), 'type': 'projected period'}, 294 | {'timestamp': datetime.date(2014, 4, 11), 'type': 'projected ovulation'}, 295 | {'timestamp': datetime.date(2014, 4, 25), 'type': 'projected period'}] 296 | self.assertEqual(expected_events, stats.predicted_events) 297 | -------------------------------------------------------------------------------- /periods/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import pytz 4 | 5 | from django.core.urlresolvers import reverse 6 | from django.http import HttpRequest, QueryDict, Http404 7 | from django.test import Client, TestCase 8 | from mock import patch 9 | from rest_framework.request import Request 10 | from rest_framework.authtoken.models import Token 11 | 12 | from periods import models as period_models, views 13 | from periods.serializers import FlowEventSerializer 14 | from periods.tests.factories import FlowEventFactory, UserFactory, PASSWORD 15 | 16 | 17 | class LoggedInUserTestCase(TestCase): 18 | 19 | def setUp(self): 20 | self.user = UserFactory() 21 | self.client = Client() 22 | self.client.login(email=self.user.email, password=PASSWORD) 23 | 24 | 25 | class TestFlowEventViewSet(TestCase): 26 | 27 | def setUp(self): 28 | self.view_set = views.FlowEventViewSet() 29 | self.view_set.format_kwarg = '' 30 | 31 | self.period = FlowEventFactory() 32 | FlowEventFactory(timestamp=pytz.utc.localize(datetime.datetime(2014, 2, 28))) 33 | 34 | self.request = Request(HttpRequest()) 35 | self.request.__setattr__('user', self.period.user) 36 | self.view_set.request = self.request 37 | 38 | def test_list(self): 39 | response = self.view_set.list(self.request) 40 | 41 | self.assertEqual(1, len(response.data)) 42 | self.assertEqual(self.period.id, response.data[0]['id']) 43 | 44 | def test_perform_create(self): 45 | serializer = FlowEventSerializer(data={'timestamp': datetime.datetime(2015, 1, 1)}) 46 | serializer.is_valid() 47 | 48 | self.view_set.perform_create(serializer) 49 | 50 | self.assertEqual(self.request.user, serializer.instance.user) 51 | 52 | 53 | class TestStatisticsViewSet(TestCase): 54 | 55 | def setUp(self): 56 | self.view_set = views.StatisticsViewSet() 57 | self.view_set.format_kwarg = '' 58 | 59 | self.period = FlowEventFactory() 60 | self.period2 = FlowEventFactory(timestamp=pytz.utc.localize(datetime.datetime(2014, 2, 28))) 61 | 62 | self.request = Request(HttpRequest()) 63 | self.request.__setattr__('user', self.period.user) 64 | self.view_set.request = self.request 65 | 66 | def test_retrieve_other_user(self): 67 | self.view_set.kwargs = {'pk': self.period2.user.statistics.pk} 68 | 69 | try: 70 | self.view_set.retrieve(self.request) 71 | self.fail("Should not be able to retrieve another user's statistics") 72 | except Http404: 73 | pass 74 | 75 | @patch('periods.models.today') 76 | def test_list_no_params(self, mock_today): 77 | mock_today.return_value = pytz.utc.localize(datetime.datetime(2014, 1, 5)) 78 | self.view_set.kwargs = {'pk': self.request.user.statistics.pk} 79 | 80 | response = self.view_set.list(self.request) 81 | 82 | self.assertEqual(4, len(response.data)) 83 | self.assertEqual(28, response.data['average_cycle_length']) 84 | self.assertEqual(self.period.timestamp.date(), response.data['first_date']) 85 | self.assertEqual(1, response.data['first_day']) 86 | 87 | def test_list_with_min_timestamp(self): 88 | http_request = HttpRequest() 89 | http_request.GET = QueryDict(u'min_timestamp=2014-01-05') 90 | request = Request(http_request) 91 | request.__setattr__('user', self.period.user) 92 | self.view_set.kwargs = {'pk': self.request.user.statistics.pk} 93 | 94 | response = self.view_set.list(request) 95 | 96 | self.assertEqual(4, len(response.data)) 97 | self.assertEqual(28, response.data['average_cycle_length']) 98 | self.assertEqual(self.period.timestamp.date(), response.data['first_date']) 99 | self.assertEqual(1, response.data['first_day']) 100 | 101 | 102 | class TestAerisView(LoggedInUserTestCase): 103 | maxDiff = None 104 | 105 | def setUp(self): 106 | super(TestAerisView, self).setUp() 107 | self.url_path = reverse('aeris') 108 | 109 | @patch('periods.models.AerisData.get_for_date') 110 | def test_get_no_data(self, mock_get_for_date): 111 | mock_get_for_date.return_value = {} 112 | 113 | response = self.client.get(self.url_path) 114 | 115 | self.assertEqual(200, response.status_code) 116 | self.assertEqual({}, response.json()) 117 | 118 | @patch('periods.models.AerisData.get_for_date') 119 | def test_get_with_data(self, mock_get_for_date): 120 | data = {'success': 'true'} 121 | mock_get_for_date.return_value = data 122 | to_date = datetime.date(2014, 2, 28) 123 | 124 | response = self.client.get(self.url_path, {'to_date': to_date}) 125 | 126 | self.assertEqual(200, response.status_code) 127 | self.assertEqual(data, response.json()) 128 | self.assertTrue(mock_get_for_date.called_once_with(None, to_date)) 129 | 130 | 131 | class TestFlowEventMixin(LoggedInUserTestCase): 132 | 133 | def setUp(self): 134 | self.mixin = views.FlowEventMixin() 135 | 136 | self.request = HttpRequest() 137 | self.request.user = UserFactory() 138 | self.mixin.request = self.request 139 | 140 | @patch('periods.models.today') 141 | def test_get_timestamp_error(self, mock_today): 142 | mock_today.return_value = pytz.utc.localize(datetime.datetime(2014, 2, 3)) 143 | 144 | timestamp = self.mixin.get_timestamp() 145 | 146 | self.assertEqual(datetime.datetime(2014, 2, 2, 19, tzinfo=pytz.UTC), timestamp) 147 | 148 | def test_get_timestamp_success(self): 149 | self.request.GET = QueryDict('timestamp=2016-11-30T00:00:00%2B00:00') 150 | 151 | timestamp = self.mixin.get_timestamp() 152 | 153 | self.assertEqual(datetime.datetime(2016, 11, 30, tzinfo=pytz.UTC), timestamp) 154 | 155 | 156 | class TestApiAuthenticate(TestCase): 157 | 158 | def setUp(self): 159 | self.client = Client() 160 | self.url_path = '/api/v2/authenticate/' 161 | self.data = {"email": "jane@jane.com", "password": "somepass"} 162 | 163 | def test_api_authenticate_get(self): 164 | response = self.client.get(self.url_path) 165 | 166 | self.assertEqual(405, response.status_code) 167 | 168 | def test_api_authenticate_missing_fields(self): 169 | response = self.client.post(self.url_path, data=json.dumps({}), 170 | content_type='application/json') 171 | 172 | self.assertEqual(400, response.status_code) 173 | self.assertEqual({"error": "Missing required field \'email\'"}, response.json()) 174 | 175 | def test_api_authenticate_failure(self): 176 | response = self.client.post(self.url_path, data=json.dumps(self.data), 177 | content_type='application/json') 178 | 179 | self.assertEqual(401, response.status_code) 180 | self.assertEqual({"error": "Invalid credentials"}, response.json()) 181 | 182 | @patch('django.contrib.auth.authenticate') 183 | def test_api_authenticate_success(self, mock_authenticate): 184 | user = UserFactory() 185 | mock_authenticate.return_value = user 186 | 187 | response = self.client.post(self.url_path, data=json.dumps(self.data), 188 | content_type='application/json') 189 | 190 | self.assertContains(response, user.auth_token.key) 191 | 192 | 193 | class TestFlowEventViews(LoggedInUserTestCase): 194 | maxDiff = None 195 | 196 | def setUp(self): 197 | super(TestFlowEventViews, self).setUp() 198 | self.url_path = reverse('flow_event_create') 199 | self.period = FlowEventFactory(user=self.user) 200 | 201 | @patch('periods.models.today') 202 | def test_create_no_parameters(self, mock_today): 203 | mock_today.return_value = pytz.utc.localize(datetime.datetime(2014, 2, 3)) 204 | 205 | response = self.client.get(self.url_path) 206 | 207 | self.assertEqual(200, response.status_code) 208 | self.assertContains(response, '