Skip to content

Commit ba82451

Browse files
authored
Merge pull request #33 from briehl/develop
Add expires endpoint.
2 parents 9386ee6 + 824423c commit ba82451

11 files changed

+400
-36
lines changed

.travis.yml

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ script:
2525
- export MONGOD=`pwd`/$MONGODB_VER/bin/mongod
2626
- sed -i "s#^mongo-exe.*#mongo-exe=$MONGOD#" test/test.cfg
2727
- cat test/test.cfg
28+
- make
2829
- make test
2930

3031
after_script:

Makefile

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
GITCOMMIT = $(shell git rev-parse HEAD)
2+
3+
all:
4+
echo "# Don't check this file into git please" > feeds/gitcommit.py
5+
echo 'commit = "$(GITCOMMIT)"' >> feeds/gitcommit.py
6+
17
install:
28
pip install -r requirements.txt
39
pip install -r dev-requirements.txt

README.md

+28-7
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,32 @@ Takes a list of notifications and marks them as unseen for the user who submitte
275275

276276
### Expire a notification right away
277277
This effectively deletes notifications by pushing their expiration time up to the time this request is made.
278-
* Path: `/api/V1/notification/expire`
278+
* Path: `/api/V1/notifications/expire`
279279
* Method: `POST`
280-
* Required header: `Authorization` - requires a service token.
281-
* URL Parameters: one of the following:
282-
* `id` = the notification id
283-
* `external_key` = the external key
284-
If both parameters are present, a 400 Bad Request error will be returned.
285-
Also note that services can only expire their own notifications.
280+
* Required header: `Authorization` - requires a service token or admin token.
281+
* Expected body:
282+
```
283+
{
284+
"note_ids": [ list of notification ids ],
285+
"external_keys": [ list of external keys ]
286+
}
287+
```
288+
At least one of the above keys must be present. If external keys are used, this must be called by a service
289+
with a valid service token that maps to the source key in the notification. That is, only services can expire
290+
their own notifications.
291+
292+
(For now, admins can only expire global notifications)
293+
* Returns:
294+
```
295+
{
296+
"expired": {
297+
"note_ids": [ list of expired notification ids ],
298+
"external_keys": [ list of expired notifications by external key ]
299+
},
300+
"unauthorized": {
301+
"note_ids": [ list of not-expired note ids ],
302+
"external_keys": [ list of not-expired external keys ]
303+
}
304+
}
305+
```
306+
This will include all of the ids passed to the endpoint, put into one category or the other. Any that were "unauthorized" either don't exist, or came from a different service than the given auth token.

feeds/api/api_v1.py

+69-2
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,6 @@ def mark_notifications_unseen():
194194
raise an error.
195195
Any of these ids that are global, do nothing... for now.
196196
"""
197-
198197
user_id = validate_user_token(get_auth_token(request))
199198

200199
params = _get_mark_notification_params(json.loads(request.get_data()))
@@ -224,7 +223,6 @@ def mark_notifications_seen():
224223
raise an error.
225224
Any of these ids that are global, do nothing... for now.
226225
"""
227-
228226
user_id = validate_user_token(get_auth_token(request))
229227

230228
params = _get_mark_notification_params(json.loads(request.get_data()))
@@ -245,6 +243,75 @@ def mark_notifications_seen():
245243
'unauthorized_notes': unauthorized_notes}), 200)
246244

247245

246+
@api_v1.route('/notifications/expire', methods=['POST'])
247+
@cross_origin()
248+
def expire_notifications():
249+
"""
250+
Notifications can be forced to expire (set their expiration time to now).
251+
This can be done by:
252+
* The service who created the notification
253+
* An admin
254+
Expects JSON in the body formatted like this:
255+
{
256+
"note_ids": [notification ids],
257+
"external_keys": [keys]
258+
}
259+
These keys are both optional, but at least one must be present. Any combination of external
260+
keys or ids is acceptable, even if they're the same notification.
261+
This returns the following:
262+
{
263+
"expired": {
264+
"note_ids": [],
265+
"external_keys": []
266+
}
267+
"unauthorized": {
268+
"note_ids": [],
269+
"external_keys": []
270+
}
271+
}
272+
This should just return the same lists of values that were input, just shuffled to
273+
their final status.
274+
"""
275+
token = get_auth_token(request)
276+
is_admin = is_feeds_admin(token)
277+
service = None
278+
try:
279+
service = validate_service_token(token)
280+
except InvalidTokenError:
281+
if not is_admin:
282+
raise InvalidTokenError('Auth token must be either a Service token '
283+
'or from a user with the FEEDS_ADMIN role!')
284+
data = _get_expire_notifications_params(json.loads(request.get_data()))
285+
manager = NotificationManager()
286+
result = manager.expire_notifications(data.get('note_ids', []), data.get('external_keys', []),
287+
source=service, is_admin=is_admin)
288+
return (flask.jsonify(result), 200)
289+
290+
291+
def _get_expire_notifications_params(params):
292+
if not isinstance(params, dict):
293+
raise IllegalParameterError('Expected a JSON object as an input.')
294+
295+
if 'note_ids' not in params and 'external_keys' not in params:
296+
raise MissingParameterError('Missing parameter "note_ids" or "external_keys"')
297+
298+
if not isinstance(params.get('note_ids', []), list):
299+
raise IllegalParameterError('Expected note_ids to be a list.')
300+
else:
301+
for i in params.get('note_ids', []):
302+
if not isinstance(i, str):
303+
raise IllegalParameterError('note_ids must be a list of strings')
304+
305+
if not isinstance(params.get('external_keys', []), list):
306+
raise IllegalParameterError('Expected external_keys to be a list.')
307+
else:
308+
for i in params.get('external_keys', []):
309+
if not isinstance(i, str):
310+
raise IllegalParameterError('external_keys must be a list of strings')
311+
312+
return params
313+
314+
248315
def _get_mark_notification_params(params):
249316
if not isinstance(params, dict):
250317
raise IllegalParameterError('Expected a JSON object as an input.')

feeds/managers/notification_manager.py

+40
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,43 @@ def get_target_users(self, note: Notification) -> List[str]:
5858
user_list = list(set(note.users + note.target))
5959

6060
return user_list
61+
62+
def expire_notifications(self, note_ids: list, external_keys: list, source: str=None, is_admin: bool=False):
63+
"""
64+
Expires notifications.
65+
All notifications identified by either their id, or external key, must come from the same
66+
source as in the source parameter (i.e. the 'source' key in the database must == the source).
67+
Or, an admin can expire any notification.
68+
"""
69+
# Get the notifications from the note_ids and external_keys.
70+
# -- mark as unauthorized the ones that don't exist.
71+
# If is_admin, cool, expire them all.
72+
# If source is not None and not is_admin, make a list of the ones that can be expired (from that source)
73+
# expire the ones that we should.
74+
# return the results.
75+
76+
storage = MongoActivityStorage()
77+
78+
notes_from_id = storage.get_by_id(note_ids, source=source)
79+
notes_from_ext_key = {}
80+
if source is not None and len(external_keys):
81+
notes_from_ext_key = storage.get_by_external_key(external_keys, source)
82+
unauthorized = {
83+
"note_ids": [k for k in notes_from_id if notes_from_id[k] is None],
84+
"external_keys": [k for k in notes_from_ext_key if notes_from_ext_key[k] is None]
85+
}
86+
ids_to_expire = list()
87+
expired = {"note_ids": [], "external_keys": []}
88+
for k,v in notes_from_id.items():
89+
if v is not None:
90+
ids_to_expire.append(k)
91+
expired["note_ids"].append(k)
92+
for k,v in notes_from_ext_key.items():
93+
if v is not None:
94+
ids_to_expire.append(v['id'])
95+
expired["external_keys"].append(v['external_key'])
96+
storage.expire_notifications(ids_to_expire)
97+
return {
98+
"unauthorized": unauthorized,
99+
"expired": expired
100+
}

feeds/server.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@
3333
log_error
3434
)
3535

36-
VERSION = "0.0.1"
36+
VERSION = "0.0.2"
37+
38+
try:
39+
from feeds import gitcommit
40+
except ImportError: # pragma: no cover
41+
# tested manually
42+
raise ValueError('Did not find git commit file at feeds/gitcommit.py ' # pragma: no cover
43+
'The build may not have completed correctly.') # pragma: no cover
3744

3845

3946
def _initialize_logging():
@@ -92,6 +99,7 @@ def postprocess_request(response):
9299
def root():
93100
return flask.jsonify({
94101
"service": "Notification Feeds Service",
102+
"gitcommithash": gitcommit.commit,
95103
"version": VERSION,
96104
"servertime": epoch_ms()
97105
})

feeds/storage/mongodb/activity_storage.py

+51-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
ActivityStorageError
66
)
77
from pymongo.errors import PyMongoError
8+
from feeds.util import epoch_ms
89

910

1011
class MongoActivityStorage(ActivityStorage):
@@ -23,12 +24,6 @@ def add_to_storage(self, activity, target_users: List[str]):
2324
except PyMongoError as e:
2425
raise ActivityStorageError("Failed to store activity: " + str(e))
2526

26-
def get_from_storage(self, activity_ids, user):
27-
pass
28-
29-
def remove_from_storage(self, activity_ids):
30-
raise NotImplementedError()
31-
3227
def set_unseen(self, act_ids: List[str], user: str):
3328
"""
3429
Setting unseen means adding the user to the list of unseens. But we should only do that for
@@ -58,3 +53,53 @@ def set_seen(self, act_ids: List[str], user: str):
5853
}, {
5954
'$pull': {'unseen': user}
6055
})
56+
57+
def get_by_id(self, act_ids: List[str], source: str=None):
58+
"""
59+
If source is not None, return only those that match the source.
60+
Returns a dict mapping from note id to note
61+
"""
62+
if len(act_ids) == 0:
63+
return {}
64+
coll = get_feeds_collection()
65+
query = {
66+
'id': {'$in': act_ids}
67+
}
68+
if source is not None:
69+
query['source'] = source
70+
notes = {k:None for k in act_ids}
71+
curs = coll.find(query)
72+
for d in curs:
73+
notes[d["id"]] = d
74+
return notes
75+
76+
def get_by_external_key(self, external_keys: List[str], source):
77+
"""
78+
Source HAS to exist here, it's part of the index.
79+
Returns a dict mapping from external_key to note
80+
"""
81+
assert source is not None
82+
if len(external_keys) == 0:
83+
return {}
84+
coll = get_feeds_collection()
85+
query = {
86+
'external_key': {'$in': external_keys},
87+
'source': source
88+
}
89+
notes = {k:None for k in external_keys}
90+
curs = coll.find(query)
91+
for d in curs:
92+
notes[d["external_key"]] = d
93+
return notes
94+
95+
def expire_notifications(self, act_ids: List[str]):
96+
"""
97+
Expires notifications by changing their expiration time to now.
98+
"""
99+
now = epoch_ms()
100+
coll = get_feeds_collection()
101+
coll.update_many({
102+
'id': {'$in': act_ids}
103+
}, {
104+
'$set': {'expires': now}
105+
})

feeds/storage/mongodb/timeline_storage.py

+5-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from ..base import TimelineStorage
33
from .connection import get_feeds_collection
44
import logging
5-
5+
from feeds.util import epoch_ms
66

77
class MongoTimelineStorage(TimelineStorage):
88
"""
@@ -21,8 +21,10 @@ def get_timeline(self, count=10, include_seen=False, level=None, verb=None, reve
2121
"""
2222
# TODO: input validation
2323
coll = get_feeds_collection()
24+
now = epoch_ms()
2425
query = {
25-
"users": self.user_id #{"$all": [self.user_id]}
26+
"users": self.user_id,
27+
"expires": {"$gt": now}
2628
}
2729
if not include_seen:
2830
query['unseen'] = self.user_id
@@ -37,9 +39,6 @@ def get_timeline(self, count=10, include_seen=False, level=None, verb=None, reve
3739
serial_notes = [note for note in timeline]
3840
return serial_notes
3941

40-
def remove_from_timeline(self, activity_ids):
41-
raise NotImplementedError()
42-
4342
def get_single_activity_from_timeline(self, note_id):
4443
coll = get_feeds_collection()
4544
query = {
@@ -53,4 +52,4 @@ def get_single_activity_from_timeline(self, note_id):
5352
note_serial['seen'] = False
5453
else:
5554
note_serial['seen'] = True
56-
return note_serial
55+
return note_serial

test/_data/mongo/README.txt

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,6 @@ test_user
2929
* 6-10 have test_user2
3030
* 8,9,10 have test_user3
3131
* external key:
32-
* 1,2,3 have external keys
32+
* 1,2,3 have external keys
33+
* expired (shouldn't be returned in a fetch ever):
34+
* 11

0 commit comments

Comments
 (0)