Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update KnowledgeEndpoint to allow editing statements through the GUI #5

Merged
merged 3 commits into from
Oct 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import binascii
from typing import Optional, Set, Tuple
from typing import Optional, Set, Tuple, Dict

from aiohttp import web
from aiohttp_apispec import docs
Expand All @@ -17,9 +17,9 @@


@froze_it
class TagsEndpoint(RESTEndpoint):
class KnowledgeEndpoint(RESTEndpoint):
"""
Top-level endpoint for tags.
Top-level endpoint for knowledge management.
"""

def __init__(self, db: KnowledgeDatabase, community: KnowledgeCommunity):
Expand All @@ -40,60 +40,69 @@ def validate_infohash(infohash: bytes) -> Tuple[bool, Optional[RESTResponse]]:
def setup_routes(self):
self.app.add_routes(
[
web.patch('/{infohash}', self.update_tags_entries),
web.get('/{infohash}/suggestions', self.get_suggestions),
web.patch('/{infohash}', self.update_knowledge_entries),
web.get('/{infohash}/tag_suggestions', self.get_tag_suggestions),
]
)

@docs(
tags=["General"],
summary="Update a particular torrent with tags.",
summary="Update the metadata associated with a particular torrent.",
responses={
200: {
"schema": schema(UpdateTagsResponse={'success': Boolean()})
},
HTTP_BAD_REQUEST: {
"schema": HandledErrorSchema, 'example': {"error": "Invalid tag length"}},
},
description="This endpoint updates a particular torrent with the provided tags."
description="This endpoint updates a particular torrent with the provided metadata."
)
async def update_tags_entries(self, request):
async def update_knowledge_entries(self, request):
params = await request.json()
infohash = request.match_info["infohash"]
ih_valid, error_response = TagsEndpoint.validate_infohash(infohash)
ih_valid, error_response = KnowledgeEndpoint.validate_infohash(infohash)
if not ih_valid:
return error_response

# Validate whether the size of the tag is within the allowed range
tags = set(params["tags"])
for tag in tags:
if len(tag) < MIN_RESOURCE_LENGTH or len(tag) > MAX_RESOURCE_LENGTH:
# Validate whether the size of the tag is within the allowed range and filter out duplicate tags.
tags = set()
statements = []
for statement in params["statements"]:
obj = statement["object"]
if statement["predicate"] == ResourceType.TAG and \
(len(obj) < MIN_RESOURCE_LENGTH or len(obj) > MAX_RESOURCE_LENGTH):
return RESTResponse({"error": "Invalid tag length"}, status=HTTP_BAD_REQUEST)

self.modify_tags(infohash, tags)
if obj not in tags:
tags.add(obj)
statements.append(statement)

self.modify_statements(infohash, statements)

return RESTResponse({"success": True})

@db_session
def modify_tags(self, infohash: str, new_tags: Set[str]):
def modify_statements(self, infohash: str, statements):
"""
Modify the tags of a particular content item.
Modify the statements of a particular content item.
"""
if not self.community:
return

# First, get the current tags and compute the diff between the old and new tags
old_tags = set(self.db.get_objects(infohash, predicate=ResourceType.TAG))
added_tags = new_tags - old_tags
removed_tags = old_tags - new_tags
# First, get the current statements and compute the diff between the old and new statements
old_statements = set([(stmt.predicate, stmt.object) for stmt in self.db.get_statements(infohash)])
new_statements = set([(stmt["predicate"], stmt["object"]) for stmt in statements])
added_statements = new_statements - old_statements
removed_statements = old_statements - new_statements

# Create individual tag operations for the added/removed tags
# Create individual statement operations for the added/removed statements
public_key = self.community.key.pub().key_to_bin()
for tag in added_tags.union(removed_tags):
type_of_operation = Operation.ADD if tag in added_tags else Operation.REMOVE
for stmt in added_statements.union(removed_statements):
predicate, obj = stmt
type_of_operation = Operation.ADD if stmt in added_statements else Operation.REMOVE
operation = StatementOperation(subject_type=ResourceType.TORRENT, subject=infohash,
predicate=ResourceType.TAG,
object=tag, operation=type_of_operation, clock=0,
predicate=predicate,
object=obj, operation=type_of_operation, clock=0,
creator_public_key=public_key)
operation.clock = self.db.get_clock(operation) + 1
signature = self.community.sign(operation)
Expand All @@ -111,12 +120,12 @@ def modify_tags(self, infohash: str, new_tags: Set[str]):
},
description="This endpoint updates a particular torrent with the provided tags."
)
async def get_suggestions(self, request):
async def get_tag_suggestions(self, request):
"""
Get suggestions for a particular tag.
"""
infohash = request.match_info["infohash"]
ih_valid, error_response = TagsEndpoint.validate_infohash(infohash)
ih_valid, error_response = KnowledgeEndpoint.validate_infohash(infohash)
if not ih_valid:
return error_response

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import Dict
from unittest.mock import Mock

import pytest
Expand All @@ -8,7 +9,7 @@

from tribler.core.components.knowledge.community.knowledge_payload import StatementOperation
from tribler.core.components.knowledge.db.knowledge_db import Operation, ResourceType
from tribler.core.components.knowledge.restapi.tags_endpoint import TagsEndpoint
from tribler.core.components.knowledge.restapi.knowledge_endpoint import KnowledgeEndpoint
from tribler.core.components.restapi.rest.base_api_test import do_request
from tribler.core.conftest import TEST_PERSONAL_KEY
from tribler.core.utilities.unicode import hexlify
Expand All @@ -17,71 +18,75 @@
# pylint: disable=redefined-outer-name

@pytest.fixture
def tags_endpoint(knowledge_db):
def knowledge_endpoint(knowledge_db):
community = Mock()
community.key = TEST_PERSONAL_KEY
community.sign = Mock(return_value=b'')
endpoint = TagsEndpoint(knowledge_db, community)
endpoint = KnowledgeEndpoint(knowledge_db, community)
return endpoint


@pytest.fixture
def rest_api(loop, aiohttp_client, tags_endpoint):
def rest_api(loop, aiohttp_client, knowledge_endpoint):
app = Application()
app.add_subapp('/tags', tags_endpoint.app)
app.add_subapp('/knowledge', knowledge_endpoint.app)
return loop.run_until_complete(aiohttp_client(app))


def tag_to_statement(tag: str) -> Dict:
return {"predicate": ResourceType.TAG, "object": tag}


async def test_add_tag_invalid_infohash(rest_api):
"""
Test whether an error is returned if we try to add a tag to content with an invalid infohash
"""
post_data = {"tags": ["abc", "def"]}
await do_request(rest_api, 'tags/3f3', request_type="PATCH", expected_code=400, post_data=post_data)
await do_request(rest_api, 'tags/3f3f', request_type="PATCH", expected_code=400, post_data=post_data)
post_data = {"knowledge": [tag_to_statement("abc"), tag_to_statement("def")]}
await do_request(rest_api, 'knowledge/3f3', request_type="PATCH", expected_code=400, post_data=post_data)
await do_request(rest_api, 'knowledge/3f3f', request_type="PATCH", expected_code=400, post_data=post_data)


async def test_add_invalid_tag(rest_api):
"""
Test whether an error is returned if we try to add a tag that is too short or long.
"""
post_data = {"tags": ["a"]}
post_data = {"statements": [tag_to_statement("a")]}
infohash = b'a' * 20
await do_request(rest_api, f'tags/{hexlify(infohash)}', request_type="PATCH", expected_code=400,
await do_request(rest_api, f'knowledge/{hexlify(infohash)}', request_type="PATCH", expected_code=400,
post_data=post_data)

post_data = {"tags": ["a" * 60]}
await do_request(rest_api, f'tags/{hexlify(infohash)}', request_type="PATCH", expected_code=400,
post_data = {"statements": [tag_to_statement("a" * 60)]}
await do_request(rest_api, f'knowledge/{hexlify(infohash)}', request_type="PATCH", expected_code=400,
post_data=post_data)


async def test_modify_tags(rest_api, knowledge_db):
"""
Test modifying tags
"""
post_data = {"tags": ["abc", "def"]}
post_data = {"statements": [tag_to_statement("abc"), tag_to_statement("def")]}
infohash = 'a' * 40
with freeze_time("2015-01-01") as frozen_time:
await do_request(rest_api, f'tags/{infohash}', request_type="PATCH", expected_code=200,
await do_request(rest_api, f'knowledge/{infohash}', request_type="PATCH", expected_code=200,
post_data=post_data)
with db_session:
tags = knowledge_db.get_objects(infohash, predicate=ResourceType.TAG)
assert len(tags) == 2

# Now remove a tag
frozen_time.move_to("2016-01-01")
post_data = {"tags": ["abc"]}
await do_request(rest_api, f'tags/{infohash}', request_type="PATCH", expected_code=200,
post_data = {"statements": [tag_to_statement("abc")]}
await do_request(rest_api, f'knowledge/{infohash}', request_type="PATCH", expected_code=200,
post_data=post_data)
with db_session:
tags = knowledge_db.get_objects(infohash, predicate=ResourceType.TAG)
assert tags == ["abc"]


async def test_modify_tags_no_community(knowledge_db, tags_endpoint):
tags_endpoint.community = None
async def test_modify_tags_no_community(knowledge_db, knowledge_endpoint):
knowledge_endpoint.community = None
infohash = 'a' * 20
tags_endpoint.modify_tags(infohash, {"abc", "def"})
knowledge_endpoint.modify_statements(infohash, [tag_to_statement("abc"), tag_to_statement("def")])

with db_session:
tags = knowledge_db.get_objects(infohash, predicate=ResourceType.TAG)
Expand All @@ -93,9 +98,8 @@ async def test_get_suggestions_invalid_infohash(rest_api):
"""
Test whether an error is returned if we fetch suggestions from content with an invalid infohash
"""
post_data = {"tags": ["abc", "def"]}
await do_request(rest_api, 'tags/3f3/suggestions', expected_code=400, post_data=post_data)
await do_request(rest_api, 'tags/3f3f/suggestions', expected_code=400, post_data=post_data)
await do_request(rest_api, 'knowledge/3f3/tag_suggestions', expected_code=400)
await do_request(rest_api, 'knowledge/3f3f/tag_suggestions', expected_code=400)


async def test_get_suggestions(rest_api, knowledge_db):
Expand All @@ -104,7 +108,7 @@ async def test_get_suggestions(rest_api, knowledge_db):
"""
infohash = b'a' * 20
infohash_str = hexlify(infohash)
response = await do_request(rest_api, f'tags/{infohash_str}/suggestions')
response = await do_request(rest_api, f'knowledge/{infohash_str}/tag_suggestions')
assert "suggestions" in response
assert not response["suggestions"]

Expand All @@ -120,5 +124,5 @@ def _add_operation(op=Operation.ADD):
_add_operation(op=Operation.ADD)
_add_operation(op=Operation.REMOVE)

response = await do_request(rest_api, f'tags/{infohash_str}/suggestions')
response = await do_request(rest_api, f'knowledge/{infohash_str}/tag_suggestions')
assert response["suggestions"] == ["test"]
4 changes: 2 additions & 2 deletions src/tribler/core/components/restapi/restapi_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from tribler.core.components.ipv8.ipv8_component import Ipv8Component
from tribler.core.components.key.key_component import KeyComponent
from tribler.core.components.knowledge.knowledge_component import KnowledgeComponent
from tribler.core.components.knowledge.restapi.tags_endpoint import TagsEndpoint
from tribler.core.components.knowledge.restapi.knowledge_endpoint import KnowledgeEndpoint
from tribler.core.components.libtorrent.libtorrent_component import LibtorrentComponent
from tribler.core.components.libtorrent.restapi.create_torrent_endpoint import CreateTorrentEndpoint
from tribler.core.components.libtorrent.restapi.downloads_endpoint import DownloadsEndpoint
Expand Down Expand Up @@ -124,7 +124,7 @@ async def run(self):
tags_db=knowledge_component.knowledge_db)
self.maybe_add('/remote_query', RemoteQueryEndpoint, gigachannel_component.community,
metadata_store_component.mds)
self.maybe_add('/tags', TagsEndpoint, db=knowledge_component.knowledge_db,
self.maybe_add('/knowledge', KnowledgeEndpoint, db=knowledge_component.knowledge_db,
community=knowledge_component.community)

if not isinstance(ipv8_component, NoneComponent):
Expand Down
16 changes: 12 additions & 4 deletions src/tribler/gui/dialogs/addtagsdialog.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from typing import Dict, Optional
from typing import Dict, Optional, List

from PyQt5 import uic
from PyQt5.QtCore import QModelIndex, QPoint, pyqtSignal
from PyQt5.QtWidgets import QSizePolicy, QWidget

from tribler.core.components.knowledge.db.knowledge_db import ResourceType
from tribler.core.components.knowledge.knowledge_constants import MAX_RESOURCE_LENGTH, MIN_RESOURCE_LENGTH

from tribler.gui.defs import TAG_HORIZONTAL_MARGIN
Expand Down Expand Up @@ -39,11 +40,13 @@ def __init__(self, parent: QWidget, infohash: str) -> None:
self.dialog_widget.suggestions_container.hide()

# Fetch suggestions
TriblerNetworkRequest(f"tags/{infohash}/suggestions", self.on_received_suggestions)
TriblerNetworkRequest(f"knowledge/{infohash}/tag_suggestions", self.on_received_tag_suggestions)

self.update_window()

def on_save_tags_button_clicked(self, _) -> None:
statements: List[Dict] = []

# Sanity check the entered tags
entered_tags = self.dialog_widget.edit_tags_input.get_entered_tags()
for tag in entered_tags:
Expand All @@ -57,9 +60,14 @@ def on_save_tags_button_clicked(self, _) -> None:
self.dialog_widget.error_text_label.setHidden(False)
return

self.save_button_clicked.emit(self.index, entered_tags)
statements.append({
"predicate": ResourceType.TAG,
"object": tag,
})

self.save_button_clicked.emit(self.index, statements)

def on_received_suggestions(self, data: Dict) -> None:
def on_received_tag_suggestions(self, data: Dict) -> None:
self.suggestions_loaded.emit()
if data["suggestions"]:
self.dialog_widget.suggestions_container.show()
Expand Down
16 changes: 10 additions & 6 deletions src/tribler/gui/tests/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
from PyQt5.QtWidgets import QListWidget, QTableView, QTextEdit, QTreeWidget, QTreeWidgetItem

import tribler.gui
from tribler.core.components.reporter.reported_error import ReportedError
from tribler.core.components.knowledge.db.knowledge_db import ResourceType
from tribler.core.components.knowledge.knowledge_constants import MIN_RESOURCE_LENGTH
from tribler.core.components.reporter.reported_error import ReportedError
from tribler.core.sentry_reporter.sentry_reporter import SentryReporter
from tribler.core.tests.tools.common import TESTS_DATA_DIR
from tribler.core.utilities.rest_utils import path_to_url
Expand Down Expand Up @@ -733,7 +734,7 @@ def test_tags_dialog(window):
screenshot(window, name="edit_tags_dialog_long_tag_removed")

QTest.mouseClick(widget.content_table.add_tags_dialog.dialog_widget.save_button, Qt.LeftButton)
wait_for_signal(widget.content_table.edited_tags)
wait_for_signal(widget.content_table.edited_metadata)
QTest.qWait(200) # It can take a bit of time to hide the dialog


Expand All @@ -747,10 +748,13 @@ def test_no_tags(window):
wait_for_list_populated(widget.content_table)

idx = widget.content_table.model().index(0, 0)
widget.content_table.save_edited_tags(idx, []) # Remove all tags
wait_for_signal(widget.content_table.edited_tags)
widget.content_table.save_edited_metadata(idx, []) # Remove all tags
wait_for_signal(widget.content_table.edited_metadata)
screenshot(window, name="content_item_no_tags")

# Put some tags back (so further tests do not fail)
widget.content_table.save_edited_tags(idx, ["abc", "def"])
wait_for_signal(widget.content_table.edited_tags)
statements = []
for tag in ["abc", "def"]:
statements.append({"predicate": ResourceType.TAG, "object": tag})
widget.content_table.save_edited_metadata(idx, statements)
wait_for_signal(widget.content_table.edited_metadata)
Loading