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 + + + +"""