This commit is contained in:
ITQ
2025-11-22 11:57:10 +03:00
parent 2dd6e53bf8
commit 579f784fbd
10 changed files with 158 additions and 42 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ build:
[group("Docker")] [group("Docker")]
[doc("Compose start")] [doc("Compose start")]
up: build up: build
docker compose --profile migrations --profile observability up -d --remove-orphans --quiet-pull --force-recreate --build docker compose --profile migrations --profile observabilit --profile backend up -d --remove-orphans --quiet-pull --force-recreate --build
# ========= # =========
# > Tests # > Tests
@@ -25,7 +25,7 @@ up: build
[no-cd] [no-cd]
[group("Tests")] [group("Tests")]
[doc("Tests run")] [doc("Tests run")]
tests: up tests:
docker compose --profile migrations --profile tests up tests --remove-orphans --abort-on-container-exit docker compose --profile migrations --profile tests up tests --remove-orphans --abort-on-container-exit
# ========= # =========
+12 -12
View File
@@ -18,10 +18,10 @@ services:
restart: false restart: false
condition: service_healthy condition: service_healthy
required: true required: true
redis: # redis:
restart: false # restart: false
condition: service_healthy # condition: service_healthy
required: true # required: true
env_file: env_file:
- path: ./infrastructure/configs/backend/.env.template - path: ./infrastructure/configs/backend/.env.template
required: true required: true
@@ -36,6 +36,8 @@ services:
retries: 5 retries: 5
networks: networks:
- default - default
profiles:
- backend
ports: ports:
- name: web - name: web
target: 8080 target: 8080
@@ -68,10 +70,6 @@ services:
- template-project-tests:latest - template-project-tests:latest
pull: true pull: true
depends_on: depends_on:
backend:
restart: false
condition: service_healthy
required: true
migrations: migrations:
restart: false restart: false
condition: service_completed_successfully condition: service_completed_successfully
@@ -80,10 +78,10 @@ services:
restart: false restart: false
condition: service_healthy condition: service_healthy
required: true required: true
redis: # redis:
restart: false # restart: false
condition: service_healthy # condition: service_healthy
required: true # required: true
env_file: env_file:
- path: ./infrastructure/configs/backend/.env.template - path: ./infrastructure/configs/backend/.env.template
required: true required: true
@@ -248,6 +246,8 @@ services:
retries: 5 retries: 5
networks: networks:
- default - default
profiles:
- redis
restart: unless-stopped restart: unless-stopped
shm_size: 4mb shm_size: 4mb
volumes: volumes:
@@ -38,6 +38,30 @@ class DefaultResumeDataGateway(ResumeDataGateway):
result = await self._session.execute(statement) result = await self._session.execute(statement)
return result.scalars().all() return result.scalars().all()
@override
async def list_latest_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]:
statement = (
select(Resume)
.where(Resume.user_id == user_id)
.where(Resume.up_resume_id.is_(None))
.limit(limit)
.offset(offset)
)
result = await self._session.execute(statement)
return result.scalars().all()
@override
async def get_history(self, resume_id: ResumeId) -> Sequence[Resume]:
history: list[Resume] = []
current_resume = await self.load(resume_id)
history.append(current_resume)
while current_resume.down_resume_id is not None:
current_resume = await self.load(current_resume.down_resume_id)
history.append(current_resume)
return history
class DefaultResumePredictionDataGateway(ResumePredictionDataGateway): class DefaultResumePredictionDataGateway(ResumePredictionDataGateway):
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
@@ -24,6 +24,14 @@ class ResumeDataGateway(Protocol):
async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]: async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]:
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def list_latest_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]:
raise NotImplementedError
@abstractmethod
async def get_history(self, resume_id: ResumeId) -> Sequence[Resume]:
raise NotImplementedError
class ResumePredictionDataGateway(Protocol): class ResumePredictionDataGateway(Protocol):
@abstractmethod @abstractmethod
@@ -26,6 +26,7 @@ class AddResumeInteractor:
key_skills=key_skills, key_skills=key_skills,
experience_type=experience_type, experience_type=experience_type,
down_resume_id=None, down_resume_id=None,
up_resume_id=None,
) )
# TODO: тут надо сделать запуск фоновой таски для вычитывания подходящей вакансии # TODO: тут надо сделать запуск фоновой таски для вычитывания подходящей вакансии
@@ -4,7 +4,7 @@ from template_project.application.common.identity_provider import IdentityProvid
from template_project.application.common.interactor import to_interactor from template_project.application.common.interactor import to_interactor
from template_project.application.common.unit_of_work import UnitOfWork from template_project.application.common.unit_of_work import UnitOfWork
from template_project.application.resume.data_gateway import ResumeDataGateway from template_project.application.resume.data_gateway import ResumeDataGateway
from template_project.application.resume.entity import ResumeId from template_project.application.resume.entity import Resume, ResumeId
from template_project.application.resume.errors import ResumeDoesBelongUserError from template_project.application.resume.errors import ResumeDoesBelongUserError
@@ -32,25 +32,36 @@ class EditResumeInteractor:
experience_type: ExperienceType | None, experience_type: ExperienceType | None,
) -> _Response: ) -> _Response:
user = await self.identity_provider.get_current_user() user = await self.identity_provider.get_current_user()
resume = await self.resume_data_gateway.load(resume_id) old_resume = await self.resume_data_gateway.load(resume_id)
if resume.user_id != user.id: if old_resume.user_id != user.id:
raise ResumeDoesBelongUserError raise ResumeDoesBelongUserError
if position is not None: new_position = position if position is not None else old_resume.position
resume.position = position new_about_me = about_me if about_me is not None else old_resume.about_me
if about_me is not None: new_key_skills = key_skills if key_skills is not None else old_resume.key_skills
resume.about_me = about_me new_experience_type = experience_type if experience_type is not None else old_resume.experience_type
if key_skills is not None:
resume.key_skills = key_skills new_resume = Resume.factory(
if experience_type is not None: user_id=user.id,
resume.experience_type = experience_type position=new_position,
about_me=new_about_me,
key_skills=new_key_skills,
experience_type=new_experience_type,
down_resume_id=old_resume.id,
up_resume_id=None,
)
await self.unit_of_work.add(new_resume)
if old_resume.up_resume_id is None:
old_resume.up_resume_id = new_resume.id
await self.unit_of_work.commit() await self.unit_of_work.commit()
return _Response( return _Response(
id=resume.id, id=new_resume.id,
position=resume.position, position=new_resume.position,
about_me=resume.about_me, about_me=new_resume.about_me,
key_skills=resume.key_skills, key_skills=new_resume.key_skills,
experience_type=resume.experience_type, experience_type=new_resume.experience_type,
) )
@@ -65,6 +65,7 @@ class GetResumeInteractor:
@to_data_structure @to_data_structure
class ResumeListItemResponse: class ResumeListItemResponse:
id: ResumeId
position: str position: str
about_me: str about_me: str
key_skills: list[str] key_skills: list[str]
@@ -79,10 +80,11 @@ class GetResumeListInteractor:
async def execute(self, limit: int, offset: int) -> list[ResumeListItemResponse]: async def execute(self, limit: int, offset: int) -> list[ResumeListItemResponse]:
user = await self.identity_provider.get_current_user() user = await self.identity_provider.get_current_user()
resumes = await self.resume_data_gateway.list_by_user_id(user.id, limit=limit, offset=offset) resumes = await self.resume_data_gateway.list_latest_by_user_id(user.id, limit=limit, offset=offset)
return [ return [
ResumeListItemResponse( ResumeListItemResponse(
id=r.id,
position=r.position, position=r.position,
about_me=r.about_me, about_me=r.about_me,
key_skills=r.key_skills, key_skills=r.key_skills,
@@ -90,3 +92,29 @@ class GetResumeListInteractor:
) )
for r in resumes for r in resumes
] ]
@to_interactor
class GetResumeHistoryInteractor:
identity_provider: IdentityProvider
resume_data_gateway: ResumeDataGateway
async def execute(self, resume_id: ResumeId) -> list[ResumeListItemResponse]:
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
history = await self.resume_data_gateway.get_history(resume_id)
return [
ResumeListItemResponse(
id=r.id,
position=r.position,
about_me=r.about_me,
key_skills=r.key_skills,
experience_type=r.experience_type,
)
for r in history
]
@@ -8,7 +8,11 @@ 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.edit import EditResumeInteractor
from template_project.application.resume.interactors.get import GetResumeInteractor from template_project.application.resume.interactors.get import (
GetResumeHistoryInteractor,
GetResumeInteractor,
GetResumeListInteractor,
)
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,6 +28,8 @@ class InteractorProvider(Provider):
NotificationInteractor, NotificationInteractor,
RegisterNotificationDeviceInteractor, RegisterNotificationDeviceInteractor,
GetResumeInteractor, GetResumeInteractor,
GetResumeListInteractor,
GetResumeHistoryInteractor,
AddResumeInteractor, AddResumeInteractor,
EditResumeInteractor, EditResumeInteractor,
) )
+3 -1
View File
@@ -16,7 +16,9 @@ from template_project.web_api.ioc.data_gateway import DataGatewayProvider
from template_project.web_api.ioc.factory import FactoryProvider from template_project.web_api.ioc.factory import FactoryProvider
from template_project.web_api.ioc.idp import IdPProvider from template_project.web_api.ioc.idp import IdPProvider
from template_project.web_api.ioc.interactor import InteractorProvider from template_project.web_api.ioc.interactor import InteractorProvider
from template_project.web_api.ioc.notifications import NotificationServiceProvider from template_project.web_api.ioc.notifications import (
NotificationServiceProvider,
)
from template_project.web_api.ioc.oauth import OAuthClientProvider from template_project.web_api.ioc.oauth import OAuthClientProvider
from template_project.web_api.ioc.storage import StorageProvider from template_project.web_api.ioc.storage import StorageProvider
+44 -8
View File
@@ -14,6 +14,7 @@ from template_project.application.resume.errors import ResumeDoesBelongUserError
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.edit import EditResumeInteractor
from template_project.application.resume.interactors.get import ( from template_project.application.resume.interactors.get import (
GetResumeHistoryInteractor,
GetResumeInteractor, GetResumeInteractor,
GetResumeListInteractor, GetResumeListInteractor,
) )
@@ -188,7 +189,7 @@ async def create_resume(
@router.get( @router.get(
"/resume/list", "/resume/list",
summary="Get resume list", summary="Get resume list",
description="Get paginated list of resumes", description="Get paginated list of latest resumes (without up_resume_id)",
responses={ responses={
200: {"description": "Resume list retrieved successfully", "model": GetResumeListResponse}, 200: {"description": "Resume list retrieved successfully", "model": GetResumeListResponse},
401: {"description": "Unauthorized - invalid or missing token"}, 401: {"description": "Unauthorized - invalid or missing token"},
@@ -199,7 +200,19 @@ async def get_resume_list(
offset: Annotated[int, Query(ge=0)], offset: Annotated[int, Query(ge=0)],
interactor: FromDishka[GetResumeListInteractor], interactor: FromDishka[GetResumeListInteractor],
) -> GetResumeListResponse: ) -> GetResumeListResponse:
raise NotImplementedError interactor_response = await interactor.execute(limit=limit, offset=offset)
return GetResumeListResponse(
resumes=[
ResumeListItem(
id=r.id,
position=r.position,
about_me=r.about_me,
key_skills=r.key_skills,
experience_type=r.experience_type,
)
for r in interactor_response
]
)
@router.get( @router.get(
@@ -294,20 +307,43 @@ class PatchResumeResponse(BaseModel):
@router.get( @router.get(
"/resume/{resume_id}/history", "/resume/{resume_id}/history",
summary="Get resume history", summary="Get resume history",
description="Get paginated history of resume changes", description="Get all versions of a resume by following down_resume_id chain",
responses={ responses={
200: {"description": "Resume history retrieved successfully", "model": GetResumeHistoryResponse}, 200: {"description": "Resume history retrieved successfully", "model": GetResumeHistoryResponse},
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: {"description": "The resume does not belong to you"},
}, },
) )
async def get_resume_history( async def get_resume_history(
resume_id: str, resume_id: ResumeId,
limit: Annotated[int, Query(ge=1, le=100, description="Number of history items to return", examples=[10])], interactor: FromDishka[GetResumeHistoryInteractor],
offset: Annotated[int, Query(ge=0, description="Number of history items to skip", examples=[0])],
) -> GetResumeHistoryResponse: ) -> GetResumeHistoryResponse:
# TODO: Implement resume history retrieval try:
raise NotImplementedError interactor_response = await interactor.execute(resume_id)
except ResumeNotFoundError as error:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Resume not found",
) from error
except ResumeDoesBelongUserError as error:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="The resume does not belong to you",
) from error
return GetResumeHistoryResponse(
resumes=[
ResumeListItem(
id=r.id,
position=r.position,
about_me=r.about_me,
key_skills=r.key_skills,
experience_type=r.experience_type,
)
for r in interactor_response
]
)
@router.patch( @router.patch(