diff --git a/.github/workflows/browser-extension-release.yml b/.github/workflows/browser-extension-release.yml index c6a6686b01..ed76050ab2 100644 --- a/.github/workflows/browser-extension-release.yml +++ b/.github/workflows/browser-extension-release.yml @@ -19,10 +19,13 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 + - uses: actions/setup-node@v2 + with: + node-version: '18' - name: Check extension version in the manifest run: | - ext_version=$(python -c 'import json; print(json.load(open("src/manifest.json"))["version"])') + ext_version=$(python -c 'import json; print(json.load(open("package.json"))["version"])') tag_exist=$(git tag -l "browser-extension-v$ext_version" | wc -l) echo "ext_version=$ext_version" >> $GITHUB_ENV echo "tag_exist=$tag_exist" >> $GITHUB_ENV diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 680abd9d68..7c43e0df48 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -18,6 +18,11 @@ jobs: with: node-version: '18' + - name: Prepare extension + working-directory: browser-extension + run: | + node prepareExtension.js + - uses: cypress-io/github-action@v5 with: working-directory: tests diff --git a/backend/tournesol/models/poll.py b/backend/tournesol/models/poll.py index 02004ce42a..c07a9bf3cd 100644 --- a/backend/tournesol/models/poll.py +++ b/backend/tournesol/models/poll.py @@ -140,15 +140,20 @@ def entity_has_unsafe_context(self, entity_metadata) -> tuple: return False, None - def get_entity_contexts(self, entity_metadata) -> list: + def get_entity_contexts(self, entity_metadata, prefetched_contexts=None) -> list: """ Return a list of all enabled contexts matching the given entity metadata. """ contexts = [] + if prefetched_contexts is not None: + available_contexts = prefetched_contexts + else: + available_contexts = self.all_entity_contexts.all() + # The entity contexts are expected to be already prefetched. - for entity_context in self.all_entity_contexts.all(): + for entity_context in available_contexts: if not entity_context.enabled: continue diff --git a/backend/tournesol/serializers/comparison.py b/backend/tournesol/serializers/comparison.py index 8ac5c5c43f..18e9f1ec04 100644 --- a/backend/tournesol/serializers/comparison.py +++ b/backend/tournesol/serializers/comparison.py @@ -5,6 +5,7 @@ from tournesol.models import Comparison, ComparisonCriteriaScore from tournesol.serializers.entity import RelatedEntitySerializer +from tournesol.serializers.entity_context import EntityContextSerializer class ComparisonCriteriaScoreSerializer(ModelSerializer): @@ -22,6 +23,11 @@ def validate_criteria(self, value): class ComparisonSerializerMixin: + def format_entity_contexts(self, poll, contexts, metadata): + return EntityContextSerializer( + poll.get_entity_contexts(metadata, contexts), many=True + ).data + def reverse_criteria_scores(self, criteria_scores): opposite_scores = criteria_scores.copy() for index, score in enumerate(criteria_scores): @@ -35,9 +41,7 @@ def validate_criteria_scores(self, value): score["criteria"] for score in value ) if missing_criterias: - raise ValidationError( - f"Missing required criteria: {','.join(missing_criterias)}" - ) + raise ValidationError(f"Missing required criteria: {','.join(missing_criterias)}") return value @@ -53,12 +57,23 @@ class ComparisonSerializer(ComparisonSerializerMixin, ModelSerializer): entity_a = RelatedEntitySerializer(source="entity_1") entity_b = RelatedEntitySerializer(source="entity_2") + entity_a_contexts = EntityContextSerializer(read_only=True, many=True, default=[]) + entity_b_contexts = EntityContextSerializer(read_only=True, many=True, default=[]) + criteria_scores = ComparisonCriteriaScoreSerializer(many=True) user = serializers.HiddenField(default=serializers.CurrentUserDefault()) class Meta: model = Comparison - fields = ["user", "entity_a", "entity_b", "criteria_scores", "duration_ms"] + fields = [ + "user", + "entity_a", + "entity_b", + "entity_a_contexts", + "entity_b_contexts", + "criteria_scores", + "duration_ms", + ] def to_representation(self, instance): """ @@ -69,8 +84,17 @@ def to_representation(self, instance): if self.context.get("reverse", False): ret["entity_a"], ret["entity_b"] = ret["entity_b"], ret["entity_a"] - ret["criteria_scores"] = self.reverse_criteria_scores( - ret["criteria_scores"] + ret["criteria_scores"] = self.reverse_criteria_scores(ret["criteria_scores"]) + + poll = self.context.get("poll") + ent_contexts = self.context.get("entity_contexts") + + if poll is not None: + ret["entity_a_contexts"] = self.format_entity_contexts( + poll, ent_contexts, ret["entity_a"]["metadata"] + ) + ret["entity_b_contexts"] = self.format_entity_contexts( + poll, ent_contexts, ret["entity_b"]["metadata"] ) return ret @@ -91,9 +115,7 @@ def create(self, validated_data): ) for criteria_score in criteria_scores: - ComparisonCriteriaScore.objects.create( - comparison=comparison, **criteria_score - ) + ComparisonCriteriaScore.objects.create(comparison=comparison, **criteria_score) return comparison @@ -111,10 +133,19 @@ class ComparisonUpdateSerializer(ComparisonSerializerMixin, ModelSerializer): criteria_scores = ComparisonCriteriaScoreSerializer(many=True) entity_a = RelatedEntitySerializer(source="entity_1", read_only=True) entity_b = RelatedEntitySerializer(source="entity_2", read_only=True) + entity_a_contexts = EntityContextSerializer(read_only=True, many=True, default=[]) + entity_b_contexts = EntityContextSerializer(read_only=True, many=True, default=[]) class Meta: model = Comparison - fields = ["criteria_scores", "duration_ms", "entity_a", "entity_b"] + fields = [ + "criteria_scores", + "duration_ms", + "entity_a", + "entity_b", + "entity_a_contexts", + "entity_b_contexts", + ] def to_representation(self, instance): """ @@ -128,8 +159,17 @@ def to_representation(self, instance): if self.context.get("reverse", False): ret["entity_a"], ret["entity_b"] = ret["entity_b"], ret["entity_a"] - ret["criteria_scores"] = self.reverse_criteria_scores( - ret["criteria_scores"] + ret["criteria_scores"] = self.reverse_criteria_scores(ret["criteria_scores"]) + + poll = self.context.get("poll") + ent_contexts = self.context.get("entity_contexts") + + if poll is not None: + ret["entity_a_contexts"] = self.format_entity_contexts( + poll, ent_contexts, ret["entity_a"]["metadata"] + ) + ret["entity_b_contexts"] = self.format_entity_contexts( + poll, ent_contexts, ret["entity_b"]["metadata"] ) ret.move_to_end("entity_b", last=False) @@ -144,9 +184,7 @@ def to_internal_value(self, data): ret = super().to_internal_value(data) if self.context.get("reverse", False): - ret["criteria_scores"] = self.reverse_criteria_scores( - ret["criteria_scores"] - ) + ret["criteria_scores"] = self.reverse_criteria_scores(ret["criteria_scores"]) return ret @transaction.atomic diff --git a/backend/tournesol/tests/test_api_comparison.py b/backend/tournesol/tests/test_api_comparison.py index 940bbae95f..9aa5dd12d3 100644 --- a/backend/tournesol/tests/test_api_comparison.py +++ b/backend/tournesol/tests/test_api_comparison.py @@ -20,6 +20,7 @@ Poll, RateLater, ) +from tournesol.models.entity_context import EntityContext, EntityContextLocale from tournesol.models.poll import ALGORITHM_MEHESTAN from tournesol.tests.factories.comparison import ComparisonCriteriaScoreFactory, ComparisonFactory from tournesol.tests.factories.entity import VideoFactory @@ -53,9 +54,7 @@ class ComparisonApiTestCase(TestCase): non_existing_comparison = { "entity_a": {"uid": _uid_01}, "entity_b": {"uid": _uid_03}, - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ], + "criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}], "duration_ms": 103, } @@ -66,9 +65,7 @@ def setUp(self): At least 4 videos and 2 users with 2 comparisons each are required. """ self.poll_videos = Poll.default_poll() - self.comparisons_base_url = "/users/me/comparisons/{}".format( - self.poll_videos.name - ) + self.comparisons_base_url = "/users/me/comparisons/{}".format(self.poll_videos.name) self.client = APIClient() @@ -116,6 +113,21 @@ def setUp(self): ), ] + self.ent_context_01 = EntityContext.objects.create( + name="context_safe", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.videos[0].metadata["video_id"]}, + unsafe=False, + enabled=True, + poll=self.poll_videos, + ) + + self.ent_context_01_text = EntityContextLocale.objects.create( + context=self.ent_context_01, + language="en", + text="Hello context", + ) + def _remove_optional_fields(self, comparison): comparison.pop("duration_ms", None) @@ -245,28 +257,28 @@ def test_authenticated_can_create(self): self.assertEqual(comparison.duration_ms, data["duration_ms"]) comparison_criteria_scores = comparison.criteria_scores.all() - self.assertEqual( - comparison_criteria_scores.count(), len(data["criteria_scores"]) - ) + self.assertEqual(comparison_criteria_scores.count(), len(data["criteria_scores"])) self.assertEqual( comparison_criteria_scores[0].criteria, data["criteria_scores"][0]["criteria"], ) - self.assertEqual( - comparison_criteria_scores[0].score, data["criteria_scores"][0]["score"] - ) + self.assertEqual(comparison_criteria_scores[0].score, data["criteria_scores"][0]["score"]) self.assertEqual( comparison_criteria_scores[0].weight, data["criteria_scores"][0]["weight"] ) # check the representation integrity - self.assertEqual(response.data["entity_a"]["uid"], data["entity_a"]["uid"]) - self.assertEqual(response.data["entity_b"]["uid"], data["entity_b"]["uid"]) - self.assertEqual(response.data["duration_ms"], data["duration_ms"]) + resp_data = response.data + self.assertEqual(resp_data["entity_a"]["uid"], data["entity_a"]["uid"]) + self.assertEqual(resp_data["entity_b"]["uid"], data["entity_b"]["uid"]) - self.assertEqual( - len(response.data["criteria_scores"]), len(data["criteria_scores"]) - ) + self.assertEqual(len(resp_data["entity_a_contexts"]), 1) + self.assertEqual(resp_data["entity_a_contexts"][0]["text"], self.ent_context_01_text.text) + self.assertEqual(resp_data["entity_b_contexts"], []) + + self.assertEqual(resp_data["duration_ms"], data["duration_ms"]) + + self.assertEqual(len(response.data["criteria_scores"]), len(data["criteria_scores"])) self.assertEqual( response.data["criteria_scores"][0]["criteria"], data["criteria_scores"][0]["criteria"], @@ -376,9 +388,7 @@ def test_authenticated_can_create_without_optional(self): ) comparison_criteria_scores = comparison.criteria_scores.all() - self.assertEqual( - comparison_criteria_scores.count(), len(data["criteria_scores"]) - ) + self.assertEqual(comparison_criteria_scores.count(), len(data["criteria_scores"])) self.assertEqual(comparison_criteria_scores[0].weight, 1) @override_settings(YOUTUBE_API_KEY=None) @@ -599,24 +609,21 @@ def test_authenticated_can_list(self): self.assertEqual(len(response.data["results"]), comparisons_made.count()) # the comparisons must be ordered by datetime_lastedit - comparison1 = response.data["results"][0] - comparison2 = response.data["results"][1] + results = response.data["results"] + comp1 = results[0] + comp2 = results[1] - self.assertEqual( - comparison1["entity_a"]["uid"], self.comparisons[1].entity_1.uid - ) - self.assertEqual( - comparison1["entity_b"]["uid"], self.comparisons[1].entity_2.uid - ) - self.assertEqual(comparison1["duration_ms"], self.comparisons[1].duration_ms) + self.assertEqual(comp1["entity_a"]["uid"], self.comparisons[1].entity_1.uid) + self.assertEqual(comp1["entity_b"]["uid"], self.comparisons[1].entity_2.uid) + self.assertEqual(comp1["duration_ms"], self.comparisons[1].duration_ms) - self.assertEqual( - comparison2["entity_a"]["uid"], self.comparisons[0].entity_1.uid - ) - self.assertEqual( - comparison2["entity_b"]["uid"], self.comparisons[0].entity_2.uid - ) - self.assertEqual(comparison2["duration_ms"], self.comparisons[0].duration_ms) + self.assertEqual(comp2["entity_a"]["uid"], self.comparisons[0].entity_1.uid) + self.assertEqual(comp2["entity_b"]["uid"], self.comparisons[0].entity_2.uid) + + self.assertEqual(len(comp1["entity_a_contexts"]), 1) + self.assertEqual(comp1["entity_a_contexts"][0]["text"], self.ent_context_01_text.text) + self.assertEqual(comp1["entity_b_contexts"], []) + self.assertEqual(comp2["duration_ms"], self.comparisons[0].duration_ms) def test_authenticated_can_list_filtered(self): """ @@ -689,9 +696,13 @@ def test_authenticated_can_read(self): ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["entity_a"]["uid"], self._uid_01) - self.assertEqual(response.data["entity_b"]["uid"], self._uid_02) - self.assertEqual(response.data["duration_ms"], 102) + data = response.data + self.assertEqual(data["entity_a"]["uid"], self._uid_01) + self.assertEqual(data["entity_b"]["uid"], self._uid_02) + self.assertEqual(len(data["entity_a_contexts"]), 1) + self.assertEqual(data["entity_a_contexts"][0]["text"], self.ent_context_01_text.text) + self.assertEqual(data["entity_b_contexts"], []) + self.assertEqual(data["duration_ms"], 102) def test_authenticated_can_read_reverse(self): """ @@ -721,10 +732,15 @@ def test_authenticated_can_read_reverse(self): ), format="json", ) + + data = response.data self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["entity_a"]["uid"], self._uid_02) - self.assertEqual(response.data["entity_b"]["uid"], self._uid_01) - self.assertEqual(response.data["duration_ms"], 102) + self.assertEqual(data["entity_a"]["uid"], self._uid_02) + self.assertEqual(data["entity_b"]["uid"], self._uid_01) + self.assertEqual(data["entity_a_contexts"], []) + self.assertEqual(len(data["entity_b_contexts"]), 1) + self.assertEqual(data["entity_b_contexts"][0]["text"], self.ent_context_01_text.text) + self.assertEqual(data["duration_ms"], 102) def test_anonymous_cant_update(self): """ @@ -736,11 +752,7 @@ def test_anonymous_cant_update(self): self._uid_01, self._uid_02, ), - { - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ] - }, + {"criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}]}, format="json", ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) @@ -756,15 +768,69 @@ def test_authenticated_cant_update_non_existing_poll(self): "/users/me/comparisons/{}/{}/{}/".format( non_existing_poll, self._uid_01, self._uid_02 ), - { - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ] - }, + {"criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}]}, format="json", ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_authenticated_can_update(self): + self.client.force_authenticate(user=self.user) + + ent_context = EntityContext.objects.create( + name="context_safe_03", + origin=EntityContext.ASSOCIATION, + predicate={"video_id": self.videos[2].metadata["video_id"]}, + unsafe=False, + enabled=True, + poll=self.poll_videos, + ) + + ent_context_text = EntityContextLocale.objects.create( + context=ent_context, + language="en", + text="Hello context 03", + ) + + comparison1 = Comparison.objects.create( + poll=self.poll_videos, + user=self.user, + entity_1=self.videos[2], + entity_2=self.videos[3], + ) + comparison2 = Comparison.objects.create( + poll=self.poll_videos, + user=self.user, + entity_1=self.videos[1], + entity_2=self.videos[2], + ) + response = self.client.put( + "{}/{}/{}/".format( + self.comparisons_base_url, + self._uid_03, + self._uid_04, + ), + {"criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}]}, + format="json", + ) + + data = response.data + self.assertEqual(len(data["entity_a_contexts"]), 1) + self.assertEqual(data["entity_a_contexts"][0]["text"], ent_context_text.text) + self.assertEqual(data["entity_b_contexts"], []) + + response = self.client.get( + self.comparisons_base_url, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + comp1 = response.data["results"][0] + comp2 = response.data["results"][1] + self.assertEqual(comp1["entity_a"]["uid"], comparison1.entity_1.uid) + self.assertEqual(comp1["entity_b"]["uid"], comparison1.entity_2.uid) + self.assertEqual(comp2["entity_a"]["uid"], comparison2.entity_1.uid) + self.assertEqual(comp2["entity_b"]["uid"], comparison2.entity_2.uid) + def test_anonymous_cant_delete(self): """ An anonymous user can't delete a comparison. @@ -843,54 +909,6 @@ def test_authenticated_can_delete(self): entity_2=self.videos[1], ) - def test_authenticated_integrated_comparison_list(self): - self.client.force_authenticate(user=self.user) - comparison1 = Comparison.objects.create( - poll=self.poll_videos, - user=self.user, - entity_1=self.videos[2], - entity_2=self.videos[3], - ) - comparison2 = Comparison.objects.create( - poll=self.poll_videos, - user=self.user, - entity_1=self.videos[1], - entity_2=self.videos[2], - ) - self.client.put( - "{}/{}/{}/".format( - self.comparisons_base_url, - self._uid_03, - self._uid_04, - ), - { - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ] - }, - format="json", - ) - response = self.client.get( - self.comparisons_base_url, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - result_comparison1 = response.data["results"][0] - result_comparison2 = response.data["results"][1] - self.assertEqual( - result_comparison1["entity_a"]["uid"], comparison1.entity_1.uid - ) - self.assertEqual( - result_comparison1["entity_b"]["uid"], comparison1.entity_2.uid - ) - self.assertEqual( - result_comparison2["entity_a"]["uid"], comparison2.entity_1.uid - ) - self.assertEqual( - result_comparison2["entity_b"]["uid"], comparison2.entity_2.uid - ) - def test_n_ratings_from_video(self): self.client.force_authenticate(user=self.user) @@ -901,9 +919,7 @@ def test_n_ratings_from_video(self): data1 = { "entity_a": {"uid": self._uid_05}, "entity_b": {"uid": self._uid_06}, - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ], + "criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}], "duration_ms": 103, } response = self.client.post( @@ -916,9 +932,7 @@ def test_n_ratings_from_video(self): data2 = { "entity_a": {"uid": self._uid_05}, "entity_b": {"uid": self._uid_07}, - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ], + "criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}], "duration_ms": 103, } response = self.client.post( @@ -933,9 +947,7 @@ def test_n_ratings_from_video(self): data3 = { "entity_a": {"uid": self._uid_05}, "entity_b": {"uid": self._uid_06}, - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ], + "criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}], "duration_ms": 103, } response = self.client.post( @@ -958,9 +970,7 @@ def test_n_ratings_from_video(self): @patch("tournesol.utils.api_youtube.get_video_metadata") def test_metadata_refresh_on_comparison_creation(self, mock_get_video_metadata): - mock_get_video_metadata.return_value = { - "views": "42000" - } + mock_get_video_metadata.return_value = {"views": "42000"} user = UserFactory(username="non_existing_user") self.client.force_authenticate(user=user) @@ -976,9 +986,7 @@ def test_metadata_refresh_on_comparison_creation(self, mock_get_video_metadata): data = { "entity_a": {"uid": self._uid_01}, "entity_b": {"uid": self._uid_02}, - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ], + "criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}], } response = self.client.post(self.comparisons_base_url, data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) @@ -989,9 +997,7 @@ def test_metadata_refresh_on_comparison_creation(self, mock_get_video_metadata): data = { "entity_a": {"uid": self._uid_01}, "entity_b": {"uid": self._uid_03}, - "criteria_scores": [ - {"criteria": "largely_recommended", "score": 10, "weight": 10} - ], + "criteria_scores": [{"criteria": "largely_recommended", "score": 10, "weight": 10}], } response = self.client.post(self.comparisons_base_url, data, format="json") @@ -1086,18 +1092,9 @@ def test_update_individual_scores_after_new_comparison(self): resp = self.client.post( f"/users/me/comparisons/{self.poll.name}", data={ - "entity_a": { - "uid": self.entities[0].uid - }, - "entity_b": { - "uid": self.entities[2].uid - }, - "criteria_scores": [ - { - "criteria": "criteria1", - "score": 3 - } - ] + "entity_a": {"uid": self.entities[0].uid}, + "entity_b": {"uid": self.entities[2].uid}, + "criteria_scores": [{"criteria": "criteria1", "score": 3}], }, format="json", ) diff --git a/backend/tournesol/views/comparison.py b/backend/tournesol/views/comparison.py index 0aa6197902..ce89d95aa2 100644 --- a/backend/tournesol/views/comparison.py +++ b/backend/tournesol/views/comparison.py @@ -23,6 +23,19 @@ class InactivePollError(exceptions.PermissionDenied): class ComparisonApiMixin: """A mixin used to factorize behaviours common to all API views.""" + entity_contexts = None + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + self.entity_contexts = self.poll_from_url.all_entity_contexts.prefetch_related( + "texts" + ).all() + + def get_serializer_context(self): + context = super().get_serializer_context() + context["entity_contexts"] = self.entity_contexts + return context + def comparison_already_exists(self, poll_id, request): """Return True if the comparison already exist, False instead.""" try: @@ -63,6 +76,7 @@ def get_queryset(self): Keyword arguments: uid -- the entity uid used to filter the results (default None) """ + queryset = ( Comparison.objects.select_related("entity_1", "entity_2") .prefetch_related("criteria_scores") @@ -82,6 +96,7 @@ class ComparisonListApi(mixins.CreateModelMixin, ComparisonListBaseApi): List all or a filtered list of comparisons made by the logged user, or create a new one. """ + def get(self, request, *args, **kwargs): """ Retrieve all comparisons made by the logged user, in a given poll. @@ -109,15 +124,11 @@ def perform_create(self, serializer): comparison.entity_1.update_entity_poll_rating(poll=poll) comparison.entity_1.inner.refresh_metadata() - comparison.entity_1.auto_remove_from_rate_later( - poll=poll, user=self.request.user - ) + comparison.entity_1.auto_remove_from_rate_later(poll=poll, user=self.request.user) comparison.entity_2.update_entity_poll_rating(poll=poll) comparison.entity_2.inner.refresh_metadata() - comparison.entity_2.auto_remove_from_rate_later( - poll=poll, user=self.request.user - ) + comparison.entity_2.auto_remove_from_rate_later(poll=poll, user=self.request.user) if settings.UPDATE_MEHESTAN_SCORES_ON_COMPARISON and poll.algorithm == ALGORITHM_MEHESTAN: update_user_scores(poll, user=self.request.user) @@ -152,7 +163,6 @@ class ComparisonDetailApi( DEFAULT_SERIALIZER = ComparisonSerializer UPDATE_SERIALIZER = ComparisonUpdateSerializer - currently_reversed = False def _select_serialization(self, straight=True): diff --git a/browser-extension/.eslintignore b/browser-extension/.eslintignore new file mode 100644 index 0000000000..9193f811f9 --- /dev/null +++ b/browser-extension/.eslintignore @@ -0,0 +1 @@ +src/config.js diff --git a/browser-extension/.eslintrc.json b/browser-extension/.eslintrc.json index 9ba923de8a..01210790dc 100644 --- a/browser-extension/.eslintrc.json +++ b/browser-extension/.eslintrc.json @@ -1,15 +1,11 @@ { "env": { - "browser": true, - "es6": true, - "webextensions": true + "node": true, + "es6": true }, "parserOptions": { - "ecmaVersion": 11, - "sourceType": "module", - "ecmaFeatures": { - "modules": true - } + "ecmaVersion": 13, + "sourceType": "module" }, "plugins": [], "extends": ["eslint:recommended", "plugin:prettier/recommended"], diff --git a/browser-extension/.gitignore b/browser-extension/.gitignore index cceca162b9..b57f663a88 100644 --- a/browser-extension/.gitignore +++ b/browser-extension/.gitignore @@ -1,2 +1,5 @@ *.zip node_modules/ +src/manifest.json +src/config.js +src/importWrappers/ diff --git a/browser-extension/README.md b/browser-extension/README.md index 9b75280fdd..19375adcdf 100644 --- a/browser-extension/README.md +++ b/browser-extension/README.md @@ -40,6 +40,12 @@ available here: yarn install ``` +### Prepare the extension + +Before loading the extension into your browser, you need to run `yarn configure`. It will generate `manifest.json`, `config.js` and the import wrappers (small scripts that allow us to use ECMAScript modules in content scripts). + +By default, the script creates an extension that connects to the production Tournesol website. If you want to connect to your development servers, you can run `yarn configure:dev` instead. + ### Code Quality We use `ESLint` to find and fix problems in the JavaScript code. diff --git a/browser-extension/build.sh b/browser-extension/build.sh index e1a2e44fe2..d723ea5e2c 100755 --- a/browser-extension/build.sh +++ b/browser-extension/build.sh @@ -15,6 +15,8 @@ TARGET_BASENAME='tournesol_extension.zip' pushd ${SCRIPT_PATH} > /dev/null +node prepareExtension.js + # zip the sources pushd ${SOURCE_DIR} > /dev/null zip -r -FS ../${TARGET_BASENAME} * @@ -23,4 +25,3 @@ popd > /dev/null # zip the license zip ${TARGET_BASENAME} LICENSE popd > /dev/null - diff --git a/browser-extension/package.json b/browser-extension/package.json index 2e9b6bd199..e8c7cdbc50 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -1,7 +1,11 @@ { "name": "tournesol-extension", + "version": "3.4.2", "license": "AGPL-3.0-or-later", + "type": "module", "scripts": { + "configure": "node prepareExtension.js", + "configure:dev": "TOURNESOL_ENV=dev-env node prepareExtension.js", "lint": "eslint .", "lint:fix": "eslint --fix ." }, diff --git a/browser-extension/prepareExtension.js b/browser-extension/prepareExtension.js new file mode 100644 index 0000000000..cd54b2d711 --- /dev/null +++ b/browser-extension/prepareExtension.js @@ -0,0 +1,123 @@ +import { + getForEnv, + generateImportWrappers, + writeManifest, + writeConfig, + readPackage, +} from './prepareTools.js'; + +const env = process.env.TOURNESOL_ENV || 'production'; + +const { version } = await readPackage(); + +const manifest = { + name: 'Tournesol Extension', + version, + description: 'Open Tournesol directly from YouTube', + permissions: [ + ...getForEnv( + { + production: ['https://tournesol.app/', 'https://api.tournesol.app/'], + 'dev-env': [ + 'http://localhost/', + 'http://localhost:3000/', + 'http://localhost:8000/', + ], + }, + env + ), + 'https://www.youtube.com/', + 'activeTab', + 'contextMenus', + 'storage', + 'webNavigation', + 'webRequest', + 'webRequestBlocking', + ], + manifest_version: 2, + icons: { + 64: 'Logo64.png', + 128: 'Logo128.png', + 512: 'Logo512.png', + }, + background: { + page: 'background.html', + persistent: true, + }, + browser_action: { + default_icon: { + 16: 'Logo16.png', + 64: 'Logo64.png', + }, + default_title: 'Tournesol actions', + default_popup: 'browserAction/menu.html', + }, + content_scripts: [ + { + matches: ['https://*.youtube.com/*'], + js: ['displayHomeRecommendations.js', 'displaySearchRecommendations.js'], + css: ['addTournesolRecommendations.css'], + run_at: 'document_start', + all_frames: true, + }, + { + matches: ['https://*.youtube.com/*'], + js: ['addVideoStatistics.js', 'addModal.js', 'addRateButtons.js'], + css: ['addVideoStatistics.css', 'addModal.css', 'addRateButtons.css'], + run_at: 'document_end', + all_frames: true, + }, + { + matches: getForEnv( + { + production: ['https://tournesol.app/*'], + 'dev-env': ['http://localhost:3000/*'], + }, + env + ), + js: [ + 'fetchTournesolToken.js', + 'fetchTournesolRecommendationsLanguages.js', + ], + run_at: 'document_end', + all_frames: true, + }, + ], + options_ui: { + page: 'options/options.html', + open_in_tab: true, + }, + default_locale: 'en', + web_accessible_resources: [ + 'Logo128.png', + 'html/*', + 'images/*', + 'utils.js', + 'models/*', + 'config.js', + ], +}; + +// Please DO NOT add a trailing slash to front end URL, this prevents +// creating duplicates in our web analytics tool +const config = getForEnv( + { + production: { + frontendUrl: 'https://tournesol.app', + frontendHost: 'tournesol.app', + apiUrl: 'https://api.tournesol.app', + }, + 'dev-env': { + frontendUrl: 'http://localhost:3000', + frontendHost: 'localhost:3000', + apiUrl: 'http://localhost:8000', + }, + }, + env +); + +(async () => { + await generateImportWrappers(manifest); + await writeManifest(manifest, 'src/manifest.json'); + await writeConfig(config, 'src/config.js'); +})(); diff --git a/browser-extension/prepareTools.js b/browser-extension/prepareTools.js new file mode 100644 index 0000000000..37cc214ec6 --- /dev/null +++ b/browser-extension/prepareTools.js @@ -0,0 +1,48 @@ +import { writeFile, mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +export const getForEnv = (object, env) => { + const result = object[env]; + if (result === undefined) { + throw new Error( + `No value found for the environment ${JSON.stringify(env)}` + ); + } + return result; +}; + +export const generateImportWrappers = async (manifest) => { + await Promise.all( + manifest['content_scripts'].map(async (contentScript) => { + await Promise.all( + contentScript.js.map(async (js, i) => { + const content = `import(chrome.runtime.getURL('../${js}'));\n`; + const newJs = join('importWrappers', js); + const path = join('src', newJs); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); + contentScript.js[i] = newJs; + manifest['web_accessible_resources'].push(js); + }) + ); + }) + ); +}; + +export const writeManifest = async (manifest, outputPath) => { + const content = JSON.stringify(manifest, null, 2); + await writeFile(outputPath, content); +}; + +export const writeConfig = async (config, outputPath) => { + let content = ''; + for (let [key, value] of Object.entries(config)) { + content += `export const ${key} = ${JSON.stringify(value)};\n`; + } + await writeFile(outputPath, content); +}; + +export const readPackage = async () => { + const packageContent = await readFile('package.json'); + return JSON.parse(packageContent); +}; diff --git a/browser-extension/src/.eslintrc.json b/browser-extension/src/.eslintrc.json new file mode 100644 index 0000000000..2659a2c24a --- /dev/null +++ b/browser-extension/src/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "root": true, + "env": { + "browser": true, + "es6": true, + "webextensions": true + }, + "parserOptions": { + "ecmaVersion": 11, + "sourceType": "module", + "ecmaFeatures": { + "modules": true + } + }, + "plugins": [], + "extends": ["eslint:recommended", "plugin:prettier/recommended"], + "rules": { + "prettier/prettier": "error" + } +} diff --git a/browser-extension/src/addModal.js b/browser-extension/src/addModal.js index 0d85bd7df6..4e73b44512 100644 --- a/browser-extension/src/addModal.js +++ b/browser-extension/src/addModal.js @@ -5,6 +5,8 @@ * This content script is meant to be run on each YouTube page. */ +import { frontendUrl } from './config.js'; + // unique HTML id of the extension modal const EXT_MODAL_ID = 'x-tournesol-modal'; // the value of the CSS property display used to make the modal visible @@ -15,7 +17,7 @@ const EXT_MODAL_INVISIBLE_STATE = 'none'; // unique HTML id of the Tournesol iframe const IFRAME_TOURNESOL_ID = 'x-tournesol-iframe'; // URL of the Tournesol login page -const IFRAME_TOURNESOL_LOGIN_URL = 'https://tournesol.app/login?embed=1&dnt=1'; +const IFRAME_TOURNESOL_LOGIN_URL = `${frontendUrl}/login?embed=1&dnt=1`; /** * YouTube doesnt completely load a page, so content script doesn't diff --git a/browser-extension/src/addRateButtons.js b/browser-extension/src/addRateButtons.js index c45ed99858..15471db8b9 100644 --- a/browser-extension/src/addRateButtons.js +++ b/browser-extension/src/addRateButtons.js @@ -4,6 +4,8 @@ * This content script is meant to be run on each YouTube video page. */ +import { frontendUrl } from './config.js'; + const TS_ACTIONS_ROW_ID = 'ts-video-actions-row'; const TS_ACTIONS_ROW_BEFORE_REF = 'bottom-row'; @@ -114,7 +116,7 @@ function addRateButtons() { chrome.runtime.sendMessage({ message: 'displayModal', modalOptions: { - src: `https://tournesol.app/comparison?embed=1&utm_source=extension&utm_medium=frame&uidA=yt%3A${videoId}`, + src: `${frontendUrl}/comparison?embed=1&utm_source=extension&utm_medium=frame&uidA=yt%3A${videoId}`, height: '90vh', }, }); diff --git a/browser-extension/src/addVideoStatistics.js b/browser-extension/src/addVideoStatistics.js index ffe0e11541..5e79a1fe99 100644 --- a/browser-extension/src/addVideoStatistics.js +++ b/browser-extension/src/addVideoStatistics.js @@ -3,6 +3,8 @@ // This part is called on connection for the first time on youtube.com/* /* ********************************************************************* */ +import { frontendUrl } from './config.js'; + var browser = browser || chrome; document.addEventListener('yt-navigate-finish', process); @@ -87,9 +89,7 @@ function process() { // On click statisticsButton.onclick = () => { - open( - `https://tournesol.app/entities/yt:${videoId}?utm_source=extension` - ); + open(`${frontendUrl}/entities/yt:${videoId}?utm_source=extension`); }; var div = diff --git a/browser-extension/src/background.js b/browser-extension/src/background.js index a2fac18649..7c56920775 100644 --- a/browser-extension/src/background.js +++ b/browser-extension/src/background.js @@ -10,6 +10,8 @@ import { getSingleSetting, } from './utils.js'; +import { frontendHost } from './config.js'; + const oversamplingRatioForRecentVideos = 3; const oversamplingRatioForOldVideos = 50; // Higher means videos recommended can come from further down the recommandation list @@ -363,6 +365,6 @@ chrome.webNavigation.onHistoryStateUpdated.addListener( chrome.tabs.sendMessage(event.tabId, 'historyStateUpdated'); }, { - url: [{ hostEquals: 'tournesol.app' }], + url: [{ hostEquals: frontendHost }], } ); diff --git a/browser-extension/src/browserAction/menu.js b/browser-extension/src/browserAction/menu.js index 9734f50b17..b6e5116826 100644 --- a/browser-extension/src/browserAction/menu.js +++ b/browser-extension/src/browserAction/menu.js @@ -1,4 +1,5 @@ import { addRateLater } from '../utils.js'; +import { frontendUrl } from '../config.js'; const i18n = chrome.i18n; @@ -24,7 +25,7 @@ function get_current_tab_video_id() { */ function openTournesolHome() { chrome.tabs.create({ - url: 'https://tournesol.app?utm_source=extension&utm_medium=menu', + url: `${frontendUrl}?utm_source=extension&utm_medium=menu`, }); } @@ -36,7 +37,7 @@ function rateNowAction(event) { get_current_tab_video_id().then( (videoId) => { chrome.tabs.create({ - url: `https://tournesol.app/comparison?uidA=yt:${videoId}&utm_source=extension&utm_medium=menu`, + url: `${frontendUrl}/comparison?uidA=yt:${videoId}&utm_source=extension&utm_medium=menu`, }); }, () => { @@ -93,7 +94,7 @@ function openAnalysisPageAction(event) { get_current_tab_video_id().then( (videoId) => { chrome.tabs.create({ - url: `https://tournesol.app/entities/yt:${videoId}?utm_source=extension&utm_medium=menu`, + url: `${frontendUrl}/entities/yt:${videoId}?utm_source=extension&utm_medium=menu`, }); }, () => { diff --git a/browser-extension/src/displayHomeRecommendations.js b/browser-extension/src/displayHomeRecommendations.js index 9084b7431d..041cb6fd33 100644 --- a/browser-extension/src/displayHomeRecommendations.js +++ b/browser-extension/src/displayHomeRecommendations.js @@ -23,22 +23,21 @@ parentComponentQuery: '#primary > ytd-rich-grid-renderer', displayCriteria: false, }); - homeRecommendations = new TournesolRecommendations(options); + return new TournesolRecommendations(options); }; const processHomeRecommendations = async () => { if (homeRecommendations === undefined) { - await initializeHomeRecommendations(); + homeRecommendations = initializeHomeRecommendations(); } - - homeRecommendations.process(); + (await homeRecommendations).process(); }; - const clearHomeRecommendations = () => { + const clearHomeRecommendations = async () => { if (homeRecommendations === undefined) { return; } - homeRecommendations.clear(); + (await homeRecommendations).clear(); }; const process = () => { diff --git a/browser-extension/src/displaySearchRecommendations.js b/browser-extension/src/displaySearchRecommendations.js index 826e673477..4805136969 100644 --- a/browser-extension/src/displaySearchRecommendations.js +++ b/browser-extension/src/displaySearchRecommendations.js @@ -26,23 +26,21 @@ displayCriteria: true, }); - searchRecommendations = new TournesolSearchRecommendations(options); + return new TournesolSearchRecommendations(options); }; const processSearchRecommendations = async () => { if (searchRecommendations === undefined) { - await initializeSearchRecommendations(); + searchRecommendations = initializeSearchRecommendations(); } - - searchRecommendations.process(); + (await searchRecommendations).process(); }; - const clearSearchRecommendations = () => { + const clearSearchRecommendations = async () => { if (searchRecommendations === undefined) { return; } - - searchRecommendations.clear(); + (await searchRecommendations).clear(); }; // Allow to display the Tournesol search results without modifying the diff --git a/browser-extension/src/manifest.json b/browser-extension/src/manifest.json deleted file mode 100644 index ac59677e17..0000000000 --- a/browser-extension/src/manifest.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "Tournesol Extension", - "version": "3.4.0", - "description": "Open Tournesol directly from YouTube", - "permissions": [ - "https://tournesol.app/", - "https://api.tournesol.app/", - "https://www.youtube.com/", - "activeTab", - "contextMenus", - "storage", - "webNavigation", - "webRequest", - "webRequestBlocking" - ], - "manifest_version": 2, - "icons": { - "64": "Logo64.png", - "128": "Logo128.png", - "512": "Logo512.png" - }, - "background": { - "page": "background.html", - "persistent": true - }, - "browser_action": { - "default_icon": { - "16": "Logo16.png", - "64": "Logo64.png" - }, - "default_title": "Tournesol actions", - "default_popup": "browserAction/menu.html" - }, - "content_scripts": [ - { - "matches": ["https://*.youtube.com/*"], - "js": ["displayHomeRecommendations.js","displaySearchRecommendations.js"], - "css": ["addTournesolRecommendations.css"], - "run_at": "document_start", - "all_frames": true - }, - { - "matches": ["https://*.youtube.com/*"], - "js": ["addVideoStatistics.js", "addModal.js", "addRateButtons.js"], - "css": [ - "addVideoStatistics.css", - "addModal.css", - "addRateButtons.css" - ], - "run_at": "document_end", - "all_frames": true - }, - { - "matches": ["https://tournesol.app/*"], - "js": [ - "fetchTournesolToken.js", - "fetchTournesolRecommendationsLanguages.js" - ], - "run_at": "document_end", - "all_frames": true - } - ], - "options_ui": { - "page": "options/options.html", - "open_in_tab": true - }, - "default_locale": "en", - "web_accessible_resources": ["Logo128.png", "html/*", "images/*", "utils.js", "models/*" ] -} diff --git a/browser-extension/src/models/tournesolContainer/TournesolContainer.js b/browser-extension/src/models/tournesolContainer/TournesolContainer.js index 19e5652811..f2d76f3021 100644 --- a/browser-extension/src/models/tournesolContainer/TournesolContainer.js +++ b/browser-extension/src/models/tournesolContainer/TournesolContainer.js @@ -1,4 +1,5 @@ import { TournesolVideoCard } from '../tournesolVideoCard/TournesolVideoCard.js'; +import { frontendUrl } from '../../config.js'; export class TournesolContainer { /** @@ -70,7 +71,7 @@ export class TournesolContainer { 'tournesol_mui_like_button view_more_link small'; view_more_link.target = '_blank'; view_more_link.rel = 'noopener'; - view_more_link.href = `https://tournesol.app/recommendations?search=${ + view_more_link.href = `${frontendUrl}/recommendations?search=${ this.recommendations.searchQuery }&language=${this.recommendations.recommandationsLanguages.replaceAll( ',', @@ -92,10 +93,7 @@ export class TournesolContainer { // Tournesol icon const tournesolIcon = document.createElement('img'); tournesolIcon.setAttribute('id', 'tournesol_icon'); - tournesolIcon.setAttribute( - 'src', - 'https://tournesol.app/svg/tournesol.svg' - ); + tournesolIcon.setAttribute('src', `${frontendUrl}/svg/tournesol.svg`); tournesolIcon.setAttribute('width', '24'); topActionBar.append(tournesolIcon); @@ -108,7 +106,7 @@ export class TournesolContainer { // Learn more const learnMore = document.createElement('a'); learnMore.id = 'tournesol_link'; - learnMore.href = 'https://tournesol.app?utm_source=extension'; + learnMore.href = `${frontendUrl}?utm_source=extension`; learnMore.target = '_blank'; learnMore.rel = 'noopener'; learnMore.append(chrome.i18n.getMessage('learnMore')); diff --git a/browser-extension/src/options/options.html b/browser-extension/src/options/options.html index 4eca6dee59..f5a0d0356b 100644 --- a/browser-extension/src/options/options.html +++ b/browser-extension/src/options/options.html @@ -24,7 +24,7 @@
These parameters are used only when you don't have a - Tournesol account, + Tournesol account, or when you are not logged in.
@@ -328,10 +328,9 @@