Skip to content

Commit d8679cf

Browse files
committedJan 10, 2019
Mostly done with major update to entity usage
1 parent 83f49e3 commit d8679cf

21 files changed

+600
-292
lines changed
 

‎README.md

+44-12
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,38 @@ make test
3030
(see [the API design doc](design/api.md) for details, also see below.)
3131
### Data Structures
3232

33+
**Entity**
34+
An Entity structure defines, loosely, an entity related to a notification. This will make more sense below in the Notification object, but Entities are all of the actors in the notifications, the objects of each notification, users who receive the notification, and targets of the notification. For example, in the notification defined by the phrase:
35+
```
36+
User "wjriehl" invited user "some_other_user" to join the group "Bill's Fancy Group".
37+
```
38+
There are 3 entities:
39+
1. wjriehl is an entity of type user
40+
2. some_other_user is an entity of type user
41+
3. Bill's Fancy Group is an entity of type group
42+
These are used as a vocabulary to strictly control what's meant in the notification structure.
43+
44+
The types currently supported are:
45+
* user - id should be a user id
46+
* group - id should be a group id
47+
* workspace - id should be a workspace id
48+
* narrative - id should be a workspace id (maybe an UPA if needed)
49+
* job - id should be a job id string
50+
{
51+
"id": string - an id for this notification, defined by context,
52+
"type": string - what type of entity this is. Allowed values below.
53+
"name": string (optional) - the full name of this entity (not stored, as its possible it could change)
54+
}
55+
```
56+
3357
**Notification**
3458
```
3559
{
3660
"id": string - a unique id for the notification,
37-
"actor": string - the id of the actor who triggered the notification,
38-
"actor_name": string - the real name (whether group or user) of the actor,
39-
"actor_type": string - (one of group, user) - type of the actor,
61+
"actor": Entity - the Entity who triggered the notification (mostly a user),
4062
"verb": string - the action represented by this notification (see list of verbs below),
41-
"object": string - the object of the notification,
42-
"target": list - the target(s) of the notification,
63+
"object": Entity - the object of the notification,
64+
"target": list(Entity) - the target(s) of the notification,
4365
"source": string - the source service that created the notification,
4466
"level": string, one of alert, error, warning, request,
4567
"seen": boolean, if true, then this has been seen before,
@@ -190,7 +212,7 @@ This is meant for services to debug their use of external keys. A service that c
190212
### Create a new notification
191213
Only services (i.e. those Authorization tokens with type=Service, as told by the Auth service) can use this endpoint to create a new notification. This requires the body to be a JSON structure with the following available keys (pretty similar to the Notification structure above):
192214
* `source` - required, this is the source service of the request.
193-
* `actor` - required, should be a kbase username
215+
* `actor` - required, should be a valid Entity
194216
* `object` - required, the object of the notice (the workspace being shared, the group being invited to, etc.)
195217
* `verb` - required, the action implied by this notice. Currently allowed verbs are:
196218
* invite / invited
@@ -207,10 +229,11 @@ Only services (i.e. those Authorization tokens with type=Service, as told by the
207229
* warning
208230
* error
209231
* request
210-
* `target` - (*TODO: update this field*) - currently required, this is a list of user ids that are affected by this notification; and it is also the list of users who see the notification.
232+
* `target` - optional, but if present should be a list of Entities - the targets of the notification
211233
* `expires` - optional, an expiration date for the notification in number of milliseconds since the epoch. Default is 30 days after creation.
212234
* `external_key` - optional, a string that can be used to look up notifications from a service.
213235
* `context` - optional, a key-value pair structure that can have some semantic meaning for the notification. "Special" keys are `text` - which is used to generate the viewed text in the browser (omitting this will autogenerate the text from the other attributes), and `link` - a URL used to craft a hyperlink in the browser.
236+
* `users` - optional, a list of Entities that should receive the notification (limited to users and groups). This list will be automatically augmented by the service if necessary. E.g. if a workspace is the object of the notification, then the workspace admins will be notified.
214237

215238
**Usage:**
216239
* Path: `/api/V1/notification`
@@ -224,20 +247,29 @@ Only services (i.e. those Authorization tokens with type=Service, as told by the
224247
```python
225248
import requests
226249
note = {
227-
"actor": "wjriehl",
250+
"actor": {
251+
"id": "wjriehl",
252+
"type": "user"
253+
},
228254
"source": "workspace",
229255
"verb": "shared",
230-
"object": "30000",
231-
"target": ["gaprice"],
256+
"object": {
257+
"id": "30000",
258+
"type": "workspace"
259+
},
260+
"target": [{
261+
"id": "gaprice",
262+
"type": "user"
263+
}],
232264
"context": {
233-
"text": "User wjriehl shared workspace 30000 with user gaprice." # this can also be auto-generated
265+
"text": "User wjriehl shared workspace 30000 with user gaprice." # this can also be auto-generated by the UI.
234266
}
235267
}
236268
r = requests.post("https://<service_url>/api/V1/notification", json=note, headers={"Authorization": auth_token})
237269
```
238270
would return:
239271
```python
240-
{"id": "some-uuid-for-the-notification"}
272+
{"id": "some-unique-id-for-the-notification"}
241273
```
242274

243275
### Mark notifications as seen

‎TODO.md

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
* Integrate with Groups
33
* Make Groups service API
44
* Include groups as an actor, target, object
5+
* Needs full name lookup
6+
* Needs separation between user/group for each type
7+
* Probably need to separate target/object from just a list of strings into a list of objects
8+
* each object = id, type
9+
510
* Return groups in the feeds lookup
611
* new "group" key
712
* Include total number of unexpired notifications for a user

‎feeds/activity/notification.py

+56-45
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,26 @@
33
import json
44
from ..util import epoch_ms
55
from .. import verbs
6-
from ..actor import validate_actor
76
from .. import notification_level
87
from feeds.exceptions import (
98
InvalidExpirationError,
109
InvalidNotificationError
1110
)
1211
import datetime
1312
from feeds.config import get_config
13+
from feeds.entity import Entity
14+
from typing import (
15+
List,
16+
TypeVar
17+
)
18+
N = TypeVar('N', bound='Notification')
1419

1520

1621
class Notification(BaseActivity):
17-
def __init__(self, actor: str, verb, note_object: str, source: str, actor_name: str=None,
18-
actor_type: str='user', level='alert', target: list=None, context: dict=None,
19-
expires: int=None, external_key: str=None, seen: bool=False, users: list=None):
22+
def __init__(self, actor: Entity, verb: str, note_object: Entity, source: str,
23+
level='alert', target: List[Entity]=None, context: dict=None,
24+
expires: int=None, external_key: str=None, seen: bool=False,
25+
users: List[Entity]=None):
2026
"""
2127
A notification is roughly of this form:
2228
actor, verb, object, target
@@ -39,7 +45,6 @@ def __init__(self, actor: str, verb, note_object: str, source: str, actor_name:
3945
a group name
4046
:param source: source service for the note. String.
4147
:param actor_name: Real name of the actor (or None)
42-
:param actor_type: Type of actor. Allowed = 'user', 'group'
4348
:param level: The level of the notification. Allowed values are alert, warning, request,
4449
error (default "alert")
4550
:param target: target of the note. Optional. Should be a user id or group id if present.
@@ -60,21 +65,16 @@ def __init__(self, actor: str, verb, note_object: str, source: str, actor_name:
6065
* validate context fits
6166
"""
6267
assert actor is not None, "actor must not be None"
63-
assert actor_type in ['user', 'group'], "actor_type must be either 'user' or 'group'"
6468
assert verb is not None, "verb must not be None"
6569
assert note_object is not None, "note_object must not be None"
6670
assert source is not None, "source must not be None"
6771
assert level is not None, "level must not be None"
6872
assert target is None or isinstance(target, list), "target must be either a list or None"
6973
assert users is None or isinstance(users, list), "users must be either a list or None"
7074
assert context is None or isinstance(context, dict), "context must be either a dict or None"
71-
assert actor_name is None or isinstance(actor_name, str), \
72-
"actor_name must be either a str or None"
7375

7476
self.id = str(uuid.uuid4())
7577
self.actor = actor
76-
self.actor_name = actor_name
77-
self.actor_type = actor_type
7878
self.verb = verbs.translate_verb(verb)
7979
self.object = note_object
8080
self.source = source
@@ -90,13 +90,16 @@ def __init__(self, actor: str, verb, note_object: str, source: str, actor_name:
9090
self.seen = seen
9191
self.users = users
9292

93-
def validate(self):
93+
def validate(self) -> None:
9494
"""
9595
Validates whether the notification fields are accurate. Should be called before
9696
sending a new notification to storage.
97+
Raises exceptions if invalid. Currently just raises an InvalidExpirationError, as
98+
that's all it checks...
9799
"""
98100
self.validate_expiration(self.expires, self.created)
99-
validate_actor(self.actor)
101+
# TODO: do this later if we need to
102+
# validate_actor(self.actor)
100103

101104
def validate_expiration(self, expires: int, created: int):
102105
"""
@@ -133,70 +136,80 @@ def to_dict(self) -> dict:
133136
"""
134137
dict_form = {
135138
"id": self.id,
136-
"actor": self.actor,
139+
"actor": self.actor.to_dict(),
137140
"verb": self.verb.id,
138-
"object": self.object,
141+
"object": self.object.to_dict(),
139142
"source": self.source,
140143
"context": self.context,
141-
"target": self.target,
142144
"level": self.level.id,
143145
"created": self.created,
144146
"expires": self.expires,
145147
"external_key": self.external_key,
146-
"users": self.users,
147-
"actor_type": self.actor_type
148148
}
149+
target_dict = []
150+
if self.target is not None:
151+
target_dict = [t.to_dict() for t in self.target]
152+
dict_form["target"] = target_dict
153+
user_dict = []
154+
if self.users is not None:
155+
user_dict = [u.to_dict() for u in self.users]
156+
dict_form["users"] = user_dict
157+
149158
return dict_form
150159

151160
def user_view(self) -> dict:
152161
"""
153162
Returns a view of the Notification that's intended for the user.
154-
That means we leave out the target and external keys.
163+
That means we leave out the users and external keys.
155164
"""
156165
view = {
157166
"id": self.id,
158-
"actor": self.actor,
159-
"actor_name": self.actor_name,
160-
"actor_type": self.actor_type,
167+
"actor": self.actor.to_dict(with_name=True),
161168
"verb": self.verb.past_tense,
162-
"object": self.object,
169+
"object": self.object.to_dict(with_name=True),
163170
"source": self.source,
164171
"context": self.context,
165172
"target": self.target,
166173
"level": self.level.name,
167174
"created": self.created,
168175
"expires": self.expires,
169-
"seen": self.seen,
170-
"external_key": self.external_key
176+
"seen": self.seen
171177
}
178+
target_dict = []
179+
if self.target is not None:
180+
target_dict = [t.to_dict(with_name=True) for t in self.target]
181+
view["target"] = target_dict
182+
user_dict = []
183+
if self.users is not None:
184+
user_dict = [u.to_dict(with_name=True) for u in self.users]
185+
view["users"] = user_dict
186+
172187
return view
173188

174189
def serialize(self) -> str:
175190
"""
176-
Serializes this notification to a string for caching / simple storage.
191+
Serializes this notification to a string for caching / simple storage (e.g. Redis).
177192
Assumes it's been validated.
178193
Just dumps it all to a json string.
179194
"""
180195
serial = {
181196
"i": self.id,
182-
"a": self.actor,
183-
"an": self.actor_name,
184-
"at": self.actor_type,
197+
"a": str(self.actor),
185198
"v": self.verb.id,
186-
"o": self.object,
199+
"o": str(self.object),
187200
"s": self.source,
188-
"t": self.target,
201+
"t": [str(t) for t in self.target],
189202
"l": self.level.id,
190203
"c": self.created,
191204
"e": self.expires,
192205
"x": self.external_key,
193206
"n": self.context,
194-
"u": self.users
207+
"u": [str(u) for u in self.users]
195208
}
196209
return json.dumps(serial, separators=(',', ':'))
197210

198211
@classmethod
199-
def deserialize(cls, serial: str):
212+
def deserialize(cls, serial: str) -> N:
200213
"""
201214
Deserializes and returns a new Notification instance.
202215
"""
@@ -212,26 +225,26 @@ def deserialize(cls, serial: str):
212225
missing_keys = required_keys.difference(struct.keys())
213226
if missing_keys:
214227
raise InvalidNotificationError('Missing keys: {}'.format(missing_keys))
228+
users = [Entity.from_str(u) for u in struct.get('u', [])]
229+
target = [Entity.from_str(t) for t in struct.get('t', [])]
215230
deserial = cls(
216-
struct['a'],
231+
Entity.from_str(struct['a']),
217232
str(struct['v']),
218-
struct['o'],
233+
Entity.from_str(struct['o']),
219234
struct['s'],
220-
actor_type=struct.get('at', 'user'),
221235
level=str(struct['l']),
222-
target=struct.get('t'),
236+
target=target,
223237
context=struct.get('n'),
224238
external_key=struct.get('x'),
225-
users=struct.get('u')
239+
users=users
226240
)
227241
deserial.created = struct['c']
228242
deserial.id = struct['i']
229243
deserial.expires = struct['e']
230-
deserial.actor_name = struct['an']
231244
return deserial
232245

233246
@classmethod
234-
def from_dict(cls, serial: dict):
247+
def from_dict(cls, serial: dict) -> N:
235248
"""
236249
Returns a new Notification from a serialized dictionary (e.g. used in Mongo)
237250
"""
@@ -246,18 +259,16 @@ def from_dict(cls, serial: dict):
246259
if missing_keys:
247260
raise InvalidNotificationError('Missing keys: {}'.format(missing_keys))
248261
deserial = cls(
249-
serial['actor'],
262+
Entity.from_dict(serial['actor']),
250263
str(serial['verb']),
251-
serial['object'],
264+
Entity.from_dict(serial['object']),
252265
serial['source'],
253266
level=str(serial['level']),
254-
target=serial.get('target'),
267+
target=[Entity.from_dict(t) for t in serial.get('target', [])],
255268
context=serial.get('context'),
256269
external_key=serial.get('external_key'),
257270
seen=serial.get('seen', False),
258-
users=serial.get('users'),
259-
actor_name=serial.get('actor_name'),
260-
actor_type=serial.get('actor_type', 'user')
271+
users=[Entity.from_dict(u) for u in serial.get('users', [])]
261272
)
262273
deserial.created = serial['created']
263274
deserial.expires = serial['expires']

‎feeds/actor.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
validate_user_id,
77
validate_user_ids
88
)
9+
from .external_api.groups import (
10+
validate_group_id
11+
)
912
from .exceptions import InvalidActorError
1013

1114

@@ -16,8 +19,10 @@ def validate_actor(actor, actor_type="user"):
1619
else:
1720
raise InvalidActorError("Actor '{}' is not a real user.".format(actor))
1821
elif actor_type == "group":
19-
# pending api
20-
return True
22+
if validate_group_id(actor):
23+
return True
24+
else:
25+
raise InvalidActorError("Actor '{}' is not a real group.".format(actor))
2126

2227

2328
def actor_ids_to_names(id_list: list):

‎feeds/api/admin_v1.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
parse_notification_params,
1919
parse_expire_notifications_params
2020
)
21+
from feeds.entity import Entity
2122

2223
cfg = get_config()
2324
admin_v1 = flask.Blueprint('admin_v1', __name__)
@@ -45,15 +46,15 @@ def add_global_notification():
4546

4647
params = parse_notification_params(json.loads(request.get_data()), is_global=True)
4748
new_note = Notification(
48-
'kbase',
49+
Entity('kbase', 'admin'),
4950
params.get('verb'),
50-
params.get('object'),
51+
Entity('kbase', 'admin'),
5152
'kbase',
5253
level=params.get('level'),
5354
context=params.get('context'),
5455
expires=params.get('expires')
5556
)
56-
global_feed = NotificationFeed(cfg.global_feed)
57+
global_feed = NotificationFeed(cfg.global_feed, cfg.global_feed_type)
5758
global_feed.add_notification(new_note)
5859
return (flask.jsonify({'id': new_note.id}), 200)
5960

‎feeds/api/api_v1.py

+8-8
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def get_notifications():
5555
1. validate/authenticate user
5656
2. make user feed object
5757
3. query user feed for most recent, based on params
58+
#TODO: support group "feeds"
5859
"""
5960
max_notes = request.args.get('n', default=10, type=int)
6061

@@ -75,14 +76,14 @@ def get_notifications():
7576
include_seen = False if include_seen == 0 else True
7677
user_id = validate_user_token(get_auth_token(request))
7778
log(__name__, 'Getting feed for {}'.format(user_id))
78-
feed = NotificationFeed(user_id)
79+
feed = NotificationFeed(user_id, "user")
7980
user_notes = feed.get_notifications(
8081
count=max_notes, include_seen=include_seen, level=level_filter,
8182
verb=verb_filter, reverse=rev_sort, user_view=True
8283
)
8384

8485
# fetch the globals
85-
global_feed = NotificationFeed(cfg.global_feed)
86+
global_feed = NotificationFeed(cfg.global_feed, cfg.global_feed_type)
8687
global_notes = global_feed.get_notifications(count=max_notes, user_view=True)
8788
return_vals = {
8889
"user": user_notes,
@@ -132,7 +133,6 @@ def add_notification():
132133
params.get('verb'),
133134
params.get('object'),
134135
params.get('source'),
135-
actor_type=params.get('actor_type', 'user'),
136136
level=params.get('level'),
137137
target=params.get('target', []),
138138
context=params.get('context'),
@@ -150,7 +150,7 @@ def add_notification():
150150
@api_v1.route('/notifications/global', methods=['GET'])
151151
@cross_origin()
152152
def get_global_notifications():
153-
global_feed = NotificationFeed(cfg.global_feed)
153+
global_feed = NotificationFeed(cfg.global_feed, cfg.global_feed_type)
154154
global_notes = global_feed.get_notifications(user_view=True)
155155
return flask.jsonify(global_notes)
156156

@@ -190,11 +190,11 @@ def get_single_notification(note_id):
190190
Should only return the note with that id if it's in the user's feed.
191191
"""
192192
user_id = validate_user_token(get_auth_token(request))
193-
feed = NotificationFeed(user_id)
193+
feed = NotificationFeed(user_id, "user")
194194
try:
195195
note = feed.get_notification(note_id)
196196
except NotificationNotFoundError:
197-
note = NotificationFeed(cfg.global_feed).get_notification(note_id)
197+
note = NotificationFeed(cfg.global_feed, cfg.global_feed_type).get_notification(note_id)
198198
return (flask.jsonify({'notification': note.user_view()}), 200)
199199

200200

@@ -212,7 +212,7 @@ def mark_notifications_unseen():
212212
params = _get_mark_notification_params(json.loads(request.get_data()))
213213
note_ids = params.get('note_ids')
214214

215-
feed = NotificationFeed(user_id)
215+
feed = NotificationFeed(user_id, "user")
216216
unauthorized_notes = list()
217217
for note_id in note_ids:
218218
try:
@@ -241,7 +241,7 @@ def mark_notifications_seen():
241241
params = _get_mark_notification_params(json.loads(request.get_data()))
242242
note_ids = params.get('note_ids')
243243

244-
feed = NotificationFeed(user_id)
244+
feed = NotificationFeed(user_id, "user")
245245
unauthorized_notes = list()
246246
for note_id in note_ids:
247247
try:

‎feeds/api/util.py

+32-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
IllegalParameterError,
33
MissingParameterError
44
)
5+
from feeds.entity import Entity
56

67

78
def parse_notification_params(params: dict, is_global: bool=False) -> dict:
@@ -10,27 +11,51 @@ def parse_notification_params(params: dict, is_global: bool=False) -> dict:
1011
Raises a MissingParameter error otherwise.
1112
Returns the params after parsing (currently does nothing, but if
1213
transformations are needed in the future, here's where that happens).
14+
In total, can raise:
15+
MissingParameterError (if required params are missing)
16+
IllegalParameterError (if the wrong types are present)
17+
EntityValidationError (if an Entity is malformed)
1318
"""
14-
# * `actor` - a user or org id.
15-
# * `type` - one of the type keywords (see below, TBD (as of 10/8))
16-
# * `target` - optional, a user or org id. - always receives this notification
17-
# * `object` - object of the notice. For invitations, the group to be invited to.
18-
# For narratives, the narrative UPA.
19+
# * `actor` - an Entity structure - gets turned into an Entity object when returned
20+
# * `type` - one of the type keywords
21+
# * `target` - optional, a list of Entity structures. This gets turned into a list of
22+
# entity object on return
23+
# * `object` - object of the notice, an Entity structure. For invitations, the group to be
24+
# invited to. For narratives, the narrative UPA. Gets turned into an Entity object
25+
# when returned
26+
# * `users` - a list of Entity objects, should be of either type user or group
1927
# * `level` - alert, error, warning, or request.
2028
# * `context` - optional, context of the notification, otherwise it'll be
2129
# autogenerated from the info above.
2230

2331
if not isinstance(params, dict):
2432
raise IllegalParameterError('Expected a JSON object as an input.')
25-
required_list = ['verb', 'object', 'level']
33+
required_list = ['verb', 'level']
2634
if not is_global:
27-
required_list = required_list + ['actor', 'target', 'source']
35+
required_list = required_list + ['actor', 'source', 'object']
2836
missing = [r for r in required_list if r not in params or params.get(r) is None]
2937
if missing:
3038
raise MissingParameterError("Missing parameter{} - {}".format(
3139
"s" if len(missing) > 1 else '',
3240
", ".join(missing)
3341
))
42+
if not is_global:
43+
# do the entity transformations
44+
# If there are any EntityValidationErrors, they'll pop on up.
45+
params["actor"] = Entity.from_dict(params["actor"])
46+
params["object"] = Entity.from_dict(params["object"])
47+
if "target" in params:
48+
target = params["target"]
49+
if isinstance(target, list):
50+
params["target"] = [Entity.from_dict(t) for t in target]
51+
else:
52+
raise IllegalParameterError("Expected target to be a list of Entity structures.")
53+
if "users" in params:
54+
users = params["users"]
55+
if isinstance(users, list):
56+
params["users"] = [Entity.from_dict(u) for u in users]
57+
else:
58+
raise IllegalParameterError("Expected users to be a list of Entity structures.")
3459
return params
3560

3661

‎feeds/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(self):
5353
self.db_pw = self._get_line(cfg, KEY_DB_PW, required=False)
5454
self.db_name = self._get_line(cfg, KEY_DB_NAME, required=False)
5555
self.global_feed = self._get_line(cfg, KEY_GLOBAL_FEED)
56+
self.global_feed_type = "user" # doesn't matter, need a valid Entity type...
5657
self.lifespan = self._get_line(cfg, KEY_LIFESPAN)
5758
try:
5859
self.lifespan = int(self._get_line(cfg, KEY_LIFESPAN))

‎feeds/exceptions.py

+35
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,38 @@ class NotificationNotFoundError(Exception):
105105
or isn't in the user's feed.
106106
"""
107107
pass
108+
109+
110+
class GroupsError(Exception):
111+
"""
112+
Generic exception wrapped around any response from the Groups server.
113+
"""
114+
pass
115+
116+
117+
class EntityNameError(Exception):
118+
"""
119+
Exception thrown when a failure to find an entity name happens
120+
"""
121+
pass
122+
123+
124+
class EntityValidationError(Exception):
125+
"""
126+
Thrown when an entity is unable to be validated
127+
"""
128+
pass
129+
130+
131+
class WorkspaceError(Exception):
132+
"""
133+
Generic exception wrapper for Workspace calls.
134+
"""
135+
pass
136+
137+
138+
class JobError(Exception):
139+
"""
140+
Generic wrapper around exceptions from Job Service calls.
141+
"""
142+
pass

‎feeds/external_api/groups.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from ..config import get_config
2-
# import json
32
import requests
43
from typing import (
54
List,
65
Dict
76
)
7+
from feeds.exceptions import GroupsError
8+
89
config = get_config()
910
GROUPS_URL = config.groups_url
1011

@@ -31,7 +32,13 @@ def validate_group_id(group_id: str) -> bool:
3132
"""
3233
r = __groups_request("/group/{}/exists".format(group_id))
3334
res = r.json()
34-
return res.get('exists', False)
35+
if 'exists' in res:
36+
return res['exists']
37+
elif 'error' in res:
38+
raise GroupsError(
39+
"Error while looking up group id: " +
40+
res['error'].get('message', 'no message available')
41+
)
3542

3643

3744
def __groups_request(path: str, token: str=None) -> Response:

‎feeds/feeds/notification/notification_feed.py

+20-17
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
from cachetools import TTLCache
66
import logging
77
from feeds.exceptions import NotificationNotFoundError
8-
from feeds.actor import actor_ids_to_names
8+
# from feeds.actor import actor_ids_to_names
9+
from feeds.entity import Entity
910

1011

1112
class NotificationFeed(BaseFeed):
12-
def __init__(self, user_id):
13-
self.user_id = user_id
14-
self.timeline_storage = MongoTimelineStorage(self.user_id)
13+
def __init__(self, user_id, user_type):
14+
self.user = Entity(user_id, user_type)
15+
self.timeline_storage = MongoTimelineStorage(user_id, user_type)
1516
self.activity_storage = MongoActivityStorage()
1617
self.timeline = None
1718
self.cache = TTLCache(1000, 600)
@@ -24,7 +25,9 @@ def _update_timeline(self):
2425
2526
TODO: add metadata to timeline storage - type and verb, first.
2627
"""
27-
logging.getLogger(__name__).info('Fetching timeline for ' + self.user_id)
28+
logging.getLogger(__name__).info(
29+
'Fetching timeline for '.format(self.user)
30+
)
2831
self.timeline = self.timeline_storage.get_timeline()
2932

3033
def get_notifications(self, count: int=10, include_seen: bool=False, level=None, verb=None,
@@ -92,17 +95,17 @@ def get_activities(self, count=10, include_seen=False, level=None, verb=None,
9295
level=level, verb=verb, reverse=reverse
9396
)
9497
note_list = list()
95-
actor_ids = set()
98+
# actor_ids = set()
9699
for note in serial_notes:
97-
actor_ids.add(note["actor"])
98-
if self.user_id not in note["unseen"]:
99-
note["seen"] = True
100-
else:
101-
note["seen"] = False
100+
# actor_ids.add(note["actor"].id)
101+
# if self.user_id not in note["unseen"]:
102+
# note["seen"] = True
103+
# else:
104+
# note["seen"] = False
102105
note_list.append(Notification.from_dict(note))
103-
actor_names = actor_ids_to_names(list(actor_ids))
104-
for note in note_list:
105-
note.actor_name = actor_names.get(note.actor, {}).get("name")
106+
# actor_names = actor_ids_to_names(list(actor_ids))
107+
# for note in note_list:
108+
# note.actor_name = actor_names.get(note.actor, {}).get("name")
106109
return note_list
107110

108111
def mark_activities(self, activity_ids, seen=False):
@@ -112,9 +115,9 @@ def mark_activities(self, activity_ids, seen=False):
112115
changed for that activity.
113116
"""
114117
if seen:
115-
self.activity_storage.set_seen(activity_ids, self.user_id)
118+
self.activity_storage.set_seen(activity_ids, self.user)
116119
else:
117-
self.activity_storage.set_unseen(activity_ids, self.user_id)
120+
self.activity_storage.set_unseen(activity_ids, self.user)
118121

119122
def add_notification(self, note):
120123
return self.add_activity(note)
@@ -123,7 +126,7 @@ def add_activity(self, note):
123126
"""
124127
Adds an activity to this user's feed
125128
"""
126-
self.activity_storage.add_to_storage(note, [self.user_id])
129+
self.activity_storage.add_to_storage(note, [self.user])
127130

128131
def get_unseen_count(self):
129132
"""

‎feeds/managers/fanout_modules/base.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from abc import abstractmethod
22
from feeds.activity.notification import Notification
3+
from feeds.entity import Entity
4+
from typing import List
35

46

57
class FanoutModule(object):
@@ -12,9 +14,9 @@ def __init__(self, note: Notification):
1214
self.note = note
1315

1416
@abstractmethod
15-
def get_target_users(self):
17+
def get_target_users(self) -> List[Entity]:
1618
"""
17-
This should always return a list, even an empty one.
19+
This should always return a list of Entities, even an empty one.
1820
Ideally, it'll be a list of users that should see the notification.
1921
"""
2022
pass
+3-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from .base import FanoutModule
22
from feeds.config import get_config
3+
from feeds.entity import Entity
34

45

56
class KBaseFanout(FanoutModule):
67
def get_target_users(self):
7-
return [get_config().global_feed]
8+
cfg = get_config()
9+
return [Entity(cfg.global_feed, cfg.global_feed_type)]

‎feeds/managers/notification_manager.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .fanout_modules.workspace import WorkspaceFanout
1818
from .fanout_modules.jobs import JobsFanout
1919
from .fanout_modules.kbase import KBaseFanout
20+
from feeds.entity import Entity
2021

2122

2223
class NotificationManager(BaseManager):
@@ -35,7 +36,7 @@ def add_notification(self, note: Notification):
3536
activity_storage = MongoActivityStorage()
3637
activity_storage.add_to_storage(note, target_users)
3738

38-
def get_target_users(self, note: Notification) -> List[str]:
39+
def get_target_users(self, note: Notification) -> List[Entity]:
3940
"""
4041
This is gonna get complex.
4142
The target users are a combination of:
@@ -62,7 +63,7 @@ def get_target_users(self, note: Notification) -> List[str]:
6263
return user_list
6364

6465
def expire_notifications(self, note_ids: list, external_keys: list, source: str=None,
65-
is_admin: bool=False):
66+
is_admin: bool=False) -> Dict[str, list]:
6667
"""
6768
Expires notifications.
6869
All notifications identified by either their id, or external key, must come from the same
@@ -104,7 +105,8 @@ def expire_notifications(self, note_ids: list, external_keys: list, source: str=
104105
"expired": expired
105106
}
106107

107-
def get_notifications_by_ext_keys(self, external_keys: List[str], source: str) -> Dict:
108+
def get_notifications_by_ext_keys(self, external_keys: List[str],
109+
source: str) -> Dict[str, Notification]:
108110
"""
109111
Fetches notifications by their external key and source.
110112
These are returned as a dictionary where the keys are the

‎feeds/storage/base.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ def remove_from_storage(self, activity_ids):
2424

2525

2626
class TimelineStorage(BaseStorage):
27-
def __init__(self, user_id):
27+
def __init__(self, user_id, user_type):
2828
assert user_id
2929
self.user_id = user_id
30+
assert user_type
31+
self.user_type = user_type # should align with entity types
3032

3133
def add_to_timeline(self, activity):
3234
raise NotImplementedError()

‎feeds/storage/mongodb/activity_storage.py

+15-11
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,48 @@
1-
from typing import List
1+
from typing import (
2+
List,
3+
Dict
4+
)
25
from ..base import ActivityStorage
36
from .connection import get_feeds_collection
47
from feeds.exceptions import (
58
ActivityStorageError
69
)
710
from pymongo.errors import PyMongoError
811
from feeds.util import epoch_ms
12+
from feeds.entity import Entity
913

1014

1115
class MongoActivityStorage(ActivityStorage):
12-
def add_to_storage(self, activity, target_users: List[str]):
16+
def add_to_storage(self, activity, target_users: List[Entity]) -> None:
1317
"""
1418
Adds a single activity to the MongoDB.
1519
Returns None if successful.
1620
Raises an ActivityStorageError if it fails.
1721
"""
1822
coll = get_feeds_collection()
1923
act_doc = activity.to_dict()
20-
act_doc["users"] = target_users
21-
act_doc["unseen"] = target_users
24+
act_doc["users"] = [t.to_dict() for t in target_users]
25+
act_doc["unseen"] = [t.to_dict() for t in target_users]
2226
try:
2327
coll.insert_one(act_doc)
2428
except PyMongoError as e:
2529
raise ActivityStorageError("Failed to store activity: " + str(e))
2630

27-
def set_unseen(self, act_ids: List[str], user: str):
31+
def set_unseen(self, act_ids: List[str], user: Entity) -> None:
2832
"""
2933
Setting unseen means adding the user to the list of unseens. But we should only do that for
3034
docs that the user can't see anyway, so put that in the query.
3135
"""
3236
coll = get_feeds_collection()
3337
coll.update_many({
3438
'id': {'$in': act_ids},
35-
'users': user,
39+
'users': user.to_dict(),
3640
'unseen': {'$nin': [user]}
3741
}, {
3842
'$addToSet': {'unseen': user}
3943
})
4044

41-
def set_seen(self, act_ids: List[str], user: str):
45+
def set_seen(self, act_ids: List[str], user: Entity) -> None:
4246
"""
4347
Setting seen just means removing the user from the list of unseens.
4448
The query should find all docs in the list of act_ids, where the user
@@ -48,13 +52,13 @@ def set_seen(self, act_ids: List[str], user: str):
4852
coll = get_feeds_collection()
4953
coll.update_many({
5054
'id': {'$in': act_ids},
51-
'users': user,
55+
'users': user.to_dict(),
5256
'unseen': {'$all': [user]}
5357
}, {
5458
'$pull': {'unseen': user}
5559
})
5660

57-
def get_by_id(self, act_ids: List[str], source: str=None):
61+
def get_by_id(self, act_ids: List[str], source: str=None) -> Dict[str, dict]:
5862
"""
5963
If source is not None, return only those that match the source.
6064
Returns a dict mapping from note id to note
@@ -73,7 +77,7 @@ def get_by_id(self, act_ids: List[str], source: str=None):
7377
notes[d["id"]] = d
7478
return notes
7579

76-
def get_by_external_key(self, external_keys: List[str], source):
80+
def get_by_external_key(self, external_keys: List[str], source: str) -> Dict[str, dict]:
7781
"""
7882
Source HAS to exist here, it's part of the index.
7983
Returns a dict mapping from external_key to note
@@ -92,7 +96,7 @@ def get_by_external_key(self, external_keys: List[str], source):
9296
notes[d["external_key"]] = d
9397
return notes
9498

95-
def expire_notifications(self, act_ids: List[str]):
99+
def expire_notifications(self, act_ids: List[str]) -> None:
96100
"""
97101
Expires notifications by changing their expiration time to now.
98102
"""

‎feeds/storage/mongodb/timeline_storage.py

+23-11
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,39 @@
22
from ..base import TimelineStorage
33
from .connection import get_feeds_collection
44
from feeds.util import epoch_ms
5+
from feeds.activity.base import BaseActivity
6+
from feeds.notification_level import Level
7+
from feeds.verbs import Verb
8+
from typing import (
9+
List,
10+
Dict
11+
)
512

613

714
class MongoTimelineStorage(TimelineStorage):
815
"""
916
Returns the serialized/dictified storage elements, not the actual Activity objects.
1017
Meant to be able to deal with Activities that aren't just Notifications.
1118
"""
12-
def add_to_timeline(self, activity):
19+
def add_to_timeline(self, activity: BaseActivity) -> None:
1320
raise NotImplementedError()
1421

15-
def get_timeline(self, count: int=10, include_seen: int=False, level=None, verb=None,
16-
reverse: bool=False) -> list:
22+
def get_timeline(self, count: int=10, include_seen: int=False, level: Level=None,
23+
verb: Verb=None, reverse: bool=False) -> List[dict]:
1724
"""
1825
:param count: int > 0
1926
:param include_seen: boolean
2027
:param level: Level or None
2128
:param verb: Verb or None
2229
"""
23-
# TODO: input validation
2430
coll = get_feeds_collection()
2531
now = epoch_ms()
2632
query = {
27-
"users": self.user_id,
33+
"users": self._user_doc(),
2834
"expires": {"$gt": now}
2935
}
3036
if not include_seen:
31-
query['unseen'] = self.user_id
37+
query['unseen'] = self._user_doc()
3238
if level is not None:
3339
query['level'] = level.id
3440
if verb is not None:
@@ -40,11 +46,11 @@ def get_timeline(self, count: int=10, include_seen: int=False, level=None, verb=
4046
serial_notes = [note for note in timeline]
4147
return serial_notes
4248

43-
def get_single_activity_from_timeline(self, note_id):
49+
def get_single_activity_from_timeline(self, note_id: str) -> dict:
4450
coll = get_feeds_collection()
4551
query = {
4652
"id": note_id,
47-
"users": self.user_id
53+
"users": self._user_doc()
4854
}
4955
note_serial = coll.find_one(query)
5056
if note_serial is None:
@@ -55,12 +61,18 @@ def get_single_activity_from_timeline(self, note_id):
5561
note_serial['seen'] = True
5662
return note_serial
5763

58-
def get_unseen_count(self):
64+
def get_unseen_count(self) -> int:
5965
coll = get_feeds_collection()
6066
now = epoch_ms()
6167
query = {
62-
"users": self.user_id,
63-
"unseen": self.user_id,
68+
"users": self._user_doc(),
69+
"unseen": self._user_doc(),
6470
"expires": {"$gt": now}
6571
}
6672
return coll.find(query).count()
73+
74+
def _user_doc(self) -> Dict[str, str]:
75+
return {
76+
"id": self.user_id,
77+
"type": self.user_type
78+
}

‎test/_data/mongo/notifications.json

+303-156
Large diffs are not rendered by default.

‎test/api/test_admin_v1.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,25 @@ def test_expire_notification_admin_from_service(client, mongo_notes, mock_valid_
233233
# service creates two notifications - one with external key
234234
service_cred = {"Authorization": "token-"+str(uuid4())}
235235
note = {
236-
"actor": "kbasetest",
236+
"actor": {
237+
"id": "kbasetest",
238+
"type": "user"
239+
},
237240
"verb": 1,
238241
"level": 1,
239-
"object": "stuff",
240-
"users": ["kbasetest"],
242+
"object": {
243+
"id": "stuff",
244+
"type": "workspace"
245+
},
246+
"users": [{
247+
"id": "kbasetest",
248+
"type": "user"
249+
}],
241250
"source": source,
242-
"target": ["kbasetest"]
251+
"target": [{
252+
"id": "kbasetest",
253+
"type": "user"
254+
}]
243255
}
244256
response = client.post(
245257
"/api/V1/notification",

‎test/feeds/notification/test_notification_feed.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
from feeds.feeds.notification.notification_feed import NotificationFeed
33
from feeds.activity.notification import Notification
44

5+
USER = "test_user"
6+
USER_TYPE = "user"
7+
58
def test_get_notifications(mongo_notes):
69
"""
710
Imported the dataset. The main user is test_user, so get their feed in various ways.
811
"""
9-
user = "test_user"
10-
feed = NotificationFeed(user)
12+
feed = NotificationFeed(USER, USER_TYPE)
1113
# as NOT user_view, should be a list of Notification objects
1214
notes = feed.get_notifications()
1315
assert "feed" in notes and len(notes["feed"]) == 7
@@ -16,15 +18,13 @@ def test_get_notifications(mongo_notes):
1618
assert isinstance(n, Notification)
1719

1820
def test_get_notifications_fail(mongo_notes):
19-
user = "test_user"
20-
feed = NotificationFeed(user)
21+
feed = NotificationFeed(USER, USER_TYPE)
2122
with pytest.raises(ValueError) as e:
2223
feed.get_notifications(count=0)
2324
assert "Count must be an integer > 0" == str(e.value)
2425

2526
def test_update_timeline(mongo_notes):
26-
user = "test_user"
27-
feed = NotificationFeed(user)
27+
feed = NotificationFeed(USER, USER_TYPE)
2828
assert feed.timeline is None
2929
feed._update_timeline()
3030
assert feed.timeline is not None

‎test/test_server.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def test_server_missing_params(client, mock_valid_service_token):
178178
_validate_error(data, {
179179
'http_code': 422,
180180
'http_status': 'Unprocessable Entity',
181-
'message': 'Missing parameters - verb, object, level, target, source'
181+
'message': 'Missing parameters - verb, level, source, object'
182182
})
183183

184184

0 commit comments

Comments
 (0)
Please sign in to comment.