From 579f784fbd6dc4e28927b20b197cd4a0aef5b1c3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 22 Nov 2025 11:57:10 +0300 Subject: [PATCH] chore --- .justfile | 6 +-- compose.yaml | 24 ++++----- .../adapters/data_gateways/resume.py | 24 +++++++++ .../application/resume/data_gateway.py | 8 +++ .../application/resume/interactors/add.py | 1 + .../application/resume/interactors/edit.py | 43 +++++++++------ .../application/resume/interactors/get.py | 30 ++++++++++- .../web_api/ioc/interactor.py | 8 ++- src/template_project/web_api/ioc/make.py | 4 +- src/template_project/web_api/routes/resume.py | 52 ++++++++++++++++--- 10 files changed, 158 insertions(+), 42 deletions(-) diff --git a/.justfile b/.justfile index 5f97c79..031de9d 100644 --- a/.justfile +++ b/.justfile @@ -16,7 +16,7 @@ build: [group("Docker")] [doc("Compose start")] 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 @@ -25,9 +25,9 @@ up: build [no-cd] [group("Tests")] [doc("Tests run")] -tests: up +tests: docker compose --profile migrations --profile tests up tests --remove-orphans --abort-on-container-exit - + # ========= # > Lints # ========= diff --git a/compose.yaml b/compose.yaml index af9096d..ba9759b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,10 +18,10 @@ services: restart: false condition: service_healthy required: true - redis: - restart: false - condition: service_healthy - required: true + # redis: + # restart: false + # condition: service_healthy + # required: true env_file: - path: ./infrastructure/configs/backend/.env.template required: true @@ -36,6 +36,8 @@ services: retries: 5 networks: - default + profiles: + - backend ports: - name: web target: 8080 @@ -68,10 +70,6 @@ services: - template-project-tests:latest pull: true depends_on: - backend: - restart: false - condition: service_healthy - required: true migrations: restart: false condition: service_completed_successfully @@ -80,10 +78,10 @@ services: restart: false condition: service_healthy required: true - redis: - restart: false - condition: service_healthy - required: true + # redis: + # restart: false + # condition: service_healthy + # required: true env_file: - path: ./infrastructure/configs/backend/.env.template required: true @@ -248,6 +246,8 @@ services: retries: 5 networks: - default + profiles: + - redis restart: unless-stopped shm_size: 4mb volumes: diff --git a/src/template_project/adapters/data_gateways/resume.py b/src/template_project/adapters/data_gateways/resume.py index 0e19453..fb750cc 100644 --- a/src/template_project/adapters/data_gateways/resume.py +++ b/src/template_project/adapters/data_gateways/resume.py @@ -38,6 +38,30 @@ class DefaultResumeDataGateway(ResumeDataGateway): result = await self._session.execute(statement) 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): def __init__(self, session: AsyncSession) -> None: diff --git a/src/template_project/application/resume/data_gateway.py b/src/template_project/application/resume/data_gateway.py index 2d7f179..44008cf 100644 --- a/src/template_project/application/resume/data_gateway.py +++ b/src/template_project/application/resume/data_gateway.py @@ -24,6 +24,14 @@ class ResumeDataGateway(Protocol): async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]: 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): @abstractmethod diff --git a/src/template_project/application/resume/interactors/add.py b/src/template_project/application/resume/interactors/add.py index 9a5586c..14285f6 100644 --- a/src/template_project/application/resume/interactors/add.py +++ b/src/template_project/application/resume/interactors/add.py @@ -26,6 +26,7 @@ class AddResumeInteractor: key_skills=key_skills, experience_type=experience_type, down_resume_id=None, + up_resume_id=None, ) # TODO: тут надо сделать запуск фоновой таски для вычитывания подходящей вакансии diff --git a/src/template_project/application/resume/interactors/edit.py b/src/template_project/application/resume/interactors/edit.py index 2305bd5..9fb09d9 100644 --- a/src/template_project/application/resume/interactors/edit.py +++ b/src/template_project/application/resume/interactors/edit.py @@ -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.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.entity import Resume, ResumeId from template_project.application.resume.errors import ResumeDoesBelongUserError @@ -32,25 +32,36 @@ class EditResumeInteractor: 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: + old_resume = await self.resume_data_gateway.load(resume_id) + if old_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 + new_position = position if position is not None else old_resume.position + new_about_me = about_me if about_me is not None else old_resume.about_me + new_key_skills = key_skills if key_skills is not None else old_resume.key_skills + new_experience_type = experience_type if experience_type is not None else old_resume.experience_type + + new_resume = Resume.factory( + user_id=user.id, + 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() return _Response( - id=resume.id, - position=resume.position, - about_me=resume.about_me, - key_skills=resume.key_skills, - experience_type=resume.experience_type, + id=new_resume.id, + position=new_resume.position, + about_me=new_resume.about_me, + key_skills=new_resume.key_skills, + experience_type=new_resume.experience_type, ) diff --git a/src/template_project/application/resume/interactors/get.py b/src/template_project/application/resume/interactors/get.py index 55e1a67..63c61d9 100644 --- a/src/template_project/application/resume/interactors/get.py +++ b/src/template_project/application/resume/interactors/get.py @@ -65,6 +65,7 @@ class GetResumeInteractor: @to_data_structure class ResumeListItemResponse: + id: ResumeId position: str about_me: str key_skills: list[str] @@ -79,10 +80,11 @@ class GetResumeListInteractor: async def execute(self, limit: int, offset: int) -> list[ResumeListItemResponse]: 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 [ ResumeListItemResponse( + id=r.id, position=r.position, about_me=r.about_me, key_skills=r.key_skills, @@ -90,3 +92,29 @@ class GetResumeListInteractor: ) 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 + ] diff --git a/src/template_project/web_api/ioc/interactor.py b/src/template_project/web_api/ioc/interactor.py index 1f75681..daa0c8f 100644 --- a/src/template_project/web_api/ioc/interactor.py +++ b/src/template_project/web_api/ioc/interactor.py @@ -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.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 ( + GetResumeHistoryInteractor, + GetResumeInteractor, + GetResumeListInteractor, +) from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor @@ -24,6 +28,8 @@ class InteractorProvider(Provider): NotificationInteractor, RegisterNotificationDeviceInteractor, GetResumeInteractor, + GetResumeListInteractor, + GetResumeHistoryInteractor, AddResumeInteractor, EditResumeInteractor, ) diff --git a/src/template_project/web_api/ioc/make.py b/src/template_project/web_api/ioc/make.py index 24ad107..b3409b3 100644 --- a/src/template_project/web_api/ioc/make.py +++ b/src/template_project/web_api/ioc/make.py @@ -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.idp import IdPProvider 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.storage import StorageProvider diff --git a/src/template_project/web_api/routes/resume.py b/src/template_project/web_api/routes/resume.py index 56ac285..c4075a8 100644 --- a/src/template_project/web_api/routes/resume.py +++ b/src/template_project/web_api/routes/resume.py @@ -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.edit import EditResumeInteractor from template_project.application.resume.interactors.get import ( + GetResumeHistoryInteractor, GetResumeInteractor, GetResumeListInteractor, ) @@ -188,7 +189,7 @@ async def create_resume( @router.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={ 200: {"description": "Resume list retrieved successfully", "model": GetResumeListResponse}, 401: {"description": "Unauthorized - invalid or missing token"}, @@ -199,7 +200,19 @@ async def get_resume_list( offset: Annotated[int, Query(ge=0)], interactor: FromDishka[GetResumeListInteractor], ) -> 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( @@ -294,20 +307,43 @@ class PatchResumeResponse(BaseModel): @router.get( "/resume/{resume_id}/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={ 200: {"description": "Resume history retrieved successfully", "model": GetResumeHistoryResponse}, 401: {"description": "Unauthorized - invalid or missing token"}, 404: {"description": "Resume not found"}, + 403: {"description": "The resume does not belong to you"}, }, ) async def get_resume_history( - resume_id: str, - limit: Annotated[int, Query(ge=1, le=100, description="Number of history items to return", examples=[10])], - offset: Annotated[int, Query(ge=0, description="Number of history items to skip", examples=[0])], + resume_id: ResumeId, + interactor: FromDishka[GetResumeHistoryInteractor], ) -> GetResumeHistoryResponse: - # TODO: Implement resume history retrieval - raise NotImplementedError + try: + 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(