19 | {% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}
20 |
21 | {% endif %}
22 |
23 | {% if form.non_field_errors %}
24 | {% for error in form.non_field_errors %}
25 |
32 | {% if settings.OIDC_ENABLE %}
33 |
34 | Mozilla SSO
35 |
36 | {% else %}
37 |
57 | {% endif %}
58 |
61 |
62 | {% endblock %}
63 |
--------------------------------------------------------------------------------
/basket/news/tests/test_fields.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import ValidationError
2 | from django.test import TestCase
3 |
4 | from mock import call, Mock, patch
5 |
6 | from basket.news.fields import CommaSeparatedEmailField
7 |
8 |
9 | class CommaSeparatedEmailFieldTests(TestCase):
10 | def setUp(self):
11 | self.field = CommaSeparatedEmailField(blank=True)
12 |
13 | def test_validate(self):
14 | """
15 | Validate should run the email validator on all non-empty emails
16 | in the list.
17 | """
18 | with patch('basket.news.fields.validate_email') as validate_email:
19 | instance = Mock()
20 | self.field.attname = 'blah'
21 | instance.blah = ' foo@example.com ,bar@example.com '
22 | self.field.pre_save(instance, False)
23 | validate_email.assert_has_calls([
24 | call('foo@example.com'),
25 | call('bar@example.com'),
26 | ])
27 |
28 | validate_email.reset_mock()
29 | instance.blah = 'foo@example.com'
30 | self.field.pre_save(instance, False)
31 | validate_email.assert_has_calls([
32 | call('foo@example.com'),
33 | ])
34 |
35 | validate_email.reset_mock()
36 | instance.blah = ''
37 | self.field.pre_save(instance, False)
38 | self.assertFalse(validate_email.called)
39 |
40 | def test_invalid_email(self):
41 | instance = Mock()
42 | self.field.attname = 'blah'
43 | instance.blah = 'the.dude'
44 | with self.assertRaises(ValidationError):
45 | self.field.pre_save(instance, False)
46 |
47 | def test_pre_save(self):
48 | """pre_save should remove unnecessary whitespace and commas."""
49 | instance = Mock()
50 | self.field.attname = 'blah'
51 |
52 | # Basic
53 | instance.blah = 'bob@example.com,larry@example.com'
54 | self.assertEqual(self.field.pre_save(instance, False),
55 | 'bob@example.com,larry@example.com')
56 |
57 | # Excess whitespace
58 | instance.blah = ' bob@example.com ,larry@example.com '
59 | self.assertEqual(self.field.pre_save(instance, False),
60 | 'bob@example.com,larry@example.com')
61 |
62 | # Extra commas
63 | instance.blah = 'bob@example.com ,,,, larry@example.com '
64 | self.assertEqual(self.field.pre_save(instance, False),
65 | 'bob@example.com,larry@example.com')
66 |
--------------------------------------------------------------------------------
/bin/irc-notify.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -eo pipefail
3 |
4 | # Required environment variables if using --stage:
5 | # BRANCH_NAME, BUILD_NUMBER
6 |
7 | # defaults and constants
8 | NICK="hms-hellina"
9 | CHANNEL="#basket"
10 | SERVER="irc.mozilla.org:6697"
11 | BLUE_BUILD_URL="https://ci.us-west.moz.works/blue/organizations/jenkins/basket_multibranch_pipeline"
12 | BLUE_BUILD_URL="${BLUE_BUILD_URL}/detail/${BRANCH_NAME/\//%2f}/${BUILD_NUMBER}/pipeline"
13 | # colors and styles: values from the following links
14 | # http://www.mirc.com/colors.html
15 | # http://stackoverflow.com/a/13382032
16 | RED=$'\x034'
17 | YELLOW=$'\x038'
18 | GREEN=$'\x039'
19 | BLUE=$'\x0311'
20 | BOLD=$'\x02'
21 | NORMAL=$'\x0F'
22 |
23 | # parse cli args
24 | while [[ $# -gt 1 ]]; do
25 | key="$1"
26 | case $key in
27 | --stage)
28 | STAGE="$2"
29 | shift # past argument
30 | ;;
31 | --status)
32 | STATUS="$2"
33 | shift # past argument
34 | ;;
35 | -m|--message)
36 | MESSAGE="$2"
37 | shift # past argument
38 | ;;
39 | --irc_nick)
40 | NICK="$2"
41 | shift # past argument
42 | ;;
43 | --irc_server)
44 | SERVER="$2"
45 | shift # past argument
46 | ;;
47 | --irc_channel)
48 | CHANNEL="$2"
49 | shift # past argument
50 | ;;
51 | esac
52 | shift # past argument or value
53 | done
54 |
55 | if [[ -n "$STATUS" ]]; then
56 | STATUS=$(echo "$STATUS" | tr '[:lower:]' '[:upper:]')
57 | case "$STATUS" in
58 | 'SUCCESS')
59 | STATUS_COLOR="🎉 ${BOLD}${GREEN}"
60 | ;;
61 | 'SHIPPED')
62 | STATUS_COLOR="🚢 ${BOLD}${GREEN}"
63 | ;;
64 | 'WARNING')
65 | STATUS_COLOR="⚠️ ${BOLD}${YELLOW}"
66 | ;;
67 | 'FAILURE')
68 | STATUS_COLOR="🚨 ${BOLD}${RED}"
69 | ;;
70 | *)
71 | STATUS_COLOR="✨ $BLUE"
72 | ;;
73 | esac
74 | STATUS="${STATUS_COLOR}${STATUS}${NORMAL}: "
75 | fi
76 |
77 | if [[ -n "$STAGE" ]]; then
78 | MESSAGE="${STATUS}${STAGE}:"
79 | MESSAGE="$MESSAGE Branch ${BOLD}${BRANCH_NAME}${NORMAL} build #${BUILD_NUMBER}: ${BLUE_BUILD_URL}"
80 | elif [[ -n "$MESSAGE" ]]; then
81 | MESSAGE="${STATUS}${MESSAGE}"
82 | else
83 | echo "Missing required arguments"
84 | echo
85 | echo "Usage: irc-notify.sh [--stage STAGE]|[-m MESSAGE]"
86 | echo "Optional args: --status, --irc_nick, --irc_server, --irc_channel"
87 | exit 1
88 | fi
89 |
90 | if [[ -n "$BUILD_NUMBER" ]]; then
91 | NICK="${NICK}-${BUILD_NUMBER}"
92 | fi
93 |
94 | (
95 | echo "NICK ${NICK}"
96 | echo "USER ${NICK} 8 * : ${NICK}"
97 | sleep 5
98 | echo "JOIN ${CHANNEL}"
99 | echo "NOTICE ${CHANNEL} :${MESSAGE}"
100 | echo "QUIT"
101 | ) | openssl s_client -connect "$SERVER" > /dev/null 2>&1
102 |
--------------------------------------------------------------------------------
/basket/news/middleware.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django.conf import settings
4 | from django.core.exceptions import MiddlewareNotUsed
5 | from django.http import HttpResponsePermanentRedirect
6 | from django.http.request import split_domain_port
7 |
8 | from django_statsd.clients import statsd
9 | from django_statsd.middleware import GraphiteRequestTimingMiddleware
10 |
11 |
12 | IP_RE = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
13 |
14 |
15 | class GraphiteViewHitCountMiddleware(GraphiteRequestTimingMiddleware):
16 | """add hit counting to statsd's request timer."""
17 |
18 | def process_view(self, request, view_func, view_args, view_kwargs):
19 | super(GraphiteViewHitCountMiddleware, self).process_view(
20 | request, view_func, view_args, view_kwargs)
21 | if hasattr(request, '_view_name'):
22 | vmodule = request._view_module
23 | if vmodule.startswith('basket.'):
24 | vmodule = vmodule[7:]
25 | data = dict(module=vmodule,
26 | name=request._view_name,
27 | method=request.method)
28 | statsd.incr('view.count.{module}.{name}.{method}'.format(**data))
29 | statsd.incr('view.count.{module}.{method}'.format(**data))
30 | statsd.incr('view.count.{method}'.format(**data))
31 |
32 |
33 | class HostnameMiddleware(object):
34 | def __init__(self, get_response):
35 | values = [getattr(settings, x) for x in ['HOSTNAME', 'DEIS_APP',
36 | 'DEIS_RELEASE', 'DEIS_DOMAIN']]
37 | self.backend_server = '.'.join(x for x in values if x)
38 | self.get_response = get_response
39 |
40 | def __call__(self, request):
41 | response = self.get_response(request)
42 | response['X-Backend-Server'] = self.backend_server
43 | return response
44 |
45 |
46 | def is_ip_address(hostname):
47 | return bool(IP_RE.match(hostname))
48 |
49 |
50 | class EnforceHostnameMiddleware(object):
51 | """
52 | Enforce the hostname per the ENFORCE_HOSTNAME setting in the project's settings
53 |
54 | The ENFORCE_HOSTNAME can either be a single host or a list of acceptable hosts
55 |
56 | via http://www.michaelvdw.nl/code/force-hostname-with-django-middleware-for-heroku/
57 | """
58 | def __init__(self, get_response):
59 | self.allowed_hosts = settings.ENFORCE_HOSTNAME
60 | self.get_response = get_response
61 | if settings.DEBUG or not self.allowed_hosts:
62 | raise MiddlewareNotUsed
63 |
64 | def __call__(self, request):
65 | """Enforce the host name"""
66 | host = request.get_host()
67 | domain, port = split_domain_port(host)
68 | if domain in self.allowed_hosts or is_ip_address(domain):
69 | return self.get_response(request)
70 |
71 | # redirect to the proper host name\
72 | new_url = "%s://%s%s" % (
73 | 'https' if request.is_secure() else 'http',
74 | self.allowed_hosts[0], request.get_full_path())
75 |
76 | return HttpResponsePermanentRedirect(new_url)
77 |
--------------------------------------------------------------------------------
/basket/news/management/commands/process_donations_queue.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function, unicode_literals
2 |
3 | import json
4 | import sys
5 | from time import time
6 |
7 | from django.conf import settings
8 | from django.core.management import BaseCommand, CommandError
9 |
10 | import boto3
11 | import requests
12 | from django_statsd.clients import statsd
13 | from raven.contrib.django.raven_compat.models import client as sentry_client
14 |
15 | from basket.news.tasks import process_donation, process_donation_event
16 |
17 |
18 | class Command(BaseCommand):
19 | snitch_delay = 300 # 5 min
20 | snitch_last_timestamp = 0
21 | snitch_id = settings.DONATE_SNITCH_ID
22 |
23 | def snitch(self):
24 | if not self.snitch_id:
25 | return
26 |
27 | time_since = int(time() - self.snitch_last_timestamp)
28 | if time_since > self.snitch_delay:
29 | requests.post('https://nosnch.in/{}'.format(self.snitch_id))
30 | self.snitch_last_timestamp = time()
31 |
32 | def handle(self, *args, **options):
33 | if not settings.DONATE_ACCESS_KEY_ID:
34 | raise CommandError('AWS SQS Credentials not configured')
35 |
36 | sqs = boto3.resource('sqs',
37 | region_name=settings.DONATE_QUEUE_REGION,
38 | aws_access_key_id=settings.DONATE_ACCESS_KEY_ID,
39 | aws_secret_access_key=settings.DONATE_SECRET_ACCESS_KEY)
40 | queue = sqs.Queue(settings.DONATE_QUEUE_URL)
41 |
42 | try:
43 | # Poll for messages indefinitely.
44 | while True:
45 | self.snitch()
46 | msgs = queue.receive_messages(WaitTimeSeconds=settings.DONATE_QUEUE_WAIT_TIME,
47 | MaxNumberOfMessages=10)
48 | for msg in msgs:
49 | if not (msg and msg.body):
50 | continue
51 |
52 | statsd.incr('mofo.donations.message.received')
53 | try:
54 | data = json.loads(msg.body)
55 | except ValueError as e:
56 | # body was not JSON
57 | statsd.incr('mofo.donations.message.json_error')
58 | sentry_client.captureException(data={'extra': {'msg.body': msg.body}})
59 | print('ERROR:', e, '::', msg.body)
60 | msg.delete()
61 | continue
62 |
63 | try:
64 | etype = data['data'].setdefault('event_type', 'donation')
65 | if etype == 'donation':
66 | process_donation.delay(data['data'])
67 | else:
68 | process_donation_event.delay(data['data'])
69 | except Exception:
70 | # something's wrong with the queue. try again.
71 | statsd.incr('mofo.donations.message.queue_error')
72 | sentry_client.captureException(tags={'action': 'retried'})
73 | continue
74 |
75 | statsd.incr('mofo.donations.message.success')
76 | msg.delete()
77 | except KeyboardInterrupt:
78 | sys.exit('\nBuh bye')
79 |
--------------------------------------------------------------------------------
/jenkins/default.groovy:
--------------------------------------------------------------------------------
1 | milestone()
2 | stage ('Build Images') {
3 | // make sure we should continue
4 | env.DOCKER_REPOSITORY = 'mozmeao/basket'
5 | env.DOCKER_IMAGE_TAG = "${env.DOCKER_REPOSITORY}:${env.GIT_COMMIT}"
6 | if ( config.require_tag ) {
7 | try {
8 | sh 'docker/bin/check_if_tag.sh'
9 | } catch(err) {
10 | utils.ircNotification([stage: 'Git Tag Check', status: 'failure'])
11 | throw err
12 | }
13 | }
14 | utils.ircNotification([stage: 'Test & Deploy', status: 'starting'])
15 | lock ("basket-docker-${env.GIT_COMMIT}") {
16 | try {
17 | sh 'docker/bin/build_images.sh'
18 | sh 'docker/bin/run_tests.sh'
19 | } catch(err) {
20 | utils.ircNotification([stage: 'Docker Build', status: 'failure'])
21 | throw err
22 | }
23 | }
24 | }
25 |
26 | milestone()
27 | stage ('Push Public Images') {
28 | try {
29 | utils.pushDockerhub()
30 | } catch(err) {
31 | utils.ircNotification([stage: 'Dockerhub Push', status: 'failure'])
32 | throw err
33 | }
34 | }
35 |
36 | /**
37 | * Do region first because deployment and testing should work like this:
38 | * region1:
39 | * push image -> deploy app1 -> test app1 -> deploy app2 -> test app2
40 | * region2:
41 | * push image -> deploy app1 -> test app1 -> deploy app2 -> test app2
42 | *
43 | * A failure at any step of the above should fail the entire job
44 | */
45 | if ( config.apps ) {
46 | milestone()
47 | // default to oregon-b only
48 | def regions = config.regions ?: ['oregon-b']
49 | for (regionId in regions) {
50 | def region = global_config.regions[regionId]
51 | if ( region.db_mode == 'rw' && config.apps_rw ) {
52 | region_apps = config.apps + config.apps_rw
53 | } else {
54 | region_apps = config.apps
55 | }
56 | for (appname in region_apps) {
57 | appURL = "https://${appname}.${region.name}.moz.works"
58 | stageName = "Deploy ${appname}-${region.name}"
59 | lock (stageName) {
60 | milestone()
61 | stage (stageName) {
62 | // do post deploy if this is an RW app or if there are no RW apps configured
63 | if ( region.db_mode == 'rw' && config.apps_post_deploy && config.apps_post_deploy.contains(appname) ) {
64 | post_deploy = 'true'
65 | } else {
66 | post_deploy = 'false'
67 | }
68 | withEnv(["DEIS_PROFILE=${region.deis_profile}",
69 | "RUN_POST_DEPLOY=${post_deploy}",
70 | "REGION_NAME=${region.name}",
71 | "DEIS_APPLICATION=${appname}"]) {
72 | withCredentials([string(credentialsId: 'newrelic-api-key', variable: 'NEWRELIC_API_KEY')]) {
73 | withCredentials([string(credentialsId: 'datadog-api-key', variable: 'DATADOG_API_KEY')]) {
74 | try {
75 | retry(2) {
76 | sh 'docker/bin/push2deis.sh'
77 | }
78 | } catch(err) {
79 | utils.ircNotification([stage: stageName, status: 'failure'])
80 | throw err
81 | }
82 | }
83 | }
84 | }
85 | utils.ircNotification([message: appURL, status: 'shipped'])
86 | }
87 | }
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/basket/news/forms.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django import forms
4 | from django.core.exceptions import ValidationError
5 | from django.core.validators import RegexValidator
6 | from product_details import product_details
7 |
8 | from basket.news.newsletters import newsletter_field_choices
9 | from basket.news.utils import parse_newsletters_csv, process_email, LANG_RE
10 |
11 |
12 | FORMATS = (('H', 'HTML'), ('T', 'Text'))
13 | SOURCE_URL_RE = re.compile(r'^https?://')
14 |
15 |
16 | class EmailForm(forms.Form):
17 | """Form to validate email addresses"""
18 | email = forms.EmailField()
19 |
20 |
21 | class EmailField(forms.CharField):
22 | """EmailField with better validation and value cleaning"""
23 | def to_python(self, value):
24 | value = super(EmailField, self).to_python(value)
25 | email = process_email(value)
26 | if not email:
27 | raise ValidationError('Enter a valid email address.', 'invalid')
28 |
29 | return email
30 |
31 |
32 | class NewslettersField(forms.MultipleChoiceField):
33 | """
34 | Django form field that validates the newsletter IDs are valid
35 |
36 | * Accepts single newsletter IDs in multiple fields, and/or
37 | a comma separated list of newsletter IDs in a single field.
38 | * Validates each individual newsletter ID.
39 | * Includes newsletter group IDs.
40 | """
41 | def __init__(self, required=True, widget=None, label=None, initial=None,
42 | help_text='', *args, **kwargs):
43 | super(NewslettersField, self).__init__(newsletter_field_choices, required, widget, label,
44 | initial, help_text, *args, **kwargs)
45 |
46 | def to_python(self, value):
47 | value = super(NewslettersField, self).to_python(value)
48 | full_list = []
49 | for v in value:
50 | full_list.extend(parse_newsletters_csv(v))
51 |
52 | return full_list
53 |
54 |
55 | def country_choices():
56 | """Upper and Lower case country codes"""
57 | regions = product_details.get_regions('en-US')
58 | return regions.items() + [(code.upper(), name) for code, name in regions.iteritems()]
59 |
60 |
61 | class SubscribeForm(forms.Form):
62 | email = EmailField()
63 | newsletters = NewslettersField()
64 | privacy = forms.BooleanField()
65 | fmt = forms.ChoiceField(required=False, choices=FORMATS)
66 | source_url = forms.CharField(required=False)
67 | first_name = forms.CharField(required=False)
68 | last_name = forms.CharField(required=False)
69 | country = forms.ChoiceField(required=False, choices=country_choices)
70 | lang = forms.CharField(required=False, validators=[RegexValidator(regex=LANG_RE)])
71 |
72 | def clean_source_url(self):
73 | source_url = self.cleaned_data['source_url']
74 | if source_url:
75 | if SOURCE_URL_RE.match(source_url):
76 | return source_url
77 |
78 | return ''
79 |
80 | def clean_country(self):
81 | country = self.cleaned_data['country']
82 | if country:
83 | return country.lower()
84 |
85 | return country
86 |
87 |
88 | class UpdateUserMeta(forms.Form):
89 | source_url = forms.CharField(required=False)
90 | first_name = forms.CharField(required=False)
91 | last_name = forms.CharField(required=False)
92 | country = forms.ChoiceField(required=False, choices=country_choices)
93 | lang = forms.CharField(required=False, validators=[RegexValidator(regex=LANG_RE)])
94 |
95 | def clean_country(self):
96 | country = self.cleaned_data['country']
97 | if country:
98 | return country.lower()
99 |
100 | return country
101 |
--------------------------------------------------------------------------------
/basket/news/tests/test_models.py:
--------------------------------------------------------------------------------
1 | from django.core import mail
2 | from django.test import TestCase
3 |
4 | from mock import patch
5 |
6 | from basket.news import models
7 |
8 |
9 | class FailedTaskTest(TestCase):
10 | good_task_args = [{'case_type': 'ringer', 'email': 'dude@example.com'}, 'walter']
11 |
12 | def test_retry_with_dict(self):
13 | """When given args with a simple dict, subtask should get matching arguments."""
14 | task_name = 'make_a_caucasian'
15 | task = models.FailedTask.objects.create(task_id='el-dudarino',
16 | name=task_name,
17 | args=self.good_task_args)
18 | with patch.object(models.celery_app, 'send_task') as sub_mock:
19 | task.retry()
20 |
21 | sub_mock.assert_called_with(task_name, args=self.good_task_args, kwargs={})
22 |
23 | def test_retry_with_querydict(self):
24 | """When given args with a QueryDict, subtask should get a dict."""
25 | task_name = 'make_a_caucasian'
26 | task_args = [{'case_type': ['ringer'], 'email': ['dude@example.com']}, 'walter']
27 | task = models.FailedTask.objects.create(task_id='el-dudarino',
28 | name=task_name,
29 | args=task_args)
30 | with patch.object(models.celery_app, 'send_task') as sub_mock:
31 | task.retry()
32 |
33 | sub_mock.assert_called_with(task_name, args=self.good_task_args, kwargs={})
34 |
35 | def test_retry_with_querydict_not_first(self):
36 | """When given args with a QueryDict in any position, subtask should get a dict."""
37 | task_name = 'make_a_caucasian'
38 | task_args = ['donny', {'case_type': ['ringer'], 'email': ['dude@example.com']}, 'walter']
39 | task = models.FailedTask.objects.create(task_id='el-dudarino',
40 | name=task_name,
41 | args=task_args)
42 | with patch.object(models.celery_app, 'send_task') as sub_mock:
43 | task.retry()
44 |
45 | sub_mock.assert_called_with(task_name, args=['donny'] + self.good_task_args, kwargs={})
46 |
47 | def test_retry_with_almost_querydict(self):
48 | """When given args with a dict with a list, subtask should get a same args."""
49 | task_name = 'make_a_caucasian'
50 | task_args = [{'case_type': 'ringer', 'email': ['dude@example.com']}, 'walter']
51 | task = models.FailedTask.objects.create(task_id='el-dudarino',
52 | name=task_name,
53 | args=task_args)
54 | with patch.object(models.celery_app, 'send_task') as sub_mock:
55 | task.retry()
56 |
57 | sub_mock.assert_called_with(task_name, args=task_args, kwargs={})
58 |
59 |
60 | class InterestTests(TestCase):
61 | def test_notify_default_stewards(self):
62 | """
63 | If there are no locale-specific stewards for the given language,
64 | notify the default stewards.
65 | """
66 | interest = models.Interest(title='mytest',
67 | default_steward_emails='bob@example.com,bill@example.com')
68 | interest.notify_stewards('Steve', 'interested@example.com', 'en-US', 'BYE')
69 |
70 | self.assertEqual(len(mail.outbox), 1)
71 | email = mail.outbox[0]
72 | self.assertTrue('mytest' in email.subject)
73 | self.assertEqual(email.to, ['bob@example.com', 'bill@example.com'])
74 |
75 | def test_notify_locale_stewards(self):
76 | """
77 | If there are locale-specific stewards for the given language,
78 | notify them instead of the default stewards.
79 | """
80 | interest = models.Interest.objects.create(
81 | title='mytest',
82 | default_steward_emails='bob@example.com,bill@example.com')
83 | models.LocaleStewards.objects.create(
84 | interest=interest,
85 | locale='ach',
86 | emails='ach@example.com')
87 | interest.notify_stewards('Steve', 'interested@example.com', 'ach', 'BYE')
88 |
89 | self.assertEqual(len(mail.outbox), 1)
90 | email = mail.outbox[0]
91 | self.assertTrue('mytest' in email.subject)
92 | self.assertEqual(email.to, ['ach@example.com'])
93 |
--------------------------------------------------------------------------------
/basket/news/tests/test_newsletters.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf8 -*-
2 |
3 | from django.test import TestCase
4 |
5 | from basket.news import newsletters, utils
6 | from basket.news.models import Newsletter, NewsletterGroup, LocalizedSMSMessage
7 |
8 |
9 | class TestSMSMessageCache(TestCase):
10 | def setUp(self):
11 | newsletters.clear_sms_cache()
12 | LocalizedSMSMessage.objects.create(message_id='the-dude', vendor_id='YOURE_NOT_WRONG_WALTER',
13 | country='us', language='de')
14 | LocalizedSMSMessage.objects.create(message_id='the-walrus', vendor_id='SHUTUP_DONNIE',
15 | country='gb', language='en-GB')
16 |
17 | def test_all_messages(self):
18 | """Messages returned should be all of the ones in the DB."""
19 |
20 | self.assertEqual(newsletters.get_sms_messages(), {
21 | 'the-dude-us-de': 'YOURE_NOT_WRONG_WALTER',
22 | 'the-walrus-gb-en-gb': 'SHUTUP_DONNIE',
23 | })
24 |
25 |
26 | class TestNewsletterUtils(TestCase):
27 | def setUp(self):
28 | self.newsies = [
29 | Newsletter.objects.create(
30 | slug='bowling',
31 | title='Bowling, Man',
32 | vendor_id='BOWLING',
33 | languages='en'),
34 | Newsletter.objects.create(
35 | slug='surfing',
36 | title='Surfing, Man',
37 | vendor_id='SURFING',
38 | languages='en'),
39 | Newsletter.objects.create(
40 | slug='extorting',
41 | title='Beginning Nihilism',
42 | vendor_id='EXTORTING',
43 | languages='en'),
44 | Newsletter.objects.create(
45 | slug='papers',
46 | title='Just papers, personal papers',
47 | vendor_id='CREEDENCE',
48 | languages='en',
49 | private=True),
50 | ]
51 | self.groupies = [
52 | NewsletterGroup.objects.create(
53 | slug='bowling',
54 | title='Bowling in Groups',
55 | active=True),
56 | NewsletterGroup.objects.create(
57 | slug='abiding',
58 | title='Be like The Dude',
59 | active=True),
60 | NewsletterGroup.objects.create(
61 | slug='failing',
62 | title='The Bums Lost!',
63 | active=False),
64 | ]
65 | self.groupies[0].newsletters.add(self.newsies[1], self.newsies[2])
66 |
67 | def test_newseltter_private_slugs(self):
68 | self.assertEqual(newsletters.newsletter_private_slugs(), ['papers'])
69 |
70 | def test_newsletter_slugs(self):
71 | self.assertEqual(set(newsletters.newsletter_slugs()),
72 | {'bowling', 'surfing', 'extorting', 'papers'})
73 |
74 | def test_newsletter_group_slugs(self):
75 | self.assertEqual(set(newsletters.newsletter_group_slugs()),
76 | {'bowling', 'abiding'})
77 |
78 | def test_newsletter_and_group_slugs(self):
79 | self.assertEqual(set(newsletters.newsletter_and_group_slugs()),
80 | {'bowling', 'abiding', 'surfing', 'extorting', 'papers'})
81 |
82 | def test_newsletter_group_newsletter_slugs(self):
83 | self.assertEqual(set(newsletters.newsletter_group_newsletter_slugs('bowling')),
84 | {'extorting', 'surfing'})
85 |
86 | def test_parse_newsletters_for_groups(self):
87 | """If newsletter slug is a group for SUBSCRIBE, expand to group's newsletters."""
88 | subs = utils.parse_newsletters(utils.SUBSCRIBE, ['bowling'], list())
89 | self.assertTrue(subs['surfing'])
90 | self.assertTrue(subs['extorting'])
91 |
92 | def test_parse_newsletters_not_groups_set(self):
93 | """If newsletter slug is a group for SET mode, don't expand to group's newsletters."""
94 | subs = utils.parse_newsletters(utils.SET, ['bowling'], list())
95 | self.assertDictEqual(subs, {'bowling': True})
96 |
97 | def test_parse_newsletters_not_groups_unsubscribe(self):
98 | """If newsletter slug is a group for SET mode, don't expand to group's newsletters."""
99 | subs = utils.parse_newsletters(utils.UNSUBSCRIBE, ['bowling'],
100 | ['bowling', 'surfing', 'extorting'])
101 | self.assertDictEqual(subs, {'bowling': False})
102 |
--------------------------------------------------------------------------------
/basket/news/management/commands/process_fxa_queue.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function, unicode_literals
2 |
3 | import json
4 | import sys
5 | from time import time
6 |
7 | from django.conf import settings
8 | from django.core.management import BaseCommand, CommandError
9 |
10 | import boto3
11 | import requests
12 | from django_statsd.clients import statsd
13 | from raven.contrib.django.raven_compat.models import client as sentry_client
14 |
15 | from basket.news.tasks import fxa_delete, fxa_email_changed, fxa_login, fxa_verified
16 |
17 |
18 | # TODO remove this after the cutover
19 | class FxATSProxyTask(object):
20 | """Fake task that will only fire the real task after timestamp"""
21 | def __init__(self, task, timestamp):
22 | self.task = task
23 | self.ts = timestamp
24 |
25 | def delay(self, data):
26 | if not self.ts or data['ts'] < self.ts:
27 | return
28 |
29 | self.task.delay(data)
30 |
31 |
32 | FXA_EVENT_TYPES = {
33 | 'delete': fxa_delete,
34 | 'verified': fxa_verified,
35 | 'primaryEmailChanged': fxa_email_changed,
36 | 'login': FxATSProxyTask(fxa_login, settings.FXA_LOGIN_CUTOVER_TIMESTAMP),
37 | }
38 |
39 |
40 | class Command(BaseCommand):
41 | snitch_delay = 300 # 5 min
42 | snitch_last_timestamp = 0
43 | snitch_id = settings.FXA_EVENTS_SNITCH_ID
44 |
45 | def snitch(self):
46 | if not self.snitch_id:
47 | return
48 |
49 | time_since = int(time() - self.snitch_last_timestamp)
50 | if time_since > self.snitch_delay:
51 | requests.post('https://nosnch.in/{}'.format(self.snitch_id))
52 | self.snitch_last_timestamp = time()
53 |
54 | def handle(self, *args, **options):
55 | if not settings.FXA_EVENTS_ACCESS_KEY_ID:
56 | raise CommandError('AWS SQS Credentials not configured')
57 |
58 | if not settings.FXA_EVENTS_QUEUE_ENABLE:
59 | raise CommandError('FxA Events Queue is not enabled')
60 |
61 | sqs = boto3.resource('sqs',
62 | region_name=settings.FXA_EVENTS_QUEUE_REGION,
63 | aws_access_key_id=settings.FXA_EVENTS_ACCESS_KEY_ID,
64 | aws_secret_access_key=settings.FXA_EVENTS_SECRET_ACCESS_KEY)
65 | queue = sqs.Queue(settings.FXA_EVENTS_QUEUE_URL)
66 |
67 | try:
68 | # Poll for messages indefinitely.
69 | while True:
70 | self.snitch()
71 | msgs = queue.receive_messages(WaitTimeSeconds=settings.FXA_EVENTS_QUEUE_WAIT_TIME,
72 | MaxNumberOfMessages=10)
73 | for msg in msgs:
74 | if not (msg and msg.body):
75 | continue
76 |
77 | statsd.incr('fxa.events.message.received')
78 | try:
79 | data = json.loads(msg.body)
80 | event = json.loads(data['Message'])
81 | except ValueError as e:
82 | # body was not JSON
83 | statsd.incr('fxa.events.message.json_error')
84 | sentry_client.captureException(data={'extra': {'msg.body': msg.body}})
85 | print('ERROR:', e, '::', msg.body)
86 | msg.delete()
87 | continue
88 |
89 | event_type = event.get('event', '__NONE__').replace(':', '-')
90 | statsd.incr('fxa.events.message.received.{}'.format(event_type))
91 | if event_type not in FXA_EVENT_TYPES:
92 | statsd.incr('fxa.events.message.received.{}.IGNORED'.format(event_type))
93 | # we can safely remove from the queue message types we don't need
94 | # this keeps the queue from filling up with old messages
95 | msg.delete()
96 | continue
97 |
98 | try:
99 | FXA_EVENT_TYPES[event_type].delay(event)
100 | except Exception:
101 | # something's wrong with the queue. try again.
102 | statsd.incr('fxa.events.message.queue_error')
103 | sentry_client.captureException(tags={'action': 'retried'})
104 | continue
105 |
106 | statsd.incr('fxa.events.message.success')
107 | msg.delete()
108 | except KeyboardInterrupt:
109 | sys.exit('\nBuh bye')
110 |
--------------------------------------------------------------------------------
/requirements/prod.txt:
--------------------------------------------------------------------------------
1 | -r dev.txt
2 |
3 | django-redis==4.2.0 \
4 | --hash=sha256:9ad6b299458f7e6bfaefa8905f52560017369d82fb8fb0ed4b41adc048dbf11c
5 | gunicorn==19.7.1 \
6 | --hash=sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6 \
7 | --hash=sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622
8 | redis==2.10.3 \
9 | --hash=sha256:a4fb37b02860f6b1617f6469487471fd086dd2d38bbce640c2055862b9c4019c
10 | hiredis==0.2.0 \
11 | --hash=sha256:ca958e13128e49674aa4a96f02746f5de5973f39b57297b84d59fd44d314d5b5
12 | msgpack-python==0.4.6 \
13 | --hash=sha256:bfcc581c9dbbf07cc2f951baf30c3249a57e20dcbd60f7e6ffc43ab3cc614794
14 | whitenoise==3.3.1 \
15 | --hash=sha256:15f43b2e701821b95c9016cf469d29e2a546cb1c7dead584ba82c36f843995cf \
16 | --hash=sha256:9d81515f2b5b27051910996e1e860b1332e354d9e7bcf30c98f21dcb6713e0dd
17 | newrelic==2.100.0.84 \
18 | --hash=sha256:b75123173ac5e8a20aa9d8120e20a7bf45c38a5aa5a4672fac6ce4c3e0c8046e
19 | urlwait==0.4 \
20 | --hash=sha256:fc39ff2c8abbcaad5043e1f79699dcb15a036cc4b0ff4d1aa825ea105d4889ff \
21 | --hash=sha256:395fc0c2a7f9736858a2c2f449aa20c6e9da1f86bfc2d1fda4f2f5b78a5c115a
22 | MySQL-python==1.2.5 \
23 | --hash=sha256:ab22d1322099098730a57fd59d610f60738f95a1cb68dacca2d1c47cb0cbe8ee \
24 | --hash=sha256:811040b647e5d5686f84db415efd697e6250008b112b6909ba77ac059e140c74
25 | certifi==2017.11.5 \
26 | --hash=sha256:244be0d93b71e93fc0a0a479862051414d0e00e16435707e5bf5000f92e04694 \
27 | --hash=sha256:5ec74291ca1136b40f0379e1128ff80e866597e4e2c1e755739a913bbc3613c0
28 | chardet==3.0.4 \
29 | --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \
30 | --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae
31 | josepy==1.0.1 \
32 | --hash=sha256:354a3513038a38bbcd27c97b7c68a8f3dfaff0a135b20a92c6db4cc4ea72915e \
33 | --hash=sha256:9f48b88ca37f0244238b1cc77723989f7c54f7b90b2eee6294390bacfe870acc
34 | mozilla-django-oidc==0.4.2 \
35 | --hash=sha256:77c29c47d67750d3c53fcd51f1aa496a2cdd65dd27a1f2a15e56ecc3c3714f19 \
36 | --hash=sha256:650716143525bb4bae553dd8c740a1c5986baf6aeae115cba01f6a217ee5fa4f
37 | packaging==16.8 \
38 | --hash=sha256:99276dc6e3a7851f32027a68f1095cd3f77c148091b092ea867a351811cfe388 \
39 | --hash=sha256:5d50835fdf0a7edf0b55e311b7c887786504efea1177abd7e69329a8e5ea619e
40 | pyOpenSSL==17.5.0 \
41 | --hash=sha256:07a2de1a54de07448732a81e38a55df7da109b2f47f599f8bb35b0cbec69d4bd \
42 | --hash=sha256:2c10cfba46a52c0b0950118981d61e72c1e5b1aac451ca1bc77de1a679456773
43 | pyparsing==2.2.0 \
44 | --hash=sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010 \
45 | --hash=sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04 \
46 | --hash=sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e \
47 | --hash=sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07 \
48 | --hash=sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5 \
49 | --hash=sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18 \
50 | --hash=sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58
51 | urllib3==1.22 \
52 | --hash=sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b \
53 | --hash=sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f
54 | greenlet==0.4.13 \
55 | --hash=sha256:50643fd6d54fd919f9a0a577c5f7b71f5d21f0959ab48767bd4bb73ae0839500 \
56 | --hash=sha256:c7b04a6dc74087b1598de8d713198de4718fa30ec6cbb84959b26426c198e041 \
57 | --hash=sha256:b6ef0cabaf5a6ecb5ac122e689d25ba12433a90c7b067b12e5f28bdb7fb78254 \
58 | --hash=sha256:fcfadaf4bf68a27e5dc2f42cbb2f4b4ceea9f05d1d0b8f7787e640bed2801634 \
59 | --hash=sha256:b417bb7ff680d43e7bd7a13e2e08956fa6acb11fd432f74c97b7664f8bdb6ec1 \
60 | --hash=sha256:769b740aeebd584cd59232be84fdcaf6270b8adc356596cdea5b2152c82caaac \
61 | --hash=sha256:c2de19c88bdb0366c976cc125dca1002ec1b346989d59524178adfd395e62421 \
62 | --hash=sha256:5b49b3049697aeae17ef7bf21267e69972d9e04917658b4e788986ea5cc518e8 \
63 | --hash=sha256:09ef2636ea35782364c830f07127d6c7a70542b178268714a9a9ba16318e7e8b \
64 | --hash=sha256:f8f2a0ae8de0b49c7b5b2daca4f150fdd9c1173e854df2cce3b04123244f9f45 \
65 | --hash=sha256:1b7df09c6598f5cfb40f843ade14ed1eb40596e75cd79b6fa2efc750ba01bb01 \
66 | --hash=sha256:75c413551a436b462d5929255b6dc9c0c3c2b25cbeaee5271a56c7fda8ca49c0 \
67 | --hash=sha256:58798b5d30054bb4f6cf0f712f08e6092df23a718b69000786634a265e8911a9 \
68 | --hash=sha256:42118bf608e0288e35304b449a2d87e2ba77d1e373e8aa221ccdea073de026fa \
69 | --hash=sha256:ad2383d39f13534f3ca5c48fe1fc0975676846dc39c2cece78c0f1f9891418e0 \
70 | --hash=sha256:1fff21a2da5f9e03ddc5bd99131a6b8edf3d7f9d6bc29ba21784323d17806ed7 \
71 | --hash=sha256:0fef83d43bf87a5196c91e73cb9772f945a4caaff91242766c5916d1dd1381e4
72 | meinheld==0.6.1 \
73 | --hash=sha256:40d9dbce0165b2d9142f364d26fd6d59d3682f89d0dfe2117717a8ddad1f4133 \
74 | --hash=sha256:293eff4983b7fcbd9134b47706b22189883fe354993bd10163c65869d141e565
75 |
--------------------------------------------------------------------------------
/basket/news/migrations/0003_auto_20151202_0808.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import models, migrations
5 | import basket.news.fields
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('news', '0002_delete_subscriber'),
12 | ]
13 |
14 | operations = [
15 | migrations.AddField(
16 | model_name='newsletter',
17 | name='private',
18 | field=models.BooleanField(default=False, help_text=b'Whether this newsletter is private. Private newsletters require the subscribe requests to use an API key.'),
19 | preserve_default=True,
20 | ),
21 | migrations.AlterField(
22 | model_name='localestewards',
23 | name='locale',
24 | field=basket.news.fields.LocaleField(max_length=32, choices=[('ach', 'ach (Acholi)'), ('af', 'af (Afrikaans)'), ('ak', 'ak (Akan)'), ('am-et', 'am-et (Amharic)'), ('an', 'an (Aragonese)'), ('ar', 'ar (Arabic)'), ('as', 'as (Assamese)'), ('ast', 'ast (Asturian)'), ('az', 'az (Azerbaijani)'), ('be', 'be (Belarusian)'), ('bg', 'bg (Bulgarian)'), ('bm', 'bm (Bambara)'), ('bn-BD', 'bn-BD (Bengali (Bangladesh))'), ('bn-IN', 'bn-IN (Bengali (India))'), ('br', 'br (Breton)'), ('brx', 'brx (Bodo)'), ('bs', 'bs (Bosnian)'), ('ca', 'ca (Catalan)'), ('ca-valencia', 'ca-valencia (Catalan (Valencian))'), ('cak', 'cak (Kaqchikel)'), ('cs', 'cs (Czech)'), ('csb', 'csb (Kashubian)'), ('cy', 'cy (Welsh)'), ('da', 'da (Danish)'), ('dbg', 'dbg (Debug Robot)'), ('de', 'de (German)'), ('de-AT', 'de-AT (German (Austria))'), ('de-CH', 'de-CH (German (Switzerland))'), ('de-DE', 'de-DE (German (Germany))'), ('dsb', 'dsb (Lower Sorbian)'), ('ee', 'ee (Ewe)'), ('el', 'el (Greek)'), ('en-AU', 'en-AU (English (Australian))'), ('en-CA', 'en-CA (English (Canadian))'), ('en-GB', 'en-GB (English (British))'), ('en-NZ', 'en-NZ (English (New Zealand))'), ('en-US', 'en-US (English (US))'), ('en-ZA', 'en-ZA (English (South African))'), ('eo', 'eo (Esperanto)'), ('es', 'es (Spanish)'), ('es-AR', 'es-AR (Spanish (Argentina))'), ('es-CL', 'es-CL (Spanish (Chile))'), ('es-ES', 'es-ES (Spanish (Spain))'), ('es-MX', 'es-MX (Spanish (Mexico))'), ('et', 'et (Estonian)'), ('eu', 'eu (Basque)'), ('fa', 'fa (Persian)'), ('ff', 'ff (Fulah)'), ('fi', 'fi (Finnish)'), ('fj-FJ', 'fj-FJ (Fijian)'), ('fr', 'fr (French)'), ('fur-IT', 'fur-IT (Friulian)'), ('fy-NL', 'fy-NL (Frisian)'), ('ga', 'ga (Irish)'), ('ga-IE', 'ga-IE (Irish)'), ('gd', 'gd (Gaelic (Scotland))'), ('gl', 'gl (Galician)'), ('gu', 'gu (Gujarati)'), ('gu-IN', 'gu-IN (Gujarati (India))'), ('ha', 'ha (Hausa)'), ('he', 'he (Hebrew)'), ('hi', 'hi (Hindi)'), ('hi-IN', 'hi-IN (Hindi (India))'), ('hr', 'hr (Croatian)'), ('hsb', 'hsb (Upper Sorbian)'), ('hu', 'hu (Hungarian)'), ('hy-AM', 'hy-AM (Armenian)'), ('id', 'id (Indonesian)'), ('ig', 'ig (Igbo)'), ('is', 'is (Icelandic)'), ('it', 'it (Italian)'), ('ja', 'ja (Japanese)'), ('ja-JP-mac', 'ja-JP-mac (Japanese)'), ('ka', 'ka (Georgian)'), ('kk', 'kk (Kazakh)'), ('km', 'km (Khmer)'), ('kn', 'kn (Kannada)'), ('ko', 'ko (Korean)'), ('kok', 'kok (Konkani)'), ('ks', 'ks (Kashmiri)'), ('ku', 'ku (Kurdish)'), ('la', 'la (Latin)'), ('lg', 'lg (Luganda)'), ('lij', 'lij (Ligurian)'), ('ln', 'ln (Lingala)'), ('lo', 'lo (Lao)'), ('lt', 'lt (Lithuanian)'), ('lv', 'lv (Latvian)'), ('mai', 'mai (Maithili)'), ('mg', 'mg (Malagasy)'), ('mi', 'mi (Maori (Aotearoa))'), ('mk', 'mk (Macedonian)'), ('ml', 'ml (Malayalam)'), ('mn', 'mn (Mongolian)'), ('mr', 'mr (Marathi)'), ('ms', 'ms (Malay)'), ('my', 'my (Burmese)'), ('nb-NO', 'nb-NO (Norwegian (Bokm\xe5l))'), ('ne-NP', 'ne-NP (Nepali)'), ('nl', 'nl (Dutch)'), ('nn-NO', 'nn-NO (Norwegian (Nynorsk))'), ('nr', 'nr (Ndebele, South)'), ('nso', 'nso (Northern Sotho)'), ('oc', 'oc (Occitan (Lengadocian))'), ('or', 'or (Oriya)'), ('pa', 'pa (Punjabi)'), ('pa-IN', 'pa-IN (Punjabi (India))'), ('pl', 'pl (Polish)'), ('pt-BR', 'pt-BR (Portuguese (Brazilian))'), ('pt-PT', 'pt-PT (Portuguese (Portugal))'), ('rm', 'rm (Romansh)'), ('ro', 'ro (Romanian)'), ('ru', 'ru (Russian)'), ('rw', 'rw (Kinyarwanda)'), ('sa', 'sa (Sanskrit)'), ('sah', 'sah (Sakha)'), ('sat', 'sat (Santali)'), ('si', 'si (Sinhala)'), ('sk', 'sk (Slovak)'), ('sl', 'sl (Slovenian)'), ('son', 'son (Songhai)'), ('sq', 'sq (Albanian)'), ('sr', 'sr (Serbian)'), ('sr-Cyrl', 'sr-Cyrl (Serbian)'), ('sr-Latn', 'sr-Latn (Serbian)'), ('ss', 'ss (Siswati)'), ('st', 'st (Southern Sotho)'), ('sv-SE', 'sv-SE (Swedish)'), ('sw', 'sw (Swahili)'), ('ta', 'ta (Tamil)'), ('ta-IN', 'ta-IN (Tamil (India))'), ('ta-LK', 'ta-LK (Tamil (Sri Lanka))'), ('te', 'te (Telugu)'), ('th', 'th (Thai)'), ('tl', 'tl (Tagalog)'), ('tn', 'tn (Tswana)'), ('tr', 'tr (Turkish)'), ('ts', 'ts (Tsonga)'), ('tsz', 'tsz (Pur\xe9pecha)'), ('tt-RU', 'tt-RU (Tatar)'), ('uk', 'uk (Ukrainian)'), ('ur', 'ur (Urdu)'), ('uz', 'uz (Uzbek)'), ('ve', 've (Venda)'), ('vi', 'vi (Vietnamese)'), ('wo', 'wo (Wolof)'), ('x-testing', 'x-testing (Testing)'), ('xh', 'xh (Xhosa)'), ('yo', 'yo (Yoruba)'), ('zh-CN', 'zh-CN (Chinese (Simplified))'), ('zh-TW', 'zh-TW (Chinese (Traditional))'), ('zu', 'zu (Zulu)')]),
25 | preserve_default=True,
26 | ),
27 | ]
28 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 |
15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest
16 |
17 | help:
18 | @echo "Please use \`make