Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 62 additions & 6 deletions app/users/routes/forms_rst.py
Original file line number Diff line number Diff line change
@@ -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()


Expand All @@ -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"
Expand All @@ -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),
)
3 changes: 2 additions & 1 deletion tests/common/faker_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
Expand Down
71 changes: 69 additions & 2 deletions tests/users/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -75,11 +78,75 @@ 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(
client.post("/api/vacancy-applications/", json=vacancy_form_data),
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"},
)
Loading