From 2963460b1cc084d8805ff65216d52c7e4b571596 Mon Sep 17 00:00:00 2001 From: gitgernit Date: Sat, 22 Nov 2025 17:30:46 +0300 Subject: [PATCH] feat(): update resume contracts --- .../adapters/data_gateways/key_skills.py | 4 +- .../adapters/data_gateways/resume.py | 66 +++- .../adapters/data_gateways/tables.py | 53 +++- .../application/common/enums.py | 11 + .../application/resume/data_gateway.py | 21 ++ .../application/resume/entity.py | 85 ++++- .../application/resume/interactors/add.py | 70 ++++- .../application/resume/interactors/edit.py | 137 ++++++++- .../application/resume/interactors/get.py | 76 ++++- .../ml/interactors/predict_salary.py | 5 +- src/template_project/ml/ioc/interactor.py | 1 - src/template_project/ml/routes/predict.py | 8 +- .../web_api/ioc/data_gateway.py | 11 +- src/template_project/web_api/routes/resume.py | 291 +++++++++++++++++- tests/web_api/e2e/test_resume.py | 16 + tests/web_api/test_api_gateway.py | 56 +++- 16 files changed, 847 insertions(+), 64 deletions(-) diff --git a/src/template_project/adapters/data_gateways/key_skills.py b/src/template_project/adapters/data_gateways/key_skills.py index c4d5ad1..8978b2f 100644 --- a/src/template_project/adapters/data_gateways/key_skills.py +++ b/src/template_project/adapters/data_gateways/key_skills.py @@ -23,8 +23,6 @@ class KeySkillsDataGateway: return result.scalars().all() async def add_skills(self, name: list[str]) -> None: - insert_statement = insert(key_skills_table).values( - [{"name": _} for _ in name] - ) + insert_statement = insert(key_skills_table).values([{"name": _} for _ in name]) with contextlib.suppress(IntegrityError): await self._session.execute(insert_statement) diff --git a/src/template_project/adapters/data_gateways/resume.py b/src/template_project/adapters/data_gateways/resume.py index 50256e6..4d1b73b 100644 --- a/src/template_project/adapters/data_gateways/resume.py +++ b/src/template_project/adapters/data_gateways/resume.py @@ -4,9 +4,29 @@ from typing import override from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from template_project.adapters.data_gateways.tables import resume_prediction_table, resume_table -from template_project.application.resume.data_gateway import ResumeDataGateway, ResumePredictionDataGateway -from template_project.application.resume.entity import Resume, ResumeEmbeddingId, ResumeId, ResumePrediction +from template_project.adapters.data_gateways.tables import ( + resume_education_table, + resume_experience_table, + resume_prediction_table, + resume_project_table, + resume_table, +) +from template_project.application.resume.data_gateway import ( + ResumeDataGateway, + ResumeEducationDataGateway, + ResumeExperienceDataGateway, + ResumePredictionDataGateway, + ResumeProjectDataGateway, +) +from template_project.application.resume.entity import ( + Resume, + ResumeEducation, + ResumeEmbeddingId, + ResumeExperience, + ResumeId, + ResumePrediction, + ResumeProject, +) from template_project.application.resume.errors import ResumeNotFoundError from template_project.application.user.entity import UserId @@ -29,12 +49,7 @@ class DefaultResumeDataGateway(ResumeDataGateway): @override async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]: - statement = ( - select(Resume) - .where(resume_table.c.user_id == user_id) - .limit(limit) - .offset(offset) - ) + statement = select(Resume).where(resume_table.c.user_id == user_id).limit(limit).offset(offset) result = await self._session.execute(statement) return result.scalars().all() @@ -73,3 +88,36 @@ class DefaultResumePredictionDataGateway(ResumePredictionDataGateway): statement = select(ResumePrediction).where(resume_prediction_table.c.resume_id == resume_id) result = await self._session.execute(statement) return result.scalar() + + +class DefaultResumeExperienceDataGateway(ResumeExperienceDataGateway): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + async def load_by_resume_id(self, resume_id: ResumeId) -> Sequence[ResumeExperience]: + statement = select(ResumeExperience).where(resume_experience_table.c.resume_id == resume_id) + result = await self._session.execute(statement) + return result.scalars().all() + + +class DefaultResumeEducationDataGateway(ResumeEducationDataGateway): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + async def load_by_resume_id(self, resume_id: ResumeId) -> Sequence[ResumeEducation]: + statement = select(ResumeEducation).where(resume_education_table.c.resume_id == resume_id) + result = await self._session.execute(statement) + return result.scalars().all() + + +class DefaultResumeProjectDataGateway(ResumeProjectDataGateway): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + async def load_by_resume_id(self, resume_id: ResumeId) -> Sequence[ResumeProject]: + statement = select(ResumeProject).where(resume_project_table.c.resume_id == resume_id) + result = await self._session.execute(statement) + return result.scalars().all() diff --git a/src/template_project/adapters/data_gateways/tables.py b/src/template_project/adapters/data_gateways/tables.py index 10f3725..c6d4cdd 100644 --- a/src/template_project/adapters/data_gateways/tables.py +++ b/src/template_project/adapters/data_gateways/tables.py @@ -3,7 +3,6 @@ from typing import Any, Final, override from pgvector.sqlalchemy import Vector from sqlalchemy import ( - ARRAY, Boolean, Column, DateTime, @@ -23,8 +22,16 @@ from sqlalchemy.orm import registry from template_project.application.access_token.entity import AccessToken from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod +from template_project.application.common.enums import EducationGrade from template_project.application.notification_device.entity import NotificationDevice -from template_project.application.resume.entity import Resume, ResumeEmbedding, ResumePrediction +from template_project.application.resume.entity import ( + Resume, + ResumeEducation, + ResumeEmbedding, + ResumeExperience, + ResumePrediction, + ResumeProject, +) from template_project.application.user.entity import User from template_project.application.user.profile.entity import Profile @@ -127,6 +134,7 @@ resume_table: Final = Table( Column("created_at", DateTime(timezone=True), nullable=False), Column("user_id", UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False), Column("position", String, nullable=False), + Column("location", String, nullable=False), Column("about_me", String, nullable=False), Column("key_skills", StringArrayType(), nullable=False, server_default=text("'[]'::jsonb")), Column("experience_type", String, nullable=False), @@ -158,7 +166,43 @@ key_skills_table: Final = Table( "key_skills", meta_data, Column("id", Integer, autoincrement=True, primary_key=True), - Column("name", String, nullable=False, unique=True) + Column("name", String, nullable=False, unique=True), +) + +resume_experience_table: Final = Table( + "resume_experience", + meta_data, + Column("id", UUID, primary_key=True), + Column("deleted_at", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=False), + Column("place", String, nullable=False), + Column("description", String, nullable=False), + Column("months_duration", Integer, nullable=False), +) + +resume_education_table: Final = Table( + "resume_education", + meta_data, + Column("id", UUID, primary_key=True), + Column("deleted_at", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=False), + Column("place", String, nullable=False), + Column("grade", Enum(EducationGrade, name="education_grade"), nullable=False), + Column("specialization", String, nullable=False), + Column("description", String, nullable=True), +) + +resume_project_table: Final = Table( + "resume_project", + meta_data, + Column("id", UUID, primary_key=True), + Column("deleted_at", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=False), + Column("name", String, nullable=False), + Column("description", String, nullable=False), ) @@ -182,3 +226,6 @@ mapper_registry.map_imperatively( "recommended_skills": resume_prediction_table.c.recommended_skills, }, ) +mapper_registry.map_imperatively(ResumeExperience, resume_experience_table) +mapper_registry.map_imperatively(ResumeEducation, resume_education_table) +mapper_registry.map_imperatively(ResumeProject, resume_project_table) diff --git a/src/template_project/application/common/enums.py b/src/template_project/application/common/enums.py index 8e91a6b..d463800 100644 --- a/src/template_project/application/common/enums.py +++ b/src/template_project/application/common/enums.py @@ -6,3 +6,14 @@ class ExperienceType(StrEnum): BETWEEN_1_AND_3 = "between1And3" BETWEEN_3_AND_6 = "between3And6" MORE_THAN_6 = "moreThan6" + + +class EducationGrade(StrEnum): + BASIC_GENERAL_EDUCATION = "basic_general_education" + SECONDARY_GENERAL_EDUCATION = "secondary_general_education" + SECONDARY_PROFESSIONAL_EDUCATION = "secondary_professional_education" + BACHELOR = "bachelor" + SPECIALIST = "specialist" + MASTER = "master" + POSTGRADUATE_STUDIES = "postgraduate_studies" + OTHER = "other" diff --git a/src/template_project/application/resume/data_gateway.py b/src/template_project/application/resume/data_gateway.py index 44008cf..66fd78f 100644 --- a/src/template_project/application/resume/data_gateway.py +++ b/src/template_project/application/resume/data_gateway.py @@ -4,9 +4,12 @@ from typing import Protocol from template_project.application.resume.entity import ( Resume, + ResumeEducation, ResumeEmbeddingId, + ResumeExperience, ResumeId, ResumePrediction, + ResumeProject, ) from template_project.application.user.entity import UserId @@ -37,3 +40,21 @@ class ResumePredictionDataGateway(Protocol): @abstractmethod async def load_by_resume_id(self, resume_id: ResumeId) -> ResumePrediction | None: raise NotImplementedError + + +class ResumeExperienceDataGateway(Protocol): + @abstractmethod + async def load_by_resume_id(self, resume_id: ResumeId) -> Sequence[ResumeExperience]: + raise NotImplementedError + + +class ResumeEducationDataGateway(Protocol): + @abstractmethod + async def load_by_resume_id(self, resume_id: ResumeId) -> Sequence[ResumeEducation]: + raise NotImplementedError + + +class ResumeProjectDataGateway(Protocol): + @abstractmethod + async def load_by_resume_id(self, resume_id: ResumeId) -> Sequence[ResumeProject]: + raise NotImplementedError diff --git a/src/template_project/application/resume/entity.py b/src/template_project/application/resume/entity.py index 0df2961..247aa7e 100644 --- a/src/template_project/application/resume/entity.py +++ b/src/template_project/application/resume/entity.py @@ -6,19 +6,22 @@ from uuid import UUID from uuid_utils.compat import uuid7 from template_project.application.common.entity import Entity, to_entity -from template_project.application.common.enums import ExperienceType +from template_project.application.common.enums import EducationGrade, ExperienceType from template_project.application.user.entity import UserId ResumeId = NewType("ResumeId", UUID) ResumeEmbeddingId = NewType("ResumeEmbeddingId", UUID) ResumePredictionId = NewType("ResumePredictionId", UUID) +ResumeExperienceId = NewType("ResumeExperienceId", UUID) +ResumeEducationId = NewType("ResumeEducationId", UUID) +ResumeProjectId = NewType("ResumeProjectId", UUID) @to_entity class Resume(Entity[ResumeId]): user_id: UserId position: str - # location: str + location: str about_me: str key_skills: list[str] experience_type: ExperienceType @@ -30,6 +33,7 @@ class Resume(Entity[ResumeId]): cls, user_id: UserId, position: str, + location: str, about_me: str, key_skills: list[str], experience_type: ExperienceType, @@ -41,6 +45,7 @@ class Resume(Entity[ResumeId]): created_at=datetime.now(tz=UTC), user_id=user_id, position=position, + location=location, about_me=about_me, key_skills=key_skills, experience_type=experience_type, @@ -74,4 +79,78 @@ class ResumePrediction(Entity[ResumePredictionId]): from_salary: Decimal to_salary: Decimal recommended_skills: list[str] - # common_recommended: str # TODO + + +@to_entity +class ResumeExperience(Entity[ResumeExperienceId]): + resume_id: ResumeId + place: str + description: str + months_duration: int + + @classmethod + def factory( + cls, + resume_id: ResumeId, + place: str, + description: str, + months_duration: int, + ) -> Self: + return cls( + id=ResumeExperienceId(uuid7()), + created_at=datetime.now(tz=UTC), + resume_id=resume_id, + place=place, + description=description, + months_duration=months_duration, + ) + + +@to_entity +class ResumeEducation(Entity[ResumeEducationId]): + resume_id: ResumeId + place: str + grade: EducationGrade + specialization: str + description: str | None = None + + @classmethod + def factory( + cls, + resume_id: ResumeId, + place: str, + grade: EducationGrade, + specialization: str, + description: str | None = None, + ) -> Self: + return cls( + id=ResumeEducationId(uuid7()), + created_at=datetime.now(tz=UTC), + resume_id=resume_id, + place=place, + grade=grade, + specialization=specialization, + description=description, + ) + + +@to_entity +class ResumeProject(Entity[ResumeProjectId]): + resume_id: ResumeId + name: str + description: str + + @classmethod + def factory( + cls, + resume_id: ResumeId, + name: str, + description: str, + ) -> Self: + return cls( + id=ResumeProjectId(uuid7()), + created_at=datetime.now(tz=UTC), + resume_id=resume_id, + name=name, + description=description, + ) diff --git a/src/template_project/application/resume/interactors/add.py b/src/template_project/application/resume/interactors/add.py index 14285f6..06d482a 100644 --- a/src/template_project/application/resume/interactors/add.py +++ b/src/template_project/application/resume/interactors/add.py @@ -1,8 +1,36 @@ -from template_project.application.common.enums import ExperienceType +from template_project.application.common.data_structure import to_data_structure +from template_project.application.common.enums import EducationGrade, 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.entity import Resume, ResumeId +from template_project.application.resume.entity import ( + Resume, + ResumeEducation, + ResumeExperience, + ResumeId, + ResumeProject, +) + + +@to_data_structure +class ExperienceInput: + place: str + description: str + months_duration: int + + +@to_data_structure +class EducationInput: + place: str + grade: EducationGrade + specialization: str + description: str | None = None + + +@to_data_structure +class ProjectInput: + name: str + description: str @to_interactor @@ -13,15 +41,20 @@ class AddResumeInteractor: async def execute( self, position: str, + location: str, about_me: str, key_skills: list[str], experience_type: ExperienceType, + experience: list[ExperienceInput] | None = None, + education: list[EducationInput] | None = None, + projects: list[ProjectInput] | None = None, ) -> ResumeId: user = await self.identity_provider.get_current_user() resume = Resume.factory( user_id=user.id, position=position, + location=location, about_me=about_me, key_skills=key_skills, experience_type=experience_type, @@ -29,9 +62,38 @@ class AddResumeInteractor: up_resume_id=None, ) - # TODO: тут надо сделать запуск фоновой таски для вычитывания подходящей вакансии - await self.unit_of_work.add(resume) + + if experience: + for exp in experience: + resume_experience = ResumeExperience.factory( + resume_id=resume.id, + place=exp.place, + description=exp.description, + months_duration=exp.months_duration, + ) + await self.unit_of_work.add(resume_experience) + + if education: + for edu in education: + resume_education = ResumeEducation.factory( + resume_id=resume.id, + place=edu.place, + grade=edu.grade, + specialization=edu.specialization, + description=edu.description, + ) + await self.unit_of_work.add(resume_education) + + if projects: + for proj in projects: + resume_project = ResumeProject.factory( + resume_id=resume.id, + name=proj.name, + description=proj.description, + ) + await self.unit_of_work.add(resume_project) + await self.unit_of_work.commit() return resume.id diff --git a/src/template_project/application/resume/interactors/edit.py b/src/template_project/application/resume/interactors/edit.py index 9fb09d9..d068deb 100644 --- a/src/template_project/application/resume/interactors/edit.py +++ b/src/template_project/application/resume/interactors/edit.py @@ -1,20 +1,77 @@ from template_project.application.common.data_structure import to_data_structure -from template_project.application.common.enums import ExperienceType +from template_project.application.common.enums import EducationGrade, 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 Resume, ResumeId +from template_project.application.resume.data_gateway import ( + ResumeDataGateway, + ResumeEducationDataGateway, + ResumeExperienceDataGateway, + ResumeProjectDataGateway, +) +from template_project.application.resume.entity import ( + Resume, + ResumeEducation, + ResumeExperience, + ResumeId, + ResumeProject, +) from template_project.application.resume.errors import ResumeDoesBelongUserError @to_data_structure -class _Response: +class ExperienceInput: + place: str + description: str + months_duration: int + + +@to_data_structure +class EducationInput: + place: str + grade: EducationGrade + specialization: str + description: str | None = None + + +@to_data_structure +class ProjectInput: + name: str + description: str + + +@to_data_structure +class ExperienceItemResponse: + place: str + description: str + months_duration: int + + +@to_data_structure +class EducationItemResponse: + place: str + grade: EducationGrade + specialization: str + description: str | None + + +@to_data_structure +class ProjectItemResponse: + name: str + description: str + + +@to_data_structure +class EditResumeResponse: id: ResumeId position: str + location: str about_me: str key_skills: list[str] experience_type: ExperienceType + experience: list[ExperienceItemResponse] + education: list[EducationItemResponse] + projects: list[ProjectItemResponse] @to_interactor @@ -22,21 +79,29 @@ class EditResumeInteractor: unit_of_work: UnitOfWork identity_provider: IdentityProvider resume_data_gateway: ResumeDataGateway + resume_experience_data_gateway: ResumeExperienceDataGateway + resume_education_data_gateway: ResumeEducationDataGateway + resume_project_data_gateway: ResumeProjectDataGateway async def execute( self, resume_id: ResumeId, position: str | None, + location: str | None, about_me: str | None, key_skills: list[str] | None, experience_type: ExperienceType | None, - ) -> _Response: + experience: list[ExperienceInput] | None = None, + education: list[EducationInput] | None = None, + projects: list[ProjectInput] | None = None, + ) -> EditResumeResponse: user = await self.identity_provider.get_current_user() old_resume = await self.resume_data_gateway.load(resume_id) if old_resume.user_id != user.id: raise ResumeDoesBelongUserError new_position = position if position is not None else old_resume.position + new_location = location if location is not None else old_resume.location 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 @@ -44,6 +109,7 @@ class EditResumeInteractor: new_resume = Resume.factory( user_id=user.id, position=new_position, + location=new_location, about_me=new_about_me, key_skills=new_key_skills, experience_type=new_experience_type, @@ -53,15 +119,74 @@ class EditResumeInteractor: await self.unit_of_work.add(new_resume) + if experience is not None: + for exp in experience: + resume_experience = ResumeExperience.factory( + resume_id=new_resume.id, + place=exp.place, + description=exp.description, + months_duration=exp.months_duration, + ) + await self.unit_of_work.add(resume_experience) + + if education is not None: + for edu in education: + resume_education = ResumeEducation.factory( + resume_id=new_resume.id, + place=edu.place, + grade=edu.grade, + specialization=edu.specialization, + description=edu.description, + ) + await self.unit_of_work.add(resume_education) + + if projects is not None: + for proj in projects: + resume_project = ResumeProject.factory( + resume_id=new_resume.id, + name=proj.name, + description=proj.description, + ) + await self.unit_of_work.add(resume_project) + if old_resume.up_resume_id is None: old_resume.up_resume_id = new_resume.id await self.unit_of_work.commit() - return _Response( + new_experiences = await self.resume_experience_data_gateway.load_by_resume_id(new_resume.id) + new_educations = await self.resume_education_data_gateway.load_by_resume_id(new_resume.id) + new_projects = await self.resume_project_data_gateway.load_by_resume_id(new_resume.id) + + return EditResumeResponse( id=new_resume.id, position=new_resume.position, + location=new_resume.location, about_me=new_resume.about_me, key_skills=new_resume.key_skills, experience_type=new_resume.experience_type, + experience=[ + ExperienceItemResponse( + place=exp.place, + description=exp.description, + months_duration=exp.months_duration, + ) + for exp in new_experiences + ], + education=[ + EducationItemResponse( + place=edu.place, + grade=edu.grade, + specialization=edu.specialization, + description=edu.description, + ) + for edu in new_educations + ], + projects=[ + ProjectItemResponse( + name=proj.name, + description=proj.description, + ) + for proj in new_projects + ], ) diff --git a/src/template_project/application/resume/interactors/get.py b/src/template_project/application/resume/interactors/get.py index 63c61d9..0cae4c7 100644 --- a/src/template_project/application/resume/interactors/get.py +++ b/src/template_project/application/resume/interactors/get.py @@ -1,10 +1,16 @@ from decimal import Decimal from template_project.application.common.data_structure import to_data_structure -from template_project.application.common.enums import ExperienceType +from template_project.application.common.enums import EducationGrade, ExperienceType from template_project.application.common.identity_provider import IdentityProvider from template_project.application.common.interactor import to_interactor -from template_project.application.resume.data_gateway import ResumeDataGateway, ResumePredictionDataGateway +from template_project.application.resume.data_gateway import ( + ResumeDataGateway, + ResumeEducationDataGateway, + ResumeExperienceDataGateway, + ResumePredictionDataGateway, + ResumeProjectDataGateway, +) from template_project.application.resume.entity import ResumeId from template_project.application.resume.errors import ResumeDoesBelongUserError @@ -17,12 +23,37 @@ class ResumePredictionResponse: @to_data_structure -class _Response: +class ExperienceItemResponse: + place: str + description: str + months_duration: int + + +@to_data_structure +class EducationItemResponse: + place: str + grade: EducationGrade + specialization: str + description: str | None + + +@to_data_structure +class ProjectItemResponse: + name: str + description: str + + +@to_data_structure +class GetResumeResponse: id: ResumeId position: str + location: str about_me: str key_skills: list[str] experience_type: ExperienceType + experience: list[ExperienceItemResponse] + education: list[EducationItemResponse] + projects: list[ProjectItemResponse] prediction: ResumePredictionResponse | None @@ -31,11 +62,14 @@ class GetResumeInteractor: identity_provider: IdentityProvider resume_data_gateway: ResumeDataGateway resume_prediction_data_gateway: ResumePredictionDataGateway + resume_experience_data_gateway: ResumeExperienceDataGateway + resume_education_data_gateway: ResumeEducationDataGateway + resume_project_data_gateway: ResumeProjectDataGateway async def execute( self, resume_id: ResumeId, - ) -> _Response: + ) -> GetResumeResponse: user = await self.identity_provider.get_current_user() resume = await self.resume_data_gateway.load(resume_id) @@ -53,12 +87,41 @@ class GetResumeInteractor: else: prediction = None - return _Response( + experiences = await self.resume_experience_data_gateway.load_by_resume_id(resume.id) + educations = await self.resume_education_data_gateway.load_by_resume_id(resume.id) + projects = await self.resume_project_data_gateway.load_by_resume_id(resume.id) + + return GetResumeResponse( id=resume.id, position=resume.position, + location=resume.location, about_me=resume.about_me, key_skills=resume.key_skills, experience_type=resume.experience_type, + experience=[ + ExperienceItemResponse( + place=exp.place, + description=exp.description, + months_duration=exp.months_duration, + ) + for exp in experiences + ], + education=[ + EducationItemResponse( + place=edu.place, + grade=edu.grade, + specialization=edu.specialization, + description=edu.description, + ) + for edu in educations + ], + projects=[ + ProjectItemResponse( + name=proj.name, + description=proj.description, + ) + for proj in projects + ], prediction=prediction, ) @@ -67,6 +130,7 @@ class GetResumeInteractor: class ResumeListItemResponse: id: ResumeId position: str + location: str about_me: str key_skills: list[str] experience_type: ExperienceType @@ -86,6 +150,7 @@ class GetResumeListInteractor: ResumeListItemResponse( id=r.id, position=r.position, + location=r.location, about_me=r.about_me, key_skills=r.key_skills, experience_type=r.experience_type, @@ -112,6 +177,7 @@ class GetResumeHistoryInteractor: ResumeListItemResponse( id=r.id, position=r.position, + location=r.location, about_me=r.about_me, key_skills=r.key_skills, experience_type=r.experience_type, diff --git a/src/template_project/ml/interactors/predict_salary.py b/src/template_project/ml/interactors/predict_salary.py index 8aab671..0775113 100644 --- a/src/template_project/ml/interactors/predict_salary.py +++ b/src/template_project/ml/interactors/predict_salary.py @@ -32,8 +32,7 @@ class PredictSalaryResponse: class PredictSalaryInteractor: async def execute(self, request: PredictSalaryRequest) -> PredictSalaryResponse: return PredictSalaryResponse( - salary_from=Decimal("50000"), - salary_to=Decimal("80000"), + salary_from=Decimal(50000), + salary_to=Decimal(80000), recommended_skills=["python", "django", "postgresql"], ) - diff --git a/src/template_project/ml/ioc/interactor.py b/src/template_project/ml/ioc/interactor.py index 6cffda7..c56095d 100644 --- a/src/template_project/ml/ioc/interactor.py +++ b/src/template_project/ml/ioc/interactor.py @@ -9,4 +9,3 @@ class InteractorProvider(Provider): interactors = provide_all( PredictSalaryInteractor, ) - diff --git a/src/template_project/ml/routes/predict.py b/src/template_project/ml/routes/predict.py index 1d63f9d..8db98ef 100644 --- a/src/template_project/ml/routes/predict.py +++ b/src/template_project/ml/routes/predict.py @@ -2,14 +2,13 @@ from decimal import Decimal from dishka import FromDishka from dishka.integrations.fastapi import DishkaRoute -from fastapi import APIRouter, status +from fastapi import APIRouter from pydantic import BaseModel, Field from template_project.application.resume.entity import ResumeId from template_project.ml.interactors.predict_salary import ( PredictSalaryInteractor, PredictSalaryRequest, - PredictSalaryResponse, VacancyInput, ) @@ -43,9 +42,7 @@ class PredictSalaryRequestModel(BaseModel): key_skills: list[str] = Field( min_length=1, description="List of key skills from resume", examples=[["Python", "FastAPI", "PostgreSQL"]] ) - vacancies: list[VacancyInputModel] = Field( - min_length=1, description="List of relevant vacancies", examples=[[]] - ) + vacancies: list[VacancyInputModel] = Field(min_length=1, description="List of relevant vacancies", examples=[[]]) model_config = { "json_schema_extra": { @@ -120,4 +117,3 @@ async def predict_salary( salary_to=response.salary_to, recommended_skills=response.recommended_skills, ) - diff --git a/src/template_project/web_api/ioc/data_gateway.py b/src/template_project/web_api/ioc/data_gateway.py index 6b36472..85d4f1f 100644 --- a/src/template_project/web_api/ioc/data_gateway.py +++ b/src/template_project/web_api/ioc/data_gateway.py @@ -5,7 +5,13 @@ from template_project.adapters.data_gateways.auth_identity import DefaultAuthIde from template_project.adapters.data_gateways.key_skills import KeySkillsDataGateway from template_project.adapters.data_gateways.notification_device import DefaultNotificationDeviceDataGateway from template_project.adapters.data_gateways.profile import DefaultProfileDataGateway -from template_project.adapters.data_gateways.resume import DefaultResumeDataGateway, DefaultResumePredictionDataGateway +from template_project.adapters.data_gateways.resume import ( + DefaultResumeDataGateway, + DefaultResumeEducationDataGateway, + DefaultResumeExperienceDataGateway, + DefaultResumePredictionDataGateway, + DefaultResumeProjectDataGateway, +) from template_project.adapters.data_gateways.user import DefaultUserDataGateway from template_project.adapters.unit_of_work import DefaultUnitOfWork @@ -23,4 +29,7 @@ class DataGatewayProvider(Provider): WithParents[DefaultNotificationDeviceDataGateway], WithParents[DefaultResumeDataGateway], WithParents[DefaultResumePredictionDataGateway], + WithParents[DefaultResumeExperienceDataGateway], + WithParents[DefaultResumeEducationDataGateway], + WithParents[DefaultResumeProjectDataGateway], ) diff --git a/src/template_project/web_api/routes/resume.py b/src/template_project/web_api/routes/resume.py index c4075a8..da1c86b 100644 --- a/src/template_project/web_api/routes/resume.py +++ b/src/template_project/web_api/routes/resume.py @@ -8,11 +8,21 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.security import HTTPBearer from pydantic import BaseModel, Field -from template_project.application.common.enums import ExperienceType +from template_project.application.common.enums import EducationGrade, ExperienceType from template_project.application.resume.entity import ResumeId from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError -from template_project.application.resume.interactors.add import AddResumeInteractor -from template_project.application.resume.interactors.edit import EditResumeInteractor +from template_project.application.resume.interactors.add import ( + AddResumeInteractor, + EducationInput, + ExperienceInput, + ProjectInput, +) +from template_project.application.resume.interactors.edit import ( + EditResumeInteractor, + EducationInput as EditEducationInput, + ExperienceInput as EditExperienceInput, + ProjectInput as EditProjectInput, +) from template_project.application.resume.interactors.get import ( GetResumeHistoryInteractor, GetResumeInteractor, @@ -23,18 +33,81 @@ security = HTTPBearer() router = APIRouter(route_class=DishkaRoute, tags=["Resume"], dependencies=[Depends(security)]) +class ExperienceItem(BaseModel): + place: str = Field(min_length=1, max_length=200, description="Company or organization name", examples=["T-bank"]) + description: str = Field( + min_length=1, max_length=2000, description="Job description", examples=["some description lorem ipsum"] + ) + months_duration: int = Field(ge=1, description="Duration in months", examples=[12]) + + model_config = { + "json_schema_extra": { + "example": { + "place": "T-bank", + "description": "some description lorem ipsum", + "months_duration": 12, + } + } + } + + +class EducationItem(BaseModel): + place: str = Field( + min_length=1, max_length=200, description="Educational institution name", examples=["Central university"] + ) + grade: EducationGrade = Field(description="Education grade", examples=[EducationGrade.BACHELOR]) + specialization: str = Field(min_length=1, max_length=200, description="Field of study", examples=["IT guy"]) + description: str | None = Field( + None, + max_length=2000, + description="Additional description (optional)", + examples=["optional field, if user want add something"], + ) + + model_config = { + "json_schema_extra": { + "example": { + "place": "Central university", + "grade": "bachelor", + "specialization": "IT guy", + "description": "optional field, if user want add something", + } + } + } + + +class ProjectItem(BaseModel): + name: str = Field(min_length=1, max_length=200, description="Project name", examples=["Rekomenci fluon"]) + description: str = Field( + min_length=1, max_length=2000, description="Project description", examples=["fucking shit"] + ) + + model_config = { + "json_schema_extra": { + "example": { + "name": "Rekomenci fluon", + "description": "fucking shit", + } + } + } + + class CreateResumeRequest(BaseModel): position: str = Field(min_length=1, max_length=200, description="Job position", examples=["Python Developer"]) about_me: str = Field( min_length=1, max_length=2000, description="About me section", - examples=["Experienced Python developer"], + examples=["Experienced Python developer with 5 years of experience"], ) key_skills: list[str] = Field( min_length=1, description="List of key skills", examples=[["Python", "FastAPI", "PostgreSQL"]] ) experience_type: ExperienceType = Field(description="Experience type", examples=[ExperienceType.BETWEEN_3_AND_6]) + location: str = Field(min_length=1, max_length=100, description="Location", examples=["Moscow"]) + experience: list[ExperienceItem] = Field(default_factory=list, description="Work experience list") + education: list[EducationItem] = Field(default_factory=list, description="Education list") + projects: list[ProjectItem] = Field(default_factory=list, description="Projects list") model_config = { "json_schema_extra": { @@ -43,6 +116,28 @@ class CreateResumeRequest(BaseModel): "about_me": "Experienced Python developer with 5 years of experience", "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "experience_type": "between3And6", + "location": "Moscow", + "experience": [ + { + "place": "T-bank", + "description": "some description lorem ipsum", + "months_duration": 12, + } + ], + "education": [ + { + "place": "Central university", + "grade": "bachelor", + "specialization": "IT guy", + "description": "optional field, if user want add something", + } + ], + "projects": [ + { + "name": "Rekomenci fluon", + "description": "fucking shit", + } + ], } } } @@ -73,18 +168,44 @@ class SalaryPrediction(BaseModel): class ResumeResponse(BaseModel): id: ResumeId = Field(description="Resume ID") position: str = Field(description="Job position") + location: str = Field(description="Location") about_me: str = Field(description="About me section") key_skills: list[str] = Field(description="List of key skills") experience_type: ExperienceType = Field(description="Experience type") + experience: list[ExperienceItem] = Field(description="Work experience list") + education: list[EducationItem] = Field(description="Education list") + projects: list[ProjectItem] = Field(description="Projects list") prediction: SalaryPrediction | None = Field(None, description="Salary prediction (can be null)") model_config = { "json_schema_extra": { "example": { "position": "Python Developer", + "location": "Moscow", "about_me": "Experienced Python developer with 5 years of experience", "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "experience_type": "between3And6", + "experience": [ + { + "place": "T-bank", + "description": "some description lorem ipsum", + "months_duration": 12, + } + ], + "education": [ + { + "place": "Central university", + "grade": "bachelor", + "specialization": "IT guy", + "description": "optional field, if user want add something", + } + ], + "projects": [ + { + "name": "Rekomenci fluon", + "description": "fucking shit", + } + ], "prediction": { "from_salary": "100000", "to_salary": "150000", @@ -98,6 +219,7 @@ class ResumeResponse(BaseModel): class ResumeListItem(BaseModel): id: ResumeId = Field(description="Resume ID") position: str = Field(description="Job position") + location: str = Field(description="Location") about_me: str = Field(description="About me section") key_skills: list[str] = Field(description="List of key skills") experience_type: ExperienceType = Field(description="Experience type") @@ -106,6 +228,7 @@ class ResumeListItem(BaseModel): "json_schema_extra": { "example": { "position": "Python Developer", + "location": "Moscow", "about_me": "Experienced Python developer with 5 years of experience", "key_skills": ["Python", "FastAPI", "PostgreSQL"], "experience_type": "between3And6", @@ -175,11 +298,39 @@ async def create_resume( request: CreateResumeRequest, interactor: FromDishka[AddResumeInteractor], ) -> CreateResumeResponse: + experience = ( + [ + ExperienceInput(place=exp.place, description=exp.description, months_duration=exp.months_duration) + for exp in request.experience + ] + if request.experience + else None + ) + education = ( + [ + EducationInput( + place=edu.place, grade=edu.grade, specialization=edu.specialization, description=edu.description + ) + for edu in request.education + ] + if request.education + else None + ) + projects = ( + [ProjectInput(name=proj.name, description=proj.description) for proj in request.projects] + if request.projects + else None + ) + interactor_response = await interactor.execute( position=request.position, + location=request.location, about_me=request.about_me, key_skills=request.key_skills, experience_type=request.experience_type, + experience=experience, + education=education, + projects=projects, ) return CreateResumeResponse( resume_id=interactor_response, @@ -206,6 +357,7 @@ async def get_resume_list( ResumeListItem( id=r.id, position=r.position, + location=r.location, about_me=r.about_me, key_skills=r.key_skills, experience_type=r.experience_type, @@ -246,9 +398,34 @@ async def get_resume( return ResumeResponse( id=interactor_response.id, position=interactor_response.position, + location=interactor_response.location, about_me=interactor_response.about_me, key_skills=interactor_response.key_skills, experience_type=interactor_response.experience_type, + experience=[ + ExperienceItem( + place=exp.place, + description=exp.description, + months_duration=exp.months_duration, + ) + for exp in interactor_response.experience + ], + education=[ + EducationItem( + place=edu.place, + grade=edu.grade, + specialization=edu.specialization, + description=edu.description, + ) + for edu in interactor_response.education + ], + projects=[ + ProjectItem( + name=proj.name, + description=proj.description, + ) + for proj in interactor_response.projects + ], prediction=SalaryPrediction( from_salary=interactor_response.prediction.from_salary, to_salary=interactor_response.prediction.to_salary, @@ -263,6 +440,7 @@ class PatchResumeRequest(BaseModel): position: str | None = Field( None, min_length=1, max_length=200, description="Job position", examples=["Python Developer"] ) + location: str | None = Field(None, min_length=1, max_length=100, description="Location", examples=["Moscow"]) about_me: str | None = Field( None, min_length=1, max_length=2000, description="About me section", examples=["Experienced Python developer"] ) @@ -272,14 +450,39 @@ class PatchResumeRequest(BaseModel): experience_type: ExperienceType | None = Field( None, description="Experience type", examples=[ExperienceType.BETWEEN_3_AND_6] ) + experience: list[ExperienceItem] | None = Field(None, description="Work experience list") + education: list[EducationItem] | None = Field(None, description="Education list") + projects: list[ProjectItem] | None = Field(None, description="Projects list") model_config = { "json_schema_extra": { "example": { "position": "Python Developer", + "location": "Moscow", "about_me": "Experienced Python developer with 5 years of experience", "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "experience_type": "between3And6", + "experience": [ + { + "place": "T-bank", + "description": "some description lorem ipsum", + "months_duration": 12, + } + ], + "education": [ + { + "place": "Central university", + "grade": "bachelor", + "specialization": "IT guy", + "description": "optional field, if user want add something", + } + ], + "projects": [ + { + "name": "Rekomenci fluon", + "description": "fucking shit", + } + ], } } } @@ -288,17 +491,43 @@ class PatchResumeRequest(BaseModel): class PatchResumeResponse(BaseModel): id: ResumeId = Field(description="Resume ID") position: str = Field(description="Job position") + location: str = Field(description="Location") about_me: str = Field(description="About me section") key_skills: list[str] = Field(description="List of key skills") experience_type: ExperienceType = Field(description="Experience type") + experience: list[ExperienceItem] = Field(description="Work experience list") + education: list[EducationItem] = Field(description="Education list") + projects: list[ProjectItem] = Field(description="Projects list") model_config = { "json_schema_extra": { "example": { "position": "Python Developer", + "location": "Moscow", "about_me": "Experienced Python developer with 5 years of experience", "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "experience_type": "between3And6", + "experience": [ + { + "place": "T-bank", + "description": "some description lorem ipsum", + "months_duration": 12, + } + ], + "education": [ + { + "place": "Central university", + "grade": "bachelor", + "specialization": "IT guy", + "description": "optional field, if user want add something", + } + ], + "projects": [ + { + "name": "Rekomenci fluon", + "description": "fucking shit", + } + ], } } } @@ -337,6 +566,7 @@ async def get_resume_history( ResumeListItem( id=r.id, position=r.position, + location=r.location, about_me=r.about_me, key_skills=r.key_skills, experience_type=r.experience_type, @@ -363,12 +593,40 @@ async def patch_resume( interactor: FromDishka[EditResumeInteractor], ) -> PatchResumeResponse: try: + experience = ( + [ + EditExperienceInput(place=exp.place, description=exp.description, months_duration=exp.months_duration) + for exp in request.experience + ] + if request.experience is not None + else None + ) + education = ( + [ + EditEducationInput( + place=edu.place, grade=edu.grade, specialization=edu.specialization, description=edu.description + ) + for edu in request.education + ] + if request.education is not None + else None + ) + projects = ( + [EditProjectInput(name=proj.name, description=proj.description) for proj in request.projects] + if request.projects is not None + else None + ) + interactor_response = await interactor.execute( resume_id=resume_id, position=request.position, + location=request.location, about_me=request.about_me, key_skills=request.key_skills, experience_type=request.experience_type, + experience=experience, + education=education, + projects=projects, ) except ResumeDoesBelongUserError as error: raise HTTPException( @@ -384,7 +642,32 @@ async def patch_resume( return PatchResumeResponse( id=interactor_response.id, position=interactor_response.position, + location=interactor_response.location, about_me=interactor_response.about_me, key_skills=interactor_response.key_skills, experience_type=interactor_response.experience_type, + experience=[ + ExperienceItem( + place=exp.place, + description=exp.description, + months_duration=exp.months_duration, + ) + for exp in interactor_response.experience + ], + education=[ + EducationItem( + place=edu.place, + grade=edu.grade, + specialization=edu.specialization, + description=edu.description, + ) + for edu in interactor_response.education + ], + projects=[ + ProjectItem( + name=proj.name, + description=proj.description, + ) + for proj in interactor_response.projects + ], ) diff --git a/tests/web_api/e2e/test_resume.py b/tests/web_api/e2e/test_resume.py index 16cb5f8..7190f2d 100644 --- a/tests/web_api/e2e/test_resume.py +++ b/tests/web_api/e2e/test_resume.py @@ -33,6 +33,7 @@ async def test_success_add_resume( experience_type="noExperience", key_skills=["i love lisp", "i love rust"], position="Position", + location="Moscow", ) assert is_success_response(response) assert response.json() == IsDict( @@ -54,6 +55,7 @@ async def test_unauthorized_add_resume( experience_type="noExperience", key_skills=["i love lisp", "i love rust"], position="Position", + location="Moscow", ) assert is_unauthorized_response(response) @@ -75,6 +77,7 @@ async def test_success_get_resume( experience_type="noExperience", key_skills=["i love lisp", "i love rust"], position="Position", + location="Moscow", ) response = await test_api_gateway.get_resume( @@ -84,9 +87,13 @@ async def test_success_get_resume( assert is_success_response(response) assert response.json() == IsPartialDict( position="Position", + location="Moscow", about_me="About me", key_skills=["i love lisp", "i love rust"], experience_type="noExperience", + experience=[], + education=[], + projects=[], prediction=None, ) @@ -108,6 +115,7 @@ async def test_unauthorized_get_resume( experience_type="noExperience", key_skills=["i love lisp", "i love rust"], position="Position", + location="Moscow", ) response = await test_api_gateway.get_resume( @@ -152,6 +160,7 @@ async def test_success_edit_resume( experience_type="noExperience", key_skills=["i love lisp", "i love rust"], position="Position", + location="Moscow", ) resume_id = response.json()["resume_id"] response = await test_api_gateway.edit_resume( @@ -161,13 +170,18 @@ async def test_success_edit_resume( experience_type="between1And3", key_skills=["i love python"], position="Updated Position", + location="St. Petersburg", ) assert is_success_response(response) assert response.json() == IsPartialDict( position="Updated Position", + location="St. Petersburg", about_me="Updated about me", key_skills=["i love python"], experience_type="between1And3", + experience=[], + education=[], + projects=[], ) @@ -188,6 +202,7 @@ async def test_unauthorized_edit_resume( experience_type="noExperience", key_skills=["i love lisp", "i love rust"], position="Position", + location="Moscow", ) resume_id = response.json()["resume_id"] response = await test_api_gateway.edit_resume( @@ -239,6 +254,7 @@ async def test_forbidden_edit_resume( experience_type="noExperience", key_skills=["i love lisp", "i love rust"], position="Position", + location="Moscow", ) response_other_sign_up = await test_api_gateway.sign_up_email("f" + unique_email, DEFAULT_PASSWORD) diff --git a/tests/web_api/test_api_gateway.py b/tests/web_api/test_api_gateway.py index 52f3697..2d39bb1 100644 --- a/tests/web_api/test_api_gateway.py +++ b/tests/web_api/test_api_gateway.py @@ -58,18 +58,30 @@ class TestApiGateway: self, access_token: str, position: str, + location: str, about_me: str, key_skills: list[str], experience_type: str, + experience: list[dict[str, Any]] | None = None, + education: list[dict[str, Any]] | None = None, + projects: list[dict[str, Any]] | None = None, ) -> Response: + json_data: dict[str, Any] = { + "position": position, + "location": location, + "about_me": about_me, + "key_skills": key_skills, + "experience_type": experience_type, + } + if experience is not None: + json_data["experience"] = experience + if education is not None: + json_data["education"] = education + if projects is not None: + json_data["projects"] = projects return await self._client.post( "/resume", - json={ - "position": position, - "about_me": about_me, - "key_skills": key_skills, - "experience_type": experience_type, - }, + json=json_data, headers=make_auth_headers(access_token), ) @@ -84,22 +96,34 @@ class TestApiGateway: access_token: str, resume_id: str, position: str | None = None, + location: str | None = None, about_me: str | None = None, key_skills: list[str] | None = None, experience_type: str | None = None, + experience: list[dict[str, Any]] | None = None, + education: list[dict[str, Any]] | None = None, + projects: list[dict[str, Any]] | None = None, ) -> Response: + json_data: dict[str, Any] = {} + if position is not None: + json_data["position"] = position + if location is not None: + json_data["location"] = location + if about_me is not None: + json_data["about_me"] = about_me + if key_skills is not None: + json_data["key_skills"] = key_skills + if experience_type is not None: + json_data["experience_type"] = experience_type + if experience is not None: + json_data["experience"] = experience + if education is not None: + json_data["education"] = education + if projects is not None: + json_data["projects"] = projects 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 - }, + json=json_data, headers=make_auth_headers(access_token), )