├── 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 |
3 | {{ form|bootstrap }} 4 |
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 | Page unavailable 5 | 6 | 7 | 8 |

Page unavailable

9 | 10 |

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 |
7 |

Page not found

8 | 9 |

Sorry, but the requested page could not be found.

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | DJANGO_DEBUG=1 2 | DJANGO_ENABLE_SSL=0 3 | 4 | ADMIN_NAME=egg timer 5 | ADMIN_EMAIL= 6 | DJANGO_SECRET_KEY= 7 | REPLY_TO_EMAIL= 8 | SENDGRID_API_KEY= 9 | DEPLOY_DATE=2023-01-24T20:49:00+0000 10 | DATABASE_URL=postgres://:@localhost:5432/ 11 | -------------------------------------------------------------------------------- /eggtimer/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | from periods import urls as period_urls 5 | 6 | 7 | urlpatterns = [ 8 | url(r'^admin/', include(admin.site.urls)), 9 | url(r'^', include(period_urls), name='periods'), 10 | ] 11 | -------------------------------------------------------------------------------- /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", "eggtimer.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /periods/helpers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sites.models import Site 3 | 4 | 5 | def get_full_domain(): 6 | scheme = 'https' 7 | if not settings.SECURE_SSL_REDIRECT: 8 | scheme = 'http' 9 | return '%s://%s' % (scheme, Site.objects.get_current().domain) 10 | -------------------------------------------------------------------------------- /periods/forms.py: -------------------------------------------------------------------------------- 1 | import floppyforms.__future__ as forms 2 | 3 | from periods import models as period_models 4 | 5 | 6 | class PeriodForm(forms.ModelForm): 7 | comment = forms.CharField(widget=forms.Textarea(attrs={'rows': 3})) 8 | 9 | class Meta: 10 | model = period_models.FlowEvent 11 | exclude = ['user'] 12 | -------------------------------------------------------------------------------- /requirements/development.txt: -------------------------------------------------------------------------------- 1 | -r common.txt 2 | coverage==4.3.4 3 | coveralls==1.8.0 4 | django-coverage-plugin==1.4.2 5 | docopt==0.6.2 6 | factory-boy==2.5.2 7 | flake8==3.3.0 8 | mccabe==0.6.1 9 | mock==1.3.0 10 | nose==1.3.7 11 | pbr==1.8.0 12 | pep8==1.7.0 13 | pycodestyle==2.3.1 14 | pyflakes==1.5.0 15 | PyYAML==3.11 16 | selenium==2.53.6 17 | -------------------------------------------------------------------------------- /eggtimer/templates/email/base.txt: -------------------------------------------------------------------------------- 1 | Hello {{ full_name }}, 2 | {% block content %}{% endblock %} 3 | Cheers! 4 | {{ admin_name }} 5 | 6 | Check your calendar: {{full_domain}}{% url 'calendar' %} 7 | Found a bug? Have a feature request? Please let us know: https://github.com/jessamynsmith/eggtimer-server/issues 8 | Disable email notifications: {{full_domain}}{% url 'user_profile' %} 9 | -------------------------------------------------------------------------------- /periods/templates/periods/email/expected_ago.html: -------------------------------------------------------------------------------- 1 | {% extends "email/base.html" %} 2 | 3 | {% block content %} 4 | You should have gotten your period {{ expected_in }} {{ day }} ago, on {{ expected_date}}. 5 |
6 | Did you forget to 7 | 8 | add your last period? 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /eggtimer/templates/socialaccount/authentication_error.html: -------------------------------------------------------------------------------- 1 | {% extends "socialaccount/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Social Network Login Failure" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Social Network Login Failure" %}

9 | 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 "Change Password" %}

8 |

{% 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 |

Periods for {{ user.get_full_name }}

8 |

*Note: all times in UTC

9 |
10 | {% csrf_token %} 11 | {{ formset|bootstrap_inline }} 12 | 13 |
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 |

{% trans "Login Cancelled" %}

10 | 11 | {% url 'account_login' as login_url %} 12 | 13 |

{% 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 |

{% trans "Password Reset" %}

10 | 11 | {% if user.is_authenticated %} 12 | {% include "account/snippets/already_logged_in.html" %} 13 | {% endif %} 14 | 15 |

{% 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 Smith " 5 | ], 6 | "description": "", 7 | "main": "", 8 | "moduleType": [], 9 | "homepage": "eggtimer.herokuapp.com", 10 | "private": true, 11 | "ignore": [ 12 | "**/.*", 13 | "node_modules", 14 | "bower_components", 15 | "test", 16 | "tests" 17 | ], 18 | "dependencies": { 19 | "bootstrap": "3.3.5", 20 | "jquery": "~2.1.4", 21 | "jquery-ui": "~1.11.4", 22 | "jquery.serializeJSON": "^2.7.2", 23 | "js-cookie": "~2.0.4", 24 | "moment": "~2.10.6", 25 | "moment-timezone": "~0.4.1", 26 | "bootstrap3-dialog": "bootstrap-dialog#^1.35.2", 27 | "fullcalendar": "^2.9.1", 28 | "highstock": "^3.0.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /periods/migrations/0002_auto_20150207_2125.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', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='user', 16 | options={'verbose_name': 'user', 'verbose_name_plural': 'users'}, 17 | ), 18 | migrations.AlterField( 19 | model_name='user', 20 | name='email', 21 | field=models.EmailField(db_index=True, max_length=255, verbose_name='email address', unique=True), 22 | preserve_default=True, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eggtimer", 3 | "version": "0.0.1", 4 | "description": "eggtimer", 5 | "main": "", 6 | "scripts": { 7 | "postinstall": "bower install" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@bitbucket.org/jessamynsmith/eggtimer-server.git" 12 | }, 13 | "author": "Jessamyn Smith", 14 | "homepage": "http://eggtimer.herokuapp.com/", 15 | "dependencies": { 16 | "bower": "^1.8.8" 17 | }, 18 | "devDependencies": { 19 | "blanket": "^1.2.3", 20 | "jshint": "^2.8.0", 21 | "lcov-parse": "^1.0.0", 22 | "mocha": "^3.0.2", 23 | "mocha-lcov-reporter": "^1.2.0", 24 | "moment": "^2.14.1", 25 | "moment-timezone": "^0.5.5" 26 | }, 27 | "keywords": [ 28 | "django", 29 | "menstrual", 30 | "tracker" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /periods/migrations/0015_aerisdata.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.9 on 2016-10-26 21:51 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields.jsonb 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('periods', '0014_auto_20161026_1742'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='AerisData', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('to_date', models.DateField(unique=True)), 21 | ('data', django.contrib.postgres.fields.jsonb.JSONField()), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /scripts/deploy_on_digital_ocean.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 | . venv/bin/activate 28 | pip install -r requirements/production.txt 29 | npm install 30 | heroku run python manage.py collectstatic --noinput 31 | heroku run python manage.py migrate --noinput --app eggtimer 32 | -------------------------------------------------------------------------------- /selenium/selenium_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: iso-8859-15 -*- 2 | 3 | import os 4 | 5 | BROWSER = 'Chrome' 6 | 7 | # Pick an environment type from the keys in BASE_URL 8 | ENVIRONMENT_TYPE = 'dev' 9 | 10 | BASE_URL = { 11 | # NOTE 'dev' is special in that tests that check email use the EMAIL_FILE_PATH 12 | # to retrieve emails rather than ADMIN_EMAIL 13 | 'dev': 'http://127.0.0.1:8000/', 14 | 'heroku-production': 'https://eggtimer.herokuapp.com/', 15 | } 16 | 17 | ADMIN_USERNAME = os.environ['SELENIUM_ADMIN_EMAIL'] 18 | ADMIN_PASSWORD = os.environ['SELENIUM_ADMIN_PASSWORD'] 19 | 20 | EMAIL_USERNAME = 'eggtimer.selenium@example.com' 21 | 22 | # Required when testing locally; must match settings.py EMAIL_FILE_PATH 23 | EMAIL_FILE_PATH = '%s/Development/django_files/eggtimer/emails' % os.environ.get('HOME') 24 | 25 | SLEEP_INTERVAL = 0.1 26 | MAX_SLEEP_TIME = 10 27 | -------------------------------------------------------------------------------- /periods/migrations/0011_populate_averages.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib import auth 5 | from django.db import migrations 6 | 7 | 8 | def update_averages(apps, schema_editor): 9 | pass 10 | # This code has been run and served its purpose, and does not work with the new user model 11 | # User = auth.get_user_model() 12 | # for user in User.objects.all(): 13 | # period = user.get_previous_period() 14 | # if period: 15 | # period.save() 16 | 17 | 18 | def reverse_update_averages(apps, schema_editor): 19 | pass 20 | 21 | 22 | class Migration(migrations.Migration): 23 | 24 | dependencies = [ 25 | ('periods', '0010_statistics_all_time_average_cycle_length'), 26 | ] 27 | 28 | operations = [ 29 | migrations.RunPython(update_averages, reverse_update_averages), 30 | ] 31 | -------------------------------------------------------------------------------- /periods/templates/periods/user_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}Account Info for {{ user.get_full_name }}{% endblock %} 5 | 6 | {% block content %} 7 |

Account Info for {{ user.get_full_name }}

8 | 9 |
10 |
11 | {% csrf_token %} 12 |
13 | {{ form.as_p }} 14 |
15 | 16 |
17 |
18 | 19 |
20 | 21 |
22 | Change Your Password 23 |
24 | 25 |
26 | API Info 27 |
28 | 29 | {% endblock %} 30 | 31 | {% block extra_js %} 32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /periods/migrations/0012_auto_20151024_2225.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', '0011_populate_averages'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='user', 16 | name='groups', 17 | field=models.ManyToManyField(help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', blank=True, related_query_name='user', verbose_name='groups', to='auth.Group', related_name='user_set'), 18 | ), 19 | migrations.AlterField( 20 | model_name='user', 21 | name='last_login', 22 | field=models.DateTimeField(blank=True, null=True, verbose_name='last login'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /periods/tests/factories.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.contrib.auth import get_user_model 3 | import factory 4 | import pytz 5 | 6 | from periods import models as period_models 7 | 8 | PASSWORD = 'bogus_password' 9 | 10 | 11 | class UserFactory(factory.DjangoModelFactory): 12 | class Meta: 13 | model = get_user_model() 14 | 15 | first_name = u'Jessamyn' 16 | birth_date = pytz.utc.localize(datetime.datetime(1995, 3, 1)) 17 | email = factory.Sequence(lambda n: "user_%d@example.com" % n) 18 | password = factory.PostGenerationMethodCall('set_password', PASSWORD) 19 | last_login = pytz.utc.localize(datetime.datetime(2015, 3, 1)) 20 | 21 | 22 | class FlowEventFactory(factory.DjangoModelFactory): 23 | class Meta: 24 | model = period_models.FlowEvent 25 | 26 | user = factory.SubFactory(UserFactory) 27 | timestamp = pytz.utc.localize(datetime.datetime(2014, 1, 31, 17, 0, 0)) 28 | first_day = True 29 | -------------------------------------------------------------------------------- /eggtimer/templates/socialaccount/snippets/provider_list.html: -------------------------------------------------------------------------------- 1 | {% load socialaccount %} 2 | 3 | {% get_providers as socialaccount_providers %} 4 | 5 | {% for provider in socialaccount_providers %} 6 | {% if provider.id == "openid" %} 7 | {% for brand in provider.get_brands %} 8 | {{ brand.name }} 12 | {% endfor %} 13 | {% endif %} 14 | {{ provider.name }} 16 | {% if not forloop.last %} 17 |
18 | {% endif %} 19 | {% endfor %} 20 | 21 | -------------------------------------------------------------------------------- /periods/tests/test_email_sender.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from mock import patch 4 | 5 | from periods import email_sender 6 | 7 | 8 | class TestEmailSender(TestCase): 9 | 10 | def setUp(self): 11 | self.user = get_user_model().objects.create_user( 12 | password='bogus', email='jessamyn@example.com', first_name=u'Jessamyn') 13 | 14 | @patch('django.core.mail.EmailMultiAlternatives.send') 15 | def test_send_text_only(self, mock_send): 16 | result = email_sender.send(self.user, 'Hi!', 'good day', None) 17 | 18 | self.assertEqual(True, result) 19 | mock_send.assert_called_once_with() 20 | 21 | @patch('django.core.mail.EmailMultiAlternatives.send') 22 | def test_send_with_html(self, mock_send): 23 | result = email_sender.send(self.user, 'Hi!', 'good day', '

good 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 |

{% trans "Confirm E-mail Address" %}

11 | 12 | {% if confirmation %} 13 | 14 | {% user_display confirmation.email_address.user as user_display %} 15 | 16 |

{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}

17 | 18 |
19 | {% csrf_token %} 20 |
21 | 22 |
23 |
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 |
10 |
11 |
12 |

{% trans "Change Password" %}

13 |
14 |
15 | 16 |
18 | {% csrf_token %} 19 | {{ form|bootstrap_horizontal }} 20 |
21 |
22 |
23 | 24 | {% trans "Cancel" %} 25 | 26 | 27 |
28 |
29 | 30 |
31 | 32 | {% endblock %} 33 | 34 | {% block extra_js %} 35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /periods/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.renderers import JSONRenderer 3 | 4 | from periods import models as period_models 5 | from periods.serializers import FlowEventSerializer, NullableEnumField 6 | from periods.tests.factories import FlowEventFactory 7 | 8 | 9 | class TestNullableEnumField(TestCase): 10 | 11 | def test_to_internal_value_empty(self): 12 | field = NullableEnumField(period_models.ClotSize) 13 | 14 | result = field.to_internal_value('') 15 | 16 | self.assertIsNone(result) 17 | 18 | def test_to_internal_value_value(self): 19 | field = NullableEnumField(period_models.ClotSize) 20 | 21 | result = field.to_internal_value('1') 22 | 23 | self.assertEqual(1, result) 24 | 25 | 26 | class TestFlowEventViewSet(TestCase): 27 | 28 | def setUp(self): 29 | FlowEventFactory() 30 | self.serializer = FlowEventSerializer(instance=period_models.FlowEvent.objects.first()) 31 | 32 | def test_serialization(self): 33 | result = JSONRenderer().render(self.serializer.data) 34 | 35 | expected = (b'{"id":[\d]+,"clots":null,"cramps":null,"timestamp":"2014-01-31T17:00:00Z",' 36 | b'"first_day":true,"level":2,"color":2,"comment":null}') 37 | self.assertRegex(result, expected) 38 | -------------------------------------------------------------------------------- /periods/migrations/0014_auto_20161026_1742.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.9 on 2016-10-26 17:42 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | import django_enumfield.db.fields 7 | import periods.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('periods', '0013_auto_20160718_2332'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name='flowevent', 19 | name='clots', 20 | field=django_enumfield.db.fields.EnumField(blank=True, default=None, enum=periods.models.ClotSize, null=True), 21 | ), 22 | migrations.AlterField( 23 | model_name='flowevent', 24 | name='color', 25 | field=django_enumfield.db.fields.EnumField(default=2, enum=periods.models.FlowColor), 26 | ), 27 | migrations.AlterField( 28 | model_name='flowevent', 29 | name='cramps', 30 | field=django_enumfield.db.fields.EnumField(blank=True, default=None, enum=periods.models.CrampLevel, null=True), 31 | ), 32 | migrations.AlterField( 33 | model_name='flowevent', 34 | name='level', 35 | field=django_enumfield.db.fields.EnumField(default=2, enum=periods.models.FlowLevel), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /periods/tests/management/commands/test_fix_timezone_for_period_data.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | 4 | from django.test import TestCase 5 | 6 | from periods import models as period_models 7 | from periods.management.commands import fix_timezone_for_period_data 8 | from periods.tests.factories import FlowEventFactory 9 | 10 | TIMEZONE = pytz.timezone("US/Eastern") 11 | 12 | 13 | class TestCommand(TestCase): 14 | def setUp(self): 15 | self.command = fix_timezone_for_period_data.Command() 16 | flow_event = FlowEventFactory(timestamp=TIMEZONE.localize( 17 | datetime.datetime(2014, 1, 31, 17, 0, 0))) 18 | self.user = flow_event.user 19 | FlowEventFactory(user=self.user, 20 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 8, 28))) 21 | 22 | def test_fix_timezone_for_period_data_no_periods(self): 23 | period_models.FlowEvent.objects.all().delete() 24 | 25 | self.command.handle() 26 | 27 | self.assertEqual(0, period_models.FlowEvent.objects.count()) 28 | 29 | def test_fix_timezone_for_period_data(self): 30 | self.command.handle() 31 | 32 | periods = period_models.FlowEvent.objects.all() 33 | self.assertEqual(2, periods.count()) 34 | self.assertEqual(pytz.utc.localize(datetime.datetime(2014, 1, 31, 22)), 35 | periods[0].timestamp) 36 | self.assertEqual(pytz.utc.localize(datetime.datetime(2014, 8, 28, 4)), periods[1].timestamp) 37 | -------------------------------------------------------------------------------- /eggtimer/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n static bootstrap %} 4 | 5 | {% block head_title %}{% trans "Sign Up" %}{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
10 |
11 |
12 | 13 |

{% trans "Sign Up" %}

14 | 15 | {% blocktrans %}Already have an account? Then please 16 | sign in. 17 | {% endblocktrans %} 18 |
19 |
20 |
21 | 22 | 37 | 38 | {% endblock %} 39 | 40 | {% block extra_js %} 41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /periods/serializers.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from rest_framework import serializers 3 | 4 | from periods import models as period_models 5 | 6 | 7 | class NullableEnumField(serializers.ChoiceField): 8 | """ 9 | Field that handles empty entries for EnumFields 10 | """ 11 | 12 | def __init__(self, enum, **kwargs): 13 | super(NullableEnumField, self).__init__(enum.choices(), allow_blank=True, required=False) 14 | 15 | def to_internal_value(self, data): 16 | if data == '' and self.allow_blank: 17 | return None 18 | 19 | return super(NullableEnumField, self).to_internal_value(data) 20 | 21 | 22 | class FlowEventSerializer(serializers.ModelSerializer): 23 | clots = NullableEnumField(period_models.ClotSize) 24 | cramps = NullableEnumField(period_models.CrampLevel) 25 | 26 | class Meta: 27 | model = period_models.FlowEvent 28 | exclude = ('user',) 29 | 30 | 31 | class FlowEventFilter(django_filters.FilterSet): 32 | min_timestamp = django_filters.DateTimeFilter(name="timestamp", lookup_type='gte') 33 | max_timestamp = django_filters.DateTimeFilter(name="timestamp", lookup_type='lte') 34 | 35 | class Meta: 36 | model = period_models.FlowEvent 37 | fields = ('min_timestamp', 'max_timestamp') 38 | 39 | 40 | class StatisticsSerializer(serializers.ModelSerializer): 41 | 42 | class Meta: 43 | model = period_models.Statistics 44 | fields = ('average_cycle_length', 'predicted_events', 'first_date', 'first_day') 45 | -------------------------------------------------------------------------------- /eggtimer/templates/socialaccount/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "socialaccount/base.html" %} 2 | 3 | {% load i18n static bootstrap %} 4 | 5 | {% block head_title %}{% trans "Signup" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Sign Up" %}

9 | 10 |
11 |
12 |
13 | {% blocktrans with provider_name=account.get_provider.name site_name=site.name %}You are 14 | about to use your {{ provider_name }} account to login to 15 | {{ site_name }}. As a final step, please complete the following form:{% endblocktrans %} 16 |
17 |
18 |
19 | 20 | 35 | 36 | {% endblock %} 37 | 38 | {% block extra_js %} 39 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /eggtimer/templates/socialaccount/connections.html: -------------------------------------------------------------------------------- 1 | {% extends "socialaccount/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Account Connections" %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans "Account Connections" %}

9 | 10 | {% if form.accounts %} 11 |

{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}

12 | 13 | 14 |
15 | {% csrf_token %} 16 | 17 |
18 | {% if form.non_field_errors %} 19 |
{{ form.non_field_errors }}
20 | {% endif %} 21 | 22 | {% for base_account in form.accounts %} 23 | {% with base_account.get_provider_account as account %} 24 |
25 | 30 |
31 | {% endwith %} 32 | {% endfor %} 33 | 34 |
35 | 36 |
37 | 38 |
39 | 40 |
41 | 42 | {% else %} 43 |

{% trans 'You currently have no social network accounts connected to this account.' %}

44 | {% endif %} 45 | 46 |

{% trans 'Add a 3rd Party Account' %}

47 | 48 |
    49 | {% include "socialaccount/snippets/provider_list.html" with process="connect" %} 50 |
51 | 52 | {% include "socialaccount/snippets/login_extra.html" %} 53 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /periods/templates/periods/api_info.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}API Info for {{ user.get_full_name }}{% endblock %} 5 | 6 | {% block content %} 7 |

API Info for {{ user.get_full_name }}

8 | 9 |
10 |
11 | {% csrf_token %} 12 | 13 | 14 | 15 |
16 |
17 | 18 |
19 | 20 |
21 | To add a period (start_time is optional):
22 | curl -v -k -X POST -H "Content-Type: application/json" -H 'Authorization: Token {{ user.auth_token.key }}' --data '{"timestamp": "<YYYY-MM-DD>T<HH:MM:SS>"}' "{{ periods_url }}" 23 | 24 |
25 |
26 | To list periods:
27 | curl -v -k -X GET -H "Content-Type: application/json" -H 'Authorization: Token {{ user.auth_token.key }}' "{{ periods_url }}" | python -m json.tool 28 | 29 |
30 |
31 | To filter periods:
32 | curl -v -k -X GET -H "Content-Type: application/json" -H 'Authorization: Token {{ user.auth_token.key }}' "{{ periods_url }}?min_timestamp=2016-01-19&max_timestamp=2016-01-20" | python -m json.tool 33 |
34 | 35 |
36 | Back to Profile 37 | 38 | {% endblock %} 39 | 40 | {% block extra_js %} 41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /eggtimer/templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n static bootstrap %} 4 | 5 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 6 | 7 | {% block content %} 8 | 9 |
10 |
11 |
12 | 13 |

{% trans "Password Reset" %}

14 | 15 | {% if user.is_authenticated %} 16 | {% include "account/snippets/already_logged_in.html" %} 17 | {% endif %} 18 | 19 |

{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}

20 |
21 |
22 | 23 |
25 | {% csrf_token %} 26 | {{ form|bootstrap_horizontal }} 27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 | 35 |
36 |
37 |
38 |

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 |
45 |
46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /config/eggtimer: -------------------------------------------------------------------------------- 1 | # /etc/nginx/sites-available/eggtimer 2 | 3 | upstream eggtimer { 4 | server unix:/home/django/eggtimer.socket fail_timeout=0; 5 | } 6 | 7 | server { 8 | #listen 80 default_server; 9 | #listen [::]:80 default_server ipv6only=on; 10 | 11 | root /usr/share/nginx/html; 12 | index index.html index.htm; 13 | 14 | client_max_body_size 4G; 15 | server_name eggtimer.jessamynsmith.ca; 16 | 17 | keepalive_timeout 5; 18 | 19 | location /media { 20 | alias /home/django/eggtimer/mediafiles; 21 | } 22 | 23 | location /favicon.ico { 24 | alias /home/django/eggtimer/staticfiles/img/favicon.ico; 25 | } 26 | 27 | location /static { 28 | alias /home/django/eggtimer/staticfiles; 29 | } 30 | 31 | location / { 32 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 33 | proxy_set_header Host $host; 34 | proxy_redirect off; 35 | proxy_buffering off; 36 | 37 | proxy_pass http://eggtimer; 38 | } 39 | 40 | 41 | listen 443 ssl; # managed by Certbot 42 | ssl_certificate /etc/letsencrypt/live/eggtimer.jessamynsmith.ca/fullchain.pem; # managed by Certbot 43 | ssl_certificate_key /etc/letsencrypt/live/eggtimer.jessamynsmith.ca/privkey.pem; # managed by Certbot 44 | include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot 45 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot 46 | 47 | } 48 | 49 | server { 50 | if ($host = eggtimer.jessamynsmith.ca) { 51 | return 301 https://$host$request_uri; 52 | } # managed by Certbot 53 | 54 | 55 | listen 80; 56 | server_name eggtimer.jessamynsmith.ca; 57 | return 404; # managed by Certbot 58 | 59 | 60 | } 61 | -------------------------------------------------------------------------------- /periods/management/commands/fix_timezone_for_period_data.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from periods import models as period_models 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Update FlowEvent data to match User timezone' 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument('--noinput', '--no-input', 13 | action='store_false', dest='interactive', default=True, 14 | help='Tells Django to NOT prompt the user for input of any kind.') 15 | 16 | def handle(self, *args, **options): 17 | interactive = options.get('interactive') 18 | 19 | users = period_models.User.objects.filter( 20 | flow_events__isnull=False).distinct() 21 | 22 | if interactive: 23 | users_info = ['\t%s (%s)' % (user.email, user.timezone) for user in users] 24 | confirm = input("""You are about to update flow events for the following users:\n%s 25 | Are you sure you want to do this? 26 | 27 | Type 'yes' to continue, or 'no' to cancel: """ % "\n".join(users_info)) 28 | else: 29 | confirm = 'yes' 30 | 31 | for user in users: 32 | user_timezone = pytz.timezone(user._timezone.zone) 33 | print("User: %s (%s)" % (user.email, user_timezone)) 34 | for flow_event in user.flow_events.all().order_by('timestamp'): 35 | timestamp = flow_event.timestamp 36 | utc_timestamp = timestamp.astimezone(pytz.utc) 37 | if confirm == 'yes': 38 | flow_event.timestamp = utc_timestamp 39 | flow_event.save() 40 | else: 41 | print("\t%s -> %s" % (flow_event.timestamp, utc_timestamp)) 42 | -------------------------------------------------------------------------------- /periods/admin.py: -------------------------------------------------------------------------------- 1 | from custom_user.admin import EmailUserAdmin 2 | from django.contrib import admin 3 | from django.contrib.auth import get_user_model 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | from periods import models 7 | 8 | 9 | class FlowAdmin(admin.ModelAdmin): 10 | 11 | list_display = ['user', 'timestamp', 'first_day', 'level', 'color', 'clots', 'cramps'] 12 | list_filter = ['timestamp', 'first_day', 'level', 'color', 'clots', 'cramps'] 13 | search_fields = ['user__email', 'user__first_name', 'user__last_name', 'timestamp', 'level', 14 | 'color', 'clots', 'cramps', 'comment'] 15 | 16 | 17 | class StatisticsAdmin(admin.ModelAdmin): 18 | 19 | list_display = ['__str__', 'average_cycle_length'] 20 | search_fields = ['user__email', 'user__first_name', 'user__last_name'] 21 | 22 | 23 | class UserAdmin(EmailUserAdmin): 24 | 25 | list_display = ['email', 'first_name', 'last_name', 'cycle_count', 'date_joined', 'is_active', 26 | 'send_emails'] 27 | fieldsets = ( 28 | (None, {'fields': ('first_name', 'last_name', 'email', 'password')}), 29 | (_('Settings'), {'fields': ('_timezone', 'send_emails', 'luteal_phase_length', 30 | 'birth_date')}), 31 | (_('General Information'), {'fields': ('last_login', 'date_joined', 'cycle_count')}), 32 | (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 33 | 'groups', 'user_permissions')}), 34 | ) 35 | search_fields = ['email', 'first_name', 'last_name'] 36 | readonly_fields = ['cycle_count'] 37 | 38 | 39 | admin.site.register(models.FlowEvent, FlowAdmin) 40 | admin.site.register(models.Statistics, StatisticsAdmin) 41 | admin.site.register(get_user_model(), UserAdmin) 42 | -------------------------------------------------------------------------------- /eggtimer/templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load bootstrap %} 5 | 6 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 7 | 8 | {% block content %} 9 | 10 |
11 |
12 |
13 |

{% if token_fail %}{% trans "Bad Token" %}{% else %} 14 | {% trans "Change Password" %}{% endif %}

15 |
16 |
17 | 18 | {% if token_fail %} 19 |
20 |
21 |
22 | {% url 'account_reset_password' as passwd_reset_url %} 23 |

{% 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 |
28 |
29 | {% else %} 30 | {% if form %} 31 |
32 | {% csrf_token %} 33 | {{ form|bootstrap_horizontal }} 34 |
35 |
36 |
37 | 39 |
40 |
41 |
42 | {% else %} 43 |

{% trans 'Your password is now changed.' %}

44 | {% endif %} 45 | {% endif %} 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /eggtimer/templates/email/base.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | logo 6 | 7 | egg timer
8 | know your cycles 9 |
10 |
11 | 12 |
13 | 14 |

15 | Hello {{ full_name }}, 16 |

17 | {% block content %}{% endblock %} 18 |

19 | Cheers! 20 |
21 | {{ admin_name }} 22 |

23 |
24 | 25 |
26 | 27 |
28 | 29 | 31 | 33 | Check your calendar 34 | 35 | 36 | 37 |
38 | Found a bug? Have a feature request? 39 |
40 | Please 41 | 42 | let us know. 43 |
44 | 45 | 47 | Disable email 49 | notifications 50 | 51 | 52 |
53 | 54 |
55 | -------------------------------------------------------------------------------- /periods/migrations/0005_auto_20150222_1302.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | from django.contrib import auth 6 | from django.db import models, migrations 7 | 8 | import periods 9 | 10 | 11 | def populate_flow(apps, schema_editor): 12 | FlowEvent = apps.get_model("periods", "FlowEvent") 13 | Period = apps.get_model("periods", "Period") 14 | for period in Period.objects.all(): 15 | if period.start_time: 16 | start_time = period.start_time 17 | else: 18 | start_time = datetime.time() 19 | flow = FlowEvent(user=period.user, 20 | timestamp=datetime.datetime.combine(period.start_date, start_time), 21 | first_day=True) 22 | flow.save() 23 | 24 | Group = apps.get_model("auth", "Group") 25 | Permission = apps.get_model("auth", "Permission") 26 | app_config = apps.get_app_config('periods') 27 | # Hack! For some reason, this is just an AppConfigStub, so have to set models_module manually. 28 | app_config.models_module = periods.models 29 | auth.management.create_permissions(app_config, verbosity=0) 30 | try: 31 | group = Group.objects.get(name='users') 32 | except Group.DoesNotExist: 33 | group = Group(name='users') 34 | group.save() 35 | group.permissions.remove(*Permission.objects.filter(codename__endswith='_period').all()) 36 | group.permissions.add(*Permission.objects.filter(codename__endswith='_flowevent').all()) 37 | group.save() 38 | 39 | 40 | def populate_period(apps, schema_editor): 41 | pass 42 | 43 | 44 | class Migration(migrations.Migration): 45 | 46 | dependencies = [ 47 | ('auth', '0001_initial'), 48 | ('periods', '0004_flowevent'), 49 | ] 50 | 51 | operations = [ 52 | migrations.RunPython(populate_flow, populate_period), 53 | ] 54 | -------------------------------------------------------------------------------- /periods/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.views.generic import RedirectView 3 | from rest_framework import routers 4 | 5 | from periods import views as period_views 6 | 7 | 8 | router = routers.DefaultRouter() 9 | router.register(r'periods', period_views.FlowEventViewSet, base_name='periods') 10 | router.register(r'statistics', period_views.StatisticsViewSet, base_name='statistics') 11 | 12 | 13 | urlpatterns = [ 14 | url(r'^$', RedirectView.as_view(url='calendar/', permanent=False)), 15 | url(r'^accounts/', include('allauth.urls')), 16 | url(r'^accounts/profile/$', period_views.ProfileUpdateView.as_view(), name='user_profile'), 17 | url(r'^accounts/profile/api_info/$', period_views.ApiInfoView.as_view(), name='api_info'), 18 | url(r'^accounts/profile/regenerate_key/$', period_views.RegenerateKeyView.as_view(), 19 | name='regenerate_key'), 20 | 21 | url(r'^api/v2/', include(router.urls)), 22 | url(r'^api/v2/authenticate/$', period_views.ApiAuthenticateView.as_view(), name='authenticate'), 23 | url(r'^api/v2/aeris/$', period_views.AerisView.as_view(), name='aeris'), 24 | url(r'^flow_event/$', period_views.FlowEventCreateView.as_view(), name='flow_event_create'), 25 | url(r'^flow_event/(?P[0-9]+)/$', period_views.FlowEventUpdateView.as_view(), 26 | name='flow_event_update'), 27 | url(r'^flow_events/$', period_views.FlowEventFormSetView.as_view(), name='flow_events'), 28 | url(r'^calendar/$', period_views.CalendarView.as_view(), name='calendar'), 29 | url(r'^statistics/$', period_views.StatisticsView.as_view(), name='statistics'), 30 | url(r'^statistics/cycle_length_frequency/$', period_views.CycleLengthFrequencyView.as_view()), 31 | url(r'^statistics/cycle_length_history/$', period_views.CycleLengthHistoryView.as_view()), 32 | url(r'^statistics/qigong_cycles/$', period_views.QigongCycleView.as_view()), 33 | ] 34 | -------------------------------------------------------------------------------- /eggtimer/static/css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 50px; 3 | margin: 10px; 4 | } 5 | 6 | .my-form-control { 7 | width: auto; 8 | display: inline; 9 | margin-left: 5px; 10 | margin-bottom: 5px; 11 | } 12 | 13 | .my-control-label { 14 | display: inline; 15 | } 16 | 17 | .small { 18 | font-size: 12px; 19 | line-height: 10px; 20 | } 21 | 22 | .center { 23 | width: 100%; 24 | text-align: center; 25 | } 26 | 27 | label:after { 28 | color: transparent; 29 | content: " *"; 30 | } 31 | 32 | label.required:after { 33 | color: #d9534f; 34 | } 35 | 36 | p.error_message { 37 | color: red; 38 | } 39 | 40 | .content { 41 | margin-top: 10px; 42 | } 43 | 44 | .glyphicon-lg { 45 | font-size: 18px; 46 | line-height: 1.33; 47 | } 48 | 49 | .statistics { 50 | padding-top: 10px; 51 | width: 90%; 52 | } 53 | 54 | .my-navbar a, .navbar-default .navbar-nav > li > a { 55 | color: #700a0a; 56 | text-decoration: none; 57 | } 58 | 59 | .brand { 60 | float: left; 61 | font-weight: bold; 62 | font-size: 22px; 63 | line-height: 18px; 64 | height: 50px; 65 | padding-top: 8px; 66 | padding-left: 10px; 67 | white-space: nowrap; 68 | } 69 | 70 | .brand img { 71 | float: left; 72 | margin-top: 2px; 73 | margin-right: 4px; 74 | } 75 | 76 | .navbar-nav > li, .navbar-nav { 77 | float: left !important; 78 | } 79 | 80 | .navbar-right > li:last-child { 81 | margin-right: 10px !important; 82 | } 83 | 84 | .navbar-right { 85 | float: right !important; 86 | } 87 | 88 | .footer { 89 | padding-top: 12px; 90 | } 91 | 92 | .vertical-space { 93 | display: block; 94 | height: 12px; 95 | } 96 | 97 | .grey { 98 | color: grey; 99 | } 100 | 101 | @media (max-width: 639px) { 102 | .mobile_hidden { 103 | display: none; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /periods/management/commands/email_active_users.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.template.loader import get_template 3 | 4 | from periods import models as period_models, email_sender 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Email all active users' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('--noinput', '--no-input', 12 | action='store_false', dest='interactive', default=True, 13 | help='Tells Django to NOT prompt the user for input of any kind.') 14 | 15 | def handle(self, *args, **options): 16 | interactive = options.get('interactive') 17 | 18 | users = period_models.User.objects.filter( 19 | is_active=True, flow_events__isnull=False, statistics__isnull=False).exclude( 20 | send_emails=False).distinct() 21 | active_users = [] 22 | for user in users: 23 | # Don't email users who haven't tracked a period in over 3 months 24 | if user.statistics.current_cycle_length < 90: 25 | active_users.append(user) 26 | 27 | if interactive: 28 | confirm = input("""You are about to email %s users about their accounts. 29 | Are you sure you want to do this? 30 | 31 | Type 'yes' to continue, or 'no' to cancel: """ % len(active_users)) 32 | else: 33 | confirm = 'yes' 34 | 35 | if confirm == 'yes': 36 | subject = 'Important information about the data in your eggtimer account' 37 | template_name = 'notification' 38 | context = {} 39 | plaintext = get_template('periods/email/%s.txt' % template_name) 40 | for user in active_users: 41 | email_sender.send(user, subject, plaintext.render(context), None) 42 | else: 43 | print("Would have emailed the following %s users:\n-------------------------" 44 | % len(active_users)) 45 | for user in active_users: 46 | print("%35s %5s periods" % (user.email, user.flow_events.count())) 47 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | orbs: 2 | heroku: circleci/heroku@1.2.5 3 | version: 2.1 4 | workflows: 5 | version: 2 6 | build-deploy: 7 | jobs: 8 | - build 9 | jobs: 10 | build: 11 | docker: 12 | - image: circleci/python:3.6.8-jessie-node 13 | environment: 14 | DATABASE_URL: postgresql://root@localhost/circle_test?sslmode=disable 15 | DJANGO_SETTINGS_MODULE: eggtimer.settings 16 | DJANGO_DEBUG: 1 17 | DJANGO_ENABLE_SSL: 0 18 | - image: circleci/postgres:9.6.5-alpine-ram 19 | environment: 20 | POSTGRES_USER: root 21 | POSTGRES_DB: circle_test 22 | POSTGRES_PASSWORD: "" 23 | steps: 24 | - checkout 25 | - restore_cache: 26 | key: deps-py-{{ .Branch }}-{{ checksum "requirements/development.txt" }} 27 | - restore_cache: 28 | key: deps-npm-{{ .Branch }}-{{ checksum "package.json" }} 29 | - restore_cache: 30 | key: deps-bower-{{ .Branch }}-{{ checksum "bower.json" }} 31 | - run: 32 | # https://discuss.circleci.com/t/circleci-python-docker-images-disallow-pip-install-due-to-directory-ownership/12504 33 | name: Install Python deps in a venv 34 | command: | 35 | python3 -m venv venv 36 | . venv/bin/activate 37 | pip install -r requirements/development.txt 38 | - save_cache: 39 | key: deps-py-{{ .Branch }}-{{ checksum "requirements/development.txt" }} 40 | paths: 41 | - "venv" 42 | - save_cache: 43 | key: deps-npm-{{ .Branch }}-{{ checksum "package.json" }} 44 | paths: 45 | - "node_modules" 46 | - save_cache: 47 | key: deps-bower-{{ .Branch }}-{{ checksum "bower.json" }} 48 | paths: 49 | - "bower_components" 50 | - run: 51 | command: | 52 | npm install 53 | . venv/bin/activate 54 | flake8 55 | node_modules/.bin/jshint */static/*/js 56 | coverage run manage.py test 57 | node_modules/.bin/mocha */tests/static/*/js/* 58 | node_modules/.bin/mocha --reporter mocha-lcov-reporter */tests/static/*/js/* > coverage.info 59 | node_modules/.bin/lcov-parse coverage.info > coverage.json 60 | PYTHONPATH=. coveralls --merge coverage.json 61 | -------------------------------------------------------------------------------- /periods/management/commands/notify_upcoming_period.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.conf import settings 4 | from django.core.management.base import BaseCommand 5 | from django.template.loader import get_template 6 | 7 | from periods import models as period_models, email_sender, helpers 8 | 9 | 10 | class Command(BaseCommand): 11 | help = 'Notify users of upcoming periods' 12 | 13 | def _format_date(self, date_value): 14 | return date_value.strftime('%A %B %d, %Y') 15 | 16 | def handle(self, *args, **options): 17 | users = period_models.User.objects.filter( 18 | is_active=True, flow_events__isnull=False, statistics__isnull=False).exclude( 19 | send_emails=False).distinct() 20 | for user in users: 21 | today = period_models.today() 22 | upcoming_events = user.statistics.predicted_events 23 | if not upcoming_events: 24 | continue 25 | # The upcoming events are in date order, ovulation/period/ovulation/... 26 | expected_date = upcoming_events[1]['timestamp'] 27 | calendar_start_date = expected_date - datetime.timedelta(days=7) 28 | expected_in = (expected_date - today.date()).days 29 | expected_abs = abs(expected_in) 30 | if expected_abs == 1: 31 | day = 'day' 32 | else: 33 | day = 'days' 34 | 35 | context = { 36 | 'full_name': user.get_full_name(), 37 | 'today': self._format_date(today), 38 | 'expected_in': expected_abs, 39 | 'day': day, 40 | 'expected_date': self._format_date(expected_date), 41 | 'calendar_start_date': self._format_date(calendar_start_date), 42 | 'admin_name': settings.ADMINS[0][0], 43 | 'full_domain': helpers.get_full_domain(), 44 | } 45 | 46 | subject = '' 47 | if expected_in < 0: 48 | subject = "Period was expected %s %s ago" % (expected_abs, day) 49 | template_name = 'expected_ago' 50 | elif expected_in == 0: 51 | subject = "Period today!" 52 | template_name = 'expected_now' 53 | elif expected_in < 4: 54 | subject = "Period starting" 55 | template_name = 'expected_in' 56 | elif expected_in == user.luteal_phase_length: 57 | subject = "Ovulation today!" 58 | template_name = 'ovulating' 59 | if subject: 60 | plaintext = get_template('periods/email/%s.txt' % template_name) 61 | html = get_template('periods/email/%s.html' % template_name) 62 | email_sender.send(user, subject, plaintext.render(context), html.render(context)) 63 | -------------------------------------------------------------------------------- /periods/tests/management/commands/test_email_active_users.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | 4 | from django.test import TestCase 5 | from mock import patch 6 | 7 | from periods import models as period_models 8 | from periods.management.commands import email_active_users 9 | from periods.tests.factories import FlowEventFactory 10 | 11 | TIMEZONE = pytz.timezone("US/Eastern") 12 | 13 | 14 | class TestCommand(TestCase): 15 | def setUp(self): 16 | self.command = email_active_users.Command() 17 | flow_event = FlowEventFactory() 18 | self.user = flow_event.user 19 | FlowEventFactory(user=self.user, 20 | timestamp=TIMEZONE.localize(datetime.datetime(2014, 2, 28))) 21 | 22 | @patch('django.core.mail.EmailMultiAlternatives.send') 23 | def test_email_active_users_no_periods(self, mock_send): 24 | period_models.FlowEvent.objects.all().delete() 25 | 26 | self.command.handle() 27 | 28 | self.assertFalse(mock_send.called) 29 | 30 | @patch('django.core.mail.EmailMultiAlternatives.send') 31 | @patch('periods.models.today') 32 | def test_email_active_users_send_disabled(self, mock_today, mock_send): 33 | mock_today.return_value = TIMEZONE.localize(datetime.datetime(2014, 3, 14)) 34 | self.user.send_emails = False 35 | self.user.save() 36 | 37 | self.command.handle() 38 | 39 | self.assertFalse(mock_send.called) 40 | 41 | @patch('periods.email_sender.send') 42 | @patch('periods.models.today') 43 | def test_email_active_users(self, mock_today, mock_send): 44 | mock_today.return_value = TIMEZONE.localize(datetime.datetime(2014, 3, 15)) 45 | 46 | self.command.handle() 47 | 48 | email_text = ('Hello ,\n\nThis is an important notification about the data in your ' 49 | 'eggtimer account.\n\nUntil now, eggtimer has been storing all data in ' 50 | 'Eastern time. As you may already be aware,\nthis creates issues for users ' 51 | 'in other timezones. I am going to update the application so all\ndata is ' 52 | 'stored in UTC. This may affect your data!\n\nIf you are in Eastern time, ' 53 | 'your data will be migrated correctly, and you need do nothing.\n\nIf you ' 54 | 'have been using eggtimer from another timezone, you have two options:\n1) ' 55 | 'Before July 14, edit your user profile to select your timezone. When the ' 56 | 'data migration is\nperformed, I will use the timezone on your profile.\n2) ' 57 | 'Do nothing, and your data will be migrated ' 58 | 'as if it is in Eastern time. This will likely\nresult in a time shift when ' 59 | 'you view your events. If desired, you can then edit events yourself.\n\nI ' 60 | 'apologize for the inconvenience.\n\nSincerely,\n\n') 61 | mock_send.assert_called_once_with(self.user, 'Important information about the data in your ' 62 | 'eggtimer account', email_text, None) 63 | -------------------------------------------------------------------------------- /eggtimer/templates/account/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account socialaccount bootstrap static %} 5 | 6 | 7 | {% block head_title %}{% trans "Sign In" %}{% endblock %} 8 | 9 | {% block content %} 10 | 11 |
12 |
13 |
14 |

The egg timer is an open-source tracker for menstrual periods. It provides a calendar, email notifications, statistical analysis, and an API allowing you to download all your data.

15 |
16 |
17 | 18 |
19 |
20 |
21 |

{% trans "Sign In" %}

22 |
23 |
24 | 25 | {% get_providers as socialaccount_providers %} 26 | 27 | {% if socialaccount_providers %} 28 | 29 | {% include "socialaccount/snippets/login_extra.html" %} 30 | 31 |
32 | 33 |
34 |
35 |
36 | {% blocktrans with site.name as site_name %}Please sign in with one 37 | of your existing third party accounts.{% endblocktrans %} 38 |
39 | {% include "socialaccount/snippets/provider_list.html" with process="login" %} 40 |
41 |
42 | 43 | 44 |
45 | {% endif %} 46 | 47 |
48 |
49 |
50 | {% blocktrans %}Sign up 51 | for a {{ site_name }} account and sign in below:{% endblocktrans %} 52 |
53 |
54 |
55 | 56 | 75 | 76 | {% endblock %} 77 | 78 | {% block extra_js %} 79 | 81 | {% endblock %} 82 | -------------------------------------------------------------------------------- /periods/templates/periods/statistics.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static tz %} 3 | 4 | {% block title %}Statistics for {{ user.get_full_name }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | 14 |
15 | 16 |
17 | {% timezone user.timezone %} 18 | 19 |

Statistics for {{ user.get_full_name }}

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
Cycle Statistics
Total Number:{{ first_days|length }}
Most Recent:{{ first_days|last }}
First:{{ first_days|first }}
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
Cycle Length Statistics
Average (Last 6 Months):{{ user.statistics.average_cycle_length }}
Average (All Time):{{ user.statistics.all_time_average_cycle_length }}
Minimum:{{ user.statistics.cycle_length_minimum|default:"" }}
Maximum:{{ user.statistics.cycle_length_maximum|default:"" }}
Mean:{{ user.statistics.cycle_length_mean|default:"" }}
Median:{{ user.statistics.cycle_length_median|default:"" }}
Mode:{{ user.statistics.cycle_length_mode|default:"" }}
Standard Deviation:{{ user.statistics.cycle_length_standard_deviation|default:"" }}
76 | 77 | {% endtimezone %} 78 |
79 | 80 | {% for graph_type in graph_types %} 81 |
82 | {% endfor %} 83 | 84 |
85 | Cycle Graphs require menstrual data to calculate. 86 | Would you like to enter some periods? 87 |
88 |
89 | Qigong Cycles require a birth date to calculate. 90 | Would you like to add a birth date to your profile? 91 |
92 | 93 | {% endblock %} 94 | 95 | {% block extra_js %} 96 | 97 | 98 | {% endblock %} 99 | -------------------------------------------------------------------------------- /eggtimer/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% load static %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% block title %}{% endblock %} 18 | 19 | {% block extra_head %}{% endblock %} 20 | 21 | 22 | 23 | 49 | 50 |
51 | {% block content %}{% endblock %} 52 |
53 | 54 | {% block footer %} 55 | 71 | {% endblock %} 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | {% block extra_js %}{% endblock %} 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /periods/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('auth', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='User', 18 | fields=[ 19 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 20 | ('password', models.CharField(verbose_name='password', max_length=128)), 21 | ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')), 22 | ('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')), 23 | ('email', models.EmailField(verbose_name='email address', max_length=254, unique=True)), 24 | ('first_name', models.CharField(verbose_name='first name', blank=True, max_length=30)), 25 | ('last_name', models.CharField(verbose_name='last name', blank=True, max_length=30)), 26 | ('is_staff', models.BooleanField(verbose_name='staff status', default=False, help_text='Designates whether the user can log into this admin site.')), 27 | ('is_active', models.BooleanField(verbose_name='active', default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.')), 28 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 29 | ('send_emails', models.BooleanField(default=True, verbose_name='send emails')), 30 | ('birth_date', models.DateTimeField(blank=True, verbose_name='birth date', null=True)), 31 | ('luteal_phase_length', models.IntegerField(default=14, verbose_name='luteal phase length')), 32 | ('groups', models.ManyToManyField(help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.', to='auth.Group', blank=True, verbose_name='groups', related_query_name='user', related_name='user_set')), 33 | ('user_permissions', models.ManyToManyField(help_text='Specific permissions for this user.', to='auth.Permission', blank=True, verbose_name='user permissions', related_query_name='user', related_name='user_set')), 34 | ], 35 | options={ 36 | 'abstract': False, 37 | }, 38 | bases=(models.Model,), 39 | ), 40 | migrations.CreateModel( 41 | name='Period', 42 | fields=[ 43 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 44 | ('start_date', models.DateField()), 45 | ('start_time', models.TimeField(blank=True, null=True)), 46 | ('length', models.IntegerField(blank=True, null=True)), 47 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True, related_name='periods')), 48 | ], 49 | options={ 50 | }, 51 | bases=(models.Model,), 52 | ), 53 | migrations.CreateModel( 54 | name='Statistics', 55 | fields=[ 56 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 57 | ('average_cycle_length', models.IntegerField(default=28)), 58 | ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL, null=True, related_name='statistics')), 59 | ], 60 | options={ 61 | }, 62 | bases=(models.Model,), 63 | ), 64 | migrations.AlterUniqueTogether( 65 | name='period', 66 | unique_together=set([('user', 'start_date')]), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /periods/tests/static/periods/js/test_calendar.js: -------------------------------------------------------------------------------- 1 | require('blanket')({ 2 | pattern: function (filename) { 3 | return !/node_modules/.test(filename); 4 | } 5 | }); 6 | 7 | var assert = require("assert"); 8 | require('moment'); 9 | var moment = require('moment-timezone'); 10 | 11 | var calendar = require("../../../../static/periods/js/calendar.js"); 12 | 13 | 14 | describe('String', function () { 15 | describe('#formatMomentDate()', function () { 16 | it('should return empty string if no moment provided', function () { 17 | var result = formatMomentDate(null); 18 | assert.equal('', result); 19 | }); 20 | it('should return formatted date if moment provided', function () { 21 | var result = formatMomentDate(moment("12-28-2014", "MM-DD-YYYY")); 22 | assert.equal('2014-12-28', result); 23 | }); 24 | }) 25 | }); 26 | 27 | describe('Array', function () { 28 | describe('#makeEvents()', function () { 29 | it('should return empty array if no data', function () { 30 | var result = makeEvents(moment, 'America/Toronto', Array()); 31 | assert.equal(0, result.events.length); 32 | }); 33 | it('should return array of items if data', function () { 34 | var data = Array( 35 | {id: '1', type: 'period', first_day: true, timestamp: '2015-01-01T09:03:00.000Z'}, 36 | {id: '2', type: 'projected period', timestamp: '2015-01-28T05:00:00.000Z'}, 37 | {id: '3', type: 'projected ovulation', timestamp: '2015-01-14T05:00:00.000Z'}, 38 | {id: '4', type: 'period', timestamp: '2015-01-02T05:00:00.000Z'} 39 | ); 40 | var result = makeEvents(moment, 'America/Toronto', data); 41 | assert.equal(2, result.periodStartDates.length); 42 | assert.equal('2015-01-01', result.periodStartDates[0].format('YYYY-MM-DD')); 43 | assert.equal('2015-01-28', result.periodStartDates[1].format('YYYY-MM-DD')); 44 | assert.equal(4, result.events.length); 45 | var expected = JSON.stringify({title: "*period", "itemId": "1", "itemType": "period", 46 | start: "2015-01-01T09:03:00.000Z", color: "#0f76ed", "editable": false}); 47 | assert.equal(JSON.stringify(result.events[0]), expected); 48 | expected = JSON.stringify({title: "projected period", "itemId": "2", "itemType": 49 | "projected period", start: "2015-01-28T05:00:00.000Z", color: "darkred", 50 | "editable": false}); 51 | assert.equal(JSON.stringify(result.events[1]), expected); 52 | expected = JSON.stringify({title: "projected ovulation", "itemId": "3", "itemType": 53 | "projected ovulation", start: "2015-01-14T05:00:00.000Z", color: "purple", 54 | "editable": false}); 55 | assert.equal(JSON.stringify(result.events[2]), expected); 56 | var expected = JSON.stringify({title: "period", "itemId": "4", "itemType": "period", 57 | start: "2015-01-02T05:00:00.000Z", color: "#0f76ed", "editable": false}); 58 | assert.equal(JSON.stringify(result.events[3]), expected); 59 | }); 60 | }) 61 | }); 62 | 63 | describe('String', function () { 64 | describe('#getDefaultDate()', function () { 65 | it('should return null if no querystring', function () { 66 | var result = getDefaultDate(""); 67 | assert.equal(null, result); 68 | }); 69 | it('should return null if only start in querystring', function () { 70 | var result = getDefaultDate(moment, "?start=2014-12-28"); 71 | assert.equal(null, result); 72 | }); 73 | it('should return null if only end in querystring', function () { 74 | var result = getDefaultDate(moment, "?end=2015-02-08"); 75 | assert.equal(null, result); 76 | }); 77 | it('should return moment if start and date in querystring', function () { 78 | var result = getDefaultDate(moment, "?start=2014-12-28&end=2015-02-08"); 79 | assert.equal("2015-01-18", moment(result).format("YYYY-MM-DD")); 80 | }); 81 | }) 82 | }); 83 | -------------------------------------------------------------------------------- /periods/static/periods/js/statistics.js: -------------------------------------------------------------------------------- 1 | convertDataToDate = function(data) { 2 | for (var i=0; i/eggtimer-server.git 18 | 19 | You may need to set environment variables to find openssl on OSX when installing Python packages: 20 | 21 | export LDFLAGS="-L/usr/local/opt/openssl/lib" 22 | export CPPFLAGS="-I/usr/local/opt/openssl/include" 23 | 24 | Create a virtualenv using Python 3.7 and install dependencies. 25 | 26 | python3 -m venv venv 27 | pip install -r requirements/development.txt 28 | 29 | Ensure you have node installed (I recommend using homebrew on OSX), then use npm to install Javacript dependencies: 30 | 31 | npm install 32 | 33 | Set environment variables as desired. Recommended dev settings: 34 | 35 | export DJANGO_DEBUG=1 36 | export DJANGO_ENABLE_SSL=0 37 | 38 | Optional environment variables, generally only required in production: 39 | 40 | DATABASE_URL 41 | ADMIN_NAME 42 | ADMIN_EMAIL 43 | DJANGO_SECRET_KEY 44 | REPLY_TO_EMAIL 45 | SENDGRID_API_KEY 46 | DEPLOY_DATE 47 | 48 | You can add the exporting of environment variables to the virtualenv activate script so they are always available. 49 | 50 | Set up db: 51 | 52 | python manage.py migrate 53 | 54 | Run tests and view coverage: 55 | 56 | coverage run manage.py test 57 | coverage report -m 58 | 59 | Check code style: 60 | 61 | flake8 62 | 63 | (Optional) Generate graph of data models. In order to do this, you will need to install some extra requirements: 64 | 65 | brew install graphviz pkg-config 66 | pip install -r requirements/extensions.txt 67 | 68 | You can then generate graphs, e.g.: 69 | 70 | python manage.py graph_models --pygraphviz -a -g -o all_models.png # all models 71 | python manage.py graph_models periods --pygraphviz -g -o period_models.png # period models 72 | 73 | Run server: 74 | 75 | python manage.py runserver 76 | 77 | Or run using gunicorn: 78 | 79 | gunicorn eggtimer.wsgi 80 | 81 | Lint JavaScript: 82 | 83 | ./node_modules/jshint/bin/jshint */static/*/js 84 | 85 | Run JavaScript tests: 86 | 87 | mocha --require-blanket -R html-cov */tests/static/*/js/* > ~/eggtimer_javascript_coverage.html 88 | 89 | To run Selenium tests, you must have chromedriver installed: 90 | 91 | brew install chromedriver 92 | 93 | Next you need to create a Django admin user and then export the email and password for that user as environment variables: 94 | 95 | export SELENIUM_ADMIN_EMAIL='' 96 | export SELENIUM_ADMIN_PASSWORD='' 97 | 98 | Finally, ensure the server is running, and run the selenium tests: 99 | 100 | nosetests selenium/ 101 | 102 | Retrieve data from the API with curl. can be found in your account info. 103 | 104 | curl -vk -X GET -H "Content-Type: application/json" -H 'Authorization: Token ' "https://eggtimer.herokuapp.com/api/v2/statistics/" | python -m json.tool 105 | 106 | curl -vk -X GET -H "Content-Type: application/json" -H 'Authorization: Token ' "https://eggtimer.herokuapp.com/api/v2/periods/" | python -m json.tool 107 | 108 | You can filter based on minimum and maximum timestamp of the events: 109 | 110 | curl -vk -X GET -H "Content-Type: application/json" -H 'Authorization: Token ' "https://eggtimer.herokuapp.com/api/v2/periods/?min_timestamp=2016-01-19&max_timestamp=2016-01-20" | python -m json.tool 111 | 112 | Create a period: 113 | 114 | curl -vk -X POST -H "Content-Type: application/json" -H 'Authorization: Token ' --data '{"timestamp": "T"}' "https://eggtimer.herokuapp.com/api/v2/periods/" 115 | 116 | ### Continuous Integration and Deployment 117 | 118 | This project is already set up for continuous integration and deployment using circleci, coveralls, 119 | and Heroku. 120 | 121 | Make a new Heroku app, and add the following addons: 122 | 123 | Heroku Postgres 124 | SendGrid 125 | New Relic APM 126 | Papertrail 127 | Heroku Scheduler 128 | Dead Man's Snitch 129 | 130 | Add Heroku buildpacks: 131 | 132 | heroku buildpacks:set heroku/nodejs -i 1 133 | heroku buildpacks:set heroku/python -i 2 134 | 135 | Enable the project on coveralls.io, and copy the repo token 136 | 137 | Enable the project on circleci.io, and under Project Settings -> Environment variables, add: 138 | 139 | COVERALLS_REPO_TOKEN 140 | HEROKU_API_KEY 141 | 142 | On circleci.io, under Project Settings -> Heroku Deployment, follow the steps to enable 143 | Heroku builds. At this point, you may need to cancel any currently running builds, then run 144 | a new build. 145 | 146 | Once your app is deployed successfully, you can add the Scheduler task on Heroku: 147 | 148 | python manage.py notify_upcoming_period --settings=eggtimer.settings 149 | 150 | You can also set up Dead Man's Snitch so you will know if the scheduled task fails. 151 | 152 | ### Ubuntu Deployment 153 | 154 | Ssh into Ubuntu server. 155 | 156 | Get source code: 157 | 158 | git clone git@github.com:jessamynsmith/eggtimer-server.git eggtimer 159 | 160 | Copy gunicorn service file into system folder: 161 | 162 | sudo cp config/eggtimer.service /etc/systemd/system/eggtimer.service 163 | 164 | After service config change: 165 | 166 | sudo systemctl daemon-reload 167 | 168 | Restart eggtimer service: 169 | 170 | sudo systemctl restart eggtimer 171 | 172 | View gunicorn logs: 173 | 174 | sudo journalctl -u eggtimer.service --no-pager -f 175 | 176 | View Django logs: 177 | 178 | tail -f /home/django/log/error_eggtimer.log 179 | 180 | Copy nginx config into nginx directory and create symlink: 181 | 182 | sudo cp config/eggtimer /etc/nginx/sites-available/eggtimer 183 | sudo ln -s /etc/nginx/sites-available/eggtimer /etc/nginx/sites-enabled/eggtimer 184 | 185 | Set up SSL: 186 | 187 | sudo certbot --nginx -d eggtimer.jessamynsmith.ca 188 | 189 | Restart nginx: 190 | 191 | sudo systemctl restart nginx 192 | 193 | 194 | Thank you to: 195 | Emily Strickland (github.com/emilyst) for the name 196 | 197 | -------------------------------------------------------------------------------- /selenium/test_signup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: iso-8859-15 -*- 2 | 3 | import datetime 4 | import selenium_settings 5 | from base_test import SeleniumBaseTest 6 | 7 | 8 | class SignupTest(SeleniumBaseTest): 9 | PASSWORD = 's3l3n1uM' 10 | PASSWORD2 = 'sel3n1uM2' 11 | PASSWORD3 = 'sel3n1uM3' 12 | 13 | def setUp(self): 14 | super(SignupTest, self).setUp() 15 | 16 | self.admin_login() 17 | self.delete_entities(self.admin_url + 'periods/user/', 18 | 'Select user to change', 19 | selenium_settings.EMAIL_USERNAME.split('@')[0], 20 | 'Delete selected users') 21 | self.admin_logout() 22 | 23 | self.USERNAME = selenium_settings.EMAIL_USERNAME.replace('@', '+%s@' % self.guid) 24 | self.NEW_USERNAME = self.USERNAME.replace('@', '1@') 25 | self.signup_url = self.base_url + 'accounts/signup/' 26 | self.login_url = self.base_url + 'accounts/login/' 27 | self.user_information = { 28 | 'id_email': self.USERNAME, 29 | } 30 | self.signup_fields = self.user_information.copy() 31 | self.signup_fields.update({ 32 | 'id_password1': self.PASSWORD, 33 | 'id_password2': self.PASSWORD, 34 | }) 35 | self.organization_fields = { 36 | 'id_organization_name': u'Selenium Organization \xe5', 37 | 'id_job_title': u'Administrator \xe5', 38 | } 39 | 40 | def login(self, username, password): 41 | fields = { 42 | 'id_login': username, 43 | 'id_password': password, 44 | } 45 | self.browser.get(self.login_url) 46 | self.wait_for_load('Sign In') 47 | self.fill_fields(fields) 48 | self.submit_form() 49 | 50 | def logout(self): 51 | # For some reason clicking the menu fails 52 | self.browser.get(self.base_url + 'accounts/logout') 53 | self.wait_for_load('egg timer') 54 | 55 | def click_menu_item(self, menu_item_text): 56 | # The menu items are capitalized via CSS, so use .upper() 57 | self.click_element_by_link_text(menu_item_text.upper()) 58 | 59 | def test_signup(self): 60 | self.browser.get(self.login_url) 61 | self.wait_for_load('Sign In') 62 | self.click_element_by_link_text('sign up') 63 | self.wait_for_load('Sign Up') 64 | 65 | # Should fail if not filled in 66 | self.submit_form() 67 | self.assert_page_contains("This field is required.", 3) 68 | 69 | # Fill in fields and re-submit 70 | self.fill_fields(self.signup_fields) 71 | 72 | self.submit_form() 73 | title = datetime.datetime.now().strftime("%B %Y") 74 | self.wait_for_load(title) 75 | 76 | self.logout() 77 | self.wait_for_load('Sign In') 78 | 79 | # Try to sign up again with same info; should fail 80 | self.browser.get(self.signup_url) 81 | self.wait_for_load('Sign Up') 82 | self.fill_fields(self.signup_fields) 83 | self.submit_form() 84 | self.wait_for_load('Sign Up') 85 | self.assert_page_contains("A user is already registered with this e-mail address.") 86 | 87 | # Activate account 88 | self.activate_user(self.USERNAME) 89 | 90 | # Log in successfully 91 | self.login(self.USERNAME, self.PASSWORD) 92 | self.wait_for_load(title) 93 | 94 | # TODO Fix and enable tests 95 | # # Change password 96 | # self.click_menu_item(self.user_information['id_first_name']) 97 | # self.wait_for_load('aria-expanded="true"') 98 | # self.click_menu_item('Change Password') 99 | # self.wait_for_load('Change Password') 100 | # self.submit_form() 101 | # self.wait_for_load('This field is required.') 102 | # self.assert_page_contains("This field is required.", 3) 103 | # self.fill_fields({'id_oldpassword': 'bogusvalue'}) 104 | # self.submit_form() 105 | # self.wait_for_load('Please type your current password.') 106 | # fields = { 107 | # 'id_oldpassword': self.PASSWORD, 108 | # 'id_password1': self.PASSWORD2, 109 | # 'id_password2': self.PASSWORD2, 110 | # } 111 | # self.fill_fields(fields) 112 | # self.submit_form() 113 | # self.wait_for_load('Change Password') 114 | # self.assert_page_contains("This field is required.", 0) 115 | # 116 | # # Test logout 117 | # self.logout() 118 | # 119 | # # Test reset password via email 120 | # self.click_menu_item('Sign In') 121 | # self.wait_for_load('Sign In') 122 | # self.click_element_by_link_text('Forgot Password?') 123 | # self.wait_for_load('Password Reset') 124 | # self.fill_fields_by_name({'email': 'bogus@example.com'}) 125 | # self.submit_form() 126 | # self.wait_for_load("The e-mail address is not assigned to any user account") 127 | # self.fill_fields_by_name({'email': self.USERNAME}) 128 | # self.submit_form() 129 | # self.wait_for_load('We have sent you an e-mail.') 130 | # # Retrieve and use email 131 | # email_text = self.retrieve_email(self.USERNAME, 'Password Reset E-mail') 132 | # reset_link = self.extract_link_from_email(email_text) 133 | # self.browser.get(reset_link) 134 | # self.wait_for_load('Change Password') 135 | # fields = { 136 | # 'id_password1': self.PASSWORD3, 137 | # 'id_password2': self.PASSWORD3, 138 | # } 139 | # self.fill_fields(fields) 140 | # self.submit_form() 141 | # self.assert_page_contains('Your password is now changed.') 142 | # 143 | # # Try to log in with old info 144 | # self.login(self.USERNAME, self.PASSWORD) 145 | # self.wait_for_load('The e-mail address and/or password you specified are not correct.') 146 | # 147 | # # Log in with updated info 148 | # self.login(self.USERNAME, self.PASSWORD3) 149 | # self.wait_for_load('Postings') 150 | # 151 | # # Update user information - no change 152 | # self.click_menu_item('Profile') 153 | # self.wait_for_load('aria-expanded="true"') 154 | # self.click_menu_item('Contact Info') 155 | # self.wait_for_load('Update Contact Information') 156 | # self.assert_fields(self.user_information) 157 | # self.submit_form() 158 | # self.wait_for_load('Postings') 159 | # 160 | # # Update user information 161 | # self.click_menu_item('Profile') 162 | # self.wait_for_load('aria-expanded="true"') 163 | # self.click_menu_item('Contact Info') 164 | # self.wait_for_load('Update Contact Information') 165 | # fields = { 166 | # 'id_email': selenium_settings.EMAIL_USERNAME, 167 | # } 168 | # self.fill_fields(fields) 169 | # self.submit_form() 170 | # self.wait_for_load(title) 171 | # 172 | # # Ensure updated information was saved 173 | # self.click_menu_item('Profile') 174 | # self.wait_for_load('aria-expanded="true"') 175 | # self.click_menu_item('Contact Info') 176 | # self.wait_for_load('Update Contact Information') 177 | # self.assert_fields(fields) 178 | -------------------------------------------------------------------------------- /eggtimer/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for eggtimer project. 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | 6 | from django.utils.dateparse import parse_datetime 7 | 8 | import dj_database_url 9 | from email.utils import formataddr 10 | 11 | load_dotenv() 12 | 13 | HOME_DIR = os.path.expanduser("~") 14 | BASE_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), os.path.pardir)) 15 | 16 | ADMINS = ( 17 | (os.environ.get('ADMIN_NAME', 'admin'), os.environ.get('ADMIN_EMAIL', 'example@example.com')), 18 | ) 19 | 20 | # Export a secret value in production; for local development, the default is good enough 21 | SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 22 | 'psu&83=i(4wgd@9*go=nps9=1rw#9b_w6psy4mp6yoxqv1i5g') 23 | 24 | # Use env setting if available, otherwise make debug false 25 | DEBUG = bool(int(os.environ.get('DJANGO_DEBUG', '0'))) 26 | 27 | ALLOWED_HOSTS = [ 28 | 'jessamynsmith.ca', 29 | 'eggtimer.jessamynsmith.ca', 30 | 'eggtimer.herokuapp.com', 31 | '165.227.42.38', 32 | 'localhost', 33 | '127.0.0.1' 34 | ] 35 | CSRF_TRUSTED_ORIGINS = ['https://eggtimer.jessamynsmith.ca'] 36 | CORS_ORIGIN_ALLOW_ALL = True 37 | 38 | SECURE_SSL_REDIRECT = bool(int(os.environ.get('DJANGO_ENABLE_SSL', '1'))) 39 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 40 | 41 | INSTALLED_APPS = [ 42 | 'django.contrib.admin', 43 | 'django.contrib.auth', 44 | 'django.contrib.contenttypes', 45 | 'django.contrib.sessions', 46 | 'django.contrib.sites', 47 | 'django.contrib.messages', 48 | 'django.contrib.staticfiles', 49 | 'custom_user', 50 | 'settings_context_processor', 51 | 'gunicorn', 52 | 'corsheaders', 53 | 'allauth', 54 | 'allauth.account', 55 | 'allauth.socialaccount', 56 | # TODO re-enable social accounts once ssl is set up 57 | # 'allauth.socialaccount.providers.facebook', 58 | # 'allauth.socialaccount.providers.github', 59 | 'rest_framework', 60 | 'rest_framework.authtoken', 61 | 'floppyforms', 62 | 'bootstrapform', 63 | 'timezone_field', 64 | 'periods', 65 | ] 66 | 67 | MIDDLEWARE_CLASSES = ( 68 | 'django.middleware.security.SecurityMiddleware', 69 | 'whitenoise.middleware.WhiteNoiseMiddleware', 70 | 'django.contrib.sessions.middleware.SessionMiddleware', 71 | 'corsheaders.middleware.CorsMiddleware', 72 | 'django.middleware.common.CommonMiddleware', 73 | 'django.middleware.csrf.CsrfViewMiddleware', 74 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 75 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 76 | 'django.contrib.messages.middleware.MessageMiddleware', 77 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 78 | 'periods.middleware.AddAuthTokenMiddleware', 79 | ) 80 | 81 | ROOT_URLCONF = 'eggtimer.urls' 82 | 83 | TEMPLATES = [ 84 | { 85 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 86 | 'DIRS': [ 87 | os.path.join(BASE_DIR, 'eggtimer', 'templates'), 88 | ], 89 | 'APP_DIRS': True, 90 | 'OPTIONS': { 91 | 'context_processors': [ 92 | "django.contrib.auth.context_processors.auth", 93 | 'django.template.context_processors.debug', 94 | 'django.template.context_processors.request', 95 | "django.template.context_processors.i18n", 96 | "django.template.context_processors.media", 97 | "django.template.context_processors.static", 98 | "django.template.context_processors.tz", 99 | "django.contrib.messages.context_processors.messages", 100 | "settings_context_processor.context_processors.settings", 101 | ], 102 | 'debug': DEBUG, 103 | }, 104 | }, 105 | ] 106 | 107 | # Python dotted path to the WSGI application used by Django's runserver. 108 | WSGI_APPLICATION = 'eggtimer.wsgi.application' 109 | 110 | # Parse database configuration from DATABASE_URL environment variable 111 | DATABASES = { 112 | 'default': dj_database_url.config( 113 | default="sqlite:///%s" % os.path.join(HOME_DIR, 'eggtimer', 'eggtimer.sqlite') 114 | ) 115 | } 116 | 117 | SITE_ID = 1 118 | 119 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 120 | 121 | TIME_ZONE = 'UTC' 122 | 123 | LANGUAGE_CODE = 'en-us' 124 | 125 | USE_I18N = True 126 | 127 | USE_L10N = True 128 | 129 | USE_TZ = True 130 | 131 | CACHES = { 132 | 'default': { 133 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 134 | } 135 | } 136 | 137 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 138 | STATIC_URL = '/static/' 139 | 140 | # Additional locations of static files 141 | STATICFILES_DIRS = ( 142 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 143 | # Always use forward slashes, even on Windows. 144 | # Don't forget to use absolute paths, not relative paths. 145 | os.path.join(BASE_DIR, 'bower_components'), 146 | os.path.join(BASE_DIR, 'eggtimer', 'static'), 147 | ) 148 | 149 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 150 | MEDIA_URL = '/media/' 151 | 152 | AUTHENTICATION_BACKENDS = ( 153 | # Needed to login by username in Django admin, regardless of `allauth` 154 | "django.contrib.auth.backends.ModelBackend", 155 | # `allauth` specific authentication methods, such as login by e-mail 156 | "allauth.account.auth_backends.AuthenticationBackend" 157 | ) 158 | 159 | # auth and allauth 160 | AUTH_USER_MODEL = 'periods.User' 161 | LOGIN_REDIRECT_URL = '/calendar/' 162 | ACCOUNT_USER_MODEL_USERNAME_FIELD = None 163 | ACCOUNT_EMAIL_REQUIRED = True 164 | ACCOUNT_USERNAME_REQUIRED = False 165 | ACCOUNT_AUTHENTICATION_METHOD = 'email' 166 | ACCOUNT_LOGOUT_ON_GET = True 167 | SOCIALACCOUNT_QUERY_EMAIL = True 168 | SOCIALACCOUNT_PROVIDERS = { 169 | 'facebook': { 170 | 'SCOPE': ['email'], 171 | 'METHOD': 'oauth2', 172 | } 173 | } 174 | 175 | ACCOUNT_ACTIVATION_DAYS = 14 176 | 177 | DEFAULT_FROM_EMAIL = formataddr(ADMINS[0]) 178 | REPLY_TO = ( 179 | os.environ.get('REPLY_TO_EMAIL', 'example@example.com'), 180 | ) 181 | EMAIL_HOST = 'smtp.sendgrid.net' 182 | EMAIL_PORT = 587 183 | EMAIL_HOST_USER = "apikey" 184 | EMAIL_HOST_PASSWORD = os.environ.get('SENDGRID_API_KEY') 185 | EMAIL_USE_TLS = True 186 | 187 | if not EMAIL_HOST_PASSWORD: 188 | EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' 189 | EMAIL_FILE_PATH = os.path.join(HOME_DIR, 'eggtimer', 'emails') 190 | 191 | REST_FRAMEWORK = { 192 | 'DEFAULT_PERMISSION_CLASSES': ( 193 | 'rest_framework.permissions.IsAuthenticated', 194 | ), 195 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 196 | 'rest_framework.authentication.TokenAuthentication', 197 | 'rest_framework.authentication.SessionAuthentication', 198 | ), 199 | 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',) 200 | } 201 | 202 | # US Navy API is used for moon phases 203 | # http://aa.usno.navy.mil/data/docs/api.php#phase 204 | MOON_PHASE_URL = 'http://api.usno.navy.mil' 205 | API_DATE_FORMAT = '%Y-%m-%d' 206 | US_DATE_FORMAT = '%-m/%-d/%Y' 207 | 208 | # TODO maybe this could be a django plugin? 209 | DEPLOY_DATE = parse_datetime(os.environ.get('DEPLOY_DATE', '')) 210 | VERSION = '0.6' 211 | TEMPLATE_VISIBLE_SETTINGS = ['DEPLOY_DATE', 'VERSION', 'ADMINS'] 212 | 213 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 214 | 215 | # if DEBUG: 216 | # INSTALLED_APPS.extend([ 217 | # 'django_extensions', 218 | # ]) 219 | 220 | LOGGING = { 221 | 'version': 1, 222 | 'disable_existing_loggers': False, 223 | 'filters': { 224 | 'require_debug_false': { 225 | '()': 'django.utils.log.RequireDebugFalse', 226 | }, 227 | 'require_debug_true': { 228 | '()': 'django.utils.log.RequireDebugTrue', 229 | }, 230 | }, 231 | 'formatters': { 232 | 'django.server': { 233 | '()': 'django.utils.log.ServerFormatter', 234 | 'format': '[%(server_time)s] %(message)s', 235 | } 236 | }, 237 | 'handlers': { 238 | 'console': { 239 | 'level': 'INFO', 240 | 'filters': ['require_debug_true'], 241 | 'class': 'logging.StreamHandler', 242 | }, 243 | # Custom handler which we will use with logger 'django'. 244 | # We want errors/warnings to be logged when DEBUG=False 245 | 'console_on_not_debug': { 246 | 'level': 'WARNING', 247 | 'filters': ['require_debug_false'], 248 | 'class': 'logging.StreamHandler', 249 | }, 250 | 'django.server': { 251 | 'level': 'INFO', 252 | 'class': 'logging.StreamHandler', 253 | 'formatter': 'django.server', 254 | }, 255 | 'mail_admins': { 256 | 'level': 'ERROR', 257 | 'filters': ['require_debug_false'], 258 | 'class': 'django.utils.log.AdminEmailHandler' 259 | } 260 | }, 261 | 'loggers': { 262 | 'django': { 263 | 'handlers': ['console', 'mail_admins', 'console_on_not_debug'], 264 | 'level': 'INFO', 265 | }, 266 | 'django.server': { 267 | 'handlers': ['django.server'], 268 | 'level': 'INFO', 269 | 'propagate': False, 270 | }, 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /selenium/base_test.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import logging 3 | import os 4 | import re 5 | import time 6 | import uuid 7 | import unittest 8 | 9 | from selenium import webdriver 10 | from selenium.webdriver.common.keys import Keys 11 | from selenium.common.exceptions import WebDriverException 12 | 13 | import selenium_settings 14 | 15 | logging.getLogger('selenium').setLevel(logging.ERROR) 16 | 17 | 18 | class SeleniumBaseTest(unittest.TestCase): 19 | 20 | def setUp(self): 21 | super(SeleniumBaseTest, self).setUp() 22 | self.guid = uuid.uuid1().hex[:8] 23 | logging.info("Running selenium tests for guid %s" % self.guid) 24 | self.base_url = self.get_base_url() 25 | self.admin_url = self.base_url + 'admin/' 26 | if selenium_settings.ENVIRONMENT_TYPE == 'dev' and not selenium_settings.EMAIL_FILE_PATH: 27 | raise Exception("Must add setting EMAIL_FILE_PATH if running in a 'dev' environment") 28 | self.browser = self.open_browser() 29 | 30 | def tearDown(self): 31 | if len(self._outcome.errors) == 0: 32 | self.browser.close() 33 | 34 | def get_url(self, url_settings): 35 | if (not hasattr(selenium_settings, 'ENVIRONMENT_TYPE') or 36 | not selenium_settings.ENVIRONMENT_TYPE): 37 | raise Exception("Must add setting ENVIRONMENT_TYPE") 38 | return url_settings[selenium_settings.ENVIRONMENT_TYPE] 39 | 40 | def get_base_url(self): 41 | return self.get_url(selenium_settings.BASE_URL) 42 | 43 | def open_browser(self): 44 | browser_constructor = getattr(webdriver, selenium_settings.BROWSER) 45 | return browser_constructor() 46 | 47 | def admin_login(self): 48 | self.browser.get(self.admin_url + 'login/') 49 | self.wait_for_load('Log in') 50 | fields = { 51 | 'id_username': selenium_settings.ADMIN_USERNAME, 52 | 'id_password': selenium_settings.ADMIN_PASSWORD, 53 | } 54 | self.fill_fields(fields) 55 | self.submit_form() 56 | self.wait_for_load('Site administration') 57 | 58 | def admin_logout(self): 59 | self.browser.get(self.admin_url + 'logout/') 60 | 61 | def retrieve_email(self, email_address, subject): 62 | email_text = '' 63 | if selenium_settings.ENVIRONMENT_TYPE == 'dev': 64 | time.sleep(selenium_settings.SLEEP_INTERVAL) 65 | email_files = [file for file in 66 | glob.glob(os.path.join(selenium_settings.EMAIL_FILE_PATH, '*.log'))] 67 | email_files.sort( 68 | key=lambda f: os.path.getmtime(os.path.join(selenium_settings.EMAIL_FILE_PATH, f)), 69 | reverse=True) 70 | for email_file in email_files: 71 | file_handle = open(os.path.join(selenium_settings.EMAIL_FILE_PATH, email_file)) 72 | email_text = file_handle.read() 73 | file_handle.close() 74 | if email_text.find(subject) >= 0: 75 | assert email_text.find(email_address) 76 | break 77 | return email_text 78 | 79 | def extract_link_from_email(self, email_text): 80 | email_text = email_text.replace('=\n', '') 81 | email_text = email_text.replace('=\r\n', '') 82 | links = re.search('[\S]*\/accounts\/[\S]*', email_text) 83 | link = links.group(0) 84 | print(link) 85 | domain = self.base_url.split('/')[-2] 86 | self.assertTrue(link.find(domain) >= 0, 87 | "Activation URL (%s) should be for domain under test (%s)." 88 | "Check Site setup in the Django admin!" 89 | % (link[:link.find('/', 8)], domain)) 90 | if selenium_settings.ENVIRONMENT_TYPE == 'dev': 91 | # Registration links need to be https for production 92 | link = link.replace('https', 'http') 93 | return link 94 | 95 | def activate_user(self, email_address): 96 | print(email_address) 97 | email_text = self.retrieve_email(email_address, 'Please Confirm Your E-mail Address') 98 | print(email_text) 99 | confirmation_link = self.extract_link_from_email(email_text) 100 | self.browser.get(confirmation_link) 101 | self.wait_for_load('Please confirm that') 102 | self.submit_form() 103 | self.wait_for_load('Sign In') 104 | 105 | def search_for_entities(self, search_text): 106 | self.fill_fields({'searchbar': search_text}) 107 | self.submit_form('changelist-search') 108 | self.wait_for_load(' result') 109 | 110 | # Get number of results 111 | form = self.browser.find_element_by_id('changelist-search') 112 | search_results = form.find_element_by_class_name('quiet') 113 | result_list = search_results.text.split(' ') 114 | return int(result_list[0]) 115 | 116 | def delete_entities(self, url, url_loaded_text, search_text, action): 117 | self.browser.get(url) 118 | self.wait_for_load(url_loaded_text) 119 | 120 | num_results = self.search_for_entities(search_text) 121 | 122 | if num_results > 0: 123 | # Delete test users 124 | self.click_element_by_id('action-toggle') 125 | self.select_option('action', action) 126 | self.submit_form(id='changelist-form') 127 | self.wait_for_load('Are you sure?') 128 | self.submit_form() 129 | self.wait_for_load('Successfully deleted') 130 | 131 | def fill_element(self, element, value): 132 | try: 133 | element.clear() 134 | except WebDriverException: 135 | pass 136 | element.send_keys(value) 137 | 138 | def fill_select2_field(self, clickable_field_id, input_field_id, value): 139 | top_level_element = self.browser.find_element_by_id(clickable_field_id) 140 | 141 | clickable_element = webdriver.ActionChains( 142 | self.browser).click_and_hold(top_level_element).release(top_level_element) 143 | clickable_element.perform() 144 | 145 | input_element = self.browser.find_element_by_id(input_field_id) 146 | input_element.send_keys(value + Keys.ENTER) 147 | 148 | def fill_fields(self, fields): 149 | for field in fields: 150 | element = self.browser.find_element_by_id(field) 151 | self.fill_element(element, fields[field]) 152 | 153 | def fill_fields_by_name(self, fields): 154 | for field in fields: 155 | element = self.browser.find_element_by_name(field) 156 | self.fill_element(element, fields[field]) 157 | 158 | def click_element_by_id(self, id): 159 | element = self.browser.find_element_by_id(id) 160 | element.click() 161 | 162 | def click_element_by_name(self, name): 163 | element = self.browser.find_element_by_name(name) 164 | element.click() 165 | 166 | def click_element_by_css_selector(self, selector): 167 | element = self.browser.find_element_by_css_selector(selector) 168 | element.click() 169 | 170 | def click_element_by_link_text(self, text): 171 | element = self.browser.find_element_by_partial_link_text(text) 172 | element.click() 173 | 174 | def select_option(self, name, value): 175 | element = self.browser.find_element_by_name(name) 176 | found = False 177 | for option in element.find_elements_by_tag_name('option'): 178 | if option.text == value: 179 | found = True 180 | option.click() 181 | break 182 | if not found: 183 | raise Exception("Could not find option '%s' in select '%s'" % (value, name)) 184 | 185 | def submit_form(self, id=None): 186 | if id: 187 | form = self.browser.find_element_by_id(id) 188 | else: 189 | form = self.browser.find_element_by_tag_name('form') 190 | form.submit() 191 | 192 | def page_contains(self, text): 193 | results = re.findall(text, self.browser.page_source) 194 | return len(results) 195 | 196 | def assert_page_contains(self, text, expected=1): 197 | found = self.page_contains(text) 198 | self.assertEqual(expected, found, 199 | "Expected %s occurrences of '%s', found %s" % (expected, text, found)) 200 | 201 | def assert_page_contains_by_css_selector(self, selector, value, expected=1): 202 | elements = self.browser.find_elements_by_css_selector(selector) 203 | found = 0 204 | for element in elements: 205 | if element.get_attribute('value') == value: 206 | found += 1 207 | self.assertEqual(expected, found, 208 | "Expected %s occurrences of '%s', found %s" % (expected, value, found)) 209 | 210 | def wait_for_load(self, text): 211 | found = 0 212 | time_slept = 0 213 | while not found: 214 | if time_slept > selenium_settings.MAX_SLEEP_TIME: 215 | raise Exception("Unable to find element '%s' on page" % text) 216 | time.sleep(selenium_settings.SLEEP_INTERVAL) 217 | time_slept += selenium_settings.SLEEP_INTERVAL 218 | found = self.page_contains(text) 219 | 220 | def assert_fields(self, fields): 221 | for field in fields: 222 | field_value = self.browser.find_element_by_id(field).get_attribute('value') 223 | self.assertEqual(fields[field], field_value) 224 | 225 | def assert_select2_choice(self, css_selector, value): 226 | elements = self.browser.find_elements_by_css_selector(css_selector) 227 | found = False 228 | for element in elements: 229 | if element.text == value: 230 | found = True 231 | break 232 | self.assertTrue(found, "Could not find element with value '%s'" % value) 233 | 234 | def assert_select2_single_choice(self, value): 235 | self.assert_select2_choice('span.select2-chosen', value) 236 | 237 | def assert_select2_multiple_choice(self, value): 238 | self.assert_select2_choice('li.select2-search-choice div', value) 239 | -------------------------------------------------------------------------------- /periods/static/periods/js/calendar.js: -------------------------------------------------------------------------------- 1 | formatMoment = function(instance, format) { 2 | if (instance !== null) { 3 | return instance.format(format); 4 | } 5 | return ''; 6 | }; 7 | 8 | formatMomentDate = function(instance) { 9 | return formatMoment(instance, 'YYYY-MM-DD'); 10 | }; 11 | 12 | var timezoneDate = function(moment, timezone, dateString) { 13 | return moment(dateString).tz(timezone); 14 | }; 15 | 16 | makeEvents = function(moment, timezone, data) { 17 | var events = []; 18 | var periodStartDates = []; 19 | 20 | data.forEach(function(item) { 21 | // TODO say "spotting" for spotting events 22 | var event = { 23 | title: 'period', 24 | itemId: item.id, 25 | itemType: item.type, 26 | start: timezoneDate(moment, timezone, item.timestamp), 27 | color: '#0f76ed', 28 | // Maybe someday allow dragging of period events 29 | editable: false 30 | }; 31 | 32 | var eventType = item.type; 33 | if (eventType == 'projected period') { 34 | periodStartDates.push(event.start); 35 | event.title = eventType; 36 | event.color = 'darkred'; 37 | } else if (eventType == 'projected ovulation') { 38 | event.title = eventType; 39 | event.color = 'purple'; 40 | } else { 41 | if (item.first_day) { 42 | event.title = '*' + event.title; 43 | periodStartDates.push(event.start); 44 | } 45 | } 46 | 47 | events.push(event); 48 | }); 49 | return {events: events, periodStartDates: periodStartDates}; 50 | }; 51 | 52 | addDayCounts = function(periodStartDates, firstDate, firstDay) { 53 | $('.day-count').remove(); 54 | if (!firstDay) { 55 | console.log("No days to add"); 56 | return; 57 | } 58 | var currentDay = firstDay; 59 | var nextPeriodStart = periodStartDates.shift(); 60 | $('.fc-day-number').each(function() { 61 | var currentDate = moment($(this).attr('data-date')); 62 | if (currentDate >= firstDate) { 63 | if (currentDate.isSame(nextPeriodStart, 'day')) { 64 | nextPeriodStart = periodStartDates.shift(); 65 | currentDay = 1; 66 | } 67 | $(this).append("

" + 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, '
') 209 | self.assertContains(response, '') 223 | self.assertContains(response, 'value="2014-01-31 19:00:00"') 224 | self.assertContains(response, '') 235 | self.assertContains(response, 'value="2015-02-25 00:00:00"') 236 | self.assertContains(response, 'first_day" checked') 237 | expected_select = '') 284 | 285 | 286 | class TestStatisticsViewsNoData(LoggedInUserTestCase): 287 | def setUp(self): 288 | super(TestStatisticsViewsNoData, self).setUp() 289 | self.url_path = reverse('statistics') 290 | 291 | def test_cycle_length_frequency(self): 292 | response = self.client.get('%scycle_length_frequency/' % self.url_path) 293 | 294 | self.assertEqual(200, response.status_code) 295 | self.assertEqual({'cycles': []}, response.json()) 296 | 297 | def test_cycle_length_history(self): 298 | response = self.client.get('%scycle_length_history/' % self.url_path) 299 | 300 | self.assertEqual(200, response.status_code) 301 | self.assertEqual({'cycles': []}, response.json()) 302 | 303 | def test_qigong_cycles(self): 304 | self.user.birth_date = None 305 | self.user.save() 306 | 307 | response = self.client.get('%sqigong_cycles/' % self.url_path) 308 | 309 | self.assertEqual(200, response.status_code) 310 | self.assertEqual({}, response.json()) 311 | 312 | def test_statistics(self): 313 | response = self.client.get(self.url_path) 314 | 315 | self.assertContains(response, 'Average (Last 6 Months):\n 28') 316 | self.assertContains(response, 'Average (All Time):\n 28') 317 | self.assertContains(response, 'Mean:\n ') 318 | 319 | 320 | class TestStatisticsViewsWithData(LoggedInUserTestCase): 321 | def setUp(self): 322 | super(TestStatisticsViewsWithData, self).setUp() 323 | FlowEventFactory(user=self.user) 324 | FlowEventFactory(user=self.user, 325 | timestamp=pytz.utc.localize(datetime.datetime(2014, 2, 28))) 326 | FlowEventFactory(user=self.user, 327 | timestamp=pytz.utc.localize(datetime.datetime(2014, 3, 26))) 328 | self.url_path = reverse('statistics') 329 | 330 | def test_cycle_length_frequency(self): 331 | response = self.client.get('%scycle_length_frequency/' % self.url_path) 332 | 333 | self.assertEqual(200, response.status_code) 334 | self.assertEqual({'cycles': [[28, 1], [26, 1]]}, response.json()) 335 | 336 | def test_cycle_length_history(self): 337 | response = self.client.get('%scycle_length_history/' % self.url_path) 338 | 339 | self.assertEqual(200, response.status_code) 340 | self.assertEqual({'cycles': [['2014-01-31', 28], ['2014-02-28', 26]]}, response.json()) 341 | 342 | @patch('periods.models.today') 343 | def test_qigong_cycles(self, mock_today): 344 | mock_today.return_value = pytz.utc.localize(datetime.datetime(1995, 3, 20)) 345 | 346 | response = self.client.get('%sqigong_cycles/' % self.url_path) 347 | 348 | self.assertEqual(200, response.status_code) 349 | 350 | expected = { 351 | 'physical': [['1995-03-01T00:00:00Z', 0], 352 | ['1995-03-12T12:00:00Z', 100], 353 | ['1995-03-20T00:00:00Z', 27], 354 | ['1995-03-24T00:00:00Z', 0], 355 | ['1995-04-03T00:00:00Z', 96]], 356 | 'emotional': [['1995-03-01T00:00:00Z', 0], 357 | ['1995-03-15T00:00:00Z', 100], 358 | ['1995-03-20T00:00:00Z', 72], 359 | ['1995-03-29T00:00:00Z', 0], 360 | ['1995-04-03T00:00:00Z', 28]], 361 | 'intellectual': [['1995-03-01T00:00:00Z', 0], 362 | ['1995-03-17T12:00:00Z', 100], 363 | ['1995-03-20T00:00:00Z', 94], 364 | ['1995-04-03T00:00:00Z', 0]], 365 | } 366 | self.assertEqual(expected, response.json()) 367 | 368 | def test_statistics(self): 369 | response = self.client.get(self.url_path) 370 | 371 | self.assertContains(response, 'Average (Last 6 Months):\n 27') 372 | self.assertContains(response, 'Average (All Time):\n 27') 373 | self.assertContains(response, 'Mean:\n 27.0') 374 | self.assertContains(response, 'Standard Deviation:\n 1.414') 375 | 376 | 377 | class TestProfileUpdateView(LoggedInUserTestCase): 378 | def setUp(self): 379 | super(TestProfileUpdateView, self).setUp() 380 | self.url_path = reverse('user_profile') 381 | 382 | def test_get(self): 383 | response = self.client.get(self.url_path) 384 | 385 | self.assertEqual(200, response.status_code) 386 | self.assertContains(response, '

Account Info for Jessamyn

') 387 | 388 | def test_post_invalid_data(self): 389 | data = { 390 | "birth_date": "blah", 391 | } 392 | 393 | response = self.client.post(self.url_path, data=data) 394 | 395 | self.assertEqual(200, response.status_code) 396 | user = period_models.User.objects.get(pk=self.user.pk) 397 | self.assertEqual(pytz.utc.localize(datetime.datetime(1995, 3, 1)), user.birth_date) 398 | self.assertContains(response, '

Account Info for %s

' % user.email) 399 | 400 | def test_post_valid_data(self): 401 | data = { 402 | "first_name": "Jess", 403 | "luteal_phase_length": "12", 404 | "_timezone": "America/New_York", 405 | } 406 | 407 | response = self.client.post(self.url_path, data=data, follow=True) 408 | 409 | self.assertEqual(200, response.status_code) 410 | self.assertEqual([('/accounts/profile/', 302)], response.redirect_chain) 411 | user = period_models.User.objects.get(pk=self.user.pk) 412 | self.assertEqual(u'Jess', user.first_name) 413 | self.assertEqual(12, user.luteal_phase_length) 414 | self.assertContains(response, '

Account Info for Jess

') 415 | 416 | 417 | class TestApiInfoView(LoggedInUserTestCase): 418 | 419 | def setUp(self): 420 | super(TestApiInfoView, self).setUp() 421 | self.url_path = reverse('api_info') 422 | 423 | def test_get(self): 424 | response = self.client.get(self.url_path) 425 | 426 | self.assertEqual(200, response.status_code) 427 | self.assertContains(response, '

API Info for Jessamyn

') 428 | 429 | 430 | class RegenerateKeyView(LoggedInUserTestCase): 431 | 432 | def setUp(self): 433 | super(RegenerateKeyView, self).setUp() 434 | self.url_path = reverse('regenerate_key') 435 | 436 | def test_get(self): 437 | response = self.client.get(self.url_path) 438 | 439 | self.assertEqual(405, response.status_code) 440 | 441 | def test_post(self): 442 | api_key = Token.objects.get(user=self.user).key 443 | 444 | response = self.client.post(self.url_path, follow=True) 445 | 446 | self.assertEqual(200, response.status_code) 447 | self.assertEqual([('/accounts/profile/api_info/', 302)], response.redirect_chain) 448 | user = period_models.User.objects.get(pk=self.user.pk) 449 | self.assertNotEquals(api_key, user.auth_token.key) 450 | --------------------------------------------------------------------------------