Skip to content

Commit

Permalink
[GSoC'24] M1.2 - Add API endpoint for fetching translations in bulk (o…
Browse files Browse the repository at this point in the history
…ppia#20386)

* Add API endpoint for fetching translations in bulk

* Write backend test for bulk translations API

* Gate API call behind feature flag

* Create frontend service for the backend API calls

* Fix typos

* Fix lint issues
  • Loading branch information
Vir-8 authored Jun 4, 2024
1 parent ce9f9b5 commit 2a0654e
Show file tree
Hide file tree
Showing 5 changed files with 307 additions and 2 deletions.
71 changes: 71 additions & 0 deletions core/controllers/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import datetime
import logging

from core import feature_flag_list
from core import feconf
from core import utils
from core.constants import constants
Expand All @@ -31,6 +32,7 @@
from core.domain import exp_domain
from core.domain import exp_fetchers
from core.domain import exp_services
from core.domain import feature_flag_services
from core.domain import fs_services
from core.domain import image_validation_services
from core.domain import question_services
Expand All @@ -39,6 +41,7 @@
from core.domain import state_domain
from core.domain import stats_domain
from core.domain import stats_services
from core.domain import translation_fetchers
from core.domain import user_services

from typing import Dict, List, Optional, TypedDict
Expand Down Expand Up @@ -312,6 +315,74 @@ def delete(self, exploration_id: str) -> None:
self.render_json(self.values)


class EntityTranslationsBulkHandler(
base.BaseHandler[Dict[str, str], Dict[str, str]]
):
"""Handles fetching all available translations for a given entity."""

GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON
URL_PATH_ARGS_SCHEMAS = {
'entity_type': {
'schema': {
'type': 'basestring',
'choices': [
feconf.ENTITY_TYPE_EXPLORATION,
feconf.ENTITY_TYPE_QUESTION
]
}
},
'entity_id': {
'schema': {
'type': 'basestring',
'validators': [{
'id': 'is_regex_matched',
'regex_pattern': constants.ENTITY_ID_REGEX
}]
}
},
'entity_version': {
'schema': {
'type': 'int',
'validators': [{
'id': 'is_at_least',
# Version must be greater than zero.
'min_value': 1
}]
}
}
}
HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {
'GET': {}
}

@acl_decorators.open_access
def get(
self,
entity_type: str,
entity_id: str,
entity_version: int,
) -> None:
exploration_editor_can_modify_translations = (
feature_flag_services.is_feature_flag_enabled(
feature_flag_list.FeatureNames.
EXPLORATION_EDITOR_CAN_MODIFY_TRANSLATIONS.value,
self.user_id))

if exploration_editor_can_modify_translations:
translations = {}
entity_translations = (
translation_fetchers.get_all_entity_translations_for_entity(
feconf.TranslatableEntityType(entity_type), entity_id,
entity_version))

for translation in entity_translations:
translations[translation.language_code] = translation.to_dict()

self.render_json(translations)
else:
raise self.NotFoundException


class UserExplorationPermissionsHandler(
base.BaseHandler[Dict[str, str], Dict[str, str]]
):
Expand Down
48 changes: 46 additions & 2 deletions core/controllers/editor_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import os
import zipfile

from core import feature_flag_list
from core import feconf
from core import utils
from core.constants import constants
Expand Down Expand Up @@ -52,10 +53,13 @@
if MYPY: # pragma: no cover
from mypy_imports import exp_models
from mypy_imports import stats_models
from mypy_imports import translation_models
from mypy_imports import user_models

(exp_models, user_models, stats_models) = models.Registry.import_models(
[models.Names.EXPLORATION, models.Names.USER, models.Names.STATISTICS])
(exp_models, stats_models, translation_models, user_models) = (
models.Registry.import_models([
models.Names.EXPLORATION, models.Names.STATISTICS,
models.Names.TRANSLATION, models.Names.USER]))


class BaseEditorControllerTests(test_utils.GenericTestBase):
Expand Down Expand Up @@ -3728,3 +3732,43 @@ def test_upload_successful_when_image_uploaded(self) -> None:
self.assertTrue(fs.isfile(filepath))

self.logout()


class EntityTranslationsBulkHandlerTest(test_utils.GenericTestBase):
"""Test fetching all translations of a given entity in bulk."""

@test_utils.enable_feature_flags(
[feature_flag_list.FeatureNames
.EXPLORATION_EDITOR_CAN_MODIFY_TRANSLATIONS])
def test_fetching_entity_translations_in_bulk(self) -> None:
"""Test fetching all available translations with the
appropriate feature flag being enabled.
"""
translations_mapping: Dict[str, feconf.TranslatedContentDict] = {
'content_0': {
'content_value': 'Translated content',
'content_format': 'html',
'needs_update': False
}
}
language_codes = ['hi', 'bn']
for language_code in language_codes:
translation_models.EntityTranslationsModel.create_new(
'exploration', 'exp1', 5, language_code, translations_mapping
).put()

self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME)

self.login(self.VIEWER_EMAIL)
url = '/entity_translations_bulk_handler/exploration/exp1/5'
entity_translations_bulk_dict = self.get_json(url)

for language in language_codes:
self.assertEqual(
entity_translations_bulk_dict[language]['translations'],
translations_mapping)
self.logout()

def test_fetching_translations_with_feature_flag_disabled(self) -> None:
url = '/entity_translations_bulk_handler/exploration/exp1/5'
self.get_json(url, expected_status_int=404)
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2024 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
* @fileoverview Unit tests for EntityBulkTranslationsBackendApiService.
*/

import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import {fakeAsync, flushMicrotasks, TestBed, tick} from '@angular/core/testing';
import {EntityBulkTranslationsBackendApiService} from './entity-bulk-translations-backend-api.service';

describe('Entity Bulk Translations Backend Api Service', () => {
let translationApiService: EntityBulkTranslationsBackendApiService;
let httpTestingController: HttpTestingController;
let successHandler = jasmine.createSpy('success');
let failHandler = jasmine.createSpy('fail');

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
httpTestingController = TestBed.inject(HttpTestingController);
translationApiService = TestBed.inject(
EntityBulkTranslationsBackendApiService
);
});

afterEach(() => {
httpTestingController.verify();
});

it('should fetch entity translations in bulk', fakeAsync(() => {
let entityId: string = 'entity1';
let entityType: string = 'exploration';
let entityVersion: number = 5;
translationApiService
.fetchEntityBulkTranslationsAsync(entityId, entityType, entityVersion)
.then(successHandler, failHandler);
let req = httpTestingController.expectOne(
'/entity_translations_bulk_handler/exploration/entity1/5'
);
expect(req.request.method).toEqual('GET');
req.flush(
{
hi: {
entity_id: 'entity1',
entity_type: 'exploration',
entity_version: 5,
language_code: 'hi',
translations: {
feedback_3: {
content_format: 'html',
content_value: '<p>This is feedback 1.</p>',
needs_update: false,
},
},
},
},
{
status: 200,
statusText: 'Success.',
}
);
tick();
flushMicrotasks();

expect(successHandler).toHaveBeenCalled();
}));

it('should handle backend failure', fakeAsync(() => {
let entityId: string = 'entity1';
let entityType: string = 'exploration';
let entityVersion: number = 5;
translationApiService
.fetchEntityBulkTranslationsAsync(entityId, entityType, entityVersion)
.then(successHandler, failHandler);
let req = httpTestingController.expectOne(
'/entity_translations_bulk_handler/exploration/entity1/5'
);
expect(req.request.method).toEqual('GET');
req.flush(
{
error: 'Some error occurred in the backend.',
},
{
status: 500,
statusText: 'Internal Server Error',
}
);

flushMicrotasks();

expect(failHandler).toHaveBeenCalledWith(
'Some error occurred in the backend.'
);
}));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2024 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
* @fileoverview Service to fetch all translations for an entity from the backend.
*/

import {downgradeInjectable} from '@angular/upgrade/static';
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
import {LanguageCodeToEntityTranslations} from 'services/entity-translations.services';

@Injectable({
providedIn: 'root',
})
export class EntityBulkTranslationsBackendApiService {
ENTITY_TRANSLATIONS_BULK_HANDLER_URL_TEMPLATE: string =
'/entity_translations_bulk_handler/<entity_type>/<entity_id>/<entity_version>';

constructor(
private httpClient: HttpClient,
private urlInterpolationService: UrlInterpolationService
) {}

private _getUrl(entityId: string, entityType: string, entityVersion: number) {
return this.urlInterpolationService.interpolateUrl(
this.ENTITY_TRANSLATIONS_BULK_HANDLER_URL_TEMPLATE,
{
entity_id: entityId,
entity_type: entityType,
entity_version: String(entityVersion),
}
);
}

async fetchEntityBulkTranslationsAsync(
entityId: string,
entityType: string,
entityVersion: number
): Promise<LanguageCodeToEntityTranslations> {
return new Promise((resolve, reject) => {
this.httpClient
.get<LanguageCodeToEntityTranslations>(
this._getUrl(entityId, entityType, entityVersion)
)
.toPromise()
.then(
response => {
resolve(response);
},
errorResponse => {
reject(errorResponse.error.error);
}
);
});
}
}

angular
.module('oppia')
.factory(
'EntityBulkTranslationsBackendApiService',
downgradeInjectable(EntityBulkTranslationsBackendApiService)
);
3 changes: 3 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,9 @@ def get_redirect_route(
r'%s/<entity_type>/<entity_id>' %
feconf.LEARNER_ANSWER_INFO_HANDLER_URL,
editor.LearnerAnswerInfoHandler),
get_redirect_route(
r'/entity_translations_bulk_handler/<entity_type>/<entity_id>/<entity_version>', # pylint: disable=line-too-long
editor.EntityTranslationsBulkHandler),

get_redirect_route(
r'%s' % feconf.RECENT_COMMITS_DATA_URL,
Expand Down

0 comments on commit 2a0654e

Please sign in to comment.