add edit resume

This commit is contained in:
ivankirpichnikov
2025-11-22 03:31:17 +03:00
parent d9a3c39980
commit a2a9b8f8c1
9 changed files with 288 additions and 15 deletions
+24
View File
@@ -184,3 +184,27 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/template_project"] packages = ["src/template_project"]
[tool.coverage.report]
show_missing = true
skip_empty = true
exclude_also = [
"if __name__ == .__main__.:",
"self.logger",
"def __repr__",
"lambda: None",
"from .*",
"import .*",
'@(abc\.)?abstractmethod',
"raise NotImplementedError",
'raise AssertionError',
'logger\..*',
"pass",
'\.\.\.',
]
omit = [
'*/__about__.py',
'*/__main__.py',
'*/__init__.py',
]
@@ -0,0 +1,56 @@
from template_project.application.common.data_structure import to_data_structure
from template_project.application.common.enums import ExperienceType
from template_project.application.common.identity_provider import IdentityProvider
from template_project.application.common.interactor import to_interactor
from template_project.application.common.unit_of_work import UnitOfWork
from template_project.application.resume.data_gateway import ResumeDataGateway
from template_project.application.resume.entity import ResumeId
from template_project.application.resume.errors import ResumeDoesBelongUserError
@to_data_structure
class _Response:
id: ResumeId
position: str
about_me: str
key_skills: list[str]
experience_type: ExperienceType
@to_interactor
class EditResumeInteractor:
unit_of_work: UnitOfWork
identity_provider: IdentityProvider
resume_data_gateway: ResumeDataGateway
async def execute(
self,
resume_id: ResumeId,
position: str | None,
about_me: str | None,
key_skills: list[str] | None,
experience_type: ExperienceType | None,
) -> _Response:
user = await self.identity_provider.get_current_user()
resume = await self.resume_data_gateway.load(resume_id)
if resume.user_id != user.id:
raise ResumeDoesBelongUserError
if position is not None:
resume.position = position
if about_me is not None:
resume.about_me = about_me
if key_skills is not None:
resume.key_skills = key_skills
if experience_type is not None:
resume.experience_type = experience_type
await self.unit_of_work.commit()
return _Response(
id=resume.id,
position=resume.position,
about_me=resume.about_me,
key_skills=resume.key_skills,
experience_type=resume.experience_type,
)
@@ -18,6 +18,7 @@ class ResumePredictionResponse:
@to_data_structure @to_data_structure
class _Response: class _Response:
id: ResumeId
position: str position: str
about_me: str about_me: str
key_skills: list[str] key_skills: list[str]
@@ -53,6 +54,7 @@ class GetResumeInteractor:
prediction = None prediction = None
return _Response( return _Response(
id=resume.id,
position=resume.position, position=resume.position,
about_me=resume.about_me, about_me=resume.about_me,
key_skills=resume.key_skills, key_skills=resume.key_skills,
@@ -7,6 +7,7 @@ from template_project.application.notification_device.interactors.register_devic
) )
from template_project.application.notification_device.interactors.send_notification import NotificationInteractor from template_project.application.notification_device.interactors.send_notification import NotificationInteractor
from template_project.application.resume.interactors.add import AddResumeInteractor from template_project.application.resume.interactors.add import AddResumeInteractor
from template_project.application.resume.interactors.edit import EditResumeInteractor
from template_project.application.resume.interactors.get import GetResumeInteractor from template_project.application.resume.interactors.get import GetResumeInteractor
from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor
from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor
@@ -24,4 +25,5 @@ class InteractorProvider(Provider):
RegisterNotificationDeviceInteractor, RegisterNotificationDeviceInteractor,
GetResumeInteractor, GetResumeInteractor,
AddResumeInteractor, AddResumeInteractor,
EditResumeInteractor,
) )
+34 -3
View File
@@ -12,6 +12,7 @@ from template_project.application.common.enums import ExperienceType
from template_project.application.resume.entity import ResumeId from template_project.application.resume.entity import ResumeId
from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError
from template_project.application.resume.interactors.add import AddResumeInteractor from template_project.application.resume.interactors.add import AddResumeInteractor
from template_project.application.resume.interactors.edit import EditResumeInteractor
from template_project.application.resume.interactors.get import GetResumeInteractor from template_project.application.resume.interactors.get import GetResumeInteractor
security = HTTPBearer() security = HTTPBearer()
@@ -66,6 +67,7 @@ class SalaryPrediction(BaseModel):
class ResumeResponse(BaseModel): class ResumeResponse(BaseModel):
id: ResumeId = Field(description="Resume ID")
position: str = Field(description="Job position") position: str = Field(description="Job position")
about_me: str = Field(description="About me section") about_me: str = Field(description="About me section")
key_skills: list[str] = Field(description="List of key skills") key_skills: list[str] = Field(description="List of key skills")
@@ -90,6 +92,7 @@ class ResumeResponse(BaseModel):
class ResumeListItem(BaseModel): class ResumeListItem(BaseModel):
id: ResumeId = Field(description="Resume ID")
position: str = Field(description="Job position") position: str = Field(description="Job position")
about_me: str = Field(description="About me section") about_me: str = Field(description="About me section")
key_skills: list[str] = Field(description="List of key skills") key_skills: list[str] = Field(description="List of key skills")
@@ -208,6 +211,7 @@ async def get_resume(
) from error ) from error
return ResumeResponse( return ResumeResponse(
id=interactor_response.id,
position=interactor_response.position, position=interactor_response.position,
about_me=interactor_response.about_me, about_me=interactor_response.about_me,
key_skills=interactor_response.key_skills, key_skills=interactor_response.key_skills,
@@ -266,6 +270,7 @@ class PatchResumeRequest(BaseModel):
class PatchResumeResponse(BaseModel): class PatchResumeResponse(BaseModel):
id: ResumeId = Field(description="Resume ID")
position: str = Field(description="Job position") position: str = Field(description="Job position")
about_me: str = Field(description="About me section") about_me: str = Field(description="About me section")
key_skills: list[str] = Field(description="List of key skills") key_skills: list[str] = Field(description="List of key skills")
@@ -310,11 +315,37 @@ async def get_resume_history(
200: {"description": "Resume updated successfully", "model": PatchResumeResponse}, 200: {"description": "Resume updated successfully", "model": PatchResumeResponse},
401: {"description": "Unauthorized - invalid or missing token"}, 401: {"description": "Unauthorized - invalid or missing token"},
404: {"description": "Resume not found"}, 404: {"description": "Resume not found"},
403: {"descriptipn": "The resume does not belong to you"},
}, },
) )
async def patch_resume( async def patch_resume(
resume_id: str, resume_id: ResumeId,
request: PatchResumeRequest, request: PatchResumeRequest,
interactor: FromDishka[EditResumeInteractor],
) -> PatchResumeResponse: ) -> PatchResumeResponse:
# TODO: Implement resume update try:
raise NotImplementedError interactor_response = await interactor.execute(
resume_id=resume_id,
position=request.position,
about_me=request.about_me,
key_skills=request.key_skills,
experience_type=request.experience_type,
)
except ResumeDoesBelongUserError as error:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="The resume does not belong to you",
) from error
except ResumeNotFoundError as error:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Resume not found",
) from error
return PatchResumeResponse(
id=interactor_response.id,
position=interactor_response.position,
about_me=interactor_response.about_me,
key_skills=interactor_response.key_skills,
experience_type=interactor_response.experience_type,
)
+10 -5
View File
@@ -1,11 +1,13 @@
import asyncio import asyncio
import logging
import os import os
from collections.abc import AsyncIterable, AsyncIterator from collections.abc import AsyncIterable, AsyncIterator
from pathlib import Path from pathlib import Path
from typing import Any from typing import cast
import pytest import pytest
from dishka import AsyncContainer from dishka import AsyncContainer
from fastapi import FastAPI
from template_project.web_api.configuration import load_configuration from template_project.web_api.configuration import load_configuration
from template_project.web_api.entry_point import make_server from template_project.web_api.entry_point import make_server
@@ -14,20 +16,23 @@ from tests.web_api.ioc import make_ioc
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def dishka_container(backend: Any) -> AsyncIterable[AsyncContainer]: async def dishka_container(backend: FastAPI) -> AsyncIterable[AsyncContainer]:
path = Path(os.environ["CONFIGURATION_PATH"]) path = Path(os.environ["CONFIGURATION_PATH"])
configuration = load_configuration(path) configuration = load_configuration(path)
ioc = make_ioc(configuration) ioc = make_ioc(configuration, backend)
yield ioc yield ioc
await ioc.close() await ioc.close()
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def backend() -> AsyncIterator[None]: async def backend() -> AsyncIterator[FastAPI]:
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
configuration = load_configuration(Path(os.environ["CONFIGURATION_PATH"])) configuration = load_configuration(Path(os.environ["CONFIGURATION_PATH"]))
server = make_server(configuration) server = make_server(configuration)
asyncio.create_task(server.serve()) # type: ignore[unused-awaitable] asyncio.create_task(server.serve()) # type: ignore[unused-awaitable]
yield yield cast(FastAPI, server.config.app)
@pytest.fixture @pytest.fixture
+127 -2
View File
@@ -1,10 +1,15 @@
from typing import Final from typing import Final
from dirty_equals import IsDict, IsUUID from dirty_equals import IsDict, IsPartialDict, IsUUID
from dishka import FromDishka from dishka import FromDishka
from uuid_utils.compat import uuid7 from uuid_utils.compat import uuid7
from tests.web_api.helpers import is_not_found_response, is_success_response, is_unauthorized_response from tests.web_api.helpers import (
is_forbidden_response,
is_not_found_response,
is_success_response,
is_unauthorized_response,
)
from tests.web_api.ioc import DatabaseClearer, inject from tests.web_api.ioc import DatabaseClearer, inject
from tests.web_api.test_api_gateway import TestApiGateway from tests.web_api.test_api_gateway import TestApiGateway
@@ -129,3 +134,123 @@ async def test_not_found_get_resume(
resume_id=str(uuid7()), resume_id=str(uuid7()),
) )
assert is_not_found_response(response) assert is_not_found_response(response)
@inject
async def test_success_edit_resume(
unique_email: str,
test_api_gateway: FromDishka[TestApiGateway],
database_clearer: FromDishka[DatabaseClearer],
) -> None:
await database_clearer.clear()
response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD)
access_token = response_sign_up.json()["access_token"]
response = await test_api_gateway.create_resume(
access_token=access_token,
about_me="About me",
experience_type="noExperience",
key_skills=["i love lisp", "i love rust"],
position="Position",
)
resume_id = response.json()["resume_id"]
response = await test_api_gateway.edit_resume(
access_token=access_token,
resume_id=resume_id,
about_me="Updated about me",
experience_type="between1And3",
key_skills=["i love python"],
position="Updated Position",
)
assert is_success_response(response)
assert response.json() == IsPartialDict(
position="Updated Position",
about_me="Updated about me",
key_skills=["i love python"],
experience_type="between1And3",
)
@inject
async def test_unauthorized_edit_resume(
unique_email: str,
test_api_gateway: FromDishka[TestApiGateway],
database_clearer: FromDishka[DatabaseClearer],
) -> None:
await database_clearer.clear()
response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD)
access_token = response_sign_up.json()["access_token"]
response = await test_api_gateway.create_resume(
access_token=access_token,
about_me="About me",
experience_type="noExperience",
key_skills=["i love lisp", "i love rust"],
position="Position",
)
resume_id = response.json()["resume_id"]
response = await test_api_gateway.edit_resume(
access_token="...",
resume_id=resume_id,
about_me="Updated about me",
experience_type="between1And3",
key_skills=["i love python"],
position="Updated Position",
)
assert is_unauthorized_response(response)
@inject
async def test_not_found_edit_resume(
unique_email: str,
test_api_gateway: FromDishka[TestApiGateway],
database_clearer: FromDishka[DatabaseClearer],
) -> None:
await database_clearer.clear()
response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD)
access_token = response_sign_up.json()["access_token"]
response = await test_api_gateway.edit_resume(
access_token=access_token,
resume_id=str(uuid7()),
about_me="Updated about me",
experience_type="between1And3",
key_skills=["i love python"],
position="Updated Position",
)
assert is_not_found_response(response)
@inject
async def test_forbidden_edit_resume(
unique_email: str,
test_api_gateway: FromDishka[TestApiGateway],
database_clearer: FromDishka[DatabaseClearer],
) -> None:
await database_clearer.clear()
response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD)
response = await test_api_gateway.create_resume(
access_token=response_sign_up.json()["access_token"],
about_me="About me",
experience_type="noExperience",
key_skills=["i love lisp", "i love rust"],
position="Position",
)
response_other_sign_up = await test_api_gateway.sign_up_email("f" + unique_email, DEFAULT_PASSWORD)
response = await test_api_gateway.edit_resume(
access_token=response_other_sign_up.json()["access_token"],
resume_id=response.json()["resume_id"],
about_me="Updated about me",
experience_type="between1And3",
key_skills=["i love python"],
position="Updated Position",
)
assert is_forbidden_response(response)
+9 -5
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable from collections.abc import AsyncIterable, Callable
from inspect import Parameter from inspect import Parameter
from typing import Final from typing import Final
@@ -13,7 +13,8 @@ from dishka import (
) )
from dishka.integrations.base import wrap_injection from dishka.integrations.base import wrap_injection
from dishka.integrations.fastapi import FastapiProvider from dishka.integrations.fastapi import FastapiProvider
from httpx import AsyncClient from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -71,11 +72,13 @@ class TestProvider(Provider):
database_clearer = provide(DatabaseClearer) database_clearer = provide(DatabaseClearer)
@provide @provide
def http_client(self) -> AsyncClient: async def http_client(self, app: FastAPI) -> AsyncIterable[AsyncClient]:
return AsyncClient(base_url="http://backend:8080") transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
yield client
def make_ioc(configuration: Configuration) -> AsyncContainer: def make_ioc(configuration: Configuration, app: FastAPI) -> AsyncContainer:
return make_async_container( return make_async_container(
IdPProvider(), IdPProvider(),
FactoryProvider(), FactoryProvider(),
@@ -97,6 +100,7 @@ def make_ioc(configuration: Configuration) -> AsyncContainer:
FirebaseConfiguration: configuration.firebase, FirebaseConfiguration: configuration.firebase,
Configuration: configuration, Configuration: configuration,
S3Config: configuration.s3, S3Config: configuration.s3,
FastAPI: app,
}, },
) )
+24
View File
@@ -78,3 +78,27 @@ class TestApiGateway:
f"/resume/{resume_id}", f"/resume/{resume_id}",
headers=make_auth_headers(access_token), headers=make_auth_headers(access_token),
) )
async def edit_resume(
self,
access_token: str,
resume_id: str,
position: str | None = None,
about_me: str | None = None,
key_skills: list[str] | None = None,
experience_type: str | None = None,
) -> Response:
return await self._client.patch(
f"/resume/{resume_id}",
json={
key: value
for key, value in {
"position": position,
"about_me": about_me,
"key_skills": key_skills,
"experience_type": experience_type,
}.items()
if value is not None
},
headers=make_auth_headers(access_token),
)