diff --git a/CHANGELOG.md b/CHANGELOG.md
index 764fc5f..5cb7486 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## [unreleased]
+
+### Added
+* Added `Retraction` entity with `DiasporaRetraction` counterpart.
+
## [0.7.0] - 2016-09-15
### Backwards incompatible changes
diff --git a/federation/entities/base.py b/federation/entities/base.py
index cf5022d..0e1534f 100644
--- a/federation/entities/base.py
+++ b/federation/entities/base.py
@@ -5,6 +5,11 @@ import warnings
from dirty_validators.basic import Email
+__all__ = (
+ "Post", "Image", "Comment", "Reaction", "Relationship", "Profile", "Retraction"
+)
+
+
class BaseEntity(object):
_required = []
@@ -75,6 +80,18 @@ class GUIDMixin(BaseEntity):
raise ValueError("GUID must be at least 16 characters")
+class TargetGUIDMixin(BaseEntity):
+ target_guid = ""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._required += ["target_guid"]
+
+ def validate_target_guid(self):
+ if len(self.target_guid) < 16:
+ raise ValueError("Target GUID must be at least 16 characters")
+
+
class HandleMixin(BaseEntity):
handle = ""
@@ -143,7 +160,7 @@ class Image(GUIDMixin, HandleMixin, PublicMixin, CreatedAtMixin, BaseEntity):
self._required += ["remote_path", "remote_name"]
-class ParticipationMixin(BaseEntity):
+class ParticipationMixin(TargetGUIDMixin):
"""Reflects a participation to something."""
target_guid = ""
participation = ""
@@ -152,7 +169,7 @@ class ParticipationMixin(BaseEntity):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self._required += ["target_guid", "participation"]
+ self._required += ["participation"]
def validate_participation(self):
"""Ensure participation is of a certain type."""
@@ -231,3 +248,17 @@ class Profile(CreatedAtMixin, HandleMixin, RawContentMixin, PublicMixin, GUIDMix
validator = Email()
if not validator.is_valid(self.email):
raise ValueError("Email is not valid")
+
+
+class Retraction(CreatedAtMixin, HandleMixin, TargetGUIDMixin):
+ """Represents a retraction of content by author."""
+ entity_type = ""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._required += ["entity_type"]
+
+ def validate_entity_type(self):
+ """Ensure type is some entity we know of."""
+ if self.entity_type not in __all__:
+ raise ValueError("Entity type %s not recognized." % self.entity_type)
diff --git a/federation/entities/diaspora/entities.py b/federation/entities/diaspora/entities.py
index 4bedb47..0488251 100644
--- a/federation/entities/diaspora/entities.py
+++ b/federation/entities/diaspora/entities.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from lxml import etree
-from federation.entities.base import Comment, Post, Reaction, Relationship, Profile
+from federation.entities.base import Comment, Post, Reaction, Relationship, Profile, Retraction
from federation.entities.diaspora.utils import format_dt, struct_to_xml, get_base_attributes
from federation.utils.diaspora import retrieve_and_parse_profile
@@ -121,3 +121,37 @@ class DiasporaProfile(DiasporaEntityMixin, Profile):
profile = retrieve_and_parse_profile(attributes.get("handle"))
attributes["guid"] = profile.guid
return attributes
+
+
+class DiasporaRetraction(DiasporaEntityMixin, Retraction):
+ """Diaspora Retraction."""
+ mapped = {
+ "Like": "Reaction",
+ "Photo": "Image",
+ }
+
+ def to_xml(self):
+ """Convert to XML message."""
+ element = etree.Element("retraction")
+ struct_to_xml(element, [
+ {"author": self.handle},
+ {"target_guid": self.target_guid},
+ {"target_type": DiasporaRetraction.entity_type_to_remote(self.entity_type)},
+ ])
+ return element
+
+ @staticmethod
+ def entity_type_from_remote(value):
+ """Convert entity type between Diaspora names and our Entity names."""
+ if value in DiasporaRetraction.mapped:
+ return DiasporaRetraction.mapped[value]
+ return value
+
+ @staticmethod
+ def entity_type_to_remote(value):
+ """Convert entity type between our Entity names and Diaspora names."""
+ if value in DiasporaRetraction.mapped.values():
+ values = list(DiasporaRetraction.mapped.values())
+ index = values.index(value)
+ return list(DiasporaRetraction.mapped.keys())[index]
+ return value
diff --git a/federation/entities/diaspora/mappers.py b/federation/entities/diaspora/mappers.py
index 9c9890c..cbcf080 100644
--- a/federation/entities/diaspora/mappers.py
+++ b/federation/entities/diaspora/mappers.py
@@ -4,9 +4,9 @@ from datetime import datetime
from lxml import etree
-from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile
+from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction
from federation.entities.diaspora.entities import (
- DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile)
+ DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction)
logger = logging.getLogger("social-federation")
@@ -18,6 +18,7 @@ MAPPINGS = {
"like": DiasporaLike,
"request": DiasporaRequest,
"profile": DiasporaProfile,
+ "retraction": DiasporaRetraction,
}
BOOLEAN_KEYS = [
@@ -73,7 +74,7 @@ def transform_attributes(attrs):
for key, value in attrs.items():
if key in ["raw_message", "text"]:
transformed["raw_content"] = value
- elif key in ["diaspora_handle", "sender_handle"]:
+ elif key in ["diaspora_handle", "sender_handle", "author"]:
transformed["handle"] = value
elif key == "recipient_handle":
transformed["target_handle"] = value
@@ -99,6 +100,8 @@ def transform_attributes(attrs):
transformed["raw_content"] = value
elif key == "searchable":
transformed["public"] = True if value == "true" else False
+ elif key == "target_type":
+ transformed["entity_type"] = DiasporaRetraction.entity_type_from_remote(value)
elif key in BOOLEAN_KEYS:
transformed[key] = True if value == "true" else False
elif key in DATETIME_KEYS:
@@ -137,4 +140,6 @@ def get_outbound_entity(entity):
return DiasporaRequest.from_base(entity)
elif cls == Profile:
return DiasporaProfile.from_base(entity)
+ elif cls == Retraction:
+ return DiasporaRetraction.from_base(entity)
raise ValueError("Don't know how to convert this base entity to Diaspora protocol entities.")
diff --git a/federation/tests/entities/diaspora/test_entities.py b/federation/tests/entities/diaspora/test_entities.py
index 1b0dae6..e56afa9 100644
--- a/federation/tests/entities/diaspora/test_entities.py
+++ b/federation/tests/entities/diaspora/test_entities.py
@@ -6,7 +6,7 @@ from lxml import etree
from federation.entities.base import Profile
from federation.entities.diaspora.entities import DiasporaComment, DiasporaPost, DiasporaLike, DiasporaRequest, \
- DiasporaProfile
+ DiasporaProfile, DiasporaRetraction
class TestEntitiesConvertToXML(object):
@@ -66,6 +66,14 @@ class TestEntitiesConvertToXML(object):
b"false#socialfederation #federation"
assert etree.tostring(result) == converted
+ def test_retraction_to_xml(self):
+ entity = DiasporaRetraction(handle="bob@example.com", target_guid="x" * 16, entity_type="Post")
+ result = entity.to_xml()
+ assert result.tag == "retraction"
+ converted = b"bob@example.com" \
+ b"xxxxxxxxxxxxxxxxPost"
+ assert etree.tostring(result) == converted
+
class TestDiasporaProfileFillExtraAttributes(object):
def test_raises_if_no_handle(self):
@@ -79,3 +87,17 @@ class TestDiasporaProfileFillExtraAttributes(object):
attrs = {"handle": "foo"}
attrs = DiasporaProfile.fill_extra_attributes(attrs)
assert attrs == {"handle": "foo", "guid": "guidguidguidguid"}
+
+
+class TestDiasporaRetractionEntityConverters(object):
+ def test_entity_type_from_remote(self):
+ assert DiasporaRetraction.entity_type_from_remote("Post") == "Post"
+ assert DiasporaRetraction.entity_type_from_remote("Like") == "Reaction"
+ assert DiasporaRetraction.entity_type_from_remote("Photo") == "Image"
+ assert DiasporaRetraction.entity_type_from_remote("Comment") == "Comment"
+
+ def test_entity_type_to_remote(self):
+ assert DiasporaRetraction.entity_type_to_remote("Post") == "Post"
+ assert DiasporaRetraction.entity_type_to_remote("Reaction") == "Like"
+ assert DiasporaRetraction.entity_type_to_remote("Image") == "Photo"
+ assert DiasporaRetraction.entity_type_to_remote("Comment") == "Comment"
diff --git a/federation/tests/entities/diaspora/test_mappers.py b/federation/tests/entities/diaspora/test_mappers.py
index 17aadb2..0a5aa32 100644
--- a/federation/tests/entities/diaspora/test_mappers.py
+++ b/federation/tests/entities/diaspora/test_mappers.py
@@ -4,12 +4,16 @@ from unittest.mock import patch
import pytest
-from federation.entities.base import Comment, Post, Reaction, Relationship, Profile
-from federation.entities.diaspora.entities import DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, \
- DiasporaProfile
+from federation.entities.base import Comment, Post, Reaction, Relationship, Profile, Retraction
+from federation.entities.diaspora.entities import (
+ DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest,
+ DiasporaProfile, DiasporaRetraction
+)
from federation.entities.diaspora.mappers import message_to_objects, get_outbound_entity
-from federation.tests.fixtures.payloads import DIASPORA_POST_SIMPLE, DIASPORA_POST_COMMENT, DIASPORA_POST_LIKE, \
- DIASPORA_REQUEST, DIASPORA_PROFILE, DIASPORA_POST_INVALID
+from federation.tests.fixtures.payloads import (
+ DIASPORA_POST_SIMPLE, DIASPORA_POST_COMMENT, DIASPORA_POST_LIKE,
+ DIASPORA_REQUEST, DIASPORA_PROFILE, DIASPORA_POST_INVALID, DIASPORA_RETRACTION
+)
def mock_fill(attributes):
@@ -90,6 +94,15 @@ class TestDiasporaEntityMappersReceive(object):
assert profile.nsfw == False
assert profile.tag_list == ["socialfederation", "federation"]
+ def test_message_to_objects_retraction(self):
+ entities = message_to_objects(DIASPORA_RETRACTION)
+ assert len(entities) == 1
+ entity = entities[0]
+ assert isinstance(entity, Retraction)
+ assert entity.handle == "bob@example.com"
+ assert entity.target_guid == "x" * 16
+ assert entity.entity_type == "Post"
+
@patch("federation.entities.diaspora.mappers.logger.error")
def test_invalid_entity_logs_an_error(self, mock_logger):
entities = message_to_objects(DIASPORA_POST_INVALID)
@@ -118,7 +131,7 @@ class TestGetOutboundEntity(object):
entity = Comment()
assert isinstance(get_outbound_entity(entity), DiasporaComment)
- def test_reaction_of_like_is_converted_to_diasporaplike(self):
+ def test_reaction_of_like_is_converted_to_diasporalike(self):
entity = Reaction(reaction="like")
assert isinstance(get_outbound_entity(entity), DiasporaLike)
@@ -141,3 +154,7 @@ class TestGetOutboundEntity(object):
entity = Relationship(relationship="foo")
with pytest.raises(ValueError):
get_outbound_entity(entity)
+
+ def test_retraction_is_converted_to_diasporaretraction(self):
+ entity = Retraction()
+ assert isinstance(get_outbound_entity(entity), DiasporaRetraction)
diff --git a/federation/tests/entities/test_base.py b/federation/tests/entities/test_base.py
index 031fe96..be48eaf 100644
--- a/federation/tests/entities/test_base.py
+++ b/federation/tests/entities/test_base.py
@@ -4,7 +4,7 @@ from unittest.mock import Mock
import pytest
from federation.entities.base import BaseEntity, Relationship, Profile, RawContentMixin, GUIDMixin, HandleMixin, \
- PublicMixin, Image
+ PublicMixin, Image, Retraction
from federation.tests.factories.entities import TaggedPostFactory, PostFactory
@@ -113,3 +113,28 @@ class TestImageEntity(object):
)
with pytest.raises(ValueError):
entity.validate()
+
+
+class TestRetractionEntity(object):
+ def test_instance_creation(self):
+ entity = Retraction(
+ handle="foo@example.com", target_guid="x"*16, entity_type="Post"
+ )
+ entity.validate()
+
+ def test_required_validates(self):
+ entity = Retraction(
+ handle="fooexample.com", target_guid="x" * 16, entity_type="Post"
+ )
+ with pytest.raises(ValueError):
+ entity.validate()
+ entity = Retraction(
+ handle="foo@example.com", target_guid="x" * 15, entity_type="Post"
+ )
+ with pytest.raises(ValueError):
+ entity.validate()
+ entity = Retraction(
+ handle="foo@example.com", target_guid="x" * 16, entity_type="Foo"
+ )
+ with pytest.raises(ValueError):
+ entity.validate()
diff --git a/federation/tests/fixtures/payloads.py b/federation/tests/fixtures/payloads.py
index aaf27f9..5bb64d6 100644
--- a/federation/tests/fixtures/payloads.py
+++ b/federation/tests/fixtures/payloads.py
@@ -111,3 +111,14 @@ DIASPORA_PROFILE = """
"""
+
+DIASPORA_RETRACTION = """
+
+
+ bob@example.com
+ xxxxxxxxxxxxxxxx
+ Post
+
+
+
+"""