diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 733b9c5..9cefcdd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -179,7 +179,7 @@ cache: policy: pull-push before_script: - apt-get update - - apt-get install -y --no-install-recommends ca-certificates curl just + - apt-get install -y --no-install-recommends ca-certificates curl just git - update-ca-certificates - curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" @@ -227,7 +227,7 @@ build-ml: <<: *build-config variables: IMAGE_NAME: $BASE_IMAGE_NAME/ml - CONTAINERFILE: Containerfile.ml + CONTAINERFILE: Containerfile BUILDTARGET: ml lint: @@ -252,6 +252,8 @@ test: --profile migrations --profile tests PODMAN_IGNORE_CGROUPSV1_WARNING: True + tags: + - self-hosted script: - dnf -y install podman podman-compose # - cp ./infrastructure/configs/podman/ci.conf /etc/containers/containers.conf @@ -348,6 +350,14 @@ sast-image-migrations: dependencies: - build-migrations +sast-image-ml: + <<: *trivy-image-scan + variables: + IMAGE_NAME: $BASE_IMAGE_NAME/ml + IMAGE_TYPE: ml + dependencies: + - build-ml + tag-runtime: <<: *tag-config variables: diff --git a/Containerfile b/Containerfile index 79e039f..71cba6b 100644 --- a/Containerfile +++ b/Containerfile @@ -1,7 +1,14 @@ # syntax=docker/dockerfile:1.20 -# Stage 1: Builder -FROM docker.io/python:3.12-slim AS builder +ARG PY_IMAGE=python:3.12-slim + +# Stage 1: Base Builder +FROM ${PY_IMAGE} AS base-builder + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl git \ + && rm -rf /var/lib/apt/lists/* COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ @@ -14,18 +21,30 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ UV_LINK_MODE=copy \ UV_PROJECT_ENVIRONMENT=/opt/venv -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - curl git \ - && rm -rf /var/lib/apt/lists/* - COPY pyproject.toml uv.lock ./ +# Stage 2: Backend Builder +FROM base-builder AS backend-builder + +COPY ./src ./src + RUN uv sync --frozen --no-dev --no-cache --group backend +# Stage 3: ML Builder +FROM base-builder AS ml-builder -# Stage 2: Runtime -FROM docker.io/python:3.12-alpine3.22 AS runtime +COPY ./src ./src + +RUN uv sync --frozen --no-dev --no-cache --group ml + + +# Stage 4: Backend Runtime +FROM ${PY_IMAGE} AS runtime + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -35,28 +54,22 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -RUN apk add --no-cache --virtual .runtime-deps \ - curl && \ - rm -rf /var/cache/apk/* +COPY --from=backend-builder /opt/venv /opt/venv -RUN adduser -D -g '' app && chown -R app:app /app - -USER app - -COPY --from=builder --chown=app:app /opt/venv /opt/venv - -COPY --chown=app:app ./src/ ./ +COPY ./src/ ./ EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD curl -fsS http://localhost:8080/healthcheck || exit 1 - -CMD [ "web_api_cli", "/app/config.toml" ] +CMD [ "/opt/venv/bin/web_api_cli", "/app/config.toml" ] -# Stage 3: Testing -FROM builder AS tests +# Stage 5: ML Runtime +FROM ${PY_IMAGE} AS ml + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -66,21 +79,39 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -RUN uv sync --no-install-project --group tests --frozen --no-cache +COPY --from=ml-builder /opt/venv /opt/venv + +COPY ./src/ ./ + +EXPOSE 8081 + +CMD [ "/opt/venv/bin/ml_api_cli", "/app/config.toml" ] + + +# Stage 6: Testing +FROM base-builder AS tests + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONOPTIMIZE=2 \ + PATH="/opt/venv/bin:$PATH" \ + PYTHONPATH="/app:$PYTHONPATH" + +WORKDIR /app COPY ./src ./src COPY ./tests ./tests -RUN uv pip install -e . +RUN uv sync --group backend --group tests --frozen --no-cache RUN mkdir -p /app/cov && mkdir /app/cov/html CMD [ "sh", "-c", "coverage run --source=src -m pytest -v && coverage report > /app/cov/coverage.txt && coverage xml -o /app/cov/coverage.xml && coverage html -d /app/cov/html" ] -# Stage 4: Migrations -FROM builder AS migrations +# Stage 7: Migrations +FROM base-builder AS migrations ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -92,14 +123,12 @@ WORKDIR /app RUN mkdir -p ./src/template_project -RUN uv sync --no-install-project --group migrations --frozen --no-cache - COPY ./src ./src COPY ./tests ./tests COPY ./alembic.ini ./ -RUN uv pip install -e . +RUN uv sync --group backend --group migrations --frozen --no-cache CMD [ "alembic", "upgrade", "head" ] diff --git a/Containerfile.ml b/Containerfile.ml deleted file mode 100644 index 2aa78a6..0000000 --- a/Containerfile.ml +++ /dev/null @@ -1,50 +0,0 @@ -# syntax=docker/dockerfile:1.20 - -ARG PY_IMAGE=docker.io/python:3.12-slim - -# Stage 1: Builder -FROM ${PY_IMAGE} AS builder - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - curl git \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ - -WORKDIR /app - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONOPTIMIZE=2 \ - UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy \ - UV_PROJECT_ENVIRONMENT=/opt/venv - -COPY pyproject.toml uv.lock ./ - -RUN uv sync --frozen --no-dev --no-cache --group ml - -# Stage 2: ML Runtime -FROM ${PY_IMAGE} AS ml - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - curl \ - && rm -rf /var/lib/apt/lists/* - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONOPTIMIZE=2 \ - PATH="/opt/venv/bin:$PATH" \ - PYTHONPATH="/app:$PYTHONPATH" - -WORKDIR /app - -COPY --from=builder /opt/venv /opt/venv - -COPY ./src/ ./ - -EXPOSE 8081 - -CMD [ "/opt/venv/bin/ml_api_cli", "/app/config.toml" ] diff --git a/compose.yaml b/compose.yaml index 8b160f9..3b4997a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -78,6 +78,8 @@ services: retries: 5 networks: - default + profiles: + - ml ports: - name: web target: 8081 diff --git a/src/template_project/adapters/data_gateways/key_skills.py b/src/template_project/adapters/data_gateways/key_skills.py index 3039d68..01e35d0 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 c84cf67..8daedd9 100644 --- a/src/template_project/adapters/data_gateways/resume.py +++ b/src/template_project/adapters/data_gateways/resume.py @@ -4,9 +4,28 @@ 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, 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, + ResumeExperience, + ResumeId, + ResumePrediction, + ResumeProject, +) from template_project.application.resume.errors import ResumeNotFoundError from template_project.application.user.entity import UserId @@ -25,12 +44,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() @@ -69,3 +83,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 15aaf61..d93fb04 100644 --- a/src/template_project/adapters/data_gateways/tables.py +++ b/src/template_project/adapters/data_gateways/tables.py @@ -22,9 +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 ExperienceType +from template_project.application.common.enums import EducationGrade, ExperienceType 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), ) vacancy_table: Final = Table( "vacancies", @@ -204,3 +248,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/adapters/vector_generators/resume.py b/src/template_project/adapters/vector_generators/resume.py index 7c202e9..a2590c9 100644 --- a/src/template_project/adapters/vector_generators/resume.py +++ b/src/template_project/adapters/vector_generators/resume.py @@ -1,8 +1,7 @@ from typing import Final, override -from template_project.application.common.enums import ExperienceType - from template_project.adapters.ml_client import MlApiGateway +from template_project.application.common.enums import ExperienceType from template_project.application.resume.vector_generator import ResumeEmbeddingVectorGenerator EMBEDDING_TEXT_TEMPLATE: Final = """ 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 80acfad..6c2ac63 100644 --- a/src/template_project/application/resume/data_gateway.py +++ b/src/template_project/application/resume/data_gateway.py @@ -4,8 +4,11 @@ from typing import Protocol from template_project.application.resume.entity import ( Resume, + ResumeEducation, + ResumeExperience, ResumeId, ResumePrediction, + ResumeProject, ) from template_project.application.user.entity import UserId @@ -32,3 +35,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/routes/predict.py b/src/template_project/ml/routes/predict.py index e678f30..8db98ef 100644 --- a/src/template_project/ml/routes/predict.py +++ b/src/template_project/ml/routes/predict.py @@ -42,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": { diff --git a/src/template_project/web_api/ioc/data_gateway.py b/src/template_project/web_api/ioc/data_gateway.py index 7b55d76..1ac7a80 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.data_gateways.vacancy import DefaultVacancyDataGateway from template_project.adapters.unit_of_work import DefaultUnitOfWork @@ -25,4 +31,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), )