ol-openedx-course-translations 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ol-openedx-course-translations might be problematic. Click here for more details.

@@ -0,0 +1,3 @@
1
+ """
2
+ MIT's Open edX course translations plugin
3
+ """
@@ -0,0 +1,23 @@
1
+ """
2
+ ol_openedx_course_translations Django application initialization.
3
+ """
4
+
5
+ from django.apps import AppConfig
6
+ from edx_django_utils.plugins import PluginSettings
7
+ from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
8
+
9
+
10
+ class OLOpenedXCourseTranslationsConfig(AppConfig):
11
+ """
12
+ Configuration for the ol_openedx_course_translations Django application.
13
+ """
14
+
15
+ name = "ol_openedx_course_translations"
16
+
17
+ plugin_app = {
18
+ PluginSettings.CONFIG: {
19
+ ProjectType.CMS: {
20
+ SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"},
21
+ },
22
+ },
23
+ }
File without changes
@@ -0,0 +1,588 @@
1
+ """
2
+ Management command to translate course content to a specified language.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import shutil
8
+ import tarfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import deepl
13
+ from defusedxml import ElementTree
14
+ from django.conf import settings
15
+ from django.core.management.base import BaseCommand, CommandError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Command(BaseCommand):
21
+ """Translate given course content to the specified language."""
22
+
23
+ help = "Translate course content to the specified language."
24
+
25
+ def add_arguments(self, parser) -> None:
26
+ """Entry point for subclassed commands to add custom arguments."""
27
+ parser.add_argument(
28
+ "--source-language",
29
+ dest="source_language",
30
+ default="EN",
31
+ help=(
32
+ "Specify the source language of the course content "
33
+ "in ISO format, e.g. `EN` for English."
34
+ ),
35
+ )
36
+ parser.add_argument(
37
+ "--translation-language",
38
+ dest="translation_language",
39
+ required=True,
40
+ help=(
41
+ "Specify the language code in ISO format "
42
+ "to translate the course content into. e.g `AR` for Arabic"
43
+ ),
44
+ )
45
+ parser.add_argument(
46
+ "--course-dir",
47
+ dest="course_directory",
48
+ required=True,
49
+ help="Specify the course directory (tar archive).",
50
+ )
51
+
52
+ def handle(self, **options) -> None:
53
+ """Handle the translate_course command."""
54
+ try:
55
+ self._validate_inputs(options)
56
+
57
+ course_dir = Path(options["course_directory"])
58
+ source_language = options["source_language"]
59
+ translation_language = options["translation_language"]
60
+
61
+ # Extract course archive
62
+ extracted_dir = self._extract_course_archive(course_dir)
63
+
64
+ # Create translated copy
65
+ translated_dir = self._create_translated_copy(
66
+ extracted_dir, translation_language
67
+ )
68
+
69
+ # Delete extracted directory after copying
70
+ if extracted_dir.exists():
71
+ shutil.rmtree(extracted_dir)
72
+
73
+ # Translate content
74
+ billed_chars = self._translate_course_content(
75
+ translated_dir, source_language, translation_language
76
+ )
77
+
78
+ # Create final archive
79
+ archive_path = self._create_translated_archive(
80
+ translated_dir, translation_language, course_dir.stem
81
+ )
82
+
83
+ self.stdout.write(
84
+ self.style.SUCCESS(
85
+ f"Translation completed. Archive created: {archive_path}"
86
+ )
87
+ )
88
+ logger.info("Total billed characters: %s", billed_chars)
89
+
90
+ except Exception as e:
91
+ logger.exception("Translation failed")
92
+ error_msg = f"Translation failed: {e}"
93
+ raise CommandError(error_msg) from e
94
+
95
+ def get_supported_archive_extension(self, filename: str) -> str | None:
96
+ """
97
+ Return the supported archive extension if filename ends with one, else None.
98
+ """
99
+ for ext in settings.OL_OPENEDX_COURSE_TRANSLATIONS_SUPPORTED_ARCHIVE_EXTENSIONS:
100
+ if filename.endswith(ext):
101
+ return ext
102
+ return None
103
+
104
+ def _validate_inputs(self, options: dict[str, Any]) -> None:
105
+ """Validate command inputs."""
106
+ course_dir = Path(options["course_directory"])
107
+
108
+ if not course_dir.exists():
109
+ error_msg = f"Course directory not found: {course_dir}"
110
+ raise CommandError(error_msg)
111
+
112
+ if self.get_supported_archive_extension(course_dir.name) is None:
113
+ supported_exts = ", ".join(
114
+ settings.OL_OPENEDX_COURSE_TRANSLATIONS_SUPPORTED_ARCHIVE_EXTENSIONS
115
+ )
116
+ error_msg = f"Course directory must be a tar file: {supported_exts}"
117
+ raise CommandError(error_msg)
118
+
119
+ if not hasattr(settings, "DEEPL_API_KEY") or not settings.DEEPL_API_KEY:
120
+ error_msg = "DEEPL_API_KEY setting is required"
121
+ raise CommandError(error_msg)
122
+
123
+ def _extract_course_archive(self, course_dir: Path) -> Path:
124
+ """Extract course archive to working directory."""
125
+ # Use the parent directory of the source file as the base extraction directory
126
+ extract_base_dir = course_dir.parent
127
+
128
+ # Get base name without extension
129
+ ext = self.get_supported_archive_extension(course_dir.name)
130
+ tarball_base = course_dir.name[: -len(ext)] if ext else course_dir.name
131
+
132
+ extracted_dir = extract_base_dir / tarball_base
133
+
134
+ if not extracted_dir.exists():
135
+ try:
136
+ with tarfile.open(course_dir, "r:*") as tar:
137
+ # Validate tar file before extraction
138
+ self._validate_tar_file(tar)
139
+ tar.extractall(path=extracted_dir, filter="data")
140
+ except (tarfile.TarError, OSError) as e:
141
+ error_msg = f"Failed to extract archive: {e}"
142
+ raise CommandError(error_msg) from e
143
+
144
+ logger.info("Extracted course to: %s", extracted_dir)
145
+ return extracted_dir
146
+
147
+ def _validate_tar_file(self, tar: tarfile.TarFile) -> None:
148
+ """Validate tar file contents for security."""
149
+ for member in tar.getmembers():
150
+ # Check for directory traversal attacks
151
+ if member.name.startswith("/") or ".." in member.name:
152
+ error_msg = f"Unsafe tar member: {member.name}"
153
+ raise CommandError(error_msg)
154
+ # Check for excessively large files
155
+ if (
156
+ member.size > 512 * 1024 * 1024
157
+ ): # 0.5GB limit because courses on Production are big
158
+ error_msg = f"File too large: {member.name}"
159
+ raise CommandError(error_msg)
160
+
161
+ def _create_translated_copy(
162
+ self, source_dir: Path, translation_language: str
163
+ ) -> Path:
164
+ """Create a copy of the course for translation."""
165
+ base_name = source_dir.name
166
+ new_dir_name = f"{translation_language}_{base_name}"
167
+ new_dir_path = source_dir.parent / new_dir_name
168
+
169
+ if new_dir_path.exists():
170
+ error_msg = f"Translation directory already exists: {new_dir_path}"
171
+ raise CommandError(error_msg)
172
+
173
+ shutil.copytree(source_dir, new_dir_path)
174
+ logger.info("Created translation copy: %s", new_dir_path)
175
+ return new_dir_path
176
+
177
+ def _translate_course_content(
178
+ self, course_dir: Path, source_language: str, translation_language: str
179
+ ) -> int:
180
+ """Translate all course content and return total billed characters."""
181
+ total_billed_chars = 0
182
+
183
+ # Translate files in main directories
184
+ for search_dir in [course_dir, course_dir.parent]:
185
+ total_billed_chars += self._translate_files_in_directory(
186
+ search_dir, source_language, translation_language, recursive=False
187
+ )
188
+
189
+ # Translate files in target subdirectories
190
+ for dir_name in settings.OL_OPENEDX_COURSE_TRANSLATIONS_TARGET_DIRECTORIES:
191
+ target_dir = search_dir / dir_name
192
+ if target_dir.exists() and target_dir.is_dir():
193
+ total_billed_chars += self._translate_files_in_directory(
194
+ target_dir,
195
+ source_language,
196
+ translation_language,
197
+ recursive=True,
198
+ )
199
+
200
+ # Translate special JSON files
201
+ total_billed_chars += self._translate_grading_policy(
202
+ course_dir, source_language, translation_language
203
+ )
204
+ total_billed_chars += self._translate_policy_json(
205
+ course_dir, source_language, translation_language
206
+ )
207
+
208
+ return total_billed_chars
209
+
210
+ def _translate_files_in_directory(
211
+ self,
212
+ directory: Path,
213
+ source_language: str,
214
+ translation_language: str,
215
+ *,
216
+ recursive: bool = False,
217
+ ) -> int:
218
+ """Translate files in a directory."""
219
+ total_billed_chars = 0
220
+
221
+ if recursive:
222
+ file_paths: list[Path] = []
223
+ for ext in settings.OL_OPENEDX_COURSE_TRANSLATIONS_TRANSLATABLE_EXTENSIONS:
224
+ file_paths.extend(directory.rglob(f"*{ext}"))
225
+ else:
226
+ file_paths = [
227
+ f
228
+ for f in directory.iterdir()
229
+ if f.is_file()
230
+ and any(
231
+ f.name.endswith(ext)
232
+ for ext in settings.OL_OPENEDX_COURSE_TRANSLATIONS_TRANSLATABLE_EXTENSIONS # noqa: E501
233
+ )
234
+ ]
235
+
236
+ for file_path in file_paths:
237
+ try:
238
+ total_billed_chars += self._translate_file(
239
+ file_path, source_language, translation_language
240
+ )
241
+ except (OSError, UnicodeDecodeError) as e:
242
+ logger.warning("Failed to translate %s: %s", file_path, e)
243
+
244
+ return total_billed_chars
245
+
246
+ def _translate_file(
247
+ self, file_path: Path, source_language: str, translation_language: str
248
+ ) -> int:
249
+ """Translate a single file and return billed characters."""
250
+ try:
251
+ content = file_path.read_text(encoding="utf-8")
252
+ logger.debug("Translating: %s", file_path)
253
+
254
+ translated_content, billed_chars = self._translate_text(
255
+ content, source_language, translation_language, file_path.name
256
+ )
257
+
258
+ # Handle XML display_name translation
259
+ if file_path.suffix == ".xml":
260
+ translated_content = self._translate_display_name(
261
+ translated_content, source_language, translation_language
262
+ )
263
+
264
+ file_path.write_text(translated_content, encoding="utf-8")
265
+ except (OSError, UnicodeDecodeError) as e:
266
+ logger.warning("Failed to translate %s: %s", file_path, e)
267
+ return 0
268
+ else:
269
+ return billed_chars
270
+
271
+ def _translate_grading_policy(
272
+ self, course_dir: Path, source_language: str, translation_language: str
273
+ ) -> int:
274
+ """Translate grading_policy.json files."""
275
+ total_billed_chars = 0
276
+ policies_dir = course_dir / "course" / "policies"
277
+
278
+ if not policies_dir.exists():
279
+ return 0
280
+
281
+ for child_dir in policies_dir.iterdir():
282
+ if not child_dir.is_dir():
283
+ continue
284
+
285
+ grading_policy_path = child_dir / "grading_policy.json"
286
+ if not grading_policy_path.exists():
287
+ continue
288
+
289
+ try:
290
+ grading_policy = json.loads(
291
+ grading_policy_path.read_text(encoding="utf-8")
292
+ )
293
+ updated = False
294
+
295
+ for item in grading_policy.get("GRADER", []):
296
+ if "short_label" in item:
297
+ translated_label, billed_chars = self._translate_text(
298
+ item["short_label"], source_language, translation_language
299
+ )
300
+ item["short_label"] = translated_label
301
+ total_billed_chars += billed_chars
302
+ updated = True
303
+
304
+ if updated:
305
+ grading_policy_path.write_text(
306
+ json.dumps(grading_policy, ensure_ascii=False, indent=4),
307
+ encoding="utf-8",
308
+ )
309
+ except (OSError, json.JSONDecodeError) as e:
310
+ logger.warning(
311
+ "Failed to translate grading policy in %s: %s", child_dir, e
312
+ )
313
+
314
+ return total_billed_chars
315
+
316
+ def _translate_policy_json(
317
+ self, course_dir: Path, source_language: str, translation_language: str
318
+ ) -> int:
319
+ """Translate policy.json files."""
320
+ total_billed_chars = 0
321
+ policies_dir = course_dir / "course" / "policies"
322
+
323
+ if not policies_dir.exists():
324
+ return 0
325
+
326
+ for child_dir in policies_dir.iterdir():
327
+ if not child_dir.is_dir():
328
+ continue
329
+
330
+ policy_path = child_dir / "policy.json"
331
+ if not policy_path.exists():
332
+ continue
333
+
334
+ try:
335
+ policy_data = json.loads(policy_path.read_text(encoding="utf-8"))
336
+ updated = False
337
+
338
+ for course_obj in policy_data.values():
339
+ if not isinstance(course_obj, dict):
340
+ continue
341
+
342
+ # Translate various fields
343
+ billed_chars, field_updated = self._translate_policy_fields(
344
+ course_obj, source_language, translation_language
345
+ )
346
+ total_billed_chars += billed_chars
347
+ updated = updated or field_updated
348
+
349
+ if updated:
350
+ policy_path.write_text(
351
+ json.dumps(policy_data, ensure_ascii=False, indent=4),
352
+ encoding="utf-8",
353
+ )
354
+ except (OSError, json.JSONDecodeError) as e:
355
+ logger.warning("Failed to translate policy in %s: %s", child_dir, e)
356
+
357
+ return total_billed_chars
358
+
359
+ def _translate_policy_fields(
360
+ self,
361
+ course_obj: dict[str, Any],
362
+ source_language: str,
363
+ translation_language: str,
364
+ ) -> tuple[int, bool]:
365
+ """Translate specific fields in policy object."""
366
+ total_billed_chars = 0
367
+ updated = False
368
+
369
+ # Translate simple string fields
370
+ billed_chars, field_updated = self._translate_string_fields(
371
+ course_obj, source_language, translation_language
372
+ )
373
+ total_billed_chars += billed_chars
374
+ updated = updated or field_updated
375
+
376
+ # Translate discussion topics
377
+ billed_chars, field_updated = self._translate_discussion_topics(
378
+ course_obj, source_language, translation_language
379
+ )
380
+ total_billed_chars += billed_chars
381
+ updated = updated or field_updated
382
+
383
+ # Translate learning info and tabs
384
+ billed_chars, field_updated = self._translate_learning_info_and_tabs(
385
+ course_obj, source_language, translation_language
386
+ )
387
+ total_billed_chars += billed_chars
388
+ updated = updated or field_updated
389
+
390
+ # Translate XML attributes
391
+ billed_chars, field_updated = self._translate_xml_attributes(
392
+ course_obj, source_language, translation_language
393
+ )
394
+ total_billed_chars += billed_chars
395
+ updated = updated or field_updated
396
+
397
+ return total_billed_chars, updated
398
+
399
+ def _translate_string_fields(
400
+ self,
401
+ course_obj: dict[str, Any],
402
+ source_language: str,
403
+ translation_language: str,
404
+ ) -> tuple[int, bool]:
405
+ """Translate simple string fields."""
406
+ total_billed_chars = 0
407
+ updated = False
408
+
409
+ string_fields = ["advertised_start", "display_name", "display_organization"]
410
+ for field in string_fields:
411
+ if field in course_obj:
412
+ translated, billed_chars = self._translate_text(
413
+ course_obj[field], source_language, translation_language
414
+ )
415
+ course_obj[field] = translated
416
+ total_billed_chars += billed_chars
417
+ updated = True
418
+
419
+ return total_billed_chars, updated
420
+
421
+ def _translate_discussion_topics(
422
+ self,
423
+ course_obj: dict[str, Any],
424
+ source_language: str,
425
+ translation_language: str,
426
+ ) -> tuple[int, bool]:
427
+ """Translate discussion topics."""
428
+ total_billed_chars = 0
429
+ updated = False
430
+
431
+ if "discussion_topics" in course_obj:
432
+ topics = course_obj["discussion_topics"]
433
+ if isinstance(topics, dict):
434
+ new_topics = {}
435
+ for topic_key, value in topics.items():
436
+ translated_key, billed_chars = self._translate_text(
437
+ topic_key, source_language, translation_language
438
+ )
439
+ new_topics[translated_key] = value
440
+ total_billed_chars += billed_chars
441
+ course_obj["discussion_topics"] = new_topics
442
+ updated = True
443
+
444
+ return total_billed_chars, updated
445
+
446
+ def _translate_learning_info_and_tabs(
447
+ self,
448
+ course_obj: dict[str, Any],
449
+ source_language: str,
450
+ translation_language: str,
451
+ ) -> tuple[int, bool]:
452
+ """Translate learning info and tabs."""
453
+ total_billed_chars = 0
454
+ updated = False
455
+
456
+ # Learning info
457
+ if "learning_info" in course_obj and isinstance(
458
+ course_obj["learning_info"], list
459
+ ):
460
+ translated_info = []
461
+ for item in course_obj["learning_info"]:
462
+ translated, billed_chars = self._translate_text(
463
+ item, source_language, translation_language
464
+ )
465
+ translated_info.append(translated)
466
+ total_billed_chars += billed_chars
467
+ course_obj["learning_info"] = translated_info
468
+ updated = True
469
+
470
+ # Tabs
471
+ if "tabs" in course_obj and isinstance(course_obj["tabs"], list):
472
+ for tab in course_obj["tabs"]:
473
+ if isinstance(tab, dict) and "name" in tab:
474
+ translated, billed_chars = self._translate_text(
475
+ tab["name"], source_language, translation_language
476
+ )
477
+ tab["name"] = translated
478
+ total_billed_chars += billed_chars
479
+ updated = True
480
+
481
+ return total_billed_chars, updated
482
+
483
+ def _translate_xml_attributes(
484
+ self,
485
+ course_obj: dict[str, Any],
486
+ source_language: str,
487
+ translation_language: str,
488
+ ) -> tuple[int, bool]:
489
+ """Translate XML attributes."""
490
+ total_billed_chars = 0
491
+ updated = False
492
+
493
+ if "xml_attributes" in course_obj and isinstance(
494
+ course_obj["xml_attributes"], dict
495
+ ):
496
+ xml_attrs = course_obj["xml_attributes"]
497
+ xml_fields = [
498
+ "diplay_name",
499
+ "info_sidebar_name",
500
+ ] # Note: keeping typo as in original
501
+ for field in xml_fields:
502
+ if field in xml_attrs:
503
+ translated, billed_chars = self._translate_text(
504
+ xml_attrs[field], source_language, translation_language
505
+ )
506
+ xml_attrs[field] = translated
507
+ total_billed_chars += billed_chars
508
+ updated = True
509
+
510
+ return total_billed_chars, updated
511
+
512
+ def _create_translated_archive(
513
+ self, translated_dir: Path, translation_language: str, original_name: str
514
+ ) -> Path:
515
+ """Create tar.gz archive of translated course."""
516
+ # Remove all archive extensions from the original name
517
+ ext = self.get_supported_archive_extension(original_name)
518
+ clean_name = original_name[: -len(ext)] if ext else original_name
519
+
520
+ tar_gz_name = f"{translation_language}_{clean_name}.tar.gz"
521
+ tar_gz_path = translated_dir.parent / tar_gz_name
522
+
523
+ # Remove existing archive
524
+ if tar_gz_path.exists():
525
+ tar_gz_path.unlink()
526
+
527
+ # Create tar.gz archive containing only the 'course' directory
528
+ course_dir_path = translated_dir / "course"
529
+ with tarfile.open(tar_gz_path, "w:gz") as tar:
530
+ tar.add(course_dir_path, arcname="course")
531
+
532
+ # Delete extracted directory after copying
533
+ if translated_dir.exists():
534
+ shutil.rmtree(translated_dir)
535
+
536
+ logger.info("Created tar.gz archive: %s", tar_gz_path)
537
+ return tar_gz_path
538
+
539
+ def _translate_text(
540
+ self,
541
+ text: str,
542
+ source_language: str,
543
+ target_language: str,
544
+ filename: str | None = None,
545
+ ) -> tuple[str, int]:
546
+ """Translate text using DeepL API."""
547
+ if not text or not text.strip():
548
+ return text, 0
549
+
550
+ try:
551
+ deepl_client = deepl.Translator(settings.DEEPL_API_KEY)
552
+
553
+ tag_handling = None
554
+ if filename:
555
+ extension = Path(filename).suffix.lstrip(".")
556
+ if extension in ["html", "xml"]:
557
+ tag_handling = extension
558
+
559
+ result = deepl_client.translate_text(
560
+ text,
561
+ source_lang=source_language,
562
+ target_lang=target_language,
563
+ tag_handling=tag_handling,
564
+ )
565
+
566
+ return result.text, result.billed_characters # noqa: TRY300
567
+ except (deepl.exceptions.DeepLException, OSError) as e:
568
+ logger.warning("Translation failed for text: %s... Error: %s", text[:50], e)
569
+ return text, 0
570
+
571
+ def _translate_display_name(
572
+ self, xml_content: str, source_language: str, target_language: str
573
+ ) -> str:
574
+ """Extract and translate the display_name attribute of the root element."""
575
+ try:
576
+ root = ElementTree.fromstring(xml_content)
577
+ display_name = root.attrib.get("display_name")
578
+
579
+ if display_name:
580
+ translated_name, _ = self._translate_text(
581
+ display_name, source_language, target_language
582
+ )
583
+ root.set("display_name", translated_name)
584
+ return ElementTree.tostring(root, encoding="unicode")
585
+ except ElementTree.ParseError as e:
586
+ logger.warning("Could not translate display_name: %s", e)
587
+
588
+ return xml_content
@@ -0,0 +1,37 @@
1
+ # noqa: INP001
2
+
3
+ """Settings to provide to edX"""
4
+
5
+
6
+ def plugin_settings(settings):
7
+ """
8
+ Populate common settings
9
+ """
10
+ env_tokens = getattr(settings, "ENV_TOKENS", {})
11
+ settings.DEEPL_API_KEY = env_tokens.get("DEEPL_API_KEY", "")
12
+ settings.OL_OPENEDX_COURSE_TRANSLATIONS_TARGET_DIRECTORIES = env_tokens.get(
13
+ "OL_OPENEDX_COURSE_TRANSLATIONS_TARGET_DIRECTORIES",
14
+ [
15
+ "about",
16
+ "course",
17
+ "chapter",
18
+ "html",
19
+ "info",
20
+ "problem",
21
+ "sequential",
22
+ "vertical",
23
+ "video",
24
+ "static",
25
+ "tabs",
26
+ ],
27
+ )
28
+ settings.OL_OPENEDX_COURSE_TRANSLATIONS_SUPPORTED_ARCHIVE_EXTENSIONS = (
29
+ env_tokens.get(
30
+ "OL_OPENEDX_COURSE_TRANSLATIONS_SUPPORTED_ARCHIVE_EXTENSIONS",
31
+ [".tar.gz", ".tgz", ".tar"],
32
+ )
33
+ )
34
+ settings.OL_OPENEDX_COURSE_TRANSLATIONS_TRANSLATABLE_EXTENSIONS = env_tokens.get(
35
+ "OL_OPENEDX_COURSE_TRANSLATIONS_TRANSLATABLE_EXTENSIONS",
36
+ [".html", ".xml"],
37
+ )
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: ol-openedx-course-translations
3
+ Version: 0.1.0
4
+ Summary: An Open edX plugin to translate courses
5
+ Author: MIT Office of Digital Learning
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE.txt
8
+ Keywords: Python,edx
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: deepl>=1.25.0
11
+ Requires-Dist: django>=4.0
12
+ Requires-Dist: djangorestframework>=3.14.0
13
+ Description-Content-Type: text/x-rst
14
+
15
+ OL Open edX Course Translations
16
+ ===============================
17
+
18
+ An Open edX plugin to manage course translations.
19
+
20
+ Purpose
21
+ *******
22
+
23
+ Translate course content into multiple languages to enhance accessibility for a global audience.
24
+
25
+ Setup
26
+ =====
27
+
28
+ For detailed installation instructions, please refer to the `plugin installation guide <../../docs#installation-guide>`_.
29
+
30
+ Installation required in:
31
+
32
+ * Studio (CMS)
33
+
34
+ Configuration
35
+ =============
36
+
37
+ - Add the following configuration values to the config file in Open edX. For any release after Juniper, that config file is ``/edx/etc/lms.yml``. If you're using ``private.py``, add these values to ``lms/envs/private.py``. These should be added to the top level. **Ask a fellow developer for these values.**
38
+
39
+ .. code-block:: python
40
+
41
+ DEEPL_API_KEY: <YOUR_DEEPL_API_KEY_HERE>
42
+
43
+ - For Tutor installations, these values can also be managed through a `custom Tutor plugin <https://docs.tutor.edly.io/tutorials/plugin.html#plugin-development-tutorial>`_.
44
+
45
+ Usage
46
+ ====================
47
+ 1. Open the course in Studio.
48
+ 2. Go to Tools -> Export Course.
49
+ 3. Export the course as a .tar.gz file.
50
+ 4. Go to the CMS shell
51
+ 5. Run the management command to translate the course:
52
+
53
+ .. code-block:: bash
54
+
55
+ ./manage.py cms translate_course --source-language <SOURCE_LANGUAGE_CODE, defaults to `EN`> --translation-language <TRANSLATION_LANGUAGE_CODE i.e. AR> --course-dir <PATH_TO_EXPORTED_COURSE_TAR_GZ>
56
+
57
+ License
58
+ *******
59
+
60
+ The code in this repository is licensed under the AGPL 3.0 unless
61
+ otherwise noted.
62
+
63
+ Please see `LICENSE.txt <LICENSE.txt>`_ for details.
@@ -0,0 +1,11 @@
1
+ ol_openedx_course_translations/__init__.py,sha256=qddojx2E0sw3CdZ_iyjwt3iTN_oTPCo1iPtRMSEgyHA,50
2
+ ol_openedx_course_translations/apps.py,sha256=AUjqONJsiuip2qG4R_CmQ1IIXoFvdEt-_G64-YWtntU,637
3
+ ol_openedx_course_translations/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ ol_openedx_course_translations/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ ol_openedx_course_translations/management/commands/translate_course.py,sha256=0H4jFC-9zbE3uMl325HW6kCXUnA4MgiS2X6Osg8xKB8,21781
6
+ ol_openedx_course_translations/settings/common.py,sha256=lzxBIJ1K8upO8Zv6WESl42NUX0xnX5otd0eEa_ko_-c,1074
7
+ ol_openedx_course_translations-0.1.0.dist-info/METADATA,sha256=s2ujGEgFhgJpWUoVxWWgNdmx1FKNSC66-Fc-vXV0G2A,1994
8
+ ol_openedx_course_translations-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ ol_openedx_course_translations-0.1.0.dist-info/entry_points.txt,sha256=07_M_0RSXTPpCQcstKi9xP27jZY-pSwZeeMrGX0mEf8,119
10
+ ol_openedx_course_translations-0.1.0.dist-info/licenses/LICENSE.txt,sha256=iVk4rSDx0SwrA7AriwFblTY0vUxpuVib1oqJEEeejN0,1496
11
+ ol_openedx_course_translations-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [cms.djangoapp]
2
+ ol_openedx_course_translations = ol_openedx_course_translations.apps:OLOpenedXCourseTranslationsConfig
@@ -0,0 +1,28 @@
1
+ Copyright (C) 2022 MIT Open Learning
2
+
3
+ All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ * Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ * Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ * Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.