Merge pull request #10 from jaywink/participation-models

Support Comments and Likes
merge-requests/130/head
Jason Robinson 2016-04-04 22:34:42 +03:00
commit 561f2ab87a
14 zmienionych plików z 302 dodań i 118 usunięć

Wyświetl plik

@ -1,7 +1,12 @@
from datetime import datetime # -*- coding: utf-8 -*-
import datetime
from dirty_validators.basic import Email from dirty_validators.basic import Email
__all__ = ("Post", "Image", "Comment")
class BaseEntity(object): class BaseEntity(object):
_required = [] _required = []
@ -16,15 +21,26 @@ class BaseEntity(object):
1) Loop through attributes and call their `validate_<attr>` methods, if any. 1) Loop through attributes and call their `validate_<attr>` methods, if any.
2) Check `_required` contents and make sure all attrs in there have a value. 2) Check `_required` contents and make sure all attrs in there have a value.
""" """
# TBD attributes = []
pass for attr in dir(self):
if not attr.startswith("_"):
attr_type = type(getattr(self, attr))
if attr_type != "method":
if getattr(self, "validate_{attr}".format(attr=attr), None):
getattr(self, "validate_{attr}".format(attr=attr))()
attributes.append(attr)
required_fulfilled = set(self._required).issubset(set(attributes))
if not required_fulfilled:
raise ValueError(
"Not all required attributes fulfilled. Required: {required}".format(required=self._required)
)
class GUIDMixin(BaseEntity): class GUIDMixin(BaseEntity):
guid = "" guid = ""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(GUIDMixin, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._required += ["guid"] self._required += ["guid"]
def validate_guid(self): def validate_guid(self):
@ -36,7 +52,7 @@ class HandleMixin(BaseEntity):
handle = "" handle = ""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(HandleMixin, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._required += ["handle"] self._required += ["handle"]
def validate_handle(self): def validate_handle(self):
@ -50,22 +66,18 @@ class PublicMixin(BaseEntity):
class CreatedAtMixin(BaseEntity): class CreatedAtMixin(BaseEntity):
created_at = datetime.today() created_at = datetime.datetime.now()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(CreatedAtMixin, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._required += ["created_at"] self._required += ["created_at"]
class Post(GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity): class RawContentMixin(BaseEntity):
"""Reflects a post, status message, etc, which will be composed from the message or to the message."""
raw_content = "" raw_content = ""
provider_display_name = ""
location = ""
photos = []
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(Post, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._required += ["raw_content"] self._required += ["raw_content"]
@property @property
@ -74,6 +86,13 @@ class Post(GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity):
return set({word.strip("#") for word in self.raw_content.split() if word.startswith("#")}) return set({word.strip("#") for word in self.raw_content.split() if word.startswith("#")})
class Post(RawContentMixin, GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity):
"""Reflects a post, status message, etc, which will be composed from the message or to the message."""
provider_display_name = ""
location = ""
photos = []
class Image(GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity): class Image(GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity):
"""Reflects a single image, possibly linked to another object.""" """Reflects a single image, possibly linked to another object."""
remote_path = "" remote_path = ""
@ -85,5 +104,51 @@ class Image(GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity):
width = 0 width = 0
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(Image, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._required += ["remote_path", "remote_name"] self._required += ["remote_path", "remote_name"]
class ParticipationMixin(BaseEntity):
"""Reflects a participation to something."""
target_guid = ""
participation = ""
_participation_valid_values = ["reaction", "subscription", "comment"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["target_guid", "participation"]
def validate_participation(self):
"""Ensure participation is of a certain type."""
if self.participation not in self._participation_valid_values:
raise ValueError("participation should be one of: {valid}".format(
valid=", ".join(self._participation_valid_values)
))
class Comment(RawContentMixin, GUIDMixin, ParticipationMixin, CreatedAtMixin, HandleMixin):
"""Represents a comment, linked to another object."""
participation = "comment"
class Reaction(GUIDMixin, ParticipationMixin, CreatedAtMixin, HandleMixin):
"""Represents a reaction to another object, for example a like."""
participation = "reaction"
reaction = ""
_reaction_valid_values = ["like"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required += ["reaction"]
def validate_reaction(self):
"""Ensure reaction is of a certain type.
Mainly for future expansion.
"""
if self.reaction not in self._reaction_valid_values:
raise ValueError("reaction should be one of: {valid}".format(
valid=", ".join(self._reaction_valid_values)
))

Wyświetl plik

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
from lxml import etree
from federation.entities.base import Comment, Post, Reaction
from federation.entities.diaspora.utils import format_dt, struct_to_xml
class DiasporaComment(Comment):
"""Diaspora comment."""
author_signature = ""
def to_xml(self):
element = etree.Element("comment")
struct_to_xml(element, [
{'guid': self.guid},
{'parent_guid': self.target_guid},
{'author_signature': self.author_signature},
{'text': self.raw_content},
{'diaspora_handle': self.handle},
])
return element
class DiasporaPost(Post):
"""Diaspora post, ie status message."""
def to_xml(self):
"""Convert to XML message."""
element = etree.Element("status_message")
struct_to_xml(element, [
{'raw_message': self.raw_content},
{'guid': self.guid},
{'diaspora_handle': self.handle},
{'public': 'true' if self.public else 'false'},
{'created_at': format_dt(self.created_at)}
])
return element
class DiasporaLike(Reaction):
"""Diaspora like."""
author_signature = ""
reaction = "like"
def to_xml(self):
"""Convert to XML message."""
element = etree.Element("like")
struct_to_xml(element, [
{"target_type": "Post"},
{'guid': self.guid},
{'parent_guid': self.target_guid},
{'author_signature': self.author_signature},
{"positive": "true"},
{'diaspora_handle': self.handle},
])
return element

Wyświetl plik

@ -1,54 +0,0 @@
from dateutil.tz import tzlocal, tzutc
from lxml import etree
def ensure_timezone(dt, tz=None):
"""
Make sure the datetime <dt> has a timezone set, using timezone <tz> if it
doesn't. <tz> defaults to the local timezone.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=tz or tzlocal())
else:
return dt
class EntityConverter(object):
def __init__(self, entity):
self.entity = entity
self.entity_type = entity.__class__.__name__.lower()
def struct_to_xml(self, node, struct):
"""
Turn a list of dicts into XML nodes with tag names taken from the dict
keys and element text taken from dict values. This is a list of dicts
so that the XML nodes can be ordered in the XML output.
"""
for obj in struct:
for k, v in obj.items():
etree.SubElement(node, k).text = v
def convert_to_xml(self):
if hasattr(self, "%s_to_xml" % self.entity_type):
method_name = "%s_to_xml" % self.entity_type
return getattr(self, method_name)()
def format_dt(cls, dt):
"""
Format a datetime in the way that D* nodes expect.
"""
return ensure_timezone(dt).astimezone(tzutc()).strftime(
'%Y-%m-%d %H:%M:%S %Z'
)
def post_to_xml(self):
req = etree.Element("status_message")
self.struct_to_xml(req, [
{'raw_message': self.entity.raw_content},
{'guid': self.entity.guid},
{'diaspora_handle': self.entity.handle},
{'public': 'true' if self.entity.public else 'false'},
{'created_at': self.format_dt(self.entity.created_at)}
])
return req

Wyświetl plik

@ -1,12 +1,16 @@
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from lxml import etree from lxml import etree
from federation.entities.base import Post, Image from federation.entities.base import Image
from federation.entities.diaspora.entities import DiasporaPost, DiasporaComment, DiasporaLike
MAPPINGS = { MAPPINGS = {
"status_message": Post, "status_message": DiasporaPost,
"photo": Image, "photo": Image,
"comment": DiasporaComment,
"like": DiasporaLike,
} }
BOOLEAN_KEYS = [ BOOLEAN_KEYS = [
@ -34,19 +38,22 @@ def message_to_objects(message):
cls = MAPPINGS.get(element.tag, None) cls = MAPPINGS.get(element.tag, None)
if cls: if cls:
attrs = xml_children_as_dict(element) attrs = xml_children_as_dict(element)
transformed = transform_attributes(cls, attrs) transformed = transform_attributes(attrs)
entities.append(cls(**transformed)) entity = cls(**transformed)
entities.append(entity)
return entities return entities
def transform_attributes(cls, attrs): def transform_attributes(attrs):
"""Transform some attribute keys.""" """Transform some attribute keys."""
transformed = {} transformed = {}
for key, value in attrs.items(): for key, value in attrs.items():
if key == "raw_message": if key in ["raw_message", "text"]:
transformed["raw_content"] = value transformed["raw_content"] = value
elif key == "diaspora_handle": elif key == "diaspora_handle":
transformed["handle"] = value transformed["handle"] = value
elif key == "parent_guid":
transformed["target_guid"] = value
elif key in BOOLEAN_KEYS: elif key in BOOLEAN_KEYS:
transformed[key] = True if value == "true" else False transformed[key] = True if value == "true" else False
elif key in DATETIME_KEYS: elif key in DATETIME_KEYS:

Wyświetl plik

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from dateutil.tz import tzlocal, tzutc
from lxml import etree
def ensure_timezone(dt, tz=None):
"""
Make sure the datetime <dt> has a timezone set, using timezone <tz> if it
doesn't. <tz> defaults to the local timezone.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=tz or tzlocal())
else:
return dt
def format_dt(dt):
"""
Format a datetime in the way that D* nodes expect.
"""
return ensure_timezone(dt).astimezone(tzutc()).strftime(
'%Y-%m-%d %H:%M:%S %Z'
)
def struct_to_xml(node, struct):
"""
Turn a list of dicts into XML nodes with tag names taken from the dict
keys and element text taken from dict values. This is a list of dicts
so that the XML nodes can be ordered in the XML output.
"""
for obj in struct:
for k, v in obj.items():
etree.SubElement(node, k).text = v

Wyświetl plik

@ -72,7 +72,7 @@ class DiasporaHostMeta(BaseHostMeta):
webfinger_host (str) webfinger_host (str)
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(DiasporaHostMeta, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
link = Link( link = Link(
rel='lrdd', rel='lrdd',
type_='application/xrd+xml', type_='application/xrd+xml',
@ -87,7 +87,7 @@ class BaseLegacyWebFinger(BaseHostMeta):
See: https://code.google.com/p/webfinger/wiki/WebFingerProtocol See: https://code.google.com/p/webfinger/wiki/WebFingerProtocol
""" """
def __init__(self, address, *args, **kwargs): def __init__(self, address, *args, **kwargs):
super(BaseLegacyWebFinger, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
subject = Element("Subject", "acct:%s" % address) subject = Element("Subject", "acct:%s" % address)
self.xrd.elements.append(subject) self.xrd.elements.append(subject)
@ -102,7 +102,7 @@ class DiasporaWebFinger(BaseLegacyWebFinger):
public_key (str) - public key public_key (str) - public key
""" """
def __init__(self, handle, host, guid, public_key, *args, **kwargs): def __init__(self, handle, host, guid, public_key, *args, **kwargs):
super(DiasporaWebFinger, self).__init__(handle, *args, **kwargs) super().__init__(handle, *args, **kwargs)
self.xrd.elements.append(Element("Alias", "%s/people/%s" % ( self.xrd.elements.append(Element("Alias", "%s/people/%s" % (
host, guid host, guid
))) )))

Wyświetl plik

@ -10,7 +10,6 @@ from Crypto.Random import get_random_bytes
from Crypto.Signature import PKCS1_v1_5 as PKCSSign from Crypto.Signature import PKCS1_v1_5 as PKCSSign
from lxml import etree from lxml import etree
from federation.entities.diaspora.generators import EntityConverter
from federation.exceptions import EncryptedMessageError, NoHeaderInMessageError, NoSenderKeyFoundError from federation.exceptions import EncryptedMessageError, NoHeaderInMessageError, NoSenderKeyFoundError
from federation.protocols.base import BaseProtocol from federation.protocols.base import BaseProtocol
@ -164,8 +163,7 @@ class Protocol(BaseProtocol):
def build_send(self, from_user, to_user, entity, *args, **kwargs): def build_send(self, from_user, to_user, entity, *args, **kwargs):
"""Build POST data for sending out to remotes.""" """Build POST data for sending out to remotes."""
converter = EntityConverter(entity) xml = entity.to_xml()
xml = converter.convert_to_xml()
self.init_message(xml, from_user.handle, from_user.private_key) self.init_message(xml, from_user.handle, from_user.private_key)
xml = quote_plus( xml = quote_plus(
self.create_salmon_envelope(to_user.key)) self.create_salmon_envelope(to_user.key))

Wyświetl plik

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from lxml import etree
from federation.entities.diaspora.entities import DiasporaComment, DiasporaPost, DiasporaLike
class TestEntitiesConvertToXML(object):
def test_post_to_xml(self):
entity = DiasporaPost(raw_content="raw_content", guid="guid", handle="handle", public=True)
result = entity.to_xml()
assert result.tag == "status_message"
assert len(result.find("created_at").text) > 0
result.find("created_at").text = "" # timestamp makes testing painful
converted = b"<status_message><raw_message>raw_content</raw_message><guid>guid</guid>" \
b"<diaspora_handle>handle</diaspora_handle><public>true</public><created_at>" \
b"</created_at></status_message>"
assert etree.tostring(result) == converted
def test_comment_to_xml(self):
entity = DiasporaComment(raw_content="raw_content", guid="guid", target_guid="target_guid", handle="handle")
result = entity.to_xml()
assert result.tag == "comment"
converted = b"<comment><guid>guid</guid><parent_guid>target_guid</parent_guid>" \
b"<author_signature></author_signature><text>raw_content</text>" \
b"<diaspora_handle>handle</diaspora_handle></comment>"
assert etree.tostring(result) == converted
def test_like_to_xml(self):
entity = DiasporaLike(guid="guid", target_guid="target_guid", handle="handle")
result = entity.to_xml()
assert result.tag == "like"
converted = b"<like><target_type>Post</target_type><guid>guid</guid><parent_guid>target_guid</parent_guid>" \
b"<author_signature></author_signature><positive>true</positive>" \
b"<diaspora_handle>handle</diaspora_handle></like>"
assert etree.tostring(result) == converted

Wyświetl plik

@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from lxml import etree
from unittest.mock import patch
from federation.entities.base import Post
from federation.entities.diaspora.generators import EntityConverter
class TestEntityConverterCallsToXML(object):
def test_entity_converter_call_to_xml(self):
entity = Post()
with patch.object(EntityConverter, "post_to_xml", return_value="foo") as mock_to_xml:
entity_converter = EntityConverter(entity=entity)
result = entity_converter.convert_to_xml()
assert result == "foo"
assert mock_to_xml.called
def test_entity_converter_converts_a_post(self):
entity = Post(raw_content="raw_content", guid="guid", handle="handle", public=True, created_at=datetime.today())
entity_converter = EntityConverter(entity)
result = entity_converter.convert_to_xml()
assert result.tag == "status_message"
assert len(result.find("created_at").text) > 0
result.find("created_at").text = "" # timestamp makes testing painful
post_converted = b"<status_message><raw_message>raw_content</raw_message><guid>guid</guid>" \
b"<diaspora_handle>handle</diaspora_handle><public>true</public><created_at>" \
b"</created_at></status_message>"
assert etree.tostring(result) == post_converted

Wyświetl plik

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from federation.entities.base import Post from federation.entities.base import Comment, Post, Reaction
from federation.entities.diaspora.entities import DiasporaPost, DiasporaComment, DiasporaLike
from federation.entities.diaspora.mappers import message_to_objects from federation.entities.diaspora.mappers import message_to_objects
from federation.tests.fixtures.payloads import DIASPORA_POST_SIMPLE from federation.tests.fixtures.payloads import DIASPORA_POST_SIMPLE, DIASPORA_POST_COMMENT, DIASPORA_POST_LIKE
class TestDiasporaEntityMappersReceive(object): class TestDiasporaEntityMappersReceive(object):
@ -12,9 +13,34 @@ class TestDiasporaEntityMappersReceive(object):
entities = message_to_objects(DIASPORA_POST_SIMPLE) entities = message_to_objects(DIASPORA_POST_SIMPLE)
assert len(entities) == 1 assert len(entities) == 1
post = entities[0] post = entities[0]
assert isinstance(post, DiasporaPost)
assert isinstance(post, Post) assert isinstance(post, Post)
assert post.raw_content == "((status message))" assert post.raw_content == "((status message))"
assert post.guid == "((guid))" assert post.guid == "((guid))"
assert post.handle == "alice@alice.diaspora.example.org" assert post.handle == "alice@alice.diaspora.example.org"
assert post.public == False assert post.public == False
assert post.created_at == datetime(2011, 7, 20, 1, 36, 7) assert post.created_at == datetime(2011, 7, 20, 1, 36, 7)
def test_message_to_objects_comment(self):
entities = message_to_objects(DIASPORA_POST_COMMENT)
assert len(entities) == 1
comment = entities[0]
assert isinstance(comment, DiasporaComment)
assert isinstance(comment, Comment)
assert comment.target_guid == "((parent_guid))"
assert comment.guid == "((guid))"
assert comment.handle == "alice@alice.diaspora.example.org"
assert comment.participation == "comment"
assert comment.raw_content == "((text))"
def test_message_to_objects_like(self):
entities = message_to_objects(DIASPORA_POST_LIKE)
assert len(entities) == 1
like = entities[0]
assert isinstance(like, DiasporaLike)
assert isinstance(like, Reaction)
assert like.target_guid == "((parent_guid))"
assert like.guid == "((guid))"
assert like.handle == "alice@alice.diaspora.example.org"
assert like.participation == "reaction"
assert like.reaction == "like"

Wyświetl plik

@ -1,9 +1,29 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from federation.tests.factories.entities import TaggedPostFactory from unittest.mock import Mock
import pytest
from federation.entities.base import BaseEntity
from federation.tests.factories.entities import TaggedPostFactory, PostFactory
class TestPostEntityTags(object): class TestPostEntityTags(object):
def test_post_entity_returns_list_of_tags(self): def test_post_entity_returns_list_of_tags(self):
post = TaggedPostFactory() post = TaggedPostFactory()
assert post.tags == {"tagone", "tagtwo", "tagthree"} assert post.tags == {"tagone", "tagtwo", "tagthree"}
class TestBaseEntityCallsValidateMethods(object):
def test_entity_calls_attribute_validate_method(self):
post = PostFactory()
post.validate_location = Mock()
post.validate()
assert post.validate_location.call_count == 1
class TestEntityRequiredAttributes(object):
def test_entity_checks_for_required_attributes(self):
entity = BaseEntity()
entity._required = ["foobar"]
with pytest.raises(ValueError):
entity.validate()

Wyświetl plik

@ -35,3 +35,30 @@ DIASPORA_POST_SIMPLE = """<XML>
</post> </post>
</XML> </XML>
""" """
DIASPORA_POST_COMMENT = """<XML>
<post>
<comment>
<guid>((guid))</guid>
<parent_guid>((parent_guid))</parent_guid>
<author_signature>((base64-encoded data))</author_signature>
<text>((text))</text>
<diaspora_handle>alice@alice.diaspora.example.org</diaspora_handle>
</comment>
</post>
</XML>
"""
DIASPORA_POST_LIKE = """<XML>
<post>
<like>
<target_type>Post</target_type>
<guid>((guid))</guid>
<parent_guid>((parent_guid))</parent_guid>
<author_signature>((base64-encoded data))</author_signature>
<positive>true</positive>
<diaspora_handle>alice@alice.diaspora.example.org</diaspora_handle>
</like>
</post>
</XML>
"""

Wyświetl plik

@ -4,7 +4,7 @@ from Crypto.PublicKey import RSA
import pytest import pytest
from federation.controllers import handle_receive, handle_create_payload from federation.controllers import handle_receive, handle_create_payload
from federation.entities.base import Post from federation.entities.diaspora.entities import DiasporaPost
from federation.exceptions import NoSuitableProtocolFoundError from federation.exceptions import NoSuitableProtocolFoundError
from federation.protocols.diaspora.protocol import Protocol from federation.protocols.diaspora.protocol import Protocol
from federation.tests.fixtures.payloads import UNENCRYPTED_DIASPORA_PAYLOAD from federation.tests.fixtures.payloads import UNENCRYPTED_DIASPORA_PAYLOAD
@ -35,7 +35,7 @@ class TestHandleCreatePayloadBuildsAPayload(object):
def test_handle_create_payload_builds_an_xml(self): def test_handle_create_payload_builds_an_xml(self):
from_user = Mock(private_key=RSA.generate(2048), handle="foobar@domain.tld") from_user = Mock(private_key=RSA.generate(2048), handle="foobar@domain.tld")
to_user = Mock(key=RSA.generate(2048).publickey()) to_user = Mock(key=RSA.generate(2048).publickey())
entity = Post() entity = DiasporaPost()
data = handle_create_payload(from_user, to_user, entity) data = handle_create_payload(from_user, to_user, entity)
assert len(data) > 0 assert len(data) > 0
parts = data.split("=") parts = data.split("=")

2
pytest.ini 100644
Wyświetl plik

@ -0,0 +1,2 @@
[pytest]
testpaths = federation