diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 59eac1f..b668cde 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -79,7 +79,7 @@ jobs: docker pull ${{ env.pull_image }} docker tag ${{ env.pull_image }} ${{ env.deploy_image }} ${{ needs.namer.outputs.branch != 'staging' }} || docker tag ${{ env.pull_image }} ${{ env.deploy_image }}-backup - docker compose up auth-stage-migrate + docker compose run --rm auth-stage-migrate docker compose up -d auth-stage ${{ needs.namer.outputs.branch != 'staging' }} || docker compose exec auth-stage alembic heads | cut -d ' ' --fields=1 > auth-stage-version.txt script_stop: true diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 5790881..e3737d3 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -38,7 +38,7 @@ jobs: docker pull ${{ env.pull_image }} docker tag ${{ env.pull_image }} ${{ env.deploy_image }} docker tag ${{ env.pull_image }} ${{ env.deploy_image }}-backup - docker compose up auth-migrate + docker compose run --rm auth-migrate docker compose up -d auth script_stop: true diff --git a/app/users/routes/forms_rst.py b/app/users/routes/forms_rst.py index c7b0f9a..7576f8d 100644 --- a/app/users/routes/forms_rst.py +++ b/app/users/routes/forms_rst.py @@ -1,23 +1,33 @@ -from typing import Annotated +from collections.abc import Iterator +from typing import Annotated, BinaryIO from discord_webhook import AsyncDiscordWebhook -from fastapi import HTTPException +from fastapi import File, HTTPException, UploadFile +from filetype import filetype # type: ignore[import-untyped] +from filetype.types.archive import Pdf # type: ignore[import-untyped] from pydantic import BaseModel, Field from app.common.config import DEMO_WEBHOOK_URL, VACANCY_WEBHOOK_URL -from app.common.fastapi_ext import APIRouterExt +from app.common.fastapi_ext import APIRouterExt, Responses from app.common.schemas.vacancy_form_sch import VacancyFormSchema router = APIRouterExt(tags=["forms"]) -async def execute_discord_webhook(url: str | None, content: str) -> None: +async def execute_discord_webhook( + url: str | None, + content: str, + attachment: tuple[str | None, BinaryIO] | None = None, +) -> None: if url is None: raise HTTPException(500, "Webhook url is not set") webhook = AsyncDiscordWebhook( # type: ignore[no-untyped-call] url=url, content=content ) + if attachment is not None: + # async discord webhook is badly typed: it uses httpx, there these types are supported + webhook.add_file(file=attachment[1], filename=attachment[0]) # type: ignore[arg-type] (await webhook.execute()).raise_for_status() @@ -39,8 +49,13 @@ async def apply_for_demonstration(demo_form: DemoFormSchema) -> None: ) -@router.post("/vacancy-applications/", status_code=204, summary="Apply for a vacancy") -async def apply_for_vacancy(vacancy_form: VacancyFormSchema) -> None: +@router.post( + "/vacancy-applications/", + status_code=204, + summary="Use POST /api/v2/vacancy-applications/ instead", + deprecated=True, +) +async def apply_for_vacancy_old(vacancy_form: VacancyFormSchema) -> None: content = ( f"**Новый отклик на вакансию {vacancy_form.position}**\n" + f"- Имя: {vacancy_form.name}\n" @@ -51,3 +66,44 @@ async def apply_for_vacancy(vacancy_form: VacancyFormSchema) -> None: content = f"{content}>>> {vacancy_form.message}" await execute_discord_webhook(url=VACANCY_WEBHOOK_URL, content=content) + + +class FileFormatResponses(Responses): + WRONG_FORMAT = 415, "Invalid file format" + + +def iter_vacancy_message_lines( + position: str, name: str, telegram: str, message: str | None +) -> Iterator[str]: + yield f"**Новый отклик на вакансию {position.lower()}**" + yield f"- Имя: {name}" + yield f"- Телеграм: {telegram}" + if message is not None and message != "": + yield f">>> {message}" + + +@router.post( + "/v2/vacancy-applications/", status_code=204, summary="Apply for a vacancy" +) +async def apply_for_vacancy( + position: Annotated[str, File()], + name: Annotated[str, File()], + telegram: Annotated[str, File()], + resume: UploadFile, + message: Annotated[str | None, File()] = None, +) -> None: + if not filetype.match(resume.file, [Pdf()]): + raise FileFormatResponses.WRONG_FORMAT.value + + await execute_discord_webhook( + url=VACANCY_WEBHOOK_URL, + content="\n".join( + iter_vacancy_message_lines( + position=position, + name=name, + telegram=telegram, + message=message, + ) + ), + attachment=(resume.filename, resume.file), + ) diff --git a/tests/common/faker_ext.py b/tests/common/faker_ext.py index 038aaa2..91afeae 100644 --- a/tests/common/faker_ext.py +++ b/tests/common/faker_ext.py @@ -2,7 +2,7 @@ import rstr from faker import Faker from faker.providers import BaseProvider, internet -from faker_file.providers import webp_file # type: ignore[import-untyped] +from faker_file.providers import pdf_file, webp_file # type: ignore[import-untyped] class RegexGeneratorProvider(BaseProvider): @@ -18,6 +18,7 @@ def _setup_faker(faker: Faker) -> None: faker.add_provider(internet) faker.add_provider(RegexGeneratorProvider) faker.add_provider(webp_file.GraphicWebpFileProvider) + faker.add_provider(pdf_file.PdfFileProvider) @pytest.fixture(scope="session") diff --git a/tests/users/test_forms.py b/tests/users/test_forms.py index 0699bde..de6af67 100644 --- a/tests/users/test_forms.py +++ b/tests/users/test_forms.py @@ -4,6 +4,9 @@ import pytest from discord_webhook import AsyncDiscordWebhook from faker import Faker +from faker_file.providers.pdf_file.generators.pil_generator import ( # type: ignore[import-untyped] + PilPdfGenerator, +) from starlette.testclient import TestClient from tests.common.assert_contains_ext import assert_nodata_response, assert_response @@ -52,7 +55,7 @@ async def test_demo_form_submitting_missing_webhook_url( @pytest.mark.anyio() -async def test_vacancy_form_submitting( +async def test_old_vacancy_form_submitting( faker: Faker, mock_stack: MockStack, client: TestClient, @@ -75,7 +78,7 @@ async def test_vacancy_form_submitting( @pytest.mark.anyio() -async def test_vacancy_form_submitting_missing_webhook_url( +async def test_old_vacancy_form_submitting_missing_webhook_url( faker: Faker, client: TestClient, vacancy_form_data: dict[str, Any] ) -> None: assert_response( @@ -83,3 +86,67 @@ async def test_vacancy_form_submitting_missing_webhook_url( expected_code=500, expected_json={"detail": "Webhook url is not set"}, ) + + +@pytest.fixture() +async def pdf(faker: Faker) -> bytes: + return faker.pdf_file(raw=True, pdf_generator_cls=PilPdfGenerator) # type: ignore[no-any-return] + + +@pytest.mark.anyio() +async def test_vacancy_form_submitting( + mock_stack: MockStack, + client: TestClient, + vacancy_form_data: dict[str, Any], + pdf: bytes, +) -> None: + response_mock = Mock() + response_mock.raise_for_status = Mock() + execute_mock = mock_stack.enter_async_mock( + AsyncDiscordWebhook, "execute", return_value=response_mock + ) + mock_stack.enter_mock( + "app.users.routes.forms_rst.VACANCY_WEBHOOK_URL", return_value="" + ) + + assert_nodata_response( + client.post( + "/api/v2/vacancy-applications/", + data=vacancy_form_data, + files={"resume": ("resume.pdf", pdf, "application/pdf")}, + ) + ) + execute_mock.assert_called_once() + response_mock.raise_for_status.assert_called_once() + + +@pytest.mark.anyio() +async def test_vacancy_form_submitting_invalid_file_format( + faker: Faker, client: TestClient, vacancy_form_data: dict[str, Any] +) -> None: + assert_response( + client.post( + "/api/v2/vacancy-applications/", + data=vacancy_form_data, + files={ + "resume": ("resume.pdf", faker.random.randbytes(100), "application/pdf") + }, + ), + expected_code=415, + expected_json={"detail": "Invalid file format"}, + ) + + +@pytest.mark.anyio() +async def test_vacancy_form_submitting_missing_webhook_url( + client: TestClient, vacancy_form_data: dict[str, Any], pdf: bytes +) -> None: + assert_response( + client.post( + "/api/v2/vacancy-applications/", + data=vacancy_form_data, + files={"resume": ("resume.pdf", pdf, "application/pdf")}, + ), + expected_code=500, + expected_json={"detail": "Webhook url is not set"}, + )