Skip to content

Commit

Permalink
Wrap user-facing strings in gettext; add icon_emoji
Browse files Browse the repository at this point in the history
  • Loading branch information
posita committed Feb 24, 2018
1 parent 6e51e9e commit ffcf992
Show file tree
Hide file tree
Showing 6 changed files with 78 additions and 51 deletions.
24 changes: 9 additions & 15 deletions docs/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,22 +95,16 @@ To override this, use the ``FERNET_KEYS`` setting. For example:
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
# The keys as a single environment variable separated by semicolons.
# The first entry is the current key (for encrypting and
# decrypting). Any additional entries are older keys for decrypting
# only.
FERNET_KEYS = environ['FERNET_KEY'].split(';')
# If the following is False, 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 <http://django-fernet-fields.readthedocs.io/en/latest/#keys>`__ for details.

Expand Down
9 changes: 5 additions & 4 deletions emojiwatch/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 2.0.2 on 2018-02-23 08:04
# Generated by Django 2.0.2 on 2018-02-24 21:16

import django.core.validators
from django.db import migrations, models
Expand All @@ -18,9 +18,10 @@ class Migration(migrations.Migration):
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')),
('team_id', models.CharField(max_length=63, unique=True, validators=[django.core.validators.RegexValidator('\\AT[0-9A-Z]*\\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('\\Axoxa-([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('\\AC[0-9A-Z]*\\Z', message='Must be of the format (e.g.) C123ABC...')], verbose_name='Channel ID')),
('icon_emoji', models.CharField(default=':robot_face:', max_length=255, validators=[django.core.validators.RegexValidator('\\A:[\\w-]+:\\Z', message='Must be of the format (e.g.) :emoji_name:...')], verbose_name='Icon Emoji')),
('notes', fernet_fields.fields.EncryptedTextField(blank=True, default='', verbose_name='Notes')),
],
options={
Expand Down
21 changes: 17 additions & 4 deletions emojiwatch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@
__all__ = ()

CHANNEL_ID_MAX_LEN = 63
CHANNEL_ID_RE = r'^C[0-9A-Z]*$'
CHANNEL_ID_RE = r'\AC[0-9A-Z]*\Z'
ICON_EMOJI_DEFAULT = ':robot_face:'
ICON_EMOJI_MAX_LEN = 255
ICON_EMOJI_RE = r'\A:[\w-]+:\Z'
TEAM_ID_MAX_LEN = 63
TEAM_ID_RE = r'^T[0-9A-Z]*$'
TEAM_ID_RE = r'\AT[0-9A-Z]*\Z'
WORKSPACE_TOKEN_MAX_LEN = 255
WORKSPACE_TOKEN_RE = r'^xoxa-([0-9A-Fa-f]+)+'
WORKSPACE_TOKEN_RE = r'\Axoxa-([0-9A-Fa-f]+)+'

# ---- Exceptions --------------------------------------------------------

Expand Down Expand Up @@ -96,7 +99,7 @@ def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_updat
updated = filtered._update(values) # pylint: disable=protected-access

if updated == 0:
raise StaleVersionError('{!r}._version {} is stale'.format(self, self._version - 1))
raise StaleVersionError(gettext('{!r}._version {} is stale').format(self, self._version - 1))

assert updated == 1

Expand Down Expand Up @@ -145,6 +148,16 @@ class Meta(object):
verbose_name=gettext('Channel ID'),
)

icon_emoji = d_d_models.CharField(
default=ICON_EMOJI_DEFAULT,
max_length=ICON_EMOJI_MAX_LEN,
null=False,
validators=[
d_c_validators.RegexValidator(ICON_EMOJI_RE, message=gettext('Must be of the format (e.g.) :emoji_name:...')),
],
verbose_name=gettext('Icon Emoji'),
)

notes = fernet_fields.EncryptedTextField(
blank=True,
default='',
Expand Down
52 changes: 29 additions & 23 deletions emojiwatch/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import django.views.decorators.http as d_v_d_http
import django.views.generic as d_v_generic

from gettext import gettext

from . import (
LOGGER,
SLACK_VERIFICATION_TOKEN,
Expand All @@ -53,7 +55,7 @@
_EMOJI_URL_MAX_LEN = 1023
_EMOJIS_MAX_LEN = 32
_FIELD_MAX_LEN = 63
_SHRUG = '\u00af\\_(\u30c4)_/\u00af'
_SHRUG = str(u'\u00af\\_(\u30c4)_/\u00af')
_SUB_HANDLERS_BY_SUBTYPE = {} # type: typing.Dict[typing.Text, typing.Callable[[SlackWorkspaceEmojiWatcher, typing.Dict], slacker.Response]]
_UNRECOGNIZED_JSON_BODY_ERR = 'unrecognized JSON structure from request body'

Expand All @@ -71,7 +73,7 @@ class RequestPayloadValidationError(Exception):

def __init__( # noqa:F811 # pylint: disable=keyword-arg-before-vararg
self,
message=_UNRECOGNIZED_JSON_BODY_ERR,
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR),
response=d_http.HttpResponseBadRequest(),
*args,
**kw
Expand Down Expand Up @@ -121,7 +123,7 @@ def event_hook_handler(
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)
LOGGER.info(gettext("Slack retry attempt %s ('%s')"), slack_retry_num, slack_retry_reason)

content_type = request.META.get('HTTP_CONTENT_TYPE', 'application/json')

Expand All @@ -132,7 +134,7 @@ def event_hook_handler(
try:
payload_data = json.loads(request.body.decode('utf-8')) # type: typing.Dict
except (JSONDecodeError, UnicodeDecodeError):
LOGGER.info('unable to parse JSON from request body')
LOGGER.info(gettext('unable to parse JSON from request body'))
truncate_len = 1024
half_truncate_len = truncate_len >> 1

Expand All @@ -148,41 +150,41 @@ def event_hook_handler(
verification_token = payload_data['token']
except (KeyError, TypeError):
verification_token = None
LOGGER.info(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'token')") # pylint: disable=logging-not-lazy
LOGGER.info(gettext(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'token')")) # pylint: disable=logging-not-lazy

if not verification_token \
or verification_token != SLACK_VERIFICATION_TOKEN:
raise RequestPayloadValidationError(
message='bad verification token',
message=gettext('bad verification token'),
response=d_http.HttpResponseForbidden(),
)

try:
call_type = payload_data['type']
except KeyError:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'type')",
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'type')"),
)

if call_type == 'url_verification':
try:
challenge = payload_data['challenge']
except KeyError:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'challenge')",
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + " (missing 'challenge')"),
)

if not isinstance(challenge, str) \
or len(challenge) > _CHALLENGE_MAX_LEN:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized challenge)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized challenge)'),
)

return d_http.HttpResponse(challenge, content_type='text/plain')

if call_type != 'event_callback':
raise RequestPayloadValidationError(
message='unrecognized call type',
message=gettext('unrecognized call type'),
)

try:
Expand All @@ -197,7 +199,7 @@ def event_hook_handler(
or len(event_type) > _FIELD_MAX_LEN \
or event_type != 'emoji_changed':
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event type)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event type)'),
)

try:
Expand All @@ -208,21 +210,21 @@ def event_hook_handler(
subhandler = _SUB_HANDLERS_BY_SUBTYPE[event_subtype]
except (KeyError, ValueError):
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event subtype)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (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 + ' (unrecognized team_id)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized team_id)'),
)

team = SlackWorkspaceEmojiWatcher.objects.filter(team_id=team_id).first()

if team is None:
raise RequestPayloadValidationError(
message='no such team ({})'.format(team_id),
message=gettext('no such team ({})').format(team_id),
)

try:
Expand All @@ -231,13 +233,13 @@ def event_hook_handler(
except slacker.Error as exc:
if exc.args == ('invalid_auth',):
raise RequestPayloadValidationError(
message='call to Slack API failed auth',
message=gettext('call to Slack API failed auth'),
response=d_http.HttpResponseForbidden(),
)

# Log, but otherwise ignore errors from our callbacks to Slack's
# API
LOGGER.info('falled call to Slack')
LOGGER.info(gettext('falled call to Slack'))
LOGGER.debug(_SHRUG, exc_info=True)

except RequestPayloadValidationError as exc:
Expand All @@ -262,13 +264,13 @@ def _handle_add(
or not emoji_name \
or len(emoji_name) > _EMOJI_NAME_MAX_LEN:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event name)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event name)'),
)

if not isinstance(emoji_url, str) \
or len(emoji_url) > _EMOJI_URL_MAX_LEN:
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event value)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event value)'),
)

if emoji_url:
Expand All @@ -277,14 +279,16 @@ def _handle_add(
'fallback': '<{}>'.format(emoji_url),
'image_url': emoji_url,
},
] # type: typing.Optional[typing.List[typing.Dict]]
] # type: typing.Optional[typing.List[typing.Dict[typing.Text, typing.Text]]]
else:
attachments = None

return team.slack.chat.post_message(
team.channel_id,
html.escape('added `:{}:`').format(emoji_name),
html.escape(gettext('added `:{}:`').format(emoji_name)),
as_user=False,
attachments=attachments,
icon_emoji=team.icon_emoji if team.icon_emoji else None,
)

# ========================================================================
Expand All @@ -296,7 +300,7 @@ def _handle_remove(

if not isinstance(emoji_names, list):
raise RequestPayloadValidationError(
message=_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event names)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event names)'),
)

if not emoji_names:
Expand All @@ -308,12 +312,14 @@ def _handle_remove(
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 + ' (unrecognized event names)',
message=gettext(_UNRECOGNIZED_JSON_BODY_ERR + ' (unrecognized event names)'),
)

return team.slack.chat.post_message(
team.channel_id,
html.escape('removed {}{}').format(', '.join('`:{}:`'.format(name) for name in emoji_names[:_EMOJIS_MAX_LEN]), '...' if too_many else ''),
html.escape(gettext('removed {}{}').format(', '.join('`:{}:`'.format(name) for name in emoji_names[:_EMOJIS_MAX_LEN]), '...' if too_many else '')),
as_user=False,
icon_emoji=team.icon_emoji if team.icon_emoji else None,
)

# ---- Initialization ----------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def test_form_validation(self):
ws_emoji_watcher.team_id = '...'
ws_emoji_watcher.workspace_token = '...'
ws_emoji_watcher.channel_id = '...'
ws_emoji_watcher.icon_emoji = '...'

with self.assertRaises(d_c_exceptions.ValidationError) as cm:
ws_emoji_watcher.full_clean()
Expand All @@ -75,6 +76,7 @@ def test_form_validation(self):
('team_id', 'T123ABC'),
('workspace_token', 'xoxa-1f2e3d-4c5b6a'),
('channel_id', 'C123ABC'),
('icon_emoji', ':emoji_name:'),
):
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))
Expand All @@ -84,6 +86,7 @@ def test_create(self):
ws_emoji_watcher1.team_id = 'T123ABC'
ws_emoji_watcher1.workspace_token = 'xoxa-1f2e3d-4c5b6a'
ws_emoji_watcher1.channel_id = 'C123ABC'
ws_emoji_watcher1.icon_emoji = ':blush:'
self.assertLess(ws_emoji_watcher1._version, 0) # pylint: disable=protected-access

ws_emoji_watcher1.full_clean()
Expand All @@ -98,6 +101,7 @@ def test_create(self):
ws_emoji_watcher2.team_id = 'T456DEF'
ws_emoji_watcher2.workspace_token = 'xoxa-4c5b6a-1f2e3d'
ws_emoji_watcher2.channel_id = 'C456DEF'
ws_emoji_watcher2.icon_emoji = ':smirk:'
self.assertLess(ws_emoji_watcher2._version, 0) # pylint: disable=protected-access

ws_emoji_watcher2.full_clean()
Expand All @@ -113,12 +117,14 @@ def test_team_id_uniqueness(self):
ws_emoji_watcher1.team_id = 'T123ABC'
ws_emoji_watcher1.workspace_token = 'xoxa-1f2e3d-4c5b6a'
ws_emoji_watcher1.channel_id = 'C123ABC'
ws_emoji_watcher1.icon_emoji = ':blush:'
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'
ws_emoji_watcher2.icon_emoji = ':smirk:'
self.assertLess(ws_emoji_watcher2._version, 0) # pylint: disable=protected-access
old_version = ws_emoji_watcher2._version # pylint: disable=protected-access

Expand All @@ -132,6 +138,7 @@ def test_version_staleness(self):
ws_emoji_watcher1.team_id = 'T123ABC'
ws_emoji_watcher1.workspace_token = 'xoxa-1f2e3d-4c5b6a'
ws_emoji_watcher1.channel_id = 'C123ABC'
ws_emoji_watcher1.icon_emoji = ':blush:'
ws_emoji_watcher1.save()

ws_emoji_watcher2 = SlackWorkspaceEmojiWatcher.objects.filter(team_id=ws_emoji_watcher1.team_id).first()
Expand Down
Loading

0 comments on commit ffcf992

Please sign in to comment.