├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── flask.yml ├── .gitignore ├── .mergify.yml ├── LICENSE ├── README.md ├── automated_survey_flask ├── __init__.py ├── answer_view.py ├── config.py ├── models.py ├── parsers.py ├── question_view.py ├── survey_view.py ├── templates │ └── index.html └── views.py ├── black.toml ├── images ├── number-conf.png └── webhook-conf.png ├── manage.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── 5a7f7ef390ce_.py ├── requirements-to-freeze.txt ├── requirements.txt ├── setup.cfg ├── survey.json └── tests ├── __init__.py ├── answer_view_tests.py ├── base.py ├── parsers_tests.py ├── question_view_tests.py ├── survey_view_tests.py └── views_tests.py /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY=change-me 2 | 3 | DATABASE_URI= 4 | 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: sqlalchemy 10 | versions: 11 | - 1.3.22 12 | - 1.3.23 13 | - 1.4.0 14 | - 1.4.1 15 | - 1.4.2 16 | - 1.4.3 17 | - 1.4.4 18 | - 1.4.5 19 | - 1.4.6 20 | - 1.4.7 21 | - 1.4.8 22 | - 1.4.9 23 | - dependency-name: twilio 24 | versions: 25 | - 6.51.1 26 | - 6.52.0 27 | - 6.53.0 28 | - 6.54.0 29 | - 6.55.0 30 | - 6.56.0 31 | - dependency-name: phonenumbers 32 | versions: 33 | - 8.12.17 34 | - 8.12.18 35 | - 8.12.19 36 | - 8.12.20 37 | - dependency-name: pyflakes 38 | versions: 39 | - 2.3.0 40 | - dependency-name: flake8 41 | versions: 42 | - 3.9.0 43 | - dependency-name: alembic 44 | versions: 45 | - 1.5.6 46 | - 1.5.7 47 | - dependency-name: pyjwt 48 | versions: 49 | - 2.0.1 50 | - dependency-name: flask-migrate 51 | versions: 52 | - 2.6.0 53 | - 2.7.0 54 | - dependency-name: flask 55 | versions: 56 | - "1.0" 57 | - dependency-name: xmlunittest 58 | versions: 59 | - 0.5.0 60 | - dependency-name: flask-script 61 | versions: 62 | - 2.0.6 63 | - dependency-name: coverage 64 | versions: 65 | - "5.4" 66 | - dependency-name: lxml 67 | versions: 68 | - 4.6.2 69 | - dependency-name: flask-sqlalchemy 70 | versions: 71 | - 2.4.4 72 | -------------------------------------------------------------------------------- /.github/workflows/flask.yml: -------------------------------------------------------------------------------- 1 | name: Flask 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ${{ matrix.platform }} 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | python-version: [3.6, 3.7, 3.8] 17 | platform: [windows-latest, macos-latest, ubuntu-latest] 18 | 19 | env: 20 | FLASK_ENV: testing 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v1 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install Dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.txt 31 | - name: Run Linter 32 | run: | 33 | flake8 34 | - name: Run Tests 35 | run: | 36 | cp .env.example .env 37 | python manage.py test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # VIM swap files 10 | *.swp 11 | 12 | # SQLite dbs 13 | *.sqlite 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | venv/ 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *,cover 53 | .hypothesis/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | #Ipython Notebook 69 | .ipynb_checkpoints 70 | 71 | # Environment variables 72 | .env 73 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge for Dependabot pull requests 3 | conditions: 4 | - author~=^dependabot(|-preview)\[bot\]$ 5 | - status-success=build 6 | actions: 7 | merge: 8 | method: squash 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Twilio Inc 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automated survey for Python - Flask 2 | ![](https://github.com/TwilioDevEd/automated-survey-flask/workflows/Flask/badge.svg) 3 | 4 | Learn how to use [Twilio Client](https://www.twilio.com/client) to conduct automated phone surveys. 5 | 6 | ## Quickstart 7 | 8 | ### Local development 9 | 10 | This project is built using the [Flask](http://flask.pocoo.org/) web framework. It runs on Python 2.7+ and Python 3.4+. 11 | 12 | To run the app locally follow these steps: 13 | 14 | 1. Clone this repository and `cd` into it. 15 | 16 | 1. Create and activate a new python3 virtual environment. 17 | 18 | ```bash 19 | python3 -m venv venv 20 | source venv/bin/activate 21 | ``` 22 | 23 | 1. Install the requirements. 24 | 25 | ```bash 26 | pip install -r requirements.txt 27 | ``` 28 | 29 | 1. Copy the `.env.example` file to `.env`, and edit it to match your database. 30 | 31 | 1. Activate Flask development environment 32 | 33 | ```bash 34 | export FLASK_ENV=development 35 | ``` 36 | 37 | 1. Run the migrations. 38 | 39 | ```bash 40 | python manage.py db upgrade 41 | ``` 42 | 43 | 1. Seed the database. 44 | 45 | ```bash 46 | python manage.py dbseed 47 | ``` 48 | 49 | Seeding will load `survey.json` into SQLite. 50 | 51 | 1. Expose your application to the wider internet using ngrok. 52 | 53 | To actually forward incoming calls, your development server will need to be publicly accessible. 54 | [We recommend using ngrok to solve this problem](https://www.twilio.com/blog/2015/09/6-awesome-reasons-to-use-ngrok-when-testing-webhooks.html). 55 | 56 | ```bash 57 | ngrok http 5000 58 | ``` 59 | 60 | Once you have started ngrok, update your TwiML app's voice URL setting to use your ngrok hostname. 61 | It will look something like this: 62 | 63 | ```bash 64 | http://88b37ada.ngrok.io/voice 65 | ``` 66 | 67 | 1. Start the development server. 68 | 69 | ```bash 70 | python manage.py runserver 71 | ``` 72 | 73 | Once ngrok is running, open up your browser and go to your ngrok URL. It will 74 | look like this: `http://88b37ada.ngrok.io` 75 | 76 | That's it! 77 | 78 | ### Configuring Twilio to call your webhooks 79 | 80 | You will also need to configure Twilio to call your application when 81 | calls are received 82 | 83 | You will need to provision at least one Twilio number with voice 84 | capabilities so the application's users can take surveys. You can buy 85 | a number 86 | [right here](https://www.twilio.com/user/account/phone-numbers/search). Once 87 | you have a number you need to configure your number to work with your 88 | application. Open 89 | [the number management page](https://www.twilio.com/user/account/phone-numbers/incoming) 90 | and open a number's configuration by clicking on it. 91 | 92 | ![Open a number configuration](https://raw.github.com/TwilioDevEd/automated-survey-flask/master/images/number-conf.png) 93 | 94 | The URL you will place the the *Request URL* field will be as follows. Set 95 | the HTTP method to GET. Be sure to change the ngrok hostname to your own. 96 | 97 | ``` 98 | http://20ee7404.ngrok.io/voice 99 | ``` 100 | 101 | Similarly, you must configure the SMS messaging section of your Twilio Phone Number 102 | To call the `/message` webhook. 103 | 104 | ``` 105 | http://20ee7404.ngrok.io/voice 106 | ``` 107 | 108 | 109 | See the images below for an example: 110 | 111 | ![Webhook Voice configuration](https://raw.githubusercontent.com/TwilioDevEd/automated-survey-flask/master/images/webhook-conf.png) 112 | 113 | You can then visit the application at [http://localhost:5000/](http://localhost:5000/). 114 | 115 | Mind the trailing slash. 116 | 117 | ## Running the tests 118 | 119 | You can run the tests locally through [coverage](http://coverage.readthedocs.org/): 120 | 121 | 1. Run the tests. 122 | 123 | ```bash 124 | python manage.py test 125 | ``` 126 | 127 | You can then view the results with `coverage report` or build an HTML report with `coverage html`. 128 | 129 | ## Meta 130 | 131 | * No warranty expressed or implied. Software is as is. Diggity. 132 | * [MIT License](LICENSE) 133 | * Lovingly crafted by Twilio Developer Education. 134 | -------------------------------------------------------------------------------- /automated_survey_flask/__init__.py: -------------------------------------------------------------------------------- 1 | from automated_survey_flask.config import config_env_files 2 | from flask import Flask 3 | 4 | from flask_sqlalchemy import SQLAlchemy 5 | 6 | db = SQLAlchemy() 7 | app = Flask(__name__) 8 | env = app.config.get("ENV", "production") 9 | 10 | 11 | def prepare_app(environment=env, p_db=db): 12 | app.config.from_object(config_env_files[environment]) 13 | p_db.init_app(app) 14 | # load views by importing them 15 | from . import views # noqa F401 16 | 17 | return app 18 | 19 | 20 | def save_and_commit(item): 21 | db.session.add(item) 22 | db.session.commit() 23 | 24 | 25 | db.save = save_and_commit 26 | -------------------------------------------------------------------------------- /automated_survey_flask/answer_view.py: -------------------------------------------------------------------------------- 1 | from . import app, db 2 | from .models import Question, Answer 3 | from flask import url_for, request, session 4 | from twilio.twiml.voice_response import VoiceResponse 5 | from twilio.twiml.messaging_response import MessagingResponse 6 | 7 | 8 | @app.route('/answer/', methods=['POST']) 9 | def answer(question_id): 10 | question = Question.query.get(question_id) 11 | 12 | db.save( 13 | Answer( 14 | content=extract_content(question), question=question, session_id=session_id() 15 | ) 16 | ) 17 | 18 | next_question = question.next() 19 | if next_question: 20 | return redirect_twiml(next_question) 21 | else: 22 | return goodbye_twiml() 23 | 24 | 25 | def extract_content(question): 26 | if is_sms_request(): 27 | return request.values['Body'] 28 | elif question.kind == Question.TEXT: 29 | return 'Transcription in progress.' 30 | else: 31 | return request.values['Digits'] 32 | 33 | 34 | def redirect_twiml(question): 35 | response = MessagingResponse() 36 | response.redirect(url=url_for('question', question_id=question.id), method='GET') 37 | return str(response) 38 | 39 | 40 | def goodbye_twiml(): 41 | if is_sms_request(): 42 | response = MessagingResponse() 43 | response.message("Thank you for answering our survey. Good bye!") 44 | else: 45 | response = VoiceResponse() 46 | response.say("Thank you for answering our survey. Good bye!") 47 | response.hangup() 48 | if 'question_id' in session: 49 | del session['question_id'] 50 | return str(response) 51 | 52 | 53 | def is_sms_request(): 54 | return 'MessageSid' in request.values.keys() 55 | 56 | 57 | @app.route('/answer/transcription/', methods=['POST']) 58 | def answer_transcription(question_id): 59 | session_id = request.values['CallSid'] 60 | content = request.values['TranscriptionText'] 61 | Answer.update_content(session_id, question_id, content) 62 | return '' 63 | 64 | 65 | def session_id(): 66 | return request.values.get('CallSid') or request.values['MessageSid'] 67 | -------------------------------------------------------------------------------- /automated_survey_flask/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | basedir = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | 6 | class DefaultConfig(object): 7 | SECRET_KEY = os.environ.get('SECRET_KEY') 8 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') 9 | SQLALCHEMY_TRACK_MODIFICATIONS = False 10 | DEBUG = False 11 | 12 | 13 | class DevelopmentConfig(DefaultConfig): 14 | SECRET_KEY = os.environ.get('SECRET_KEY', 'secret-key') 15 | SQLALCHEMY_DATABASE_URI = ( 16 | os.environ.get('DATABASE_URI') 17 | or f"sqlite:///{os.path.join(basedir, 'dev.sqlite')}" 18 | ) 19 | DEBUG = True 20 | 21 | 22 | class TestConfig(DefaultConfig): 23 | SECRET_KEY = os.environ.get('SECRET_KEY', 'secret-key') 24 | SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' 25 | PRESERVE_CONTEXT_ON_EXCEPTION = False 26 | SERVER_NAME = 'server.test' 27 | TESTING = True 28 | DEBUG = True 29 | 30 | 31 | config_env_files = { 32 | 'testing': 'automated_survey_flask.config.TestConfig', 33 | 'development': 'automated_survey_flask.config.DevelopmentConfig', 34 | 'production': 'automated_survey_flask.config.DefaultConfig', 35 | } 36 | -------------------------------------------------------------------------------- /automated_survey_flask/models.py: -------------------------------------------------------------------------------- 1 | from automated_survey_flask import db 2 | 3 | 4 | class Survey(db.Model): 5 | __tablename__ = 'surveys' 6 | 7 | id = db.Column(db.Integer, primary_key=True) 8 | title = db.Column(db.String, nullable=False) 9 | questions = db.relationship('Question', backref='survey', lazy='dynamic') 10 | 11 | def __init__(self, title): 12 | self.title = title 13 | 14 | @property 15 | def has_questions(self): 16 | return self.questions.count() > 0 17 | 18 | 19 | class Question(db.Model): 20 | __tablename__ = 'questions' 21 | 22 | TEXT = 'text' 23 | NUMERIC = 'numeric' 24 | BOOLEAN = 'boolean' 25 | 26 | id = db.Column(db.Integer, primary_key=True) 27 | content = db.Column(db.String, nullable=False) 28 | kind = db.Column(db.Enum(TEXT, NUMERIC, BOOLEAN, name='question_kind')) 29 | survey_id = db.Column(db.Integer, db.ForeignKey('surveys.id')) 30 | answers = db.relationship('Answer', backref='question', lazy='dynamic') 31 | 32 | def __init__(self, content, kind=TEXT): 33 | self.content = content 34 | self.kind = kind 35 | 36 | def next(self): 37 | return self.survey.questions.filter(Question.id > self.id).order_by('id').first() 38 | 39 | 40 | class Answer(db.Model): 41 | __tablename__ = 'answers' 42 | 43 | id = db.Column(db.Integer, primary_key=True) 44 | content = db.Column(db.String, nullable=False) 45 | session_id = db.Column(db.String, nullable=False) 46 | question_id = db.Column(db.Integer, db.ForeignKey('questions.id')) 47 | 48 | @classmethod 49 | def update_content(cls, session_id, question_id, content): 50 | existing_answer = cls.query.filter( 51 | Answer.session_id == session_id and Answer.question_id == question_id 52 | ).first() 53 | existing_answer.content = content 54 | db.session.add(existing_answer) 55 | db.session.commit() 56 | 57 | def __init__(self, content, question, session_id): 58 | self.content = content 59 | self.question = question 60 | self.session_id = session_id 61 | -------------------------------------------------------------------------------- /automated_survey_flask/parsers.py: -------------------------------------------------------------------------------- 1 | from .models import Survey, Question 2 | import json 3 | 4 | 5 | def survey_from_json(survey_json_string): 6 | survey_dict = json.loads(survey_json_string) 7 | survey = Survey(title=survey_dict['title']) 8 | survey.questions = questions_from_json(survey_json_string) 9 | return survey 10 | 11 | 12 | def questions_from_json(survey_json_string): 13 | questions = [] 14 | questions_dicts = json.loads(survey_json_string).get('questions') 15 | for question_dict in questions_dicts: 16 | body = question_dict['body'] 17 | kind = question_dict['type'] 18 | questions.append(Question(content=body, kind=kind)) 19 | return questions 20 | -------------------------------------------------------------------------------- /automated_survey_flask/question_view.py: -------------------------------------------------------------------------------- 1 | from . import app 2 | from twilio.twiml.voice_response import VoiceResponse 3 | from twilio.twiml.messaging_response import MessagingResponse 4 | from .models import Question 5 | from flask import url_for, request, session 6 | 7 | 8 | @app.route('/question/') 9 | def question(question_id): 10 | question = Question.query.get(question_id) 11 | session['question_id'] = question.id 12 | if not is_sms_request(): 13 | return voice_twiml(question) 14 | else: 15 | return sms_twiml(question) 16 | 17 | 18 | def is_sms_request(): 19 | return 'MessageSid' in request.values.keys() 20 | 21 | 22 | def voice_twiml(question): 23 | response = VoiceResponse() 24 | response.say(question.content) 25 | response.say(VOICE_INSTRUCTIONS[question.kind]) 26 | 27 | action_url = url_for('answer', question_id=question.id) 28 | transcription_url = url_for('answer_transcription', question_id=question.id) 29 | if question.kind == Question.TEXT: 30 | response.record(action=action_url, transcribe_callback=transcription_url) 31 | else: 32 | response.gather(action=action_url) 33 | return str(response) 34 | 35 | 36 | VOICE_INSTRUCTIONS = { 37 | Question.TEXT: 'Please record your answer after the beep and then hit the pound sign', 38 | Question.BOOLEAN: 'Please press the one key for yes and the zero key for no and then' 39 | ' hit the pound sign', 40 | Question.NUMERIC: 'Please press a number between 1 and 10 and then' 41 | ' hit the pound sign', 42 | } 43 | 44 | 45 | def sms_twiml(question): 46 | response = MessagingResponse() 47 | response.message(question.content) 48 | response.message(SMS_INSTRUCTIONS[question.kind]) 49 | return str(response) 50 | 51 | 52 | SMS_INSTRUCTIONS = { 53 | Question.TEXT: 'Please type your answer', 54 | Question.BOOLEAN: 'Please type 1 for yes and 0 for no', 55 | Question.NUMERIC: 'Please type a number between 1 and 10', 56 | } 57 | -------------------------------------------------------------------------------- /automated_survey_flask/survey_view.py: -------------------------------------------------------------------------------- 1 | from . import app 2 | from .models import Survey 3 | from flask import url_for, session 4 | from twilio.twiml.voice_response import VoiceResponse 5 | from twilio.twiml.messaging_response import MessagingResponse 6 | 7 | 8 | @app.route('/voice') 9 | def voice_survey(): 10 | response = VoiceResponse() 11 | 12 | survey = Survey.query.first() 13 | if survey_error(survey, response.say): 14 | return str(response) 15 | 16 | welcome_user(survey, response.say) 17 | redirect_to_first_question(response, survey) 18 | return str(response) 19 | 20 | 21 | @app.route('/message') 22 | def sms_survey(): 23 | response = MessagingResponse() 24 | 25 | survey = Survey.query.first() 26 | if survey_error(survey, response.message): 27 | return str(response) 28 | 29 | if 'question_id' in session: 30 | response.redirect(url_for('answer', question_id=session['question_id'])) 31 | else: 32 | welcome_user(survey, response.message) 33 | redirect_to_first_question(response, survey) 34 | return str(response) 35 | 36 | 37 | def redirect_to_first_question(response, survey): 38 | first_question = survey.questions.order_by('id').first() 39 | first_question_url = url_for('question', question_id=first_question.id) 40 | response.redirect(url=first_question_url, method='GET') 41 | 42 | 43 | def welcome_user(survey, send_function): 44 | welcome_text = 'Welcome to the %s' % survey.title 45 | send_function(welcome_text) 46 | 47 | 48 | def survey_error(survey, send_function): 49 | if not survey: 50 | send_function('Sorry, but there are no surveys to be answered.') 51 | return True 52 | elif not survey.has_questions: 53 | send_function('Sorry, there are no questions for this survey.') 54 | return True 55 | return False 56 | -------------------------------------------------------------------------------- /automated_survey_flask/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Survey Results 25 | 26 | 27 | 43 |
44 |

Results for survey: {{surveyTitle}}

45 | 46 |
47 |
    48 |
  • 49 | {% for question in questions %} 50 |
    51 |
    52 | Question: {{question.content}} 53 |
    54 | {% for answer in question.answers.all() %} 55 |
    56 |
      57 |
    1. Session SID: {{answer.session_id}}
    2. 58 |
    3. 59 | Response: {{answer.content}} 60 |
    4. 61 |
    62 |
    63 | {% endfor %} 64 |
    65 | {% endfor %} 66 |
  • 67 |
68 |
69 |
70 | 71 | -------------------------------------------------------------------------------- /automated_survey_flask/views.py: -------------------------------------------------------------------------------- 1 | from . import app 2 | from . import question_view # noqa F401 3 | from . import answer_view # noqa F401 4 | from . import survey_view # noqa F401 5 | from flask import render_template 6 | from .models import Question 7 | 8 | 9 | @app.route('/') 10 | def root(): 11 | questions = Question.query.all() 12 | return render_template('index.html', questions=questions) 13 | -------------------------------------------------------------------------------- /black.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 90 3 | target-version = ['py36'] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | 7 | ( 8 | /( 9 | \.eggs # exclude a few common directories in the 10 | | \.git # root of the project 11 | | \.tox 12 | | \.venv 13 | | _build 14 | | build 15 | | dist 16 | )/ 17 | ) 18 | ''' 19 | -------------------------------------------------------------------------------- /images/number-conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/automated-survey-flask/af3d72ebdac4b9b8536317913185d4a66ada2880/images/number-conf.png -------------------------------------------------------------------------------- /images/webhook-conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/automated-survey-flask/af3d72ebdac4b9b8536317913185d4a66ada2880/images/webhook-conf.png -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from flask_script import Manager 2 | from flask_migrate import Migrate, MigrateCommand 3 | 4 | # from flask_migrate import upgrade as upgrade_database 5 | from automated_survey_flask import app, db, parsers, prepare_app 6 | 7 | prepare_app() 8 | migrate = Migrate(app, db) 9 | 10 | manager = Manager(app) 11 | manager.add_command('db', MigrateCommand) 12 | 13 | 14 | @manager.command 15 | def test(): 16 | """Run the unit tests.""" 17 | import sys 18 | import unittest 19 | 20 | prepare_app(environment='testing') 21 | tests = unittest.TestLoader().discover('.', pattern="*_tests.py") 22 | test_result = unittest.TextTestRunner(verbosity=2).run(tests) 23 | 24 | if not test_result.wasSuccessful(): 25 | sys.exit(1) 26 | 27 | 28 | @manager.command 29 | def dbseed(): 30 | with open('survey.json') as survey_file: 31 | db.session.add(parsers.survey_from_json(survey_file.read())) 32 | db.session.commit() 33 | 34 | 35 | if __name__ == "__main__": 36 | manager.run() 37 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | 22 | config.set_main_option( 23 | 'sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI') 24 | ) 25 | target_metadata = current_app.extensions['migrate'].db.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline(): 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure(url=url) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | 60 | # this callback is used to prevent an auto-migration from being generated 61 | # when there are no changes to the schema 62 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 63 | def process_revision_directives(context, revision, directives): 64 | if getattr(config.cmd_opts, 'autogenerate', False): 65 | script = directives[0] 66 | if script.upgrade_ops.is_empty(): 67 | directives[:] = [] 68 | logger.info('No changes in schema detected.') 69 | 70 | engine = engine_from_config( 71 | config.get_section(config.config_ini_section), 72 | prefix='sqlalchemy.', 73 | poolclass=pool.NullPool, 74 | ) 75 | 76 | connection = engine.connect() 77 | context.configure( 78 | connection=connection, 79 | target_metadata=target_metadata, 80 | process_revision_directives=process_revision_directives, 81 | **current_app.extensions['migrate'].configure_args, 82 | ) 83 | 84 | try: 85 | with context.begin_transaction(): 86 | context.run_migrations() 87 | finally: 88 | connection.close() 89 | 90 | 91 | if context.is_offline_mode(): 92 | run_migrations_offline() 93 | else: 94 | run_migrations_online() 95 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/5a7f7ef390ce_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 5a7f7ef390ce 4 | Revises: None 5 | Create Date: 2016-04-11 19:14:32.988918 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '5a7f7ef390ce' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table( 20 | 'surveys', 21 | sa.Column('id', sa.Integer(), nullable=False), 22 | sa.Column('title', sa.String(), nullable=False), 23 | sa.PrimaryKeyConstraint('id'), 24 | ) 25 | op.create_table( 26 | 'questions', 27 | sa.Column('id', sa.Integer(), nullable=False), 28 | sa.Column('content', sa.String(), nullable=False), 29 | sa.Column( 30 | 'kind', 31 | sa.Enum('text', 'numeric', 'boolean', name='question_kind'), 32 | nullable=True, 33 | ), 34 | sa.Column('survey_id', sa.Integer(), nullable=True), 35 | sa.ForeignKeyConstraint( 36 | ['survey_id'], 37 | ['surveys.id'], 38 | ), 39 | sa.PrimaryKeyConstraint('id'), 40 | ) 41 | op.create_table( 42 | 'answers', 43 | sa.Column('id', sa.Integer(), nullable=False), 44 | sa.Column('content', sa.String(), nullable=False), 45 | sa.Column('session_id', sa.String(), nullable=False), 46 | sa.Column('question_id', sa.Integer(), nullable=True), 47 | sa.ForeignKeyConstraint( 48 | ['question_id'], 49 | ['questions.id'], 50 | ), 51 | sa.PrimaryKeyConstraint('id'), 52 | ) 53 | ### end Alembic commands ### 54 | 55 | 56 | def downgrade(): 57 | ### commands auto generated by Alembic - please adjust! ### 58 | op.drop_table('answers') 59 | op.drop_table('questions') 60 | op.drop_table('surveys') 61 | ### end Alembic commands ### 62 | -------------------------------------------------------------------------------- /requirements-to-freeze.txt: -------------------------------------------------------------------------------- 1 | # Flask 2 | Flask 3 | 4 | # Flask components for forms/database/bootstrap 5 | Flask-Migrate 6 | Flask-Script 7 | Flask-SQLAlchemy 8 | 9 | # External libraries 10 | twilio 11 | phonenumbers 12 | 13 | # Testing 14 | xmlunittest 15 | 16 | # Development 17 | flake8 18 | black 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.5.5 2 | appdirs==1.4.4 3 | black==20.8b1 4 | certifi==2020.12.5 5 | chardet==4.0.0 6 | click==7.1.2 7 | flake8==3.8.4 8 | Flask==1.1.2 9 | Flask-Migrate==2.7.0 10 | Flask-Script==2.0.6 11 | Flask-SQLAlchemy==2.4.4 12 | idna==2.10 13 | itsdangerous==1.1.0 14 | Jinja2==2.11.3 15 | lxml==4.6.2 16 | Mako==1.1.4 17 | MarkupSafe==1.1.1 18 | mccabe==0.6.1 19 | mypy-extensions==0.4.3 20 | pathspec==0.8.1 21 | phonenumbers==8.12.18 22 | pycodestyle==2.6.0 23 | pyflakes==2.2.0 24 | PyJWT==1.7.1 25 | python-dateutil==2.8.1 26 | python-editor==1.0.4 27 | pytz==2021.1 28 | regex==2020.11.13 29 | requests==2.25.1 30 | six==1.15.0 31 | SQLAlchemy==1.3.23 32 | toml==0.10.2 33 | twilio==6.52.0 34 | typed-ast==1.4.2 35 | typing-extensions==3.7.4.3 36 | urllib3==1.26.3 37 | Werkzeug==1.0.1 38 | xmlunittest==0.5.0 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | exclude = *migrations*,*venv* 4 | extend-ignore = W503,E203 5 | -------------------------------------------------------------------------------- /survey.json: -------------------------------------------------------------------------------- 1 | { 2 | "questions": [ 3 | { 4 | "body": "What is your full name?", 5 | "type": "text" 6 | }, 7 | { 8 | "body": "Are you more than 21 years old?", 9 | "type": "boolean" 10 | }, 11 | { 12 | "body": "In a scale of 1-10, how would you rate Python?", 13 | "type": "numeric" 14 | }, 15 | { 16 | "body": "Name a Python based framework you know about.", 17 | "type": "text" 18 | } 19 | ], 20 | "title": "Flask" 21 | } 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/automated-survey-flask/af3d72ebdac4b9b8536317913185d4a66ada2880/tests/__init__.py -------------------------------------------------------------------------------- /tests/answer_view_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | from automated_survey_flask.models import Question, Answer 3 | from flask import url_for 4 | 5 | 6 | class AnswersTest(BaseTest): 7 | def post_answer_for_question_kind(self, kind, is_sms=False): 8 | self.data = {('MessageSid' if is_sms else 'CallSid'): 'unique'} 9 | if is_sms: 10 | self.data['Body'] = '42' 11 | if kind == Question.NUMERIC: 12 | self.data['Digits'] = '42' 13 | elif kind == Question.BOOLEAN: 14 | self.data['Digits'] = '1' 15 | elif kind == Question.TEXT: 16 | self.data['RecordingUrl'] = 'example.com/yours.mp3' 17 | question_id = self.question_by_kind[kind].id 18 | response = self.client.post( 19 | url_for('answer', question_id=question_id), data=self.data 20 | ) 21 | return self.assertXmlDocument(response.data) 22 | 23 | def test_answer_numeric_question_during_a_call(self): 24 | self.post_answer_for_question_kind(Question.NUMERIC) 25 | 26 | new_answer = Answer.query.first() 27 | self.assertTrue(new_answer, "No answer generated for numeric question") 28 | self.assertEquals(self.data['Digits'], new_answer.content) 29 | 30 | def test_answer_numeric_question_over_sms(self): 31 | self.post_answer_for_question_kind(Question.NUMERIC, is_sms=True) 32 | 33 | new_answer = Answer.query.first() 34 | self.assertTrue(new_answer, "No answer generated for numeric question") 35 | self.assertEquals(self.data['Body'], new_answer.content) 36 | 37 | def test_answer_record_question_stores_transcription_in_progress(self): 38 | self.post_answer_for_question_kind(Question.TEXT) 39 | 40 | new_answer = Answer.query.first() 41 | self.assertTrue(new_answer, "No answer generated for numeric question") 42 | self.assertEquals('Transcription in progress.', new_answer.content) 43 | 44 | def test_answer_boolean_question_during_a_call(self): 45 | self.post_answer_for_question_kind(Question.BOOLEAN) 46 | 47 | new_answer = Answer.query.first() 48 | self.assertTrue(new_answer, "No answer generated for numeric question") 49 | self.assertEquals(self.data['Digits'], new_answer.content) 50 | 51 | def test_answer_boolean_question_over_sms(self): 52 | self.post_answer_for_question_kind(Question.BOOLEAN, is_sms=True) 53 | 54 | new_answer = Answer.query.first() 55 | self.assertTrue(new_answer, "No answer generated for boolean question") 56 | self.assertEquals(self.data['Body'], new_answer.content) 57 | 58 | def test_redirects_to_next_question_after_saving(self): 59 | first_question = self.questions[0] 60 | root = self.post_answer_for_question_kind(first_question.kind) 61 | 62 | next_question = self.questions[1] 63 | next_question_url = url_for('question', question_id=next_question.id) 64 | self.assertEquals([next_question_url], root.xpath('./Redirect/text()')) 65 | self.assertEquals(['GET'], root.xpath('./Redirect/@method')) 66 | 67 | def test_thanks_user_on_last_answer(self): 68 | last_question = self.questions[-1] 69 | root = self.post_answer_for_question_kind(last_question.kind) 70 | 71 | thank_you_text = 'Thank you for answering our survey. Good bye!' 72 | self.assertEquals([thank_you_text], root.xpath('(./Say|./Message)/text()')) 73 | 74 | def test_hangup_on_last_answer_during_a_call(self): 75 | last_question = self.questions[-1] 76 | root = self.post_answer_for_question_kind(last_question.kind) 77 | 78 | self.assertEquals(1, len(root.xpath('./Hangup'))) 79 | 80 | def test_transcription_callback_will_update(self): 81 | question = self.question_by_kind[Question.TEXT] 82 | self.post_answer_for_question_kind(question.kind) 83 | self.data['TranscriptionText'] = 'I think it is ok.' 84 | self.client.post( 85 | url_for('answer_transcription', question_id=question.id), data=self.data 86 | ) 87 | 88 | self.assertEquals(1, Answer.query.count()) 89 | new_answer = Answer.query.first() 90 | self.assertEquals(self.data['TranscriptionText'], new_answer.content) 91 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from xmlunittest import XmlTestCase 2 | from automated_survey_flask.models import Survey, Question, Answer 3 | 4 | from automated_survey_flask import app, db 5 | 6 | 7 | class BaseTest(XmlTestCase): 8 | def setUp(self): 9 | self.client = app.test_client() 10 | db.create_all() 11 | self.seed() 12 | 13 | def tearDown(self): 14 | db.session.remove() 15 | db.drop_all() 16 | 17 | @staticmethod 18 | def delete_all_surveys(): 19 | Survey.query.delete() 20 | 21 | @staticmethod 22 | def delete_all_questions(): 23 | Question.query.delete() 24 | 25 | @staticmethod 26 | def delete_all_answers(): 27 | Answer.query.delete() 28 | 29 | def seed(self): 30 | self.survey = Survey(title='Test') 31 | db.session.add(self.survey) 32 | 33 | all_kinds = [Question.TEXT, Question.BOOLEAN, Question.NUMERIC] 34 | self.question_by_kind = {} 35 | for index, kind in enumerate(all_kinds): 36 | question = Question(content=('test %s' % str(index)), kind=kind) 37 | question.survey = self.survey 38 | db.session.add(question) 39 | self.question_by_kind[kind] = question 40 | 41 | db.session.commit() 42 | 43 | self.questions = self.survey.questions.order_by('id').all() 44 | -------------------------------------------------------------------------------- /tests/parsers_tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from automated_survey_flask import parsers 3 | from automated_survey_flask.models import Question 4 | 5 | 6 | class ParserTests(unittest.TestCase): 7 | def setUp(self): 8 | self.survey_as_json = '{"title": "TDD Survey", "questions": [\ 9 | {"body":"What is your name?", "type":"text"},\ 10 | {"body":"What is your age?", "type":"numeric"},\ 11 | {"body":"Do you know Python?", "type":"boolean"}]}' 12 | 13 | def test_parse_survey(self): 14 | survey = parsers.survey_from_json(self.survey_as_json) 15 | self.assertEquals("TDD Survey", survey.title) 16 | 17 | def test_survey_includes_questions(self): 18 | survey = parsers.survey_from_json(self.survey_as_json) 19 | self.assertEquals(3, survey.questions.count()) 20 | 21 | def test_parse_question_title(self): 22 | questions = parsers.questions_from_json(self.survey_as_json) 23 | self.assertEquals("What is your name?", questions[0].content) 24 | 25 | def test_parse_text_question(self): 26 | questions = parsers.questions_from_json(self.survey_as_json) 27 | self.assertEquals(Question.TEXT, questions[0].kind) 28 | 29 | def test_parse_numeric_question(self): 30 | questions = parsers.questions_from_json(self.survey_as_json) 31 | self.assertEquals(Question.NUMERIC, questions[1].kind) 32 | 33 | def test_parse_boolean_questions(self): 34 | questions = parsers.questions_from_json(self.survey_as_json) 35 | self.assertEquals(Question.BOOLEAN, questions[2].kind) 36 | -------------------------------------------------------------------------------- /tests/question_view_tests.py: -------------------------------------------------------------------------------- 1 | from flask import url_for, session 2 | 3 | from automated_survey_flask import app 4 | from automated_survey_flask.models import Question 5 | 6 | from .base import BaseTest 7 | 8 | 9 | class QuestionsTest(BaseTest): 10 | def get_question_as_xml(self, question, client=None, data=None): 11 | client = client or self.client 12 | response = client.get(url_for('question', question_id=question.id), data=data) 13 | return self.assertXmlDocument(response.data) 14 | 15 | def test_first_question_during_a_call(self): 16 | first_question = self.questions[0] 17 | root = self.get_question_as_xml(first_question) 18 | 19 | self.assertIn(first_question.content, root.xpath('./Say/text()')) 20 | 21 | def test_first_question_over_sms(self): 22 | first_question = self.questions[0] 23 | data = {'MessageSid': 'unique'} 24 | root = self.get_question_as_xml(first_question, data=data) 25 | 26 | self.assertIn(first_question.content, root.xpath('./Message/text()')) 27 | 28 | def test_current_question_being_answered_goes_to_session(self): 29 | first_question = self.questions[0] 30 | with app.test_client() as client: 31 | self.get_question_as_xml(first_question, client=client) 32 | self.assertEquals(first_question.id, session['question_id']) 33 | 34 | def test_gather_keys_on_numeric_question_during_a_call(self): 35 | numeric_question = self.question_by_kind[Question.NUMERIC] 36 | root = self.get_question_as_xml(numeric_question) 37 | 38 | answer_url = url_for('answer', question_id=numeric_question.id) 39 | self.assertEquals([answer_url], root.xpath('./Gather/@action')) 40 | 41 | def test_record_on_text_questions_during_a_call(self): 42 | text_question = self.question_by_kind[Question.TEXT] 43 | root = self.get_question_as_xml(text_question) 44 | 45 | answer_url = url_for('answer', question_id=text_question.id) 46 | self.assertEquals([answer_url], root.xpath('./Record/@action')) 47 | 48 | def test_transcription_is_enabled_for_text_questions_during_a_call(self): 49 | text_question = self.question_by_kind[Question.TEXT] 50 | root = self.get_question_as_xml(text_question) 51 | 52 | answer_transcription_url = url_for( 53 | 'answer_transcription', question_id=text_question.id 54 | ) 55 | self.assertEquals( 56 | [answer_transcription_url], root.xpath('./Record/@transcribeCallback') 57 | ) 58 | 59 | def test_gather_keys_on_boolean_question_during_a_call(self): 60 | boolean_question = self.question_by_kind[Question.BOOLEAN] 61 | root = self.get_question_as_xml(boolean_question) 62 | 63 | answer_url = url_for('answer', question_id=boolean_question.id) 64 | self.assertEquals([answer_url], root.xpath('./Gather/@action')) 65 | -------------------------------------------------------------------------------- /tests/survey_view_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | from flask import url_for 3 | 4 | 5 | class VoiceSurveysTest(BaseTest): 6 | def test_says_welcome_on_a_call(self): 7 | response = self.client.get(url_for('voice_survey')) 8 | root = self.assertXmlDocument(response.data) 9 | 10 | welcome_text = 'Welcome to the %s' % self.survey.title 11 | self.assertEquals([welcome_text], root.xpath('./Say/text()')) 12 | 13 | def test_says_welcome_on_a_SMS_session(self): 14 | response = self.client.get(url_for('sms_survey')) 15 | root = self.assertXmlDocument(response.data) 16 | 17 | welcome_text = 'Welcome to the %s' % self.survey.title 18 | self.assertEquals([welcome_text], root.xpath('./Message/text()')) 19 | 20 | def test_says_sorry_if_no_survey(self): 21 | self.delete_all_surveys() 22 | 23 | response = self.client.get(url_for('voice_survey')) 24 | root = self.assertXmlDocument(response.data) 25 | 26 | sorry_text = 'Sorry, but there are no surveys to be answered.' 27 | self.assertEquals([sorry_text], root.xpath('./Say/text()')) 28 | 29 | def test_messages_sorry_if_no_survey_for_sms(self): 30 | self.delete_all_surveys() 31 | 32 | response = self.client.get(url_for('sms_survey')) 33 | root = self.assertXmlDocument(response.data) 34 | 35 | sorry_text = 'Sorry, but there are no surveys to be answered.' 36 | self.assertEquals([sorry_text], root.xpath('./Message/text()')) 37 | 38 | def test_says_sorry_if_no_questions_for_this_survey(self): 39 | self.delete_all_questions() 40 | 41 | response = self.client.get(url_for('voice_survey')) 42 | root = self.assertXmlDocument(response.data) 43 | 44 | sorry_text = 'Sorry, there are no questions for this survey.' 45 | self.assertEquals([sorry_text], root.xpath('./Say/text()')) 46 | 47 | def test_says_sorry_if_no_questions_for_this_survey_over_sms(self): 48 | self.delete_all_questions() 49 | 50 | response = self.client.get(url_for('sms_survey')) 51 | root = self.assertXmlDocument(response.data) 52 | 53 | sorry_text = 'Sorry, there are no questions for this survey.' 54 | self.assertEquals([sorry_text], root.xpath('./Message/text()')) 55 | 56 | def test_redirects_to_first_question_during_a_call(self): 57 | response = self.client.get(url_for('voice_survey')) 58 | root = self.assertXmlDocument(response.data) 59 | 60 | first_question = self.questions[0] 61 | first_question_url = url_for('question', question_id=first_question.id) 62 | self.assertEquals([first_question_url], root.xpath('./Redirect/text()')) 63 | 64 | def test_redirects_to_first_question_over_sms(self): 65 | response = self.client.get(url_for('sms_survey')) 66 | root = self.assertXmlDocument(response.data) 67 | 68 | first_question = self.questions[0] 69 | first_question_url = url_for('question', question_id=first_question.id) 70 | self.assertEquals([first_question_url], root.xpath('./Redirect/text()')) 71 | 72 | def test_sms_redirects_to_answer_url_if_question_in_session(self): 73 | question = self.questions[0] 74 | with self.client.session_transaction() as session: 75 | session['question_id'] = question.id 76 | response = self.client.get(url_for('sms_survey')) 77 | root = self.assertXmlDocument(response.data) 78 | 79 | answer_url = url_for('answer', question_id=question.id) 80 | self.assertEquals([answer_url], root.xpath('./Redirect/text()')) 81 | -------------------------------------------------------------------------------- /tests/views_tests.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTest 2 | 3 | 4 | class RootTest(BaseTest): 5 | def test_renders_all_questions(self): 6 | response = self.client.get('/') 7 | for question in self.questions: 8 | self.assertIn(question.content, response.data.decode('utf8')) 9 | --------------------------------------------------------------------------------