Many changes in post model and in generic stream
[diaspy.git] / diaspy / streams.py
index 39080e32ad7f861ea8343de3b3bef1e0f3b2c0ee..24ab5ba38b790f8a7d09c333e9d31f27617ee017 100644 (file)
@@ -1,8 +1,3 @@
-import json
-import re
-import time
-from diaspy.models import Post
-
 """Docstrings for this module are taken from:
 https://gist.github.com/MrZYX/01c93096c30dc44caf71
 
@@ -11,14 +6,16 @@ http://pad.spored.de/ro/r.qWmvhSZg7rk4OQam
 """
 
 
-class Generic:
-    """Object representing generic stream. Used in Tag(),
-    Stream(), Activity() etc.
+import json
+import time
+from diaspy.models import Post, Aspect
+from diaspy import errors
+
+
+class Generic():
+    """Object representing generic stream.
     """
     _location = 'stream.json'
-    _stream = []
-    #   since epoch
-    max_time = int(time.mktime(time.gmtime()))
 
     def __init__(self, connection, location=''):
         """
@@ -29,13 +26,14 @@ class Generic:
         """
         self._connection = connection
         if location: self._location = location
+        self._stream = []
+        #   since epoch
+        self.max_time = int(time.mktime(time.gmtime()))
         self.fill()
 
     def __contains__(self, post):
         """Returns True if stream contains given post.
         """
-        if type(post) is not Post:
-            raise TypeError('stream can contain only posts: checked for {0}'.format(type(post)))
         return post in self._stream
 
     def __iter__(self):
@@ -53,13 +51,40 @@ class Generic:
         """
         return len(self._stream)
 
-    def _obtain(self):
+    def _obtain(self, max_time=0):
         """Obtains stream from pod.
         """
-        request = self._connection.get(self._location)
+        params = {}
+        if max_time:
+            params['max_time'] = max_time
+            params['_'] = int(time.time() * 1000)
+        request = self._connection.get(self._location, params=params)
         if request.status_code != 200:
-            raise Exception('wrong status code: {0}'.format(request.status_code))
-        return [Post(str(post['id']), self._connection) for post in request.json()]
+            raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
+        return [Post(self._connection, post['id']) for post in request.json()]
+
+    def _expand(self, new_stream):
+        """Appends older posts to stream.
+        """
+        ids = [post.id for post in self._stream]
+        stream = self._stream
+        for post in new_stream:
+            if post.id not in ids:
+                stream.append(post)
+                ids.append(post.id)
+        self._stream = stream
+
+    def _update(self, new_stream):
+        """Updates stream with new posts.
+        """
+        ids = [post.id for post in self._stream]
+
+        stream = self._stream
+        for i in range(len(new_stream)):
+            if new_stream[-i].id not in ids:
+                stream = [new_stream[-i]] + stream
+                ids.append(new_stream[-i].id)
+        self._stream = stream
 
     def clear(self):
         """Removes all posts from stream.
@@ -73,7 +98,7 @@ class Generic:
         for post in self._stream:
             deleted = False
             try:
-                post.get_data()
+                post.update()
                 stream.append(post)
             except Exception:
                 deleted = True
@@ -84,43 +109,106 @@ class Generic:
     def update(self):
         """Updates stream.
         """
-        new_stream = self._obtain()
-        ids = [post.post_id for post in self._stream]
-
-        stream = self._stream
-        for i in range(len(new_stream)):
-            if new_stream[-i].post_id not in ids:
-                stream = [new_stream[-i]] + stream
-                ids.append(new_stream[-i].post_id)
-
-        self._stream = stream
+        self._update(self._obtain())
 
     def fill(self):
         """Fills the stream with posts.
         """
         self._stream = self._obtain()
 
-    def more(self):
+    def more(self, max_time=0, backtime=84600):
         """Tries to download more (older ones) Posts from Stream.
+
+        :param backtime: how many seconds substract each time (defaults to one day)
+        :type backtime: int
+        :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own)
+        :type max_time: int
         """
-        self.max_time -= 3000000
-        params = {'max_time':self.max_time}
-        request = self._connection.get('{0}', params=params)
-        if request.status_code != 200:
-            raise Exception('wrong status code: {0}'.format(request.status_code))
+        if not max_time: max_time = self.max_time - backtime
+        self.max_time = max_time
+        new_stream = self._obtain(max_time=max_time)
+        self._expand(new_stream)
+
+    def full(self, backtime=84600, retry=42, callback=None):
+        """Fetches full stream - containing all posts.
+        WARNING: this is a **VERY** long running function.
+        Use callback parameter to access information about the stream during its
+        run.
+
+        Default backtime is one day. But sometimes user might not have any activity for longer
+        period (on the beginning I posted once a month or so).
+        The role of retry is to hadle such situations by trying to go further back in time.
+        If a post is found the counter is restored.
+
+        :param backtime: how many seconds to substract each time
+        :type backtime: int
+        :param retry: how many times the functin should look deeper than your last post
+        :type retry: int
+        :param callback: callable taking diaspy.streams.Generic as an argument
+        :returns: integer, lenght of the stream
+        """
+        oldstream = self.copy()
+        self.more()
+        while len(oldstream) < len(self):
+            oldstream = self.copy()
+            if callback is not None: callback(self)
+            self.more(backtime=backtime)
+            if len(oldstream) < len(self): continue
+            # but if no posts were found start retrying...
+            print('retrying... {0}'.format(retry))
+            n = retry
+            while n > 0:
+                print('\t', n, self.max_time)
+                # try to get even more posts...
+                self.more(backtime=backtime)
+                print('\t', len(oldstream), len(self))
+                # check if it was a success...
+                if len(oldstream) < len(self):
+                    # and if so restore normal order of execution by
+                    # going one loop higher
+                    break
+                oldstream = self.copy()
+                # if it was not a success substract one day, keep calm and
+                # try going further rback in time...
+                n -= 1
+            #if len(oldstream) == len(self): break
+        return len(self)
+
+    def copy(self):
+        """Returns copy (list of posts) of current stream.
+        """
+        return [p for p in self._stream]
+
+    def json(self, comments=False):
+        """Returns JSON encoded string containing stream's data.
+
+        :param comments: to include comments or not to include 'em, that is the question this param holds answer to
+        :type comments: bool
+        """
+        stream = [post for post in self._stream]
+        if comments:
+            for i, post in enumerate(stream):
+                post._fetchcomments()
+                comments = [c.data for c in post.comments]
+                post['interactions']['comments'] = comments
+                stream[i] = post
+        stream = [post.data for post in stream]
+        return json.dumps(stream)
 
 
 class Outer(Generic):
     """Object used by diaspy.models.User to represent
     stream of other user.
     """
-    def _obtain(self):
-        """Obtains stream of other user.
+    def _obtain(self, max_time=0):
+        """Obtains stream from pod.
         """
-        request = self._connection.get(self._location)
+        params = {}
+        if max_time: params['max_time'] = max_time
+        request = self._connection.get(self._location, params=params)
         if request.status_code != 200:
-            raise Exception('wrong status code: {0}'.format(request.status_code))
-        return [Post(str(post['id']), self._connection) for post in request.json()]
+            raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
+        return [Post(self._connection, post['id']) for post in request.json()]
 
 
 class Stream(Generic):
@@ -155,11 +243,11 @@ class Stream(Generic):
                                         data=json.dumps(data),
                                         headers={'content-type': 'application/json',
                                                  'accept': 'application/json',
-                                                 'x-csrf-token': self._connection.get_token()})
+                                                 'x-csrf-token': repr(self._connection)})
         if request.status_code != 201:
             raise Exception('{0}: Post could not be posted.'.format(request.status_code))
 
-        post = Post(str(request.json()['id']), self._connection)
+        post = Post(self._connection, request.json()['id'])
         return post
 
     def _photoupload(self, filename):
@@ -183,16 +271,16 @@ class Stream(Generic):
             params['photo[aspect_ids][{0}]'.format(i)] = aspect['id']
 
         headers = {'content-type': 'application/octet-stream',
-                   'x-csrf-token': self._connection.get_token(),
+                   'x-csrf-token': repr(self._connection),
                    'x-file-name': filename}
 
         request = self._connection.post('photos', data=image, params=params, headers=headers)
         if request.status_code != 200:
-            raise Exception('wrong error code: {0}'.format(request.status_code))
+            raise errors.StreamError('photo cannot be uploaded: {0}'.format(request.status_code))
         return request.json()['data']['photo']['id']
 
 
-class Activity(Generic):
+class Activity(Stream):
     """Stream representing user's activity.
     """
     _location = 'activity.json'
@@ -218,8 +306,7 @@ class Activity(Generic):
         """
         if type(post) == str: self._delid(post)
         elif type(post) == Post: post.delete()
-        else:
-            raise TypeError('this method accepts str or Post types: {0} given')
+        else: raise TypeError('this method accepts str or Post types: {0} given')
         self.fill()
 
 
@@ -232,38 +319,31 @@ class Aspects(Generic):
     An example call would be `aspects.json?aspect_ids=23,5,42`
     """
     _location = 'aspects.json'
-    _id_regexp = re.compile(r'<a href="/aspects/[0-9]+/edit" rel="facebox"')
 
-    def getID(self, aspect):
+    def getAspectID(self, aspect_name):
         """Returns id of an aspect of given name.
         Returns -1 if aspect is not found.
 
-        :param aspect: aspect name (must be spelled exactly as when created)
-        :type aspect: str
+        :param aspect_name: aspect name (must be spelled exactly as when created)
+        :type aspect_name: str
         :returns: int
         """
         id = -1
         aspects = self._connection.getUserInfo()['aspects']
-        for item in aspects:
-            if item['name'] == aspect: id = item['id']
+        for aspect in aspects:
+            if aspect['name'] == aspect_name: id = aspect['id']
         return id
 
     def filterByIDs(self, ids):
         self._location += '?{0}'.format(','.join(ids))
         self.fill()
 
-    def _getaid(self, response):
-        """Extracts id of just created aspect.
-        """
-        id = self._id_regexp.search(response.text).group(0).split('/')[2]
-        return int(id)
-
     def add(self, aspect_name, visible=0):
         """This function adds a new aspect.
-        Status code 422 is accepteb because it is returned by D* when
+        Status code 422 is accepted because it is returned by D* when
         you try to add aspect already present on your aspect list.
 
-        :returns: id of created aspect (or -1 if status_code was 422)
+        :returns: Aspect() object of just created aspect
         """
         data = {'authenticity_token': self._connection.get_token(),
                 'aspect[name]': aspect_name,
@@ -273,13 +353,15 @@ class Aspects(Generic):
         if request.status_code not in [200, 422]:
             raise Exception('wrong status code: {0}'.format(request.status_code))
 
-        if request.status_code == 422: id = -1
-        else: id = self._getaid(request)
-        return id
+        id = self.getAspectID(aspect_name)
+        return Aspect(self._connection, id)
 
-    def remove(self, aspect_id=0, name=''):
+    def remove(self, aspect_id=-1, name=''):
         """This method removes an aspect.
-        500 is accepted because although the D* will
+        You can give it either id or name of the aspect.
+        When both are specified, id takes precedence over name.
+
+        Status code 500 is accepted because although the D* will
         go nuts it will remove the aspect anyway.
 
         :param aspect_id: id fo aspect to remove
@@ -287,12 +369,12 @@ class Aspects(Generic):
         :param name: name of aspect to remove
         :type name: str
         """
-        if not aspect_id and name: aspect_id = self.getID(name)
-        data = {'authenticity_token': self._connection.get_token()}
-        request = self._connection.delete('aspects/{}'.format(aspect_id),
-                                          data=data)
-        if request.status_code not in [404, 500]:
-            raise Exception('wrong status code: {0}'.format(request.status_code))
+        if aspect_id == -1 and name: aspect_id = self.getAspectID(name)
+        data = {'_method': 'delete',
+                'authenticity_token': self._connection.get_token()}
+        request = self._connection.post('aspects/{0}'.format(aspect_id), data=data)
+        if request.status_code not in [200, 302, 500]:
+            raise Exception('wrong status code: {0}: cannot remove aspect'.format(request.status_code))
 
 
 class Commented(Generic):
@@ -354,3 +436,18 @@ class FollowedTags(Generic):
         if request.status_code not in [201, 403]:
             raise Exception('wrong error code: {0}'.format(request.status_code))
         return request.status_code
+
+
+class Tag(Generic):
+    """This stream contains all posts containing a tag.
+    """
+    def __init__(self, connection, tag):
+        """
+        :param connection: Connection() object
+        :type connection: diaspy.connection.Connection
+        :param tag: tag name
+        :type tag: str
+        """
+        self._connection = connection
+        self._location = 'tags/{0}.json'.format(tag)
+        self.fill()