├── README.md ├── runtime.txt ├── Procfile ├── powerapp ├── core │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── collect_services.py │ │ │ └── self_update.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_service_enabled_flag.py │ │ └── 0001_initial.py │ ├── views │ │ ├── __init__.py │ │ ├── web.py │ │ └── webhooks.py │ ├── templatetags │ │ ├── __init__.py │ │ ├── materializecss.py │ │ └── core.py │ ├── __init__.py │ ├── templates │ │ ├── 403.html │ │ ├── 404.html │ │ ├── 500.html │ │ ├── oauth2cb.html │ │ ├── materialize │ │ │ └── form.html │ │ ├── login.html │ │ ├── services.html │ │ ├── loggedin.html │ │ ├── base.html │ │ ├── dashboard.html │ │ ├── utils.mako │ │ └── edit_integration_base.html │ ├── static │ │ ├── default_logo.png │ │ ├── readme_webhooks.png │ │ ├── readme_app_settings.png │ │ ├── readme_heroku_config.png │ │ ├── readme_heroku_success.png │ │ └── readme_heroku_app_name.png │ ├── models │ │ ├── __init__.py │ │ ├── periodic_task.py │ │ ├── oauth.py │ │ ├── integration.py │ │ ├── service.py │ │ └── user.py │ ├── context_processors.py │ ├── redis_utils.py │ ├── django_auth_backend.py │ ├── tasks.py │ ├── django_fields.py │ ├── urls.py │ ├── exceptions.py │ ├── oauth_impl.py │ ├── attrdict.py │ ├── signals.py │ ├── statsd_middleware.py │ ├── service_collector.py │ ├── web_utils.py │ ├── django_widgets.py │ ├── cron.py │ ├── integration_utils.py │ ├── periodic_tasks.py │ ├── logging_utils.py │ ├── app_signals.py │ ├── todoist_utils.py │ ├── django_forms.py │ ├── apps.py │ └── generic_views.py ├── sync_bridge │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── __init__.py │ ├── todoist_sync_adapter.py │ └── models.py ├── contrib │ ├── github │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_create_model.py │ │ ├── __init__.py │ │ ├── static │ │ │ └── github │ │ │ │ └── logo.png │ │ ├── templates │ │ │ └── github │ │ │ │ ├── authorize_github.html │ │ │ │ └── edit_integration.html │ │ ├── models.py │ │ ├── apps.py │ │ ├── urls.py │ │ └── signals.py │ ├── evernote_sync │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0002_evernoteaccountcache_evernote_user_id.py │ │ │ ├── 0003_fill_evernote_user_id.py │ │ │ └── 0001_initial.py │ │ ├── static │ │ │ └── evernote_sync │ │ │ │ └── logo.png │ │ ├── __init__.py │ │ ├── templates │ │ │ └── evernote_sync │ │ │ │ ├── authorize_evernote.html │ │ │ │ ├── authorize_evernote_done.html │ │ │ │ └── edit_integration.html │ │ ├── tasks.py │ │ ├── apps.py │ │ ├── urls.py │ │ ├── models.py │ │ ├── signals.py │ │ ├── forms.py │ │ ├── views.py │ │ ├── utils.py │ │ └── sync_adapter.py │ ├── __init__.py │ ├── gcal_sync │ │ ├── __init__.py │ │ ├── static │ │ │ └── gcal_sync │ │ │ │ └── logo.png │ │ ├── templates │ │ │ └── gcal_sync │ │ │ │ ├── authorize_gcal.html │ │ │ │ └── edit_integration.html │ │ ├── apps.py │ │ ├── urls.py │ │ ├── oauth_impl.py │ │ ├── signals.py │ │ ├── tasks.py │ │ ├── views.py │ │ └── utils.py │ ├── catcomments │ │ ├── __init__.py │ │ ├── templates │ │ │ └── catcomments │ │ │ │ └── edit_integration.html │ │ ├── static │ │ │ └── catcomments │ │ │ │ └── logo.png │ │ ├── urls.py │ │ ├── views.py │ │ ├── apps.py │ │ └── signals.py │ └── hackernews │ │ ├── __init__.py │ │ ├── templates │ │ └── hackernews │ │ │ └── edit_integration.html │ │ ├── static │ │ └── hackernews │ │ │ └── logo.png │ │ ├── urls.py │ │ ├── apps.py │ │ ├── views.py │ │ └── signals.py ├── __init__.py ├── project_static │ ├── img │ │ ├── logo.gif │ │ └── todoist_256.png │ ├── font │ │ ├── roboto │ │ │ ├── Roboto-Bold.ttf │ │ │ ├── Roboto-Thin.ttf │ │ │ ├── Roboto-Bold.woff │ │ │ ├── Roboto-Bold.woff2 │ │ │ ├── Roboto-Light.ttf │ │ │ ├── Roboto-Light.woff │ │ │ ├── Roboto-Light.woff2 │ │ │ ├── Roboto-Medium.ttf │ │ │ ├── Roboto-Medium.woff │ │ │ ├── Roboto-Regular.ttf │ │ │ ├── Roboto-Thin.woff │ │ │ ├── Roboto-Thin.woff2 │ │ │ ├── Roboto-Medium.woff2 │ │ │ ├── Roboto-Regular.woff │ │ │ └── Roboto-Regular.woff2 │ │ └── material-design-icons │ │ │ ├── Material-Design-Icons.eot │ │ │ ├── Material-Design-Icons.ttf │ │ │ ├── Material-Design-Icons.woff │ │ │ └── Material-Design-Icons.woff2 │ ├── js_src │ │ ├── utils.js │ │ └── script.js │ └── less │ │ └── style.less ├── runner.py ├── celery_local.py ├── wsgi.py ├── discovery.py ├── urls.py └── devops_utils.py ├── setup.cfg ├── staticfiles └── .gitignore ├── tests ├── requirements.txt ├── test_services.py ├── test_app_discovery.py ├── integration │ ├── test_webhooks.py │ ├── README.rst │ └── conftest.py ├── conftest.py └── test_sync_bridge.py ├── pytest.ini ├── .gitignore ├── bin ├── post_compile └── pre_compile ├── requirements.txt ├── MANIFEST.in ├── manage.py ├── uwsgi.ini ├── app.json ├── setup.py ├── gulpfile.js ├── env.sample └── README.rst /README.md: -------------------------------------------------------------------------------- 1 | # webhook_test 2 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.4.3 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: uwsgi uwsgi.ini 2 | -------------------------------------------------------------------------------- /powerapp/core/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /powerapp/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /powerapp/sync_bridge/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /powerapp/contrib/github/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /powerapp/core/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /staticfiles/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /powerapp/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /powerapp/core/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /powerapp/sync_bridge/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /powerapp/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery_local import app as celery_app 2 | -------------------------------------------------------------------------------- /powerapp/core/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /powerapp/core/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'powerapp.core.apps.AppConfig' 2 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | mock 2 | pytest 3 | pytest-django 4 | pytest-xprocess 5 | -------------------------------------------------------------------------------- /powerapp/contrib/github/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'powerapp.contrib.github.apps.AppConfig' -------------------------------------------------------------------------------- /powerapp/core/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block body %}403{% endblock %} 3 | -------------------------------------------------------------------------------- /powerapp/core/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block body %}404{% endblock %} 3 | -------------------------------------------------------------------------------- /powerapp/core/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block body %}500{% endblock %} 3 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'powerapp.contrib.gcal_sync.apps.AppConfig' 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = powerapp.settings 3 | addopts = --tb=short --reuse-db 4 | -------------------------------------------------------------------------------- /powerapp/contrib/catcomments/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'powerapp.contrib.catcomments.apps.AppConfig' 2 | -------------------------------------------------------------------------------- /powerapp/contrib/hackernews/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'powerapp.contrib.hackernews.apps.AppConfig' 2 | -------------------------------------------------------------------------------- /powerapp/contrib/hackernews/templates/hackernews/edit_integration.html: -------------------------------------------------------------------------------- 1 | {% extends "edit_integration_base.html" %} 2 | -------------------------------------------------------------------------------- /powerapp/contrib/catcomments/templates/catcomments/edit_integration.html: -------------------------------------------------------------------------------- 1 | {% extends "edit_integration_base.html" %} 2 | -------------------------------------------------------------------------------- /powerapp/project_static/img/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/img/logo.gif -------------------------------------------------------------------------------- /powerapp/core/static/default_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/core/static/default_logo.png -------------------------------------------------------------------------------- /powerapp/core/static/readme_webhooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/core/static/readme_webhooks.png -------------------------------------------------------------------------------- /powerapp/core/static/readme_app_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/core/static/readme_app_settings.png -------------------------------------------------------------------------------- /powerapp/project_static/img/todoist_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/img/todoist_256.png -------------------------------------------------------------------------------- /powerapp/contrib/github/static/github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/contrib/github/static/github/logo.png -------------------------------------------------------------------------------- /powerapp/core/static/readme_heroku_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/core/static/readme_heroku_config.png -------------------------------------------------------------------------------- /powerapp/core/static/readme_heroku_success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/core/static/readme_heroku_success.png -------------------------------------------------------------------------------- /powerapp/core/static/readme_heroku_app_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/core/static/readme_heroku_app_name.png -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Bold.ttf -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Thin.ttf -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/static/gcal_sync/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/contrib/gcal_sync/static/gcal_sync/logo.png -------------------------------------------------------------------------------- /powerapp/contrib/hackernews/static/hackernews/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/contrib/hackernews/static/hackernews/logo.png -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Bold.woff -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Bold.woff2 -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Light.woff -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Light.woff2 -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Medium.woff -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Thin.woff -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Thin.woff2 -------------------------------------------------------------------------------- /powerapp/contrib/catcomments/static/catcomments/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/contrib/catcomments/static/catcomments/logo.png -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Medium.woff2 -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Regular.woff -------------------------------------------------------------------------------- /powerapp/project_static/font/roboto/Roboto-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/roboto/Roboto-Regular.woff2 -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/static/evernote_sync/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/contrib/evernote_sync/static/evernote_sync/logo.png -------------------------------------------------------------------------------- /powerapp/runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def configure_app(settings_module='powerapp.settings'): 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tests/fixtures 2 | /dist 3 | /build 4 | /.idea 5 | /.cache 6 | /.xprocess 7 | /node_modules 8 | .DS_Store 9 | .env 10 | *.pyc 11 | *.swp 12 | *.egg-info 13 | -------------------------------------------------------------------------------- /bin/post_compile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Run ./manage.py migrate" 3 | python ./manage.py migrate 4 | echo "Run ./manage.py collect_services" 5 | python ./manage.py collect_services 6 | -------------------------------------------------------------------------------- /powerapp/project_static/font/material-design-icons/Material-Design-Icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/material-design-icons/Material-Design-Icons.eot -------------------------------------------------------------------------------- /powerapp/project_static/font/material-design-icons/Material-Design-Icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/material-design-icons/Material-Design-Icons.ttf -------------------------------------------------------------------------------- /powerapp/project_static/font/material-design-icons/Material-Design-Icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/material-design-icons/Material-Design-Icons.woff -------------------------------------------------------------------------------- /powerapp/project_static/font/material-design-icons/Material-Design-Icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Doist/powerapp/HEAD/powerapp/project_static/font/material-design-icons/Material-Design-Icons.woff2 -------------------------------------------------------------------------------- /bin/pre_compile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os, sys 3 | sys.path.insert(0, os.getcwd()) 4 | 5 | 6 | from powerapp import devops_utils 7 | devops_utils.extend_requirements_txt() 8 | open('.env', 'a') 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Py3 version of Evernote SDK has to be installed from github 2 | -e git+https://github.com/Doist/evernote-sdk-python3.git#egg=evernote-dev 3 | # Heroku requirements file 4 | uWSGI 5 | psycopg2 6 | -e . 7 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/__init__.py: -------------------------------------------------------------------------------- 1 | # Your application config. It's important for the AppConfig 2 | # to subclass powerapp.core.apps.ServiceAppConfig 3 | default_app_config = 'powerapp.contrib.evernote_sync.apps.AppConfig' 4 | -------------------------------------------------------------------------------- /powerapp/contrib/github/templates/github/authorize_github.html: -------------------------------------------------------------------------------- 1 | {% extends "loggedin.html" %} 2 | 3 | {% block content %} 4 | Please authorize yourself in Github 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /powerapp/core/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .integration import Integration 3 | from .service import Service 4 | from .periodic_task import PeriodicTask 5 | from .user import User 6 | from .oauth import OAuthToken 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include docs * 3 | recursive-exclude docs *.pyc 4 | recursive-exclude docs *.pyo 5 | recursive-include powerapp/core/templates * 6 | recursive-include powerapp/core/static * 7 | prune docs/_build 8 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/templates/evernote_sync/authorize_evernote.html: -------------------------------------------------------------------------------- 1 | {% extends "loggedin.html" %} 2 | 3 | {% block content %} 4 | Please authorize yourself in Evernote 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/templates/gcal_sync/authorize_gcal.html: -------------------------------------------------------------------------------- 1 | {% extends "loggedin.html" %} 2 | 3 | {% block content %} 4 | Please authorize yourself in Google Calendar 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "powerapp.settings") 7 | from django.core.management import execute_from_command_line 8 | execute_from_command_line(sys.argv) 9 | 10 | 11 | -------------------------------------------------------------------------------- /powerapp/celery_local.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import runner; runner.configure_app() 3 | 4 | from celery import Celery 5 | from django.conf import settings 6 | 7 | app = Celery('powerapp') 8 | app.config_from_object('django.conf:settings') 9 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 10 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/templates/evernote_sync/authorize_evernote_done.html: -------------------------------------------------------------------------------- 1 | {% extends "loggedin.html" %} 2 | 3 | {% block content %} 4 | Unable to authorize Evernote. {{ error }} 5 |

6 | Try Again 7 |

8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /powerapp/core/templates/oauth2cb.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 |
6 |
Houston, we have a problem
7 |

{{ error }}

8 |
9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /powerapp/core/context_processors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | 4 | 5 | def settings_values(request): 6 | """ 7 | The context processor which populates template variables with some 8 | values from settings 9 | """ 10 | return {'google_site_verification': settings.GOOGLE_SITE_VERIFICATION} 11 | -------------------------------------------------------------------------------- /powerapp/project_static/js_src/utils.js: -------------------------------------------------------------------------------- 1 | var entityMap = { 2 | "&": "&", 3 | "<": "<", 4 | ">": ">", 5 | '"': '"', 6 | "'": ''', 7 | "/": '/' 8 | }; 9 | 10 | function escapeHTML(string) { 11 | return String(string).replace(/[&<>"'\/]/g, function (s) { 12 | return entityMap[s]; 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /powerapp/contrib/hackernews/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import url 3 | from .views import EditIntegrationView 4 | 5 | urlpatterns = [ 6 | url(r'^integrations/add/$', EditIntegrationView.as_view(), name='add_integration'), 7 | url(r'^integrations/(?P\d+)/$', EditIntegrationView.as_view(), name='edit_integration'), 8 | ] 9 | -------------------------------------------------------------------------------- /powerapp/project_static/js_src/script.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $(".powerapp-usermenu").dropdown(); 3 | $('select').not('.disabled').material_select(); 4 | $("#logout-link").click(function() {$("#logout-form").submit(); return false}); 5 | $.each(django_messages || [], function(i, obj) { 6 | Materialize.toast(escapeHTML(obj.message), 4000); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /powerapp/contrib/catcomments/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import url 3 | from .views import EditIntegrationView 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^integrations/add/$', EditIntegrationView.as_view(), name='add_integration'), 8 | url(r'^integrations/(?P\d+)/$', EditIntegrationView.as_view(), name='edit_integration'), 9 | ] 10 | -------------------------------------------------------------------------------- /powerapp/core/management/commands/collect_services.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.core.management.base import NoArgsCommand 3 | 4 | from powerapp.core import service_collector, periodic_tasks 5 | 6 | 7 | class Command(NoArgsCommand): 8 | 9 | def handle_noargs(self, **options): 10 | service_collector.collect_services() 11 | periodic_tasks.sync() 12 | -------------------------------------------------------------------------------- /tests/test_services.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | def test_catcomments_has_icon(catcomments_service): 3 | assert catcomments_service.logo_filename == 'catcomments/logo.png' 4 | assert catcomments_service.logo_url == '/static/catcomments/logo.png' 5 | 6 | def test_catcomments_urls(catcomments_service): 7 | assert catcomments_service.urls.add_integration == '/catcomments/integrations/add/' 8 | -------------------------------------------------------------------------------- /tests/test_app_discovery.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from powerapp.discovery import app_discovery 3 | 4 | 5 | def test_app_discovery_finds_catcomments(): 6 | assert 'powerapp.contrib.catcomments' in app_discovery() 7 | 8 | 9 | def test_reverse_url_knows_about_catcomments(): 10 | assert reverse('catcomments:add_integration') == '/catcomments/integrations/add/' 11 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | exec-asap = ./manage.py self_update 3 | http-socket = :$(PORT) 4 | master = true 5 | processes = 4 6 | die-on-term = true 7 | module = powerapp.wsgi:application 8 | env = DJANGO_SETTINGS_MODULE=powerapp.settings 9 | smart-attach-daemon = /var/run/celery.pid celery worker --app=powerapp.celery -l info --pidfile=/var/run/celery.pid 10 | lazy-apps = true 11 | static-map = /static=./staticfiles/ 12 | -------------------------------------------------------------------------------- /powerapp/core/redis_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities to work with Redis. Currently consists just of a Redis pool (one per 3 | thread) and a function returning the Redis client using this pool 4 | """ 5 | import redis 6 | from django.conf import settings 7 | from redis.lock import LuaLock 8 | 9 | pool = redis.ConnectionPool.from_url(settings.REDIS_URL) 10 | 11 | 12 | def get_redis(): 13 | return redis.StrictRedis(connection_pool=pool) 14 | -------------------------------------------------------------------------------- /powerapp/contrib/catcomments/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from powerapp.core import django_forms, django_fields, generic_views 3 | 4 | 5 | class IntegrationForm(django_forms.IntegrationForm): 6 | service_label = 'catcomments' 7 | project = django_fields.ProjectChoiceField(label=u'Project to add kittens to') 8 | 9 | 10 | class EditIntegrationView(generic_views.EditIntegrationView): 11 | service_label = 'catcomments' 12 | form = IntegrationForm 13 | -------------------------------------------------------------------------------- /powerapp/contrib/hackernews/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Pools the hackernews feed and add tasks with links to new posts to your Todoist 4 | project. 5 | """ 6 | from powerapp.core.apps import ServiceAppConfig 7 | 8 | 9 | class AppConfig(ServiceAppConfig): 10 | name = 'powerapp.contrib.hackernews' 11 | verbose_name = 'Hacker News Reader' 12 | url = 'http://news.ycombinator.com' 13 | description = __doc__ 14 | models_module = None 15 | -------------------------------------------------------------------------------- /powerapp/core/django_auth_backend.py: -------------------------------------------------------------------------------- 1 | from powerapp.core.models import User 2 | 3 | 4 | class TodoistUserAuth(object): 5 | 6 | def authenticate(self, user): 7 | if isinstance(user, User) and user.api_token: 8 | return user 9 | else: 10 | return None 11 | 12 | def get_user(self, uid): 13 | try: 14 | return User.objects.get(id=uid) 15 | except User.DoesNotExist: 16 | return None 17 | -------------------------------------------------------------------------------- /powerapp/core/templates/materialize/form.html: -------------------------------------------------------------------------------- 1 | {% csrf_token %} 2 | {% for bound_field in form %} 3 |
4 |
5 | {{ bound_field }} 6 | {{ bound_field.label_tag }} 7 | {% if bound_field.help_text %}{{ bound_field.help_text }}{% endif %} 8 | {% if bound_field.errors %}
{{ bound_field.errors }}
{% endif %} 9 |
10 |
11 | {% endfor %} 12 | -------------------------------------------------------------------------------- /powerapp/core/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 |
6 |

Welcome to Todoist PowerApp

7 | 8 |
9 | 10 | 12 | Login to get started 13 |
14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /powerapp/core/templatetags/materializecss.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Template tags to render forms with Materialize CSS: http://materializecss.com/forms.html 4 | """ 5 | from django import template 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.inclusion_tag('materialize/form.html') 11 | def materialize_form(form): 12 | """ 13 | Take the form object and return its "materialized representation" 14 | """ 15 | return {'form': form} 16 | -------------------------------------------------------------------------------- /powerapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for powerapp project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "powerapp.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /powerapp/contrib/catcomments/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | An extremely useful service to boost your morale. It adds notes with kittens 4 | to every task you create. Works only for premium accounts. 5 | """ 6 | from powerapp.core.apps import ServiceAppConfig 7 | 8 | 9 | class AppConfig(ServiceAppConfig): 10 | name = 'powerapp.contrib.catcomments' 11 | verbose_name = 'Cat comments' 12 | url = 'http://thecatapi.com/' 13 | description = __doc__ 14 | models_module = None 15 | -------------------------------------------------------------------------------- /powerapp/contrib/github/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import models 3 | 4 | 5 | class GithubDataMap(models.Model): 6 | id = models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True) 7 | integration = models.ForeignKey(to='core.Integration') 8 | github_data_id = models.IntegerField(db_index=True, auto_created=False) 9 | github_data_type = models.TextField(auto_created=False) 10 | github_data_url = models.TextField(auto_created=False) 11 | todoist_task_id = models.IntegerField(db_index=True, auto_created=False) 12 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Synchronizes your Todoist tasks with your Google Calendar 4 | """ 5 | import environ 6 | from powerapp.core.apps import ServiceAppConfig 7 | 8 | 9 | env = environ.Env() 10 | 11 | 12 | class AppConfig(ServiceAppConfig): 13 | name = 'powerapp.contrib.gcal_sync' 14 | verbose_name = 'Google Calendar Sync' 15 | url = 'https://calendar.google.com' 16 | description = __doc__ 17 | 18 | GOOGLE_CLIENT_ID = env('GOOGLE_CLIENT_ID', default=None) 19 | GOOGLE_CLIENT_SECRET = env('GOOGLE_CLIENT_SECRET', default=None) 20 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/migrations/0002_evernoteaccountcache_evernote_user_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('evernote_sync', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='evernoteaccountcache', 16 | name='evernote_user_id', 17 | field=models.PositiveIntegerField(null=True, db_index=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /powerapp/contrib/github/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Synchronizes your Github issues and pull requests with Todoist 4 | """ 5 | import environ 6 | from powerapp.core.apps import ServiceAppConfig 7 | 8 | env = environ.Env() 9 | 10 | class AppConfig(ServiceAppConfig): 11 | name = 'powerapp.contrib.github' 12 | verbose_name = 'Github Integration' 13 | url = 'http://todoist.com' 14 | description = __doc__ 15 | models_module = None 16 | 17 | GITHUB_CLIENT_ID = env('GITHUB_CLIENT_ID', default=None) 18 | GITHUB_CLIENT_SECRET = env('GITHUB_CLIENT_SECRET', default=None) 19 | -------------------------------------------------------------------------------- /powerapp/contrib/github/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import url 3 | from . import views 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^integrations/add/$', views.EditIntegrationView.as_view(), name='add_integration'), 8 | url(r'^integrations/(?P\d+)/$', views.EditIntegrationView.as_view(), name='edit_integration'), 9 | url(r'^authorize_github/$', views.authorize_github, name='authorize_github'), 10 | url(r'^authorize_github/done/$', views.authorize_github_done, name='authorize_github_done'), 11 | url(r'^webhook/(?P\d+)/$', views.webhook, name='webhook'), 12 | ] 13 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/templates/gcal_sync/edit_integration.html: -------------------------------------------------------------------------------- 1 | {% extends "edit_integration_base.html" %} 2 | 3 | {% block extra_actions %} 4 | {% if form.integration %} 5 |
{% csrf_token %}
6 | sync now 7 | {% endif %} 8 | {% endblock %} 9 | 10 | {% block extra_js %} 11 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /powerapp/core/management/commands/self_update.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | A management command which is mostly useful on Heroku installations. 4 | It checks environment variables and its current state and update 5 | itself accordingly. 6 | 7 | Duplicate the work of pre-compile and post-compile hooks mostly 8 | """ 9 | from django.core.management.base import NoArgsCommand 10 | from powerapp.devops_utils import install_requirements, run_bootstrap_management_commands 11 | 12 | 13 | class Command(NoArgsCommand): 14 | 15 | def handle_noargs(self, **options): 16 | install_requirements() 17 | run_bootstrap_management_commands() 18 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import url 3 | from .views import EditIntegrationView, authorize_gcal, sync_now, accept_webhook 4 | 5 | urlpatterns = [ 6 | url(r'^integrations/add/$', EditIntegrationView.as_view(), name='add_integration'), 7 | url(r'^integrations/(?P\d+)/$', EditIntegrationView.as_view(), name='edit_integration'), 8 | url(r'^authorize_gcal/', authorize_gcal, name='authorize_gcal'), 9 | url(r'^sync_now/(?P\d+)/$', sync_now, name='sync_now'), 10 | url(r'^webhooks/accept/(?P\d+)/$', accept_webhook, name='accept_webhook'), 11 | ] 12 | -------------------------------------------------------------------------------- /powerapp/core/templatetags/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import collections 4 | from django import template 5 | from django.contrib.messages.storage.base import Message 6 | from django.utils.safestring import mark_safe 7 | 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.filter('to_json') 13 | def to_json(value): 14 | return mark_safe(json.dumps(value, separators=',:', default=json_default)) 15 | 16 | 17 | def json_default(value): 18 | if isinstance(value, collections.Iterable): 19 | return list(value) 20 | if isinstance(value, Message): 21 | return value.__dict__ 22 | raise TypeError('%r is not JSON serializable' % value) 23 | -------------------------------------------------------------------------------- /tests/integration/test_webhooks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def test_webhook(api, catcomments_integration, ngrok): 5 | # add a new item to the inbox 6 | item = api.items.add('test', api.user.get('inbox_project')) 7 | api.commit() 8 | # wait for the update 9 | # 1. api.commit() creates a new task 10 | # 2. Todoist sends a webhook back to the application 11 | # 3. application runs the catcomment integratoin 12 | # 4. the integration adds a new comment to the post, 13 | # and the client can see the update in seq_no 14 | api.wait_for_update(resource_types=['notes']) 15 | assert len(api.notes.all()) == 1 16 | note = api.notes.all()[0] 17 | assert 'http://thecatapi.com/' in note['content'] 18 | -------------------------------------------------------------------------------- /powerapp/core/tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from powerapp.celery_local import app 3 | from powerapp.core.models.integration import Integration 4 | from powerapp.core import sync 5 | from powerapp.core.logging_utils import ctx 6 | 7 | 8 | @app.task(ignore_result=True) 9 | def initial_stateless_sync(integration_id): 10 | """ 11 | The sync command which is performed for "stateless integrations" 12 | """ 13 | try: 14 | integration = Integration.objects.get(pk=integration_id) 15 | except Integration.DoesNotExist: 16 | return 17 | api = sync.StatefulTodoistAPI.create(integration) 18 | with ctx(user=integration.user, integration=integration): 19 | api.sync(resource_types=['projects', 'items', 'notes'], 20 | save_state=False) 21 | -------------------------------------------------------------------------------- /powerapp/core/django_fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.forms.fields import TypedChoiceField 3 | from django.utils.encoding import force_text 4 | from powerapp.core.django_widgets import ProjectsSelect 5 | 6 | 7 | class ProjectChoiceField(TypedChoiceField): 8 | 9 | widget = ProjectsSelect 10 | 11 | def __init__(self, *args, **kwargs): 12 | kwargs['coerce'] = int 13 | super(ProjectChoiceField, self).__init__(*args, **kwargs) 14 | 15 | def valid_value(self, value): 16 | text_value = force_text(value) 17 | for project in self.choices: 18 | if text_value == force_text(project['id']): 19 | return True 20 | return False 21 | 22 | def populate_with_user(self, user): 23 | self.choices = user.api.projects.all() 24 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/oauth_impl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from powerapp.core.oauth import register_oauth_client 3 | 4 | 5 | OAUTH_CLIENT_NAME = 'gcal' 6 | GCAL_SCOPE = 'https://www.googleapis.com/auth/calendar' 7 | 8 | 9 | @register_oauth_client(OAUTH_CLIENT_NAME, 10 | authorize_endpoint='https://accounts.google.com/o/oauth2/auth', 11 | access_token_endpoint='https://www.googleapis.com/oauth2/v3/token', 12 | scope='https://www.googleapis.com/auth/calendar', 13 | client_id='GOOGLE_CLIENT_ID', 14 | client_secret='GOOGLE_CLIENT_SECRET', 15 | oauth2cb_redirect_uri='gcal_sync:add_integration') 16 | def gcal_oauth(client, token, request): 17 | client.save_token(request.user, token) 18 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/tasks.py: -------------------------------------------------------------------------------- 1 | from . import utils 2 | from powerapp.celery_local import app 3 | from powerapp.core.models import Integration 4 | from powerapp.core.logging_utils import ctx 5 | from celery.exceptions import SoftTimeLimitExceeded 6 | from logging import getLogger 7 | 8 | 9 | logger = getLogger(__name__) 10 | 11 | 12 | @app.task(ignore_result=True) 13 | def sync_evernote(integration_id): 14 | try: 15 | integration = Integration.objects.select_related('user').get(id=integration_id) 16 | except Integration.DoesNotExist: 17 | return 18 | with ctx(user=integration.user, integration=integration): 19 | try: 20 | utils.sync_evernote(integration) 21 | except SoftTimeLimitExceeded: 22 | logger.error('Synchronization with Evernote took too long and was aborted') 23 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Synchronizes your Todoist tasks with your Evernote reminders 4 | """ 5 | import environ 6 | from powerapp.core.apps import ServiceAppConfig 7 | 8 | 9 | env = environ.Env() 10 | 11 | 12 | class AppConfig(ServiceAppConfig): 13 | name = 'powerapp.contrib.evernote_sync' 14 | verbose_name = 'Evernote Sync' 15 | url = 'https://evernote.com' 16 | description = __doc__ 17 | 18 | EVERNOTE_CONSUMER_KEY = env('EVERNOTE_CONSUMER_KEY', default=None) 19 | EVERNOTE_CONSUMER_SECRET = env('EVERNOTE_CONSUMER_SECRET', default=None) 20 | EVERNOTE_SANDBOX = env.bool('EVERNOTE_SANDBOX', default=True) 21 | EVERNOTE_USE_POLLING = env.bool('EVERNOTE_USE_POLLING', default=False) 22 | EVERNOTE_WEBHOOK_SECRET_KEY = env('EVERNOTE_WEBHOOK_SECRET_KEY', default=None) 23 | -------------------------------------------------------------------------------- /powerapp/contrib/hackernews/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django import forms 3 | from django.utils.safestring import mark_safe 4 | from powerapp.core import generic_views 5 | from powerapp.core.django_forms import IntegrationForm 6 | 7 | 8 | class HackerNewsForm(IntegrationForm): 9 | service_label = 'hackernews' 10 | feed_url = forms.CharField(label=u'RSS feed URL', required=True, 11 | initial='https://news.ycombinator.com/rss', 12 | help_text=mark_safe(u'Check out hnrss project ' 13 | u'for more powerful RSS filtration')) 14 | 15 | 16 | class EditIntegrationView(generic_views.EditIntegrationView): 17 | form = HackerNewsForm 18 | service_label = 'hackernews' 19 | -------------------------------------------------------------------------------- /powerapp/discovery.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import importlib 4 | import pkg_resources 5 | 6 | 7 | def app_discovery(): 8 | """ 9 | Find integrations (as strings) to populate INSTALLED_APPS 10 | """ 11 | ret = [] 12 | 13 | # search in contrib 14 | contrib_root = 'powerapp.contrib' 15 | contrib = importlib.import_module(contrib_root) 16 | dirname = os.path.dirname(os.path.abspath(contrib.__file__)) 17 | for subdir in os.listdir(dirname): 18 | if subdir == '__pycache__': 19 | continue 20 | if os.path.isdir(os.path.join(dirname, subdir)): 21 | ret.append('%s.%s' % (contrib_root, subdir)) 22 | 23 | # search in external services 24 | for ep in pkg_resources.iter_entry_points('powerapp_services'): 25 | ret.append(ep.module_name) 26 | 27 | return ret 28 | -------------------------------------------------------------------------------- /powerapp/core/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import url 3 | from powerapp.core.views import web, webhooks 4 | 5 | 6 | urlpatterns = [ 7 | # web 8 | url(r'^$', web.dashboard, name='web_index'), 9 | url(r'^services/$', web.services, name='web_services'), 10 | url(r'^login/$', web.login, name='web_login'), 11 | url(r'^logout/$', web.logout, name='web_logout'), 12 | url(r'^oauth2cb/$', web.oauth2cb, name='web_oauth2cb'), 13 | url(r'delete_integration/(?P[\w\-]+)/(?P\d+)/$', 14 | web.delete_integration, name='web_delete_integration'), 15 | url(r'edit_integration/(?P[\w\-]+)/(?P\d+)/$', 16 | web.edit_integration, name='web_edit_integration'), 17 | 18 | # webhooks 19 | url(r'^webhooks/accept/$', webhooks.accept, name='webhooks_accept'), 20 | ] 21 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/migrations/0003_fill_evernote_user_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, transaction 5 | 6 | 7 | @transaction.atomic() 8 | def fill_evernote_user_id(apps, schema_editor): 9 | EAC = apps.get_model("evernote_sync", "EvernoteAccountCache") 10 | for cache in EAC.objects.all(): 11 | try: 12 | cache.evernote_user_id = cache.user_data.id 13 | except AttributeError: 14 | continue 15 | cache.save(update_fields=['evernote_user_id']) 16 | 17 | 18 | def nop(apps, schema_editor): 19 | pass 20 | 21 | 22 | class Migration(migrations.Migration): 23 | 24 | dependencies = [ 25 | ('evernote_sync', '0002_evernoteaccountcache_evernote_user_id'), 26 | ] 27 | 28 | operations = [ 29 | migrations.RunPython(fill_evernote_user_id, nop) 30 | ] 31 | -------------------------------------------------------------------------------- /powerapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf.urls import include, url 3 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 4 | 5 | 6 | def app_urls(): 7 | """ 8 | Automatically add URLs from applications which want to register their URLs 9 | 10 | In order to do so, the application has to have a urlpatterns() method, 11 | returning the list of url objects for inclusion. 12 | 13 | By default though (see `powerapp.core.apps:ServiceAppConfig.urlpatterns`) 14 | every service app just returns its own urls.py for inclusion 15 | """ 16 | ret = [] 17 | for app in apps.get_app_configs(): 18 | if hasattr(app, 'urlpatterns'): 19 | ret += app.urlpatterns() 20 | return ret 21 | 22 | 23 | urlpatterns = [ 24 | url(r'', include('powerapp.core.urls')) 25 | ] 26 | urlpatterns += list(staticfiles_urlpatterns()) 27 | urlpatterns += app_urls() 28 | -------------------------------------------------------------------------------- /powerapp/core/templates/services.html: -------------------------------------------------------------------------------- 1 | {% extends "loggedin.html" %} 2 | 3 | {% block content %} 4 | {% for service in services %} 5 |
6 |
7 | {{ service.verbose_name }} 9 |
10 | 11 |
12 |

{{ service.app_config.verbose_name }}

13 |

{{ service.app_config.url }}

14 |

{{ service.app_config.description }}

15 |
16 | 17 | Add 18 |
19 |
20 | {% endfor %} 21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /powerapp/core/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def return_or_raise(result): 9 | """ 10 | Take the result of sync operation and pass it through if everything is 11 | okay, or raise PowerAppError, if there is an error 12 | """ 13 | if result is None: 14 | return result 15 | 16 | if not isinstance(result, dict): 17 | raise PowerAppError(result) 18 | 19 | if 'error' in result: 20 | err = result['error'] 21 | logger.error(err) 22 | raise PowerAppError(err) 23 | return result 24 | 25 | 26 | class PowerAppError(Exception): 27 | """ 28 | Base class for all "managed exceptions" we raise 29 | """ 30 | pass 31 | 32 | 33 | class PowerAppInvalidTokenError(PowerAppError): 34 | """ 35 | The exception is raised when we found that the OAuth token is invalid or 36 | non-existent 37 | """ 38 | pass 39 | -------------------------------------------------------------------------------- /powerapp/core/migrations/0002_service_enabled_flag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('core', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='integration', 16 | name='service_enabled', 17 | field=models.BooleanField(editable=False, default=True), 18 | ), 19 | migrations.AddField( 20 | model_name='service', 21 | name='enabled', 22 | field=models.BooleanField(verbose_name='Service enabled', default=True), 23 | ), 24 | migrations.AlterIndexTogether( 25 | name='integration', 26 | index_together=set([('service_enabled', 'user', 'service'), ('service_enabled', 'stateless', 'api_next_sync')]), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import url 3 | from .views import EditIntegrationView, authorize_evernote, authorize_evernote_done, sync_now, accept_webhook 4 | 5 | # URL patterns have to contain at least two records: one to add integration, 6 | # and another one to edit it, as provided below. You are welcome to add more 7 | # endpoints if you need it. 8 | 9 | urlpatterns = [ 10 | url(r'^integrations/add/$', EditIntegrationView.as_view(), name='add_integration'), 11 | url(r'^integrations/(?P\d+)/$', EditIntegrationView.as_view(), name='edit_integration'), 12 | url(r'^authorize_evernote/', authorize_evernote, name='authorize_evernote'), 13 | url(r'^authorize_evernote_done/', authorize_evernote_done, name='authorize_evernote_done'), 14 | url(r'^sync_now/(?P\d+)/$', sync_now, name='sync_now'), 15 | url(r'^webhooks/accept/(?P\w+)/$', accept_webhook, name='accept_webhook') 16 | ] 17 | -------------------------------------------------------------------------------- /powerapp/core/oauth_impl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib.auth import authenticate, login 3 | from django.http import Http404 4 | from django.conf import settings 5 | 6 | from powerapp.core.oauth import register_oauth_client 7 | from powerapp.core.models.user import User 8 | from powerapp.core.exceptions import PowerAppError 9 | 10 | 11 | @register_oauth_client('todoist', 12 | authorize_endpoint='%s/oauth/authorize' % settings.API_ENDPOINT, 13 | access_token_endpoint='%s/oauth/access_token' % settings.API_ENDPOINT, 14 | scope='data:read_write,data:delete,project:delete', 15 | redirect_uri_required=False) 16 | def todoist_oauth(client, token, request): 17 | try: 18 | user = User.objects.register(token['access_token']) 19 | except PowerAppError: 20 | raise Http404() 21 | client.save_token(user, token) 22 | check_user = authenticate(user=user) 23 | login(request, check_user) 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from mock import patch 4 | import todoist 5 | from powerapp.core.models import Service, User, Integration 6 | from powerapp.core.service_collector import collect_services 7 | 8 | 9 | @pytest.fixture 10 | def services(db): 11 | collect_services() 12 | 13 | 14 | @pytest.fixture 15 | def catcomments_service(services): 16 | return Service.objects.get(label='catcomments') 17 | 18 | 19 | @pytest.yield_fixture 20 | def quiet_sync(): 21 | with patch.object(todoist.TodoistAPI, 'sync') as sync: 22 | sync.return_value = {} 23 | yield sync 24 | 25 | 26 | @pytest.fixture 27 | def detached_user(db, quiet_sync): 28 | return User.objects.create(id=1, email='detached@example.com', api_token='x') 29 | 30 | 31 | @pytest.fixture 32 | def detached_integration(detached_user, catcomments_service): 33 | return Integration.objects.create(name='catcomments', 34 | service=catcomments_service, 35 | user=detached_user) 36 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PowerApp", 3 | "description": "A tool to extend the functionality of your Todoist account by integrating it with third-party applications", 4 | "keywords": ["todoist", "productivity"], 5 | "env": { 6 | "SECRET_KEY": { 7 | "description": "A secret key for verifying the integrity of signed cookies.", 8 | "generator": "secret" 9 | }, 10 | "SITE_URL": { 11 | "description": "Base URL of your websit" 12 | }, 13 | "TODOIST_CLIENT_ID": { 14 | "description": "Create your client at https://developer.todoist.com/appconsole.html and copy your personal client id from there" 15 | }, 16 | "TODOIST_CLIENT_SECRET": { 17 | "description": "Create your client at https://developer.todoist.com/appconsole.html and copy your personal client secret from there" 18 | }, 19 | "POWERAPP_SERVICES": { 20 | "description": "Optional space-separated list of PowerApp services (packages from PyPI or git repos)", 21 | "required": false 22 | } 23 | }, 24 | "addons": [ 25 | "heroku-postgresql", 26 | "heroku-redis" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /powerapp/contrib/github/migrations/0001_create_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('core', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='GithubDataMap', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), 19 | ('integration', models.ForeignKey(to='core.Integration')), 20 | ('github_data_id', models.IntegerField(db_index=True, auto_created=False)), 21 | ('github_data_type', models.TextField(auto_created=False)), 22 | ('github_data_url', models.TextField(auto_created=False)), 23 | ('todoist_task_id', models.IntegerField(db_index=True, auto_created=False)) 24 | ], 25 | ) 26 | ] 27 | -------------------------------------------------------------------------------- /powerapp/contrib/github/templates/github/edit_integration.html: -------------------------------------------------------------------------------- 1 | {% extends "edit_integration_base.html" %} 2 | 3 | 4 | 5 | {% block before_form %} 6 | 7 | 8 | 9 |

Github Integration Setup

10 |

11 | To complete the Github integration setup, you need to configure 12 | your github repository webhook setting. 13 |

14 | 15 |
    16 |
  1. 17 | In your Github repository setting, go to the "Webhooks & Services" section, and create a new webhook. 18 |
  2. 19 |
  3. 20 | Fill in the "Payload URL" field with the following URL: 21 |
    22 | {{ extra_context.webhook_url }} 23 |
  4. 24 |
  5. 25 | Fill in the "Secret" field with the same secret string configured below. 26 |
  6. 27 |
  7. 28 | For the webhook event setting, select "Issues" and "Pull Request" options (or choose one that you which 29 | would like to synchronize) and saving the setting to complete the setup. 30 |
  8. 31 |
32 | 33 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /powerapp/core/templates/loggedin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load staticfiles %} 3 | 4 | {% block usermenu %} 5 | 6 |
{% csrf_token %}
7 | 8 | 12 | {% endblock %} 13 | 14 | {% block main %} 15 |
16 | 17 |
18 | 22 |
23 | 24 |
25 | {% block content %}{% endblock %} 26 |
27 | 28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /powerapp/core/attrdict.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from pprint import pformat 3 | 4 | 5 | class AttrDict(dict): 6 | """ 7 | Dict allowing to get access to its keys as attributes 8 | """ 9 | 10 | def __getattr__(self, name): 11 | if name in self: 12 | return self[name] 13 | raise AttributeError('%s not found' % name) 14 | 15 | def __setattr__(self, name, value): 16 | self[name] = value 17 | 18 | def __repr__(self): 19 | formatted_dict = pformat(dict(self)) 20 | classname = self.__class__.__name__ 21 | return '%s(%s)' % (classname, formatted_dict) 22 | 23 | @property 24 | def __members__(self): 25 | return self.keys() 26 | 27 | 28 | def recursive_attrdict(obj): 29 | """ 30 | Recursively find all dicts in a stricture, and convert them to attr dict 31 | """ 32 | if isinstance(obj, (dict, AttrDict)): 33 | return AttrDict({k: recursive_attrdict(v) for k, v in obj.items()}) 34 | 35 | if isinstance(obj, (list, tuple, set)): 36 | return obj.__class__([recursive_attrdict(item) for item in obj]) 37 | 38 | return obj 39 | -------------------------------------------------------------------------------- /powerapp/core/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db.models.signals import post_save, post_delete 3 | from django.dispatch import receiver 4 | 5 | from django_statsd.clients import statsd 6 | 7 | from .models import Integration, Service 8 | from . import periodic_tasks 9 | 10 | 11 | @receiver(post_save, sender=Integration) 12 | def on_integration_create(sender, instance=None, created=None, **kwargs): 13 | if created: 14 | # create periodic tasks 15 | periodic_tasks.add(instance) 16 | 17 | 18 | @receiver(post_save, sender=Service) 19 | def change_enabled_status_on_service_update(sender, instance=None, created=None, **kwargs): 20 | Integration.objects.filter(service=instance).update(service_enabled=instance.enabled) 21 | 22 | 23 | @receiver(post_save, sender=Integration) 24 | def integration_cnt_incr(sender, instance=None, created=None, **kwargs): 25 | if created: 26 | statsd.incr('integration_cnt') 27 | statsd.incr('integration_cnt.%s' % instance.service_id) 28 | 29 | 30 | @receiver(post_delete, sender=Integration) 31 | def integration_cnt_decr(sender, instance=None, **kwargs): 32 | statsd.decr('integration_cnt') 33 | statsd.decr('integration_cnt.%s' % instance.service_id) 34 | -------------------------------------------------------------------------------- /powerapp/core/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles core %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | {% block extra_css %} {% endblock %} 15 | 16 | 17 | 18 |
19 |
20 | 24 | 25 | {% block usermenu %} 26 | {% endblock %} 27 |
28 |
29 | 30 | {% block main %} 31 | {% endblock %} 32 | {% block extra_js %} 33 | {% endblock %} 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /powerapp/core/statsd_middleware.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import inspect 3 | import time 4 | from django_statsd.clients import statsd 5 | 6 | 7 | class GrafanaRequestTimingMiddleware(object): 8 | """statsd's timing data per view stored as gauges""" 9 | 10 | def process_view(self, request, view_func, view_args, view_kwargs): 11 | view = view_func 12 | if not inspect.isfunction(view_func): 13 | view = view.__class__ 14 | try: 15 | request._view_module = view.__module__ 16 | request._view_name = view.__name__ 17 | request._start_time = time.time() 18 | except AttributeError: 19 | pass 20 | 21 | def process_response(self, request, response): 22 | self._record_time(request) 23 | return response 24 | 25 | def process_exception(self, request, exception): 26 | self._record_time(request) 27 | 28 | def _record_time(self, request): 29 | if hasattr(request, '_start_time'): 30 | ms = int((time.time() - request._start_time) * 1000) 31 | data = dict(module=request._view_module, name=request._view_name, 32 | method=request.method) 33 | statsd.gauge('view.response_ms.{module}.{name}.{method}'.format(**data), ms) 34 | -------------------------------------------------------------------------------- /powerapp/core/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "loggedin.html" %} 2 | {% load staticfiles %} 3 | 4 | {% block content %} 5 |
6 |
Your Services
7 | 11 |
12 | 13 | {% for integration in integrations %} 14 |
16 | {% csrf_token %} 17 |
18 | 19 |
20 | {{ integration.name }} 22 |

{{ integration.name }}

23 |
24 | Edit 26 | 27 |
28 |
29 | {% endfor %} 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/templates/evernote_sync/edit_integration.html: -------------------------------------------------------------------------------- 1 | {% extends "edit_integration_base.html" %} 2 | 3 | {% block extra_actions %} 4 | {% if form.integration %} 5 |
{% csrf_token %}
6 | sync now 7 | {% endif %} 8 | {% endblock %} 9 | 10 | {% block extra_js %} 11 | 19 | {% endblock %} 20 | 21 | {% block form %} 22 | {% csrf_token %} 23 | 24 |
25 |
26 | {{ form.name }} 27 | {{ form.name.label_tag }} 28 | {% if form.name.errors %}
{{ form.name.errors }}
{% endif %} 29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 | {{ form.evernote_notebooks }} 37 | {% if form.evernote_notebooks.errors %}
{{ form.evernote_notebooks.errors }}
{% endif %} 38 |
39 |
40 | 41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /powerapp/core/templates/utils.mako: -------------------------------------------------------------------------------- 1 | <%def name="form(form_obj, button_text, button_icon)"> 2 |
3 | %for field in form_obj: 4 |
5 | ${ field.label(class_="col-md-2 col-md-offset-2 control-label") } 6 |
7 | ${ field(class_='form-control') } 8 | %if field.description: 9 | ${ field.description|h } 10 | %endif 11 | %if field.errors: 12 |

${ ". ".join(field.errors) | h }

13 | %endif 14 |
15 |
16 | %endfor 17 |
18 |
19 | ${ submit_button(button_text, icon=button_icon) } 20 |
21 |
22 |
23 | 24 | 25 | <%def name="submit_button(text, type='success', icon=None)"> 26 | 32 | 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from setuptools import setup, find_packages 4 | 5 | PY3 = sys.version_info.major == 3 6 | 7 | 8 | def read(filename): 9 | try: 10 | return open(filename).read() 11 | except IOError: 12 | pass 13 | 14 | 15 | install_requires = [ 16 | 'todoist-python', 17 | 'celery[redis]', 18 | 'Django>=1.8.2', 19 | 'django-celery', 20 | 'django-environ', 21 | 'django-picklefield', 22 | 'django-statsd-mozilla', 23 | 'django-redis-cache', 24 | 'graypy', 25 | 'colorlog', 26 | 'requests', 27 | 'feedparser', 28 | 'pytz', 29 | 'raven', 30 | 'requests-oauthlib', 31 | 'pyRFC3339', 32 | ] 33 | 34 | 35 | # We cannot install evernote on Python3, because the version supporting it 36 | # is not on PyPI yet. Use the requirement from requirements.txt to set it up 37 | if not PY3: 38 | install_requires.append('evernote') 39 | 40 | 41 | setup(name='powerapp', 42 | version='0.2', 43 | url='https://github.com/Doist/powerapp', 44 | license='BSD', 45 | zip_safe=False, 46 | description='The app to integrate Todoist with third-party service', 47 | long_description=read('README.rst'), 48 | packages=find_packages(), 49 | install_requires=install_requires, 50 | entry_points={}, 51 | classifiers=[ 52 | 'Development Status :: 3 - Alpha', 53 | 'License :: OSI Approved :: BSD License', 54 | 'Programming Language :: Python', 55 | 'Programming Language :: Python :: 3', 56 | ]) 57 | -------------------------------------------------------------------------------- /tests/integration/README.rst: -------------------------------------------------------------------------------- 1 | Integration tests require a bit of preliminary actions to work. 2 | 3 | Set up webhooks 4 | --------------- 5 | 6 | First, we have to make tests accept webhooks from the "real world Todoist". 7 | For that purpose the test suite runs a django live server listening for 8 | a local host and port, and additionally, we start a ngrok server to create a 9 | tunnel so that your local address could be available from the Internet. 10 | 11 | Visit the `Todoist app_console `_ and create 12 | a new application. The application has to emit all webhooks and the webhook 13 | URL has to look like `https://yourname.ngrok.com/webhooks/accept/`, where 14 | "yourname" is something "yours". 15 | 16 | The visit `the ngrok project website `_, download ngrok for 17 | your platform. In order to be able to use it with custom subdomains, you have 18 | to sign up there (for free) and obtain your personal auth token. 19 | 20 | Set up environment variables:: 21 | 22 | TEST_NGROK_SUBDOMAIN=yourname 23 | TEST_NGROK_AUTH_TOKEN=xxx-xxxxxxxxxx 24 | 25 | Start tests. If everything goes well, webhooks will be sent to 26 | yourname.ngrok.com and forwarded to your local test server. 27 | 28 | 29 | Set up premium account 30 | ---------------------- 31 | 32 | Create a premium account for testing purposes. If you are a developer, you 33 | may ask for the Support section to create a free premium testing account 34 | for you. 35 | 36 | :: 37 | 38 | TEST_PREMIUM_EMAIL=premium.foo@example.com 39 | TEST_PREMIUM_PASSWORD=password 40 | -------------------------------------------------------------------------------- /powerapp/contrib/github/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import requests 3 | import json 4 | from logging import getLogger 5 | from .apps import AppConfig 6 | from django.dispatch.dispatcher import receiver 7 | from .models import GithubDataMap 8 | from powerapp.core.models.oauth import OAuthToken 9 | from .views import ACCESS_TOKEN_CLIENT 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | @receiver(AppConfig.signals.todoist_task_updated) 15 | def on_task_changed(sender, user=None, service=None, integration=None, obj=None, **kwargs): 16 | if not obj.data.get("checked"): 17 | return 18 | 19 | try: 20 | item_issue_record = GithubDataMap.objects.get(integration=integration, 21 | todoist_task_id=obj['id']) 22 | except GithubDataMap.DoesNotExist: 23 | return 24 | 25 | if item_issue_record.github_data_type == "issue": 26 | access_token = OAuthToken.objects.get(user=integration.user, client=ACCESS_TOKEN_CLIENT) 27 | 28 | resp = requests.patch(item_issue_record.github_data_url, 29 | params={'access_token': access_token.access_token}, 30 | json={'state': "closed"}, 31 | headers={'Accept': 'application/json'}) 32 | 33 | if resp.status_code != 200: 34 | # TODO: LOG THE ERROR 35 | print(resp.json()) 36 | else: 37 | item_issue_record.delete() 38 | 39 | if item_issue_record.github_data_type == "pull_request": 40 | item_issue_record.delete() 41 | -------------------------------------------------------------------------------- /powerapp/contrib/hackernews/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import requests 3 | import feedparser 4 | import time 5 | import datetime 6 | from logging import getLogger 7 | from django.conf import settings 8 | 9 | from .apps import AppConfig 10 | from powerapp.core.sync import StatelessTodoistAPI 11 | from powerapp.core.todoist_utils import get_personal_project, extract_urls 12 | 13 | 14 | logger = getLogger(__name__) 15 | 16 | 17 | DEFAULT_FEED_URL = 'https://news.ycombinator.com/rss' 18 | PROJECT_NAME = 'HackerNews feed' 19 | 20 | 21 | @AppConfig.periodic_task(datetime.timedelta(minutes=1 if settings.DEBUG else 15)) 22 | def poll_hackernews_rss_feed(integration): 23 | assert isinstance(integration.api, StatelessTodoistAPI) # IDE hint 24 | 25 | settings = integration.settings 26 | if not isinstance(settings, dict): 27 | settings = {} 28 | 29 | project = get_personal_project(integration, PROJECT_NAME) 30 | resp = requests.get(integration.settings.get('feed_url', DEFAULT_FEED_URL)).content 31 | feed = feedparser.parse(resp) 32 | 33 | with integration.api.autocommit(): 34 | for entry in feed.entries: 35 | if 'published_parsed' in entry: 36 | published = int(time.mktime(entry.published_parsed)) 37 | if published < settings.get('last_updated', 0): 38 | continue 39 | 40 | content = u'%s (%s)' % (entry.link, entry.title) 41 | logger.debug('Added hacker news %s' % content) 42 | integration.api.items.add(content, project['id']) 43 | 44 | integration.update_settings(last_updated=int(time.time()), 45 | project_id=project['id']) 46 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import picklefield.fields 6 | from django.utils.timezone import utc 7 | from django.conf import settings 8 | import datetime 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ('core', '0001_initial'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='EvernoteAccountCache', 21 | fields=[ 22 | ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), 23 | ('last_update_count', models.IntegerField(default=0)), 24 | ('last_update_time', models.DateTimeField(default=datetime.datetime(2000, 1, 1, 0, 0, tzinfo=utc))), 25 | ('user_data', picklefield.fields.PickledObjectField(editable=False, null=True)), 26 | ('notebooks', picklefield.fields.PickledObjectField(editable=False, null=True)), 27 | ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name='EvernoteSyncState', 32 | fields=[ 33 | ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), 34 | ('last_update_count', models.IntegerField(default=0)), 35 | ('last_sync_time', models.BigIntegerField(default=0)), 36 | ('integration', models.OneToOneField(to='core.Integration')), 37 | ], 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /powerapp/sync_bridge/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import picklefield.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('core', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='ItemMapping', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), 19 | ('bridge_name', models.CharField(max_length=512)), 20 | ('left_id', models.CharField(db_index=True, null=True, max_length=512, verbose_name='"Left system" item id')), 21 | ('left_hash', models.CharField(default='!', verbose_name='Last seen hash of the item', max_length=64)), 22 | ('left_extra', picklefield.fields.PickledObjectField(editable=False, default={}, verbose_name='Extra data of the "left side"')), 23 | ('right_id', models.CharField(db_index=True, null=True, max_length=512, verbose_name='"Right system" item id')), 24 | ('right_hash', models.CharField(default='!', verbose_name='Last seen hash of the item', max_length=64)), 25 | ('right_extra', picklefield.fields.PickledObjectField(editable=False, default={}, verbose_name='Extra data of the "right side"')), 26 | ('integration', models.ForeignKey(to='core.Integration')), 27 | ], 28 | ), 29 | migrations.AlterIndexTogether( 30 | name='itemmapping', 31 | index_together=set([('integration', 'bridge_name', 'left_id'), ('integration', 'bridge_name', 'right_id')]), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /powerapp/core/models/periodic_task.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from logging import getLogger 3 | from django.db import models 4 | from django.utils.timezone import now 5 | from powerapp.core.logging_utils import ctx 6 | 7 | 8 | logger = getLogger(__name__) 9 | 10 | 11 | class PeriodicTask(models.Model): 12 | name = models.CharField(max_length=1024) 13 | integration = models.ForeignKey('Integration', db_index=True) 14 | next_run = models.DateTimeField(db_index=True) 15 | 16 | class Meta: 17 | app_label = 'core' 18 | unique_together = [('integration', 'name')] 19 | 20 | def __str__(self): 21 | return self.name 22 | 23 | def schedule_forward(self): 24 | task_fun = self.get_task_fun() 25 | if not task_fun: 26 | self.delete() 27 | return 28 | 29 | self.next_run = now() + task_fun.delta 30 | self.save() 31 | 32 | def run(self): 33 | # find the task function object 34 | task_fun = self.get_task_fun() 35 | if not task_fun: 36 | # unknown periodic task, destroy itself 37 | self.delete() 38 | return 39 | 40 | with ctx(user=self.integration.user, integration=self.integration): 41 | # 2. run the task as being asked 42 | logger.debug('Run periodic task %r', self.name) 43 | task_fun.func(self.integration) 44 | 45 | def get_task_fun(self): 46 | """ 47 | Helper function returning PeriodicTaskFun object, associated with 48 | this task. Return None if nothing is found. 49 | """ 50 | app_config = self.integration.service.app_config 51 | try: 52 | return app_config.periodic_tasks[self.name] 53 | except KeyError: 54 | pass 55 | -------------------------------------------------------------------------------- /powerapp/core/service_collector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from logging import getLogger 3 | 4 | from django.apps import apps 5 | 6 | from powerapp.core.models.service import Service 7 | 8 | 9 | logger = getLogger(__name__) 10 | 11 | 12 | def collect_services(): 13 | app_configs = get_service_app_configs() 14 | services = get_services() 15 | 16 | # add new services, if there are some unknown or modify their data 17 | for label, config in app_configs.items(): 18 | if label not in services.keys(): 19 | Service.objects.create(label=config.label, 20 | name=config.name, 21 | path=config.path) 22 | logger.info('Created service %s', label) 23 | else: 24 | service = services[label] 25 | if service.name != config.name or service.path != config.path: 26 | service.name = config.name 27 | service.path = config.path 28 | service.save() 29 | logger.info('Updated service %s', label) 30 | 31 | # delete services which aren't known anymore 32 | for label, service in services.items(): 33 | if label not in app_configs.keys(): 34 | service.delete() 35 | logger.info('Deleted service %s', label) 36 | 37 | 38 | def get_service_app_configs(): 39 | """ 40 | Return a dict of application configs known by Django 41 | """ 42 | ret = {} 43 | for config in apps.get_app_configs(): 44 | if getattr(config, 'service', None): 45 | ret[config.label] = config 46 | return ret 47 | 48 | 49 | def get_services(): 50 | """ 51 | Return the list of services we know about 52 | """ 53 | return {s.label: s for s in Service.objects.all()} 54 | -------------------------------------------------------------------------------- /powerapp/contrib/catcomments/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | from xml.etree import ElementTree 4 | import requests 5 | from logging import getLogger 6 | from django.dispatch.dispatcher import receiver 7 | from .apps import AppConfig 8 | from powerapp.core.sync import StatelessTodoistAPI 9 | 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | @receiver(AppConfig.signals.todoist_task_added) 15 | def on_task_added(sender, integration=None, obj=None, **kwargs): 16 | """ 17 | Add a comment with a random cat to every new task 18 | """ 19 | if obj['project_id'] != integration.settings['project']: 20 | return 21 | 22 | assert isinstance(integration.api, StatelessTodoistAPI) # IDE hint 23 | with integration.api.autocommit(): 24 | url, source_url = get_cat_picture() 25 | content = '%s (The cat API)' % source_url 26 | integration.api.notes.add(obj['id'], content, 27 | file_attachment=json.dumps({ 28 | 'file_url': url, 29 | 'file_name': 'cat.jpg', 30 | 'file_type': 'image/jpeg', 31 | 'tn_l': [url, 500, 500], 32 | 'tn_m': [url, 500, 500], 33 | 'tn_s': [url, 500, 500], 34 | })) 35 | 36 | 37 | def get_cat_picture(): 38 | resp = requests.get('http://thecatapi.com/api/images/get', 39 | params={'format': 'xml', 40 | 'results_per_page': 1, 41 | 'size': 'med'}) 42 | tree = ElementTree.fromstring(resp.content) 43 | url = tree.findtext('data/images/image[1]/url') 44 | source_url = tree.findtext('data/images/image[1]/source_url') 45 | return url, source_url 46 | -------------------------------------------------------------------------------- /powerapp/core/web_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.utils.six.moves.urllib import parse 3 | from django.utils.encoding import force_bytes 4 | from django.conf import settings 5 | 6 | 7 | def extend_qs(base_url, **kwargs): 8 | """ 9 | Extend querystring of the URL with kwargs, taking care of python types. 10 | 11 | - True is converted to "1" 12 | - When a value is equal to False or None, then corresponding key is removed 13 | from the querystring at all. Please note that empty strings and numeric 14 | zeroes are not equal to False here. 15 | - Unicode is converted to utf-8 string 16 | - Everything else is converted to string using str(obj) 17 | 18 | For instance: 19 | 20 | >>> extend_querystring('/foo/?a=b', c='d', e=True, f=False) 21 | '/foo/?a=b&c=d&e=1' 22 | """ 23 | parsed = parse.urlparse(base_url) 24 | query = dict(parse.parse_qsl(parsed.query)) 25 | for key, value in kwargs.items(): 26 | value = convert_to_string(value) 27 | if value is None: 28 | query.pop(key, None) 29 | else: 30 | query[key] = value 31 | query_str = parse.urlencode(query) 32 | parsed_as_list = list(parsed) 33 | parsed_as_list[4] = query_str 34 | return parse.urlunparse(parsed_as_list) 35 | 36 | 37 | def convert_to_string(value): 38 | """ 39 | Helper function converting python objects to strings 40 | 41 | None is special value menaning "remove me from the queryset" 42 | """ 43 | if value is None or value is False: 44 | return None 45 | if value is True: 46 | return b'1' 47 | return force_bytes(value) 48 | 49 | 50 | def build_absolute_uri(relative_url): 51 | """ 52 | Build absolute URL from a relative one. If the URL is already absolute, 53 | keep it as is 54 | """ 55 | return parse.urljoin(settings.SITE_URL, relative_url) 56 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from logging import getLogger 3 | from django.dispatch.dispatcher import receiver 4 | from .apps import AppConfig 5 | from powerapp.contrib.gcal_sync.sync_adapter import GcalSyncAdapter 6 | from powerapp.sync_bridge.bridge import SyncBridge, task 7 | from powerapp.sync_bridge.todoist_sync_adapter import TodoistSyncAdapter 8 | from . import utils, sync_adapter 9 | 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | @receiver(AppConfig.signals.todoist_task_added) 15 | @receiver(AppConfig.signals.todoist_task_updated) 16 | def on_task_changed(sender, integration=None, obj=None, **kwargs): 17 | td = TodoistSyncAdapter(obj['project_id']) 18 | gc = GcalSyncAdapter() 19 | bridge = SyncBridge(integration, td, gc) 20 | 21 | # we delete tasks, if they're marked as "in history" 22 | if obj['in_history']: 23 | bridge.delete_task(td, obj['id']) 24 | else: 25 | bridge.push_task(td, obj['id'], obj) 26 | 27 | 28 | @receiver(AppConfig.signals.todoist_task_deleted) 29 | def on_task_deleted(sender, integration=None, obj=None, **kwargs): 30 | td = TodoistSyncAdapter(obj['project_id']) 31 | gc = GcalSyncAdapter() 32 | bridge = SyncBridge(integration, td, gc) 33 | bridge.delete_task(td, obj['id']) 34 | 35 | 36 | @receiver(utils.gcal_event_changed) 37 | def on_gcal_event_changed(sender, integration=None, event=None, **kwargs): 38 | bridge = sync_adapter.get_bridge_by_event_id(integration, event['id']) 39 | bridge.push_task(bridge.right, event['id'], event) 40 | 41 | 42 | @receiver(utils.gcal_event_deleted) 43 | def on_gcal_event_deleted(sender, integration=None, event_id=None, **kwargs): 44 | bridge = sync_adapter.get_bridge_by_event_id(integration, event_id) 45 | # we don't delete task, but instead we mark the task as "checked" 46 | bridge.complete_task(bridge.right, event_id, delete_mapping=True) 47 | -------------------------------------------------------------------------------- /powerapp/core/templates/edit_integration_base.html: -------------------------------------------------------------------------------- 1 | {% extends "loggedin.html" %} 2 | {% load materializecss %} 3 | 4 | {% block content %} 5 |
6 |
7 | {{ form.service.verbose_name }} 9 |
10 | {% block extra_actions %} 11 | {% endblock %} 12 |
13 | 14 |
15 |

{{ form.service.app_config.verbose_name }}

16 |

{{ form.service.app_config.url }}

17 |

{{ form.service.app_config.description }}

18 | 19 | {% block form_wrapper %} 20 | {# edit integration form #} 21 | 22 | {% block before_form %}{% endblock %} 23 | 24 |
25 | {% block form %}{% materialize_form form %}{% endblock %} 26 |
27 | 28 | {# delete integration form #} 29 | {% if form.integration %} 30 |
{% csrf_token %} 33 |
34 | {% endif %} 35 | 36 | {# submit buttons #} 37 | 40 | {% if form.integration %} 41 | 44 | {% endif %} 45 | 46 | {% endblock %} 47 |
48 |
49 | 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import utils 3 | from celery.exceptions import SoftTimeLimitExceeded 4 | from requests import HTTPError 5 | from powerapp.celery_local import app 6 | from powerapp.contrib.gcal_sync.utils import get_authorized_client, json_post 7 | from powerapp.core.models.integration import Integration 8 | from powerapp.core.models.user import User 9 | from powerapp.core.logging_utils import ctx 10 | from logging import getLogger 11 | 12 | 13 | logger = getLogger(__name__) 14 | 15 | 16 | @app.task(ignore_result=True) 17 | def create_calendar(integration_id): 18 | try: 19 | integration = Integration.objects.get(id=integration_id) 20 | except Integration.DoesNotExist: 21 | return 22 | with ctx(user=integration.user, integration=integration): 23 | calendar = utils.get_or_create_todoist_calendar(integration) 24 | utils.subscribe_to_todoist_calendar(integration, calendar) 25 | 26 | 27 | @app.task(ignore_result=True) 28 | def sync_gcal(integration_id): 29 | try: 30 | integration = Integration.objects.select_related('user').get(id=integration_id) 31 | except Integration.DoesNotExist: 32 | return 33 | with ctx(user=integration.user, integration=integration): 34 | try: 35 | utils.sync_gcal(integration) 36 | except SoftTimeLimitExceeded: 37 | logger.error('Synchronization with GCal took too long and was aborted') 38 | 39 | 40 | @app.task(ignore_result=True) 41 | def stop_channel(user_id, channel_id, resource_id): 42 | """ 43 | Stop channel if integration does not exist 44 | """ 45 | try: 46 | user = User.objects.get(id=user_id) 47 | except User.DoesNotExist: 48 | return 49 | 50 | with ctx(user=user): 51 | client = get_authorized_client(user) 52 | try: 53 | json_post(client, '/channels/stop', id=channel_id, resouceId=resource_id) 54 | except HTTPError: 55 | # FIXME: it doesn't work :/ 56 | pass 57 | -------------------------------------------------------------------------------- /powerapp/devops_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Utility functions to simplify the deployment process 4 | """ 5 | import os 6 | import re 7 | from logging import getLogger 8 | import subprocess 9 | 10 | 11 | def extend_requirements_txt(): 12 | """ 13 | Extend requirements.txt with extra requirements from POWERAPP_SERVICES 14 | """ 15 | requirements = [r.strip() for r in open('requirements.txt').readlines()] 16 | for req in get_extra_requirements(): 17 | req_str = ' '.join(req) 18 | if req_str not in requirements: 19 | requirements.append(req_str) 20 | with open('requirements.txt', 'w') as fd: 21 | fd.write('\n'.join(requirements) + '\n') 22 | 23 | 24 | def install_requirements(): 25 | """ 26 | Install extra requirements from POWERAPP_SERVICES 27 | """ 28 | for req in get_extra_requirements(): 29 | subprocess.call(['pip', 'install'] + req) 30 | 31 | 32 | def get_extra_requirements(): 33 | """ 34 | Read POWERAPP_SERVICES environment variable, and return the list 35 | of extra packages to install. 36 | 37 | Every package in the list is a list of one (["powerapp-foo"]) or two 38 | (["-e", "git+git@github.com:Doist/powerapp-foo.git"]) elements 39 | """ 40 | services = os.environ.get('POWERAPP_SERVICES') 41 | for service in re.split(r'[\s,;]+', services or ''): 42 | if not service: 43 | continue 44 | if service.startswith('http') and '/' in service: 45 | # it's a git URL, extend it with extra fields 46 | app_name = re.search(r'([^/]+)(.git)?$', service).group(1) 47 | url = 'git+%s#egg=%s' % (service, app_name) 48 | yield ['-e', url] 49 | else: 50 | # return as is 51 | yield [service] 52 | 53 | 54 | def run_bootstrap_management_commands(): 55 | """ 56 | Run "bootstap management commands" 57 | """ 58 | # it's inside function to make sure we can use the rest of devops_utils 59 | # without setting up all the Django machinery 60 | from django.core.management import call_command 61 | call_command('migrate') 62 | call_command('collectstatic', interactive=False) 63 | call_command('collect_services') 64 | -------------------------------------------------------------------------------- /powerapp/core/django_widgets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from itertools import chain 3 | from django.forms.widgets import Select, Widget 4 | from django.utils.encoding import force_text 5 | from django.utils.html import format_html 6 | from django.utils.safestring import mark_safe 7 | 8 | 9 | class ProjectsSelect(Select): 10 | 11 | def render_options(self, choices, selected_choices): 12 | selected_choices = set(force_text(v) for v in selected_choices) 13 | output = [] 14 | 15 | filter_func = lambda p: isinstance(p['id'], int) 16 | sort_key = lambda p: (p['item_order'], p['id']) 17 | 18 | projects = chain(self.choices, choices) 19 | projects = filter(filter_func, projects) 20 | projects = sorted(projects, key=sort_key) 21 | 22 | for project in projects: 23 | option_value = force_text(project['id']) 24 | indent = max(project['indent'] - 1, 0) 25 | option_label = '..' * indent + ' ' + project['name'] 26 | output.append(self.render_option(selected_choices, option_value, option_label)) 27 | return '\n'.join(output) 28 | 29 | 30 | class SwitchWidget(Widget): 31 | 32 | choices = [] 33 | 34 | def value_from_datadict(self, data, files, name): 35 | return data.getlist(name, None) 36 | 37 | def render(self, name, value, attrs=None): 38 | checked_options = set(value or []) 39 | ret = [] 40 | for value, label in self.choices: 41 | ret.append(self.render_checkbox(name, value, label, 42 | value in checked_options)) 43 | return mark_safe('\n'.join(ret)) 44 | 45 | @staticmethod 46 | def render_checkbox(name, value, label, checked): 47 | checked_str = mark_safe(' checked') if checked else '' 48 | return format_html(u'

' 49 | u'
' 50 | u' ' 55 | u'
', name, value, checked_str, label) 56 | -------------------------------------------------------------------------------- /powerapp/core/cron.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | A module containing Celery tasks for all periodic tasks we perform. 4 | 5 | Basically, we run two types of tasks. 6 | 7 | - For every "stateful integraion" we perform a periodic sync and generate a 8 | bunch of signals (item_added, item_deleted, etc...) 9 | - If the service defines some cron job (like, polling external service), we 10 | perform these periodic tasks as well (PeriodicTask instances) 11 | """ 12 | from logging import getLogger 13 | from powerapp.celery_local import app 14 | from powerapp.core.models import Integration, PeriodicTask 15 | from django.utils.timezone import now 16 | 17 | 18 | logger = getLogger(__name__) 19 | 20 | 21 | @app.task(ignore_result=True) 22 | def schedule_sync_tasks(): 23 | """ 24 | A celery beat task which by itself does nothing but schedule a buch of 25 | sync tasks for execution 26 | 27 | For every integration object we acquire a lock, and pass this lock object 28 | to the worker. If lock is unable to acquire then probably another task is 29 | performing the sync operation right now. Don't do anything at all. 30 | """ 31 | for integration in Integration.objects.filter(stateless=False, 32 | api_next_sync__lte=now()): 33 | integration.api_last_sync = now() 34 | integration.save(update_fields=['api_last_sync']) 35 | run_sync_task.delay(integration.id) 36 | 37 | 38 | @app.task(ignore_result=True) 39 | def run_sync_task(integration_id): 40 | """ 41 | Perform sync operation for "stateful integrations" 42 | """ 43 | try: 44 | integration = Integration.objects.get(id=integration_id) 45 | except Integration.DoesNotExist: 46 | return 47 | integration.api.sync(resource_types=['projects', 'items', 'notes']) 48 | 49 | 50 | @app.task(ignore_result=True) 51 | def schedule_cron_tasks(): 52 | """ 53 | A celery beat task schedules cron tasks for execution 54 | """ 55 | for ptask in PeriodicTask.objects.filter(next_run__lt=now()): 56 | ptask.schedule_forward() 57 | run_cron_task.delay(ptask.id) 58 | 59 | 60 | @app.task(ignore_result=True) 61 | def run_cron_task(periodic_task_id): 62 | """ 63 | Run one periodic cron job for one integration 64 | """ 65 | try: 66 | task = PeriodicTask.objects.get(id=periodic_task_id) 67 | except PeriodicTask.DoesNotExist: 68 | return 69 | task.run() 70 | -------------------------------------------------------------------------------- /powerapp/core/views/web.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import auth, messages 3 | from django.contrib.auth.decorators import login_required 4 | from django.shortcuts import redirect, render, get_object_or_404 5 | from django.core.urlresolvers import reverse 6 | from django.views.decorators.http import require_POST 7 | from powerapp.core.exceptions import PowerAppError 8 | 9 | from powerapp.core.models import Service, Integration 10 | from powerapp.core import oauth 11 | 12 | 13 | @login_required 14 | def dashboard(request): 15 | integrations = Integration.objects.filter(user=request.user, 16 | service_enabled=True) 17 | if not integrations: 18 | return redirect('web_services') 19 | return render(request, 'dashboard.html', { 20 | 'active': 'dashboard', 21 | 'integrations': integrations 22 | }) 23 | 24 | 25 | @login_required 26 | def services(request): 27 | return render(request, 'services.html', { 28 | 'active': 'services', 29 | 'services': Service.objects.filter(enabled=True) 30 | }) 31 | 32 | 33 | def login(request): 34 | if request.user.is_authenticated(): 35 | return redirect('web_index') 36 | 37 | client = oauth.get_client_by_name('todoist') 38 | authorize_url = client.get_authorize_url() 39 | client.set_state(request) 40 | return render(request, 'login.html', {'authorize_url': authorize_url}) 41 | 42 | 43 | def logout(request): 44 | auth.logout(request) 45 | return redirect('web_index') 46 | 47 | 48 | def oauth2cb(request): 49 | try: 50 | client = oauth.get_client_by_state(request) 51 | code = request.GET['code'] 52 | token = client.exchange_code_for_token(code) 53 | except PowerAppError as e: 54 | return render(request, 'oauth2cb.html', {'error': str(e)}) 55 | 56 | client.callback_fn(client=client, 57 | token=token, 58 | request=request) 59 | 60 | return redirect(client.oauth2cb_redirect_uri) 61 | 62 | 63 | @require_POST 64 | @login_required 65 | def delete_integration(request, service_id, integration_id): 66 | user = request.user 67 | integration = get_object_or_404(Integration, id=integration_id, user=user) 68 | integration.delete() 69 | messages.info(request, "Integration '%s' deleted" % integration.name) 70 | return redirect(reverse('web_index')) 71 | 72 | 73 | @login_required 74 | def edit_integration(request, service_id, integration_id): 75 | return redirect('%s:edit_integration' % service_id, integration_id) 76 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var gulp = require('gulp'); 3 | var changed = require('gulp-changed'); 4 | var gulpif = require('gulp-if'); 5 | var concat = require('gulp-concat'); 6 | var uglify = require('gulp-uglify'); 7 | var uglifycss = require('gulp-uglifycss'); 8 | var beautify = require('gulp-beautify'); 9 | var autoprefixer = require('gulp-autoprefixer'); 10 | var sourcemaps = require('gulp-sourcemaps'); 11 | var less = require('gulp-less'); 12 | var rename = require('gulp-rename'); 13 | 14 | 15 | var config = { 16 | release: process.env.RELEASE !== undefined, 17 | materialize_path: 'node_modules/materialize-css/bin', 18 | less_path: 'powerapp/project_static/less', 19 | 20 | js_files: [ 21 | "node_modules/jquery/dist/jquery.js", 22 | "node_modules/materialize-css/bin/materialize.js", 23 | "powerapp/project_static/js_src/*.js" 24 | ], 25 | less_files: [ 26 | "powerapp/project_static/less/*.less" 27 | ], 28 | font_files: [ 29 | "node_modules/materialize-css/font/**/*" 30 | ] 31 | }; 32 | 33 | 34 | // Take css file from materialize, rename it to less and add to "our library" 35 | gulp.task('lessify-materialize', function() { 36 | var target = path.join(config.less_path, "lib"); 37 | return gulp.src(path.join(config.materialize_path, "*.css")) 38 | .pipe(changed(target, {extension: ".less"})) 39 | .pipe(rename({extname: ".less"})) 40 | .pipe(gulp.dest(target)) 41 | }); 42 | 43 | 44 | // Compile all less files to one big CSS 45 | gulp.task('styles', ['lessify-materialize'], function() { 46 | gulp.src(path.join(config.less_path, "*.less")) 47 | .pipe(less({paths: [config.less_path]})) 48 | .pipe(gulpif(config.release, uglifycss())) 49 | .pipe(autoprefixer()) 50 | .pipe(concat("style.css")) 51 | .pipe(gulp.dest("powerapp/project_static/css")); 52 | }); 53 | 54 | 55 | gulp.task('scripts', function() { 56 | gulp.src(config.js_files) 57 | .pipe(gulpif(config.release, sourcemaps.init())) 58 | .pipe(gulpif(config.release, uglify(), beautify())) 59 | .pipe(concat("script.js")) 60 | .pipe(gulpif(config.release, sourcemaps.write())) 61 | .pipe(gulp.dest("powerapp/project_static/js")); 62 | }); 63 | 64 | 65 | gulp.task('fonts', function() { 66 | gulp.src(config.font_files) 67 | .pipe(gulp.dest("powerapp/project_static/font")); 68 | }); 69 | 70 | gulp.task('default', ['scripts', 'styles', 'fonts']); 71 | 72 | gulp.task('watch', ['default'], function() { 73 | gulp.watch([ 74 | config.js_files, config.less_files, config.font_files 75 | ], ['default']); 76 | }); 77 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils.timezone import make_aware, now 6 | from picklefield.fields import PickledObjectField 7 | 8 | 9 | class EvernoteAccountCache(models.Model): 10 | """ 11 | Account cache, common for all integrations of the same user 12 | 13 | Contains the information about the user itself (to build correct links) 14 | and about their notebooks 15 | """ 16 | UPDATE_THRESHOLD = datetime.timedelta(seconds=30) if settings.DEBUG else datetime.timedelta(minutes=15) 17 | MILLENIUM = make_aware(datetime.datetime(2000, 1, 1)) 18 | 19 | user = models.OneToOneField('core.User') 20 | last_update_count = models.IntegerField(default=0) 21 | last_update_time = models.DateTimeField(default=MILLENIUM) 22 | user_data = PickledObjectField(null=True) 23 | notebooks = PickledObjectField(null=True) 24 | evernote_user_id = models.PositiveIntegerField(null=True, db_index=True) 25 | 26 | def refresh(self): 27 | """ 28 | Refresh the evernote cache if required 29 | """ 30 | from .utils import get_evernote_client 31 | if self.last_update_time > now() - self.UPDATE_THRESHOLD: 32 | # no need to update yet 33 | return 34 | 35 | client = get_evernote_client(self.user) 36 | 37 | # update user_data 38 | self.user_data = client.get_user_store().getUser() 39 | self.evernote_user_id = self.user_data.id 40 | 41 | # let's see if there are some updates in the note store 42 | note_store = client.get_note_store() 43 | update_count = note_store.getSyncState().updateCount 44 | if update_count <= self.last_update_count: 45 | # no updates yet 46 | self.last_update_time = now() 47 | self.save() 48 | return 49 | 50 | # it's time for update 51 | self.notebooks = note_store.listNotebooks() 52 | self.last_update_time = now() 53 | self.last_update_count = update_count 54 | self.save() 55 | 56 | 57 | 58 | class EvernoteSyncState(models.Model): 59 | """ 60 | Evernote sync state 61 | """ 62 | integration = models.OneToOneField('core.Integration') 63 | last_update_count = models.IntegerField(default=0) 64 | last_sync_time = models.BigIntegerField(default=0) 65 | 66 | def get_last_sync(self): 67 | return datetime.datetime.fromtimestamp(self.last_sync_time / 1000) 68 | 69 | def __str__(self): 70 | return '%s (%s)' % (self.last_update_count, 71 | self.get_last_sync().strftime('%Y-%m-%d %T')) 72 | -------------------------------------------------------------------------------- /powerapp/core/integration_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | from django.db import transaction 4 | from powerapp.core.models import Integration 5 | 6 | 7 | @transaction.atomic 8 | def schedule_with_rate_limit(integration_id, last_op_settings_key, 9 | task, timeout=60): 10 | """ 11 | Schedule an idempotent task (such as "sync with the upstream") to Celery, 12 | and make sure the task isn't executed more often than once in "timeout" 13 | seconds. 14 | 15 | The logic is following: 16 | 17 | - If for the last time we executed the task more than `timeout` 18 | seconds ago, we schedule it to start right now and keep current time 19 | in the `last_op_settings_key` 20 | 21 | - If we executed the task quite recently (less than `timeout` ago), but 22 | still in the past, we schedule the next task to be started in a minute 23 | after the last launch. 24 | 25 | - If the task is scheduled for the future (the value in 26 | `last_op_settings_key` more than current time), skip scheduling a new task 27 | 28 | Misc considerations: 29 | 30 | - `task` has to be a "half-baked Celery task object" (so called 31 | "signature"), made with Celery API like ``task_func.s(params)`` or 32 | a task object 33 | 34 | - Return the time (always in future) on which the task is scheduled, or 35 | None, if integration is not found 36 | 37 | - The task doesn't have to be executed more that `timeout` seconds. We 38 | enforce this by setting up the time_limit and soft_time_limit (timeout - 10s). 39 | If worker wants to react on soft time limit, it has to catch the 40 | SoftTimeLimitExceeded exception 41 | """ 42 | try: 43 | integration = Integration.objects.get(id=integration_id) 44 | except Integration.DoesNotExist: 45 | return 46 | 47 | last_op = integration.settings.get(last_op_settings_key) 48 | now = int(time.time()) 49 | 50 | apply_async_kwargs = {'time_limit': timeout, 51 | 'soft_time_limit': timeout if timeout < 20 else timeout - 10} 52 | 53 | if not last_op or last_op < now - timeout: 54 | integration.update_settings(**{last_op_settings_key: now}) 55 | task.apply_async(**apply_async_kwargs) 56 | return now 57 | 58 | if last_op < now: 59 | next_schedule = last_op + timeout 60 | countdown = next_schedule - now 61 | integration.update_settings(**{last_op_settings_key: next_schedule}) 62 | task.apply_async(countdown=countdown, **apply_async_kwargs) 63 | return next_schedule 64 | 65 | # otherwise last op is in future, just return the value 66 | return last_op 67 | -------------------------------------------------------------------------------- /powerapp/core/periodic_tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.utils.timezone import now 3 | from powerapp.core.models import Service, Integration, PeriodicTask 4 | from logging import getLogger 5 | 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | def add(integration): 11 | """ 12 | Add all periodic tasks for the given integration 13 | """ 14 | to_add = [] 15 | for task_name in integration.service.app_config.periodic_tasks.keys(): 16 | to_add.append(PeriodicTask(integration=integration, 17 | name=task_name, next_run=now())) 18 | logger.info('Task %s:%s scheduled for adding', 19 | integration.service_id, task_name) 20 | if to_add: 21 | PeriodicTask.objects.bulk_create(to_add) 22 | 23 | 24 | def sync(): 25 | """ 26 | Make sure every integration has a periodic task. We add new periodic tasks 27 | if we find something new, we delete outdated periodic tasks, if integration 28 | doesn't have such tasks anymore 29 | """ 30 | serice_periodic_tasks = {} 31 | 32 | for service in Service.objects.all(): 33 | serice_periodic_tasks[service.label] = service.app_config.periodic_tasks.keys() 34 | 35 | to_delete = [] 36 | to_add = [] 37 | 38 | for integration in Integration.objects.prefetch_related('periodictask_set').filter(service_enabled=True): 39 | expected_tasks = serice_periodic_tasks[integration.service_id] 40 | actual_tasks = {t.name: t.id for t in integration.periodictask_set.all()} 41 | 42 | for actual_task_name, actual_task_id in actual_tasks.items(): 43 | if actual_task_name not in expected_tasks: 44 | to_delete.append(actual_task_id) 45 | logger.info('Task %s:%s scheduled for removal', 46 | integration.service_id, actual_task_name) 47 | 48 | for expected_task in expected_tasks: 49 | if expected_task not in actual_tasks.keys(): 50 | to_add.append(PeriodicTask(integration=integration, 51 | name=expected_task, 52 | next_run=now())) 53 | logger.info('Task %s:%s scheduled for adding', 54 | integration.service_id, expected_task) 55 | 56 | if to_add: 57 | PeriodicTask.objects.bulk_create(to_add) 58 | 59 | if to_delete: 60 | PeriodicTask.objects.filter(id__in=to_delete).delete() 61 | 62 | 63 | def get_pending(): 64 | """ Iterator returning periodic tasks to be executed 65 | 66 | :rtype: Iterator[PeriodicTask] 67 | """ 68 | for task in PeriodicTask.objects.filter(next_run__lt=now()): 69 | yield task 70 | -------------------------------------------------------------------------------- /powerapp/core/models/oauth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | from django.db import models 4 | from django.conf import settings 5 | from django.utils.timezone import now 6 | 7 | # token, returned by most services, is a dict containing the access 8 | # token at least, and may or may not contain some extra fields 9 | # 10 | # { 11 | # 'token_type': 'Bearer', 12 | # 'refresh_token': 'xxxxxxxxxxxxxxxxx', 13 | # 'expires_in': 3600, 14 | # 'access_token': 'xxxxxxxxxxxxxxxxx' 15 | # } 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class OAuthToken(models.Model): 21 | user = models.ForeignKey(settings.AUTH_USER_MODEL) 22 | client = models.CharField('OAuth client name', max_length=128) 23 | access_token = models.CharField('OAuth access token', max_length=1024) 24 | refresh_token = models.CharField('OAuth refresh token', max_length=1024, null=True) 25 | token_type = models.CharField('Token type', max_length=64, default='Bearer') 26 | access_token_expires = models.DateTimeField('Access token expiration date', 27 | null=True, default=None) 28 | 29 | class Meta: 30 | app_label = 'core' 31 | unique_together = [ 32 | ('user', 'client'), 33 | ] 34 | 35 | def __str__(self): 36 | return '%s:%s' % (self.client, self.token_type) 37 | 38 | @classmethod 39 | def register(cls, user, client, 40 | access_token, expires_in=None, 41 | refresh_token=None, 42 | token_type='Bearer'): 43 | """ 44 | Register new access token. 45 | """ 46 | if expires_in is not None: 47 | # we want to update the token at least one min before it expires 48 | access_token_expires = now() + datetime.timedelta(seconds=expires_in - 60) 49 | else: 50 | access_token_expires = None 51 | 52 | default_values = { 53 | 'access_token': access_token, 54 | 'access_token_expires': access_token_expires, 55 | 'refresh_token': refresh_token, 56 | 'token_type': token_type, 57 | } 58 | obj, _ = cls.objects.update_or_create(client=client, user=user, 59 | defaults=default_values) 60 | return obj 61 | 62 | def get_expires_in(self): 63 | if self.access_token_expires is None: 64 | return 3600 # always return some positive value 65 | return int((self.access_token_expires - now()).total_seconds()) 66 | 67 | def refresh(self, token): 68 | self.access_token = token['access_token'] 69 | self.access_token_expires = now() + datetime.timedelta(seconds=token['expires_in'] - 60) 70 | self.save() 71 | -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | # Hi! This file contains custom settings for your local development 2 | # installation. It can also be used to setup the production environment if you 3 | # want to host the service yourself. 4 | # 5 | # Copy this file from "env.sample" to ".env" (mind the leading dot) and perform 6 | # your modification in this new file 7 | 8 | # Values are read in settings.py. Every configuration option has to be written 9 | # in a separate line in KEY=value format, without any spaces around "=" or 10 | # before KEY. 11 | 12 | # REQUIRED. 13 | # Your website base URL (with http/https prefix). Used to construct absolute URLs 14 | # when the request object is not available 15 | # SITE_URL=https://powerapp.todoist.com 16 | 17 | # Turn on Django debug mode 18 | # See https://docs.djangoproject.com/en/dev/ref/settings/#debug for more details 19 | DEBUG=on 20 | 21 | # A Django secret key to crypto sign your messages, sessions, etc. Can be any 22 | # random string and has to be kept in secret. 23 | # See https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY 24 | # for more details 25 | SECRET_KEY=arandomsecretstring 26 | 27 | # URL to connect to your database. You can use SQLite in dev environment 28 | # Postgres example: 29 | # DATABASE_URL=postgres://username:password@localhost/databasename 30 | # SQLite example: 31 | DATABASE_URL=sqlite:///powerapp.sqlite 32 | 33 | # Todoist Application settings. Create your own set of settings in 34 | # https://todoist.com/app_console/ and copy "Client ID" and "Client Secret" here 35 | TODOIST_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 36 | TODOIST_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx 37 | 38 | # Redis settings. Required for Celery 39 | # REDIS_URL=redis://user:password@host:port 40 | 41 | # Statsd settings. Useful if you want to collect performance statistics for 42 | # your application 43 | # STATSD_CLIENT='django_statsd.clients.normal' 44 | # STATSD_MODEL_SIGNALS=on 45 | # STATSD_CELERY_SIGNALS=on 46 | # STATSD_HOST=localhost 47 | # STATSD_PORT=8025 48 | # STATSD_PREFIX=powerapp 49 | 50 | # Settings for integration testing. Optional. Even if you're a developer :) 51 | TEST_NGROK_SUBDOMAIN= 52 | TEST_NGROK_AUTH_TOKEN= 53 | TEST_PREMIUM_EMAIL=premium@example.com 54 | TEST_PREMIUM_PASSWORD=password 55 | 56 | # Settings to receive webhooks from Google: https://www.google.com/webmasters/verification/ 57 | # GOOGLE_SITE_VERIFICATION=.... 58 | 59 | # Optional settings for Sentry logging 60 | # SENTRY_DSN=... 61 | 62 | # Graylog2 settings 63 | # GRAYLOG2_HOST=192.168.1.1 64 | # GRAYLOG2_PORT=12201 65 | 66 | # Other optional setting for all sorts of third-party integrations. 67 | # GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxx 68 | # GOOGLE_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxx 69 | # POCKET_CONSUMER_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxx 70 | -------------------------------------------------------------------------------- /powerapp/sync_bridge/todoist_sync_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from powerapp.core.exceptions import return_or_raise 3 | from powerapp.sync_bridge.bridge import SyncAdapter, TASK_FIELDS, task, defined, \ 4 | undefined 5 | 6 | TODOIST_ESSENTIAL_FIELDS = TASK_FIELDS[::] 7 | TODOIST_ESSENTIAL_FIELDS.remove('tags') 8 | 9 | 10 | class TodoistSyncAdapter(SyncAdapter): 11 | """ 12 | Sync Adapter for Todoist. Used to sync data between the chosen project in 13 | Todoist, and a third-party library. 14 | 15 | Does not support syncing of tags yet 16 | """ 17 | DEFAULT_NAME = 'todoist' 18 | ESSENTIAL_FIELDS = TODOIST_ESSENTIAL_FIELDS 19 | 20 | def __init__(self, project_id): 21 | name = '%s-%s' % (self.DEFAULT_NAME, project_id) 22 | super(TodoistSyncAdapter, self).__init__(name=name) 23 | self.project_id = project_id 24 | 25 | def push_task(self, task_id, task, extra): 26 | todoist_task_fields = ['checked', 'in_history', 'indent', 'item_order', 27 | 'priority', 'due_date', 'date_string', 'content'] 28 | task_dict = task._asdict() 29 | kwargs = {} 30 | for field_name in todoist_task_fields: 31 | value = task_dict[field_name] 32 | if value is not undefined: 33 | kwargs[field_name] = value 34 | 35 | if task_id: 36 | self.api.item_update(task_id, **kwargs) 37 | return_or_raise(self.api.commit()) 38 | return task_id, {} # return task_id and extra 39 | 40 | else: 41 | content = defined(kwargs.pop('content', 'New Task'), 'New Task') 42 | obj = self.api.items.add(content, self.project_id, **kwargs) 43 | return_or_raise(self.api.commit()) 44 | return obj['id'], {} # return task_id and extra 45 | 46 | def complete_task(self, task_id, extra): 47 | self.api.item_update(task_id, checked=True, in_history=True) 48 | return_or_raise(self.api.commit()) 49 | 50 | def delete_task(self, task_id, extra): 51 | with self.api.autocommit(): 52 | self.api.item_delete(task_id) 53 | 54 | def task_from_data(self, data, extra): 55 | """ 56 | Take a Todoist item (as data) and return a new generic task. "Extra" 57 | is not used. 58 | """ 59 | return task(checked=data['checked'], 60 | content=data['content'], 61 | date_string=data['date_string'], 62 | due_date=data['due_date'], 63 | in_history=data['in_history'], 64 | indent=data['indent'], 65 | item_order=data['item_order'], 66 | priority=data['priority'], 67 | tags=None) 68 | -------------------------------------------------------------------------------- /powerapp/core/logging_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from threading import local 3 | import datetime 4 | from contextlib import contextmanager 5 | from django.utils.encoding import force_text 6 | 7 | 8 | # local context to be used in logging 9 | _local_ctx = local() 10 | 11 | 12 | @contextmanager 13 | def ctx(**kwargs): 14 | """ 15 | context manager used to set up extra logging variables 16 | """ 17 | _ensure_local_has_ctx() 18 | old_ctx = _local_ctx.ctx.copy() 19 | try: 20 | update_ctx(**kwargs) 21 | yield 22 | finally: 23 | _local_ctx.ctx = old_ctx 24 | 25 | 26 | def update_ctx(**kwargs): 27 | """ 28 | A helper function to update the context. Data will be available until 29 | the end of the thread, unless overwritten 30 | """ 31 | _ensure_local_has_ctx() 32 | _local_ctx.ctx.update(**kwargs) 33 | 34 | 35 | def get_ctx(): 36 | """ get the dict with thread local context """ 37 | _ensure_local_has_ctx() 38 | return _local_ctx.ctx 39 | 40 | 41 | def _ensure_local_has_ctx(): 42 | if not hasattr(_local_ctx, 'ctx'): 43 | _local_ctx.ctx = {} 44 | 45 | 46 | class ContextFilter(object): 47 | """ 48 | A filter which simply adds log data to the context 49 | """ 50 | def __init__(self, fields=None): 51 | self.fields = fields or {} 52 | 53 | def filter(self, record): 54 | effective_ctx = dict(self.fields, **get_ctx()) 55 | for k, v in effective_ctx.items(): 56 | for processed_key, processed_value in self.process(k, v).items(): 57 | setattr(record, processed_key, processed_value) 58 | return True 59 | 60 | def process(self, key, value): 61 | """ helper function to convert whatever value to dict """ 62 | if isinstance(value, datetime.datetime): 63 | return {key: value.isoformat()} 64 | 65 | if hasattr(value, '__log__') and callable(value.__log__): 66 | return {'%s_%s' % (key, k): v for k, v in value.__log__().items()} 67 | 68 | return {key: force_text(value)} 69 | 70 | 71 | class RequestContextMiddleware(object): 72 | 73 | def process_request(self, request): 74 | """ 75 | Populate context with useful information. 76 | """ 77 | # we're safe here, as long as this function is called on every request, 78 | # and all fields are populated 79 | fields = { 80 | # user field 81 | 'user': request.user, 82 | # request-related fields 83 | 'request_url': request.get_full_path(), 84 | 'request_method': request.method, 85 | 'user_agent': request.META['HTTP_USER_AGENT'], 86 | 'remote_addr': request.META['REMOTE_ADDR'], 87 | } 88 | update_ctx(**fields) 89 | -------------------------------------------------------------------------------- /powerapp/core/models/integration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from django.db import models 4 | from django.utils.timezone import now, make_aware 5 | from django.conf import settings 6 | from django.apps import apps 7 | from django.utils.functional import cached_property 8 | from picklefield import PickledObjectField 9 | from powerapp.core.sync import TodoistAPI 10 | 11 | 12 | SYNC_PERIOD = datetime.timedelta(minutes=1 if settings.DEBUG else 30) 13 | 14 | 15 | class Integration(models.Model): 16 | 17 | # Woohoo! Millenium! 18 | MILLENIUM = make_aware(datetime.datetime(2000, 1, 1)) 19 | 20 | name = models.CharField(max_length=1024) 21 | service = models.ForeignKey('Service', max_length=1024) 22 | user = models.ForeignKey(settings.AUTH_USER_MODEL) 23 | settings = PickledObjectField(default={}) 24 | 25 | # API client fields 26 | stateless = models.BooleanField(default=True) 27 | api_state = PickledObjectField() 28 | api_last_sync = models.DateTimeField(default=MILLENIUM) 29 | api_next_sync = models.DateTimeField(default=MILLENIUM) 30 | 31 | # enabled status (cache value from the "service" field) 32 | service_enabled = models.BooleanField(default=True, editable=False) 33 | 34 | def __str__(self): 35 | return self.name 36 | 37 | def __log__(self): 38 | return { 39 | 'id': self.id, 40 | 'service': self.service_id, 41 | 'uid': self.user_id, 42 | } 43 | 44 | class Meta: 45 | app_label = 'core' 46 | index_together = [ 47 | ['service_enabled', 'user', 'service'], 48 | ['service_enabled', 'stateless', 'api_next_sync'] 49 | ] 50 | 51 | def update_settings(self, **kwargs): 52 | self.settings = dict(self.settings or {}, **kwargs) 53 | self.save(update_fields=['settings']) 54 | 55 | def reset_api(self): 56 | if not self.stateless: 57 | self.api_state = None 58 | self.api_last_sync = self.MILLENIUM 59 | self.api_next_sync = self.MILLENIUM 60 | self.api = TodoistAPI.create(self) 61 | self.api.user.sync() 62 | 63 | @cached_property 64 | def api(self): 65 | return TodoistAPI.create(self) 66 | 67 | @property 68 | def app_config(self): 69 | """ 70 | Return the initialized Application Config instance, connected to 71 | this instance's service. It's here to avoid extra database query 72 | for requests like `instance.service.app_config` 73 | 74 | :rtype: powerapp.core.apps.ServiceAppConfig 75 | """ 76 | return apps.get_app_config(self.service_id) 77 | 78 | def save(self, **kwargs): 79 | # move next_sync forward if needed 80 | threshold = self.api_last_sync or now() 81 | if not self.stateless and (not self.api_next_sync 82 | or self.api_next_sync < threshold): 83 | self.api_next_sync = threshold + SYNC_PERIOD 84 | super(Integration, self).save(**kwargs) 85 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from logging import getLogger 4 | from django.conf import settings 5 | from django.dispatch.dispatcher import receiver 6 | from .apps import AppConfig 7 | from powerapp.contrib.evernote_sync.sync_adapter import EvernoteSyncAdapter, \ 8 | get_bridge_by_guid, build_bridge 9 | from powerapp.core.exceptions import PowerAppInvalidTokenError 10 | from powerapp.sync_bridge.bridge import SyncBridge 11 | from powerapp.sync_bridge.todoist_sync_adapter import TodoistSyncAdapter 12 | from . import utils 13 | 14 | 15 | logger = getLogger(__name__) 16 | 17 | 18 | @receiver(AppConfig.signals.todoist_task_added) 19 | @receiver(AppConfig.signals.todoist_task_updated) 20 | def on_task_changed(sender, user=None, service=None, integration=None, obj=None, **kwargs): 21 | # let's see if we have to keep track of this 22 | guid = integration.settings.get('projects_notebooks', {}).get(obj['project_id']) 23 | if guid is None: 24 | return 25 | 26 | td = TodoistSyncAdapter(obj['project_id']) 27 | ev = EvernoteSyncAdapter(guid) 28 | bridge = SyncBridge(integration, td, ev) 29 | bridge.push_task(td, obj['id'], obj) 30 | 31 | 32 | @receiver(AppConfig.signals.todoist_task_deleted) 33 | def on_task_deleted(sender, user=None, service=None, integration=None, obj=None, **kwargs): 34 | # let's see if we have to keep track of this 35 | guid = integration.settings.get('projects_notebooks', {}).get(obj['project_id']) 36 | if guid is None: 37 | return 38 | 39 | td = TodoistSyncAdapter(obj['project_id']) 40 | ev = EvernoteSyncAdapter(guid) 41 | bridge = SyncBridge(integration, td, ev) 42 | bridge.delete_task(td, obj['id']) 43 | 44 | 45 | @receiver(utils.evernote_note_changed) 46 | def on_note_changed(sender, integration, note, **kwargs): 47 | # we don't care about this note 48 | if note.notebookGuid not in integration.settings.get('evernote_notebooks', []): 49 | return 50 | # this note doesn't have a reminder, skip it as well 51 | if not note.attributes.reminderOrder: 52 | return 53 | projects_notebooks = integration.settings.get('projects_notebooks') or {} 54 | notebooks_projects = {v: k for k, v in projects_notebooks.items()} 55 | project_id = notebooks_projects.get(note.notebookGuid) 56 | bridge = build_bridge(integration, project_id, note.notebookGuid) 57 | bridge.push_task(bridge.right, note.guid, note) 58 | 59 | 60 | @receiver(utils.evernote_note_deleted) 61 | def on_note_deleted(sender, integration, guid, **kwargs): 62 | bridge = get_bridge_by_guid(integration, guid) 63 | if bridge: 64 | bridge.delete_task(bridge.right, guid) 65 | 66 | 67 | if settings.EVERNOTE_USE_POLLING: 68 | @AppConfig.periodic_task(datetime.timedelta(minutes=1 if settings.DEBUG else 15)) 69 | def sync_evernote(integration): 70 | try: 71 | utils.sync_evernote(integration) 72 | except PowerAppInvalidTokenError: 73 | logger.warning("Evernote access token for %s not found. " 74 | "Skip synchronization", integration.user) 75 | -------------------------------------------------------------------------------- /powerapp/core/app_signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.dispatch import Signal 3 | from logging import getLogger 4 | from powerapp.core.logging_utils import ctx 5 | from todoist import models 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | class TodoistSyncSignal(Signal): 11 | 12 | SYNC_SIGNAL_ARGS = ['integration', 'obj'] 13 | 14 | def __init__(self, name, model): 15 | super(TodoistSyncSignal, self).__init__(providing_args=self.SYNC_SIGNAL_ARGS) 16 | self.name = name 17 | self.model = model 18 | 19 | def fire(self, integration, obj): 20 | """ 21 | A wrapper around `send_robust` with logging and type conversion 22 | """ 23 | if self.has_listeners(): 24 | # type conversion: we don't want plain dict as an obj 25 | if not isinstance(obj, self.model): 26 | obj = self.model(obj, integration.api) 27 | 28 | # note: we use "select_related('user')" to make sure we don't 29 | # have a separate query for integration.user over here 30 | with ctx(integration=integration, user=integration.user): 31 | extra = {'signal_name': self.name, 'obj': obj} 32 | logger.debug('Send Todoist event', extra=extra) 33 | resp = self.send_robust(None, integration=integration, obj=obj) 34 | for func, item in resp: 35 | # log exceptions 36 | if isinstance(item, Exception): 37 | exc_info = (item.__class__, item, item.__traceback__) 38 | logger.error('Todoist event handler %s() raises %r', 39 | func.__name__, item, 40 | exc_info=exc_info, 41 | extra=dict(extra, func_name=func.__name__)) 42 | 43 | 44 | class ServiceAppSignals(object): 45 | """ 46 | The registry of all signals which the service app can emit. 47 | """ 48 | 49 | def __init__(self): 50 | self.todoist_project_added = TodoistSyncSignal('todoist_project_added', models.Project) 51 | self.todoist_project_updated = TodoistSyncSignal('todoist_project_updated', models.Project) 52 | self.todoist_project_deleted = TodoistSyncSignal('todoist_project_deleted', models.Project) 53 | 54 | self.todoist_task_added = TodoistSyncSignal('todoist_task_added', models.Item) 55 | self.todoist_task_updated = TodoistSyncSignal('todoist_task_updated', models.Item) 56 | self.todoist_task_deleted = TodoistSyncSignal('todoist_task_deleted', models.Item) 57 | 58 | self.todoist_note_added = TodoistSyncSignal('todoist_note_added', models.Note) 59 | self.todoist_note_updated = TodoistSyncSignal('todoist_note_updated', models.Note) 60 | self.todoist_note_deleted = TodoistSyncSignal('todoist_note_deleted', models.Note) 61 | 62 | def __getitem__(self, item): 63 | """ 64 | :rtype: Signal 65 | """ 66 | try: 67 | return getattr(self, item) 68 | except AttributeError: 69 | raise KeyError('Signal %r not found' % item) 70 | -------------------------------------------------------------------------------- /powerapp/core/todoist_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Todoist-related utils 4 | """ 5 | import re 6 | from collections import namedtuple 7 | from powerapp.core.exceptions import return_or_raise 8 | from powerapp.core.sync import UserTodoistAPI 9 | from logging import getLogger 10 | 11 | 12 | logger = getLogger(__name__) 13 | 14 | 15 | def get_personal_project(integration, project_name, pid_field='project_id'): 16 | """ 17 | It's a common thing for integrations to have personal projects, which they 18 | operate in. 19 | 20 | This helper function searches for a "personal project id" saved in the 21 | integration. If project is found and exist, it returns it. Otherwise 22 | it creates a new project with a given name, saves its id in the integration 23 | settings, and returns the object 24 | """ 25 | # we're using the "shared API", because in a stateless mode we don't 26 | # have access to all user projects 27 | api = integration.user.api 28 | assert isinstance(api, UserTodoistAPI) # IDE hint 29 | 30 | settings = integration.settings or {} 31 | 32 | # Try to find a project by id 33 | project_id = settings.get(pid_field) 34 | 35 | # it's a broken project, ignore it 36 | if str(project_id).startswith('$'): 37 | project_id = None 38 | 39 | if project_id: 40 | project = api.projects.get_by_id(project_id) 41 | if project: 42 | return project 43 | 44 | # Project is not found. Search by project name, or create a new one 45 | projects = api.projects.all(filt=lambda p: p['name'] == project_name and not str(p['id']).startswith('$')) 46 | 47 | project = projects[0] if projects else api.projects.add(project_name) 48 | 49 | # commit all changes and update settings 50 | return_or_raise(api.commit()) 51 | integration.update_settings(**{pid_field: project['id']}) 52 | return project 53 | 54 | 55 | url = namedtuple('url', ['link', 'title']) 56 | re_url = re.compile(r''' 57 | (?Phttps?://[^ \(\)]+) # URL itself 58 | \s* # optional space 59 | (?:\((?P[^)]+)\))? # optional text (in) 60 | ''', re.VERBOSE) 61 | 62 | 63 | def extract_urls(text): 64 | """ 65 | This function returns the list of URLs from the text 66 | 67 | Every item is returned as an URL object with link and optional title 68 | attribute 69 | """ 70 | ret = [] 71 | for m in re_url.finditer(text): 72 | ret.append(url(m.group('link'), m.group('title'))) 73 | return ret 74 | 75 | 76 | def plaintext_content(content, cut_url_pattern=None, cut_all_urls=False): 77 | """ 78 | We expect the content of a task to be written in different formats. With 79 | this function we extract only the plaintext content 80 | """ 81 | ret = [] 82 | for chunk in re_url.split(content): 83 | if not chunk: 84 | continue 85 | 86 | if (cut_url_pattern and cut_url_pattern in chunk) or cut_all_urls: 87 | if chunk.startswith('http:') or chunk.startswith('https:'): 88 | continue 89 | 90 | ret.append(chunk.strip('() ')) 91 | ret = ' '.join(ret) 92 | logger.debug('Plaintext content from %s -> %s (exclude links: %s)', content, ret, cut_url_pattern) 93 | return ret 94 | -------------------------------------------------------------------------------- /powerapp/core/models/service.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import logging 3 | import os 4 | 5 | from django.contrib.staticfiles.storage import staticfiles_storage 6 | from django.core.urlresolvers import reverse 7 | from django.db import models 8 | from django.utils.functional import cached_property 9 | from django.apps import apps 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Service(models.Model): 16 | """ 17 | Service is a "wrapper" and enumerator for service defined in the 18 | `services_impl` module. 19 | 20 | The service is initialized with the `root` parameter, which can be a 21 | filename or a module directory, and optional `id` and `name` attributes 22 | 23 | Field examples: 24 | 25 | - label: catcomments 26 | - name: powerapp.contrib.catcomments 27 | - path: /full/path/to/module/named/catcomments 28 | """ 29 | label = models.CharField('Service label', max_length=256, primary_key=True) 30 | name = models.CharField('Service name', max_length=256) 31 | path = models.CharField('Service path', max_length=1024) 32 | enabled = models.BooleanField('Service enabled', default=True) 33 | 34 | class Meta: 35 | app_label = 'core' 36 | 37 | def __str__(self): 38 | return self.name 39 | 40 | @property 41 | def app_config(self): 42 | """ 43 | Return the initialized Application Config instance, connected to 44 | this service 45 | 46 | :rtype: powerapp.core.apps.ServiceAppConfig 47 | """ 48 | return apps.get_app_config(self.label) 49 | 50 | @cached_property 51 | def urls(self): 52 | """ 53 | A helper method returning URL for current application. Useful in 54 | templates like `{{ service.urls.add_integration }}` 55 | """ 56 | class AttachedURL(object): 57 | def __init__(self, srv): 58 | self.srv = srv 59 | def __getattr__(self, item): 60 | return reverse('%s:%s' % (self.srv.label, item)) 61 | return AttachedURL(self) 62 | 63 | @cached_property 64 | def logo_filename(self): 65 | static_root = os.path.join(self.path, 'static', self.label) 66 | choices = glob.glob1(static_root, 'logo.*') 67 | if choices: 68 | return os.path.join(self.label, choices[0]) 69 | else: 70 | return 'common/default_logo.png' 71 | 72 | @cached_property 73 | def logo_url(self): 74 | return staticfiles_storage.url(self.logo_filename) 75 | 76 | def event_handler(self, event_name, hooks=True, sync=False): 77 | """ decorator for registering event handlers. 78 | 79 | Decorated function should accept two arguments 80 | - todoist_user instance 81 | - added/modified/deleted object 82 | 83 | See `TodoistUser.sync` docstring for all currently available event types 84 | """ 85 | def decorator(func): 86 | self.event_handlers[event_name] = {'func': func, 87 | 'hooks': hooks, 88 | 'sync': sync} 89 | return func 90 | return decorator 91 | 92 | def periodic_task(self, timedelta): 93 | """ decorator for registering periodic tasks """ 94 | def decorator(func): 95 | # we use dict instead of list to ensure uniqueness 96 | self.periodic_tasks[func.__name__] = (func, timedelta) 97 | return func 98 | return decorator 99 | -------------------------------------------------------------------------------- /powerapp/core/django_forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django import forms 3 | from celery import chain 4 | from powerapp.core.models import Service, Integration 5 | from powerapp.core import tasks 6 | 7 | 8 | class IntegrationForm(forms.Form): 9 | """ 10 | Base integration form. Accepts two extra arguments: user and optional 11 | integration object. When the integration is None, it is supposed that 12 | the integration is added, otherwise the integration is changed. 13 | 14 | When you subclass your form: 15 | 16 | - define a service_label attribute 17 | - provide any extra fields you want, they're all will be stored as 18 | integration settings 19 | """ 20 | service_label = None 21 | name = forms.CharField(label=u'Integration name') 22 | 23 | def __init__(self, request, integration=None, *args, **kwargs): 24 | self.request = request 25 | self.user = request.user 26 | self.integration = integration 27 | self.service = Service.objects.get(label=self.service_label) 28 | 29 | # set up initial values for the form 30 | initial = kwargs.get('initial') or {} 31 | if integration: 32 | assert integration.service_id == self.service_label 33 | initial.update(dict(integration.settings, name=integration.name)) 34 | else: 35 | initial.update(name=self.service.app_config.verbose_name) 36 | kwargs['initial'] = initial 37 | 38 | # init the form itself 39 | super(IntegrationForm, self).__init__(*args, **kwargs) 40 | 41 | # populate instance fields with user data 42 | self.populate_with_user() 43 | 44 | def populate_with_user(self): 45 | for field_obj in self.fields.values(): 46 | if hasattr(field_obj, 'populate_with_user'): 47 | field_obj.populate_with_user(self.user) 48 | 49 | def pre_save(self, integration_settings): 50 | """ 51 | pre_save is called where form data is cleaned and already valid, and the 52 | integration is ready to save. It accepts integration settings, 53 | optionally modifies them and returns the value 54 | """ 55 | return integration_settings 56 | 57 | def post_save(self): 58 | pass 59 | 60 | def save(self): 61 | if not self.integration: 62 | self.integration = Integration(service_id=self.service_label, 63 | user=self.user) 64 | self.integration_created = True 65 | else: 66 | self.integration_created = False 67 | 68 | # merge current integration settings with new values 69 | integration_settings = dict(self.integration.settings or {}, 70 | **dict(self.cleaned_data)) 71 | # modify them with pre_save 72 | integration_settings = self.pre_save(integration_settings) 73 | 74 | self.integration.name = integration_settings.pop('name') 75 | self.integration.settings = integration_settings 76 | self.integration.save() 77 | 78 | # post-save task may optionally return a Celery subtask, which will 79 | # be executed before "initial_stateless_sync" 80 | c = chain() 81 | 82 | post_save_task = self.post_save() 83 | if post_save_task: 84 | c |= post_save_task 85 | 86 | # init stateless instances, we sync it and then we drop it 87 | if self.integration_created and self.integration.stateless: 88 | c |= tasks.initial_stateless_sync.si(self.integration.id) 89 | 90 | # run the chain 91 | c() 92 | return self.integration 93 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from logging import getLogger 3 | from django.contrib import messages 4 | from django.contrib.auth.decorators import login_required 5 | from django.http.response import HttpResponse 6 | from django.shortcuts import redirect, render, get_object_or_404 7 | from django.views.decorators.csrf import csrf_exempt 8 | from django.views.decorators.http import require_POST 9 | from powerapp.core import generic_views, oauth, django_forms 10 | from . import utils, oauth_impl, tasks 11 | from powerapp.core.exceptions import PowerAppError 12 | from powerapp.core.integration_utils import schedule_with_rate_limit 13 | from powerapp.core.logging_utils import ctx 14 | from powerapp.core.models.integration import Integration 15 | 16 | logger = getLogger(__name__) 17 | 18 | 19 | class IntegrationForm(django_forms.IntegrationForm): 20 | service_label = 'gcal_sync' 21 | 22 | def post_save(self): 23 | if self.integration_created: 24 | return tasks.create_calendar.si(self.integration.id) 25 | 26 | 27 | class EditIntegrationView(generic_views.EditIntegrationView): 28 | service_label = 'gcal_sync' 29 | access_token_client = oauth_impl.OAUTH_CLIENT_NAME 30 | access_token_scope = oauth_impl.GCAL_SCOPE 31 | form = IntegrationForm 32 | 33 | def access_token_redirect(self, request): 34 | return redirect('gcal_sync:authorize_gcal') 35 | 36 | 37 | @login_required 38 | def authorize_gcal(request): 39 | client = oauth.get_client_by_name(oauth_impl.OAUTH_CLIENT_NAME) 40 | authorize_url = client.get_authorize_url(access_type='offline', 41 | approval_prompt='force') 42 | client.set_state(request) 43 | context = {'authorize_url': authorize_url} 44 | return render(request, 'gcal_sync/authorize_gcal.html', context) 45 | 46 | 47 | @login_required 48 | @require_POST 49 | def sync_now(request, integration_id): 50 | get_object_or_404(Integration, id=integration_id, user_id=request.user.id) 51 | subtask = tasks.sync_gcal.s(integration_id) 52 | schedule_with_rate_limit(integration_id, 'last_sync', subtask) 53 | messages.info(request, 'Synchronization with Google Calendar scheduled') 54 | return redirect('gcal_sync:edit_integration', integration_id) 55 | 56 | 57 | @csrf_exempt 58 | def accept_webhook(request, integration_id): 59 | """ 60 | Google Calendar webhook handler 61 | 62 | For more details see 63 | https://developers.google.com/google-apps/calendar/v3/push?hl=en_US#receiving-notifications 64 | """ 65 | try: 66 | channel_id = request.META['HTTP_X_GOOG_CHANNEL_ID'] 67 | resource_id = request.META['HTTP_X_GOOG_RESOURCE_ID'] 68 | resource_state = request.META['HTTP_X_GOOG_RESOURCE_STATE'] 69 | resource_uri = request.META['HTTP_X_GOOG_RESOURCE_URI'] 70 | token = request.META['HTTP_X_GOOG_CHANNEL_TOKEN'] 71 | except KeyError: 72 | # not a google request 73 | return HttpResponse() 74 | 75 | try: 76 | token_data = utils.validate_webhook_token(token) 77 | except PowerAppError: 78 | return HttpResponse() 79 | 80 | try: 81 | integration = Integration.objects.get(id=integration_id) 82 | except Integration.DoesNotExist: 83 | tasks.stop_channel.delay(token_data['u'], channel_id, resource_id) 84 | else: 85 | with ctx(integration=integration, user=integration.user): 86 | logging_extra = {'channel_id': channel_id, 87 | 'token': token, 88 | 'resource_id': resource_id, 89 | 'resource_state': resource_state, 90 | 'resource_uri': resource_uri} 91 | logger.debug('Received GCal webhook. Schedule Sync', extra=logging_extra) 92 | subtask = tasks.sync_gcal.s(integration_id) 93 | schedule_with_rate_limit(integration_id, 'last_sync', subtask) 94 | 95 | return HttpResponse() 96 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django import forms 3 | from evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException 4 | from powerapp.core import django_forms 5 | 6 | from . import utils 7 | from powerapp.core.django_widgets import SwitchWidget 8 | from powerapp.core.exceptions import PowerAppInvalidTokenError 9 | 10 | DEFAULT_PROJECT_NAME = u'Evernote' 11 | 12 | 13 | class EvernoteChoiceField(forms.MultipleChoiceField): 14 | 15 | widget = SwitchWidget 16 | 17 | def populate_with_user(self, user): 18 | try: 19 | notebooks = utils.get_notebooks(user) 20 | except (EDAMSystemException, EDAMUserException): 21 | # looks like the auth token is expired, delete and re-issue it 22 | raise PowerAppInvalidTokenError() 23 | 24 | self.choices = [(n.guid, n.name) for n in notebooks] 25 | 26 | 27 | 28 | class IntegrationForm(django_forms.IntegrationForm): 29 | service_label = 'evernote_sync' 30 | evernote_notebooks = EvernoteChoiceField(label=u'Evernote Notebook', required=False) 31 | 32 | def pre_save(self, integration_settings): 33 | """ 34 | Perform "pre-save integration" actions 35 | """ 36 | self.create_todoist_projects(integration_settings) 37 | self.sync_new_notebooks_pre_save(integration_settings) 38 | return integration_settings 39 | 40 | def post_save(self): 41 | self.sync_new_notebooks_post_save() 42 | 43 | def create_todoist_projects(self, integration_settings): 44 | """ 45 | Create Todoist projects and map them to evernote notebooks 46 | """ 47 | # a dict: project_id -> notebook guid 48 | projects_notebooks = integration_settings.get('projects_notebooks') or {} 49 | # inverted structure 50 | notebooks_projects = {v: k for k, v in projects_notebooks.items()} 51 | 52 | # a dict: notebook guid: notebook name 53 | notebooks = {n.guid: n.name for n in utils.get_notebooks(self.integration.user)} 54 | 55 | # create all projects we need to perform sync operations 56 | projects = [] 57 | guids = [] 58 | with self.integration.user.api.autocommit(): 59 | for guid in integration_settings.get('evernote_notebooks'): 60 | # check that project exists 61 | project_id = notebooks_projects.get(guid) 62 | if project_id: 63 | if not self.integration.user.api.projects.get_by_id(project_id): 64 | project_id = None 65 | # if it doesn't exist, create one 66 | if project_id is None: 67 | project_name = notebooks.get(guid, DEFAULT_PROJECT_NAME) 68 | project = self.integration.user.api.projects.add(name=project_name) 69 | projects.append(project) 70 | guids.append(guid) 71 | 72 | # populate "project_notebooks" with newer values 73 | # at this point all projects have to have valid ids 74 | pids = [p['id'] for p in projects] 75 | projects_notebooks.update(dict(zip(pids, guids))) 76 | integration_settings['projects_notebooks'] = projects_notebooks 77 | return integration_settings 78 | 79 | def sync_new_notebooks_pre_save(self, integration_settings): 80 | """ 81 | If integration is not new, and user adds some new notebooks to their 82 | watchlist, we have to perform the initial synchronization for them and 83 | their corresponding Todoist projects. 84 | """ 85 | old_guids = self.integration.settings.get('evernote_notebooks', []) 86 | new_guids = integration_settings.get('evernote_notebooks', []) 87 | self._new_notebook_guids = set(new_guids) - set(old_guids) 88 | 89 | def sync_new_notebooks_post_save(self): 90 | if self._new_notebook_guids: 91 | utils.sync_evernote_projects(self.integration, 92 | self._new_notebook_guids) 93 | 94 | 95 | -------------------------------------------------------------------------------- /powerapp/sync_bridge/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | from django.db import models 4 | from django.utils.text import Truncator 5 | from picklefield.fields import PickledObjectField 6 | 7 | 8 | class BridgeManager(models.Manager): 9 | 10 | def bridge_delete(self, bridge, **kwargs): 11 | return self.get_queryset().filter(integration=bridge.integration, 12 | bridge_name=bridge.name, **kwargs).delete() 13 | 14 | def bridge_create(self, bridge, **kwargs): 15 | return self.get_queryset().create(integration=bridge.integration, 16 | bridge_name=bridge.name, **kwargs) 17 | 18 | def bridge_filter(self, bridge, **kwargs): 19 | return self.get_queryset().filter(integration=bridge.integration, 20 | bridge_name=bridge.name, **kwargs) 21 | 22 | def bridge_get(self, bridge, **kwargs): 23 | return self.get_queryset().get(integration=bridge.integration, 24 | bridge_name=bridge.name, **kwargs) 25 | 26 | 27 | 28 | class ItemMapping(models.Model): 29 | 30 | objects = BridgeManager() 31 | 32 | # a part of the compound key to identify the bridge 33 | integration = models.ForeignKey('core.Integration') 34 | bridge_name = models.CharField(max_length=512) 35 | 36 | # item requisites 37 | left_id = models.CharField(u'"Left system" item id', max_length=512, null=True, db_index=True) 38 | left_hash = models.CharField(u'Last seen hash of the item', max_length=64, default='!') 39 | left_extra = PickledObjectField(u'Extra data of the "left side"', default={}) 40 | 41 | right_id = models.CharField(u'"Right system" item id', max_length=512, null=True, db_index=True) 42 | right_hash = models.CharField(u'Last seen hash of the item', max_length=64, default='!') 43 | right_extra = PickledObjectField(u'Extra data of the "right side"', default={}) 44 | 45 | def side(self, side_name): 46 | return SideProxy(self, side_name) 47 | 48 | class Meta: 49 | index_together = [ 50 | ['integration', 'bridge_name', 'left_id'], 51 | ['integration', 'bridge_name', 'right_id'], 52 | ] 53 | 54 | def __log__(self): 55 | """ 56 | Logging utils use this information to convert object to a log record 57 | """ 58 | return { 59 | 'id': self.id, 60 | 'integration_id': self.integration_id, 61 | 'bridge_name': self.bridge_name, 62 | 'left_id': self.left_id, 63 | 'left_hash': hash_and_extra(self.left_hash, self.left_extra), 64 | 'right_id': self.right_id, 65 | 'right_hash': hash_and_extra(self.right_hash, self.right_extra), 66 | } 67 | 68 | def __str__(self): 69 | return '%s: %s (%s) -> %s (%s)' % (self.bridge_name, 70 | self.left_id, 71 | hash_and_extra(self.left_hash, self.left_extra), 72 | self.right_id, 73 | hash_and_extra(self.right_hash, self.right_extra)) 74 | 75 | 76 | class SideProxy(object): 77 | """ 78 | internal helper to replace ugly assignments like 79 | 80 | >>> setattr(mapping, '%s_id' % side, value) 81 | 82 | with more meaningful 83 | 84 | >>> mapping.side('right').id = value 85 | """ 86 | def __init__(self, item_mapping, side_name): 87 | object.__setattr__(self, 'item_mapping', item_mapping) 88 | object.__setattr__(self, 'side_name', side_name) 89 | 90 | def __setattr__(self, key, value): 91 | return setattr(self.item_mapping, '%s_%s' % (self.side_name, key), value) 92 | 93 | def __getattr__(self, item): 94 | return getattr(self.item_mapping, '%s_%s' % (self.side_name, item)) 95 | 96 | 97 | def hash_and_extra(hash_value, extra_value): 98 | ret = hash_value[:6] 99 | if extra_value: 100 | shorten_extra = {} 101 | for k, v in extra_value.items(): 102 | shorten_extra[Truncator(k).chars(20)] = Truncator(v).chars(20) 103 | ret += ' %s' % json.dumps(shorten_extra) 104 | return ret 105 | -------------------------------------------------------------------------------- /powerapp/core/views/webhooks.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | from itertools import groupby 5 | import json 6 | import binascii 7 | import datetime 8 | from logging import getLogger 9 | 10 | from django.utils.encoding import force_bytes, force_text 11 | from django.utils.timezone import now 12 | from django.views.decorators.csrf import csrf_exempt 13 | from django.http import HttpResponse 14 | from django.conf import settings 15 | from powerapp.core.app_signals import ServiceAppSignals 16 | from powerapp.core.models import Integration 17 | 18 | 19 | FAST_SYNC_INTERVAL = datetime.timedelta(seconds=10 if settings.DEBUG else 30) 20 | 21 | 22 | logger = getLogger(__name__) 23 | 24 | 25 | @csrf_exempt 26 | def accept(request): 27 | signature = request.META.get('HTTP_X_TODOIST_HMAC_SHA256') 28 | if not signature: 29 | return HttpResponse() 30 | 31 | raw_data = request.body 32 | if not request_valid(raw_data, signature): 33 | return HttpResponse() 34 | 35 | try: 36 | data = json.loads(force_text(raw_data)) 37 | except ValueError: 38 | # quietly ignore invalid JSON 39 | return HttpResponse() 40 | 41 | handle_stateful_integrations(data) 42 | handle_stateless_integrations(data) 43 | 44 | # Empty 200 OK response is enough to mark webhook as processed 45 | # on the server side 46 | return HttpResponse() 47 | 48 | 49 | def request_valid(raw_data, signature): 50 | """ 51 | Helper function to test if the request is valid 52 | """ 53 | try: 54 | raw_signature = base64.b64decode(signature) 55 | except (binascii.Error, TypeError): 56 | return False 57 | expected_signature = hmac.new(force_bytes(settings.TODOIST_CLIENT_SECRET), 58 | force_bytes(raw_data), 59 | hashlib.sha256) 60 | return expected_signature.digest() == raw_signature 61 | 62 | 63 | def handle_stateful_integrations(data): 64 | user_ids = {ev['user_id'] for ev in data} 65 | next_sync = now() + FAST_SYNC_INTERVAL 66 | Integration.objects.filter(user_id__in=user_ids, 67 | stateless=False, 68 | service_enabled=True).update(api_next_sync=next_sync) 69 | 70 | 71 | def handle_stateless_integrations(data): 72 | user_ids = {ev['user_id'] for ev in data} 73 | 74 | # 1. Get all integrations 75 | integrations = (Integration.objects.filter(user_id__in=user_ids, 76 | stateless=True, 77 | service_enabled=True) 78 | .select_related('user').order_by('user_id')) 79 | 80 | # 2. Group them by user id 81 | user_integrations = {} 82 | for user_id, integration_subset in groupby(integrations, lambda i: i.user_id): 83 | user_integrations[user_id] = list(integration_subset) 84 | 85 | # 3. Handle events one by one 86 | for ev in data: 87 | signal_name, event_data = webhook_to_django_signal(ev) 88 | if not signal_name: 89 | continue 90 | user_id = ev['user_id'] 91 | for integration in user_integrations.get(user_id, []): 92 | signal = integration.app_config.signals[signal_name] 93 | signal.fire(integration, event_data) 94 | 95 | 96 | def webhook_to_django_signal(event): 97 | """ 98 | Convert webhook name to django signal name. If None is returned, then 99 | there's no Django signal corresponding to an incoming event. 100 | """ 101 | event_name = event['event_name'] 102 | event_data = event['event_data'] 103 | try: 104 | obj, action = event_name.split(':', 1) 105 | except IndexError: 106 | return None, None 107 | # item -> task 108 | obj = obj if obj != 'item' else 'task' 109 | 110 | # Todoist server-side bug workaround 111 | if action == 'uncompleted': 112 | event_data.update({'checked': False, 'in_history': False}) 113 | 114 | # We only support added, deleted and updated events 115 | if action not in ('added', 'deleted'): 116 | action = 'updated' 117 | 118 | full_name = 'todoist_%s_%s' % (obj, action) 119 | if hasattr(ServiceAppSignals(), full_name): 120 | return full_name, event_data 121 | 122 | return None, None 123 | -------------------------------------------------------------------------------- /tests/test_sync_bridge.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | import uuid 4 | from powerapp.sync_bridge.bridge import SyncBridge, SyncAdapter, task, get_hash, \ 5 | TASK_FIELDS 6 | from powerapp.sync_bridge.models import ItemMapping 7 | 8 | 9 | class SampleAdapter(SyncAdapter): 10 | """ 11 | A adapter for testing purposes. 12 | """ 13 | DEFAULT_NAME = 'sample' 14 | 15 | def __init__(self, name=None): 16 | super(SampleAdapter, self).__init__(name) 17 | self.storage = {} 18 | 19 | def push_task(self, task_id, task, extra): 20 | task_id = task_id or self.random_id() 21 | self.storage[task_id] = task 22 | return task_id, {'foo': 'bar'} 23 | 24 | def delete_task(self, task_id, extra): 25 | self.storage.pop(task_id, None) 26 | 27 | def task_from_data(self, data, extra): 28 | return data 29 | 30 | @staticmethod 31 | def random_id(): 32 | return uuid.uuid4().hex 33 | 34 | 35 | class DumbSampleAdapter(SampleAdapter): 36 | ESSENTIAL_FIELDS = ['content'] 37 | DEFAULT_NAME = 'dumb' 38 | 39 | 40 | @pytest.fixture 41 | def td(): 42 | return SampleAdapter('todoist') 43 | 44 | @pytest.fixture 45 | def gh(): 46 | return SampleAdapter('github') 47 | 48 | @pytest.fixture 49 | def dumb(): 50 | return DumbSampleAdapter() 51 | 52 | @pytest.fixture 53 | def bridge(detached_integration, td, gh): 54 | return SyncBridge(detached_integration, td, gh) 55 | 56 | @pytest.fixture 57 | def dumb_bridge(detached_integration, td, dumb): 58 | return SyncBridge(detached_integration, td, dumb) 59 | 60 | 61 | def test_bridge_has_name(bridge): 62 | assert bridge.name == 'todoist-github' 63 | 64 | 65 | def test_adapter_knows_its_bridge(td, gh, bridge): 66 | assert td.bridge == gh.bridge == bridge 67 | 68 | 69 | def test_adapter_cant_change_its_bridge(detached_integration): 70 | a1 = SampleAdapter('a1') 71 | a2 = SampleAdapter('a2') 72 | a3 = SampleAdapter('a3') 73 | SyncBridge(detached_integration, a1, a2) 74 | with pytest.raises(RuntimeError): 75 | SyncBridge(detached_integration, a2, a3) 76 | 77 | 78 | def test_task_hashes(): 79 | essential_fields = ['content', 'tags'] 80 | t1 = task(content='A') 81 | t2 = task(content='B', tags=None) 82 | t3 = task(content='B', tags=[]) 83 | assert get_hash(t1, essential_fields) != get_hash(t2, essential_fields) 84 | assert get_hash(t2, essential_fields) == get_hash(t3, essential_fields) 85 | 86 | 87 | def test_bridge_passes_tasks_through(td, gh, bridge): 88 | # add a task to the adapter 89 | foo = task(content='foo') 90 | bridge.push_task(td, 1, foo) 91 | 92 | # check how id mapping works 93 | mapping = ItemMapping.objects.bridge_get(bridge, left_id=1) 94 | assert mapping.left_hash == get_hash(foo, essential_fields=TASK_FIELDS) 95 | assert mapping.left_id == '1' 96 | assert gh.storage[mapping.right_id] == foo 97 | 98 | 99 | def test_bridge_deletes_tasks(td, gh, bridge): 100 | # add the task 101 | foo = task(content='foo') 102 | bridge.push_task(td, 1, foo) 103 | 104 | # make sure it's there 105 | assert ItemMapping.objects.bridge_get(bridge, left_id=1).right_id in gh.storage 106 | 107 | # delete the task 108 | bridge.delete_task(td, 1) 109 | 110 | # make sure the task isn't there anymore 111 | assert gh.storage == {} 112 | 113 | 114 | def test_bridge_updates_tasks(td, gh, bridge): 115 | # add the task, and then update it 116 | bridge.push_task(td, 1, task(content='foo')) 117 | bridge.push_task(td, 1, task(content='bar')) 118 | 119 | # make sure "gh" has everything in sync 120 | gh_id = ItemMapping.objects.bridge_get(bridge, left_id=1).right_id 121 | obj = gh.storage[gh_id] 122 | assert obj.content == 'bar' 123 | 124 | 125 | def test_get_hash_essential_fields(): 126 | id2 = task(content='foo', indent=2) 127 | id3 = task(content='foo', indent=3) 128 | assert get_hash(id2, essential_fields=['content']) == get_hash(id3, essential_fields=['content']) 129 | assert get_hash(id2, essential_fields=['content', 'indent']) != get_hash(id3, essential_fields=['content', 'indent']) 130 | 131 | 132 | def test_essential_fields(td, dumb, dumb_bridge): 133 | dumb_bridge.push_task(td, 1, task(content='foo', indent=2)) 134 | mapping = ItemMapping.objects.bridge_get(dumb_bridge, left_id=1) 135 | assert mapping.left_hash != mapping.right_hash 136 | -------------------------------------------------------------------------------- /powerapp/core/models/user.py: -------------------------------------------------------------------------------- 1 | import re 2 | import pytz 3 | import datetime 4 | from django.contrib.auth.models import AbstractBaseUser 5 | from django.core.exceptions import ObjectDoesNotExist 6 | from django.db import models 7 | from django.conf import settings 8 | from django.utils.functional import cached_property 9 | from django.utils.timezone import now, make_aware 10 | from picklefield.fields import PickledObjectField 11 | from todoist.api import TodoistAPI 12 | from powerapp.core.sync import UserTodoistAPI 13 | 14 | SYNC_PERIOD = datetime.timedelta(minutes=1 if settings.DEBUG else 30) 15 | 16 | 17 | class UserManager(models.Manager): 18 | 19 | def register(self, api_token): 20 | """ Register a new user """ 21 | try: 22 | return self.get(api_token=api_token) 23 | except ObjectDoesNotExist: 24 | # Use "classic API", we don't need connection to any model 25 | api = TodoistAPI(api_token, api_endpoint=settings.API_ENDPOINT) 26 | api.user.sync() 27 | # At this point we have a locally stored user data, including API 28 | # or token. Create a new user, or update existing one 29 | data = {'email': api.user.get('email'), 'api_token': api_token} 30 | obj, _ = self.update_or_create(id=api.user.get('id'), 31 | defaults=data) 32 | return obj 33 | 34 | 35 | class User(AbstractBaseUser): 36 | 37 | # Woohoo! Millenium! 38 | MILLENIUM = make_aware(datetime.datetime(2000, 1, 1)) 39 | 40 | USERNAME_FIELD = 'id' 41 | objects = UserManager() 42 | 43 | id = models.IntegerField(primary_key=True) 44 | # note: although both email and api token are supposed to be unique, we 45 | # don't require the database uniqueness for them, because in some corner 46 | # cases, because the local database isn't always fully in sync with the 47 | # upstream, we may end up with twi users having the same email. 48 | email = models.CharField(db_index=True, max_length=255) 49 | api_token = models.CharField(db_index=True, max_length=255) 50 | 51 | # we use these data exclusively to keep track of user personal data 52 | api_state = PickledObjectField() 53 | api_last_sync = models.DateTimeField(default=MILLENIUM, db_index=True) 54 | 55 | @cached_property 56 | def api(self): 57 | """ 58 | The API object which should be used exclusively for getting user 59 | personal data, such as email, full name, avatar, etc, but not tasks or 60 | projects. 61 | 62 | For tasks and items most likely you should use integration-attached 63 | API objects 64 | """ 65 | obj = UserTodoistAPI.create(self) 66 | if self.api_last_sync < now() - SYNC_PERIOD: 67 | obj.sync(resource_types=["projects", "labels", "filters"]) 68 | obj.user.sync() 69 | return obj 70 | 71 | def reset_api(self): 72 | self.api_state = '' 73 | self.api_last_sync = self.MILLENIUM 74 | self.save() 75 | 76 | def get_timezone(self): 77 | """ 78 | Return pytz timezone object from User settings 79 | """ 80 | tzname = self.api.user.get('timezone') 81 | if tzname is None: 82 | self.api.user.sync() 83 | tzname = self.api.user.get('timezone') 84 | if not tzname: 85 | tzname = 'UTC' 86 | 87 | pytz_tzname = tzname_todoist_to_pytz(tzname) 88 | return pytz.timezone(pytz_tzname) 89 | 90 | def get_inbox_project(self): 91 | return self.api.user.get('inbox_project') 92 | 93 | class Meta: 94 | app_label = 'core' 95 | 96 | def __str__(self): 97 | return self.email 98 | 99 | def __log__(self): 100 | return { 101 | 'id': self.id, 102 | 'email': self.email, 103 | } 104 | 105 | 106 | 107 | re_todoist_static_tz = re.compile(r'GMT ([+-])(\d+):00$') 108 | 109 | 110 | def tzname_todoist_to_pytz(timezone_name): 111 | """ 112 | GMT +XX:00 has to be converted to Etc/GMT-XX (mind that we change + to - 113 | and vice versa) 114 | """ 115 | match = re_todoist_static_tz.match(timezone_name) 116 | if match: 117 | plus_minus = match.group(1) 118 | sign = {'+': '-', '-': '+'}[plus_minus] 119 | return 'Etc/GMT%s%s' % (sign, match.group(2)) 120 | else: 121 | return timezone_name 122 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PowerApp 2 | -------- 3 | PowerApp is a tool to extend the functionality of your Todoist account by 4 | integrating it with third-party applications. 5 | 6 | 7 | Quick setup instructions with Heroku 8 | ------------------------------------ 9 | 10 | The easiest way to try out the PowerApp is to install it on Heroku plartform. 11 | Heroku is a service hosting platform, and it allows you to run small 12 | applications for free. PowerApp easily fits into this category, so enjoy. 13 | 14 | Before the start you need to create some accounts here and there first. 15 | 16 | 1. Come up with a name for your installation. Requirements for the name is 17 | roughly same as requirements to domain names: lowercase latin letters with 18 | optional dashes and numbers, unique enough to avoid clashes when you create 19 | your app on Heroku. Something like `<myname>-powerapp`, where `myname` is your 20 | first name, nickname or the name of your company, could be a good start. 21 | 22 | 2. Log in with your Todoist account and create a new 23 | application in `Todoist Management Console <https://developer.todoist.com/appconsole.html>`_ 24 | "App Display Name" can be anything, and since we decided to launch the app 25 | on Heroku, the App Service URL has to be `https://<myname>-powerapp.herokuapp.com`. 26 | 27 | 3. In application settings, set up "OAuth Redirect URL". The value has to be 28 | `https://<myname>-powerapp.herokuapp.com/oauth2cb/` 29 | 30 | .. image:: powerapp/core/static/readme_app_settings.png 31 | 32 | Also, set the "Webhook callback URL". The value has to be 33 | `https://<myname>-powerapp.herokuapp.com/webhooks/accept/` 34 | 35 | Todoist part of the configuration is done, but don't close the window yet. It 36 | will be needed to copy access requisites on the next step. Let's move forward 37 | with the Heroku installation. 38 | 39 | 1. Click on a button below to launch the instant set up wizard. 40 | 41 | .. image:: https://www.herokucdn.com/deploy/button.png 42 | :alt: Deploy to Heroku 43 | :target: https://heroku.com/deploy?template=https://github.com/Doist/powerapp 44 | 45 | 2. Fill in required fields. 46 | 47 | The name 48 | 49 | .. image:: powerapp/core/static/readme_heroku_app_name.png 50 | 51 | App settings 52 | 53 | .. image:: powerapp/core/static/readme_heroku_config.png 54 | 55 | Click "Deploy for Free" button. After a while the application will be 56 | deployed and started on your account. 57 | 58 | .. image:: powerapp/core/static/readme_heroku_success.png 59 | 60 | 61 | If everything is okay, click "View", open the application on your domain and 62 | try to sign up for the first time. 63 | 64 | 65 | Current status of the application 66 | --------------------------------- 67 | 68 | The application is currently in alpha, which means that the application might 69 | not be stable enough for regular day-to-day use, and sometimes may work not 70 | as expected, therefore we encourage to test how the application works yourself. 71 | If you find some bugs or rough corners, feel free to create a bug report on 72 | GitHub, or even better, send us a pull request with a fix :) 73 | 74 | Current list of integrations 75 | ---------------------------- 76 | 77 | There's not a lot of built-in integrations at this point. We have just two of 78 | them, and they serve mostly the demo purpose to show developers how to create 79 | PowerApp services: 80 | 81 | - `Cat Comments <https://github.com/Doist/powerapp/tree/master/powerapp/contrib/catcomments>`_ 82 | is the app to boost your morale when it's low. It adds a note with a cat 83 | picture to every new task you create. 84 | - `HackerNews feed <https://github.com/Doist/powerapp/tree/master/powerapp/contrib/hackernews>`_ 85 | pools the `hackernews feed <https://news.ycombinator.com/>`_ and adds tasks 86 | with links to your Todoist account in a separate project. 87 | 88 | You don't need to install anything besides the PowerApp itself, to make them 89 | work. 90 | 91 | Third-party integrations 92 | ------------------------ 93 | 94 | Here is a list of custom integrations. They all have to be installed separately, 95 | most of them require integration with third-party services, and thus extra 96 | configuration with secret keys or something like this. Please read corresponding 97 | installation instructions. 98 | 99 | - `Pocket integration <https://github.com/Doist/powerapp-pocket>`_ 100 | 101 | If you have your own integration, and want it to be in this list, please create 102 | a pull request. 103 | -------------------------------------------------------------------------------- /powerapp/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import picklefield.fields 6 | from django.conf import settings 7 | import datetime 8 | from django.utils.timezone import utc 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='User', 19 | fields=[ 20 | ('password', models.CharField(verbose_name='password', max_length=128)), 21 | ('last_login', models.DateTimeField(null=True, blank=True, verbose_name='last login')), 22 | ('id', models.IntegerField(serialize=False, primary_key=True)), 23 | ('email', models.CharField(db_index=True, max_length=255)), 24 | ('api_token', models.CharField(db_index=True, max_length=255)), 25 | ('api_state', picklefield.fields.PickledObjectField(editable=False)), 26 | ('api_last_sync', models.DateTimeField(default=datetime.datetime(2000, 1, 1, 0, 0, tzinfo=utc), db_index=True)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name='Integration', 31 | fields=[ 32 | ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), 33 | ('name', models.CharField(max_length=1024)), 34 | ('settings', picklefield.fields.PickledObjectField(editable=False, default={})), 35 | ('stateless', models.BooleanField(default=True)), 36 | ('api_state', picklefield.fields.PickledObjectField(editable=False)), 37 | ('api_last_sync', models.DateTimeField(default=datetime.datetime(2000, 1, 1, 0, 0, tzinfo=utc))), 38 | ('api_next_sync', models.DateTimeField(default=datetime.datetime(2000, 1, 1, 0, 0, tzinfo=utc))), 39 | ], 40 | ), 41 | migrations.CreateModel( 42 | name='OAuthToken', 43 | fields=[ 44 | ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), 45 | ('client', models.CharField(verbose_name='OAuth client name', max_length=128)), 46 | ('access_token', models.CharField(verbose_name='OAuth access token', max_length=1024)), 47 | ('refresh_token', models.CharField(null=True, max_length=1024, verbose_name='OAuth refresh token')), 48 | ('token_type', models.CharField(default='Bearer', verbose_name='Token type', max_length=64)), 49 | ('access_token_expires', models.DateTimeField(default=None, null=True, verbose_name='Access token expiration date')), 50 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), 51 | ], 52 | ), 53 | migrations.CreateModel( 54 | name='PeriodicTask', 55 | fields=[ 56 | ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), 57 | ('name', models.CharField(max_length=1024)), 58 | ('next_run', models.DateTimeField(db_index=True)), 59 | ('integration', models.ForeignKey(to='core.Integration')), 60 | ], 61 | ), 62 | migrations.CreateModel( 63 | name='Service', 64 | fields=[ 65 | ('label', models.CharField(max_length=256, serialize=False, verbose_name='Service label', primary_key=True)), 66 | ('name', models.CharField(verbose_name='Service name', max_length=256)), 67 | ('path', models.CharField(verbose_name='Service path', max_length=1024)), 68 | ], 69 | ), 70 | migrations.AddField( 71 | model_name='integration', 72 | name='service', 73 | field=models.ForeignKey(to='core.Service', max_length=1024), 74 | ), 75 | migrations.AddField( 76 | model_name='integration', 77 | name='user', 78 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL), 79 | ), 80 | migrations.AlterUniqueTogether( 81 | name='periodictask', 82 | unique_together=set([('integration', 'name')]), 83 | ), 84 | migrations.AlterUniqueTogether( 85 | name='oauthtoken', 86 | unique_together=set([('user', 'client')]), 87 | ), 88 | migrations.AlterIndexTogether( 89 | name='integration', 90 | index_together=set([('user', 'service'), ('stateless', 'api_next_sync')]), 91 | ), 92 | ] 93 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | import todoist 4 | import time 5 | import requests 6 | import environ 7 | from django.conf import settings 8 | from powerapp.core.models import Integration, User 9 | 10 | 11 | env = environ.Env() 12 | TEST_NGROK_SUBDOMAIN = env('TEST_NGROK_SUBDOMAIN') 13 | TEST_NGROK_AUTH_TOKEN = env('TEST_NGROK_AUTH_TOKEN') 14 | TEST_PREMIUM_EMAIL = env('TEST_PREMIUM_EMAIL') 15 | TEST_PREMIUM_PASSWORD = env('TEST_PREMIUM_PASSWORD') 16 | 17 | 18 | @pytest.fixture(scope='session') 19 | def ngrok(live_server, xprocess): 20 | port = live_server.url.rsplit(':', 1)[-1] 21 | 22 | def preparefunc(cwd): 23 | pattern = 'Tunnel established' 24 | args = ['ngrok', 25 | '-authtoken', TEST_NGROK_AUTH_TOKEN, 26 | '-log', 'stdout', 27 | '-subdomain', TEST_NGROK_SUBDOMAIN, 28 | port] 29 | return pattern, args 30 | 31 | logfile = xprocess.ensure("ngrok", preparefunc) 32 | return logfile 33 | 34 | 35 | @pytest.yield_fixture(scope='session') 36 | def api(): 37 | """ 38 | Get a premium Todoist user and returns the API object to work with it 39 | """ 40 | # and now, follow the password grant OAuth flow to make sure webhooks work 41 | access_token = get_access_token(TEST_PREMIUM_EMAIL, TEST_PREMIUM_PASSWORD) 42 | api = TodoistAPI(access_token, api_endpoint=settings.API_ENDPOINT) 43 | api.sync(resource_types=['user', 'projects', 'items', 'notes']) 44 | 45 | yield api 46 | 47 | # delete tasks 48 | api.sync(resource_types=['user', 'projects', 'items', 'notes']) 49 | for item in api.items.all(): 50 | item.delete() 51 | 52 | # delete projects (everything except inbox) 53 | for project in api.projects.all(): 54 | if project['id'] != api.user.get('inbox_project'): 55 | project.delete() 56 | 57 | api.commit() 58 | 59 | 60 | @pytest.fixture 61 | def user(api, db): 62 | return User.objects.create(id=api.user.get('id'), 63 | email=api.user.get('email'), 64 | api_token=api.token) 65 | 66 | 67 | @pytest.fixture 68 | def catcomments_integration(api, user, catcomments_service): 69 | """ 70 | Create a "catcomments" integration 71 | """ 72 | s = {'project': api.user.get('inbox_project')} 73 | return Integration.objects.create(service=catcomments_service, 74 | name='Cat Comments', 75 | user=user, 76 | settings=s) 77 | 78 | 79 | class TodoistAPI(todoist.TodoistAPI): 80 | """ 81 | A slightly extended version of the standard API client 82 | """ 83 | DEFAULT_RESOURCE_TYPES = ['projects', 'items', 'notes'] 84 | 85 | 86 | def wait_for_update(self, timeout=15, 87 | resource_types=DEFAULT_RESOURCE_TYPES): 88 | """ 89 | Wait for the update for at least timeout minutes. 90 | 91 | The functions wait for any updates in projects, items or notes in 92 | user's Todoist account by polling it. If nothing has changed withing 93 | `timeout` minutes, it raises the RuntimeError. 94 | 95 | Upon successful exit the local state of the object is synchronized 96 | with Todoist. 97 | """ 98 | start = time.time() 99 | delay = 0.5 100 | while True: 101 | # if something has changed on the server side, the server returns 102 | # non-empty list with results, like 103 | # {..., "Projects": [..], "Items": [...]} 104 | result = self.sync(resource_types=resource_types) 105 | for value in result.values(): 106 | if isinstance(value, list) and len(value) > 0: 107 | return 108 | 109 | # timeout 110 | if time.time() - start > timeout: 111 | raise RuntimeError('No updates in %r within %s sec' % (resource_types, timeout)) 112 | 113 | # wait a bit more 114 | print('Wait %s sec...' % delay) 115 | time.sleep(delay) 116 | delay *= 2 117 | 118 | 119 | def get_access_token(email, password, scope='data:read_write,data:delete,project:delete'): 120 | """ 121 | Helper function to exchange email and password to access token 122 | """ 123 | data = { 124 | 'client_id': settings.TODOIST_CLIENT_ID, 125 | 'client_secret': settings.TODOIST_CLIENT_SECRET, 126 | 'grant_type': 'password', 127 | 'username': email, 128 | 'password': password, 129 | 'scope': scope, 130 | } 131 | url = settings.API_ENDPOINT + '/oauth/access_token' 132 | resp = requests.post(url, data=data) 133 | return resp.json()['access_token'] 134 | -------------------------------------------------------------------------------- /powerapp/core/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import datetime 4 | from collections import namedtuple 5 | from importlib import import_module 6 | from logging import getLogger 7 | 8 | from django import apps 9 | from django.conf import settings 10 | from django.conf.urls import url, include 11 | from django.utils.six import with_metaclass 12 | 13 | from powerapp.core.app_signals import ServiceAppSignals 14 | 15 | logger = getLogger(__name__) 16 | 17 | 18 | class LoadModuleMixin(object): 19 | """ 20 | A mixin for an app to load any of its submodule 21 | """ 22 | 23 | def load_module(self, name, quiet=True): 24 | """ 25 | A helper to load any app's submodule by its name 26 | """ 27 | full_name = '%s.%s' % (self.name, name) 28 | try: 29 | return import_module(full_name) 30 | except ImportError: 31 | if quiet: 32 | return None 33 | raise 34 | 35 | 36 | class AppConfig(LoadModuleMixin, apps.AppConfig): 37 | """ 38 | App Config for the powerapp.core app itself 39 | """ 40 | name = 'powerapp.core' 41 | verbose_name = 'PowerApp core application' 42 | 43 | def ready(self): 44 | # import the submodule with cron tasks 45 | self.load_module('cron') 46 | # import the submodule with signal handlers 47 | self.load_module('signals') 48 | # import the submodule with OAuth implementations 49 | self.load_module('oauth_impl') 50 | 51 | 52 | class ServiceAppConfigMeta(type): 53 | """ 54 | A metaclass to create the ServiceAppConfig. 55 | 56 | We need this for two reasons: 57 | 58 | 1. to create new objects for every signal in every subclass 59 | 2. to have a personal periodic task registry for every subclass we have 60 | """ 61 | 62 | def __new__(mcs, name, bases, attrs): 63 | attrs['signals'] = ServiceAppSignals() 64 | attrs['periodic_tasks'] = {} 65 | return type.__new__(mcs, name, bases, attrs) 66 | 67 | 68 | class ServiceAppConfig(with_metaclass(ServiceAppConfigMeta, LoadModuleMixin, apps.AppConfig)): 69 | """ 70 | Base class for the application config object of services 71 | """ 72 | #: A special flag to denote that current Django app represents a 73 | #: powerapp service 74 | service = True 75 | 76 | #: This flag has to be set to True if the application is "stateless" 77 | #: Stateless application reacts immediately on webhooks, it's easier to 78 | #: scale, but this app doesn't keep local model in sync, and you cannot 79 | #: perform queries such as "api.items.all(...)" against it. 80 | #: 81 | #: We in Todoist love stateless apps, because Sync queries are kind of 82 | #: expensive for us, so we encourage everyone to use this flag :) 83 | stateless = True 84 | 85 | #: The registry of powerapp signals. We overwrite it in metaclass anyway, 86 | #: but this way it provides hints for IDEs 87 | signals = ServiceAppSignals() 88 | 89 | #: The registry of periodic tasks. We overwrite it in metaclass as well 90 | periodic_tasks = {} 91 | """:type: dict[str,PeriodicTaskFun]""" 92 | 93 | def urlpatterns(self): 94 | """ 95 | Returns the list of URL patterns which have to be added to main urls.py 96 | 97 | By default returns a sigle URL pattern which mounts app's urls.py as 98 | under the app's label path. Most likely you don't need to edit this 99 | function. 100 | """ 101 | regex = r'^%s/' % self.label 102 | urls_module = '%s.urls' % self.name 103 | ns = self.label 104 | return [url(regex, include(urls_module, namespace=ns, app_name=ns))] 105 | 106 | def ready(self): 107 | """ 108 | A signal called by the constructor once the app instance is ready 109 | (once it's registered) 110 | """ 111 | # export app settings 112 | self.export_settings() 113 | # import the submodule with signal handlers 114 | self.load_module('signals') 115 | 116 | def export_settings(self): 117 | re_variable = re.compile(r'^[A-Z0-9_]+$') 118 | for key, value in self.__class__.__dict__.items(): 119 | if re_variable.match(key) and not hasattr(settings, key): 120 | setattr(settings, key, value) 121 | 122 | @classmethod 123 | def periodic_task(cls, delta, name=None): 124 | """ 125 | A decorator to add a periodic task. Decorated function has to accept 126 | two arguments: user and integration object 127 | """ 128 | if isinstance(delta, int): 129 | delta = datetime.timedelta(seconds=delta) 130 | 131 | def decorator(func): 132 | registry_name = name or '%s.%s' % (func.__module__, func.__name__) 133 | cls.periodic_tasks[registry_name] = PeriodicTaskFun(func, delta, registry_name) 134 | return func 135 | return decorator 136 | 137 | 138 | #: A wrapper for periodic tasks 139 | PeriodicTaskFun = namedtuple('PeriodicTaskFun', ['func', 'delta', 'name']) 140 | -------------------------------------------------------------------------------- /powerapp/core/generic_views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import messages 3 | from django.contrib.auth.decorators import login_required 4 | from django.shortcuts import render, redirect, get_object_or_404 5 | from django.utils.decorators import method_decorator 6 | from django.utils.functional import cached_property 7 | from django.views.generic import View 8 | from powerapp.core.django_forms import IntegrationForm 9 | from powerapp.core.exceptions import PowerAppInvalidTokenError 10 | from powerapp.core.models.integration import Integration 11 | from powerapp.core.models.oauth import OAuthToken 12 | 13 | 14 | class LoginRequiredMixin(object): 15 | """ 16 | A mixin to check the user is logged in 17 | """ 18 | 19 | @method_decorator(login_required) 20 | def dispatch(self, request, *args, **kwargs): 21 | self.request.current_app = self.request.resolver_match.namespace 22 | return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs) 23 | 24 | 25 | class OAuthTokenRequiredMixin(object): 26 | """ 27 | A mixin to check the user has access token 28 | 29 | To make it work, the subclass has to define access_token_client (as a 30 | string), optionally access_token_redirect. The 31 | dispatcher test the database first, and if there's no access token with 32 | the requested client, it calls the access_token_redirect() 33 | function. 34 | """ 35 | access_token_client = None 36 | 37 | def dispatch(self, request, *args, **kwargs): 38 | # shortcut: we don't need any extra verifications 39 | if not self.access_token_client: 40 | return super(OAuthTokenRequiredMixin, self).dispatch(request, *args, **kwargs) 41 | 42 | if not OAuthToken.objects.filter(user=request.user, 43 | client=self.access_token_client).exists(): 44 | return self.access_token_redirect(request) 45 | else: 46 | try: 47 | return super(OAuthTokenRequiredMixin, self).dispatch(request, *args, **kwargs) 48 | except PowerAppInvalidTokenError: 49 | # something goes wrong, the access token is invalid 50 | # clean it up and redirect back to access token form 51 | OAuthToken.objects.filter(user=request.user, 52 | client=self.access_token_client).delete() 53 | return self.access_token_redirect(request) 54 | 55 | def access_token_redirect(self, request): 56 | raise NotImplementedError('Implement "access_token_redirect" function ' 57 | 'in your subclass') 58 | 59 | 60 | 61 | class EditIntegrationView(LoginRequiredMixin, OAuthTokenRequiredMixin, View): 62 | """ 63 | A view to create and modify an integration. Subclasses of this view have to 64 | have a `form` attrubute, which has to be a subclass of the IntegrationForm, 65 | and a `service_label` attribute. 66 | """ 67 | form = None 68 | service_label = None 69 | 70 | 71 | def extra_template_context(self, request, integration): 72 | """ 73 | For subclass EditIntegrationView to override this 74 | method to provide extra context data to the template. 75 | 76 | The extra context data could be accessed via the template variable 77 | { extra_context.DICT_KEY } 78 | 79 | :return: a dictionary 80 | """ 81 | return {} 82 | 83 | def get(self, request, integration_id=None): 84 | integration = self.get_integration(request, integration_id) 85 | 86 | form = self.form_class(request, integration=integration) 87 | context = { 88 | "form": form, 89 | "extra_context": self.extra_template_context(request, integration) 90 | } 91 | return render(request, self.get_template_name(), context) 92 | 93 | def post(self, request, integration_id=None): 94 | integration = self.get_integration(request, integration_id) 95 | form = self.form_class(request, integration, data=request.POST) 96 | 97 | if form.is_valid(): 98 | integration = form.save() 99 | messages.info(request, "Integration '%s' saved" % integration.name) 100 | return self.on_save(integration) 101 | 102 | context = { 103 | "form": form, 104 | "extra_context": self.extra_template_context(request, integration) 105 | } 106 | return render(request, self.get_template_name(), context) 107 | 108 | def get_template_name(self): 109 | return '%s/edit_integration.html' % self.service_label 110 | 111 | def on_save(self, integration): 112 | return redirect('web_index') 113 | 114 | @cached_property 115 | def form_class(self): 116 | """ 117 | A wrapper around `self.form` so that you can just set up service_label, 118 | and the form will be created for you automatically 119 | """ 120 | return self.form or type('Form', (IntegrationForm, ), { 121 | 'service_label': self.service_label 122 | }) 123 | 124 | def get_integration(self, request, integration_id): 125 | if integration_id: 126 | return get_object_or_404(Integration, 127 | id=integration_id, 128 | user_id=request.user.id) 129 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from logging import getLogger 3 | from django.conf import settings 4 | from django.contrib import messages 5 | from django.contrib.auth.decorators import login_required 6 | from django.core.urlresolvers import reverse 7 | from django.http.response import HttpResponse 8 | from django.shortcuts import redirect, render, get_object_or_404 9 | from django.utils.html import strip_tags 10 | from django.views.decorators.csrf import csrf_exempt 11 | from django.views.decorators.http import require_POST 12 | from powerapp.contrib.evernote_sync.models import EvernoteAccountCache 13 | from powerapp.core import generic_views 14 | from . import forms, utils, tasks 15 | from powerapp.core.integration_utils import schedule_with_rate_limit 16 | from powerapp.core.logging_utils import ctx 17 | from powerapp.core.models.integration import Integration 18 | from powerapp.core.models.oauth import OAuthToken 19 | from powerapp.core.web_utils import build_absolute_uri 20 | 21 | 22 | logger = getLogger(__name__) 23 | 24 | 25 | class EditIntegrationView(generic_views.EditIntegrationView): 26 | service_label = 'evernote_sync' 27 | access_token_client = utils.ACCESS_TOKEN_CLIENT 28 | form = forms.IntegrationForm 29 | 30 | def access_token_redirect(self, request): 31 | return redirect('evernote_sync:authorize_evernote') 32 | 33 | 34 | @login_required 35 | def authorize_evernote(request): 36 | client = utils.get_unauthorized_evernote_client() 37 | callback_url = build_absolute_uri(reverse('evernote_sync:authorize_evernote_done')) 38 | request_token = client.get_request_token(callback_url) 39 | request.session['evernote_request_token'] = request_token 40 | context = {'auth_uri': client.get_authorize_url(request_token)} 41 | return render(request, 'evernote_sync/authorize_evernote.html', context) 42 | 43 | 44 | @login_required 45 | def authorize_evernote_done(request): 46 | 47 | request_token = request.session.pop('evernote_request_token', None) 48 | if not request_token: 49 | return redirect('evernote_sync:authorize_evernote') 50 | 51 | error = None 52 | access_token = None 53 | 54 | oauth_token = request.GET.get('oauth_token') 55 | oauth_verifier = request.GET.get('oauth_verifier') 56 | 57 | if not oauth_token: 58 | error = u'Invalid response from Evernote' 59 | elif oauth_token != request_token.get('oauth_token'): 60 | error = u'Unexpected response from Evernote' 61 | elif not oauth_verifier: 62 | error = u'Access to Evernote rejected' 63 | 64 | if not error: 65 | client = utils.get_unauthorized_evernote_client() 66 | oauth_token_secret = request_token['oauth_token_secret'] 67 | try: 68 | access_token = client.get_access_token(oauth_token, 69 | oauth_token_secret, 70 | oauth_verifier) 71 | except ValueError as e: 72 | error = strip_tags(str(e)) 73 | 74 | if error: 75 | return render(request, 'evernote_sync/authorize_evernote_done.html', 76 | {'error': error}) 77 | 78 | OAuthToken.register(request.user, utils.ACCESS_TOKEN_CLIENT, access_token) 79 | return redirect('evernote_sync:add_integration') 80 | 81 | 82 | @login_required 83 | @require_POST 84 | def sync_now(request, integration_id): 85 | integration = get_object_or_404(Integration, 86 | id=integration_id, 87 | user_id=request.user.id) 88 | 89 | subtask = tasks.sync_evernote.s(integration.id) 90 | schedule_with_rate_limit(integration.id, 'last_sync', subtask, timeout=120) 91 | 92 | messages.info(request, 'Synchronization with Evernote scheduled') 93 | return redirect('evernote_sync:edit_integration', integration.id) 94 | 95 | 96 | @csrf_exempt 97 | def accept_webhook(request, webhook_secret_key): 98 | """ 99 | Webhooks recipient, as described here: 100 | 101 | https://dev.evernote.com/doc/articles/polling_notification.php 102 | """ 103 | if settings.EVERNOTE_WEBHOOK_SECRET_KEY != webhook_secret_key: 104 | # not an Evernote request 105 | return HttpResponse() 106 | 107 | # "Webhook reasons" we react on: Evernote note created, Evernote note 108 | # updated. Everything else is quietly ignored 109 | reasons = {'create', 'update'} 110 | reason = request.GET.get('reason') 111 | if reason not in reasons: 112 | return HttpResponse() 113 | 114 | # We react on userId and notebookGuid. If we have an integration to 115 | # sync, schedule a new evernote synchronization, but make sure we don't 116 | # perform sync more often than once in a minute 117 | try: 118 | user_id = int(request.GET.get('userId')) 119 | except (TypeError, ValueError): 120 | return HttpResponse() 121 | notebook_guid = request.GET.get('notebookGuid') 122 | 123 | # most likely, it's going to be no more than one cache object, and no 124 | # more than one integration. Two or more Evernote accounts connected to 125 | # the same client, or more than one Evernote integration per account 126 | # is an exception 127 | for cache in (EvernoteAccountCache.objects 128 | .filter(evernote_user_id=user_id) 129 | .select_related('user')): 130 | for integration in cache.user.integration_set.filter(service_id='evernote_sync'): 131 | notebooks = integration.settings.get('evernote_notebooks') or [] 132 | if notebook_guid in notebooks: 133 | with ctx(integration=integration, user=integration.user): 134 | logger.debug('Received Evernote webhook. Schedule Sync') 135 | subtask = tasks.sync_evernote.s(integration.id) 136 | schedule_with_rate_limit(integration.id, 'last_sync', subtask, timeout=120) 137 | 138 | return HttpResponse() 139 | -------------------------------------------------------------------------------- /powerapp/project_static/less/style.less: -------------------------------------------------------------------------------- 1 | .td_grey { 2 | background-color: #ededed !important; } 3 | 4 | @import "lib/materialize"; 5 | 6 | body, html { 7 | height: 100%; 8 | } 9 | 10 | .muted { 11 | &:extend(.grey-text.text-darken-2); 12 | } 13 | a.muted { 14 | &:extend(.grey-text); 15 | } 16 | 17 | .powerapp-header { 18 | 19 | &:extend(.white-text, .td_grey); 20 | @height: 43px; 21 | @top-padding: 7px; 22 | 23 | height: @height; 24 | 25 | .powerapp-logo { 26 | display: block; 27 | float: left; 28 | padding-top: @top-padding; 29 | height: @height - @top-padding; 30 | line-height: @height - @top-padding; 31 | color: #555; 32 | font-size: 13px; 33 | img { 34 | vertical-align: text-bottom; 35 | margin-right: 5px; 36 | } 37 | } 38 | 39 | } 40 | 41 | .powerapp-main-plain:extend(.grey-text.text-darken-3, .valign-wrapper) { 42 | > div:extend(.valign) { 43 | width: 100%; 44 | text-align: center; 45 | } 46 | } 47 | 48 | .powerapp-main:extend(.grey-text.text-darken-3) { 49 | .powerapp-nav { 50 | float: left; 51 | padding-top: 36px; 52 | width: 195px; 53 | 54 | .left-menu { 55 | li { 56 | display: block; 57 | list-style: none; 58 | height: 42px; 59 | line-height: 42px; 60 | font-size: 10pt; 61 | padding-left: 12px; 62 | a { 63 | color: #666; 64 | } 65 | &.active { 66 | background-color: #f2f2f2; 67 | cursor: default; 68 | } 69 | } 70 | } 71 | } 72 | 73 | .powerapp-content { 74 | float: right; 75 | padding-top: 36px; 76 | min-width: 600px; 77 | 78 | .title { 79 | font-size: 18pt; 80 | } 81 | .top-action-button:extend(.waves-effect, .waves-light, .btn, .right) { 82 | } 83 | 84 | .dashboard-card:extend(.grey-text.text-darken-2) { 85 | float: left; 86 | width: 250px; 87 | padding: 10px; 88 | text-align: center; 89 | border: 1px solid #e5e5e5; 90 | margin-right: 10px; 91 | margin-bottom: 10px; 92 | 93 | p:first-of-type { 94 | margin: 0; 95 | } 96 | img { 97 | border-radius: 8px; 98 | width: 100px; 99 | height: 100px; 100 | margin-top: 40px - 10px; 101 | } 102 | .actions { 103 | margin-top: 8px; 104 | opacity: 0.3; 105 | transition: opacity 0.5s; 106 | } 107 | 108 | &:hover .actions { 109 | opacity: 1; 110 | } 111 | } 112 | 113 | 114 | .service-card:extend(.z-depth-1) { 115 | border-radius: 2px; 116 | display: flex; 117 | flex-direction: row; 118 | margin-bottom: 25px; 119 | max-width: 400px + 200px; 120 | 121 | .service-picture { 122 | text-align: center; 123 | padding: 24px 20px 20px 20px; 124 | 125 | img { 126 | border-radius: 8px; 127 | width: 100px; 128 | height: 100px; 129 | margin-bottom: 25px; 130 | } 131 | } 132 | .service-description { 133 | padding: 24px 20px 20px 20px; 134 | width: 100%; 135 | 136 | // The service name 137 | h4 { 138 | text-transform: uppercase; 139 | font-size: 20px; 140 | line-height: 20px; 141 | margin: 0; 142 | } 143 | // The link under the service 144 | p:first-of-type { 145 | margin: 0; 146 | } 147 | 148 | form { 149 | margin-top: 100px; 150 | } 151 | } 152 | } 153 | 154 | } 155 | 156 | } 157 | 158 | .powerapp-dashboard-top { 159 | margin-top: 12px; 160 | 161 | .title { 162 | padding: 0; 163 | } 164 | } 165 | 166 | .powerapp-right-menu { 167 | float: right; 168 | font-size: 13px; 169 | 170 | li { 171 | display: inline-block; 172 | margin-left: 15px; 173 | } 174 | 175 | a { 176 | color: #555; 177 | font-weight: normal; 178 | 179 | &:hover { 180 | text-decoration: underline; 181 | } 182 | } 183 | } 184 | 185 | .powerapp-container, .powerapp-main-plain { 186 | max-width: 900px; 187 | margin: auto; 188 | padding: 0 40px; 189 | } 190 | 191 | @media screen and (max-width: 870px) { 192 | .left-menu li { 193 | display: inline-block !important; 194 | padding: 0 12px !important; 195 | } 196 | 197 | .powerapp-nav, .powerapp-content { 198 | float: none !important; 199 | padding-top: 15px !important; 200 | } 201 | 202 | .powerapp-main .powerapp-content { 203 | min-width: 0 !important; 204 | } 205 | 206 | .service-card, .dashboard-card { 207 | min-width: none !important; 208 | max-width: none !important; 209 | } 210 | } 211 | 212 | @media screen and (max-width: 500px) { 213 | .service-picture img { 214 | width: 50px !important; 215 | height: 50px !important; 216 | } 217 | 218 | .powerapp-dashboard-top { 219 | display: none; 220 | } 221 | } 222 | 223 | @media screen and (max-width: 385px) { 224 | .service-picture img { 225 | width: 30px !important; 226 | height: 30px !important; 227 | } 228 | 229 | .service-picture, .service-description { 230 | padding: 24px 10px 20px !important; 231 | } 232 | 233 | .top-action-button { 234 | margin-right: 10px !important; 235 | } 236 | } 237 | 238 | @media screen and (max-width: 355px) { 239 | .powerapp-open_todoist { 240 | display: none !important; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Evernote utility functions 4 | """ 5 | import pytz 6 | from evernote.api.client import EvernoteClient 7 | from django.conf import settings 8 | from django.dispatch import Signal 9 | from django.utils.html import escape 10 | from evernote.edam.notestore.ttypes import SyncChunkFilter 11 | from powerapp.contrib.evernote_sync.models import EvernoteSyncState, \ 12 | EvernoteAccountCache 13 | from powerapp.core.exceptions import PowerAppInvalidTokenError 14 | from powerapp.core.models.oauth import OAuthToken 15 | from powerapp.core.redis_utils import get_redis 16 | 17 | evernote_note_changed = Signal(providing_args=['integration', 'note']) 18 | evernote_note_deleted = Signal(providing_args=['integration', 'guid']) 19 | 20 | 21 | ACCESS_TOKEN_CLIENT = 'evernote_sync' 22 | 23 | 24 | def get_unauthorized_evernote_client(): 25 | return EvernoteClient(sandbox=settings.EVERNOTE_SANDBOX, 26 | consumer_key=settings.EVERNOTE_CONSUMER_KEY, 27 | consumer_secret=settings.EVERNOTE_CONSUMER_SECRET) 28 | 29 | 30 | def get_evernote_client(user): 31 | """ 32 | Return the authenticated version of the Evernote client 33 | """ 34 | try: 35 | token = OAuthToken.objects.get(user=user, client=ACCESS_TOKEN_CLIENT) 36 | except OAuthToken.DoesNotExist: 37 | raise PowerAppInvalidTokenError() 38 | return EvernoteClient(token=token.access_token, sandbox=settings.EVERNOTE_SANDBOX) 39 | 40 | 41 | def get_notebooks(user): 42 | """ 43 | Returns the list of notebooks. 44 | """ 45 | cache = get_evernote_account_cache(user) 46 | cache.refresh() 47 | notebooks = cache.notebooks 48 | return sorted(notebooks, key=lambda n: n.name) 49 | 50 | 51 | def get_evernote_timezone(user): 52 | """ 53 | Return a pytz timezone object from user's Evernote settings 54 | """ 55 | cache = get_evernote_account_cache(user) 56 | cache.refresh() 57 | return pytz.timezone(cache.user_data.timezone) 58 | 59 | 60 | def get_note_url(note): 61 | """ 62 | Return evernote note URL by its guid 63 | 64 | The URL can be constructed according to 65 | https://dev.evernote.com/doc/articles/note_links.php, like 66 | https://[service]/shard/[shardId]/nl/[userId]/[noteGuid]/ , but we prefer 67 | to have more user-friendly URLs like 68 | https://[service]/Home.action?#n=[noteGuid] 69 | """ 70 | client = get_unauthorized_evernote_client() 71 | return 'https://%s/Home.action?#n=%s' % (client.service_host, note.guid) 72 | 73 | 74 | def get_evernote_account_cache(user): 75 | """ 76 | Get or create the evernote account cache 77 | """ 78 | obj, created = EvernoteAccountCache.objects.get_or_create(user=user) 79 | return obj 80 | 81 | 82 | def format_note_content(content): 83 | return """<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"> 84 | <en-note>%s</en-note> 85 | """ % escape(content) 86 | 87 | 88 | def sync_evernote(integration): 89 | """ 90 | Sync evernote project with Todoist 91 | 92 | The function asks for new changes in Evernote account and generates 93 | `evernote_note_changed` and `evernote_note_deleted` events (handled in 94 | signals.py). It keeps the "last sync" state in the EvernoteSyncState object 95 | """ 96 | lock_key = 'utils-sync-evernote-%s' % integration.id 97 | lock_timeout = 5 * 60 98 | blocking_timeout = 60 99 | 100 | with get_redis().lock(lock_key, timeout=lock_timeout, 101 | blocking_timeout=blocking_timeout): 102 | local_sync_state, _ = EvernoteSyncState.objects.get_or_create(integration=integration) 103 | 104 | last_update_count, last_sync_time = _do_sync(integration, 105 | local_sync_state.last_update_count, 106 | local_sync_state.last_sync_time) 107 | 108 | local_sync_state.last_update_count = last_update_count 109 | local_sync_state.last_sync_time = last_sync_time 110 | local_sync_state.save() 111 | 112 | 113 | def sync_evernote_projects(integration, notebook_guids): 114 | """ 115 | Sync evernote projects by guids. The function is called whenever a user 116 | new projects to synchronization. 117 | 118 | We perform the synchronization "from scratch" and never save the updated 119 | value for last_update_count and last_sync_time 120 | """ 121 | _do_sync(integration, 0, 0, notebook_guids) 122 | 123 | 124 | def _do_sync(integration, last_update_count, last_sync_time, notebook_guids=None): 125 | """ 126 | Low-level funtion performing sync operations. If notebook_guids is not None, 127 | launch note_add events only for notebooks with given guids 128 | 129 | Returns new values for last_update_count and last_sync_time 130 | """ 131 | client = get_evernote_client(integration.user) 132 | note_store = client.get_note_store() 133 | sync_state = note_store.getSyncState() 134 | 135 | if notebook_guids is not None: 136 | notebook_guids = set(notebook_guids) 137 | 138 | # nothing to update? 139 | if last_update_count == sync_state.updateCount: 140 | return last_update_count, last_sync_time 141 | 142 | # do we need a full sync? 143 | if sync_state.fullSyncBefore > last_sync_time: 144 | last_update_count = 0 145 | 146 | max_entries = 1000 147 | 148 | sync_filter = SyncChunkFilter() 149 | sync_filter.includeNotes = True 150 | sync_filter.includeNoteAttributes = True 151 | sync_filter.includeNotebooks = True 152 | sync_filter.includeExpunged = True 153 | 154 | while True: 155 | chunk = note_store.getFilteredSyncChunk(last_update_count, 156 | max_entries, 157 | sync_filter) 158 | 159 | for note in (chunk.notes or []): 160 | if notebook_guids is None or note.notebookGuid in notebook_guids: 161 | if note.deleted is not None: 162 | evernote_note_deleted.send(None, integration=integration, guid=note.guid) 163 | else: 164 | evernote_note_changed.send(None, integration=integration, note=note) 165 | 166 | for guid in (chunk.expungedNotes or []): 167 | evernote_note_deleted.send(None, integration=integration, guid=guid) 168 | 169 | last_update_count = chunk.chunkHighUSN 170 | last_sync_time = chunk.currentTime 171 | 172 | if chunk.chunkHighUSN == chunk.updateCount: 173 | return last_update_count, last_sync_time 174 | -------------------------------------------------------------------------------- /powerapp/contrib/evernote_sync/sync_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import time 4 | import datetime 5 | import pytz 6 | from logging import getLogger 7 | from evernote.edam.error.ttypes import EDAMNotFoundException 8 | import evernote.edam.type.ttypes as Types 9 | from powerapp.core.todoist_utils import plaintext_content 10 | from powerapp.sync_bridge.bridge import SyncAdapter, SyncBridge, task, undefined 11 | from powerapp.sync_bridge.models import ItemMapping 12 | from powerapp.sync_bridge.todoist_sync_adapter import TodoistSyncAdapter 13 | from . import utils 14 | 15 | 16 | EPOCH = datetime.datetime(1970,1,1) 17 | REMINDER_BASE_TIME = 946684800 # millenium 18 | 19 | 20 | logger = getLogger(__name__) 21 | 22 | 23 | def build_bridge(integration, project_id, notebook_guid): 24 | td = TodoistSyncAdapter(project_id) 25 | ev = EvernoteSyncAdapter(notebook_guid) 26 | return SyncBridge(integration, td, ev) 27 | 28 | 29 | def get_bridge_by_guid(integration, guid): 30 | """ 31 | Return SyncBridge instance (or None) by task GUID 32 | 33 | We need this function, because there is no way to identify the notebook 34 | guid by item id, and so we're forced to use this dirty workaround 35 | 36 | Note that here we rely on the fact that the bridge has a predictable 37 | structure (todoist <--> evernote) and sides of the bridge have predictable 38 | names containing id of the project and the guid of the notebook coresondingly 39 | """ 40 | mapping = ItemMapping.objects.filter(right_id=guid, integration=integration).order_by('-id').first() 41 | if not mapping: 42 | return None 43 | 44 | match = re.compile(r'^todoist-(\d+)-evernote-(.*)$').match(mapping.bridge_name) 45 | if not match: 46 | return None 47 | 48 | return build_bridge(integration, match.group(1), match.group(2)) 49 | 50 | 51 | class EvernoteSyncAdapter(SyncAdapter): 52 | """ 53 | Sync Adapter for Todoist. Used to sync data between the chosen project in 54 | Todoist, and a third-party library. 55 | 56 | Does not support syncing of tags yet 57 | """ 58 | DEFAULT_NAME = 'evernote' 59 | ESSENTIAL_FIELDS = ['content', 'item_order', 'checked', 60 | 'due_date', 'date_string'] 61 | 62 | def __init__(self, notebook_guid): 63 | name = '%s-%s' % (self.DEFAULT_NAME, notebook_guid) 64 | super(EvernoteSyncAdapter, self).__init__(name=name) 65 | self.notebook_guid = notebook_guid 66 | 67 | def push_task(self, task_id, task, extra): 68 | """ 69 | Push task from Todoist to Evernote and save extra information in the 70 | "extra" field 71 | """ 72 | client = utils.get_evernote_client(self.user) 73 | note_store = client.get_note_store() 74 | 75 | if task_id: 76 | try: 77 | note = note_store.getNote(task_id, True, True, False, False) 78 | create = False 79 | except EDAMNotFoundException: 80 | note = Types.Note() 81 | create = True 82 | else: 83 | note = Types.Note() 84 | create = True 85 | 86 | note.title = plaintext_content(task.content, cut_url_pattern='evernote.com') 87 | if create: 88 | note.content = utils.format_note_content('') 89 | note.notebookGuid = self.notebook_guid 90 | 91 | note.attributes = Types.NoteAttributes() 92 | note.attributes.reminderOrder = REMINDER_BASE_TIME - task.item_order 93 | 94 | if task.checked: 95 | note.attributes.reminderDoneTime = int(time.time()) 96 | else: 97 | note.attributes.reminderDoneTime = None 98 | 99 | if task.due_date: 100 | due_date_ms = int((task.due_date - EPOCH).total_seconds()) * 1000 101 | note.attributes.reminderTime = due_date_ms 102 | else: 103 | note.attributes.reminderTime = None 104 | 105 | if create: 106 | note = note_store.createNote(note) 107 | else: 108 | note = note_store.updateNote(note) 109 | 110 | new_extra = { 111 | 'original_content': task.content, 112 | 'original_due_date': task.due_date, 113 | 'original_date_string': task.date_string, 114 | } 115 | 116 | return note.guid, new_extra 117 | 118 | def delete_task(self, task_id, extra): 119 | client = utils.get_evernote_client(self.user) 120 | note_store = client.get_note_store() 121 | try: 122 | note = note_store.getNote(task_id, True, True, False, False) 123 | except EDAMNotFoundException: 124 | return 125 | note.attributes.reminderTime = None 126 | note.attributes.reminderOrder = None 127 | note.attributes.reminderDoneTime = None 128 | note_store.updateNote(note) 129 | 130 | def task_from_data(self, data, extra): 131 | """ 132 | Take an evernote Note and return a new generic task. data is an 133 | evernote Note instance 134 | 135 | If evernote note doesn't have to be synchronized with Todoist, 136 | return None. 137 | """ 138 | if not data.attributes.reminderOrder: 139 | return None 140 | 141 | note_url = utils.get_note_url(data) 142 | content = '%s (%s)' % (note_url, data.title) 143 | 144 | item_order = REMINDER_BASE_TIME - data.attributes.reminderOrder 145 | if item_order < 1: 146 | item_order = 1 147 | 148 | if data.attributes.reminderTime is None: 149 | due_date = None 150 | date_string = None 151 | else: 152 | user = self.bridge.integration.user 153 | user_timezone = user.get_timezone() 154 | 155 | due_date = datetime.datetime.fromtimestamp(data.attributes.reminderTime / 1000) 156 | local_due_date = user_timezone.normalize(pytz.utc.localize(due_date)) 157 | date_string = local_due_date.strftime('%d %b %Y at %H:%M') 158 | 159 | logger.debug('Timezone dance. Convert %s from UTC to %s, get %s', 160 | data.attributes.reminderTime, 161 | user_timezone.zone, 162 | date_string) 163 | 164 | # we don't want to overwrite "Todoist rich date strings" 165 | original_due_date = extra.get('original_due_date') 166 | if due_date == original_due_date: 167 | logger.debug('Due date was not changed. Don\'t update the date') 168 | date_string = undefined 169 | due_date = undefined 170 | else: 171 | logger.debug('Due date changed from %s to %s. Update the date' % (original_due_date, due_date)) 172 | 173 | return task(checked=data.attributes.reminderDoneTime is not None, 174 | content=content, item_order=item_order, due_date=due_date, 175 | date_string=date_string) 176 | -------------------------------------------------------------------------------- /powerapp/contrib/gcal_sync/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Google Calendar utility functions 4 | """ 5 | import json 6 | import uuid 7 | from logging import getLogger 8 | from django.core.urlresolvers import reverse 9 | from django.dispatch.dispatcher import Signal 10 | from django.utils.crypto import salted_hmac, constant_time_compare 11 | from django.conf import settings 12 | from powerapp.core import oauth 13 | from . import oauth_impl 14 | from powerapp.core.exceptions import PowerAppError 15 | from powerapp.core.web_utils import build_absolute_uri 16 | from django.utils.six.moves.urllib import parse 17 | 18 | 19 | logger = getLogger(__name__) 20 | CALENDAR_SUMMARY = 'Todoist Playground' if settings.DEBUG else 'Todoist' 21 | WEBHOOK_HMAC_SALT = 'gcal-webhooks' 22 | 23 | 24 | gcal_event_changed = Signal(providing_args=['integration', 'event']) 25 | gcal_event_deleted = Signal(providing_args=['integration', 'event_id']) 26 | 27 | 28 | def get_authorized_client(user): 29 | """ 30 | Return the Authorized requests session object 31 | """ 32 | client = oauth.get_client_by_name(oauth_impl.OAUTH_CLIENT_NAME) 33 | return client.get_oauth2_session(user) 34 | 35 | 36 | def get_or_create_todoist_calendar(integration): 37 | client = get_authorized_client(integration.user) 38 | resp = json_get(client, '/users/me/calendarList') 39 | 40 | my_calendars = [c for c in resp['items'] if c['accessRole'] == 'owner'] 41 | 42 | calendars_by_id = {c['id']: c for c in my_calendars} 43 | calendars_by_summary = {c['summary']: c for c in my_calendars} 44 | calendar_id = integration.settings.get('calendar_id') 45 | 46 | # find calendar by id 47 | if calendar_id and calendar_id in calendars_by_id.keys(): 48 | calendar = calendars_by_id[calendar_id] 49 | logger.debug('Found existing calendar %r', calendar) 50 | 51 | # find and save calendar by summary 52 | elif CALENDAR_SUMMARY in calendars_by_summary: 53 | calendar = calendars_by_summary[CALENDAR_SUMMARY] 54 | integration.update_settings(calendar_id=calendar['id']) 55 | logger.debug('Found existing calendar by name: %r', calendar) 56 | 57 | # or just create a new one 58 | else: 59 | calendar = json_post(client, '/calendars', summary=CALENDAR_SUMMARY) 60 | integration.update_settings(calendar_id=calendar['id']) 61 | logger.debug('Created a new calendar: %r', calendar) 62 | 63 | return calendar 64 | 65 | 66 | def subscribe_to_todoist_calendar(integration, calendar): 67 | """ 68 | Subscribe for all events from the calendar 69 | 70 | See https://developers.google.com/google-apps/calendar/v3/push?hl=en_US for 71 | more details. 72 | """ 73 | if 'channel_id' in integration.settings: 74 | channel_id = integration.settings['channel_id'] 75 | logger.debug('We have cal. channel %s. Skip subscription', channel_id) 76 | return channel_id 77 | 78 | client = get_authorized_client(integration.user) 79 | channel_id = str(uuid.uuid4()) 80 | webhook_url = build_absolute_uri( 81 | reverse('gcal_sync:accept_webhook', args=(integration.id, )) 82 | ) 83 | token = create_webhook_token(integration) 84 | resp = json_post(client, '/calendars/%s/events/watch' % calendar['id'], 85 | id=channel_id, type='web_hook', address=webhook_url, token=token) 86 | integration.update_settings(channel_id=channel_id) 87 | logger.debug('Create cal. channel %s. Server replied %s', channel_id, resp) 88 | return channel_id 89 | 90 | 91 | def create_webhook_token(integration): 92 | """ 93 | Create a signed dict with integration_id (i) and user_id (u) attributes 94 | """ 95 | qs = {'i': integration.id, 'u': integration.user_id} 96 | string = parse.urlencode(qs) 97 | token = salted_hmac(WEBHOOK_HMAC_SALT, string).hexdigest() 98 | return '%s?%s' % (token, string) 99 | 100 | 101 | def validate_webhook_token(string): 102 | """ 103 | Verifies the webhook token, andn raises "Invalid webhook token" exception, 104 | or return {u: <user_id>, i: <integration_id>} dict 105 | """ 106 | token, qs = parse.splitquery(string) 107 | expected_token = salted_hmac(WEBHOOK_HMAC_SALT, qs or '').hexdigest() 108 | if not constant_time_compare(token, expected_token): 109 | raise PowerAppError('Invalid Webhook token') 110 | return dict(parse.parse_qsl(qs)) 111 | 112 | 113 | def sync_gcal(integration): 114 | """ 115 | Sync Google Calendars with Todoist 116 | """ 117 | sync_token = integration.settings.get('sync_token', '') 118 | page_token = '' 119 | calendar_id = integration.settings.get('calendar_id') 120 | url = 'https://www.googleapis.com/calendar/v3/calendars/%s/events' % calendar_id 121 | client = get_authorized_client(integration.user) 122 | 123 | for _ in range(1000): # instead of "while True:" 124 | params = {} 125 | if sync_token: 126 | params['syncToken'] = sync_token 127 | if page_token: 128 | params['pageToken'] = page_token 129 | 130 | resp = client.get(url, params=params) 131 | if resp.status_code == 410: 132 | sync_token = '' 133 | page_token = '' 134 | continue 135 | 136 | resp.raise_for_status() 137 | json_resp = resp.json() 138 | 139 | page_token = json_resp.get('nextPageToken') 140 | sync_token = json_resp.get('nextSyncToken') 141 | 142 | for gcal_event in json_resp['items']: 143 | if gcal_event['status'] == 'cancelled': 144 | gcal_event_deleted.send(None, integration=integration, 145 | event_id=gcal_event['id']) 146 | else: 147 | gcal_event_changed.send(None, integration=integration, 148 | event=gcal_event) 149 | 150 | if not page_token: 151 | integration.update_settings(sync_token=sync_token) 152 | return 153 | 154 | 155 | def json_get(client, url, **params): 156 | resp = client.get('https://www.googleapis.com/calendar/v3' + url, 157 | params=params) 158 | resp.raise_for_status() 159 | return resp.json() 160 | 161 | 162 | def json_delete(client, url, **params): 163 | resp = client.delete('https://www.googleapis.com/calendar/v3' + url, 164 | params=params) 165 | resp.raise_for_status() 166 | 167 | 168 | def json_post(client, url, **data): 169 | headers = {'Content-type': 'application/json'} 170 | resp = client.post('https://www.googleapis.com/calendar/v3' + url, 171 | data=json.dumps(data), 172 | headers=headers) 173 | resp.raise_for_status() 174 | return resp.json() 175 | 176 | 177 | def json_put(client, url, **data): 178 | headers = {'Content-type': 'application/json'} 179 | resp = client.put('https://www.googleapis.com/calendar/v3' + url, 180 | data=json.dumps(data), 181 | headers=headers) 182 | resp.raise_for_status() 183 | return resp.json() 184 | --------------------------------------------------------------------------------