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 "Нет"