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
10 changes: 8 additions & 2 deletions app/common/bridges/public_users_bdg.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import BinaryIO

from httpx import AsyncClient

from app.common.config import settings
Expand All @@ -10,8 +12,12 @@ def __init__(self) -> None:
base_url=settings.bridge_base_url,
)

async def apply_for_vacancy(self, vacancy_form: VacancyFormSchema) -> None:
async def apply_for_vacancy(
self, vacancy_form: VacancyFormSchema, resume: tuple[str, BinaryIO, str]
) -> None:
response = await self.client.post(
"/api/vacancy-applications/", json=vacancy_form.model_dump()
"/api/v2/vacancy-applications/",
data=vacancy_form.model_dump(),
files={"resume": resume},
)
response.raise_for_status()
8 changes: 8 additions & 0 deletions app/common/schemas/demo_form_sch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from typing import Annotated

from pydantic import BaseModel, Field


class DemoFormSchema(BaseModel):
name: str
contacts: Annotated[list[str], Field(min_length=1)]
3 changes: 1 addition & 2 deletions app/common/schemas/vacancy_form_sch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@


class VacancyFormSchema(BaseModel):
position: str
name: str
telegram: str
position: str
link: str
message: str | None = None
41 changes: 31 additions & 10 deletions app/supbot/routers/vacancy_tgm.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import BinaryIO

from aiogram import F, Router
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
Expand All @@ -8,7 +10,7 @@
from app.common.schemas.vacancy_form_sch import VacancyFormSchema
from app.supbot import texts
from app.supbot.utils.aiogram_ext import MessageExt, MessageFromUser
from app.supbot.utils.filters import command_filter
from app.supbot.utils.filters import DocumentErrorType, DocumentFilter, command_filter

router = Router()

Expand Down Expand Up @@ -118,13 +120,11 @@ async def set_telegram_and_request_resume(
)


@router.message(VacancyStates.sending_resume, F.text)
@router.message(VacancyStates.sending_comment, F.text == texts.BACK_BUTTON_TEXT)
@router.message(VacancyStates.sending_resume, DocumentFilter())
async def set_resume_and_request_comment(
message: MessageExt, state: FSMContext
message: MessageExt, document_data: tuple[str, BinaryIO, str], state: FSMContext
) -> None:
if message.text != texts.BACK_BUTTON_TEXT:
await state.update_data(link=message.text)
await state.update_data(resume=document_data)
await state.set_state(VacancyStates.sending_comment)
await message.answer(
text=texts.SEND_INFO_MESSAGE,
Expand All @@ -142,13 +142,13 @@ async def submit_vacancy_form(message: MessageExt, state: FSMContext) -> None:
answers["message"] = message.text

await public_users_bridge.apply_for_vacancy(
VacancyFormSchema(
name=answers["name"],
vacancy_form=VacancyFormSchema(
position=answers["position"],
name=answers["name"],
telegram=answers["telegram"],
link=answers["link"],
message=answers.get("message"),
)
),
resume=answers["resume"],
)

await message.answer(
Expand All @@ -160,6 +160,27 @@ async def submit_vacancy_form(message: MessageExt, state: FSMContext) -> None:
await state.clear()


@router.message(
VacancyStates.sending_resume, DocumentFilter(DocumentErrorType.NO_DOCUMENT)
)
async def handle_missing_document(message: MessageExt) -> None:
await message.answer(texts.VACANCY_NO_DOCUMENT_MESSAGE)


@router.message(
VacancyStates.sending_resume, DocumentFilter(DocumentErrorType.WRONG_MIME_TYPE)
)
async def handle_unsupported_document_type(message: MessageExt) -> None:
await message.answer(texts.VACANCY_UNSUPPORTED_DOCUMENT_TYPE_MESSAGE)


@router.message(
VacancyStates.sending_resume, DocumentFilter(DocumentErrorType.FILE_TO_LARGE)
)
async def handle_unsupported_document_size(message: MessageExt) -> None:
await message.answer(texts.VACANCY_DOCUMENT_TOO_LARGE_MESSAGE)


@router.message(
StateFilter(*VacancyStates.__all_states__),
~F.text,
Expand Down
7 changes: 6 additions & 1 deletion app/supbot/texts.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@
CHOOSE_VACANCY_MESSAGE = "Выберите вакансию или введите свою:"
SEND_NAME_MESSAGE = "Как вас зовут?"
SEND_TELEGRAM_MESSAGE = "Пожалуйста, оставьте ваш телеграм ⬇️"
SEND_RESUME_MESSAGE = "Добавьте ссылку на резюме ⬇️"
SEND_RESUME_MESSAGE = (
"Пожалуйста, загрузите ваше резюме одним файлом в формате PDF (до 10 MiB) ⬇️"
)
SEND_INFO_MESSAGE = "Почти готово. Можете оставить для нас сообщение 🙂"
VACANCY_FORM_FINAL_MESSAGE = "Спасибо! Мы получили ваш отклик и рассмотрим его"
VACANCY_NO_DOCUMENT_MESSAGE = f"В сообщении не прикреплён файл. {SEND_RESUME_MESSAGE}"
VACANCY_UNSUPPORTED_DOCUMENT_TYPE_MESSAGE = f"Неверный тип файла. {SEND_RESUME_MESSAGE}"
VACANCY_DOCUMENT_TOO_LARGE_MESSAGE = f"Слишком большой файл. {SEND_RESUME_MESSAGE}"
VACANCY_INVALID_INPUT_TYPE_MESSAGE = """
Пожалуйста, используйте только текстовые сообщения или кнопки для заполнения формы вакансии
"""
Expand Down
52 changes: 52 additions & 0 deletions app/supbot/utils/filters.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from enum import StrEnum
from typing import Any

from aiogram import F
from aiogram.filters import Command, Filter, or_f
from aiogram.fsm.context import FSMContext
from filetype import filetype # type: ignore[import-untyped]
from filetype.types.archive import Pdf # type: ignore[import-untyped]

from app.supbot import texts
from app.supbot.models.support_db import SupportTicket
Expand Down Expand Up @@ -30,6 +33,55 @@ async def __call__( # noqa: FNE005
return {"ticket": ticket}


class DocumentErrorType(StrEnum):
NO_DOCUMENT = "no_document"
WRONG_MIME_TYPE = "wrong_mime_type"
FILE_TO_LARGE = "file_to_large"


class DocumentFilter(Filter):
MAX_DOCUMENT_SIZE: int = 10 * 2**20

def __init__(
self,
expected_error: str | None = None,
max_size: int = MAX_DOCUMENT_SIZE,
mime_type: str = "application/pdf",
) -> None:
self.expected_error = expected_error
self.max_size = max_size
self.mime_type = mime_type

async def __call__( # noqa: FNE005
self,
message: MessageExt,
) -> bool | dict[str, Any]:
if message.document is None:
return self.expected_error == DocumentErrorType.NO_DOCUMENT

if message.document.mime_type != self.mime_type:
return self.expected_error == DocumentErrorType.WRONG_MIME_TYPE
if (
message.document.file_size is None
or message.document.file_size >= self.max_size
):
return self.expected_error == DocumentErrorType.FILE_TO_LARGE

content = await message.bot.download(message.document.file_id)

match self.mime_type:
case "application/pdf":
if not filetype.match(content, [Pdf()]):
return self.expected_error == DocumentErrorType.WRONG_MIME_TYPE
return {
"document_data": (
message.document.file_name,
content,
message.document.mime_type,
)
}


def command_filter(command: str) -> Filter:
return or_f(
Command(command),
Expand Down
21 changes: 21 additions & 0 deletions app/users/dependencies/forms_dep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import Annotated

from fastapi import Depends, UploadFile
from filetype import filetype # type: ignore[import-untyped]
from filetype.types.archive import Pdf # type: ignore[import-untyped]

from app.common.fastapi_ext import Responses, with_responses


class ResumeFileResponses(Responses):
WRONG_FORMAT = 415, "Invalid file format"


@with_responses(ResumeFileResponses)
async def validate_resume_file(resume: UploadFile) -> UploadFile:
if not filetype.match(resume.file, [Pdf()]):
raise ResumeFileResponses.WRONG_FORMAT
return resume


ResumeFile = Annotated[UploadFile, Depends(validate_resume_file)]
51 changes: 9 additions & 42 deletions app/users/routes/forms_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@
from typing import Annotated, BinaryIO

from discord_webhook import AsyncDiscordWebhook
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 fastapi import Form, HTTPException

from app.common.config import settings
from app.common.fastapi_ext import APIRouterExt, Responses
from app.common.schemas.vacancy_form_sch import VacancyFormSchema
from app.common.fastapi_ext import APIRouterExt
from app.common.schemas.demo_form_sch import DemoFormSchema
from app.users.dependencies.forms_dep import ResumeFile

router = APIRouterExt(tags=["forms"])

Expand All @@ -31,11 +29,6 @@ async def execute_discord_webhook(
(await webhook.execute()).raise_for_status()


class DemoFormSchema(BaseModel):
name: str
contacts: Annotated[list[str], Field(min_length=1)]


@router.post(
"/demo-applications/", status_code=204, summary="Apply for a demonstration"
)
Expand All @@ -49,29 +42,6 @@ async def apply_for_demonstration(demo_form: DemoFormSchema) -> 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"
+ f"- Телеграм: {vacancy_form.telegram}\n"
+ f"- [Резюме](<{vacancy_form.link}>)\n"
)
if vacancy_form.message is not None:
content = f"{content}>>> {vacancy_form.message}"

await execute_discord_webhook(url=settings.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]:
Expand All @@ -86,15 +56,12 @@ def iter_vacancy_message_lines(
"/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,
position: Annotated[str, Form()],
name: Annotated[str, Form()],
telegram: Annotated[str, Form()],
resume: ResumeFile,
message: Annotated[str | None, Form()] = None,
) -> None:
if not filetype.match(resume.file, [Pdf()]):
raise FileFormatResponses.WRONG_FORMAT.value

await execute_discord_webhook(
url=settings.vacancy_webhook_url,
content="\n".join(
Expand Down
31 changes: 16 additions & 15 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.100.0"
fastapi = "^0.115.12"
uvicorn = {extras = ["standard"], version = "^0.22.0"}
sqlalchemy = {extras = ["asyncio"], version = "^2.0.18"}
pydantic-marshals = "0.3.12"
Expand Down
Loading