diff --git a/src/apps/notion/admin/__init__.py b/src/apps/notion/admin/__init__.py
new file mode 100644
index 00000000000..9316212eccc
--- /dev/null
+++ b/src/apps/notion/admin/__init__.py
@@ -0,0 +1,9 @@
+from apps.notion.admin.file import NotionMaterialFileAdmin
+from apps.notion.admin.material import NotionMaterialAdmin
+from apps.notion.admin.video import NotionVideoAdmin
+
+__all__ = [
+ "NotionMaterialAdmin",
+ "NotionMaterialFileAdmin",
+ "NotionVideoAdmin",
+]
diff --git a/src/apps/notion/admin/file.py b/src/apps/notion/admin/file.py
new file mode 100644
index 00000000000..7bf10e64749
--- /dev/null
+++ b/src/apps/notion/admin/file.py
@@ -0,0 +1,6 @@
+from apps.notion.models import MaterialFile
+from core.admin import ModelAdmin, admin
+
+
+@admin.register(MaterialFile)
+class NotionMaterialFileAdmin(ModelAdmin): ...
diff --git a/src/apps/notion/admin.py b/src/apps/notion/admin/material.py
similarity index 95%
rename from src/apps/notion/admin.py
rename to src/apps/notion/admin/material.py
index 16bd8c237c8..01c81b39821 100644
--- a/src/apps/notion/admin.py
+++ b/src/apps/notion/admin/material.py
@@ -9,7 +9,7 @@
from apps.notion import helpers
from apps.notion.client import NotionClient
from apps.notion.exceptions import NotionError
-from apps.notion.models import Material, MaterialFile
+from apps.notion.models import Material
from core.admin import ModelAdmin, admin
@@ -87,7 +87,3 @@ def notion_page(self, obj: Material) -> str:
return f"""
{obj.page_id}"""
-
-
-@admin.register(MaterialFile)
-class MaterialFileAdmin(ModelAdmin): ...
diff --git a/src/apps/notion/admin/video.py b/src/apps/notion/admin/video.py
new file mode 100644
index 00000000000..ff9d97a4aef
--- /dev/null
+++ b/src/apps/notion/admin/video.py
@@ -0,0 +1,55 @@
+from typing import no_type_check
+
+from django import forms
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
+
+from apps.notion import helpers
+from apps.notion.models import Video
+from core.admin import ModelAdmin, admin
+
+
+class NotionVideoForm(forms.ModelForm):
+ class Meta:
+ model = Video
+ fields = "__all__"
+
+ @no_type_check
+ def clean_rutube_id(self) -> str:
+ return helpers.get_rutube_video_id(self.cleaned_data["rutube_id"])
+
+ @no_type_check
+ def clean_youtube_id(self) -> str:
+ return helpers.get_youtube_video_id(self.cleaned_data["youtube_id"])
+
+
+@admin.register(Video)
+class NotionVideoAdmin(ModelAdmin):
+ form = NotionVideoForm
+
+ list_display = [
+ "id",
+ "youtube",
+ "rutube",
+ ]
+ fields = [
+ "youtube_id",
+ "rutube_id",
+ ]
+
+ @admin.display(description=_("Youtube"))
+ @mark_safe
+ def youtube(self, obj: Video) -> str:
+ return f"""
+
+ {obj.youtube_id}"""
+
+ @admin.display(description=_("RuTube"))
+ @mark_safe
+ def rutube(self, obj: Video) -> str:
+ if obj.rutube_id is not None:
+ return f"""
+
+ {obj.rutube_id}"""
+
+ return "—"
diff --git a/src/apps/notion/helpers.py b/src/apps/notion/helpers.py
index e3c7f73c492..fb728077b9a 100644
--- a/src/apps/notion/helpers.py
+++ b/src/apps/notion/helpers.py
@@ -1,5 +1,5 @@
from os.path import basename
-from urllib.parse import urlparse
+from urllib.parse import parse_qs, urlparse
from apps.notion.types import BlockId
@@ -24,3 +24,31 @@ def id_to_uuid(id: BlockId) -> BlockId:
def uuid_to_id(uuid: BlockId) -> BlockId:
return uuid.replace("-", "")
+
+
+def get_youtube_video_id(url: str) -> str | None:
+ parsed = urlparse(url)
+ if parsed.netloc == "": # assume non-urls are direct youtube ids
+ return url
+
+ if parsed.netloc not in ["youtu.be", "www.youtube.com"]:
+ return None
+
+ if parsed.query is not None:
+ query_string = parse_qs(parsed.query)
+ if "v" in query_string:
+ return query_string["v"][0]
+
+ return parsed.path.replace("/", "")
+
+
+def get_rutube_video_id(url: str) -> str | None:
+ parsed = urlparse(url)
+
+ if parsed.netloc == "": # assume non-urls are direct rutube ids
+ return url
+
+ if "rutube" not in parsed.netloc:
+ return None
+
+ return parsed.path.split("/")[-2]
diff --git a/src/apps/notion/migrations/0010_notion_video.py b/src/apps/notion/migrations/0010_notion_video.py
new file mode 100644
index 00000000000..e7d1cd4ccb4
--- /dev/null
+++ b/src/apps/notion/migrations/0010_notion_video.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.15 on 2024-09-04 07:38
+
+from django.db import migrations, models
+
+import core.models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notion", "0009_notion_slug_field_translation"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Video",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("created", models.DateTimeField(auto_now_add=True, db_index=True)),
+ ("modified", models.DateTimeField(blank=True, db_index=True, null=True)),
+ ("youtube_id", models.CharField(db_index=True, max_length=32, unique=True)),
+ ("rutube_id", models.CharField(blank=True, db_index=True, max_length=32, null=True)),
+ ],
+ options={
+ "verbose_name": "Notion video",
+ "verbose_name_plural": "Notion videos",
+ },
+ bases=(core.models.TestUtilsMixin, models.Model),
+ ),
+ ]
diff --git a/src/apps/notion/migrations/0011_notion_video_fields_and_description.py b/src/apps/notion/migrations/0011_notion_video_fields_and_description.py
new file mode 100644
index 00000000000..a61ecda97bc
--- /dev/null
+++ b/src/apps/notion/migrations/0011_notion_video_fields_and_description.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.15 on 2024-09-04 18:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notion", "0010_notion_video"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="video",
+ name="rutube_id",
+ field=models.CharField(blank=True, db_index=True, help_text="Paste it from the address bar", max_length=256, null=True),
+ ),
+ migrations.AlterField(
+ model_name="video",
+ name="youtube_id",
+ field=models.CharField(db_index=True, help_text="Paste it from the address bar", max_length=256, unique=True),
+ ),
+ ]
diff --git a/src/apps/notion/models/__init__.py b/src/apps/notion/models/__init__.py
index 2e548adc0ba..7b981058877 100644
--- a/src/apps/notion/models/__init__.py
+++ b/src/apps/notion/models/__init__.py
@@ -2,10 +2,12 @@
from apps.notion.models.cache_entry import NotionCacheEntry
from apps.notion.models.material import Material
from apps.notion.models.material_file import MaterialFile
+from apps.notion.models.video import Video
__all__ = [
"NotionAsset",
"NotionCacheEntry",
"Material",
"MaterialFile",
+ "Video",
]
diff --git a/src/apps/notion/models/video.py b/src/apps/notion/models/video.py
new file mode 100644
index 00000000000..dd99487abe5
--- /dev/null
+++ b/src/apps/notion/models/video.py
@@ -0,0 +1,29 @@
+from django.utils.translation import gettext_lazy as _
+
+from core.models import TimestampedModel, models
+
+
+class Video(TimestampedModel):
+ """Video mapping for multiple videohostings"""
+
+ youtube_id = models.CharField(max_length=256, unique=True, db_index=True, help_text=_("Paste it from the address bar"))
+ rutube_id = models.CharField(max_length=256, blank=True, null=True, db_index=True, help_text=_("Paste it from the address bar"))
+
+ class Meta:
+ verbose_name = _("Notion video")
+ verbose_name_plural = _("Notion videos")
+
+ def __str__(self) -> str:
+ return self.youtube_id
+
+ def get_youtube_embed_src(self) -> str:
+ return f"https://www.youtube.com/embed/{ self.youtube_id }?rel=0"
+
+ def get_youtube_url(self) -> str:
+ return f"https://youtu.be/{ self.youtube_id }"
+
+ def get_rutube_embed_src(self) -> str:
+ return f"https://rutube.ru/play/embed/{self.rutube_id }/"
+
+ def get_rutube_url(self) -> str:
+ return f"https://rutube.ru/video/{ self.rutube_id }/"
diff --git a/src/apps/notion/rewrite/__init__.py b/src/apps/notion/rewrite/__init__.py
index 5973dd972eb..c3058f107b8 100644
--- a/src/apps/notion/rewrite/__init__.py
+++ b/src/apps/notion/rewrite/__init__.py
@@ -2,13 +2,21 @@
from apps.notion.rewrite.links import rewrite_links
from apps.notion.rewrite.notion_so_assets import rewrite_notion_so_assets
from apps.notion.rewrite.tags import drop_extra_tags
+from apps.notion.rewrite.video import rewrite_video
from apps.notion.types import BlockData
+from core.request import get_request
def apply_our_adjustments(notion_block_data: BlockData) -> BlockData:
adjusted = rewrite_links(notion_block_data) # replace material ids
adjusted = drop_extra_tags(adjusted) # remove tags not needed in frontend rendering
adjusted = rewrite_fetched_assets(adjusted) # replace asset links with links to our cdn
+
+ request = get_request()
+ if request is not None and request.country_code == "RU":
+ if "frkn" in request.headers:
+ adjusted = rewrite_video(adjusted) # apply video adjustements for russian users
+
return rewrite_notion_so_assets(adjusted) # rewrite remaining assets to go though notion.so so they would sign our request to their S3
@@ -18,4 +26,5 @@ def apply_our_adjustments(notion_block_data: BlockData) -> BlockData:
"rewrite_fetched_assets",
"rewrite_links",
"rewrite_notion_so_assets",
+ "rewrite_video",
]
diff --git a/src/apps/notion/rewrite/video.py b/src/apps/notion/rewrite/video.py
new file mode 100644
index 00000000000..1acfc2d1649
--- /dev/null
+++ b/src/apps/notion/rewrite/video.py
@@ -0,0 +1,49 @@
+from typing import Mapping
+
+from django.core.cache import cache
+
+from apps.notion.helpers import get_youtube_video_id
+from apps.notion.models import Video
+from apps.notion.types import BlockData as NotionBlockData
+
+
+def get_video_mapping() -> Mapping[str, Mapping[str, str]]:
+ cached = cache.get("notion-video-mapping")
+ if cached is not None:
+ return cached
+
+ mapping = dict()
+
+ for video in Video.objects.all().iterator():
+ mapping[video.youtube_id] = {
+ "youtube_embed": video.get_youtube_embed_src(),
+ "rutube_embed": video.get_rutube_embed_src(),
+ "rutube_url": video.get_rutube_url(),
+ "youtube_url": video.get_youtube_url(),
+ }
+
+ cache.set(key="notion-video-mapping", value=mapping, timeout=60)
+
+ return mapping
+
+
+def rewrite_video(block_data: NotionBlockData) -> NotionBlockData:
+ if "type" not in block_data.get("value", {}): # skip rewrite for untyped blocks
+ return block_data
+
+ if block_data["value"]["type"] != "video":
+ return block_data
+
+ video_id = get_youtube_video_id(block_data["value"]["properties"]["source"][0][0])
+
+ if video_id is None: # skip rewrite for non-youtube videos
+ return block_data
+
+ video_mapping = get_video_mapping()
+
+ if video_id in video_mapping:
+ block_data["value"]["properties"]["source"] = [[video_mapping[video_id]["rutube_url"]]]
+ block_data["value"]["format"]["link_provider"] = "RuTube"
+ block_data["value"]["format"]["display_source"] = video_mapping[video_id]["rutube_embed"]
+
+ return block_data
diff --git a/src/apps/notion/tests/notion/api/conftest.py b/src/apps/notion/tests/notion/api/conftest.py
index 6cbaf8595ad..1ed3ec41c66 100644
--- a/src/apps/notion/tests/notion/api/conftest.py
+++ b/src/apps/notion/tests/notion/api/conftest.py
@@ -7,6 +7,11 @@
]
+@pytest.fixture
+def disable_notion_cache(mocker):
+ return mocker.patch("apps.notion.cache.should_bypass_cache", return_value=True)
+
+
@pytest.fixture(autouse=True)
def set_current_user(_set_current_user):
return _set_current_user
diff --git a/src/apps/notion/tests/notion/api/test_notion_api_basic.py b/src/apps/notion/tests/notion/api/test_notion_api_basic.py
new file mode 100644
index 00000000000..d213740f517
--- /dev/null
+++ b/src/apps/notion/tests/notion/api/test_notion_api_basic.py
@@ -0,0 +1,45 @@
+import pytest
+
+pytestmark = [
+ pytest.mark.django_db,
+ pytest.mark.single_thread,
+ pytest.mark.usefixtures(
+ "mock_notion_response",
+ "_cdn_dev_storage",
+ ),
+]
+
+
+@pytest.mark.parametrize("material_id", ["0e5693d2-173a-4f77-ae81-06813b6e5329", "0e5693d2173a4f77ae8106813b6e5329"])
+def test_both_formats_work_with_id(api, material_id, mock_notion_response):
+ api.get(f"/api/v2/notion/materials/{material_id}/")
+
+ mock_notion_response.assert_called_once_with("0e5693d2173a4f77ae8106813b6e5329")
+
+
+@pytest.mark.parametrize("material_slug", ["4d5726e8-ee52-4448-b8f9-7be4c7f8e632", "4d5726e8ee524448b8f97be4c7f8e632"])
+def test_both_formats_work_with_slug(api, material_slug, mock_notion_response):
+ api.get(f"/api/v2/notion/materials/{material_slug}/")
+
+ mock_notion_response.assert_called_once_with("0e5693d2173a4f77ae8106813b6e5329") # original material id
+
+
+def test_content_is_passed_from_notion_client(api, material):
+ got = api.get(f"/api/v2/notion/materials/{material.page_id}/")
+
+ assert got["block-1"]["value"]["parent_id"] == "100500"
+ assert got["block-2"]["value"]["parent_id"] == "100600"
+
+
+def test_404_for_non_existant_materials(api, mock_notion_response):
+ api.get("/api/v2/notion/materials/nonexistant/", expected_status_code=404)
+
+ mock_notion_response.assert_not_called()
+
+
+def test_404_for_inactive_materials(api, mock_notion_response, material):
+ material.update(active=False)
+
+ api.get(f"/api/v2/notion/materials/{material.page_id}/", expected_status_code=404)
+
+ mock_notion_response.assert_not_called()
diff --git a/src/apps/notion/tests/notion/api/test_notion_cache_api.py b/src/apps/notion/tests/notion/api/test_notion_api_caching.py
similarity index 100%
rename from src/apps/notion/tests/notion/api/test_notion_cache_api.py
rename to src/apps/notion/tests/notion/api/test_notion_api_caching.py
diff --git a/src/apps/notion/tests/notion/api/test_notion_material_api_permissions.py b/src/apps/notion/tests/notion/api/test_notion_api_permissions.py
similarity index 100%
rename from src/apps/notion/tests/notion/api/test_notion_material_api_permissions.py
rename to src/apps/notion/tests/notion/api/test_notion_api_permissions.py
diff --git a/src/apps/notion/tests/notion/api/test_notion_material_api.py b/src/apps/notion/tests/notion/api/test_notion_material_api_rewriting.py
similarity index 69%
rename from src/apps/notion/tests/notion/api/test_notion_material_api.py
rename to src/apps/notion/tests/notion/api/test_notion_material_api_rewriting.py
index cf96133c298..16abbf81a24 100644
--- a/src/apps/notion/tests/notion/api/test_notion_material_api.py
+++ b/src/apps/notion/tests/notion/api/test_notion_material_api_rewriting.py
@@ -3,7 +3,7 @@
import pytest
from django.utils import timezone
-from apps.notion.models import NotionAsset
+from apps.notion.models import NotionAsset, Video
pytestmark = [
pytest.mark.django_db,
@@ -11,13 +11,19 @@
pytest.mark.usefixtures(
"mock_notion_response",
"_cdn_dev_storage",
+ "disable_notion_cache",
),
]
-@pytest.fixture(autouse=True)
-def disable_notion_cache(mocker):
- return mocker.patch("apps.notion.cache.should_bypass_cache", return_value=True)
+@pytest.fixture
+def fetched_asset() -> NotionAsset:
+ return NotionAsset.objects.create(
+ url="secure.notion-static.com/typicalmacuser.jpg",
+ file="assets/typicalmacuser-downloaded.jpg",
+ size=100,
+ md5_sum="D34DBEEF",
+ )
@pytest.fixture
@@ -45,36 +51,13 @@ def _get_cached_material(page_id: str):
@pytest.fixture
-def fetched_asset() -> NotionAsset:
- return NotionAsset.objects.create(
- url="secure.notion-static.com/typicalmacuser.jpg",
- file="assets/typicalmacuser-downloaded.jpg",
- size=100,
- md5_sum="D34DBEEF",
+def _rutube_video():
+ Video.objects.create(
+ youtube_id="dVo80vW4ekw", # check 'page' fixture in notion/conftest
+ rutube_id="c30a209fe2e31c0d1513b746e168b1a3",
)
-@pytest.mark.parametrize("material_id", ["0e5693d2-173a-4f77-ae81-06813b6e5329", "0e5693d2173a4f77ae8106813b6e5329"])
-def test_both_formats_work_with_id(api, material_id, mock_notion_response):
- api.get(f"/api/v2/notion/materials/{material_id}/")
-
- mock_notion_response.assert_called_once_with("0e5693d2173a4f77ae8106813b6e5329")
-
-
-@pytest.mark.parametrize("material_slug", ["4d5726e8-ee52-4448-b8f9-7be4c7f8e632", "4d5726e8ee524448b8f97be4c7f8e632"])
-def test_both_formats_work_with_slug(api, material_slug, mock_notion_response):
- api.get(f"/api/v2/notion/materials/{material_slug}/")
-
- mock_notion_response.assert_called_once_with("0e5693d2173a4f77ae8106813b6e5329") # original material id
-
-
-def test_content_is_passed_from_notion_client(api, material):
- got = api.get(f"/api/v2/notion/materials/{material.page_id}/")
-
- assert got["block-1"]["value"]["parent_id"] == "100500"
- assert got["block-2"]["value"]["parent_id"] == "100600"
-
-
def test_page_block_goes_first_during_upstream_api_call(api, material):
"""Despite block-3 is the last block, it should be first cuz it the block with type=="page" """
got = api.get(f"/api/v2/notion/materials/{material.page_id}/")
@@ -132,15 +115,47 @@ def test_fetched_asset_paths_are_rewritten_for_cached_material(get_cached_materi
assert got["block-3"]["value"]["format"]["page_cover"] == "https://cdn.tough-dev.school/assets/typicalmacuser-downloaded.jpg"
-def test_404_for_non_existant_materials(api, mock_notion_response):
- api.get("/api/v2/notion/materials/nonexistant/", expected_status_code=404)
+def test_video_is_not_rewrited_by_default(api, material):
+ got = api.get(f"/api/v2/notion/materials/{material.page_id}/")
- mock_notion_response.assert_not_called()
+ assert "youtube" in got["block-video"]["value"]["format"]["display_source"]
-def test_404_for_inactive_materials(api, mock_notion_response, material):
- material.update(active=False)
+@pytest.mark.usefixtures("_rutube_video")
+def test_video_is_not_rewritten_for_unknown_country(api, material):
+ got = api.get(f"/api/v2/notion/materials/{material.page_id}/")
- api.get(f"/api/v2/notion/materials/{material.page_id}/", expected_status_code=404)
+ assert "youtube" in got["block-video"]["value"]["format"]["display_source"]
+
+
+@pytest.mark.usefixtures("_rutube_video")
+@pytest.mark.parametrize(
+ "country, should_rewrite",
+ [
+ ("XX", False),
+ ("RU", True),
+ ("LV", False),
+ ],
+)
+def test_video_is_not_rewritten_for_russia(api, material, country, should_rewrite):
+ got = api.get(
+ f"/api/v2/notion/materials/{material.page_id}/",
+ headers={
+ "cf-ipcountry": country,
+ "frkn": "1",
+ },
+ )
- mock_notion_response.assert_not_called()
+ assert ("rutube" in got["block-video"]["value"]["format"]["display_source"]) is should_rewrite
+
+
+@pytest.mark.usefixtures("_rutube_video")
+def test_rewrite_is_not_made_without_frkn_header(api, material):
+ """Remove this test after frontend update"""
+ got = api.get(
+ f"/api/v2/notion/materials/{material.page_id}/",
+ headers={
+ "cf-ipcountry": "RU",
+ },
+ )
+ assert "rutube" not in got["block-video"]["value"]["format"]["display_source"]
diff --git a/src/apps/notion/tests/notion/conftest.py b/src/apps/notion/tests/notion/conftest.py
index 99950ba80ce..730e44cd576 100644
--- a/src/apps/notion/tests/notion/conftest.py
+++ b/src/apps/notion/tests/notion/conftest.py
@@ -49,6 +49,7 @@ def fetch_page(mocker):
@pytest.fixture
def page() -> NotionPage:
+ """Uber page for all block manipulation testing"""
return NotionPage(
blocks=NotionBlockList(
[
@@ -79,6 +80,20 @@ def page() -> NotionPage:
}
},
),
+ NotionBlock(
+ id="block-video",
+ data={
+ "value": {
+ "type": "video",
+ "format": {
+ "link_provider": "YouTube",
+ "display_source": "https://www.youtube.com/embed/dVo80vW4ekw?rel=0",
+ },
+ "properties": {"source": [["https://youtu.be/dVo80vW4ekw"]]},
+ "parent_table": "block",
+ }
+ },
+ ),
]
)
)
diff --git a/src/apps/notion/tests/notion/rewrite/conftest.py b/src/apps/notion/tests/notion/rewrite/conftest.py
index add8b18c788..acd850a11af 100644
--- a/src/apps/notion/tests/notion/rewrite/conftest.py
+++ b/src/apps/notion/tests/notion/rewrite/conftest.py
@@ -64,3 +64,45 @@ def another_asset() -> NotionAsset:
size=100,
md5_sum="DEADBEEF",
)
+
+
+@pytest.fixture
+def youtube_video() -> dict:
+ return {
+ "value": {
+ "id": "video_block",
+ "type": "video",
+ "format": {
+ "block_width": 1280,
+ "link_author": "Школа сильных программистов",
+ "link_provider": "YouTube",
+ "display_source": "https://www.youtube.com/embed/dVo80vW4ekw?rel=0",
+ },
+ "version": 1,
+ "properties": {"source": [["https://youtu.be/dVo80vW4ekw"]]},
+ "created_time": 1723019010455,
+ "parent_table": "block",
+ "last_edited_time": 1723019010455,
+ }
+ }
+
+
+@pytest.fixture
+def non_youtube_video():
+ return {
+ "value": {
+ "id": "video_block",
+ "type": "video",
+ "format": {
+ "block_width": 1280,
+ "link_author": "Школа сильных программистов",
+ "link_provider": "YouTube",
+ "display_source": "https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9294be93-51f7-4b61-b330-cdf098234a34/diagrams.mp4",
+ },
+ "version": 1,
+ "properties": {"source": [["https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9294be93-51f7-4b61-b330-cdf098234a34/diagrams.mp4"]]},
+ "created_time": 1723019010455,
+ "parent_table": "block",
+ "last_edited_time": 1723019010455,
+ }
+ }
diff --git a/src/apps/notion/tests/notion/rewrite/test_rewrite_video.py b/src/apps/notion/tests/notion/rewrite/test_rewrite_video.py
new file mode 100644
index 00000000000..45d6b401f17
--- /dev/null
+++ b/src/apps/notion/tests/notion/rewrite/test_rewrite_video.py
@@ -0,0 +1,44 @@
+import pytest
+
+from apps.notion.models import Video
+from apps.notion.rewrite import rewrite_video
+from apps.notion.types import BlockValue
+
+pytestmark = [
+ pytest.mark.django_db,
+ pytest.mark.single_thread,
+]
+
+
+def rewrite(block) -> BlockValue:
+ return rewrite_video(block)["value"]
+
+
+@pytest.fixture(autouse=True)
+def _video_mapping():
+ Video.objects.create(
+ youtube_id="dVo80vW4ekw",
+ rutube_id="c30a209fe2e31c0d1513b746e168b1a3",
+ )
+
+
+def test_non_youtube_video(non_youtube_video):
+ assert rewrite(non_youtube_video) == non_youtube_video["value"] # should be unchanged
+
+
+def test_rewritten(youtube_video):
+ result = rewrite(youtube_video)
+
+ assert result["properties"]["source"] == [["https://rutube.ru/video/c30a209fe2e31c0d1513b746e168b1a3/"]] # Video source
+ assert result["format"]["display_source"] == "https://rutube.ru/play/embed/c30a209fe2e31c0d1513b746e168b1a3/" # embed
+ assert result["format"]["link_provider"] == "RuTube"
+
+
+def test_not_rewritten(youtube_video):
+ Video.objects.all().delete()
+
+ result = rewrite(youtube_video)
+
+ assert result["properties"]["source"] == [["https://youtu.be/dVo80vW4ekw"]]
+ assert result["format"]["display_source"] == "https://www.youtube.com/embed/dVo80vW4ekw?rel=0"
+ assert result["format"]["link_provider"] == "YouTube"
diff --git a/src/apps/notion/tests/notion/test_get_rutube_id.py b/src/apps/notion/tests/notion/test_get_rutube_id.py
new file mode 100644
index 00000000000..8eaa18ff2ae
--- /dev/null
+++ b/src/apps/notion/tests/notion/test_get_rutube_id.py
@@ -0,0 +1,16 @@
+import pytest
+
+from apps.notion.helpers import get_rutube_video_id
+
+
+@pytest.mark.parametrize(
+ "input, expected",
+ [
+ ("https://rutube.ru/video/dc26809c58022f1a1f4acfe3b86297a4/", "dc26809c58022f1a1f4acfe3b86297a4"),
+ ("dc26809c58022f1a1f4acfe3b86297a4", "dc26809c58022f1a1f4acfe3b86297a4"),
+ ("https://youtu.be/1aSDLtmircA", None),
+ ("https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9294be93-51f7-4b61-b330-cdf098234a34/diagrams.mp4", None),
+ ],
+)
+def test(input, expected):
+ assert get_rutube_video_id(input) == expected
diff --git a/src/apps/notion/tests/notion/test_get_youtube_id.py b/src/apps/notion/tests/notion/test_get_youtube_id.py
new file mode 100644
index 00000000000..ef1a90e4eba
--- /dev/null
+++ b/src/apps/notion/tests/notion/test_get_youtube_id.py
@@ -0,0 +1,18 @@
+import pytest
+
+from apps.notion.helpers import get_youtube_video_id
+
+
+@pytest.mark.parametrize(
+ "input, expected",
+ [
+ ("https://youtu.be/1aSDLtmircA", "1aSDLtmircA"),
+ ("1aSDLtmircA", "1aSDLtmircA"),
+ ("https://youtu.be/watch?v=1aSDLtmircA", "1aSDLtmircA"),
+ ("https://youtu.be/Vz-dbHfe5d4?si=gTJccaXi7B7BoqOF", "Vz-dbHfe5d4"),
+ ("https://www.youtube.com/watch?v=FmIzx00xDmz&list=PLZuAsus9sSrpdana5Mw-jSkNLNSqgfw3x&index=10&ab_channel=100500", "FmIzx00xDmz"),
+ ("https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9294be93-51f7-4b61-b330-cdf098234a34/diagrams.mp4", None),
+ ],
+)
+def test(input, expected):
+ assert get_youtube_video_id(input) == expected
diff --git a/src/apps/notion/types.py b/src/apps/notion/types.py
index d0210cee147..4db148b77be 100644
--- a/src/apps/notion/types.py
+++ b/src/apps/notion/types.py
@@ -41,6 +41,8 @@ class BlockFormat(TypedDict, total=False):
page_icon: str
page_cover: str
page_cover_position: str
+ display_source: str
+ link_provider: str
class BlockValue(TypedDict, total=False):
diff --git a/src/core/conf/middleware.py b/src/core/conf/middleware.py
index 4d85a38c43b..ff39a09eef5 100644
--- a/src/core/conf/middleware.py
+++ b/src/core/conf/middleware.py
@@ -14,7 +14,9 @@
"core.middleware.real_ip.real_ip_middleware",
"core.middleware.set_user_from_non_django_authentication.JWTAuthMiddleware",
"core.middleware.set_user_from_non_django_authentication.TokenAuthMiddleware",
+ "core.middleware.country.CountryMiddleware",
"core.middleware.global_current_user.set_global_user",
+ "core.middleware.global_request.set_global_request",
"axes.middleware.AxesMiddleware",
]
diff --git a/src/core/middleware/country.py b/src/core/middleware/country.py
new file mode 100644
index 00000000000..5fa2c73395c
--- /dev/null
+++ b/src/core/middleware/country.py
@@ -0,0 +1,22 @@
+from typing import Callable
+
+from rest_framework.response import Response
+
+from core.types import Request
+
+
+class CountryMiddleware:
+ def __init__(self, get_response: Callable) -> None:
+ self.get_response = get_response
+
+ def __call__(self, request: Request) -> Response:
+ request.country_code = self.get_country_code(request)
+
+ return self.get_response(request)
+
+ @staticmethod
+ def get_country_code(request: Request) -> str:
+ if "cf-ipcountry" in request.headers:
+ return request.headers["cf-ipcountry"]
+
+ return "XX"
diff --git a/src/core/middleware/global_request.py b/src/core/middleware/global_request.py
new file mode 100644
index 00000000000..d78f45be562
--- /dev/null
+++ b/src/core/middleware/global_request.py
@@ -0,0 +1,18 @@
+from typing import Callable
+
+from django.http import HttpRequest, HttpResponse
+
+from core.request import set_request, unset_request
+
+
+def set_global_request(get_response: Callable[[HttpRequest], HttpResponse]) -> Callable[[HttpRequest], HttpResponse]:
+ def middleware(request: HttpRequest) -> HttpResponse:
+ set_request(request) # type: ignore
+
+ response = get_response(request)
+
+ unset_request()
+
+ return response
+
+ return middleware
diff --git a/src/core/request.py b/src/core/request.py
new file mode 100644
index 00000000000..4967baaee9d
--- /dev/null
+++ b/src/core/request.py
@@ -0,0 +1,25 @@
+from threading import current_thread, local
+
+from core.types import Request
+
+_thread_locals = local()
+
+
+def get_request() -> Request | None:
+ return getattr(_thread_locals, _thread_key(), None)
+
+
+def set_request(request: Request) -> None:
+ setattr(_thread_locals, _thread_key(), request)
+
+
+def unset_request() -> None:
+ thread_key = _thread_key()
+
+ if hasattr(_thread_locals, thread_key):
+ delattr(_thread_locals, thread_key)
+
+
+def _thread_key() -> str:
+ thread_name = current_thread().name
+ return f"request_{thread_name}"
diff --git a/src/core/static/admin.css b/src/core/static/admin.css
index 83b2d03265f..19aeb94a760 100644
--- a/src/core/static/admin.css
+++ b/src/core/static/admin.css
@@ -18,3 +18,13 @@
.notion-lms-logo {
max-width: 16px;
}
+
+.notion-youtube-logo {
+ max-width: 16px;
+}
+
+.notion-rutube-logo {
+ max-width: 16px;
+ position: relative;
+ top: -3px;
+}
diff --git a/src/core/static/logo/rutube.png b/src/core/static/logo/rutube.png
new file mode 100755
index 00000000000..8273d9cd17b
Binary files /dev/null and b/src/core/static/logo/rutube.png differ
diff --git a/src/core/static/logo/youtube.png b/src/core/static/logo/youtube.png
new file mode 100644
index 00000000000..e93293852d7
Binary files /dev/null and b/src/core/static/logo/youtube.png differ
diff --git a/src/core/types.py b/src/core/types.py
index f2918072c81..bfeedeb0640 100644
--- a/src/core/types.py
+++ b/src/core/types.py
@@ -1,3 +1,15 @@
from typing import Literal
+from rest_framework.request import Request as DRFRequest
+
Language = Literal["ru", "en", "RU", "EN"]
+
+
+class Request(DRFRequest):
+ country_code: str
+
+
+__all__ = [
+ "Language",
+ "Request",
+]
diff --git a/src/locale/ru/LC_MESSAGES/django.po b/src/locale/ru/LC_MESSAGES/django.po
index 82763c4f676..99ff2bccb79 100644
--- a/src/locale/ru/LC_MESSAGES/django.po
+++ b/src/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-08-30 20:27+0300\n"
+"POT-Creation-Date: 2024-09-04 21:20+0300\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
"Language: \n"
@@ -19,245 +19,239 @@ msgstr ""
"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || "
"(n%100>=11 && n%100<=14)? 2 : 3);\n"
-#: src/apps/a12n/signals/handlers.py:13
+#: apps/a12n/signals/handlers.py:13
msgid "Too many failed login attempts"
msgstr ""
-#: src/apps/banking/base.py:22
+#: apps/banking/base.py:22
msgid "—"
msgstr ""
-#: src/apps/banking/zero_price_bank.py:19
+#: apps/banking/zero_price_bank.py:19
msgid "Zero Price"
msgstr "Бесплатно"
-#: src/apps/chains/admin/chain.py:12
+#: apps/chains/admin/chain.py:12
msgid "Archived"
msgstr "Заархивирована"
-#: src/apps/chains/admin/forms.py:16 src/apps/chains/models/message.py:39
+#: apps/chains/admin/forms.py:16 apps/chains/models/message.py:39
msgid "Parent"
msgstr "Родитель"
-#: src/apps/chains/admin/forms.py:20 src/apps/chains/models/message.py:33
+#: apps/chains/admin/forms.py:20 apps/chains/models/message.py:33
msgid "Chain"
msgstr "Цепочка"
-#: src/apps/chains/admin/forms.py:21
+#: apps/chains/admin/forms.py:21
msgid ""
"Only the chains that are neither archived nor active for sending are listed"
msgstr "В списке только не запущенные и не заархивированные цепочки"
-#: src/apps/chains/admin/message.py:48 src/apps/chains/models/chain.py:33
-#: src/apps/diplomas/admin/diploma.py:55
-#: src/apps/homework/admin/answer/admin.py:56
-#: src/apps/homework/admin/answer/admin.py:100
-#: src/apps/orders/models/order.py:59 src/apps/products/models/course.py:74
-#: src/apps/studying/models.py:9
+#: apps/chains/admin/message.py:48 apps/chains/models/chain.py:33
+#: apps/diplomas/admin/diploma.py:55 apps/homework/admin/answer/admin.py:56
+#: apps/homework/admin/answer/admin.py:100 apps/orders/models/order.py:59
+#: apps/products/models/course.py:74 apps/studying/models.py:9
msgid "Course"
msgstr "Курс"
-#: src/apps/chains/models/chain.py:32 src/apps/chains/models/message.py:32
-#: src/apps/homework/models/question.py:14 src/apps/magnets/models.py:20
-#: src/apps/products/admin/course.py:17
+#: apps/chains/models/chain.py:32 apps/chains/models/message.py:32
+#: apps/homework/models/question.py:14 apps/magnets/models.py:20
+#: apps/products/admin/course.py:17
msgid "Name"
msgstr "Название"
-#: src/apps/chains/models/chain.py:35
+#: apps/chains/models/chain.py:35
msgid "Sending is active"
msgstr "Запущена"
-#: src/apps/chains/models/chain.py:36
+#: apps/chains/models/chain.py:36
msgid "The chain is archived"
msgstr "Заархивирована"
-#: src/apps/chains/models/chain.py:39
+#: apps/chains/models/chain.py:39
msgid "Email chain"
msgstr "Цепочка"
-#: src/apps/chains/models/chain.py:40
+#: apps/chains/models/chain.py:40
msgid "Email chains"
msgstr "Цепочки"
-#: src/apps/chains/models/message.py:34
+#: apps/chains/models/message.py:34
msgid "Template id"
msgstr "ID шаблона в постмарке"
-#: src/apps/chains/models/message.py:43
+#: apps/chains/models/message.py:43
msgid "Messages without parent will be sent upon start"
msgstr "Если не поставить — сообщение уйдёт сразу при запуске цепочки"
-#: src/apps/chains/models/message.py:46
+#: apps/chains/models/message.py:46
msgid "Delay (minutes)"
msgstr "Задержка (мин.)"
-#: src/apps/chains/models/message.py:46
+#: apps/chains/models/message.py:46
msgid "1440 for day, 10080 for week"
msgstr "День — 1440, Неделя — 10080"
-#: src/apps/chains/models/message.py:49
+#: apps/chains/models/message.py:49
msgid "Email chain message"
msgstr "Письмо"
-#: src/apps/chains/models/message.py:50
+#: apps/chains/models/message.py:50
msgid "Email chain messages"
msgstr "Письма"
-#: src/apps/diplomas/admin/diploma.py:51 src/apps/studying/models.py:8
-#: src/apps/users/models.py:107
+#: apps/diplomas/admin/diploma.py:51 apps/studying/models.py:8
+#: apps/users/models.py:107
msgid "Student"
msgstr "Студент"
-#: src/apps/diplomas/admin/diploma.py:59
-#: src/apps/homework/models/question.py:19
+#: apps/diplomas/admin/diploma.py:59 apps/homework/models/question.py:19
msgid "Homework"
msgstr "Домашка"
-#: src/apps/diplomas/admin/diploma.py:63
-#: src/apps/orders/admin/orders/admin.py:93
+#: apps/diplomas/admin/diploma.py:63 apps/orders/admin/orders/admin.py:93
msgid "Date"
msgstr "Дата"
-#: src/apps/diplomas/admin/diploma.py:67
+#: apps/diplomas/admin/diploma.py:67
msgid "Send diploma to student"
msgstr "Отправить студенту"
-#: src/apps/diplomas/admin/diploma.py:74
+#: apps/diplomas/admin/diploma.py:74
msgid "Regenerate diploma"
msgstr "Перегенерировать"
-#: src/apps/diplomas/admin/study.py:30
+#: apps/diplomas/admin/study.py:30
msgid "RU diploma exists"
msgstr "Есть диплом на русском"
-#: src/apps/diplomas/admin/study.py:36
+#: apps/diplomas/admin/study.py:36
msgid "EN diploma exists"
msgstr "Есть диплом на английском"
-#: src/apps/diplomas/apps.py:9 src/apps/diplomas/apps.py:10
-#: src/apps/diplomas/models.py:70
+#: apps/diplomas/apps.py:9 apps/diplomas/apps.py:10 apps/diplomas/models.py:70
msgid "Diplomas"
msgstr "Дипломы"
-#: src/apps/diplomas/models.py:20
+#: apps/diplomas/models.py:20
msgid "Russian"
msgstr "Русский"
-#: src/apps/diplomas/models.py:21
+#: apps/diplomas/models.py:21
msgid "English"
msgstr "Английский"
-#: src/apps/diplomas/models.py:51 src/apps/studying/models.py:21
+#: apps/diplomas/models.py:51 apps/studying/models.py:21
msgid "Study"
msgstr "Студент-курс"
-#: src/apps/diplomas/models.py:53
+#: apps/diplomas/models.py:53
msgid "Language"
msgstr "Язык"
-#: src/apps/diplomas/models.py:54
+#: apps/diplomas/models.py:54
msgid "Image"
msgstr "Обложка"
-#: src/apps/diplomas/models.py:66
+#: apps/diplomas/models.py:66
msgid "May access diplomas of all students"
msgstr "Видит все дипломы"
-#: src/apps/diplomas/models.py:69
+#: apps/diplomas/models.py:69
msgid "Diploma"
msgstr "Диплом"
-#: src/apps/diplomas/models.py:95
+#: apps/diplomas/models.py:95
msgid "Check out https://is.gd/eutOYr for available templates"
msgstr "Шаблоны искать в https://is.gd/eutOYr"
-#: src/apps/diplomas/models.py:97
+#: apps/diplomas/models.py:97
msgid "This template is for students that have completed the homework"
msgstr "Для тех, кто сдал домашку"
-#: src/apps/diplomas/models.py:100
+#: apps/diplomas/models.py:100
msgid "Diploma template"
msgstr "Шаблон диплома"
-#: src/apps/diplomas/models.py:101
+#: apps/diplomas/models.py:101
msgid "Diploma templates"
msgstr "Шаблоны дипломов"
-#: src/apps/diplomas/models.py:117
+#: apps/diplomas/models.py:117
msgid "Manual upload"
msgstr "Ручная загрузка"
-#: src/apps/diplomas/models.py:118
+#: apps/diplomas/models.py:118
msgid "Manual uploads"
msgstr "Ручные загрузки"
-#: src/apps/homework/admin/answer/admin.py:64
+#: apps/homework/admin/answer/admin.py:64
msgid "Crosschecking people"
msgstr "Учеников проверяет"
-#: src/apps/homework/admin/answer/admin.py:69
-#: src/apps/homework/admin/answer/admin.py:112
-#: src/apps/orders/models/refund.py:27
+#: apps/homework/admin/answer/admin.py:69
+#: apps/homework/admin/answer/admin.py:112 apps/orders/models/refund.py:27
msgid "Author"
msgstr "Автор"
-#: src/apps/homework/admin/answer/admin.py:108
+#: apps/homework/admin/answer/admin.py:108
msgid "Question"
msgstr "Вопрос"
-#: src/apps/homework/admin/answer/admin.py:116
+#: apps/homework/admin/answer/admin.py:116
msgid "View"
msgstr ""
-#: src/apps/homework/admin/answer/filters.py:9
+#: apps/homework/admin/answer/filters.py:9
msgid "Is root answer"
msgstr "Первый ответ"
-#: src/apps/homework/admin/question/admin.py:29
+#: apps/homework/admin/question/admin.py:29
msgid "Dispatch crosscheck"
msgstr "Запустить p2p-проверку домашки"
-#: src/apps/homework/models/answer.py:82
+#: apps/homework/models/answer.py:82
msgid "Exclude from cross-checking"
msgstr "Исключить из p2p-проверки"
-#: src/apps/homework/models/answer.py:87
+#: apps/homework/models/answer.py:87
msgid "Homework answer"
msgstr "Ответ на домашку"
-#: src/apps/homework/models/answer.py:88
+#: apps/homework/models/answer.py:88
msgid "Homework answers"
msgstr "Ответы на домашку"
-#: src/apps/homework/models/answer.py:91
+#: apps/homework/models/answer.py:91
msgid "May see answers from every user"
msgstr "Видит все ответы на домашку"
-#: src/apps/homework/models/answer_cross_check.py:29
+#: apps/homework/models/answer_cross_check.py:29
msgid "Date when crosscheck got checked"
msgstr "Когда проверен"
-#: src/apps/homework/models/question.py:20
+#: apps/homework/models/question.py:20
msgid "Homeworks"
msgstr "Домашки"
-#: src/apps/homework/models/question.py:22
+#: apps/homework/models/question.py:22
msgid "May see questions for all homeworks"
msgstr "Видит все вопросы домашки"
-#: src/apps/homework/models/reaction.py:26
+#: apps/homework/models/reaction.py:26
msgid "Reaction"
msgstr "Реакция"
-#: src/apps/homework/models/reaction.py:27
+#: apps/homework/models/reaction.py:27
msgid "Reactions"
msgstr "Реакции"
-#: src/apps/homework/services/reaction_creator.py:50
+#: apps/homework/services/reaction_creator.py:50
msgid "Invalid emoji symbol"
msgstr "Неверный символ emoji"
-#: src/apps/homework/services/reaction_creator.py:55
+#: apps/homework/services/reaction_creator.py:55
#, python-brace-format
msgid ""
"Only {Reaction.MAX_REACTIONS_FROM_ONE_AUTHOR} reactions per answer are "
@@ -266,364 +260,391 @@ msgstr ""
"Только {Reaction.MAX_REACTIONS_FROM_ONE_AUTHOR} реакций на ответ разрешено "
"для одного пользователя."
-#: src/apps/magnets/admin.py:31
+#: apps/magnets/admin.py:31
msgid "Lead count"
msgstr "Количество лидов"
-#: src/apps/magnets/apps.py:8
+#: apps/magnets/apps.py:8
msgid "Magnets"
msgstr "Лид-магниты"
-#: src/apps/magnets/models.py:23
+#: apps/magnets/models.py:23
msgid "Letter template id"
msgstr "ID почтового шаблона"
-#: src/apps/magnets/models.py:23
+#: apps/magnets/models.py:23
msgid "Will be sent upon amocrm_lead registration"
msgstr "Автоматически уходит юзеру, когда он зарегился"
-#: src/apps/magnets/models.py:25
+#: apps/magnets/models.py:25
msgid "Success Message"
msgstr "Сообщение об успехе"
-#: src/apps/magnets/models.py:25
+#: apps/magnets/models.py:25
msgid "Will be shown under tilda form"
msgstr "Покажется под формой в тильде"
-#: src/apps/magnets/models.py:28
+#: apps/magnets/models.py:28
msgid "Email Lead Magnet Campaign"
msgstr "Почтовая кампания"
-#: src/apps/magnets/models.py:29
+#: apps/magnets/models.py:29
msgid "Email Lead Magnet Campaigns"
msgstr "Почтовые кампании"
-#: src/apps/mailing/models/configuration.py:11
+#: apps/mailing/models/configuration.py:11
msgid "Unset"
msgstr "Дефолтный"
-#: src/apps/mailing/models/configuration.py:12
+#: apps/mailing/models/configuration.py:12
msgid "Postmark"
msgstr ""
-#: src/apps/mailing/models/configuration.py:16
+#: apps/mailing/models/configuration.py:16
msgid "Email sender"
msgstr "Отправитель"
-#: src/apps/mailing/models/configuration.py:16
+#: apps/mailing/models/configuration.py:16
msgid "E.g. Fedor Borshev <fedor@borshev.com>. MUST configure postmark!"
msgstr ""
-#: src/apps/mailing/models/configuration.py:17
+#: apps/mailing/models/configuration.py:17
msgid "Reply-to header"
msgstr "Reply-to"
-#: src/apps/mailing/models/configuration.py:17
+#: apps/mailing/models/configuration.py:17
msgid "E.g. Fedor Borshev <fedor@borshev.com>"
msgstr ""
-#: src/apps/mailing/models/configuration.py:22
+#: apps/mailing/models/configuration.py:22
msgid "Email configuration"
msgstr "Способ отсылки"
-#: src/apps/mailing/models/configuration.py:23
+#: apps/mailing/models/configuration.py:23
msgid "Email configurations"
msgstr "Способы отсылки"
-#: src/apps/mailing/models/personal_email_domain.py:10
+#: apps/mailing/models/personal_email_domain.py:10
msgid "Personal email domain"
msgstr "Домен личного email"
-#: src/apps/mailing/models/personal_email_domain.py:11
+#: apps/mailing/models/personal_email_domain.py:11
msgid "Personal email domains"
msgstr "Домены личных email"
-#: src/apps/notion/admin.py:66 src/apps/notion/models/material.py:43
-msgid "Our page id"
-msgstr "ID страницы LMS"
+#: apps/notion/admin/material.py:73
+msgid "LMS"
+msgstr ""
+
+#: apps/notion/admin/material.py:83
+#, fuzzy
+#| msgid "Notion page id"
+msgid "Notion"
+msgstr "ID страницы в ноушене"
+
+#: apps/notion/admin/video.py:33
+msgid "Youtube"
+msgstr "Youtube"
+
+#: apps/notion/admin/video.py:37
+msgid "RuTube"
+msgstr "Rutube"
-#: src/apps/notion/models/asset.py:11
+#: apps/notion/models/asset.py:11
msgid "Image size in bytes"
msgstr ""
-#: src/apps/notion/models/asset.py:15
+#: apps/notion/models/asset.py:15
#, fuzzy
#| msgid "Notion page id"
msgid "Notion asset"
msgstr "ID страницы в ноушене"
-#: src/apps/notion/models/asset.py:16
+#: apps/notion/models/asset.py:16
msgid "Notion assets"
msgstr "Файлы"
-#: src/apps/notion/models/material.py:45
+#: apps/notion/models/material.py:43
+msgid "Our page id"
+msgstr "ID страницы LMS"
+
+#: apps/notion/models/material.py:45
msgid "Page title"
msgstr "Заголовок страницы"
-#: src/apps/notion/models/material.py:45
+#: apps/notion/models/material.py:45
msgid "Will be fetched automatically if empty"
msgstr "Если не указать — попробуем скачать из ноушена"
-#: src/apps/notion/models/material.py:47
+#: apps/notion/models/material.py:47
msgid "Notion page id"
msgstr "ID страницы в ноушене"
-#: src/apps/notion/models/material.py:47
-msgid "Paste it from notion address bar"
+#: apps/notion/models/material.py:47
+#, fuzzy
+#| msgid "Paste it from notion address bar"
+msgid "Paste it from apps.notion address bar"
msgstr "Скопируйте адрес из строки браузера"
-#: src/apps/notion/models/material.py:48
-#: src/apps/orders/admin/promocodes/admin.py:14
-#: src/apps/orders/admin/promocodes/admin.py:71
-#: src/apps/orders/models/promocode.py:48
+#: apps/notion/models/material.py:48 apps/orders/admin/promocodes/admin.py:14
+#: apps/orders/admin/promocodes/admin.py:71 apps/orders/models/promocode.py:48
msgid "Active"
msgstr "Включено"
-#: src/apps/notion/models/material.py:49
+#: apps/notion/models/material.py:49
msgid "Is home page of the course"
msgstr "Главная страница курса"
-#: src/apps/notion/models/material.py:52
+#: apps/notion/models/material.py:52
msgid "Notion material"
msgstr "Материал"
-#: src/apps/notion/models/material.py:53
+#: apps/notion/models/material.py:53
msgid "Notion materials"
msgstr "Материалы"
-#: src/apps/notion/models/material.py:58
+#: apps/notion/models/material.py:58
msgid "May access materials from every course"
msgstr "Видит страницы ноушена от всех курсов"
-#: src/apps/notion/models/material_file.py:10
+#: apps/notion/models/material_file.py:10
msgid "Material file"
msgstr "Файл"
-#: src/apps/notion/models/material_file.py:11
+#: apps/notion/models/material_file.py:11
msgid "Material files"
msgstr "Файлы"
-#: src/apps/orders/admin/orders/actions.py:21
+#: apps/notion/models/video.py:9 apps/notion/models/video.py:10
+msgid "Paste it from the address bar"
+msgstr "Скопируйте адрес из строки браузера"
+
+#: apps/notion/models/video.py:13
+msgid "Notion video"
+msgstr "Видео"
+
+#: apps/notion/models/video.py:14
+msgid "Notion videos"
+msgstr "Видео"
+
+#: apps/orders/admin/orders/actions.py:21
msgid "Set paid"
msgstr "Пометить оплаченным"
-#: src/apps/orders/admin/orders/actions.py:29
-#: src/apps/orders/models/refund.py:31
+#: apps/orders/admin/orders/actions.py:29 apps/orders/models/refund.py:31
msgid "Refund"
msgstr "Сделать полный возврат"
-#: src/apps/orders/admin/orders/actions.py:44
+#: apps/orders/admin/orders/actions.py:44
#, python-brace-format
msgid "Orders {refunded_orders_as_message} refunded."
msgstr "Заказы {refunded_orders_as_message} возвращены."
-#: src/apps/orders/admin/orders/actions.py:50
+#: apps/orders/admin/orders/actions.py:50
#, python-brace-format
msgid "Some orders have not been refunded: {error_messages}"
msgstr ""
-#: src/apps/orders/admin/orders/actions.py:55
+#: apps/orders/admin/orders/actions.py:55
msgid "Ship without payments"
msgstr "Допустить до уроков без оплаты"
-#: src/apps/orders/admin/orders/actions.py:72
+#: apps/orders/admin/orders/actions.py:72
msgid "Ship again if paid"
msgstr "Ещё раз выполнить (если заказ не оплачен — не выполнится)"
-#: src/apps/orders/admin/orders/actions.py:85
-#: src/apps/products/admin/courses/actions.py:40
+#: apps/orders/admin/orders/actions.py:85
+#: apps/products/admin/courses/actions.py:40
msgid "Generate diplomas"
msgstr "Сгенерировать дипломы"
-#: src/apps/orders/admin/orders/actions.py:95
+#: apps/orders/admin/orders/actions.py:95
msgid "Accept homework"
msgstr "Засчитать домашку"
-#: src/apps/orders/admin/orders/actions.py:110
+#: apps/orders/admin/orders/actions.py:110
msgid "Disaccept homework"
msgstr "Не засчитать домашку"
-#: src/apps/orders/admin/orders/admin.py:89 src/apps/orders/models/order.py:45
-#: src/apps/products/admin/course.py:34
+#: apps/orders/admin/orders/admin.py:89 apps/orders/models/order.py:45
+#: apps/products/admin/course.py:34
msgid "Price"
msgstr "Цена"
-#: src/apps/orders/admin/orders/admin.py:97 src/apps/orders/models/order.py:44
+#: apps/orders/admin/orders/admin.py:97 apps/orders/models/order.py:44
msgid "User"
msgstr "Юзер"
-#: src/apps/orders/admin/orders/admin.py:101
+#: apps/orders/admin/orders/admin.py:101
msgid "Item"
msgstr "Товар"
-#: src/apps/orders/admin/orders/admin.py:105
+#: apps/orders/admin/orders/admin.py:105
msgid "Payment"
msgstr "Оплата"
-#: src/apps/orders/admin/orders/admin.py:109
+#: apps/orders/admin/orders/admin.py:109
msgid "Login as customer"
msgstr "Зайти от имени студента"
-#: src/apps/orders/admin/orders/filters.py:13
+#: apps/orders/admin/orders/filters.py:13
msgctxt "orders"
msgid "status"
msgstr "Статус"
-#: src/apps/orders/admin/orders/filters.py:18
+#: apps/orders/admin/orders/filters.py:18
msgid "Not paid"
msgstr "Не оплачен"
-#: src/apps/orders/admin/orders/filters.py:19
+#: apps/orders/admin/orders/filters.py:19
msgid "Paid"
msgstr "Оплачен"
-#: src/apps/orders/admin/orders/filters.py:20
-#: src/apps/orders/human_readable.py:19
+#: apps/orders/admin/orders/filters.py:20 apps/orders/human_readable.py:19
msgid "Shipped without payment"
msgstr "Ждём денег"
-#: src/apps/orders/admin/orders/forms.py:11 src/apps/users/admin/student.py:13
+#: apps/orders/admin/orders/forms.py:11 apps/users/admin/student.py:12
msgid "Email"
msgstr "Почта"
-#: src/apps/orders/admin/orders/forms.py:11
-#: src/apps/orders/admin/orders/forms.py:17
+#: apps/orders/admin/orders/forms.py:11 apps/orders/admin/orders/forms.py:17
msgid "User receives new welcome letter"
msgstr "Студент заново получит все письма"
-#: src/apps/orders/admin/promocodes/actions.py:10
+#: apps/orders/admin/promocodes/actions.py:10
msgid "Deactivate selected promo codes"
msgstr "Деактивировать выбранные промо-коды"
-#: src/apps/orders/admin/promocodes/admin.py:54
+#: apps/orders/admin/promocodes/admin.py:54
msgid "Order count"
msgstr "Заказов"
-#: src/apps/orders/admin/promocodes/admin.py:61
+#: apps/orders/admin/promocodes/admin.py:61
msgid "Discount"
msgstr "Скидка"
-#: src/apps/orders/admin/refunds/admin.py:20
+#: apps/orders/admin/refunds/admin.py:20
msgid "Partial refund"
msgstr "Частичный возврат"
-#: src/apps/orders/admin/refunds/admin.py:21
+#: apps/orders/admin/refunds/admin.py:21
msgid "Partial refunds"
msgstr "Возвраты"
-#: src/apps/orders/human_readable.py:14
+#: apps/orders/human_readable.py:14
msgid "B2B"
msgstr ""
-#: src/apps/orders/human_readable.py:16
+#: apps/orders/human_readable.py:16
msgid "Is paid"
msgstr "Оплачен"
-#: src/apps/orders/models/order.py:43
+#: apps/orders/models/order.py:43
msgid "Order author"
msgstr "Продавец"
-#: src/apps/orders/models/order.py:46 src/apps/orders/models/promocode.py:44
-#: src/apps/orders/models/promocode.py:54
+#: apps/orders/models/order.py:46 apps/orders/models/promocode.py:44
+#: apps/orders/models/promocode.py:54
msgid "Promo Code"
msgstr "Промо-код"
-#: src/apps/orders/models/order.py:49
+#: apps/orders/models/order.py:49
msgid "Date when order got paid"
msgstr "Когда оплачен"
-#: src/apps/orders/models/order.py:53
+#: apps/orders/models/order.py:53
msgid "Date when order was shipped"
msgstr "Дата выполнения"
-#: src/apps/orders/models/order.py:55
+#: apps/orders/models/order.py:55
msgid "User-requested bank string"
msgstr "Банк"
-#: src/apps/orders/models/order.py:56
+#: apps/orders/models/order.py:56
msgid "Purchase-time UE rate"
msgstr "Курс у.е. на момент покупки"
-#: src/apps/orders/models/order.py:60 src/apps/products/models/record.py:18
+#: apps/orders/models/order.py:60 apps/products/models/record.py:18
msgid "Record"
msgstr "Запись"
-#: src/apps/orders/models/order.py:61 src/apps/products/models/bundle.py:20
+#: apps/orders/models/order.py:61 apps/products/models/bundle.py:20
msgid "Bundle"
msgstr "Набор"
-#: src/apps/orders/models/order.py:70
+#: apps/orders/models/order.py:70
msgctxt "orders"
msgid "Order"
msgstr "Заказ"
-#: src/apps/orders/models/order.py:71
+#: apps/orders/models/order.py:71
msgctxt "orders"
msgid "Orders"
msgstr "Заказы"
-#: src/apps/orders/models/order.py:74
+#: apps/orders/models/order.py:74
msgid "May mark orders as paid"
msgstr "Отмечать заказы оплаченными"
-#: src/apps/orders/models/order.py:75
+#: apps/orders/models/order.py:75
msgid "May mark orders as unpaid"
msgstr "Возвращать заказы"
-#: src/apps/orders/models/promocode.py:45
+#: apps/orders/models/promocode.py:45
msgid "Discount percent"
msgstr "Процент скидки"
-#: src/apps/orders/models/promocode.py:46
+#: apps/orders/models/promocode.py:46
msgid "Discount amount"
msgstr "Скидка в деньгах"
-#: src/apps/orders/models/promocode.py:46
+#: apps/orders/models/promocode.py:46
msgid "Takes precedence over percent"
msgstr "Если задать — процент не будет работать"
-#: src/apps/orders/models/promocode.py:47
+#: apps/orders/models/promocode.py:47
msgid "Expiration date"
msgstr "Дата окончания"
-#: src/apps/orders/models/promocode.py:49
+#: apps/orders/models/promocode.py:49
msgid "Destination"
msgstr "Куда пойдет"
-#: src/apps/orders/models/promocode.py:51
+#: apps/orders/models/promocode.py:51
msgid "Can not be used for courses not checked here"
msgstr ""
"Если тут выбрать хоть что-нибудь, то промо-код будет действовать только для "
"этого курса"
-#: src/apps/orders/models/promocode.py:55
+#: apps/orders/models/promocode.py:55
msgid "Promo Codes"
msgstr "Промо-коды"
-#: src/apps/orders/models/promocode.py:62
+#: apps/orders/models/promocode.py:62
msgid "Percent or value must be set"
msgstr "Нужно задать или процент скидки или сумму в деньгах"
-#: src/apps/orders/models/refund.py:25 src/apps/studying/models.py:10
+#: apps/orders/models/refund.py:25 apps/studying/models.py:10
#, fuzzy
#| msgctxt "orders"
#| msgid "Order"
msgid "Order"
msgstr "Заказ"
-#: src/apps/orders/models/refund.py:26
+#: apps/orders/models/refund.py:26
msgid "Amount"
msgstr "Сумма"
-#: src/apps/orders/models/refund.py:28
+#: apps/orders/models/refund.py:28
msgid "Order bank at the moment of refund"
msgstr ""
-#: src/apps/orders/models/refund.py:32
+#: apps/orders/models/refund.py:32
msgid "Refunds"
msgstr "Возвраты"
-#: src/apps/orders/services/order_refunder.py:86
+#: apps/orders/services/order_refunder.py:86
#, fuzzy
#| msgid ""
#| "Orders {non_refunded_orders_as_message} have not been refunded. Up to 5 "
@@ -635,7 +656,7 @@ msgstr ""
"Заказы {non_refunded_orders_as_message} не были возвращены. Разрешено до 5 "
"возвратов в день, попробуйте снова завтра."
-#: src/apps/orders/services/order_refunder.py:88
+#: apps/orders/services/order_refunder.py:88
#, fuzzy
#| msgid ""
#| "Orders {non_refunded_orders_as_message} have not been refunded. Up to 5 "
@@ -647,294 +668,294 @@ msgstr ""
"Заказы {non_refunded_orders_as_message} не были возвращены. Разрешено до 5 "
"возвратов в день, попробуйте снова завтра."
-#: src/apps/orders/services/order_refunder.py:94
+#: apps/orders/services/order_refunder.py:94
msgid "Partial refund is not available"
msgstr "Частичные возвраты недоступны"
-#: src/apps/orders/services/order_refunder.py:98
+#: apps/orders/services/order_refunder.py:98
msgid "Only 0 can be refunded for not paid order"
msgstr ""
-#: src/apps/orders/services/order_refunder.py:100
+#: apps/orders/services/order_refunder.py:100
msgid "Amount to refund is more than available"
msgstr ""
-#: src/apps/orders/services/order_refunder.py:102
+#: apps/orders/services/order_refunder.py:102
msgid "Amount to refund should be more or equal 0"
msgstr ""
-#: src/apps/products/admin/course.py:43
+#: apps/products/admin/course.py:43
msgid "Email messages"
msgstr "Письма"
-#: src/apps/products/admin/course.py:52
+#: apps/products/admin/course.py:52
msgid "Order confirmation"
msgstr "Подтверждение покупки (для бесплатных курсов)"
-#: src/apps/products/admin/courses/actions.py:15
+#: apps/products/admin/courses/actions.py:15
msgid "Email template id"
msgstr "ID шаблона в постмарке"
-#: src/apps/products/admin/courses/actions.py:26
+#: apps/products/admin/courses/actions.py:26
msgid "Send email to all purchased_users"
msgstr "Отправить письмо всем купившим"
-#: src/apps/products/models/base.py:21
+#: apps/products/models/base.py:21
msgid "Name for receipts"
msgstr "Для чеков"
-#: src/apps/products/models/base.py:24
+#: apps/products/models/base.py:24
msgid "Full name for letters"
msgstr "Для писем"
-#: src/apps/products/models/base.py:28
+#: apps/products/models/base.py:28
msgid "Name used for international purchases"
msgstr "Название для международных покупок"
-#: src/apps/products/models/base.py:36
+#: apps/products/models/base.py:36
msgid "Fixed promo code for tinkoff credit"
msgstr "Промо-код на рассрочку в Тинькофф"
-#: src/apps/products/models/base.py:36
+#: apps/products/models/base.py:36
msgid "Used in tinkoff credit only"
msgstr ""
"Пересылается в тинькофф, если покупатель оформляет рассрочку на курс. Если "
"не заполнять — покупатель переплатит за кредит. Можно взять в админке "
"Тинькофф"
-#: src/apps/products/models/base.py:39 src/apps/products/models/group.py:13
+#: apps/products/models/base.py:39 apps/products/models/group.py:13
msgid "Analytical group"
msgstr "Группа товаров (аналитика)"
-#: src/apps/products/models/bundle.py:21
+#: apps/products/models/bundle.py:21
msgid "Bundles"
msgstr "Наборы"
-#: src/apps/products/models/course.py:45
+#: apps/products/models/course.py:45
msgid "Genitive name"
msgstr "В родительном падеже"
-#: src/apps/products/models/course.py:48
+#: apps/products/models/course.py:48
msgid "Welcome letter template id"
msgstr "ID шаблона приветственного письма"
-#: src/apps/products/models/course.py:48
+#: apps/products/models/course.py:48
msgid "Will be sent upon purchase if set"
msgstr "Если задать — письмо будет уходить вместе с каждой покупкой"
-#: src/apps/products/models/course.py:50
+#: apps/products/models/course.py:50
msgid "Display in LMS"
msgstr "Показывать в LMS"
-#: src/apps/products/models/course.py:50
+#: apps/products/models/course.py:50
msgid "If disabled will not be shown in LMS"
msgstr "Если снять галочку, студенты не будут видеть курс в LMS"
-#: src/apps/products/models/course.py:54
+#: apps/products/models/course.py:54
msgid "Disable all triggers"
msgstr "Отключить триггерные письма"
-#: src/apps/products/models/course.py:57
+#: apps/products/models/course.py:57
msgid "Confirmation template id"
msgstr "ID шаблона для письма-подтверждения"
-#: src/apps/products/models/course.py:61
+#: apps/products/models/course.py:61
msgid "If set user sill receive this message upon creating zero-priced order"
msgstr "Уходит пользователю, если он берёт бесплатный курс"
-#: src/apps/products/models/course.py:63
+#: apps/products/models/course.py:63
msgid "Confirmation success URL"
msgstr "Ссылка после успешного подтверждения"
-#: src/apps/products/models/course.py:66
+#: apps/products/models/course.py:66
msgid "Cover image"
msgstr ""
-#: src/apps/products/models/course.py:69
+#: apps/products/models/course.py:69
#, fuzzy
#| msgid "Is home page of the course"
msgid "The cover image of course"
msgstr "Главная страница курса"
-#: src/apps/products/models/course.py:75
+#: apps/products/models/course.py:75
msgid "Courses"
msgstr "Курсы"
-#: src/apps/products/models/course.py:84
+#: apps/products/models/course.py:84
msgid "Both confirmation_template_id and confirmation_success_url must be set"
msgstr ""
"У курсов с подтверждением обязательно нужно устанавливать оба поля: и "
"почтовый шаблон и ссылку для упешного подтверждения "
-#: src/apps/products/models/course.py:87
+#: apps/products/models/course.py:87
msgid "Courses with confirmation should have zero price"
msgstr ""
"Курсы с подтверждением могут быть только бесплатными. Если пользователь "
"платит деньги — это уже достаточное подтверждение"
-#: src/apps/products/models/group.py:14
+#: apps/products/models/group.py:14
msgid "Analytical groups"
msgstr "Группы товаров (аналитика)"
-#: src/apps/products/models/record.py:14
+#: apps/products/models/record.py:14
msgid "Postmark template_id"
msgstr "ID шаблона в постмарке"
-#: src/apps/products/models/record.py:14
+#: apps/products/models/record.py:14
msgid "Leave it blank for the default template"
msgstr "Если оставить пустым — будет дефолтным"
-#: src/apps/products/models/record.py:19
+#: apps/products/models/record.py:19
msgid "Records"
msgstr "Записи"
-#: src/apps/stripebank/bank.py:15
+#: apps/stripebank/bank.py:15
msgid "Stripe"
msgstr "Страйп"
-#: src/apps/stripebank/bank.py:69
+#: apps/stripebank/bank.py:69
#, fuzzy
#| msgid "Stripe"
msgid "Stripe USD"
msgstr "Страйп"
-#: src/apps/stripebank/bank.py:78
+#: apps/stripebank/bank.py:78
#, fuzzy
#| msgid "Stripe"
msgid "Stripe KZT"
msgstr "Страйп"
-#: src/apps/studying/apps.py:8 src/apps/studying/apps.py:9
+#: apps/studying/apps.py:8 apps/studying/apps.py:9
msgid "Studying"
msgstr "Студенты-курсы"
-#: src/apps/studying/models.py:12
+#: apps/studying/models.py:12
msgid "Homework accepted"
msgstr "Домашка принята"
-#: src/apps/studying/models.py:22
+#: apps/studying/models.py:22
msgid "Studies"
msgstr "Студенты-курсы"
-#: src/apps/tinkoff/bank.py:17
+#: apps/tinkoff/bank.py:17
msgid "Tinkoff"
msgstr "Тинькофф"
-#: src/apps/tinkoff/dolyame.py:23
+#: apps/tinkoff/dolyame.py:23
msgid "Dolyame"
msgstr "Долями"
-#: src/apps/tinkoff/models.py:10
+#: apps/tinkoff/models.py:10
msgid "Authorized"
msgstr ""
-#: src/apps/tinkoff/models.py:11
+#: apps/tinkoff/models.py:11
msgid "Confirmed"
msgstr ""
-#: src/apps/tinkoff/models.py:12
+#: apps/tinkoff/models.py:12
msgid "Reversed"
msgstr ""
-#: src/apps/tinkoff/models.py:13 src/apps/tinkoff/models.py:37
+#: apps/tinkoff/models.py:13 apps/tinkoff/models.py:37
msgid "Refunded"
msgstr ""
-#: src/apps/tinkoff/models.py:14
+#: apps/tinkoff/models.py:14
msgid "Partial refunded"
msgstr ""
-#: src/apps/tinkoff/models.py:15 src/apps/tinkoff/models.py:36
+#: apps/tinkoff/models.py:15 apps/tinkoff/models.py:36
msgid "Rejected"
msgstr ""
-#: src/apps/tinkoff/models.py:35
+#: apps/tinkoff/models.py:35
msgid "Approved"
msgstr ""
-#: src/apps/tinkoff/models.py:38
+#: apps/tinkoff/models.py:38
msgid "Canceled"
msgstr ""
-#: src/apps/tinkoff/models.py:39
+#: apps/tinkoff/models.py:39
#, fuzzy
#| msgid "Comment"
msgid "Committed"
msgstr "Комментарий"
-#: src/apps/tinkoff/models.py:40
+#: apps/tinkoff/models.py:40
msgid "Waiting for commit"
msgstr ""
-#: src/apps/tinkoff/models.py:41
+#: apps/tinkoff/models.py:41
msgid "Completed"
msgstr ""
-#: src/apps/users/admin/student.py:14
+#: apps/users/admin/student.py:13
msgid "first name"
msgstr "Имя"
-#: src/apps/users/admin/student.py:15
+#: apps/users/admin/student.py:14
msgid "last name"
msgstr "Фамилия"
-#: src/apps/users/admin/student.py:61
+#: apps/users/admin/student.py:56
msgid "Personal info"
msgstr "Персональные данные"
-#: src/apps/users/admin/student.py:62
+#: apps/users/admin/student.py:57
msgid "Name in english"
msgstr "Имя на английском"
-#: src/apps/users/apps.py:7
+#: apps/users/apps.py:7
msgid "Users"
msgstr "Юзеры"
-#: src/apps/users/models.py:21
+#: apps/users/models.py:21
msgid "Male"
msgstr "Мужчина"
-#: src/apps/users/models.py:22
+#: apps/users/models.py:22
msgid "Female"
msgstr "Женщина"
-#: src/apps/users/models.py:24
+#: apps/users/models.py:24
msgid "Subscribed to newsletter"
msgstr "Подписан на новости"
-#: src/apps/users/models.py:25
+#: apps/users/models.py:25
msgid "first name in english"
msgstr "Имя на английском"
-#: src/apps/users/models.py:26
+#: apps/users/models.py:26
msgid "last name in english"
msgstr "Фамилия на английском"
-#: src/apps/users/models.py:29
+#: apps/users/models.py:29
msgid "Gender"
msgstr "Пол"
-#: src/apps/users/models.py:37
+#: apps/users/models.py:37
msgid "Avatar"
msgstr "Аватар"
-#: src/apps/users/models.py:46
+#: apps/users/models.py:46
msgid "user"
msgstr "пользователь"
-#: src/apps/users/models.py:47
+#: apps/users/models.py:47
msgid "users"
msgstr "пользователи"
-#: src/apps/users/models.py:108
+#: apps/users/models.py:108
msgid "Students"
msgstr "Студенты"
-#: src/core/admin/filters.py:25
+#: core/admin/filters.py:25
msgid "Yes"
msgstr "Да"
-#: src/core/admin/filters.py:26
+#: core/admin/filters.py:26
msgid "No"
msgstr "Нет"