Skip to content

Commit

Permalink
Merge pull request #9 from briehl/develop
Browse files Browse the repository at this point in the history
tests and more notification functionality
  • Loading branch information
briehl committed Oct 26, 2018
2 parents b4d5fb3 + 69907dd commit 1181876
Show file tree
Hide file tree
Showing 26 changed files with 643 additions and 68 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ docs:

test:
flake8 feeds
pytest --verbose test --cov feeds
pytest --verbose test --cov --cov-report html feeds -s

start:
gunicorn --worker-class gevent --timeout 300 --workers 10 --bind :5000 feeds.server:app
Expand Down
10 changes: 10 additions & 0 deletions deploy.cfg.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,13 @@ admins=wjriehl,scanon,kkeller,mmdrake
# fake user name for the global feed. Should be something that's not a valid
# user name.
global-feed=_global_

# Default lifetime for each notification in days. Notes older than this won't be
# returned without explicitly looking them up by either their id or external key
# (when given).
lifespan=30

# In debug mode, auth is effectively ignored.
# Useful for testing, etc.
# SET TO FALSE IN PRODUCTION!
debug=False
3 changes: 2 additions & 1 deletion dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ coverage==4.5.1
pytest-cov==2.6.0
flake8==3.5.0
pytest==3.8.2
coveralls==1.5.1
coveralls==1.5.1
requests-mock==1.5.2
7 changes: 6 additions & 1 deletion feeds/activity/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from abc import abstractmethod


class BaseActivity(object):
"""
Common parent class for Activity and Notification.
Activity will be done later. But a Notification is an Activity.
"""
pass
@abstractmethod
def to_dict(self):
pass
147 changes: 121 additions & 26 deletions feeds/activity/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
import json
from ..util import epoch_ms
from .. import verbs
# from ..actor import validate_actor
from ..actor import validate_actor
from .. import notification_level

SERIAL_TOKEN = "|"
from feeds.exceptions import (
InvalidExpirationError,
InvalidNotificationError
)
import datetime
from feeds.config import get_config


class Notification(BaseActivity):
def __init__(self, actor, verb, note_object, source, level='alert', target=None, context={}):
def __init__(self, actor: str, verb, note_object: str, source: str, level='alert',
target: list=None, context: dict=None, expires: int=None, external_key: str=None):
"""
A notification is roughly of this form:
actor, verb, object, (target)
Expand All @@ -36,6 +41,9 @@ def __init__(self, actor, verb, note_object, source, level='alert', target=None,
:param target: target of the note. Optional. Should be a user id or group id if present.
:param context: freeform context of the note. key-value pairs.
:param validate: if True, runs _validate immediately
:param expires: if not None, set a new expiration date - should be an int, ms since epoch
:param external_key: an optional special key given by the service that created the
notification
TODO:
* decide on global ids for admin use
Expand All @@ -45,6 +53,13 @@ def __init__(self, actor, verb, note_object, source, level='alert', target=None,
* validate target is valid
* validate context fits
"""
assert actor is not None, "actor must not be None"
assert verb is not None, "verb must not be None"
assert note_object is not None, "note_object must not be None"
assert source is not None, "source must not be None"
assert level is not None, "level must not be None"
assert target is None or isinstance(target, list), "target must be either a list or None"
assert context is None or isinstance(context, dict), "context must be either a dict or None"
self.id = str(uuid.uuid4())
self.actor = actor
self.verb = verbs.translate_verb(verb)
Expand All @@ -53,32 +68,87 @@ def __init__(self, actor, verb, note_object, source, level='alert', target=None,
self.target = target
self.context = context
self.level = notification_level.translate_level(level)
self.time = epoch_ms() # int timestamp down to millisecond
self.created = epoch_ms() # int timestamp down to millisecond
if expires is None:
expires = self._default_lifespan() + self.created
self.validate_expiration(expires, self.created)
self.expires = expires
self.external_key = external_key

def validate(self):
"""
Validates whether the notification fields are accurate. Should be called before
sending a new notification to storage.
"""
pass
self.validate_expiration(self.expires, self.created)
validate_actor(self.actor)

def validate_expiration(self, expires: int, created: int):
"""
Validates whether the expiration time is valid and after the created time.
If yes, returns True. If not, raises an InvalidExpirationError.
"""
# Just validate that the time looks like a real time in epoch millis.
try:
datetime.datetime.fromtimestamp(expires/1000)
except (TypeError, ValueError):
raise InvalidExpirationError(
"Expiration time should be the number "
"of milliseconds since the epoch"
)
if expires <= created:
raise InvalidExpirationError(
"Notifications should expire sometime after they are created"
)

def _default_lifespan(self) -> int:
"""
Returns the default lifespan of this notification in ms.
"""
return get_config().lifespan * 24 * 60 * 60 * 1000

def to_dict(self) -> dict:
"""
Returns a dict form of the Notification.
Useful for storing in a document store, returns the id of each verb and level.
Less useful, but not terrible, for returning to a user.
"""
dict_form = {
"id": self.id,
"actor": self.actor,
"verb": self.verb.id,
"object": self.object,
"source": self.source,
"context": self.context,
"target": self.target,
"level": self.level.id,
"created": self.created,
"expires": self.expires,
"external_key": self.external_key
}
return dict_form

def to_json(self):
# returns a jsonifyable structure
# leave out target. don't need to see who else saw this.
return {
def user_view(self) -> dict:
"""
Returns a view of the Notification that's intended for the user.
That means we leave out the target and external keys.
"""
view = {
"id": self.id,
"actor": self.actor,
"verb": self.verb.infinitive,
"verb": self.verb.past_tense,
"object": self.object,
"source": self.source,
"context": self.context,
"level": self.level.name,
"time": self.time
"created": self.created,
"expires": self.expires
}
return view

def serialize(self):
def serialize(self) -> str:
"""
Serializes this notification for caching / simple storage.
Serializes this notification to a string for caching / simple storage.
Assumes it's been validated.
Just dumps it all to a json string.
"""
Expand All @@ -90,46 +160,71 @@ def serialize(self):
"s": self.source,
"t": self.target,
"l": self.level.id,
"m": self.time
"c": self.created,
"e": self.expires,
"x": self.external_key,
"n": self.context
}
return json.dumps(serial, separators=(',', ':'))

@classmethod
def deserialize(cls, serial):
def deserialize(cls, serial: str):
"""
Deserializes and returns a new Notification instance.
"""
if serial is None:
return None
struct = json.loads(serial)
try:
assert serial
except AssertionError:
raise InvalidNotificationError("Can't deserialize an input of 'None'")
try:
struct = json.loads(serial)
except json.JSONDecodeError:
raise InvalidNotificationError("Can only deserialize a JSON string")
required_keys = set(['a', 'v', 'o', 's', 'l', 't', 'c', 'i', 'e'])
missing_keys = required_keys.difference(struct.keys())
if missing_keys:
raise InvalidNotificationError('Missing keys: {}'.format(missing_keys))
deserial = cls(
struct['a'],
str(struct['v']),
struct['o'],
struct['s'],
level=str(struct['l']),
target=struct.get('t'),
context=struct.get('c')
context=struct.get('n'),
external_key=struct.get('x')
)
deserial.time = struct['m']
deserial.created = struct['c']
deserial.id = struct['i']
deserial.expires = struct['e']
return deserial

@classmethod
def from_dict(cls, serial):
def from_dict(cls, serial: dict):
"""
Returns a new Notification from a serialized dictionary (e.g. used in Mongo)
"""
assert serial
try:
assert serial is not None and isinstance(serial, dict)
except AssertionError:
raise InvalidNotificationError("Can only run 'from_dict' on a dict.")
required_keys = set([
'actor', 'verb', 'object', 'source', 'level', 'created', 'expires', 'id'
])
missing_keys = required_keys.difference(set(serial.keys()))
if missing_keys:
raise InvalidNotificationError('Missing keys: {}'.format(missing_keys))
deserial = cls(
serial['actor'],
str(serial['verb']),
serial['object'],
serial['source'],
level=str(serial['level']),
target=serial.get('target'),
context=serial.get('context')
context=serial.get('context'),
external_key=serial.get('external_key')
)
deserial.time = serial['created']
deserial.id = serial['act_id']
deserial.created = serial['created']
deserial.expires = serial['expires']
deserial.id = serial['id']
return deserial
6 changes: 5 additions & 1 deletion feeds/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
TODO: decide whether to use a class, or just a validated string. I'm leaning toward string.
"""
from .auth import validate_user_id
from .exceptions import InvalidActorError


def validate_actor(actor):
"""
TODO: groups can be actors, too, when that's ready.
"""
return validate_user_id(actor)
if validate_user_id(actor):
return True
else:
raise InvalidActorError("Actor '{}' is not a real user.".format(actor))
10 changes: 7 additions & 3 deletions feeds/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ def validate_service_token(token):
TODO: I know this is going to be rife with issues. The name of the token doesn't have
to be the service. But as long as it's a Service token, then it came from in KBase, so
everything should be ok.
TODO: Add 'source' to PUT notification endpoint.
"""
token = __fetch_token(token)
if token.get('type') == 'Service':
Expand All @@ -67,12 +66,17 @@ def validate_user_token(token):
"""
Validates a user auth token.
If valid, returns the user id. If invalid, raises an InvalidTokenError.
If debug is True, always validates and returns a nonsense user name
"""
return __fetch_token(token)['user']


def validate_user_id(user_id):
return validate_user_ids([user_id])
"""
Validates whether a SINGLE user is real or not.
Returns a boolean.
"""
return user_id in validate_user_ids([user_id])


def validate_user_ids(user_ids):
Expand All @@ -95,7 +99,7 @@ def validate_user_ids(user_ids):
filtered_users = set(user_ids).difference(set(users))
if not filtered_users:
return users
r = __auth_request('users?list={}'.format(','.join(filtered_users)))
r = __auth_request('users?list={}'.format(','.join(filtered_users)), config.auth_token)
found_users = json.loads(r.content)
__user_cache.update(found_users)
users.update(found_users)
Expand Down
8 changes: 7 additions & 1 deletion feeds/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
KEY_ADMIN_LIST = "admins"
KEY_GLOBAL_FEED = "global-feed"
KEY_DEBUG = "debug"
KEY_LIFESPAN = "lifespan"


class FeedsConfig(object):
Expand Down Expand Up @@ -52,6 +53,11 @@ def __init__(self):
self.global_feed = self._get_line(cfg, KEY_GLOBAL_FEED)
self.auth_url = self._get_line(cfg, KEY_AUTH_URL)
self.admins = self._get_line(cfg, KEY_ADMIN_LIST).split(",")
self.lifespan = self._get_line(cfg, KEY_LIFESPAN)
try:
self.lifespan = int(self._get_line(cfg, KEY_LIFESPAN))
except ValueError:
raise ConfigError("{} must be an int! Got {}".format(KEY_LIFESPAN, self.lifespan))
self.debug = self._get_line(cfg, KEY_DEBUG, required=False)
if not self.debug or self.debug.lower() != "true":
self.debug = False
Expand Down Expand Up @@ -108,7 +114,7 @@ def _get_line(self, config, key, required=True):
__config = None


def get_config():
def get_config(from_disk=False):
global __config
if not __config:
__config = FeedsConfig()
Expand Down
14 changes: 14 additions & 0 deletions feeds/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,17 @@ class ActivityRetrievalError(Exception):
Raised if the service fails to retrieve an activity from a database.
"""
pass


class InvalidExpirationError(Exception):
"""
Raised when trying to give a Notification an invalid expiration time.
"""
pass


class InvalidNotificationError(Exception):
"""
Raised when trying to deserialize a Notification that has been stored badly.
"""
pass
4 changes: 3 additions & 1 deletion feeds/notification_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ def translate_level(level):
:param level: Either a string or a Level. (stringify numerical ids before looking them up)
"""
if isinstance(level, str):
if isinstance(level, int):
return get_level(str(level))
elif isinstance(level, str):
return get_level(level)
elif isinstance(level, Level):
return get_level(level.name)
Expand Down
Loading

0 comments on commit 1181876

Please sign in to comment.