diff --git a/.gitignore b/.gitignore index 3fec537..ef747c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ +.DS_Store .env migrations node_modules __pycache__ *.sw[op] *.pyc -.coverage \ No newline at end of file +.coverage diff --git a/.travis.yml b/.travis.yml index 4d585b8..4580c94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,5 +6,9 @@ install: make install.travis script: make test.travis addons: postgresql: "9.4" +env: + - TEST_DATABASE_URL="postgresql+psycopg2://postgres@localhost/test_typeseam" +before_script: + - psql -c 'create database test_typeseam;' -U postgres after_success: - coveralls \ No newline at end of file diff --git a/Makefile b/Makefile index 679b6a2..a931dce 100644 --- a/Makefile +++ b/Makefile @@ -35,13 +35,20 @@ test: dropdb test_$(DB_NAME) --if-exists createdb test_$(DB_NAME) nosetests \ - --eval-attr "not slow" \ + --eval-attr "not selenium" \ --verbose \ --nocapture \ --with-coverage \ --cover-package=./typeseam \ --cover-erase - dropdb test_$(DB_NAME) + +test.specific: + dropdb test_$(DB_NAME) --if-exists + createdb test_$(DB_NAME) + nosetests \ + $(CURRENT_TESTS) \ + --verbose \ + --nocapture test.full: $(info This test requires the server to be running locally) @@ -53,7 +60,6 @@ test.full: --with-coverage \ --cover-package=./typeseam \ --cover-erase - dropdb test_$(DB_NAME) test.travis: nosetests \ diff --git a/frontend/js/main.js b/frontend/js/main.js index 88e97ff..9ef041d 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1,4 +1,5 @@ $( document ).ready(function() { + addCSRFTokenToRequests() listenToEvents(); getNewResponses(); }); @@ -6,18 +7,31 @@ $( document ).ready(function() { var PDF_LOADING_STATES = [ ["sending", 2000], ["generating", 13000], - ["retrieving", 5000] + ["retrieving", 5000], ]; +function addCSRFTokenToRequests(){ + // Taken directly from + // http://flask-wtf.readthedocs.org/en/latest/csrf.html#ajax + var csrftoken = $('meta[name=csrf-token]').attr('content'); + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken) + } + } + }); +} + function listenToEvents(){ $('.responses-header').on('click', '.load_new_responses', getNewResponses); - $('.responses').on('click', '.pdf_cell', getPDF); + $('.container').on('click', '.pdf_button', getPDF); } function getNewResponses(e){ $('button.load_new_responses').addClass('loading'); $.ajax({ - url: "/api/new_responses", + url: API_ENDPOINTS.new_responses, success: handleNewResponses, timeout: 10000 }); @@ -29,6 +43,8 @@ function stateTransitionChain(target, stateStack, index){ target.removeClass(prevStateClassName); } if( index == stateStack.length ){ + target.removeClass("loading"); + target.addClass('default'); return; } var stateClassName = stateStack[index][0]; @@ -41,13 +57,15 @@ function stateTransitionChain(target, stateStack, index){ function getPDF(e){ var target = $(this); - target.removeClass("untouched"); + console.log("clicked to get pdf on", target); + target.removeClass("default"); target.addClass('loading'); - var responseId = target.parent('.response').attr('id'); + var responseId = target.parents('.response').attr('id'); responseId = responseId.split("-")[1] - stateTransitionChain($(this), PDF_LOADING_STATES, 0); + stateTransitionChain(target, PDF_LOADING_STATES, 0); $.ajax({ - url: "/api/get_pdf/" + responseId, + method: "POST", + url: target.attr("data-apiendpoint"), success: handleNewPDF(responseId), timeout: 20000 }); diff --git a/frontend/less/custom.less b/frontend/less/custom.less index abe888f..f41b71b 100644 --- a/frontend/less/custom.less +++ b/frontend/less/custom.less @@ -15,10 +15,10 @@ // states .stateful .state { display: none; } -.untouched .state.content_default { display: inline; } -.sending .state.content_sending { display: inline; } -.generating .state.content_generating { display: inline; } -.retrieving .state.content_retrieving { display: inline; } +.stateful.default .state.content_default { display: inline; } +.stateful.sending .state.content_sending { display: inline; } +.stateful.generating .state.content_generating { display: inline; } +.stateful.retrieving .state.content_retrieving { display: inline; } .btn .glyphicon { margin-right: .5em; diff --git a/requirements/ci.txt b/requirements/ci.txt index c574655..b2d60ba 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -7,4 +7,5 @@ nose==1.3.6 coverage==3.7.1 -e git+https://github.com/jarus/flask-testing.git@c969b41b31f60a5a8bacd44b3eb63d1642f2d8bf#egg=Flask_Testing-master factory-boy==2.6.0 -mock==1.3.0 \ No newline at end of file +mock==1.3.0 +beautifulsoup4==4.4.1 \ No newline at end of file diff --git a/requirements/prod.txt b/requirements/prod.txt index 4944119..72850d4 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -14,3 +14,10 @@ requests==2.8.1 # Remote server gunicorn==19.4.1 + +# User Accounts (includes Flask-mail as dependency) +Flask-User==0.6.8 +Flask-SSLify==0.1.5 + +# Email +sendgrid==1.5.19 diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py new file mode 100644 index 0000000..ae55aab --- /dev/null +++ b/tests/functional/test_auth.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +import os +from pprint import pprint +from nose.plugins.attrib import attr + +from tests.selenium_test_base import SeleniumBaseTestCase + +class TestAuthTasks(SeleniumBaseTestCase): + + user = { + 'email': os.environ.get('DEFAULT_ADMIN_EMAIL', 'the_colonel@gmail.com'), + 'password': os.environ.get('DEFAULT_ADMIN_PASSWORD', 'this-sketch-is-too-silly'), + } + + def open_email_inbox(self): + self.get_email() + self.wait(1) + email_input = self.xpath("//input[@name='Email']") + email_input.send_keys(self.user['email']) + email_input.send_keys(self.keys.ENTER) + self.wait(1) + password_input = self.xpath("//input[@name='Passwd']") + password_input.send_keys(self.user['password']) + password_input.send_keys(self.keys.ENTER) + self.wait(30) + + def test_redirect_to_login(self): + self.get('/') + self.assertIn('Log in', self.browser.title) + email_input = self.browser.find_element_by_xpath('//input[@name=email]') + print(email_input) + + def test_click_on_forgot_password_gets_email_form(self): + self.get('/') + self.assertIn('Log in', self.browser.title) + email_input = self.browser.find_element_by_name('email') + # find email + + def test_get_login_page(self): + self.get('/login') + self.assertIn('Log in', self.browser.title) + self.screenshot('login.png') + + def test_able_to_login(self): + self.get('/login') + email_input = self.browser.find_element_by_name('email') + email_input.send_keys(self.user['email']) + password_input = self.browser.find_element_by_name('password') + password_input.send_keys(self.user['password']) + self.screenshot('login-filled.png') + password_input.submit() diff --git a/tests/functional/test_intake.py b/tests/functional/test_intake.py new file mode 100644 index 0000000..8091aa7 --- /dev/null +++ b/tests/functional/test_intake.py @@ -0,0 +1,8 @@ +from tests.selenium_test_base import SeleniumBaseTestCase + +class TestFormFillerTasks(SeleniumBaseTestCase): + + def test_index_get(self): + self.get('/') + self.assertIn('Clean Slate', self.browser.title) + self.screenshot('index.png') \ No newline at end of file diff --git a/tests/functional/test_selenium.py b/tests/functional/test_selenium.py deleted file mode 100644 index 4e8941c..0000000 --- a/tests/functional/test_selenium.py +++ /dev/null @@ -1,15 +0,0 @@ -from nose.plugins.attrib import attr -from tests.test_base import BaseTestCase - -@attr(selenium=True, speed="slow") -class TestSelenium(BaseTestCase): - def setUp(self): - BaseTestCase.setUp(self) - from selenium import webdriver - self.driver = webdriver - - def test_index_get(self): - browser = self.driver.Firefox() - browser.get('http://localhost:9000/') - assert 'Clean Slate' in browser.title - browser.quit() \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 0000000..5df2e23 --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,116 @@ +from flask import url_for, request, session +from flask.ext.login import current_user +from sqlalchemy import func, distinct +from pprint import pprint + +from typeseam.app import db +from typeseam.auth.models import User +from typeseam.auth.queries import create_user, get_user_by_email + +from tests.test_base import BaseTestCase + + +class TestAuthViews(BaseTestCase): + + sample_user_data = dict( + email="something@gmail.com", + password="Hell0" + ) + + def setUp(self): + BaseTestCase.setUp(self) + self.client = self.app.test_client() + create_user(**self.sample_user_data) + + def get_user(self): + return get_user_by_email(self.sample_user_data['email']) + + def test_wrong_password_returns_to_login(self): + response = self.login(password="not hello") + self.assertIn('Sign in', response.data.decode('utf-8')) + self.assertFalse(current_user.is_authenticated) + + def login(self, **kwargs): + login_data = dict(**self.sample_user_data) + login_data.update(**kwargs) + get_response = self.client.get('/', follow_redirects=True) + csrf_token = self.get_input_value('csrf_token', get_response) + return self.client.post( + url_for('user.login'), + data=dict(csrf_token=csrf_token, **login_data), + follow_redirects=True) + + def logout(self): + return self.client.get(url_for('user.logout'), follow_redirects=True) + + def test_new_user_password_is_properly_encrypted(self): + # check that the password was hashed with bcrypt + raw_password = self.sample_user_data['password'] + user = self.get_user() + encoded_raw_password = raw_password.encode('utf-8') + presumably_hashed_password = user.password.encode('utf-8') + import bcrypt + self.assertEqual( + bcrypt.hashpw(encoded_raw_password, presumably_hashed_password), + presumably_hashed_password) + + def test_unauthenticated_home_page_resolves_to_login_view(self): + r = self.client.get('/') + self.assertEqual(r.status_code, 302) # is it a redirect? + r = self.client.get('/', follow_redirects=True) + self.assertIn('Sign in', r.data.decode('utf-8')) # did it redirect to Log in? + + def test_login_form_includes_csrf_token(self): + r = self.client.get(url_for('user.login')) + self.assertIn('csrf_token', r.data.decode('utf-8')) + + def test_can_login(self): + response = self.login() + self.assertEqual(response.status_code, 200) + + def test_can_logout(self): + self.login() + response = self.logout() + self.assertFalse(current_user.is_authenticated) + + def test_successful_login_has_message(self): + response = self.login() + self.assertIn('signed in successfully', response.data.decode('utf-8')) + + def test_login_fails_without_csrf(self): + response = self.client.post( + url_for('user.login'), + data=dict(**self.sample_user_data), + follow_redirects=True) + self.assertEqual(response.status_code, 400) + + # def test_login_warns_about_http_and_links_to_https(self): + # raise NotImplementedError + + # def test_login_has_forgot_password_link(self): + # raise NotImplementedError + + # def test_forgot_password_post_sends_email(self): + # raise NotImplementedError + + # def test_forgot_password_view_errors_on_unused_email(self): + # raise NotImplementedError + + # def test_password_reset_email_contains_proper_link(self): + # raise NotImplementedError + + # def test_password_reset_link_expires(self): + # raise NotImplementedError + + # def test_password_reset_form_looks_sufficient(self): + # raise NotImplementedError + + # def test_password_reset_has_csrf_and_https_warning(self): + # raise NotImplementedError + + # def test_password_reset_fails_without_csrf(self): + # raise NotImplementedError + + # def test_successful_password_reset_goes_to_next_with_message(self): + # raise NotImplementedError + diff --git a/tests/integration/test_mail.py b/tests/integration/test_mail.py new file mode 100644 index 0000000..8611592 --- /dev/null +++ b/tests/integration/test_mail.py @@ -0,0 +1,26 @@ +from tests.test_base import BaseTestCase +from typeseam.utils.sendgrid_mailer import email_dispatched +from typeseam.auth.tasks import sendgrid_email + + +class TestMail(BaseTestCase): + + def setUp(self): + BaseTestCase.setUp(self) + self.body = """Hey there, this is an email message.""" + self.subject = "Hello from mail tests" + + def send_mail(self): + sendgrid_email( + subject="testing mail again", + recipients=['benjamin.j.golder@gmail.com'], + text_message="What is up?" + ) + + def test_can_send_mail(self): + messages = [] + def fire(app, message, **extra): + messages.append(message) + email_dispatched.connect(fire) + self.send_mail() + self.assertEqual(len(messages), 1) \ No newline at end of file diff --git a/tests/mock/factories.py b/tests/mock/factories.py index f82d7aa..7d5dad0 100644 --- a/tests/mock/factories.py +++ b/tests/mock/factories.py @@ -1,21 +1,31 @@ # -*- coding: utf-8 -*- import datetime - +import random import factory from faker import Factory as FakerFactory from factory.alchemy import SQLAlchemyModelFactory from typeseam.app import db -from typeseam.intake.models import Typeform, TypeformResponse +from typeseam.form_filler.models import ( + Typeform, TypeformResponse, SeamlessDoc + ) +from typeseam.auth.models import User +from typeseam.auth.queries import create_user, hash_password +from typeseam.form_filler.serializers import TypeformResponseSerializer faker = FakerFactory.create('en_US', includes=['tests.mock.typeform']) def lazy(func): return factory.LazyAttribute(func) -def typeform_key(x): +def deferred(func, *args, **kwargs): + def toss(obj): + return func(*args, **kwargs) + return factory.LazyAttribute(toss) + +def typeform_key(*args): '''example: "o8MrpO" ''' return faker.password( @@ -23,10 +33,9 @@ def typeform_key(x): special_chars=False, ) -def recent_date(*args, **kwargs): +def recent_date(start_date='-8w'): # return a datetime within last 8 weeks - return faker.date_time_between(start_date='-8w') - + return faker.date_time_between(start_date=start_date) class SessionFactory(SQLAlchemyModelFactory): class Meta: @@ -35,17 +44,110 @@ class Meta: class TypeformFactory(SessionFactory): - id = factory.Sequence(lambda n: n) form_key = lazy(typeform_key) - title = lazy(lambda x: return faker.sentence(nb_words=4)) + title = lazy(lambda x: faker.sentence(nb_words=4)) + added_on = deferred(recent_date) + class Meta: model = Typeform +class SeamlessDocFactory(SessionFactory): + class Meta: + model = SeamlessDoc class TypeformResponseFactory(SessionFactory): - id = factory.Sequence(lambda n: n) - date_received = lazy(recent_date) - answers = lazy(faker.answers) + date_received = deferred(recent_date) + answers = lazy(lambda x: faker.answers()) class Meta: model = TypeformResponse + +class UserFactory(SessionFactory): + id = factory.Sequence(lambda n: n) + email = factory.Faker('free_email') + confirmed_at = deferred(recent_date) + class Meta: + model = User + +def user_data(**kwargs): + d = { + 'email': faker.email(), + 'password': faker.password(), + 'confirmed_at': recent_date() + } + d.update(**kwargs) + return d + +def fake_typeform_responses(num_responses=1, start_date='-8w'): + responses = [] + for n in range(num_responses): + response = { + 'answers': faker.answers(), + 'metadata': { + 'date_submit': faker.date_time_between(start_date=start_date).strftime("%Y-%m-%d %H:%M:%S") + }} + responses.append(response) + return {'responses': responses} + +def generate_fake_responses(typeform=None, count=None): + deserializer = TypeformResponseSerializer() + if count == None: + count = random.randint(1, 20) + raw_responses = fake_typeform_responses(count) + models, errors = deserializer.load(raw_responses, many=True, session=db.session) + if errors: + print("!ERRORS generating fake responses!!:", errors) + for m in models: + if typeform: + m.typeform_id = typeform.id + if typeform.user_id: + m.user_id = typeform.user_id + db.session.add(m) + if typeform: + typeform.response_count = len(models) + typeform.latest_response = max(models, key=lambda m: m.date_received).date_received + db.session.add(typeform) + db.session.commit() + return models + +def generate_fake_typeforms(user=None, count=None): + if count == None: + count = random.randint(1, 6) + forms = [] + user_id = None + if user: + user_id = user.id + for i in range(count): + form = TypeformFactory.create( + user_id=user_id, + added_on=recent_date(start_date=user.confirmed_at) + ) + forms.append(form) + return forms + +def fake_user_data(num_users=20): + return [user_data() for n in range(num_users)]\ + +def generate_fake_users(num_users=20): + data = fake_user_data(num_users) + users = [] + for datum in data: + users.append(create_user(**datum)) + return users, data + +def generate_fake_data(num_users=10): + users, user_data = generate_fake_users(num_users) + user_report = "" + user_report += "\nCreated {} users:".format(num_users) + user_report += '\n'.join([ + ' {email}: "{password}"'.format(**d) + for d in user_data]) + form_sets = [] + for user in users: + form_sets.append(generate_fake_typeforms(user)) + response_sets = [] + for form_set in form_sets: + for form in form_set: + response_sets.append(generate_fake_responses(form)) + return user_report, user_data, users, form_sets, response_sets + diff --git a/tests/mock/mock_api_responses.py b/tests/mock/mock_api_responses.py new file mode 100644 index 0000000..4aa3c7e --- /dev/null +++ b/tests/mock/mock_api_responses.py @@ -0,0 +1,21 @@ + +from tests.mock.factories import faker, fake_typeform_responses +from unittest.mock import MagicMock + + + +good_response = MagicMock() +sample_response = fake_typeform_responses(2) +sample_response.update(http_status=200) +good_response.json.return_value = sample_response + +forbidden_response = MagicMock() +forbidden_response.json.return_value = { + 'http_status': 403, +} + +bad_response = MagicMock() +bad_response.json.return_value = { + 'http_status': 500, +} + diff --git a/tests/mock/typeform/__init__.py b/tests/mock/typeform/__init__.py index 34753a2..73aef9e 100644 --- a/tests/mock/typeform/__init__.py +++ b/tests/mock/typeform/__init__.py @@ -59,7 +59,7 @@ def set_phone(self, answers): phone_type = self.random_element(self.phone_type_choices) answers['list_15076883_choice'] = phone_type if phone_type == self.phone_type_choices[-1]: - answers['list_15076883_other'] = self.generator.word() + answers['list_15076883_other'] = self.generator.word() return answers def outside_convictions(self, answers): @@ -104,7 +104,8 @@ def answers(self): "yesno_15075576": self.random_element({"1": 0.8, "0": 0.2}), "yesno_15076724": self.random_element({"1": 0.1, "0": 0.9}), "yesno_15076728": self.random_element({"1": 0.1, "0": 0.9}), - "yesno_15076795": self.random_element({"1": 0.6, "0": 0.4})} + "yesno_15076795": self.random_element({"1": 0.6, "0": 0.4}), + } answers = self.set_phone(answers) answers = self.set_address(answers) answers = self.outside_convictions(answers) diff --git a/tests/screenshots/.gitignore b/tests/screenshots/.gitignore new file mode 100644 index 0000000..aab52d9 --- /dev/null +++ b/tests/screenshots/.gitignore @@ -0,0 +1 @@ +*.png \ No newline at end of file diff --git a/tests/selenium_test_base.py b/tests/selenium_test_base.py new file mode 100644 index 0000000..f9bec8d --- /dev/null +++ b/tests/selenium_test_base.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +import os +from tests.test_base import BaseTestCase + +from nose.plugins.attrib import attr + +@attr(selenium=True, speed="slow") +class SeleniumBaseTestCase(BaseTestCase): + """A base test case for Selenium functional testing + """ + baseURL = os.environ.get('BASE_URL', 'http://localhost:9000') + emailBaseURL = os.environ.get('TEST_EMAIL_URL', '') + screenshots_folder = os.environ.get('TEST_SCREENSHOTS_FOLDER', 'tests/screenshots') + + def wait(self, seconds=5): + self.browser.implicitly_wait(seconds) + + def get(self, path): + self.browser.get(self.baseURL + path) + + def get_email(self, path=''): + self.browser.get(self.emailBaseURL + path) + + def xpath(self, xpath=''): + return self.browser.find_element_by_xpath(xpath) + + def screenshot(self, image_name): + path = os.path.join(self.screenshots_folder, image_name) + self.browser.save_screenshot(path) + + def setUp(self): + BaseTestCase.setUp(self) + from selenium import webdriver + self.browser = webdriver.Firefox() + from selenium.webdriver.common.keys import Keys + self.keys = Keys + + def tearDown(self): + self.browser.close() + self.browser.quit() diff --git a/tests/test_base.py b/tests/test_base.py index 0cfed13..0d507fd 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- - import os - from flask.ext.testing import TestCase as FlaskTestCase from typeseam.app import ( @@ -9,6 +7,7 @@ db ) +from tests.utils import get_value_for_name class BaseTestCase(FlaskTestCase): ''' @@ -16,12 +15,18 @@ class BaseTestCase(FlaskTestCase): ''' def create_app(self): os.environ['CONFIG'] = 'typeseam.settings.TestConfig' - return _create_app() + app = _create_app() + app.testing = True + return app + + def get_input_value(self, name, response): + return get_value_for_name(name, response.data.decode('utf-8')) def setUp(self): + FlaskTestCase.setUp(self) db.create_all() def tearDown(self): db.session.remove() db.drop_all() - db.get_engine(self.app).dispose() \ No newline at end of file + db.get_engine(self.app).dispose() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_api_calls.py b/tests/unit/test_api_calls.py new file mode 100644 index 0000000..468c33a --- /dev/null +++ b/tests/unit/test_api_calls.py @@ -0,0 +1,31 @@ +from unittest.mock import MagicMock, patch + +from flask import current_app +from typeseam.app import load_initial_data + +from typeseam.form_filler.tasks import ( + get_typeform_responses, + get_seamless_doc_pdf + ) + + +from tests.test_base import BaseTestCase +from tests.mock.mock_api_responses import ( + good_response, + forbidden_response, + bad_response + ) + +class TestExternalApiCalls(BaseTestCase): + + def setUp(self): + BaseTestCase.setUp(self) + load_initial_data(current_app) + + @patch('typeseam.form_filler.tasks.requests') + def test_typeform_success(self, mock_requests): + from typeseam.form_filler.tasks import get_typeform_responses + mock_requests.get.return_value = good_response + get_typeform_responses() + self.assertTrue(mock_requests.get.called) + self.assertTrue(good_response.json.called) diff --git a/tests/unit/test_queries.py b/tests/unit/test_queries.py new file mode 100644 index 0000000..b458a5e --- /dev/null +++ b/tests/unit/test_queries.py @@ -0,0 +1,93 @@ +from unittest.mock import patch +from tests.test_base import BaseTestCase +from werkzeug.exceptions import Unauthorized, Forbidden + +from tests.mock.factories import ( + generate_fake_users, + generate_fake_typeforms, + generate_fake_responses + ) + +from typeseam.form_filler.serializers import ( + TypeformResponseSerializer, + FlatResponseSerializer, + TypeformSerializer, + SerializationError, + DeserializationError + ) + +from typeseam.form_filler.queries import ( + get_response_model, + save_new_typeform_data, + get_typeforms_for_user, + get_responses_for_typeform, + get_responses_csv, + get_response_model, + get_response_detail + ) + +response_serializer = TypeformResponseSerializer() +flat_response_serializer = FlatResponseSerializer() +typeform_serializer = TypeformSerializer() + +class TestQueries(BaseTestCase): + + + def setUp(self): + BaseTestCase.setUp(self) + user = generate_fake_users(1)[0][0] + typeforms = generate_fake_typeforms(user, 2) + responses = generate_fake_responses(typeforms[0], 5) + self.user = user + self.typeforms = typeforms + self.typeform = typeforms[0] + self.responses = responses + self.response = responses[0] + + def test_get_response_model(self): + response = self.responses[0] + result = get_response_model(str(response.id)) + self.assertEqual(response, result) + + def test_get_response_detail_success(self): + response = self.responses[0] + result = get_response_detail( + self.user, + response.id + ) + serialized_response = response_serializer.dump(response).data + self.assertDictEqual(serialized_response, result) + + def test_get_response_detail_abort(self): + response = self.responses[0] + other_user = generate_fake_users(1)[0][0] + with self.assertRaises(Forbidden): + result = get_response_detail( + other_user, + response.id + ) + + def test_get_responses_for_typeform(self): + # make some other responses to see if they show up + other_user = generate_fake_users(1)[0][0] + other_typeform = generate_fake_typeforms(other_user, 1)[0] + form, responses = get_responses_for_typeform( + self.user, + self.typeform.form_key) + self.assertEqual(len(responses), 5) + self.assertEqual(form['form_key'], self.typeform.form_key) + form, responses = get_responses_for_typeform( + other_user, + other_typeform.form_key + ) + self.assertEqual(len(responses), 0) + + def test_get_typeforms_for_user(self): + forms = get_typeforms_for_user(self.user) + self.assertEqual(len(forms), 2) + # try a user with no forms + other_user = generate_fake_users(1)[0][0] + forms = get_typeforms_for_user(other_user) + self.assertEqual(len(forms), 0) + + diff --git a/tests/unit/test_save.py b/tests/unit/test_save.py new file mode 100644 index 0000000..be33c8e --- /dev/null +++ b/tests/unit/test_save.py @@ -0,0 +1,53 @@ + +from nose.plugins.attrib import attr +from tests.test_base import BaseTestCase + +from tests.mock.factories import ( + generate_fake_data, + generate_fake_users, + generate_fake_typeforms, + generate_fake_responses, + UserFactory, + TypeformFactory, + SeamlessDocFactory, + TypeformResponseFactory, + User, + Typeform, TypeformResponse, SeamlessDoc + ) + +class TestModelSaving(BaseTestCase): + + def test_save_a_user(self): + generate_fake_users(1) + users = User.query.all() + self.assertEqual(len(users), 1) + self.assertTrue(users[0].id) + + def test_save_a_typeform(self): + form = TypeformFactory.create() + forms = Typeform.query.all() + self.assertEqual(len(forms), 1) + self.assertTrue(forms[0].id) + + def test_save_a_seamless_doc(self): + doc = SeamlessDocFactory.create() + docs = SeamlessDoc.query.all() + self.assertEqual(len(docs), 1) + self.assertTrue(docs[0].id) + + def test_save_a_response(self): + response = generate_fake_responses(None, 1)[0] + responses = TypeformResponse.query.all() + self.assertEqual(len(responses), 1) + self.assertTrue(responses[0].id) + + @attr(speed="slow") + def test_save_everything(self): + user_report, user_data, users, form_sets, response_sets = generate_fake_data(num_users=10) + users = User.query.all() + forms = Typeform.query.all() + responses = TypeformResponse.query.all() + self.assertTrue(len(users) == 10) + self.assertTrue(len(forms) > 0) + self.assertTrue(len(responses) > 0) + diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..0a92086 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,8 @@ +import re + +from bs4 import BeautifulSoup + +def get_value_for_name(name, unicode_text): + soup = BeautifulSoup(unicode_text, 'html.parser') + t = soup.find(attrs={'name': name}) + return t.attrs.get('value') \ No newline at end of file diff --git a/typeseam/app.py b/typeseam/app.py index 663859d..3e69381 100644 --- a/typeseam/app.py +++ b/typeseam/app.py @@ -1,9 +1,11 @@ import os from flask import Flask -from pprint import pprint from typeseam.extensions import ( - db, migrate, seamless_auth, ma + db, migrate, seamless_auth, ma, csrf, mail, sg + ) +from flask_user import ( + UserManager, SQLAlchemyAdapter ) def create_app(): @@ -12,6 +14,11 @@ def create_app(): app.config.from_object(config) register_extensions(app) register_blueprints(app) + + @app.before_first_request + def setup(): + load_initial_data(app) + return app def register_extensions(app): @@ -19,7 +26,51 @@ def register_extensions(app): migrate.init_app(app, db) seamless_auth.init_app(app) ma.init_app(app) + csrf.init_app(app) + mail.init_app(app) + sg.init_app(app) + + from flask_sslify import SSLify + # only trigger SSLify if the app is running on Heroku + if 'DYNO' in os.environ: + SSLify(app) + + # setup flask-user + from typeseam.auth.models import User, UserInvitation + db_adapter = SQLAlchemyAdapter(db, User, UserInvitationClass=UserInvitation) + user_manager = UserManager(db_adapter, app) + # use sendgrid for sending emails + from typeseam.auth.tasks import sendgrid_email + user_manager.send_email_function = sendgrid_email def register_blueprints(app): - from typeseam.intake import blueprint as intake - app.register_blueprint(intake) \ No newline at end of file + from typeseam.form_filler import blueprint as form_filler + app.register_blueprint(form_filler) + from typeseam.auth import blueprint as auth + app.register_blueprint(auth) + +def load_initial_data(app): + with app.app_context(): + if os.environ.get('MAKE_DEFAULT_USER', False): + # create default user + email = os.environ.get('DEFAULT_ADMIN_EMAIL', 'someone@example.com') + password = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'Passw0rd') + from typeseam.auth.queries import create_user + user = create_user(email, password) + # create default typeform + form_key = os.environ.get('DEFAULT_TYPEFORM_KEY', '') + title = os.environ.get('DEFAULT_TYPEFORM_TITLE', '') + if form_key and title: + from typeseam.form_filler.queries import create_typeform + create_typeform(form_key=form_key, title=title, user=user) + if app.config.get('LOAD_FAKE_DATA', False) and not app.testing: + from typeseam.form_filler.queries import get_response_count + from tests.mock.factories import generate_fake_data + if get_response_count() < 10: + results = generate_fake_data(num_users=10) + print(results[0]) + + + + + diff --git a/typeseam/auth/__init__.py b/typeseam/auth/__init__.py new file mode 100644 index 0000000..b0ddc08 --- /dev/null +++ b/typeseam/auth/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from flask import Blueprint + +blueprint = Blueprint( + 'auth', __name__, + template_folder='../templates' +) diff --git a/typeseam/auth/models.py b/typeseam/auth/models.py new file mode 100644 index 0000000..cb79b4e --- /dev/null +++ b/typeseam/auth/models.py @@ -0,0 +1,26 @@ +import datetime +from typeseam.extensions import db +from flask_user import UserMixin + +class User(db.Model, UserMixin): + __tablename__ = 'auth_user' + id = db.Column(db.Integer, primary_key=True, index=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + reset_password_token = db.Column(db.String(100), nullable=False, server_default='') + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + + def __repr__(self): + return ''.format(self.email) + +class UserInvitation(db.Model): + __tablename__ = 'auth_invite' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), nullable=False) + created_on = db.Column(db.DateTime(), server_default=db.func.now()) + # token used for registration page to identify user registering + token = db.Column(db.String(100), nullable=False, server_default='') + + def __repr__(self): + return ''.format(self.email, self.created_on) diff --git a/typeseam/auth/queries.py b/typeseam/auth/queries.py new file mode 100644 index 0000000..bea252a --- /dev/null +++ b/typeseam/auth/queries.py @@ -0,0 +1,26 @@ +from datetime import datetime +from flask import current_app as app +from typeseam.app import db + +from typeseam.auth.models import User + +def create_user(email, password, active=True, confirmed_at=None): + user, _ = app.user_manager.find_user_by_email(email) + if user: + return user + if confirmed_at == None: + confirmed_at = datetime.utcnow() + user = User(email=email, + password=hash_password(password), + active=active, + confirmed_at=confirmed_at) + db.session.add(user) + db.session.commit() + return user + +def get_user_by_email(email): + user, _ = app.user_manager.find_user_by_email(email) + return user + +def hash_password(raw_password): + return app.user_manager.hash_password(raw_password) \ No newline at end of file diff --git a/typeseam/auth/serializers.py b/typeseam/auth/serializers.py new file mode 100644 index 0000000..713ce0c --- /dev/null +++ b/typeseam/auth/serializers.py @@ -0,0 +1,20 @@ +from marshmallow import Schema, fields, pre_load +from flask import current_app +from typeseam.app import ma + + +class LookupMixin(ma.ModelSchema): + + def get_instance(self, data): + """Overrides ModelSchema.get_instance with custom lookup fields""" + filters = { + key: data[key] + for key in self.fields.keys() if key in self.lookup_fields} + + if None not in filters.values(): + return self.session.query( + self.opts.model + ).filter_by( + **filters + ).first() + return None diff --git a/typeseam/auth/tasks.py b/typeseam/auth/tasks.py new file mode 100644 index 0000000..53ea9e4 --- /dev/null +++ b/typeseam/auth/tasks.py @@ -0,0 +1,65 @@ + +import sendgrid +from flask import current_app, url_for +from typeseam.extensions import sg + +class UserAlreadyRegisteredError(Exception): + pass + +def sendgrid_email(recipients=None, subject=None, html_message=None, text_message=None, sender=None): + """ + sendgrid.Mail Args: + to: Recipient or list + to_name: Recipient name + from_email: Sender email + from_name: Sender name + subject: Email title + text: Email body + html: Email body + bcc: Recipient or list + reply_to: Reply address + date: Set date + headers: Set headers + files: Attachments + """ + message = sendgrid.Mail( + to=recipients, + subject=subject, + html=html_message, + text=text_message, + from_email=sender + ) + result = sg.send(message) + +def invite_new_user(email): + user_manager = current_app.user_manager + db_adapter = user_manager.db_adapter + user, user_email = user_manager.find_user_by_email(email) + if user: + raise UserAlreadyRegisteredError('{} is already registered'.format(email)) + + # the following is copied from flask_user.views.invite + from flask_user import signals, emails + user_invite = db_adapter.add_object(db_adapter.UserInvitationClass, email=email) + db_adapter.commit() + token = user_manager.generate_token(user_invite.id) + accept_invite_link = url_for('user.register', + token=token, + _external=True) + + # Store token + if hasattr(db_adapter.UserInvitationClass, 'token'): + user_invite.token = token + db_adapter.commit() + try: + # Send 'invite' email + emails.send_invite_email(user_invite, accept_invite_link) + except Exception as e: + # delete new User object if send fails + db_adapter.delete_object(user_invite) + db_adapter.commit() + raise + + signals \ + .user_sent_invitation \ + .send(current_app._get_current_object(), user_invite=user_invite) \ No newline at end of file diff --git a/typeseam/extensions.py b/typeseam/extensions.py index 65b41fe..94f3c79 100644 --- a/typeseam/extensions.py +++ b/typeseam/extensions.py @@ -1,5 +1,5 @@ -from typeseam.utils import SeamlessDocsAPIAuth +from typeseam.utils.seamless_auth import SeamlessDocsAPIAuth seamless_auth = SeamlessDocsAPIAuth() from flask_sqlalchemy import SQLAlchemy @@ -10,3 +10,15 @@ from flask_migrate import Migrate migrate = Migrate() + +from flask_login import LoginManager +login_manager = LoginManager() + +from flask_wtf.csrf import CsrfProtect +csrf = CsrfProtect() + +from flask_mail import Mail +mail = Mail() + +from typeseam.utils.sendgrid_mailer import SendGridEmailer +sg = SendGridEmailer() \ No newline at end of file diff --git a/typeseam/intake/__init__.py b/typeseam/form_filler/__init__.py similarity index 82% rename from typeseam/intake/__init__.py rename to typeseam/form_filler/__init__.py index f39fbc0..78854bb 100644 --- a/typeseam/intake/__init__.py +++ b/typeseam/form_filler/__init__.py @@ -3,7 +3,7 @@ from flask import Blueprint blueprint = Blueprint( - 'intake', __name__, + 'form_filler', __name__, template_folder='../templates' ) diff --git a/typeseam/intake/form_field_processors.py b/typeseam/form_filler/form_field_processors.py similarity index 70% rename from typeseam/intake/form_field_processors.py rename to typeseam/form_filler/form_field_processors.py index dfb1a03..38dd0e0 100644 --- a/typeseam/intake/form_field_processors.py +++ b/typeseam/form_filler/form_field_processors.py @@ -4,19 +4,19 @@ """ -def initials(target, intake): - return intake.strip()[0].upper() +def initials(target, input_value): + return input_value.strip()[0].upper() def add(target, *args): return " ".join(args) -def yesno(target, intake): - if intake == "1": +def yesno(target, input_value): + if input_value == "1": return "Yes" else: return "No" -def phone_switch(target, value, value_type, value_type_other): +def phone_switch(target, value='', value_type='', value_type_other=''): target_type = target.split("_")[-1] type_search = value_type.lower() if target_type in type_search: diff --git a/typeseam/form_filler/models.py b/typeseam/form_filler/models.py new file mode 100644 index 0000000..aa2a5e3 --- /dev/null +++ b/typeseam/form_filler/models.py @@ -0,0 +1,42 @@ +import datetime +from typeseam.extensions import db +from sqlalchemy.dialects.postgresql import JSON + +class Typeform(db.Model): + __tablename__ = 'form_filler_typeform' + id = db.Column(db.Integer, primary_key=True, index=True) + form_key = db.Column(db.String(64)) + title = db.Column(db.String(128)) + user_id = db.Column(db.Integer, db.ForeignKey('auth_user.id')) + added_on = db.Column(db.DateTime(), server_default=db.func.now()) + response_count = db.Column(db.Integer, default=0) + latest_response = db.Column(db.DateTime()) + + def __repr__(self): + return ''.format(self.form_key, self.title) + +class SeamlessDoc(db.Model): + __tablename__ = 'form_filler_seamlessdoc' + id = db.Column(db.Integer, primary_key=True, index=True) + seamless_key = db.Column(db.String(64)) + user_id = db.Column(db.Integer, db.ForeignKey('auth_user.id')) + typeform_id = db.Column(db.Integer, db.ForeignKey('form_filler_typeform.id')) + + def __repr__(self): + return ''.format(self.seamless_key) + +class TypeformResponse(db.Model): + __tablename__ = 'form_filler_response' + id = db.Column(db.Integer, primary_key=True, index=True) + user_id = db.Column(db.Integer, db.ForeignKey('auth_user.id')) + typeform_id = db.Column(db.Integer, db.ForeignKey('form_filler_typeform.id')) + seamless_id = db.Column(db.Integer, db.ForeignKey('form_filler_seamlessdoc.id')) + date_received = db.Column(db.DateTime) + answers = db.Column(JSON) + answers_translated = db.Column(db.Boolean(), default=False) + seamless_submitted = db.Column(db.Boolean(), default=False) + seamless_submission_id = db.Column(db.String(128)) + pdf_url = db.Column(db.String(128)) + + def __repr__(self): + return "".format(str(self.date_received)) \ No newline at end of file diff --git a/typeseam/form_filler/queries.py b/typeseam/form_filler/queries.py new file mode 100644 index 0000000..e648628 --- /dev/null +++ b/typeseam/form_filler/queries.py @@ -0,0 +1,132 @@ +from sqlalchemy import desc, inspect, func +from flask import abort +from flask.ext.login import current_user + +from typeseam.app import db +import io +import csv +from pprint import pprint + +from .models import ( + TypeformResponse, + Typeform, SeamlessDoc + ) + +from .serializers import ( + TypeformResponseSerializer, + FlatResponseSerializer, + TypeformSerializer, + SerializationError, + DeserializationError + ) + + +response_serializer = TypeformResponseSerializer() +flat_response_serializer = FlatResponseSerializer() +typeform_serializer = TypeformSerializer() + + +def save_new_typeform_data(data, form_key=None): + models, errors = response_serializer.load(data, many=True, session=db.session) + new_responses = [] + if errors: + raise DeserializationError(str(errors)) + for m in models or []: + if not inspect(m).persistent: + db.session.add(m) + new_responses.append(m) + if new_responses and form_key: + update_typeform_with_new_responses(form_key, new_responses) + db.session.commit() + return response_serializer.dump(new_responses, many=True).data + +def update_typeform_with_new_responses(form_key, responses): + typeform = db.session.query(Typeform).\ + filter(Typeform.form_key == form_key).first() + if not typeform: + return + latest_date = max(responses, key=lambda r: r.date_received).date_received + count = len(responses) + typeform.response_count = count + typeform.latest_response = latest_date + db.session.add(typeform) + +def get_typeforms_for_user(user): + q = db.session.query(Typeform).\ + filter(Typeform.user_id == user.id).\ + order_by(desc(Typeform.latest_response)) + return typeform_serializer.dump(q.all(), many=True).data + +def get_responses_for_typeform(user, typeform_key, count=20): + q = db.session.query(TypeformResponse, Typeform).\ + join(Typeform, Typeform.id==TypeformResponse.typeform_id).\ + filter(Typeform.form_key == typeform_key).\ + filter(Typeform.user_id == user.id).\ + order_by(desc(TypeformResponse.date_received)).\ + limit(count) + recordsets = q.all() + if len(recordsets) < 1: + form = db.session.query(Typeform).\ + filter(Typeform.form_key == typeform_key).\ + filter(Typeform.user_id == user.id).first() + return typeform_serializer.dump(form).data, [] + form = recordsets[0].Typeform + responses = [r.TypeformResponse for r in recordsets] + form_data = typeform_serializer.dump(form).data + responses_data = response_serializer.dump(responses, many=True).data + return form_data, responses_data + +def get_responses_csv(user, typeform_key): + # get responses + q = TypeformResponse.query.\ + join(Typeform.form_key, TypeformResponse.typeform_id == Typeform.id).\ + filter(TypeformResponse.user_id == user.id).\ + order_by(desc(TypeformResponse.date_received)).all() + # serialize them + data = flat_response_serializer.dump(q, many=True).data + if len(data) < 1: + abort(404) + # build csv + keys = list(data[0].keys()) + keys.sort() + with io.StringIO() as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=keys, quoting=csv.QUOTE_NONNUMERIC) + writer.writeheader() + writer.writerows(data) + return csvfile.getvalue() + +def get_seamless_doc_key_for_response(response): + return SeamlessDoc.query.get(response.seamless_id).seamless_key + +def get_typeform_for_response(response): + return +def get_response_model(response_id): + return TypeformResponse.query.get(int(response_id)) + +def get_response_detail(user, response_id): + response = get_response_model(response_id) + if user.id != response.user_id: + abort(403) + return response_serializer.dump(response).data + +def get_response_count(): + return db.session.query(func.count(TypeformResponse.id)).scalar() + +def create_typeform(form_key, title='', user=None): + params = dict(form_key=form_key, title=title, user_id=user.id) + typeform = db.session.query(Typeform).filter_by(**params).first() + if not typeform: + typeform = Typeform(**params) + db.session.add(typeform) + db.session.commit() + +def get_typeform(**kwargs): + params = {k:v for k, v in kwargs.items() if v} + if not params: + abort(404) + typeform = db.session.query(Typeform).filter_by(**params).first() + if not typeform: + abort(404) + return typeform_serializer.dump(typeform).data + + diff --git a/typeseam/intake/serializers.py b/typeseam/form_filler/serializers.py similarity index 54% rename from typeseam/intake/serializers.py rename to typeseam/form_filler/serializers.py index 4081c8f..17b571b 100644 --- a/typeseam/intake/serializers.py +++ b/typeseam/form_filler/serializers.py @@ -1,29 +1,27 @@ -from marshmallow import Schema, fields, post_dump -from typeseam.extensions import ma -from typeseam.intake.models import ( +from marshmallow import Schema, fields, post_dump, pre_load + +from typeseam.app import ma + +from typeseam.form_filler.models import ( TypeformResponse, Typeform ) +from typeseam.utils import translate +from typeseam.form_filler import form_field_processors + +from typeseam.auth.serializers import LookupMixin + # '2015-12-19 00:19:43' TYPEFORM_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" -class LookupMixin(ma.ModelSchema): - def get_instance(self, data): - """Overrides ModelSchema.get_instance with custom lookup fields""" - filters = { - key: data[key] - for key in self.fields.keys() if key in self.lookup_fields} - - if None not in filters.values(): - return self.session.query( - self.opts.model - ).filter_by( - **filters - ).first() - return None - -class TypeformResponseModelSerializer(LookupMixin): +class DeserializationError(Exception): + pass + +class SerializationError(Exception): + pass + +class TypeformResponseSerializer(LookupMixin): answers = fields.Dict() date_received = fields.DateTime(format=TYPEFORM_DATE_FORMAT) @@ -42,6 +40,18 @@ class Meta: 'pdf_url' ) + @pre_load(pass_many=True) + def parse_typeform_responses(self, data, many=True): + items = [] + for response in data['responses']: + translated_answers = translate.translate_to_seamless(response, processors=form_field_processors) + items.append(dict( + answers=translated_answers, + answers_translated=True, + date_received=response['metadata']['date_submit'] + )) + return items + class FlatResponseSerializer(ma.ModelSchema): answers = fields.Dict() date_received = fields.DateTime(format=TYPEFORM_DATE_FORMAT) @@ -72,5 +82,7 @@ class Meta: fields = ( 'form_key', 'id', - 'title' + 'title', + 'response_count', + 'latest_response' ) \ No newline at end of file diff --git a/typeseam/form_filler/tasks.py b/typeseam/form_filler/tasks.py new file mode 100644 index 0000000..cd7457e --- /dev/null +++ b/typeseam/form_filler/tasks.py @@ -0,0 +1,54 @@ +import requests +import os +import time +from pprint import pprint +from flask import abort +from typeseam.app import db +from typeseam.utils import seamless_auth +from typeseam.form_filler import queries + +def get_typeform_responses(form_key=None): + if not form_key: + form_key = os.environ.get('DEFAULT_TYPEFORM_KEY') + template = 'https://api.typeform.com/v0/form/{}' + url = template.format(form_key) + args = { + 'key': os.environ.get('TYPEFORM_API_KEY', None), + 'completed': 'true'} + data = requests.get(url, params=args).json() + responses = queries.save_new_typeform_data(data, form_key) + return responses + +def get_seamless_doc_pdf(response_id): + response = queries.get_response_model(response_id) + base_url = 'https://cleanslate.seamlessdocs.com/api/' + if not response.seamless_id: + form_id = os.environ.get('DEFAULT_SEAMLESS_FORM_ID') + else: + form_id = queries.get_seamless_doc_key_for_response(response) + submit_url = base_url + 'form/{}/submit'.format(form_id) + submit_result = requests.post(submit_url, + auth=seamless_auth.build_seamless_auth(), + data=response.answers).json() + if 'application_id' in submit_result: + response.seamless_submission_id = submit_result['application_id'] + db.session.commit() + else: + # these abort errors should be more specific + abort(404) + app_url = base_url + 'application/{}'.format(response.seamless_submission_id) + # wait for the pdf to be generated + time.sleep(10) + app_result = requests.get(app_url, auth=seamless_auth.build_seamless_auth()).json() + response.pdf_url = app_result.get('submission_pdf_url', '') + if response.pdf_url: + db.session.commit() + else: + abort(404) + form = queries.get_typeform(id=response.typeform_id) + response_data = queries.response_serializer.dump(response).data + return form, response_data + + + + diff --git a/typeseam/form_filler/views.py b/typeseam/form_filler/views.py new file mode 100644 index 0000000..22574cb --- /dev/null +++ b/typeseam/form_filler/views.py @@ -0,0 +1,74 @@ + +from flask import render_template, jsonify, Response +from flask_user import login_required +from flask.ext.login import current_user +from typeseam.form_filler import ( + blueprint, + queries, + tasks + ) + + + +@blueprint.route('/', methods=['GET']) +@login_required +def index(): + typeforms = queries.get_typeforms_for_user(current_user) + return render_template( + 'index.html', + typeforms=typeforms, + ) + +@blueprint.route('//responses/', methods=['GET']) +@login_required +def responses(typeform_key): + """get the responses of a particular typeform + """ + form, responses = queries.get_responses_for_typeform(current_user, typeform_key, count=30) + return render_template( + 'responses.html', + form=form, + responses=responses, + ) + +@blueprint.route('//responses//', methods=['GET']) +@login_required +def response_detail(typeform_key, response_id): + """Show the details of a particular typeform response + """ + response = queries.get_response_detail(current_user, response_id) + form = queries.get_typeform(typeform_key) + return render_template( + "response_detail.html", + response=response, + form=form + ) + +@blueprint.route('//responses.csv') +@login_required +def responses_csv(typeform_key): + """Generates a csv file of all responses + """ + csv = queries.get_responses_csv(current_user, typeform_key) + return Response(csv, mimetype="text/csv") + + +@blueprint.route('/api//new_responses/', methods=['GET']) +@login_required +def remote_responses(typeform_key): + # make an api call to Typeform + # this can be done as a background task + responses = tasks.get_typeform_responses() + return render_template( + "response_list.html", + responses=responses) + +@blueprint.route('/api/response//fill_pdf/', methods=['POST']) +@login_required +def fill_seamless_docs_pdf(response_id): + # make an api call to Seamless docs + # save the new pdf URL + # return the new pdf + # this can be done as a background task + form, response = tasks.get_seamless_doc_pdf(response_id) + return render_template("response_listing.html", form=form, response=response) diff --git a/typeseam/intake/models.py b/typeseam/intake/models.py deleted file mode 100644 index 3b4fe38..0000000 --- a/typeseam/intake/models.py +++ /dev/null @@ -1,22 +0,0 @@ -import datetime -from typeseam.app import db -from sqlalchemy.dialects.postgresql import JSON - -class Typeform(db.Model): - __tablename__ = 'typeform' - id = db.Column(db.Integer, primary_key=True, index=True) - form_key = db.Column(db.String(64)) - title = db.Column(db.String(128)) - -class TypeformResponse(db.Model): - __tablename__ = 'response' - id = db.Column(db.Integer, primary_key=True, index=True) - date_received = db.Column(db.DateTime) - answers = db.Column(JSON) - answers_translated = db.Column(db.Boolean(), default=False) - seamless_submitted = db.Column(db.Boolean(), default=False) - seamless_key = db.Column(db.String(128)) - pdf_url = db.Column(db.String(128)) - - def __repr__(self): - return "".format(str(self.date_received)) \ No newline at end of file diff --git a/typeseam/intake/queries.py b/typeseam/intake/queries.py deleted file mode 100644 index 27dafe9..0000000 --- a/typeseam/intake/queries.py +++ /dev/null @@ -1,71 +0,0 @@ -from sqlalchemy import desc, inspect - -from typeseam.app import db -from typeseam import utils - -import io -import csv -from pprint import pprint - -from .models import ( - TypeformResponse, - Typeform - ) - -from .serializers import ( - TypeformResponseModelSerializer, - FlatResponseSerializer, - TypeformSerializer - ) - -from typeseam.intake import form_field_processors - -response_serializer = TypeformResponseModelSerializer() -flat_response_serializer = FlatResponseSerializer() -form_serializer = TypeformSerializer() - -def get_response_model(response_id): - return TypeformResponse.query.get(int(response_id)) - -def get_response_detail(response_id): - response = get_response_model(response_id) - return response_serializer.dump(response).data - -def most_recent_responses(count=20): - q = TypeformResponse.query.\ - order_by(desc(TypeformResponse.date_received)).\ - limit(count) - return response_serializer.dump(q.all(), many=True).data - -def get_responses_csv(): - q = TypeformResponse.query.\ - order_by(desc(TypeformResponse.date_received)).all() - data = flat_response_serializer.dump(q, many=True).data - keys = list(data[0].keys()) - keys.sort() - with io.StringIO() as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=keys, quoting=csv.QUOTE_NONNUMERIC) - writer.writeheader() - writer.writerows(data) - return csvfile.getvalue() - -def parse_typeform_data(data): - items = [] - for response in data['responses']: - translated_answers = utils.translate_to_seamless(response, processors=form_field_processors) - items.append(dict( - answers=translated_answers, - answers_translated=True, - date_received=response['metadata']['date_submit'] - )) - models, errors = response_serializer.load(items, many=True, session=db.session) - new_responses = [] - # if errors, report them - for m in models or []: - if not inspect(m).persistent: - db.session.add(m) - new_responses.append(m) - db.session.commit() - return response_serializer.dump(new_responses, many=True).data - - diff --git a/typeseam/intake/tasks.py b/typeseam/intake/tasks.py deleted file mode 100644 index b0cf1bc..0000000 --- a/typeseam/intake/tasks.py +++ /dev/null @@ -1,45 +0,0 @@ -import requests -import os -import time -from pprint import pprint -from typeseam.app import db -from typeseam import utils -from typeseam.intake import queries - -def get_typeform_responses(form_key=None): - if not form_key: - form_key = os.environ.get('DEFAULT_TYPEFORM_KEY') - template = 'https://api.typeform.com/v0/form/{}' - url = template.format(form_key) - args = { - 'key': os.environ.get('TYPEFORM_API_KEY', None), - 'completed': 'true'} - data = requests.get(url, params=args).json() - responses = queries.parse_typeform_data(data) - return responses - -def get_seamless_doc_pdf(response_id): - response = queries.get_response_model(response_id) - base_url = 'https://cleanslate.seamlessdocs.com/api/' - if not response.seamless_key: - form_id = os.environ.get('DEFAULT_SEAMLESS_FORM_ID') - submit_url = base_url + 'form/{}/submit'.format(form_id) - submit_result = requests.post(submit_url, - auth=utils.build_seamless_auth(), - data=response.answers).json() - if 'application_id' in submit_result: - response.seamless_key = submit_result['application_id'] - db.session.commit() - else: - return None - app_url = base_url + 'application/{}'.format(response.seamless_key) - # wait for the pdf to be generated - time.sleep(10) - app_result = requests.get(app_url, auth=utils.build_seamless_auth()).json() - response.pdf_url = app_result.get('submission_pdf_url', '') - if response.pdf_url: - db.session.commit() - return queries.response_serializer.dump(response).data - - - diff --git a/typeseam/intake/views.py b/typeseam/intake/views.py deleted file mode 100644 index 3a7d726..0000000 --- a/typeseam/intake/views.py +++ /dev/null @@ -1,57 +0,0 @@ - -from flask import render_template, jsonify, Response -from typeseam.intake import ( - blueprint, - queries, - tasks - ) -FORM = { - 'id': 1, - 'title': 'Clean Slate SF', - 'form_key': 'o8MrpO', - 'edit_url': 'https://admin.typeform.com/form/1084993/fields/', - 'live_url': 'https://bgolder.typeform.com/to/o8MrpO', - } -@blueprint.route('/', methods=['GET']) -def local_responses(): - responses = queries.most_recent_responses() - return render_template( - 'index.html', - form=FORM, - responses=responses, - ) - -@blueprint.route('/response/') -def response_detail(response_id): - response = queries.get_response_detail(response_id) - return render_template( - "response_detail.html", - response=response, - form=FORM - ) - -@blueprint.route('/responses.csv') -def responses_csv(): - # this generates a csv on the fly - # this can be done as a background task - # should regenerate a static file when respones are refreshed - csv = queries.get_responses_csv() - return Response(csv, mimetype="text/csv") - -@blueprint.route('/api/new_responses', methods=['GET']) -def remote_responses(): - # make an api call to Typeform - # this can be done as a background task - responses = tasks.get_typeform_responses() - return render_template( - "response_list.html", - responses=responses) - -@blueprint.route('/api/get_pdf/', methods=['GET']) -def get_seamless_docs_pdf(response_id): - # make an api call to Seamless docs - # save the new pdf URL - # return the new pdf - # this can be done as a background task - response = tasks.get_seamless_doc_pdf(response_id) - return render_template("response_listing.html", response=response) diff --git a/typeseam/scripts/__init__.py b/typeseam/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/typeseam/scripts/invite_user.py b/typeseam/scripts/invite_user.py new file mode 100644 index 0000000..20c02d2 --- /dev/null +++ b/typeseam/scripts/invite_user.py @@ -0,0 +1,18 @@ +import sys +from typeseam.app import create_app +from typeseam.auth.tasks import invite_new_user + +class MissingEmailError(Exception): + pass + +def run(emails): + app = create_app() + with app.app_context(): + for email in emails: + invite_new_user(email) + +if __name__ == '__main__': + if len(sys.argv) < 2: + raise MissingEmailError("You must provide at least one email as an argument") + else: + run([email for email in sys.argv[1:]]) \ No newline at end of file diff --git a/typeseam/scripts/send_one_mail.py b/typeseam/scripts/send_one_mail.py new file mode 100644 index 0000000..6ef144d --- /dev/null +++ b/typeseam/scripts/send_one_mail.py @@ -0,0 +1,16 @@ + +from typeseam.app import create_app +from typeseam.auth.tasks import invite_new_user + +def run(): + app = create_app() + with app.app_context(): + status = sendgrid_email( + subject="testing mail again", + recipients=['benjamin.j.golder@gmail.com'], + text_message="What is up?" + ) + print( status ) + +if __name__ == '__main__': + run() \ No newline at end of file diff --git a/typeseam/settings.py b/typeseam/settings.py index cf8e45b..ce55ec0 100644 --- a/typeseam/settings.py +++ b/typeseam/settings.py @@ -4,13 +4,17 @@ HERE = os.path.abspath(os.path.dirname(__file__)) PROJECT_ROOT = os.path.abspath(os.path.join(HERE, os.pardir)) -class Config(object): +from .settings_auth import AuthConfig + +class Config(AuthConfig): SECRET_KEY = os.environ.get('SECRET_KEY', 'secret-key') SQLALCHEMY_TRACK_MODIFICATIONS = False TYPEFORM_API_KEY = os.environ.get('TYPEFORM_API_KEY') SEAMLESS_DOCS_API_KEY = os.environ.get('SEAMLESS_DOCS_API_KEY') SEAMLESS_DOCS_API_SECRET = os.environ.get('SEAMLESS_DOCS_API_SECRET') SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') + SENDGRID_API_KEY = os.environ.get('SENDGRID_API_KEY') + SERVER_NAME = os.environ.get('HOST_NAME', 'localhost:9000') class ProdConfig(Config): ENV = 'prod' @@ -19,6 +23,7 @@ class ProdConfig(Config): class DevConfig(Config): ENV = 'dev' DEBUG = True + LOAD_FAKE_DATA = True class TestConfig(Config): SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') diff --git a/typeseam/settings_auth.py b/typeseam/settings_auth.py new file mode 100644 index 0000000..b6650df --- /dev/null +++ b/typeseam/settings_auth.py @@ -0,0 +1,106 @@ +# settings that are specific to Flask-User + +class AuthConfig(object): + USER_ENABLE_USERNAMES = False + USER_ENABLE_CHANGE_USERNAME = False + +# def set_default_settings(user_manager, app_config): +# """ Set default app.config settings, but only if they have not been set before """ +# # define short names +# um = user_manager +# sd = app_config.setdefault + +# # Retrieve obsoleted settings +# # These plural settings have been replaced by singular settings +# obsoleted_enable_emails = sd('USER_ENABLE_EMAILS', True) +# obsoleted_enable_retype_passwords = sd('USER_ENABLE_RETYPE_PASSWORDS', True) +# obsoleted_enable_usernames = sd('USER_ENABLE_USERNAMES', True) +# obsoleted_enable_registration = sd('USER_ENABLE_REGISTRATION', True) + +# # General settings +# um.app_name = sd('USER_APP_NAME', 'AppName') + +# # Set default features +# um.enable_change_password = sd('USER_ENABLE_CHANGE_PASSWORD', True) +# um.enable_change_username = sd('USER_ENABLE_CHANGE_USERNAME', True) +# um.enable_email = sd('USER_ENABLE_EMAIL', obsoleted_enable_emails) +# um.enable_confirm_email = sd('USER_ENABLE_CONFIRM_EMAIL', um.enable_email) +# um.enable_forgot_password = sd('USER_ENABLE_FORGOT_PASSWORD', um.enable_email) +# um.enable_login_without_confirm_email = sd('USER_ENABLE_LOGIN_WITHOUT_CONFIRM_EMAIL', False) +# um.enable_multiple_emails = sd('USER_ENABLE_MULTIPLE_EMAILS', False) +# um.enable_register = sd('USER_ENABLE_REGISTER', obsoleted_enable_registration) +# um.enable_remember_me = sd('USER_ENABLE_REMEMBER_ME', True) +# um.enable_retype_password = sd('USER_ENABLE_RETYPE_PASSWORD', obsoleted_enable_retype_passwords) +# um.enable_username = sd('USER_ENABLE_USERNAME', obsoleted_enable_usernames) + +# # Set default settings +# um.auto_login = sd('USER_AUTO_LOGIN', True) +# um.auto_login_after_confirm = sd('USER_AUTO_LOGIN_AFTER_CONFIRM', um.auto_login) +# um.auto_login_after_register = sd('USER_AUTO_LOGIN_AFTER_REGISTER', um.auto_login) +# um.auto_login_after_reset_password = sd('USER_AUTO_LOGIN_AFTER_RESET_PASSWORD', um.auto_login) +# um.auto_login_at_login = sd('USER_AUTO_LOGIN_AT_LOGIN', um.auto_login) +# um.confirm_email_expiration = sd('USER_CONFIRM_EMAIL_EXPIRATION', 2*24*3600) # 2 days +# um.invite_expiration = sd('USER_INVITE_EXPIRATION', 90*24*3600) # 90 days +# um.password_hash_mode = sd('USER_PASSWORD_HASH_MODE', 'passlib') +# um.password_hash = sd('USER_PASSWORD_HASH', 'bcrypt') +# um.password_salt = sd('USER_PASSWORD_SALT', app_config['SECRET_KEY']) +# um.reset_password_expiration = sd('USER_RESET_PASSWORD_EXPIRATION', 2*24*3600) # 2 days +# um.enable_invitation = sd('USER_ENABLE_INVITATION', False) +# um.require_invitation = sd('USER_REQUIRE_INVITATION', False) +# um.send_password_changed_email = sd('USER_SEND_PASSWORD_CHANGED_EMAIL',um.enable_email) +# um.send_registered_email = sd('USER_SEND_REGISTERED_EMAIL', um.enable_email) +# um.send_username_changed_email = sd('USER_SEND_USERNAME_CHANGED_EMAIL',um.enable_email) +# um.show_username_email_does_not_exist = sd('USER_SHOW_USERNAME_EMAIL_DOES_NOT_EXIST', um.enable_register) + +# # Set default URLs +# um.change_password_url = sd('USER_CHANGE_PASSWORD_URL', '/user/change-password') +# um.change_username_url = sd('USER_CHANGE_USERNAME_URL', '/user/change-username') +# um.confirm_email_url = sd('USER_CONFIRM_EMAIL_URL', '/user/confirm-email/') +# um.email_action_url = sd('USER_EMAIL_ACTION_URL', '/user/email//') +# um.forgot_password_url = sd('USER_FORGOT_PASSWORD_URL', '/user/forgot-password') +# um.login_url = sd('USER_LOGIN_URL', '/user/sign-in') +# um.logout_url = sd('USER_LOGOUT_URL', '/user/sign-out') +# um.manage_emails_url = sd('USER_MANAGE_EMAILS_URL', '/user/manage-emails') +# um.register_url = sd('USER_REGISTER_URL', '/user/register') +# um.resend_confirm_email_url = sd('USER_RESEND_CONFIRM_EMAIL_URL', '/user/resend-confirm-email') +# um.reset_password_url = sd('USER_RESET_PASSWORD_URL', '/user/reset-password/') +# um.user_profile_url = sd('USER_PROFILE_URL', '/user/profile') +# um.invite_url = sd('USER_INVITE_URL', '/user/invite') + +# # Set default ENDPOINTs +# home_endpoint = '' +# login_endpoint = um.login_endpoint = 'user.login' +# um.after_change_password_endpoint = sd('USER_AFTER_CHANGE_PASSWORD_ENDPOINT', home_endpoint) +# um.after_change_username_endpoint = sd('USER_AFTER_CHANGE_USERNAME_ENDPOINT', home_endpoint) +# um.after_confirm_endpoint = sd('USER_AFTER_CONFIRM_ENDPOINT', home_endpoint) +# um.after_forgot_password_endpoint = sd('USER_AFTER_FORGOT_PASSWORD_ENDPOINT', home_endpoint) +# um.after_login_endpoint = sd('USER_AFTER_LOGIN_ENDPOINT', home_endpoint) +# um.after_logout_endpoint = sd('USER_AFTER_LOGOUT_ENDPOINT', login_endpoint) +# um.after_register_endpoint = sd('USER_AFTER_REGISTER_ENDPOINT', home_endpoint) +# um.after_resend_confirm_email_endpoint = sd('USER_AFTER_RESEND_CONFIRM_EMAIL_ENDPOINT', home_endpoint) +# um.after_reset_password_endpoint = sd('USER_AFTER_RESET_PASSWORD_ENDPOINT', home_endpoint) +# um.after_invite_endpoint = sd('USER_INVITE_ENDPOINT', home_endpoint) +# um.unconfirmed_email_endpoint = sd('USER_UNCONFIRMED_EMAIL_ENDPOINT', home_endpoint) +# um.unauthenticated_endpoint = sd('USER_UNAUTHENTICATED_ENDPOINT', login_endpoint) +# um.unauthorized_endpoint = sd('USER_UNAUTHORIZED_ENDPOINT', home_endpoint) + +# # Set default template files +# um.change_password_template = sd('USER_CHANGE_PASSWORD_TEMPLATE', 'flask_user/change_password.html') +# um.change_username_template = sd('USER_CHANGE_USERNAME_TEMPLATE', 'flask_user/change_username.html') +# um.forgot_password_template = sd('USER_FORGOT_PASSWORD_TEMPLATE', 'flask_user/forgot_password.html') +# um.login_template = sd('USER_LOGIN_TEMPLATE', 'flask_user/login.html') +# um.manage_emails_template = sd('USER_MANAGE_EMAILS_TEMPLATE', 'flask_user/manage_emails.html') +# um.register_template = sd('USER_REGISTER_TEMPLATE', 'flask_user/register.html') +# um.resend_confirm_email_template = sd('USER_RESEND_CONFIRM_EMAIL_TEMPLATE', 'flask_user/resend_confirm_email.html') +# um.reset_password_template = sd('USER_RESET_PASSWORD_TEMPLATE', 'flask_user/reset_password.html') +# um.user_profile_template = sd('USER_PROFILE_TEMPLATE', 'flask_user/user_profile.html') +# um.invite_template = sd('USER_INVITE_TEMPLATE', 'flask_user/invite.html') +# um.invite_accept_template = sd('USER_INVITE_ACCEPT_TEMPLATE', 'flask_user/register.html') + +# # Set default email template files +# um.confirm_email_email_template = sd('USER_CONFIRM_EMAIL_EMAIL_TEMPLATE', 'flask_user/emails/confirm_email') +# um.forgot_password_email_template = sd('USER_FORGOT_PASSWORD_EMAIL_TEMPLATE', 'flask_user/emails/forgot_password') +# um.password_changed_email_template = sd('USER_PASSWORD_CHANGED_EMAIL_TEMPLATE', 'flask_user/emails/password_changed') +# um.registered_email_template = sd('USER_REGISTERED_EMAIL_TEMPLATE', 'flask_user/emails/registered') +# um.username_changed_email_template = sd('USER_USERNAME_CHANGED_EMAIL_TEMPLATE', 'flask_user/emails/username_changed') +# um.invite_email_template = sd('USER_INVITE_EMAIL_TEMPLATE', 'flask_user/emails/invite') \ No newline at end of file diff --git a/typeseam/static/css/main.css b/typeseam/static/css/main.css index 2fbc3da..dad9e33 100644 --- a/typeseam/static/css/main.css +++ b/typeseam/static/css/main.css @@ -3603,16 +3603,16 @@ input[type="button"].btn-block { .stateful .state { display: none; } -.untouched .state.content_default { +.stateful.default .state.content_default { display: inline; } -.sending .state.content_sending { +.stateful.sending .state.content_sending { display: inline; } -.generating .state.content_generating { +.stateful.generating .state.content_generating { display: inline; } -.retrieving .state.content_retrieving { +.stateful.retrieving .state.content_retrieving { display: inline; } .btn .glyphicon { diff --git a/typeseam/static/js/main.js b/typeseam/static/js/main.js index 4d2c3b6..28525f7 100644 --- a/typeseam/static/js/main.js +++ b/typeseam/static/js/main.js @@ -9210,6 +9210,7 @@ return jQuery; })); $( document ).ready(function() { + addCSRFTokenToRequests() listenToEvents(); getNewResponses(); }); @@ -9217,18 +9218,31 @@ $( document ).ready(function() { var PDF_LOADING_STATES = [ ["sending", 2000], ["generating", 13000], - ["retrieving", 5000] + ["retrieving", 5000], ]; +function addCSRFTokenToRequests(){ + // Taken directly from + // http://flask-wtf.readthedocs.org/en/latest/csrf.html#ajax + var csrftoken = $('meta[name=csrf-token]').attr('content'); + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken) + } + } + }); +} + function listenToEvents(){ $('.responses-header').on('click', '.load_new_responses', getNewResponses); - $('.responses').on('click', '.pdf_cell', getPDF); + $('.container').on('click', '.pdf_button', getPDF); } function getNewResponses(e){ $('button.load_new_responses').addClass('loading'); $.ajax({ - url: "/api/new_responses", + url: API_ENDPOINTS.new_responses, success: handleNewResponses, timeout: 10000 }); @@ -9240,6 +9254,8 @@ function stateTransitionChain(target, stateStack, index){ target.removeClass(prevStateClassName); } if( index == stateStack.length ){ + target.removeClass("loading"); + target.addClass('default'); return; } var stateClassName = stateStack[index][0]; @@ -9252,13 +9268,15 @@ function stateTransitionChain(target, stateStack, index){ function getPDF(e){ var target = $(this); - target.removeClass("untouched"); + console.log("clicked to get pdf on", target); + target.removeClass("default"); target.addClass('loading'); - var responseId = target.parent('.response').attr('id'); + var responseId = target.parents('.response').attr('id'); responseId = responseId.split("-")[1] - stateTransitionChain($(this), PDF_LOADING_STATES, 0); + stateTransitionChain(target, PDF_LOADING_STATES, 0); $.ajax({ - url: "/api/get_pdf/" + responseId, + method: "POST", + url: target.attr("data-apiendpoint"), success: handleNewPDF(responseId), timeout: 20000 }); diff --git a/typeseam/templates/auth/login.html b/typeseam/templates/auth/login.html new file mode 100644 index 0000000..a6423cc --- /dev/null +++ b/typeseam/templates/auth/login.html @@ -0,0 +1,23 @@ +{% extends "index.html" %} + +{% block body %} +
+

+ Login Form +

+
+ + {% include "includes/csrf_field.html" %} + {% include "includes/next_field.html" %} + + + + + + + +
+
+{% endblock body %} \ No newline at end of file diff --git a/typeseam/templates/base.html b/typeseam/templates/base.html index 40f31e6..34f9b7d 100644 --- a/typeseam/templates/base.html +++ b/typeseam/templates/base.html @@ -1,12 +1,19 @@ - {% include "head.html" %} + {% include "includes/head.html" %} {% block body %} + {% include "includes/header.html" %} + + {% block main %}{% block content %}{% endblock %}{% endblock %} {% endblock body %} + \ No newline at end of file diff --git a/typeseam/templates/flash_messages.html b/typeseam/templates/flash_messages.html new file mode 100644 index 0000000..14bfce3 --- /dev/null +++ b/typeseam/templates/flash_messages.html @@ -0,0 +1,15 @@ + + {% block flash_messages %} + {%- with messages = get_flashed_messages(with_categories=true) -%} + {% if messages %} +
+ {% for category, message in messages %} + {% if category=='error' %} + {% set category='danger' %} + {% endif %} +
{{ message|safe }}
+ {% endfor %} +
+ {% endif %} + {%- endwith %} + {% endblock %} \ No newline at end of file diff --git a/typeseam/templates/form_header.html b/typeseam/templates/form_header.html index b04f594..64e4070 100644 --- a/typeseam/templates/form_header.html +++ b/typeseam/templates/form_header.html @@ -1,4 +1,4 @@ -
+

FORM TITLE

@@ -19,4 +19,5 @@

{{ form.title }}

-
\ No newline at end of file + + \ No newline at end of file diff --git a/typeseam/templates/form_listing.html b/typeseam/templates/form_listing.html new file mode 100644 index 0000000..9462996 --- /dev/null +++ b/typeseam/templates/form_listing.html @@ -0,0 +1,19 @@ + + + + + + {{ form.title }} + + + + + + {{ form.response_count }} + + + + {{ form.latest_response }} + + + \ No newline at end of file diff --git a/typeseam/templates/includes/csrf_field.html b/typeseam/templates/includes/csrf_field.html new file mode 100644 index 0000000..0f3bc8e --- /dev/null +++ b/typeseam/templates/includes/csrf_field.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/typeseam/templates/includes/flash_messages.html b/typeseam/templates/includes/flash_messages.html new file mode 100644 index 0000000..e7c8985 --- /dev/null +++ b/typeseam/templates/includes/flash_messages.html @@ -0,0 +1,15 @@ + + {% block flash_messages %} +
+ {%- with messages = get_flashed_messages(with_categories=true) -%} + {%- if messages -%} + {%- for category, message in messages -%} + {%- if category=='error' -%} + {%- set category='danger' -%} + {%- endif %} +
{{ message|safe }}
+ {%- endfor %} + {%- endif %} + {%- endwith %} +
+ {% endblock %} \ No newline at end of file diff --git a/typeseam/templates/head.html b/typeseam/templates/includes/head.html similarity index 82% rename from typeseam/templates/head.html rename to typeseam/templates/includes/head.html index b76788f..692b6d3 100644 --- a/typeseam/templates/head.html +++ b/typeseam/templates/includes/head.html @@ -1,6 +1,7 @@ + {%- if page_title -%} {{- page_title -}} diff --git a/typeseam/templates/includes/header.html b/typeseam/templates/includes/header.html new file mode 100644 index 0000000..474e0e9 --- /dev/null +++ b/typeseam/templates/includes/header.html @@ -0,0 +1,20 @@ + <header class="header"> + + <div class="top_bar"> + <!-- TO DO: show navigation breadcrumbs in a mobile-friendly way --> + {% if current_user.is_authenticated %} + <div class="auth-notice authenticated"> + <span class="message">Logged in as</span> + <span class="username">{{ current_user.email }}</span> + <a href="{{ url_for('user.logout') }}" class="logout_link">Log out</a> + </div> + {% else %} + <div class="auth-notice not-authenticated"> + <span class="message">Not logged in</span> + <a href="{{ url_for('user.login') }}" class="login_link">Log in</a> + </div> + {% endif %} + </div> <!-- end top_bar --> + + {% include "includes/flash_messages.html" %} + </header> diff --git a/typeseam/templates/includes/next_field.html b/typeseam/templates/includes/next_field.html new file mode 100644 index 0000000..c6eee54 --- /dev/null +++ b/typeseam/templates/includes/next_field.html @@ -0,0 +1 @@ + <input type="hidden" name="next" value="{{ next }}" /> \ No newline at end of file diff --git a/typeseam/templates/index.html b/typeseam/templates/index.html index 9360925..3012be7 100644 --- a/typeseam/templates/index.html +++ b/typeseam/templates/index.html @@ -1,41 +1,31 @@ {% extends "base.html" %} {% block body %} -{% include "form_header.html" %} + +{% include "includes/header.html" %} <div class="container"> - {% block main %} + {% block main %} - <div class="responses-header"> - <h3> - Form responses - </h3> - <div class="reponses-actions"> - <button class="btn btn-primary load_new_responses"> - <span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> - Check for new responses - </button> - <a class="btn btn-default" href="/responses.csv"> - <span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span> - Download .csv - </a> - </div> - </div> + <h3> + {{ current_user.email }} + </h3> <table class="table"> - <caption> - </caption> <thead> <tr> - <th>Received</th> - <th>PDF</th> + <th>Typeform</th> + <th>Total responses</th> + <th>Most recent response</th> </tr> </thead> - <tbody class="responses"> - {% include "response_list.html" %} + <tbody class="typeforms"> + {% for form in typeforms %} + {% include "form_listing.html" %} + {% endfor %} </tbody> </table> - {% endblock main %} + {% endblock main %} </div> {% endblock body %} \ No newline at end of file diff --git a/typeseam/templates/pdf_button.html b/typeseam/templates/pdf_button.html index 1b2f40e..8e06b6d 100644 --- a/typeseam/templates/pdf_button.html +++ b/typeseam/templates/pdf_button.html @@ -1,10 +1,7 @@ -{%- if response.pdf_url -%} - <a class="btn btn-primary" href="{{ response.pdf_url }}" target="_blank"> - <span class="glyphicon glyphicon-file" aria-hidden="true"></span> - PDF - </a> - {%- else -%} - <span class="btn btn-default stateful "> +{%- if not response.pdf_url -%} + <span class="btn btn-default stateful pdf_button default" data-apiendpoint="{{ + url_for('form_filler.fill_seamless_docs_pdf',response_id=response.id) + }}"> <span class="state content_default"> Click to generate PDF </span> @@ -24,4 +21,9 @@ Retrieving link to PDF </span> </span> +{%- else -%} + <a class="btn btn-primary" href="{{ response.pdf_url }}" target="_blank"> + <span class="glyphicon glyphicon-file" aria-hidden="true"></span> + PDF + </a> {%- endif -%} \ No newline at end of file diff --git a/typeseam/templates/response_detail.html b/typeseam/templates/response_detail.html index af4c723..3d74a90 100644 --- a/typeseam/templates/response_detail.html +++ b/typeseam/templates/response_detail.html @@ -3,21 +3,17 @@ {% block main %} <div class="responses-header"> <h3 class="breadcrumb"> - <a class="backlink" href="/">form responses</a> + <a class="backlink" href="{{ url_for('form_filler.responses', typeform_key=form.form_key) }}">form responses</a> → <span class="current"> - response {{response.id}} + response {{ response.date_received }} </span> </h3> </div> - <table class="table response"> + <table id="response-{{ response.id }}" class="table response"> <!-- loop through keys and write them out --> - <tr class="reponse-attribute"> - <th>Date submitted</th> - <td>{{ response.date_received }}</td> - </tr> <tr class="reponse-attribute"> <th>PDF</th> <td>{% include "pdf_button.html" %}</td> diff --git a/typeseam/templates/response_list.html b/typeseam/templates/response_list.html index 1616726..7855975 100644 --- a/typeseam/templates/response_list.html +++ b/typeseam/templates/response_list.html @@ -1,3 +1,4 @@ -{% for response in responses %} +{# this template, though minimal, is needed for API calls for new responses #} +{% for response in responses -%} {% include "response_listing.html" %} -{% endfor %} \ No newline at end of file +{%- endfor %} \ No newline at end of file diff --git a/typeseam/templates/response_listing.html b/typeseam/templates/response_listing.html index e21c578..09a5507 100644 --- a/typeseam/templates/response_listing.html +++ b/typeseam/templates/response_listing.html @@ -1,9 +1,9 @@ <tr id="response-{{response.id}}" class="response"> <td class="date_cell"> - <a href="/response/{{response.id}}"> + <a href="{{ url_for('form_filler.response_detail', typeform_key=form.form_key, response_id=response.id)}}"> {{ response.date_received }} </a> </td> - <td class="pdf_cell untouched">{% include "pdf_button.html" %}</td> + <td class="pdf_cell">{% include "pdf_button.html" %}</td> </tr> \ No newline at end of file diff --git a/typeseam/templates/responses.html b/typeseam/templates/responses.html new file mode 100644 index 0000000..1437c99 --- /dev/null +++ b/typeseam/templates/responses.html @@ -0,0 +1,38 @@ +{% extends "index.html" %} + +{% block body %} + +{% include "includes/header.html" %} + + +{% include "form_header.html" %} + + <div class="container"> + {% block main %} + + <table class="table"> + <thead> + <tr> + <th>Received</th> + <th>PDF</th> + </tr> + </thead> + + <tbody class="responses"> +{% include "response_list.html" %} +{% for response in responses %} +{% include "response_listing.html" %} +{% endfor %} + </tbody> + </table> + + + {% endblock main %} + </div> <!-- end container --> +{% endblock body %} + +{% block js_variables %} + var API_ENDPOINTS = { + new_responses: "{{ url_for('form_filler.remote_responses', typeform_key=form.form_key) }}", + }; +{% endblock js_variables %} \ No newline at end of file diff --git a/typeseam/utils/__init__.py b/typeseam/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/typeseam/utils.py b/typeseam/utils/seamless_auth.py similarity index 62% rename from typeseam/utils.py rename to typeseam/utils/seamless_auth.py index 5779175..3ff16a6 100644 --- a/typeseam/utils.py +++ b/typeseam/utils/seamless_auth.py @@ -1,13 +1,8 @@ -from requests.auth import AuthBase import os import time import hmac import hashlib - - -import sys -sys.path.append('./data') -import translator +from requests.auth import AuthBase def build_seamless_auth(): return SeamlessDocsAPIAuth() @@ -31,8 +26,8 @@ def config_from_env(self): nonce = os.environ.get('SECRET_KEY', 'ministryOfSillyWalks') self.config.update( nonce=nonce, - api_key=os.environ.get('SEAMLESS_DOCS_API_KEY'), - api_secret=os.environ.get('SEAMLESS_DOCS_API_SECRET') + api_key=os.environ.get('SEAMLESS_DOCS_API_KEY', ''), + api_secret=os.environ.get('SEAMLESS_DOCS_API_SECRET', '') ) def config_from_app(self, app): @@ -41,8 +36,8 @@ def config_from_app(self, app): nonce = app.config.get('SECRET_KEY', 'ministryOfSillyWalks') self.config.update( nonce=nonce, - api_key=app.config.get('SEAMLESS_DOCS_API_KEY'), - api_secret=app.config.get('SEAMLESS_DOCS_API_SECRET') + api_key=app.config.get('SEAMLESS_DOCS_API_KEY', ''), + api_secret=app.config.get('SEAMLESS_DOCS_API_SECRET', '') ) def set_config(self, app=None): @@ -83,43 +78,3 @@ def __call__(self, request, nonce=None): 'Date': timestamp }) return request - - -def translate_to_seamless(typeform, processors=None): - trans = translator.fields - answers = {} - answers.update({ - "_meta_date_submitted": typeform['metadata']['date_submit'] - }) - answers.update(typeform['answers']) - translated_answers = {} - - for target, config in trans.items(): - answer_key = config[0] - answer_keys = [] - raw_values = "" - raw_value = "" - if not isinstance(answer_key, str): - answer_keys = answer_key - if answer_keys: - raw_values = [answers[k] for k in answer_keys if k in answers] - else: - raw_value = answers.get(answer_key, "") - - final_value = "" - if len(config) == 2: - processor_keys = config[-1] - answer_processors = [processors.lookup[k] for k in processor_keys] - for answer_processor in answer_processors: - if raw_values: - final_value = answer_processor(target, *raw_values) - else: - final_value = answer_processor(target, raw_value) - elif "yesno" in answer_key: - yn_processor = processors.lookup["yesno"] - final_value = yn_processor(target, raw_value) - else: - final_value = raw_value - translated_answers[target] = final_value - - return translated_answers \ No newline at end of file diff --git a/typeseam/utils/sendgrid_mailer.py b/typeseam/utils/sendgrid_mailer.py new file mode 100644 index 0000000..a44125d --- /dev/null +++ b/typeseam/utils/sendgrid_mailer.py @@ -0,0 +1,59 @@ +import os +from flask import current_app +from flask.signals import Namespace +import sendgrid + +class SendEmailError(Exception): + pass + +class SendGridEmailer(object): + + def __init__(self, app=None): + self.app = app + self.config = dict( + MAIL_DEFAULT_SENDER='', + SENDGRID_API_KEY='' + ) + self.set_config(app) + + def init_app(self, app): + self.set_config(app) + + def set_config(self, app=None): + if app: + self.config_from_app(app) + else: + self.config_from_env() + self.sg = sendgrid.SendGridClient(self.config.get('SENDGRID_API_KEY','')) + + def config_from_app(self, app): + for key in self.config: + if key in app.config: + self.config[key] = app.config[key] + self.app = app + + def config_from_env(self): + for key in self.config: + self.config[key] = os.environ.get(key, self.config[key]) + + def send(self, message): + if not message.from_email: + message.set_from(self.config['MAIL_DEFAULT_SENDER']) + + # send signal + email_dispatched.send(current_app._get_current_object(), message=message) + + if self.app and self.app.testing: + return + + status, reason = self.sg.send(message) + if status == 200: + return status, reason + else: + raise SendEmailError('{}: {}'.format(status, message)) + +signals = Namespace() +email_dispatched = signals.signal("email-dispatched", doc=""" +Signal sent when an email is dispatched. This signal will also be sent +in testing mode, even though the email will not actually be sent. +""") \ No newline at end of file diff --git a/typeseam/utils/translate.py b/typeseam/utils/translate.py new file mode 100644 index 0000000..b0d73d2 --- /dev/null +++ b/typeseam/utils/translate.py @@ -0,0 +1,43 @@ + +import sys +sys.path.append('./data') +import translator + +def translate_to_seamless(typeform, processors=None): + trans = translator.fields + answers = {} + answers.update({ + "_meta_date_submitted": typeform['metadata']['date_submit'] + }) + answers.update(typeform['answers']) + translated_answers = {} + + for target, config in trans.items(): + answer_key = config[0] + answer_keys = [] + raw_values = "" + raw_value = "" + if not isinstance(answer_key, str): + answer_keys = answer_key + if answer_keys: + raw_values = [answers[k] for k in answer_keys if k in answers] + else: + raw_value = answers.get(answer_key, "") + + final_value = "" + if len(config) == 2: + processor_keys = config[-1] + answer_processors = [processors.lookup[k] for k in processor_keys] + for answer_processor in answer_processors: + if raw_values: + final_value = answer_processor(target, *raw_values) + else: + final_value = answer_processor(target, raw_value) + elif "yesno" in answer_key: + yn_processor = processors.lookup["yesno"] + final_value = yn_processor(target, raw_value) + else: + final_value = raw_value + translated_answers[target] = final_value + + return translated_answers