From 722872e66375f8bc288205b52c91ab04a2b1e1f7 Mon Sep 17 00:00:00 2001 From: Matt Bogosian Date: Tue, 13 Feb 2018 21:31:42 -0800 Subject: [PATCH] Initial implementation --- .gitignore | 4 + .travis.yml | 21 + MANIFEST.in | 2 + README.rst | 52 +- _skel/main.py | 171 ----- docs/Makefile | 2 +- docs/conf.py | 3 + docs/contrib.rst | 8 +- docs/index.rst | 10 +- docs/intro.rst | 109 ++- {_skel => emojiwatch}/__init__.py | 11 +- emojiwatch/admin.py | 58 ++ emojiwatch/migrations/0001_initial.py | 30 + emojiwatch/migrations/__init__.py | 0 emojiwatch/models.py | 156 +++++ .../static/emojiwatch/images/emojiwatch.png | Bin 0 -> 30688 bytes tests/test_main.py => emojiwatch/urls.py | 34 +- {_skel => emojiwatch}/version.py | 0 emojiwatch/views.py | 318 +++++++++ setup.py | 26 +- tests/__init__.py | 35 +- tests/django_settings.py | 124 ++++ tests/django_urls.py | 43 ++ tests/requirements.txt | 3 +- tests/test_meta.py | 4 - tests/test_models.py | 154 +++++ tests/test_views.py | 644 ++++++++++++++++++ tox.ini | 39 +- 28 files changed, 1785 insertions(+), 276 deletions(-) delete mode 100644 _skel/main.py rename {_skel => emojiwatch}/__init__.py (80%) create mode 100644 emojiwatch/admin.py create mode 100644 emojiwatch/migrations/0001_initial.py create mode 100644 emojiwatch/migrations/__init__.py create mode 100644 emojiwatch/models.py create mode 100644 emojiwatch/static/emojiwatch/images/emojiwatch.png rename tests/test_main.py => emojiwatch/urls.py (67%) mode change 100755 => 100644 rename {_skel => emojiwatch}/version.py (100%) create mode 100644 emojiwatch/views.py create mode 100644 tests/django_settings.py create mode 100644 tests/django_urls.py create mode 100755 tests/test_models.py create mode 100755 tests/test_views.py diff --git a/.gitignore b/.gitignore index e41414d..af3e911 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,12 @@ /.tox/ /build/ /dist/ +/django-test.db /docs/_build/ /docs/_static/ +/docs/emojiwatch.*.rst +/docs/emojiwatch.rst +/docs/modules.rst /htmlcov/ __pycache__/ flycheck_*.py diff --git a/.travis.yml b/.travis.yml index edb53f1..85e4f3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,12 @@ python: - "3.6" - "pypy3" +env: + - DJANGO="1.8-lts" + - DJANGO="1.11-lts" + - DJANGO="2" + - DJANGO="dev" + install: - pip install coveralls tox-travis @@ -27,6 +33,21 @@ matrix: # PyPy3 is experimental - python: "pypy3" + # Django dev is experimental + - env: DJANGO="dev" + + exclude: + - python: "2.7" + env: DJANGO="2" + - python: "2.7" + env: DJANGO="dev" + - python: "pypy" + env: DJANGO="2" + - python: "pypy" + env: DJANGO="dev" + - python: "3.4" + env: DJANGO="dev" + script: - tox diff --git a/MANIFEST.in b/MANIFEST.in index 6e3ee85..f89dcd3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -12,3 +12,5 @@ include \ CREDITS \ LICENSE \ tests/requirements.txt + +recursive-include emojiwatch/static * diff --git a/README.rst b/README.rst index ca267d3..d960e35 100644 --- a/README.rst +++ b/README.rst @@ -16,12 +16,12 @@ If those files are missing or appear to be modified from their originals, then p .. |CREDITS| replace:: ``CREDITS`` .. _`CREDITS`: CREDITS -.. image:: https://travis-ci.org/posita/_skel.svg?branch=master - :target: https://travis-ci.org/posita/_skel?branch=master +.. image:: https://travis-ci.org/posita/django-emojiwatch.svg?branch=master + :target: https://travis-ci.org/posita/django-emojiwatch?branch=master :alt: [Build Status] -.. image:: https://coveralls.io/repos/posita/_skel/badge.svg?branch=master - :target: https://coveralls.io/r/posita/_skel?branch=master +.. image:: https://coveralls.io/repos/posita/django-emojiwatch/badge.svg?branch=master + :target: https://coveralls.io/r/posita/django-emojiwatch?branch=master :alt: [Coverage Status] Curious about integrating your project with the above services? @@ -30,50 +30,48 @@ Jeff Knupp (|@jeffknupp|_) `describes how `_. See the |LICENSE|_ file for details. -Source code is `available on GitHub `__. -See `the docs `__ for more information. +Source code is `available on GitHub `__. +See `the docs `__ for more information. -Examples --------- +Use +--- -.. TODO - -Coming soon. +See `the introduction `__. Issues ------ -If you find a bug, or want a feature, please `file an issue `__ (if it hasn't already been filed). -If you're willing and able, consider `contributing `__. +If you find a bug, or want a feature, please `file an issue `__ (if it hasn't already been filed). +If you're willing and able, consider `contributing `__. diff --git a/_skel/main.py b/_skel/main.py deleted file mode 100644 index 4c3c737..0000000 --- a/_skel/main.py +++ /dev/null @@ -1,171 +0,0 @@ -# -*- encoding: utf-8; test-case-name: tests.test_main -*- -# ====================================================================== -""" -Copyright and other protections apply. Please see the accompanying -:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights -and restrictions governing use of this software. All rights not -expressly waived or licensed are reserved. If those files are missing or -appear to be modified from their originals, then please contact the -author before viewing or using this software in any capacity. -""" -# ====================================================================== - -from __future__ import absolute_import, division, print_function - -TYPE_CHECKING = False # from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression - -from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import -from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import - -# ---- Imports --------------------------------------------------------- - -import argparse -import logging -import os -import sys - -from .version import __release__ - -# ---- Data ------------------------------------------------------------ - -__all__ = () - -_LOGGER = logging.getLogger(__name__) - -_LOG_FMT_ENV = 'LOG_FMT' -_LOG_FMT_DFLT = '%(message)s' -_LOG_LVL_ENV = 'LOG_LVL' -_LOG_LVL_DFLT = logging.getLevelName(logging.WARNING) - -# ---- Exceptions ------------------------------------------------------ - -# ---- Decorators ------------------------------------------------------ - -# ---- Classes --------------------------------------------------------- - -# ====================================================================== -class Template(object): - """ - TODO - """ - - __slots__ = ( - '_todo', - ) - - # ---- Data -------------------------------------------------------- - - # ---- Static methods ---------------------------------------------- - - # ---- Class methods ----------------------------------------------- - - # ---- Inner classes ----------------------------------------------- - - # ---- Constructor ------------------------------------------------- - - def __init__( - self, - todo=None, # type: typing.Any - ): # type: (...) -> None - """ - :param todo: the todo to associate with this object - """ - super().__init__() # type: ignore # py2 - self._todo = todo - - # ---- Overrides --------------------------------------------------- - - def todohook(self): - # type: (...) -> None - """ - Hook method to TODO. - - >>> True is True and False is False - True - """ - - # ---- Properties -------------------------------------------------- - - @property - def todo(self): - # type: (...) -> typing.Any - """ - The todo associated with this object. - """ - return self._todo - - @todo.setter - def todo( - self, - todo, # type: typing.Any - ): # type: (...) -> None - self._todo = todo - - # ---- Methods ----------------------------------------------------- - -# ---- Functions ------------------------------------------------------- - -# ====================================================================== -def configlogging(): - # type: (...) -> None - log_lvl_name = os.environ.get(_LOG_LVL_ENV) or _LOG_LVL_DFLT - - try: - log_lvl = int(log_lvl_name, 0) - except (TypeError, ValueError): - log_lvl = 0 - log_lvl = logging.getLevelName(log_lvl_name) # type: ignore - - log_fmt = os.environ.get(_LOG_FMT_ENV, _LOG_FMT_DFLT) - logging.basicConfig(format=log_fmt) - logging.getLogger().setLevel(log_lvl) - from . import LOGGER - LOGGER.setLevel(log_lvl) - -# ====================================================================== -def main(): - # type: (...) -> None - configlogging() - sys.exit(_main()) - -# ====================================================================== -def _main( - argv=None, # type: typing.Optional[typing.Sequence[typing.Text]] -): # type: (...) -> int - parser = _parser() - ns = parser.parse_args(argv) - - assert ns - - return 0 - -# ====================================================================== -def _parser( - prog=None, # type: typing.Optional[typing.Text] -): # type: (...) -> argparse.ArgumentParser - description = u""" -TODO -""".strip() - - log_lvls = u', '.join(u'"{}"'.format(logging.getLevelName(l)) for l in (logging.CRITICAL, logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG)) - epilog = u""" -The environment variables {log_lvl} and {log_fmt} can be used to configure logging output. -If set, {log_lvl} must be an integer, or one of (from least to most verbose): {log_lvls}. -It defaults to "{log_lvl_dflt}". -If set, {log_fmt} must be a logging format compatible with Python's ``logging`` module. -It defaults to "{log_fmt_dflt}". -""".strip().format(log_fmt=_LOG_FMT_ENV, log_fmt_dflt=_LOG_FMT_DFLT, log_lvl=_LOG_LVL_ENV, log_lvl_dflt=_LOG_LVL_DFLT, log_lvls=log_lvls) - - parser = argparse.ArgumentParser(prog=prog, description=description, epilog=epilog) - - parser.add_argument(u'-V', u'--version', action='version', version=u'%(prog)s {}'.format(__release__)) - - return parser - -# ---- Initialization -------------------------------------------------- - -if __name__ == '__main__': - pass diff --git a/docs/Makefile b/docs/Makefile index abadc58..8bf4cae 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -4,7 +4,7 @@ # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build -SPHINXPROJ = _skel +SPHINXPROJ = django-emojiwatch SOURCEDIR = . BUILDDIR = _build diff --git a/docs/conf.py b/docs/conf.py index 3d918ec..fe06530 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,9 @@ # _setup = {} # execfile(os.path.join(os.getcwd(), os.path.pardir, 'setup.py'), _setup, _setup) # SETUP_ARGS = _setup['SETUP_ARGS'] +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.django_settings') +import django +django.setup() # -- General configuration ------------------------------------------------ diff --git a/docs/contrib.rst b/docs/contrib.rst index ddb408e..49e0b82 100644 --- a/docs/contrib.rst +++ b/docs/contrib.rst @@ -15,15 +15,15 @@ Please see the accompanying :doc:`LICENSE ` and :doc:`CREDITS All rights not expressly waived or licensed are reserved. If those files are missing or appear to be modified from their originals, then please contact the author before viewing or using this software in any capacity. -Contributing to ``_skel`` -========================= +Contributing to ``django-emojiwatch`` +===================================== There are several ways you can contribute. Filing Issues ------------- -You can `file new issues `__ as you find them. +You can `file new issues `__ as you find them. Please avoid duplicating issues. These may be helpful: @@ -33,7 +33,7 @@ These may be helpful: Submission Guidelines --------------------- -If you're willing and able, consider `submitting a pull request `__ (PR) with a fix. +If you're willing and able, consider `submitting a pull request `__ (PR) with a fix. There are only a few guidelines: * If it isn't already there, please add your name (and optionally your GitHub username, email, website address, or other contact information) to the :doc:`CREDITS ` file: diff --git a/docs/index.rst b/docs/index.rst index 2604fdd..ddecca0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,10 +11,10 @@ Please see the accompanying :doc:`LICENSE ` and :doc:`CREDITS All rights not expressly waived or licensed are reserved. If those files are missing or appear to be modified from their originals, then please contact the author before viewing or using this software in any capacity. -``_skel`` - Python Project Skeleton -=================================== +``django-emojiwatch`` +===================== -``_skel`` is a project skeleton for Python. +``django-emojiwatch`` is a bare bones Slack app for posting custom emoji updates to a designated channel. It is licensed under the `MIT License `_. See the :doc:`LICENSE ` file for details. @@ -25,9 +25,7 @@ Contents :maxdepth: 3 intro + modules contrib LICENSE CREDITS - -.. Add "modules" to the above list (usually before "contrib") if enabling - the ``sphinx-apidoc ...`` command in ``tox.ini`` diff --git a/docs/intro.rst b/docs/intro.rst index 9edefcc..a6d3914 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -18,52 +18,131 @@ If those files are missing or appear to be modified from their originals, then p Introduction ============ -``_skel`` is a project skeleton for Python. +``django-emojiwatch`` is a bare bones Slack app for posting custom emoji updates to a designated channel. +It is implemented as a Django app. +It was loosely inspired by Khan Academy's |emojiwatch|_, which provides similar functionality, but for hosting on on Google App Engine. + +.. |emojiwatch| replace:: ``emojiwatch`` +.. _`emojiwatch`: https://github.com/Khan/emojiwatch License ------- -``_skel`` is licensed under the `MIT License `_. +``django-emojiwatch`` is licensed under the `MIT License `_. See the :doc:`LICENSE ` file for details. -Source code is `available on GitHub `__. +Source code is `available on GitHub `__. Installation ------------ -This project is not meant to be installed as is, but rather cloned and then modified as necessary. -It is intended that derived projects allow installation via ``pip``. +Django +~~~~~~ -Installation can be performed via ``pip`` (which will download and install the `latest release `__): +Installation can be performed via ``pip`` (which will download and install the `latest release `__): .. code-block:: console - % pip install _skel + % pip install django-emojiwatch ... -Alternately, you can download the sources (e.g., `from GitHub `__) and run ``setup.py``: +Alternately, you can download the sources (e.g., `from GitHub `__) and run ``setup.py``: .. code-block:: console - % git clone https://github.com/posita/_skel + % git clone https://github.com/posita/django-emojiwatch ... - % cd _skel + % cd django-emojiwatch % python setup.py install ... +Now you can add it to your ``DJANGO_SETTINGS_MODULE``: + +.. code-block:: python + + INSTALLED_APPS = ( + # ... + 'emojiwatch', + ) + + EMOJIWATCH = { + 'slack_verification_token': '...', + } + +And add it to your site-wide URLs: + +.. code-block:: python + + from django.conf.urls import include, url + + urlpatterns = ( + # ... + url( + r'^emojiwatch/', # or werever you want + include('emojiwatch.urls'), + ), + # ... + ) + +If you haven't already, you'll also need to `enable the admin site `__ for your Django installation. + +Configuring Token Encryption in Django's Database ++++++++++++++++++++++++++++++++++++++++++++++++++ + +By default, workspace IDs will be encrypted using a hash of the ``SECRET_KEY`` Django setting. +To override this, use the ``FERNET_KEYS`` setting. For example: + +.. code-block:: python + + from os import environ + SECRET_KEY = environ['SECRET_KEY'] + # Use only Base64-encoded 32 byte values for keys; don't derive them + # from arbitrary strings + FERNET_USE_HKDF = False + # For supporting any legacy keys that were used when FERNET_USE_HKDF + # was True + from fernet_fields.hkdf import derive_fernet_key + # The keys + FERNET_KEYS = [ + # The first entry is the current key (for encrypting and + # decrypting) + environ['FERNET_KEY'], + # Optional additional entries are older keys for decrypting only + # environ['OLD_FERNET_KEY_1'], + # Equivalent to the default key + # derive_fernet_key(SECRET_KEY), + ] + +See `the docs `__ for details. + +Slack App Setup +~~~~~~~~~~~~~~~ + +For illustration, we'll create a `workspace-based Slack app `__, but we could just as easily use a traditional one. + +TODO: Finish this section. + Requirements ------------ +You'll a Slack account (and admin approval) for setting up your app. A modern version of Python is required: -* `cPython `_ (2.7 or 3.3+) - -* `PyPy `_ (Python 2.7 or 3.3+ compatible) +* `cPython `_ (2.7 or 3.4+) -Python 2.6 will *not* work. +* `PyPy `_ (Python 2.7 or 3.4+ compatible) -``_skel`` has the following dependencies (which will be installed automatically): +``django-emojiwatch`` has the following dependencies (which will be installed automatically): +* |Django|_ (1.8 or higher) +* |django-fernet-fields|_ * |future|_ +* |slacker|_ +.. |Django| replace:: ``Django`` +.. _`Django`: https://www.djangoproject.com/ +.. |django-fernet-fields| replace:: ``django-fernet-fields`` +.. _`django-fernet-fields`: https://django-fernet-fields.readthedocs.io/ .. |future| replace:: ``future`` .. _`future`: http://python-future.org/ +.. |slacker| replace:: ``slacker`` +.. _`slacker`: https://github.com/os/slacker diff --git a/_skel/__init__.py b/emojiwatch/__init__.py similarity index 80% rename from _skel/__init__.py rename to emojiwatch/__init__.py index 75fb76e..972d519 100644 --- a/_skel/__init__.py +++ b/emojiwatch/__init__.py @@ -26,7 +26,8 @@ import logging as _logging -from .main import * # noqa: F401,F403 # pylint: disable=wildcard-import +import django.conf as d_conf + from .version import __version__ # noqa: F401 # ---- Data ------------------------------------------------------------ @@ -34,3 +35,11 @@ __all__ = () LOGGER = _logging.getLogger(__name__) + +SETTINGS = getattr(d_conf.settings, 'EMOJIWATCH', {}) + +try: + SLACK_VERIFICATION_TOKEN = SETTINGS['slack_verification_token'] +except (KeyError, TypeError): + SLACK_VERIFICATION_TOKEN = 'xoxa-NOT-A-REAL-TOKEN' + LOGGER.warning("EMOJIWATCH['slack_verification_token'] setting is missing (using '%s')", SLACK_VERIFICATION_TOKEN) diff --git a/emojiwatch/admin.py b/emojiwatch/admin.py new file mode 100644 index 0000000..2f80bfa --- /dev/null +++ b/emojiwatch/admin.py @@ -0,0 +1,58 @@ +# -*- encoding: utf-8 -*- + +# ======================================================================== +""" +Copyright and other protections apply. Please see the accompanying +:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights +and restrictions governing use of this software. All rights not expressly +waived or licensed are reserved. If those files are missing or appear to +be modified from their originals, then please contact the author before +viewing or using this software in any capacity. +""" +# ======================================================================== + +from __future__ import absolute_import, division, print_function + +TYPE_CHECKING = False # from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression + +from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import +from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import + +# ---- Imports ----------------------------------------------------------- + +import django.forms as d_forms +import django.contrib.admin as d_c_admin + +from .models import SlackWorkspaceEmojiWatcher + +# ---- Data -------------------------------------------------------------- + +__all__ = () + +# ---- Classes ----------------------------------------------------------- + +# ======================================================================== +class VersionedAdminForm(d_forms.ModelForm): + + # ---- Constructor --------------------------------------------------- + + def __init__(self, *args, **kw): + # type: (...) -> None + super().__init__(*args, **kw) # type: ignore # py2 + # Hack to hide the _version field from the user, but still submit + # its value with the form + self.fields['_version'].widget = d_c_admin.widgets.AdminTextInputWidget(attrs={'type': 'hidden'}) + +# ======================================================================== +class VersionedAdmin(d_c_admin.ModelAdmin): + + # ---- Data ---------------------------------------------------------- + + form = VersionedAdminForm + +# ---- Initialization ---------------------------------------------------- + +d_c_admin.site.register(SlackWorkspaceEmojiWatcher, VersionedAdmin) diff --git a/emojiwatch/migrations/0001_initial.py b/emojiwatch/migrations/0001_initial.py new file mode 100644 index 0000000..63dede7 --- /dev/null +++ b/emojiwatch/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 2.0.2 on 2018-02-23 08:04 + +import django.core.validators +from django.db import migrations, models +import fernet_fields.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SlackWorkspaceEmojiWatcher', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('_version', models.IntegerField(default=-2147483648)), + ('team_id', models.CharField(max_length=63, unique=True, validators=[django.core.validators.RegexValidator('^T[0-9A-Z]*$', message='Must be of the format (e.g.) T123ABC...')], verbose_name='Team ID')), + ('workspace_token', fernet_fields.fields.EncryptedCharField(max_length=255, validators=[django.core.validators.RegexValidator('^xoxa-([0-9A-Fa-f]+)+', message='Must be of the format (e.g.) xoxa-1f2e3d-4c5b6a...')], verbose_name='Workspace Token')), + ('channel_id', models.CharField(max_length=63, validators=[django.core.validators.RegexValidator('^C[0-9A-Z]*$', message='Must be of the format (e.g.) C123ABC...')], verbose_name='Channel ID')), + ('notes', fernet_fields.fields.EncryptedTextField(blank=True, default='', verbose_name='Notes')), + ], + options={ + 'verbose_name': 'Slack Workspace Emoji Watcher', + }, + ), + ] diff --git a/emojiwatch/migrations/__init__.py b/emojiwatch/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/emojiwatch/models.py b/emojiwatch/models.py new file mode 100644 index 0000000..3b0136c --- /dev/null +++ b/emojiwatch/models.py @@ -0,0 +1,156 @@ +# -*- encoding: utf-8; test-case-name: tests.test_models -*- + +# ======================================================================== +""" +Copyright and other protections apply. Please see the accompanying +:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights +and restrictions governing use of this software. All rights not expressly +waived or licensed are reserved. If those files are missing or appear to +be modified from their originals, then please contact the author before +viewing or using this software in any capacity. +""" +# ======================================================================== + +from __future__ import absolute_import, division, print_function + +TYPE_CHECKING = False # from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression + +from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import +from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import + +# ---- Imports ----------------------------------------------------------- + +import fernet_fields +import slacker + +from gettext import gettext + +import django.core.validators as d_c_validators +import django.db.models as d_d_models + +# ---- Data -------------------------------------------------------------- + +__all__ = () + +CHANNEL_ID_MAX_LEN = 63 +CHANNEL_ID_RE = r'^C[0-9A-Z]*$' +TEAM_ID_MAX_LEN = 63 +TEAM_ID_RE = r'^T[0-9A-Z]*$' +WORKSPACE_TOKEN_MAX_LEN = 255 +WORKSPACE_TOKEN_RE = r'^xoxa-([0-9A-Fa-f]+)+' + +# ---- Exceptions -------------------------------------------------------- + +# ======================================================================== +class StaleVersionError(Exception): pass + +# ---- Classes ----------------------------------------------------------- + +# ======================================================================== +class VersionedModel(d_d_models.Model): + """ + Class to stores the data associated with a particular team. + """ + + # ---- Inner classes ------------------------------------------------- + + class Meta(object): + abstract = True + + # ---- Properties ---------------------------------------------------- + + _version = d_d_models.IntegerField( + default=-0x80000000, + null=False, + ) + + # ---- Overrides ----------------------------------------------------- + + def save(self, *args, **kw): # pylint: disable=arguments-differ + # type: (...) -> None + old_version = self._version + + if old_version < 0: + self._version = 0 + kw['force_insert'] = True + kw['force_update'] = False + else: + self._version += 1 + kw['force_insert'] = False + kw['force_update'] = True + + try: + super().save(*args, **kw) # type: ignore # py2 + except: # noqa: E722 + self._version = old_version + raise + + def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update): # pylint: disable=unused-argument + # type: (...) -> bool + # See + # + filtered = base_qs.filter(pk=pk_val, _version=self._version - 1) + updated = filtered._update(values) # pylint: disable=protected-access + + if updated == 0: + raise StaleVersionError('{!r}._version {} is stale'.format(self, self._version - 1)) + + assert updated == 1 + + return True + +# ======================================================================== +class SlackWorkspaceEmojiWatcher(VersionedModel): + """ + Class to stores the data associated with a particular team. + """ + + # ---- Inner classes ------------------------------------------------- + + class Meta(object): + verbose_name = 'Slack Workspace Emoji Watcher' + + # ---- Properties ---------------------------------------------------- + + team_id = d_d_models.CharField( + max_length=TEAM_ID_MAX_LEN, + null=False, + unique=True, + validators=[ + d_c_validators.RegexValidator(TEAM_ID_RE, message=gettext('Must be of the format (e.g.) T123ABC...')), + ], + verbose_name=gettext('Team ID'), + ) + + team_id.short_description = gettext('Team ID (e.g., T123ABC...)') + + workspace_token = fernet_fields.EncryptedCharField( + max_length=WORKSPACE_TOKEN_MAX_LEN, + null=False, + validators=[ + d_c_validators.RegexValidator(WORKSPACE_TOKEN_RE, message=gettext('Must be of the format (e.g.) xoxa-1f2e3d-4c5b6a...')), + ], + verbose_name=gettext('Workspace Token'), + ) + + channel_id = d_d_models.CharField( + max_length=CHANNEL_ID_MAX_LEN, + null=False, + validators=[ + d_c_validators.RegexValidator(CHANNEL_ID_RE, message=gettext('Must be of the format (e.g.) C123ABC...')), + ], + verbose_name=gettext('Channel ID'), + ) + + notes = fernet_fields.EncryptedTextField( + blank=True, + default='', + verbose_name=gettext('Notes'), + ) + + @property + def slack(self): + return slacker.Slacker(self.workspace_token) diff --git a/emojiwatch/static/emojiwatch/images/emojiwatch.png b/emojiwatch/static/emojiwatch/images/emojiwatch.png new file mode 100644 index 0000000000000000000000000000000000000000..cd79e708cfe907e53cc369577415a804b452bdb0 GIT binary patch literal 30688 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 literal 0 HcmV?d00001 diff --git a/tests/test_main.py b/emojiwatch/urls.py old mode 100755 new mode 100644 similarity index 67% rename from tests/test_main.py rename to emojiwatch/urls.py index 1e7e8fd..f8b3a85 --- a/tests/test_main.py +++ b/emojiwatch/urls.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- encoding: utf-8 -*- # ====================================================================== """ @@ -23,30 +22,23 @@ # ---- Imports --------------------------------------------------------- -import logging -import unittest +import django.conf.urls as d_c_urls -# from tests.symmetries import mock +from .views import ( + CsrfExemptRedirectView, + event_hook_handler, +) # ---- Data ------------------------------------------------------------ __all__ = () -_LOGGER = logging.getLogger(__name__) +app_name = 'emojiwatch' -# ---- Classes --------------------------------------------------------- - -# ====================================================================== -class MainTestCase(unittest.TestCase): - - # ---- Methods ----------------------------------------------------- - - def test_main(self): - # type: (...) -> None - pass - -# ---- Initialization -------------------------------------------------- - -if __name__ == '__main__': - import tests # noqa: F401 # pylint: disable=unused-import - unittest.main() +urlpatterns = ( + d_c_urls.url(r'event_hook$', event_hook_handler, name=u'event_hook'), + d_c_urls.url(r'', CsrfExemptRedirectView.as_view( + permanent=True, + url=u'https://github.com/posita/django-emojiwatch', + )), +) diff --git a/_skel/version.py b/emojiwatch/version.py similarity index 100% rename from _skel/version.py rename to emojiwatch/version.py diff --git a/emojiwatch/views.py b/emojiwatch/views.py new file mode 100644 index 0000000..16cc40b --- /dev/null +++ b/emojiwatch/views.py @@ -0,0 +1,318 @@ +# -*- encoding: utf-8 -*- + +# ======================================================================== +""" +Copyright and other protections apply. Please see the accompanying +:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights +and restrictions governing use of this software. All rights not expressly +waived or licensed are reserved. If those files are missing or appear to +be modified from their originals, then please contact the author before +viewing or using this software in any capacity. +""" +# ======================================================================== + +from __future__ import absolute_import, division, print_function + +TYPE_CHECKING = False # from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression + +from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import +from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import + +# ---- Imports ----------------------------------------------------------- + +import html +import json.decoder +import re +import slacker + +import django.http as d_http +import django.utils.decorators as d_u_decorators +import django.views.decorators.csrf as d_v_d_csrf +import django.views.decorators.http as d_v_d_http +import django.views.generic as d_v_generic + +from . import ( + LOGGER, + SLACK_VERIFICATION_TOKEN, +) +from .models import ( + SlackWorkspaceEmojiWatcher, + TEAM_ID_MAX_LEN, + TEAM_ID_RE, +) + +# ---- Data -------------------------------------------------------------- + +__all__ = () + +_CHALLENGE_MAX_LEN = 1023 +_EMOJI_NAME_MAX_LEN = 255 +_EMOJI_URL_MAX_LEN = 1023 +_EMOJIS_MAX_LEN = 32 +_FIELD_MAX_LEN = 63 +_SHRUG = u'\u00af\\_(\u30c4)_/\u00af' +_SUB_HANDLERS_BY_SUBTYPE = {} # type: typing.Dict[typing.Text, typing.Callable[[SlackWorkspaceEmojiWatcher, typing.Dict], slacker.Response]] +_UNRECOGNIZED_JSON_BODY_ERR = u'unrecognized JSON structure from request body' + +# ---- Exceptions -------------------------------------------------------- + +try: + from json.decoder import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError # type: ignore # py2 + +# ======================================================================== +class RequestPayloadValidationError(Exception): + + # ---- Constructor --------------------------------------------------- + + def __init__( # noqa:F811 # pylint: disable=keyword-arg-before-vararg + self, + message=_UNRECOGNIZED_JSON_BODY_ERR, + response=d_http.HttpResponseBadRequest(), + *args, + **kw + ): # type: (typing.Text, d_http.HttpResponse, *typing.Any, **typing.Any) -> None + super().__init__(*args, **kw) # type: ignore # py2 + self._message = message + self._response = response + + # ---- Properties ---------------------------------------------------- + + @property + def message( # type: ignore # py2 + self, + ): # type: (...) -> typing.Text + return self._message # type: ignore + + @property + def response( + self, + ): # type: (...) -> d_http.Response + return self._response # type: ignore + +# ---- Classes ----------------------------------------------------------- + +# ======================================================================== +class CsrfExemptRedirectView(d_v_generic.RedirectView): + + @d_u_decorators.method_decorator(d_v_d_http.require_GET) + @d_u_decorators.method_decorator(d_v_d_csrf.csrf_exempt) + def dispatch(self, request, *args, **kw): + return super(CsrfExemptRedirectView, self).dispatch(request, *args, **kw) + +# ---- Functions --------------------------------------------------------- + +# ======================================================================== +@d_v_d_http.require_POST +@d_v_d_csrf.csrf_exempt +def event_hook_handler( + request, # type: d_http.HttpRequest +): # type: (...) -> d_http.HttpResponse + slack_retry_num = request.META.get('HTTP_X_SLACK_RETRY_NUM', 0) + slack_retry_reason = request.META.get('HTTP_X_SLACK_RETRY_REASON', None) + + if slack_retry_num: + LOGGER.info("Slack retry attempt %s ('%s')", slack_retry_num, slack_retry_reason) + + content_type = request.META.get('HTTP_CONTENT_TYPE', 'application/json') + + if content_type != 'application/json': + return d_http.HttpResponse(status_code=415) + + try: + payload_data = json.loads(request.body.decode('utf-8'), encoding=request.encoding) # type: typing.Dict + except JSONDecodeError: + LOGGER.info(u'unable to parse JSON from request body') + truncate_len = 1024 + half_truncate_len = truncate_len >> 1 + + if len(request.body) > truncate_len: + LOGGER.debug(u'%r', request.body[:half_truncate_len] + b'<...>' + request.body[-half_truncate_len:]) + else: + LOGGER.debug(u'%r', request.body) + + return d_http.HttpResponseBadRequest() + + try: + try: + verification_token = payload_data[u'token'] + except (KeyError, TypeError): + verification_token = None + LOGGER.info(_UNRECOGNIZED_JSON_BODY_ERR + u" (missing 'token')") # pylint: disable=logging-not-lazy + + if not verification_token \ + or verification_token != SLACK_VERIFICATION_TOKEN: + raise RequestPayloadValidationError( + message=u'bad verification token', + response=d_http.HttpResponseForbidden(), + ) + + try: + call_type = payload_data[u'type'] + except KeyError: + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u" (missing 'type')", + ) + + if call_type == u'url_verification': + try: + challenge = payload_data[u'challenge'] + except KeyError: + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u" (missing 'challenge')", + ) + + if not isinstance(challenge, str) \ + or len(challenge) > _CHALLENGE_MAX_LEN: + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u" (unrecognized challenge)", + ) + + return d_http.HttpResponse(challenge, content_type='text/plain') + + if call_type != u'event_callback': + raise RequestPayloadValidationError( + message=u'unrecognized call type', + ) + + try: + event = payload_data.get(u'event', {}) + event_type = event[u'type'] + event_subtype = event[u'subtype'] + team_id = payload_data[u'team_id'] + except (AttributeError, KeyError, TypeError): + raise RequestPayloadValidationError() + + if not isinstance(event_type, str) \ + or len(event_type) > _FIELD_MAX_LEN \ + or event_type != 'emoji_changed': + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u' (unrecognized event type)', + ) + + try: + if not isinstance(event_subtype, str) \ + or len(event_subtype) > _FIELD_MAX_LEN: + raise ValueError + + subhandler = _SUB_HANDLERS_BY_SUBTYPE[event_subtype] + except (KeyError, ValueError): + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u' (unrecognized event subtype)', + ) + + if not isinstance(team_id, str) \ + or len(team_id) > TEAM_ID_MAX_LEN \ + or not re.search(TEAM_ID_RE, team_id): + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u' (unrecognized team_id)', + ) + + team = SlackWorkspaceEmojiWatcher.objects.filter(team_id=team_id).first() + + if team is None: + raise RequestPayloadValidationError( + message=u'no such team ({})'.format(team_id), + ) + + try: + # By this point we're confident that event is a dict + subhandler(team, event) + except slacker.Error as exc: + if exc.args == ('invalid_auth',): + raise RequestPayloadValidationError( + message=u'call to Slack API failed auth', + response=d_http.HttpResponseForbidden(), + ) + + # Log, but otherwise ignore errors from our callbacks to Slack's + # API + LOGGER.info(u'falled call to Slack') + LOGGER.debug(_SHRUG, exc_info=True) + + except RequestPayloadValidationError as exc: + if exc.message: + LOGGER.info(exc.message) + + LOGGER.debug(u'%r', payload_data) + + return exc.response + + return d_http.HttpResponse() + +# ======================================================================== +def _handle_add( + team, # type: SlackWorkspaceEmojiWatcher + event, # type: typing.Dict +): # type: (...) -> typing.Optional[slacker.Response] + emoji_name = event.get('name', '') # type: typing.Optional[typing.Text] + emoji_url = event.get('value', '') # type: typing.Optional[typing.Text] + + if not isinstance(emoji_name, str) \ + or not emoji_name \ + or len(emoji_name) > _EMOJI_NAME_MAX_LEN: + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u' (unrecognized event name)', + ) + + if not isinstance(emoji_url, str) \ + or len(emoji_url) > _EMOJI_URL_MAX_LEN: + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u' (unrecognized event value)', + ) + + if emoji_url: + attachments = [ # needs to be a list, not a tuple + { + 'fallback': u'<{}>'.format(emoji_url), + 'image_url': emoji_url, + }, + ] # type: typing.Optional[typing.List[typing.Dict]] + else: + attachments = None + + return team.slack.chat.post_message( + team.channel_id, + html.escape(u'added `:{}:`').format(emoji_name), + attachments=attachments, + ) + +# ======================================================================== +def _handle_remove( + team, # type: SlackWorkspaceEmojiWatcher + event, # type: typing.Dict +): # type: (...) -> slacker.Response + emoji_names = event.get('names', []) # type: typing.Optional[typing.List[typing.Text]] + + if not isinstance(emoji_names, list): + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u' (unrecognized event names)', + ) + + if not emoji_names: + return None + + too_many = len(emoji_names) > _EMOJIS_MAX_LEN + emoji_names = emoji_names[:_EMOJIS_MAX_LEN] + + if any((not isinstance(emoji_name, str) for emoji_name in emoji_names[:_EMOJIS_MAX_LEN])) \ + or any((len(emoji_name) > _EMOJI_NAME_MAX_LEN for emoji_name in emoji_names)): + raise RequestPayloadValidationError( + message=_UNRECOGNIZED_JSON_BODY_ERR + u' (unrecognized event names)', + ) + + return team.slack.chat.post_message( + team.channel_id, + html.escape(u'removed {}{}').format(u', '.join(u'`:{}:`'.format(name) for name in emoji_names[:_EMOJIS_MAX_LEN]), u'...' if too_many else u''), + ) + +# ---- Initialization ---------------------------------------------------- + +_SUB_HANDLERS_BY_SUBTYPE.update(( + ('add', _handle_add), + ('remove', _handle_remove), +)) diff --git a/setup.py b/setup.py index a1850c2..093ac25 100755 --- a/setup.py +++ b/setup.py @@ -29,7 +29,10 @@ _MY_DIR = os.path.abspath(os.path.dirname(inspect.getframeinfo(inspect.currentframe()).filename)) INSTALL_REQUIRES = ( + 'Django >= 1.8.0', + 'django-fernet-fields', 'future', + 'slacker', ) TESTS_REQUIRE = [ @@ -45,7 +48,7 @@ # ---- Initialization -------------------------------------------------- vers_info = { - '__path__': os.path.join(_MY_DIR, '_skel', 'version.py'), + '__path__': os.path.join(_MY_DIR, 'emojiwatch', 'version.py'), } if os.path.isfile(vers_info['__path__']): @@ -59,20 +62,25 @@ __release__ = vers_info.get('__release__', __vers_str__) SETUP_ARGS = { - 'name': u'py_skel', + 'name': u'django-emojiwatch', 'version': __vers_str__, 'author': u'Matt Bogosian', 'author_email': u'matt@bogosian.net', - 'url': u'https://_skel.readthedocs.org/en/{}/'.format(__release__), + 'url': u'https://django-emojiwatch.readthedocs.org/en/{}/'.format(__release__), 'license': u'MIT License', - 'description': u'Python Project Skeleton', + 'description': u'Bare bones Slack app for posting custom emoji updates to a designated channel', 'long_description': README, # From 'classifiers': ( - u'Topic :: Software Development :: Libraries :: Python Modules', + u'Topic :: Communications :: Chat', + u'Topic :: Office/Business :: Groupware', u'Development Status :: 3 - Alpha', - u'Intended Audience :: Developers', + u'Framework :: Django', + u'Framework :: Django :: 1.8', + u'Framework :: Django :: 1.11', + u'Framework :: Django :: 2.0', + u'Intended Audience :: System Administrators', u'License :: OSI Approved :: MIT License', u'Operating System :: OS Independent', u'Programming Language :: Python', @@ -90,12 +98,6 @@ 'setup_requires': ('pytest-runner',), 'test_suite': 'tests', 'tests_require': TESTS_REQUIRE, - - 'entry_points': { - 'console_scripts': ( - '_skel = _skel.main:main', - ), - }, } if __name__ == '__main__': diff --git a/tests/__init__.py b/tests/__init__.py index 5573fb6..572ffd1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -22,15 +22,44 @@ # ---- Imports --------------------------------------------------------- +import os import six import unittest -from _skel.main import configlogging - # ---- Data ------------------------------------------------------------ __all__ = () +# ---- Functions ------------------------------------------------------- + +# ====================================================================== +def setup(): + # type: (...) -> None + """ + Prerequisite to importing any Django models. + """ + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.django_settings') + import django + django.setup() + +# ====================================================================== +def main(): + # type: (...) -> None + """ + Wraps :func:`unittest.main` with the necessary Django setup. If your + test code also requires importing Django models, call :func:setup + before making those imports. + """ + import django.test.utils as d_t_utils + + try: + d_t_utils.setup_test_environment() + old_config = d_t_utils.setup_databases(verbosity=1, interactive=False) + unittest.main() + finally: + d_t_utils.teardown_databases(old_config, verbosity=1) + d_t_utils.teardown_test_environment() + # ---- Initialization -------------------------------------------------- # See @@ -47,5 +76,3 @@ if not hasattr(unittest.TestCase, 'assertRegex'): setattr(unittest.TestCase, 'assertRegex', six.assertRegex) - -configlogging() diff --git a/tests/django_settings.py b/tests/django_settings.py new file mode 100644 index 0000000..3f267b9 --- /dev/null +++ b/tests/django_settings.py @@ -0,0 +1,124 @@ +# -*- encoding: utf-8 -*- + +# ======================================================================== +""" +Copyright and other protections apply. Please see the accompanying +:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights +and restrictions governing use of this software. All rights not expressly +waived or licensed are reserved. If those files are missing or appear to +be modified from their originals, then please contact the author before +viewing or using this software in any capacity. +""" +# ======================================================================== + +from __future__ import absolute_import, division, print_function + +TYPE_CHECKING = False # from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression + +from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import +from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import + +# ---- Imports ----------------------------------------------------------- + +import logging + +# ---- Data -------------------------------------------------------------- + +__all__ = () + +_LOGGER = logging.getLogger(__name__) +_SECRET_KEY = '' + +ALLOWED_HOSTS = () + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'django-test.db', + 'TEST_NAME': ':memory:', + } +} + +DEBUG = True + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.messages', + 'django.contrib.sessions', + 'django.contrib.staticfiles', + 'emojiwatch', +) + +LANGUAGE_CODE = 'en-us' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s\t%(levelname)s\t%(name)s\t%(filename)s:%(lineno)d\t%(message)s', + }, + }, + 'handlers': { + 'default': { + 'class': 'logging.StreamHandler', + 'level': 'DEBUG', + 'formatter': 'standard', + }, + }, + 'loggers': { + '': { + 'handlers': [], # ['default'], + 'level': 'DEBUG', + 'propagate': False, + }, + 'django': { + 'level': 'INFO', + 'propagate': True, + }, + }, +} + +MIDDLEWARE = ( + 'django.contrib.sessions.middleware.SessionMiddleware', # needs to come before AuthenticationMiddleware + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.middleware.security.SecurityMiddleware', +) + +ROOT_URLCONF = 'tests.django_urls' +SECRET_KEY = _SECRET_KEY +STATIC_URL = '/static/' + +TEMPLATES = ( + { + 'APP_DIRS': True, + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': (), + 'OPTIONS': { + 'context_processors': ( + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.request', + 'django.template.context_processors.static', + 'django.template.context_processors.tz', + ) + }, + }, +) + +TIME_ZONE = 'UTC' +USE_I18N = True +USE_L10N = True +USE_TZ = True diff --git a/tests/django_urls.py b/tests/django_urls.py new file mode 100644 index 0000000..7565735 --- /dev/null +++ b/tests/django_urls.py @@ -0,0 +1,43 @@ +# -*- encoding: utf-8 -*- + +# ======================================================================== +""" +Copyright and other protections apply. Please see the accompanying +:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights +and restrictions governing use of this software. All rights not expressly +waived or licensed are reserved. If those files are missing or appear to +be modified from their originals, then please contact the author before +viewing or using this software in any capacity. +""" +# ======================================================================== + +from __future__ import absolute_import, division, print_function + +TYPE_CHECKING = False # from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression + +from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import +from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import + +# ---- Imports ----------------------------------------------------------- + +import django.conf.urls as d_c_urls +import django.contrib.admin as d_c_admin + +# ---- Data -------------------------------------------------------------- + +__all__ = () + +try: + # For Django 1.8 + emojiwatch_include = d_c_urls.include('emojiwatch.urls', app_name='emojiwatch', namespace='emojiwatch') # pylint: disable=unexpected-keyword-arg,useless-suppression +except TypeError: + # In Django 1.9+, app_name moved into the module (e.g., emojiwatch.urls.app_name) + emojiwatch_include = d_c_urls.include('emojiwatch.urls', namespace='emojiwatch') + +urlpatterns = ( + d_c_urls.url(r'^emojiwatch/', emojiwatch_include), + d_c_urls.url(r'^admin/', d_c_admin.site.urls), +) diff --git a/tests/requirements.txt b/tests/requirements.txt index 312599f..7de37a3 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,4 @@ -# mock ; python_version <= '3.3' +mock ; python_version <= '3.3' +pytest-django six typing ; python_version <= '3.4' diff --git a/tests/test_meta.py b/tests/test_meta.py index e2adf4f..101433d 100755 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -23,7 +23,6 @@ # ---- Imports --------------------------------------------------------- -import logging import re import unittest @@ -31,8 +30,6 @@ __all__ = () -_LOGGER = logging.getLogger(__name__) - # ---- Classes --------------------------------------------------------- # ====================================================================== @@ -75,5 +72,4 @@ def test_shims(self): # ---- Initialization -------------------------------------------------- if __name__ == '__main__': - import tests # noqa: F401 # pylint: disable=unused-import unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100755 index 0000000..4eb0b66 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +# ======================================================================== +""" +Copyright and other protections apply. Please see the accompanying +:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights +and restrictions governing use of this software. All rights not expressly +waived or licensed are reserved. If those files are missing or appear to +be modified from their originals, then please contact the author before +viewing or using this software in any capacity. +""" +# ======================================================================== + +from __future__ import absolute_import, division, print_function + +TYPE_CHECKING = False # from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression + +from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import +from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import + +# ---- Imports ----------------------------------------------------------- + +import re + +import django.core.exceptions as d_c_exceptions +import django.db.utils as d_d_utils +import django.test as d_test + +if __name__ == '__main__': + import tests + tests.setup() + +from emojiwatch.models import ( + SlackWorkspaceEmojiWatcher, + StaleVersionError, +) + +# ---- Data -------------------------------------------------------------- + +__all__ = () + +# ---- Classes ----------------------------------------------------------- + +# ======================================================================== +class SlackWorkspaceEmojiWatcherTestCase(d_test.TestCase): + + # ---- Methods ------------------------------------------------------- + + def test_form_validation(self): + self.assertEqual(len(SlackWorkspaceEmojiWatcher.objects.all()), 0) + ws_emoji_watcher = SlackWorkspaceEmojiWatcher() + + with self.assertRaises(d_c_exceptions.ValidationError) as cm: + ws_emoji_watcher.full_clean() + + for field in ( + 'team_id', + 'workspace_token', + 'channel_id', + ): + self.assertEqual(cm.exception.message_dict[field][0], 'This field cannot be blank.', msg='(field = {!r})'.format(field)) + + ws_emoji_watcher.team_id = u'...' + ws_emoji_watcher.workspace_token = u'...' + ws_emoji_watcher.channel_id = u'...' + + with self.assertRaises(d_c_exceptions.ValidationError) as cm: + ws_emoji_watcher.full_clean() + + for field, field_fmt in ( + ('team_id', u'T123ABC'), + ('workspace_token', u'xoxa-1f2e3d-4c5b6a'), + ('channel_id', u'C123ABC'), + ): + field_fmt_re = r'\AMust be of the format \(e\.g\.\) {}\.\.\.\Z'.format(re.escape(field_fmt)) + self.assertRegex(cm.exception.message_dict[field][0], field_fmt_re, msg='(field = {!r})'.format(field)) + + def test_create(self): + ws_emoji_watcher1 = SlackWorkspaceEmojiWatcher() + ws_emoji_watcher1.team_id = 'T123ABC' + ws_emoji_watcher1.workspace_token = 'xoxa-1f2e3d-4c5b6a' + ws_emoji_watcher1.channel_id = 'C123ABC' + self.assertLess(ws_emoji_watcher1._version, 0) # pylint: disable=protected-access + + ws_emoji_watcher1.full_clean() + ws_emoji_watcher1.save() + self.assertEqual(len(SlackWorkspaceEmojiWatcher.objects.all()), 1) + self.assertEqual(ws_emoji_watcher1._version, 0) # pylint: disable=protected-access + + ws_emoji_watcher1.save() + self.assertEqual(ws_emoji_watcher1._version, 1) # pylint: disable=protected-access + + ws_emoji_watcher2 = SlackWorkspaceEmojiWatcher() + ws_emoji_watcher2.team_id = 'T456DEF' + ws_emoji_watcher2.workspace_token = 'xoxa-4c5b6a-1f2e3d' + ws_emoji_watcher2.channel_id = 'C456DEF' + self.assertLess(ws_emoji_watcher2._version, 0) # pylint: disable=protected-access + + ws_emoji_watcher2.full_clean() + ws_emoji_watcher2.save() + self.assertEqual(len(SlackWorkspaceEmojiWatcher.objects.all()), 2) + self.assertEqual(ws_emoji_watcher2._version, 0) # pylint: disable=protected-access + + ws_emoji_watcher2.save() + self.assertEqual(ws_emoji_watcher2._version, 1) # pylint: disable=protected-access + + def test_team_id_uniqueness(self): + ws_emoji_watcher1 = SlackWorkspaceEmojiWatcher() + ws_emoji_watcher1.team_id = 'T123ABC' + ws_emoji_watcher1.workspace_token = 'xoxa-1f2e3d-4c5b6a' + ws_emoji_watcher1.channel_id = 'C123ABC' + ws_emoji_watcher1.save() + + ws_emoji_watcher2 = SlackWorkspaceEmojiWatcher() + ws_emoji_watcher2.team_id = ws_emoji_watcher1.team_id + ws_emoji_watcher2.workspace_token = 'xoxa-4c5b6a-1f2e3d' + ws_emoji_watcher2.channel_id = 'C456DEF' + self.assertLess(ws_emoji_watcher2._version, 0) # pylint: disable=protected-access + old_version = ws_emoji_watcher2._version # pylint: disable=protected-access + + with self.assertRaises(d_d_utils.IntegrityError): + ws_emoji_watcher2.save() + + self.assertEqual(ws_emoji_watcher2._version, old_version) # pylint: disable=protected-access + + def test_version_staleness(self): + ws_emoji_watcher1 = SlackWorkspaceEmojiWatcher() + ws_emoji_watcher1.team_id = 'T123ABC' + ws_emoji_watcher1.workspace_token = 'xoxa-1f2e3d-4c5b6a' + ws_emoji_watcher1.channel_id = 'C123ABC' + ws_emoji_watcher1.save() + + ws_emoji_watcher2 = SlackWorkspaceEmojiWatcher.objects.filter(team_id=ws_emoji_watcher1.team_id).first() + self.assertEqual(ws_emoji_watcher1, ws_emoji_watcher2) + + ws_emoji_watcher1.save() + self.assertEqual(ws_emoji_watcher1, ws_emoji_watcher2) # weird, but correct + self.assertNotEqual(ws_emoji_watcher1._version, ws_emoji_watcher2._version) # pylint: disable=protected-access + + old_version = ws_emoji_watcher2._version # pylint: disable=protected-access + + with self.assertRaises(StaleVersionError): + ws_emoji_watcher2.save() + + self.assertEqual(ws_emoji_watcher2._version, old_version) # pylint: disable=protected-access + +# ---- Initialization ---------------------------------------------------- + +if __name__ == '__main__': + tests.main() diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100755 index 0000000..21a5ef0 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +# ======================================================================== +""" +Copyright and other protections apply. Please see the accompanying +:doc:`LICENSE ` and :doc:`CREDITS ` file(s) for rights +and restrictions governing use of this software. All rights not expressly +waived or licensed are reserved. If those files are missing or appear to +be modified from their originals, then please contact the author before +viewing or using this software in any capacity. +""" +# ======================================================================== + +from __future__ import absolute_import, division, print_function + +TYPE_CHECKING = False # from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import typing # noqa: F401 # pylint: disable=import-error,unused-import,useless-suppression + +from builtins import * # noqa: F401,F403 # pylint: disable=redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import +from future.builtins.disabled import * # noqa: F401,F403 # pylint: disable=no-name-in-module,redefined-builtin,unused-wildcard-import,useless-suppression,wildcard-import + +# ---- Imports ----------------------------------------------------------- + +import json +import slacker +import unittest + +try: + import django.urls as d_urls +except ImportError: + import django.core.urlresolvers as d_urls + +import django.http as d_http +import django.test as d_test + +if __name__ == '__main__': + import tests + tests.setup() + +from emojiwatch import SLACK_VERIFICATION_TOKEN +from emojiwatch.models import ( + SlackWorkspaceEmojiWatcher, + TEAM_ID_MAX_LEN, +) +from emojiwatch.views import ( + RequestPayloadValidationError, + _CHALLENGE_MAX_LEN, + _FIELD_MAX_LEN, +) + +from tests.symmetries import mock + + +# ---- Data -------------------------------------------------------------- + +__all__ = () + +# ---- Classes ----------------------------------------------------------- + +# ======================================================================== +class RequestPayloadValidationErrorTestCase(unittest.TestCase): + + # ---- Methods ------------------------------------------------------- + + def test_constructors(self): + # type: (...) -> None + message = u'Hey!' + response = d_http.HttpResponseServerError() + + exc = RequestPayloadValidationError() + self.assertEqual(exc.message, u'unrecognized JSON structure from request body') + self.assertIsInstance(exc.response, d_http.HttpResponseBadRequest) + + exc = RequestPayloadValidationError(message) + self.assertEqual(exc.message, message) + self.assertIsInstance(exc.response, d_http.HttpResponseBadRequest) + + exc = RequestPayloadValidationError(message=message) + self.assertEqual(exc.message, message) + self.assertIsInstance(exc.response, d_http.HttpResponseBadRequest) + + exc = RequestPayloadValidationError(response=response) + self.assertEqual(exc.message, u'unrecognized JSON structure from request body') + self.assertEqual(exc.response, response) + + exc = RequestPayloadValidationError(message, response) + self.assertEqual(exc.message, message) + self.assertEqual(exc.response, response) + + exc = RequestPayloadValidationError(message, response=response) + self.assertEqual(exc.message, message) + self.assertEqual(exc.response, response) + + exc = RequestPayloadValidationError(message=message, response=response) + self.assertEqual(exc.message, message) + self.assertEqual(exc.response, response) + + # TODO: These should all generate MyPy errors + RequestPayloadValidationError(u'', True) + RequestPayloadValidationError(u'', response=True) + RequestPayloadValidationError(message=u'', response=True) + +# ======================================================================== +class EventHandlerTestCaseBase(d_test.TestCase): + + # ---- Data ---------------------------------------------------------- + + CLIENT_ID = 'C123ABC' + TEAM_ID = 'T123ABC' + + # ---- Properties ---------------------------------------------------- + + @property + def good_add_event(self): + # type: (...) -> typing.Dict + return { + 'event': { + 'name': 'blam', + 'subtype': 'add', + 'type': 'emoji_changed', + 'value': 'https://gulfcoastmakers.files.wordpress.com/2015/03/blam.jpg', + }, + 'team_id': self.TEAM_ID, + 'token': SLACK_VERIFICATION_TOKEN, + 'type': 'event_callback', + } + + @property + def good_remove_event(self): + # type: (...) -> typing.Dict + return { + 'event': { + 'names': [ + 'biff', + 'blam', + 'pow', + 'zok', + ], + 'subtype': 'remove', + 'type': 'emoji_changed', + }, + 'team_id': self.TEAM_ID, + 'token': SLACK_VERIFICATION_TOKEN, + 'type': 'event_callback', + } + + @property + def good_url_verification(self): + # type: (...) -> typing.Dict + return { + 'challenge': '', + 'token': SLACK_VERIFICATION_TOKEN, + 'type': 'url_verification', + } + + # ---- Hooks --------------------------------------------------------- + + def setUp(self): + super().setUp() # type: ignore # py2 + SlackWorkspaceEmojiWatcher( + team_id=self.TEAM_ID, + workspace_token='xoxa-1f2e3d-4c5b6a', + channel_id=self.CLIENT_ID, + ).save() + + # ---- Methods ------------------------------------------------------- + + def post_event_hook(self, payload_data, content_type=None, encoding=None): + if not isinstance(payload_data, str): + # See and + # (JSON is + # UTF-8 and charset parameter should be omitted). In addition, + # at least with Django dev, if an explicit charset is + # provided, data is blindly assumed to be a Unicode string + # (not raw bytes) and the charset will be used to encode it. + content_type = 'application/json' + encoding = None + payload_data = json.dumps(payload_data) + + return self.client.post( + d_urls.reverse('emojiwatch:event_hook'), + content_type='{}{}{}'.format(content_type, '; charset=' if encoding else '', encoding), + data=payload_data, + follow=True, + ) + +# ======================================================================== +class EventHandlerTestCase(EventHandlerTestCaseBase): + + # ---- Methods ------------------------------------------------------- + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_bad_event( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + for event in ( + None, + '<...>', + [], + '**' * _FIELD_MAX_LEN, + ): + payload_data['event'] = event + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_bad_event_type( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + for event_type in ( + None, + '<...>', + [], + {'type': None}, + '**' * _FIELD_MAX_LEN, + ): + payload_data['event']['type'] = event_type + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_bad_event_subtype( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + for event_type in ( + None, + '<...>', + [], + {'subtype': None}, + '**' * _FIELD_MAX_LEN, + ): + payload_data['event']['subtype'] = event_type + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_bad_team( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + for team_id in ( + None, + '<...>', + 'T' + 'A' * TEAM_ID_MAX_LEN, + list(self.TEAM_ID), + {self.TEAM_ID: None}, + ): + payload_data['team_id'] = team_id + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_bad_type( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + for event_type in ( + None, + '<...>', + list('emoji_changed'), + {'emoji_changed': None}, + '**' * _FIELD_MAX_LEN, + ): + payload_data['event']['type'] = event_type + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_no_event( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + del payload_data['event'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_no_event_type( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + del payload_data['event']['type'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_no_event_subtype( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + del payload_data['event']['subtype'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_no_team( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + del payload_data['team_id'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_emoji_no_type( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + ): + del payload_data['event']['type'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_payload_bad_json( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + res = self.post_event_hook(str('---'), content_type='application/json') + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_payload_bad_type( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + self.good_url_verification, + ): + for payload_type in ( + None, + '<...>', + list('event_callback'), + {'event_callback': None}, + '**' * _FIELD_MAX_LEN, + ): + payload_data['type'] = payload_type + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_payload_no_type( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + self.good_url_verification, + ): + del payload_data['type'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + +# ======================================================================== +class EmojiAddTestCase(EventHandlerTestCaseBase): + + # ---- Methods ------------------------------------------------------- + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_add_emoji( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + res = self._post_good_add_event(self.good_add_event, mocked_post_message) + self.assertEqual(res.status_code, 200) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_add_emoji_invalid_auth( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = slacker.Error('invalid_auth') + res = self._post_good_add_event(self.good_add_event, mocked_post_message) + self.assertEqual(res.status_code, 403) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_add_emoji_slacker_errors_ignored( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = slacker.Error() + res = self._post_good_add_event(self.good_add_event, mocked_post_message) + self.assertEqual(res.status_code, 200) + + def _post_good_add_event( + self, + good_add_event, # type: typing.Dict + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> d_http.Response + emoji_name = good_add_event['event']['name'] + emoji_url = good_add_event['event']['value'] + res = self.post_event_hook(good_add_event) + mocked_post_message.assert_called_with( + self.CLIENT_ID, + 'added `:{}:`'.format(emoji_name), + attachments=[{ + 'fallback': '<{}>'.format(emoji_url), + 'image_url': emoji_url, + }] + ) + + return res + +# ======================================================================== +class EmojiRemoveTestCase(EventHandlerTestCaseBase): + + # ---- Methods ------------------------------------------------------- + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_remove_emoji( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + res = self._post_good_add_event(self.good_remove_event, mocked_post_message) + self.assertEqual(res.status_code, 200) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_remove_emoji_invalid_auth( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = slacker.Error('invalid_auth') + res = self._post_good_add_event(self.good_remove_event, mocked_post_message) + self.assertEqual(res.status_code, 403) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_event_remove_emoji_slacker_errors_ignored( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = slacker.Error() + res = self._post_good_add_event(self.good_remove_event, mocked_post_message) + self.assertEqual(res.status_code, 200) + + def _post_good_add_event( + self, + good_remove_event, # type: typing.Dict + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> d_http.Response + emoji_names = good_remove_event['event']['names'] + res = self.post_event_hook(good_remove_event) + mocked_post_message.assert_called_with( + self.CLIENT_ID, + 'removed `:{}:`'.format(':`, `:'.join(emoji_names)), + ) + + return res + +# ======================================================================== +class VerificationTestCase(EventHandlerTestCaseBase): + + # ---- Methods ------------------------------------------------------- + + @mock.patch.object(slacker.Chat, 'post_message') + def test_bad_challenge( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + payload_data = self.good_url_verification + + for challenge in ( + None, + list('<...>'), + {'challenge': None}, + '**' * _CHALLENGE_MAX_LEN, + ): + payload_data['challenge'] = challenge + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_bad_token( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + + res = self.post_event_hook({ + 'token': '<...>', + }) + + self.assertEqual(res.status_code, 403) + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + self.good_url_verification, + ): + for token in ( + None, + '<...>', + list(SLACK_VERIFICATION_TOKEN), + {'token': None}, + '**' * _FIELD_MAX_LEN, + ): + payload_data['token'] = token + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 403) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_no_challenge( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + payload_data = self.good_url_verification + del payload_data['challenge'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 400) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_no_token( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + res = self.post_event_hook({}) + self.assertEqual(res.status_code, 403) + + for payload_data in ( + self.good_add_event, + self.good_remove_event, + self.good_url_verification, + ): + del payload_data['token'] + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 403) + + @mock.patch.object(slacker.Chat, 'post_message') + def test_url_verification( + self, + mocked_post_message, # type: mock.MagicMock + ): + # type: (...) -> None + mocked_post_message.side_effect = AssertionError('should not have reached slacker.Chat.post_message') + payload_data = self.good_url_verification + payload_data['challenge'] = 'NXEJp99-JO7kCaVrBbMteU4EhOzW3Bek59_NXmR6uXo=' + res = self.post_event_hook(payload_data) + self.assertEqual(res.status_code, 200) + self.assertEqual(payload_data['challenge'], res.content.decode('utf-8')) + +# ---- Initialization ---------------------------------------------------- + +if __name__ == '__main__': + tests.main() diff --git a/tox.ini b/tox.ini index 3e3cb6f..d218ce1 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ [tox] # --------------------------------------------------------------- -envlist = check, lint, mypy, py{27,py,34,35,36,py3} +envlist = check, lint, mypy, py{27,py,34,35,36,py3}-django_{1_8,1_11}_lts, py{34,35,36,py3}-django_2, py{35,36,py3}-django_dev skip_missing_interpreters = true [travis] # ------------------------------------------------------------ @@ -23,6 +23,14 @@ python = 3.6: py36, check, lint, mypy pypy3: pypy3, lint, mypy +[travis:env] # -------------------------------------------------------- + +DJANGO = + 1.8-lts: django_1_8_lts, check, lint, mypy + 1.11-lts: django_1_11_lts, check, lint, mypy + 2: django_2, check, lint, mypy + dev: django_dev, check, lint, mypy + [testreqs] # ---------------------------------------------------------- deps = @@ -30,12 +38,19 @@ deps = # . -rtests/requirements.txt +setenv = + DJANGO_SETTINGS_MODULE=tests.django_settings + [testenv] # ----------------------------------------------------------- commands = coverage run --append -m py.test {posargs} deps = + django_1_8_lts: Django >= 1.8.0, < 1.9.0 + django_1_11_lts: Django >= 1.11.0, < 1.12.0 + django_2: Django >= 2.0.0, < 3.0.0 + django_dev: git+https://github.com/django/django.git@master coverage pytest {[testreqs]deps} @@ -47,15 +62,17 @@ passenv = setenv = PYTHONWARNINGS = all + {[testreqs]setenv} [testenv:check] # ----------------------------------------------------- basepython = {env:PYTHON:python} commands = + django-admin makemigrations python setup.py check -m -r -s -v - # rm -frv docs/_build docs/_static docs/modules.rst docs/_skel.rst docs/_skel.*.rst - # sphinx-apidoc --output-dir docs --separate _skel + rm -frv docs/_build docs/_static docs/modules.rst docs/emojiwatch.rst docs/emojiwatch.*.rst + sphinx-apidoc --output-dir docs --separate emojiwatch emojiwatch/migrations/ {toxinidir}/helpers/checkmodified.sh mkdir -p docs/_static make -C docs html @@ -69,8 +86,9 @@ deps = setenv = PYTHONWARNINGS = + {[testreqs]setenv} -skip_install = true +usedevelop = true whitelist_externals = make @@ -84,8 +102,8 @@ basepython = {env:PYTHON:python} commands = -coverage report -coverage html - flake8 _skel tests setup.py - pylint --rcfile=.pylintrc _skel tests setup.py + flake8 emojiwatch tests setup.py + pylint --rcfile=.pylintrc emojiwatch tests setup.py deps = coverage @@ -103,14 +121,17 @@ usedevelop = true basepython = {env:PYTHON:python} commands = - mypy --follow-imports=skip --ignore-missing-imports --no-implicit-optional --strict-optional --warn-redundant-casts --warn-unused-configs _skel tests setup.py - mypy --follow-imports=skip --ignore-missing-imports --no-implicit-optional --strict-optional --warn-redundant-casts --warn-unused-configs --py2 _skel tests setup.py + sh -c "find emojiwatch tests setup.py -name '*.py' -not -path 'emojiwatch/migrations/*' -print0 -o -name '*.pyi' -not -path 'emojiwatch/migrations/*' -print0 | xargs -0 mypy --follow-imports=skip --ignore-missing-imports --no-implicit-optional --strict-optional --warn-redundant-casts --warn-unused-configs" + sh -c "find emojiwatch tests setup.py -name '*.py' -not -path 'emojiwatch/migrations/*' -print0 -o -name '*.pyi' -not -path 'emojiwatch/migrations/*' -print0 | xargs -0 mypy --follow-imports=skip --ignore-missing-imports --no-implicit-optional --strict-optional --warn-redundant-casts --warn-unused-configs --py2" deps = mypy usedevelop = true +whitelist_externals = + sh + [flake8] # ------------------------------------------------------------ # See @@ -129,5 +150,5 @@ ignore = E124,E128,E301,E302,E305,E402,E501,E701,W503 [pytest] # ------------------------------------------------------------ -addopts = --doctest-modules +addopts = --create-db --doctest-modules --reuse-db doctest_optionflags = IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE