ol-openedx-course-translations 0.1.0__py3-none-any.whl → 0.3.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.

Files changed (35) hide show
  1. ol_openedx_course_translations/apps.py +12 -2
  2. ol_openedx_course_translations/glossaries/machine_learning/ar.txt +175 -0
  3. ol_openedx_course_translations/glossaries/machine_learning/de.txt +175 -0
  4. ol_openedx_course_translations/glossaries/machine_learning/el.txt +988 -0
  5. ol_openedx_course_translations/glossaries/machine_learning/es.txt +175 -0
  6. ol_openedx_course_translations/glossaries/machine_learning/fr.txt +175 -0
  7. ol_openedx_course_translations/glossaries/machine_learning/ja.txt +175 -0
  8. ol_openedx_course_translations/glossaries/machine_learning/pt-br.txt +175 -0
  9. ol_openedx_course_translations/glossaries/machine_learning/ru.txt +213 -0
  10. ol_openedx_course_translations/management/commands/sync_and_translate_language.py +1866 -0
  11. ol_openedx_course_translations/management/commands/translate_course.py +419 -470
  12. ol_openedx_course_translations/middleware.py +143 -0
  13. ol_openedx_course_translations/providers/__init__.py +1 -0
  14. ol_openedx_course_translations/providers/base.py +278 -0
  15. ol_openedx_course_translations/providers/deepl_provider.py +292 -0
  16. ol_openedx_course_translations/providers/llm_providers.py +565 -0
  17. ol_openedx_course_translations/settings/cms.py +17 -0
  18. ol_openedx_course_translations/settings/common.py +57 -30
  19. ol_openedx_course_translations/settings/lms.py +15 -0
  20. ol_openedx_course_translations/tasks.py +222 -0
  21. ol_openedx_course_translations/urls.py +16 -0
  22. ol_openedx_course_translations/utils/__init__.py +0 -0
  23. ol_openedx_course_translations/utils/command_utils.py +197 -0
  24. ol_openedx_course_translations/utils/constants.py +216 -0
  25. ol_openedx_course_translations/utils/course_translations.py +581 -0
  26. ol_openedx_course_translations/utils/translation_sync.py +808 -0
  27. ol_openedx_course_translations/views.py +73 -0
  28. ol_openedx_course_translations-0.3.0.dist-info/METADATA +407 -0
  29. ol_openedx_course_translations-0.3.0.dist-info/RECORD +35 -0
  30. ol_openedx_course_translations-0.3.0.dist-info/entry_points.txt +5 -0
  31. ol_openedx_course_translations-0.1.0.dist-info/METADATA +0 -63
  32. ol_openedx_course_translations-0.1.0.dist-info/RECORD +0 -11
  33. ol_openedx_course_translations-0.1.0.dist-info/entry_points.txt +0 -2
  34. {ol_openedx_course_translations-0.1.0.dist-info → ol_openedx_course_translations-0.3.0.dist-info}/WHEEL +0 -0
  35. {ol_openedx_course_translations-0.1.0.dist-info → ol_openedx_course_translations-0.3.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,581 @@
1
+ """Utility functions for course translations."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import shutil
7
+ import tarfile
8
+ from pathlib import Path
9
+ from xml.etree.ElementTree import Element
10
+
11
+ from defusedxml import ElementTree
12
+ from django.conf import settings
13
+ from django.core.management.base import CommandError
14
+
15
+ from ol_openedx_course_translations.providers.deepl_provider import DeepLProvider
16
+ from ol_openedx_course_translations.providers.llm_providers import (
17
+ GeminiProvider,
18
+ MistralProvider,
19
+ OpenAIProvider,
20
+ )
21
+ from ol_openedx_course_translations.utils.constants import (
22
+ PROVIDER_DEEPL,
23
+ PROVIDER_GEMINI,
24
+ PROVIDER_MISTRAL,
25
+ PROVIDER_OPENAI,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Archive and file size limits
31
+ TAR_FILE_SIZE_LIMIT = 512 * 1024 * 1024 # 512MB
32
+
33
+
34
+ def _get_deepl_api_key() -> str:
35
+ """
36
+ Get DeepL API key from settings.
37
+
38
+ Returns:
39
+ DeepL API key
40
+
41
+ Raises:
42
+ ValueError: If DeepL API key is not configured
43
+ """
44
+ providers_config = getattr(settings, "TRANSLATIONS_PROVIDERS", {})
45
+
46
+ if PROVIDER_DEEPL in providers_config:
47
+ deepl_config = providers_config[PROVIDER_DEEPL]
48
+ if isinstance(deepl_config, dict):
49
+ api_key = deepl_config.get("api_key", "")
50
+ if api_key:
51
+ return api_key
52
+
53
+ msg = (
54
+ "DeepL API key is required. Configure it in "
55
+ "TRANSLATIONS_PROVIDERS['deepl']['api_key']"
56
+ )
57
+ raise ValueError(msg)
58
+
59
+
60
+ def get_translation_provider(
61
+ provider_name: str,
62
+ model_name: str | None = None,
63
+ ):
64
+ """
65
+ Get translation provider instance based on provider name.
66
+
67
+ Note: This function assumes validation has already been done via
68
+ _parse_and_validate_provider_spec() in the management command.
69
+
70
+ Args:
71
+ provider_name: Name of the provider (deepl, openai, gemini, mistral)
72
+ model_name: Model name to use
73
+
74
+ Returns:
75
+ Translation provider instance
76
+
77
+ Raises:
78
+ ValueError: If provider configuration is invalid
79
+ """
80
+ # Handle DeepL
81
+ deepl_api_key = _get_deepl_api_key()
82
+ if provider_name == PROVIDER_DEEPL:
83
+ return DeepLProvider(deepl_api_key, None)
84
+
85
+ # Handle LLM providers
86
+ providers_config = getattr(settings, "TRANSLATIONS_PROVIDERS", {})
87
+ provider_config = providers_config[provider_name]
88
+ api_key = provider_config["api_key"]
89
+
90
+ if provider_name == PROVIDER_OPENAI:
91
+ return OpenAIProvider(api_key, deepl_api_key, model_name)
92
+ elif provider_name == PROVIDER_GEMINI:
93
+ return GeminiProvider(api_key, deepl_api_key, model_name)
94
+ elif provider_name == PROVIDER_MISTRAL:
95
+ return MistralProvider(api_key, deepl_api_key, model_name)
96
+
97
+ msg = f"Unknown provider: {provider_name}"
98
+ raise ValueError(msg)
99
+
100
+
101
+ def translate_xml_display_name(
102
+ xml_content: str,
103
+ target_language: str,
104
+ provider,
105
+ glossary_directory: str | None = None,
106
+ ) -> str:
107
+ """
108
+ Translate display_name attribute in XML content.
109
+
110
+ This function is used primarily with DeepL for separate display_name translation.
111
+ LLM providers handle display_name translation as part of the full XML translation.
112
+
113
+ Args:
114
+ xml_content: XML content as string
115
+ target_language: Target language code
116
+ provider: Translation provider instance
117
+ glossary_directory: Optional glossary directory path
118
+
119
+ Returns:
120
+ Updated XML content with translated display_name
121
+ """
122
+ try:
123
+ xml_root = ElementTree.fromstring(xml_content)
124
+ display_name = xml_root.attrib.get("display_name")
125
+
126
+ if display_name:
127
+ translated_name = provider.translate_text(
128
+ display_name,
129
+ target_language.lower(),
130
+ glossary_directory=glossary_directory,
131
+ )
132
+ xml_root.set("display_name", translated_name)
133
+ return ElementTree.tostring(xml_root, encoding="unicode")
134
+ except ElementTree.ParseError as e:
135
+ logger.warning("Failed to parse XML for display_name translation: %s", e)
136
+
137
+ return xml_content
138
+
139
+
140
+ def update_video_xml_transcripts(xml_content: str, target_language: str) -> str:
141
+ """
142
+ Update video XML transcripts for target language.
143
+
144
+ Args:
145
+ xml_content: XML content as string
146
+ target_language: Target language code
147
+
148
+ Returns:
149
+ Updated XML content with target language transcripts
150
+ """
151
+ try:
152
+ xml_root = ElementTree.fromstring(xml_content)
153
+ target_lang_code = target_language.lower()
154
+
155
+ # Update transcripts attribute in <video> tag
156
+ if xml_root.tag == "video" and "transcripts" in xml_root.attrib:
157
+ transcripts_json_str = xml_root.attrib["transcripts"].replace("&quot;", '"')
158
+ transcripts_dict = json.loads(transcripts_json_str)
159
+
160
+ for key in list(transcripts_dict.keys()):
161
+ value = transcripts_dict[key]
162
+ new_value = re.sub(
163
+ r"-[a-zA-Z]{2}\.srt$",
164
+ f"-{target_lang_code}.srt",
165
+ value,
166
+ )
167
+ transcripts_dict[target_lang_code] = new_value
168
+
169
+ xml_root.set(
170
+ "transcripts", json.dumps(transcripts_dict, ensure_ascii=False)
171
+ )
172
+
173
+ return ElementTree.tostring(xml_root, encoding="unicode")
174
+ except (ElementTree.ParseError, json.JSONDecodeError) as e:
175
+ logger.warning("Failed to update video XML transcripts: %s", e)
176
+ return xml_content
177
+
178
+
179
+ def update_course_language_attribute(course_dir: Path, target_language: str) -> None:
180
+ """
181
+ Update language attribute in course XML files.
182
+
183
+ Args:
184
+ course_dir: Parent course directory path
185
+ target_language: Target language code
186
+ """
187
+ for xml_file in (course_dir / "course").glob("*.xml"):
188
+ try:
189
+ xml_content = xml_file.read_text(encoding="utf-8")
190
+ xml_root = ElementTree.fromstring(xml_content)
191
+
192
+ # Check if root tag is 'course' and has language attribute
193
+ if xml_root.tag == "course" and "language" in xml_root.attrib:
194
+ current_language = xml_root.attrib["language"]
195
+ xml_root.set("language", target_language.lower())
196
+ updated_xml_content = ElementTree.tostring(xml_root, encoding="unicode")
197
+ xml_file.write_text(updated_xml_content, encoding="utf-8")
198
+ logger.debug(
199
+ "Updated language attribute in %s from %s to %s",
200
+ xml_file,
201
+ current_language,
202
+ target_language.lower(),
203
+ )
204
+ except (OSError, ElementTree.ParseError) as e:
205
+ logger.warning("Failed to update language attribute in %s: %s", xml_file, e)
206
+
207
+
208
+ def translate_policy_fields( # noqa: C901
209
+ course_policy_obj: dict,
210
+ target_language: str,
211
+ provider,
212
+ glossary_directory: str | None = None,
213
+ ) -> None:
214
+ """
215
+ Translate fields in policy object.
216
+
217
+ Args:
218
+ course_policy_obj: Policy object dictionary
219
+ target_language: Target language code
220
+ provider: Translation provider instance
221
+ glossary_directory: Optional glossary directory path
222
+ """
223
+ # Translate string fields
224
+ string_fields = ["advertised_start", "display_name", "display_organization"]
225
+ for field in string_fields:
226
+ if field in course_policy_obj:
227
+ translated = provider.translate_text(
228
+ course_policy_obj[field],
229
+ target_language.lower(),
230
+ glossary_directory=glossary_directory,
231
+ )
232
+ course_policy_obj[field] = translated
233
+
234
+ # Update language attribute
235
+ course_policy_obj["language"] = target_language.lower()
236
+
237
+ # Translate discussion topics
238
+ if "discussion_topics" in course_policy_obj:
239
+ topics = course_policy_obj["discussion_topics"]
240
+ if isinstance(topics, dict):
241
+ translated_topics = {}
242
+ for key, value in topics.items():
243
+ translated_key = provider.translate_text(
244
+ key, target_language.lower(), glossary_directory=glossary_directory
245
+ )
246
+ translated_topics[translated_key] = value
247
+ course_policy_obj["discussion_topics"] = translated_topics
248
+
249
+ # Translate learning info
250
+ if "learning_info" in course_policy_obj and isinstance(
251
+ course_policy_obj["learning_info"], list
252
+ ):
253
+ translated_info = [
254
+ provider.translate_text(
255
+ item, target_language.lower(), glossary_directory=glossary_directory
256
+ )
257
+ for item in course_policy_obj["learning_info"]
258
+ ]
259
+ course_policy_obj["learning_info"] = translated_info
260
+
261
+ # Translate tabs
262
+ if "tabs" in course_policy_obj and isinstance(course_policy_obj["tabs"], list):
263
+ for tab in course_policy_obj["tabs"]:
264
+ if isinstance(tab, dict) and "name" in tab:
265
+ tab["name"] = provider.translate_text(
266
+ tab["name"],
267
+ target_language.lower(),
268
+ glossary_directory=glossary_directory,
269
+ )
270
+
271
+ # Translate XML attributes
272
+ if "xml_attributes" in course_policy_obj and isinstance(
273
+ course_policy_obj["xml_attributes"], dict
274
+ ):
275
+ xml_attributes_dict = course_policy_obj["xml_attributes"]
276
+ translatable_xml_fields = ["diplay_name", "info_sidebar_name"]
277
+ for xml_field_name in translatable_xml_fields:
278
+ if xml_field_name in xml_attributes_dict:
279
+ translated_value = provider.translate_text(
280
+ xml_attributes_dict[xml_field_name],
281
+ target_language.lower(),
282
+ glossary_directory=glossary_directory,
283
+ )
284
+ xml_attributes_dict[xml_field_name] = translated_value
285
+
286
+
287
+ def get_srt_output_filename(input_filename: str, target_language: str) -> str:
288
+ """
289
+ Generate output filename for translated SRT file.
290
+
291
+ Args:
292
+ input_filename: Original SRT filename
293
+ target_language: Target language code
294
+
295
+ Returns:
296
+ Output filename with target language code
297
+ """
298
+ if "-" in input_filename and input_filename.endswith(".srt"):
299
+ filename_parts = input_filename.rsplit("-", 1)
300
+ return f"{filename_parts[0]}-{target_language.lower()}.srt"
301
+ return input_filename
302
+
303
+
304
+ def get_supported_archive_extension(filename: str) -> str | None:
305
+ """
306
+ Return the supported archive extension if filename ends with one, else None.
307
+
308
+ Args:
309
+ filename: Name of the archive file
310
+
311
+ Returns:
312
+ Archive extension if supported, None otherwise
313
+ """
314
+ for ext in settings.COURSE_TRANSLATIONS_SUPPORTED_ARCHIVE_EXTENSIONS:
315
+ if filename.endswith(ext):
316
+ return ext
317
+ return None
318
+
319
+
320
+ def validate_tar_file(tar_file: tarfile.TarFile) -> None:
321
+ """
322
+ Validate tar file contents for security.
323
+
324
+ Args:
325
+ tar_file: Open tarfile object
326
+
327
+ Raises:
328
+ CommandError: If tar file contains unsafe members or excessively large files
329
+ """
330
+ for tar_member in tar_file.getmembers():
331
+ # Check for directory traversal attacks
332
+ if tar_member.name.startswith("/") or ".." in tar_member.name:
333
+ error_msg = f"Unsafe tar member: {tar_member.name}"
334
+ raise CommandError(error_msg)
335
+ # Check for excessively large files (512MB limit)
336
+ if tar_member.size > TAR_FILE_SIZE_LIMIT:
337
+ error_msg = f"File too large: {tar_member.name}"
338
+ raise CommandError(error_msg)
339
+
340
+
341
+ def extract_course_archive(course_archive_path: Path) -> Path:
342
+ """
343
+ Extract course archive to working directory.
344
+
345
+ Args:
346
+ course_archive_path: Path to the course archive file
347
+
348
+ Returns:
349
+ Path to extracted course directory
350
+
351
+ Raises:
352
+ CommandError: If extraction fails
353
+ """
354
+ # Use the parent directory of the source file as the base extraction directory
355
+ extraction_base_dir = course_archive_path.parent
356
+
357
+ # Get base name without extension
358
+ archive_extension = get_supported_archive_extension(course_archive_path.name)
359
+ archive_base_name = (
360
+ course_archive_path.name[: -len(archive_extension)]
361
+ if archive_extension
362
+ else course_archive_path.name
363
+ )
364
+
365
+ extracted_course_dir = extraction_base_dir / archive_base_name
366
+
367
+ if not extracted_course_dir.exists():
368
+ try:
369
+ with tarfile.open(course_archive_path, "r:*") as tar_file:
370
+ # Validate tar file before extraction
371
+ validate_tar_file(tar_file)
372
+ tar_file.extractall(path=extracted_course_dir, filter="data")
373
+ except (tarfile.TarError, OSError) as e:
374
+ error_msg = f"Failed to extract archive: {e}"
375
+ raise CommandError(error_msg) from e
376
+
377
+ logger.info("Extracted course to: %s", extracted_course_dir)
378
+ return extracted_course_dir
379
+
380
+
381
+ def create_translated_copy(source_course_dir: Path, target_language: str) -> Path:
382
+ """
383
+ Create a copy of the course for translation.
384
+
385
+ Args:
386
+ source_course_dir: Path to source course directory
387
+ target_language: Target language code
388
+
389
+ Returns:
390
+ Path to translated course directory
391
+
392
+ Raises:
393
+ CommandError: If translation directory already exists
394
+ """
395
+ source_base_name = source_course_dir.name
396
+ translated_dir_name = f"{target_language}_{source_base_name}"
397
+ translated_course_dir = source_course_dir.parent / translated_dir_name
398
+
399
+ if translated_course_dir.exists():
400
+ error_msg = f"Translation directory already exists: {translated_course_dir}"
401
+ raise CommandError(error_msg)
402
+
403
+ shutil.copytree(source_course_dir, translated_course_dir)
404
+ logger.info("Created translation copy: %s", translated_course_dir)
405
+ return translated_course_dir
406
+
407
+
408
+ def create_translated_archive(
409
+ translated_course_dir: Path,
410
+ target_language: str,
411
+ original_archive_name: str,
412
+ ) -> Path:
413
+ """
414
+ Create tar.gz archive of translated course.
415
+
416
+ Args:
417
+ translated_course_dir: Path to translated course directory
418
+ target_language: Target language code
419
+ original_archive_name: Original archive filename
420
+
421
+ Returns:
422
+ Path to created archive
423
+ """
424
+ # Remove all archive extensions from the original name
425
+ archive_extension = get_supported_archive_extension(original_archive_name)
426
+ clean_archive_name = (
427
+ original_archive_name[: -len(archive_extension)]
428
+ if archive_extension
429
+ else original_archive_name
430
+ )
431
+
432
+ translated_archive_name = f"{target_language}_{clean_archive_name}.tar.gz"
433
+ translated_archive_path = translated_course_dir.parent / translated_archive_name
434
+
435
+ # Remove existing archive
436
+ if translated_archive_path.exists():
437
+ translated_archive_path.unlink()
438
+
439
+ # Create tar.gz archive containing only the 'course' directory
440
+ course_directory_path = translated_course_dir / "course"
441
+ with tarfile.open(translated_archive_path, "w:gz") as tar_archive:
442
+ tar_archive.add(course_directory_path, arcname="course")
443
+
444
+ # Delete extracted directory after archiving
445
+ if translated_course_dir.exists():
446
+ shutil.rmtree(translated_course_dir)
447
+
448
+ logger.info("Created tar.gz archive: %s", translated_archive_path)
449
+ return translated_archive_path
450
+
451
+
452
+ def update_video_xml_complete(xml_content: str, target_language: str) -> str: # noqa: C901
453
+ """
454
+ Update video XML transcripts and transcript tags for the target language.
455
+ This is a more complete version that handles nested transcript elements.
456
+
457
+ Args:
458
+ xml_content: XML content as string
459
+ target_language: Target language code
460
+
461
+ Returns:
462
+ Updated XML content with all video transcript references
463
+ """
464
+ try:
465
+ xml_root = ElementTree.fromstring(xml_content)
466
+ target_lang_code = target_language.lower()
467
+
468
+ # Update transcripts attribute in <video>
469
+ if xml_root.tag == "video" and "transcripts" in xml_root.attrib:
470
+ transcripts_json_str = xml_root.attrib["transcripts"].replace("&quot;", '"')
471
+ transcripts_dict = json.loads(transcripts_json_str)
472
+ for transcript_key in list(transcripts_dict.keys()):
473
+ transcript_value = transcripts_dict[transcript_key]
474
+ new_transcript_key = target_lang_code
475
+ new_transcript_value = re.sub(
476
+ r"-[a-zA-Z]{2}\.srt$",
477
+ f"-{new_transcript_key}.srt",
478
+ transcript_value,
479
+ )
480
+ transcripts_dict[new_transcript_key] = new_transcript_value
481
+ updated_transcripts_json = json.dumps(transcripts_dict, ensure_ascii=False)
482
+ xml_root.set("transcripts", updated_transcripts_json)
483
+
484
+ # Add a new <transcript> tag inside <transcripts> for the target language
485
+ for video_asset_element in xml_root.findall("video_asset"):
486
+ for transcripts_element in video_asset_element.findall("transcripts"):
487
+ existing_transcript_element = transcripts_element.find("transcript")
488
+ new_transcript_element = Element("transcript")
489
+ if existing_transcript_element is not None:
490
+ new_transcript_element.attrib = (
491
+ existing_transcript_element.attrib.copy()
492
+ )
493
+ new_transcript_element.set("language_code", target_lang_code)
494
+ # Avoid duplicates
495
+ if not any(
496
+ transcript_elem.attrib == new_transcript_element.attrib
497
+ for transcript_elem in transcripts_element.findall("transcript")
498
+ ):
499
+ transcripts_element.append(new_transcript_element)
500
+
501
+ # Add a new <transcript> tag for the target language
502
+ for transcript_element in xml_root.findall("transcript"):
503
+ transcript_src = transcript_element.get("src")
504
+ if transcript_src:
505
+ new_transcript_src = re.sub(
506
+ r"-[a-zA-Z]{2}\.srt$",
507
+ f"-{target_lang_code}.srt",
508
+ transcript_src,
509
+ )
510
+ new_transcript_element = Element("transcript")
511
+ new_transcript_element.set("language", target_lang_code)
512
+ new_transcript_element.set("src", new_transcript_src)
513
+ # Avoid duplicates
514
+ if not any(
515
+ existing_transcript.get("language") == target_lang_code
516
+ and existing_transcript.get("src") == new_transcript_src
517
+ for existing_transcript in xml_root.findall("transcript")
518
+ ):
519
+ xml_root.append(new_transcript_element)
520
+
521
+ return ElementTree.tostring(xml_root, encoding="unicode")
522
+ except (ElementTree.ParseError, json.JSONDecodeError, KeyError) as e:
523
+ logger.warning("Failed to update video XML completely: %s", e)
524
+ return xml_content
525
+
526
+
527
+ def validate_course_inputs(
528
+ course_archive_path: Path,
529
+ ) -> None:
530
+ """
531
+ Validate command inputs for course translation.
532
+
533
+ Args:
534
+ course_archive_path: Path to course archive file
535
+
536
+ Raises:
537
+ CommandError: If validation fails
538
+ """
539
+ if not course_archive_path.exists():
540
+ error_msg = f"Course archive not found: {course_archive_path}"
541
+ raise CommandError(error_msg)
542
+
543
+ if get_supported_archive_extension(course_archive_path.name) is None:
544
+ supported_extensions = ", ".join(
545
+ settings.COURSE_TRANSLATIONS_SUPPORTED_ARCHIVE_EXTENSIONS
546
+ )
547
+ error_msg = f"Course archive must be a tar file: {supported_extensions}"
548
+ raise CommandError(error_msg)
549
+
550
+
551
+ def get_translatable_file_paths(
552
+ directory_path: Path,
553
+ *,
554
+ recursive: bool = False,
555
+ ) -> list[Path]:
556
+ """
557
+ Get list of translatable file paths from a directory.
558
+
559
+ Args:
560
+ directory_path: Path to directory to scan
561
+ recursive: Whether to search recursively
562
+
563
+ Returns:
564
+ List of translatable file paths
565
+ """
566
+ if recursive:
567
+ translatable_file_paths: list[Path] = []
568
+ for file_extension in settings.COURSE_TRANSLATIONS_TRANSLATABLE_EXTENSIONS:
569
+ translatable_file_paths.extend(directory_path.rglob(f"*{file_extension}"))
570
+ else:
571
+ translatable_file_paths = [
572
+ file_path
573
+ for file_path in directory_path.iterdir()
574
+ if file_path.is_file()
575
+ and any(
576
+ file_path.name.endswith(extension)
577
+ for extension in settings.COURSE_TRANSLATIONS_TRANSLATABLE_EXTENSIONS
578
+ )
579
+ ]
580
+
581
+ return translatable_file_paths