не помнб

This commit is contained in:
ivankirpichnikov
2025-11-22 18:18:23 +03:00
19 changed files with 919 additions and 143 deletions
+12 -2
View File
@@ -179,7 +179,7 @@ cache:
policy: pull-push policy: pull-push
before_script: before_script:
- apt-get update - 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 - update-ca-certificates
- curl -LsSf https://astral.sh/uv/install.sh | sh - curl -LsSf https://astral.sh/uv/install.sh | sh
- export PATH="$HOME/.local/bin:$PATH" - export PATH="$HOME/.local/bin:$PATH"
@@ -227,7 +227,7 @@ build-ml:
<<: *build-config <<: *build-config
variables: variables:
IMAGE_NAME: $BASE_IMAGE_NAME/ml IMAGE_NAME: $BASE_IMAGE_NAME/ml
CONTAINERFILE: Containerfile.ml CONTAINERFILE: Containerfile
BUILDTARGET: ml BUILDTARGET: ml
lint: lint:
@@ -252,6 +252,8 @@ test:
--profile migrations --profile migrations
--profile tests --profile tests
PODMAN_IGNORE_CGROUPSV1_WARNING: True PODMAN_IGNORE_CGROUPSV1_WARNING: True
tags:
- self-hosted
script: script:
- dnf -y install podman podman-compose - dnf -y install podman podman-compose
# - cp ./infrastructure/configs/podman/ci.conf /etc/containers/containers.conf # - cp ./infrastructure/configs/podman/ci.conf /etc/containers/containers.conf
@@ -348,6 +350,14 @@ sast-image-migrations:
dependencies: dependencies:
- build-migrations - build-migrations
sast-image-ml:
<<: *trivy-image-scan
variables:
IMAGE_NAME: $BASE_IMAGE_NAME/ml
IMAGE_TYPE: ml
dependencies:
- build-ml
tag-runtime: tag-runtime:
<<: *tag-config <<: *tag-config
variables: variables:
+61 -32
View File
@@ -1,7 +1,14 @@
# syntax=docker/dockerfile:1.20 # syntax=docker/dockerfile:1.20
# Stage 1: Builder ARG PY_IMAGE=python:3.12-slim
FROM docker.io/python:3.12-slim AS builder
# 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/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
@@ -14,18 +21,30 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv 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 ./ 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 RUN uv sync --frozen --no-dev --no-cache --group backend
# Stage 3: ML Builder
FROM base-builder AS ml-builder
# Stage 2: Runtime COPY ./src ./src
FROM docker.io/python:3.12-alpine3.22 AS runtime
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 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@@ -35,28 +54,22 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apk add --no-cache --virtual .runtime-deps \ COPY --from=backend-builder /opt/venv /opt/venv
curl && \
rm -rf /var/cache/apk/*
RUN adduser -D -g '' app && chown -R app:app /app COPY ./src/ ./
USER app
COPY --from=builder --chown=app:app /opt/venv /opt/venv
COPY --chown=app:app ./src/ ./
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD [ "/opt/venv/bin/web_api_cli", "/app/config.toml" ]
CMD curl -fsS http://localhost:8080/healthcheck || exit 1
CMD [ "web_api_cli", "/app/config.toml" ]
# Stage 3: Testing # Stage 5: ML Runtime
FROM builder AS tests 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 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@@ -66,21 +79,39 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app 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 ./src ./src
COPY ./tests ./tests 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 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" ] 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 # Stage 7: Migrations
FROM builder AS migrations FROM base-builder AS migrations
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@@ -92,14 +123,12 @@ WORKDIR /app
RUN mkdir -p ./src/template_project RUN mkdir -p ./src/template_project
RUN uv sync --no-install-project --group migrations --frozen --no-cache
COPY ./src ./src COPY ./src ./src
COPY ./tests ./tests COPY ./tests ./tests
COPY ./alembic.ini ./ COPY ./alembic.ini ./
RUN uv pip install -e . RUN uv sync --group backend --group migrations --frozen --no-cache
CMD [ "alembic", "upgrade", "head" ] CMD [ "alembic", "upgrade", "head" ]
-50
View File
@@ -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" ]
+2
View File
@@ -78,6 +78,8 @@ services:
retries: 5 retries: 5
networks: networks:
- default - default
profiles:
- ml
ports: ports:
- name: web - name: web
target: 8081 target: 8081
@@ -23,8 +23,6 @@ class KeySkillsDataGateway:
return result.scalars().all() return result.scalars().all()
async def add_skills(self, name: list[str]) -> None: async def add_skills(self, name: list[str]) -> None:
insert_statement = insert(key_skills_table).values( insert_statement = insert(key_skills_table).values([{"name": _} for _ in name])
[{"name": _} for _ in name]
)
with contextlib.suppress(IntegrityError): with contextlib.suppress(IntegrityError):
await self._session.execute(insert_statement) await self._session.execute(insert_statement)
@@ -4,9 +4,28 @@ from typing import override
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from template_project.adapters.data_gateways.tables import resume_prediction_table, resume_table from template_project.adapters.data_gateways.tables import (
from template_project.application.resume.data_gateway import ResumeDataGateway, ResumePredictionDataGateway resume_education_table,
from template_project.application.resume.entity import Resume, ResumeId, ResumePrediction 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.resume.errors import ResumeNotFoundError
from template_project.application.user.entity import UserId from template_project.application.user.entity import UserId
@@ -25,12 +44,7 @@ class DefaultResumeDataGateway(ResumeDataGateway):
@override @override
async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]: async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]:
statement = ( statement = select(Resume).where(resume_table.c.user_id == user_id).limit(limit).offset(offset)
select(Resume)
.where(resume_table.c.user_id == user_id)
.limit(limit)
.offset(offset)
)
result = await self._session.execute(statement) result = await self._session.execute(statement)
return result.scalars().all() return result.scalars().all()
@@ -69,3 +83,36 @@ class DefaultResumePredictionDataGateway(ResumePredictionDataGateway):
statement = select(ResumePrediction).where(resume_prediction_table.c.resume_id == resume_id) statement = select(ResumePrediction).where(resume_prediction_table.c.resume_id == resume_id)
result = await self._session.execute(statement) result = await self._session.execute(statement)
return result.scalar() 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()
@@ -22,9 +22,16 @@ from sqlalchemy.orm import registry
from template_project.application.access_token.entity import AccessToken from template_project.application.access_token.entity import AccessToken
from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod 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.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.entity import User
from template_project.application.user.profile.entity import Profile 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("created_at", DateTime(timezone=True), nullable=False),
Column("user_id", UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False), Column("user_id", UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
Column("position", String, nullable=False), Column("position", String, nullable=False),
Column("location", String, nullable=False),
Column("about_me", String, nullable=False), Column("about_me", String, nullable=False),
Column("key_skills", StringArrayType(), nullable=False, server_default=text("'[]'::jsonb")), Column("key_skills", StringArrayType(), nullable=False, server_default=text("'[]'::jsonb")),
Column("experience_type", String, nullable=False), Column("experience_type", String, nullable=False),
@@ -158,7 +166,43 @@ key_skills_table: Final = Table(
"key_skills", "key_skills",
meta_data, meta_data,
Column("id", Integer, autoincrement=True, primary_key=True), 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( vacancy_table: Final = Table(
"vacancies", "vacancies",
@@ -204,3 +248,6 @@ mapper_registry.map_imperatively(
"recommended_skills": resume_prediction_table.c.recommended_skills, "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)
@@ -1,8 +1,7 @@
from typing import Final, override from typing import Final, override
from template_project.application.common.enums import ExperienceType
from template_project.adapters.ml_client import MlApiGateway 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 from template_project.application.resume.vector_generator import ResumeEmbeddingVectorGenerator
EMBEDDING_TEXT_TEMPLATE: Final = """ EMBEDDING_TEXT_TEMPLATE: Final = """
@@ -6,3 +6,14 @@ class ExperienceType(StrEnum):
BETWEEN_1_AND_3 = "between1And3" BETWEEN_1_AND_3 = "between1And3"
BETWEEN_3_AND_6 = "between3And6" BETWEEN_3_AND_6 = "between3And6"
MORE_THAN_6 = "moreThan6" 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"
@@ -4,8 +4,11 @@ from typing import Protocol
from template_project.application.resume.entity import ( from template_project.application.resume.entity import (
Resume, Resume,
ResumeEducation,
ResumeExperience,
ResumeId, ResumeId,
ResumePrediction, ResumePrediction,
ResumeProject,
) )
from template_project.application.user.entity import UserId from template_project.application.user.entity import UserId
@@ -32,3 +35,21 @@ class ResumePredictionDataGateway(Protocol):
@abstractmethod @abstractmethod
async def load_by_resume_id(self, resume_id: ResumeId) -> ResumePrediction | None: async def load_by_resume_id(self, resume_id: ResumeId) -> ResumePrediction | None:
raise NotImplementedError 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
@@ -6,19 +6,22 @@ from uuid import UUID
from uuid_utils.compat import uuid7 from uuid_utils.compat import uuid7
from template_project.application.common.entity import Entity, to_entity 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 from template_project.application.user.entity import UserId
ResumeId = NewType("ResumeId", UUID) ResumeId = NewType("ResumeId", UUID)
ResumeEmbeddingId = NewType("ResumeEmbeddingId", UUID) ResumeEmbeddingId = NewType("ResumeEmbeddingId", UUID)
ResumePredictionId = NewType("ResumePredictionId", UUID) ResumePredictionId = NewType("ResumePredictionId", UUID)
ResumeExperienceId = NewType("ResumeExperienceId", UUID)
ResumeEducationId = NewType("ResumeEducationId", UUID)
ResumeProjectId = NewType("ResumeProjectId", UUID)
@to_entity @to_entity
class Resume(Entity[ResumeId]): class Resume(Entity[ResumeId]):
user_id: UserId user_id: UserId
position: str position: str
# location: str location: str
about_me: str about_me: str
key_skills: list[str] key_skills: list[str]
experience_type: ExperienceType experience_type: ExperienceType
@@ -30,6 +33,7 @@ class Resume(Entity[ResumeId]):
cls, cls,
user_id: UserId, user_id: UserId,
position: str, position: str,
location: str,
about_me: str, about_me: str,
key_skills: list[str], key_skills: list[str],
experience_type: ExperienceType, experience_type: ExperienceType,
@@ -41,6 +45,7 @@ class Resume(Entity[ResumeId]):
created_at=datetime.now(tz=UTC), created_at=datetime.now(tz=UTC),
user_id=user_id, user_id=user_id,
position=position, position=position,
location=location,
about_me=about_me, about_me=about_me,
key_skills=key_skills, key_skills=key_skills,
experience_type=experience_type, experience_type=experience_type,
@@ -74,4 +79,78 @@ class ResumePrediction(Entity[ResumePredictionId]):
from_salary: Decimal from_salary: Decimal
to_salary: Decimal to_salary: Decimal
recommended_skills: list[str] 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,
)
@@ -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.identity_provider import IdentityProvider
from template_project.application.common.interactor import to_interactor from template_project.application.common.interactor import to_interactor
from template_project.application.common.unit_of_work import UnitOfWork from template_project.application.common.unit_of_work import UnitOfWork
from template_project.application.resume.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 @to_interactor
@@ -13,15 +41,20 @@ class AddResumeInteractor:
async def execute( async def execute(
self, self,
position: str, position: str,
location: str,
about_me: str, about_me: str,
key_skills: list[str], key_skills: list[str],
experience_type: ExperienceType, experience_type: ExperienceType,
experience: list[ExperienceInput] | None = None,
education: list[EducationInput] | None = None,
projects: list[ProjectInput] | None = None,
) -> ResumeId: ) -> ResumeId:
user = await self.identity_provider.get_current_user() user = await self.identity_provider.get_current_user()
resume = Resume.factory( resume = Resume.factory(
user_id=user.id, user_id=user.id,
position=position, position=position,
location=location,
about_me=about_me, about_me=about_me,
key_skills=key_skills, key_skills=key_skills,
experience_type=experience_type, experience_type=experience_type,
@@ -29,9 +62,38 @@ class AddResumeInteractor:
up_resume_id=None, up_resume_id=None,
) )
# TODO: тут надо сделать запуск фоновой таски для вычитывания подходящей вакансии
await self.unit_of_work.add(resume) 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() await self.unit_of_work.commit()
return resume.id return resume.id
@@ -1,20 +1,77 @@
from template_project.application.common.data_structure import to_data_structure 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.identity_provider import IdentityProvider
from template_project.application.common.interactor import to_interactor from template_project.application.common.interactor import to_interactor
from template_project.application.common.unit_of_work import UnitOfWork from template_project.application.common.unit_of_work import UnitOfWork
from template_project.application.resume.data_gateway import ResumeDataGateway from template_project.application.resume.data_gateway import (
from template_project.application.resume.entity import Resume, ResumeId ResumeDataGateway,
ResumeEducationDataGateway,
ResumeExperienceDataGateway,
ResumeProjectDataGateway,
)
from template_project.application.resume.entity import (
Resume,
ResumeEducation,
ResumeExperience,
ResumeId,
ResumeProject,
)
from template_project.application.resume.errors import ResumeDoesBelongUserError from template_project.application.resume.errors import ResumeDoesBelongUserError
@to_data_structure @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 id: ResumeId
position: str position: str
location: str
about_me: str about_me: str
key_skills: list[str] key_skills: list[str]
experience_type: ExperienceType experience_type: ExperienceType
experience: list[ExperienceItemResponse]
education: list[EducationItemResponse]
projects: list[ProjectItemResponse]
@to_interactor @to_interactor
@@ -22,21 +79,29 @@ class EditResumeInteractor:
unit_of_work: UnitOfWork unit_of_work: UnitOfWork
identity_provider: IdentityProvider identity_provider: IdentityProvider
resume_data_gateway: ResumeDataGateway resume_data_gateway: ResumeDataGateway
resume_experience_data_gateway: ResumeExperienceDataGateway
resume_education_data_gateway: ResumeEducationDataGateway
resume_project_data_gateway: ResumeProjectDataGateway
async def execute( async def execute(
self, self,
resume_id: ResumeId, resume_id: ResumeId,
position: str | None, position: str | None,
location: str | None,
about_me: str | None, about_me: str | None,
key_skills: list[str] | None, key_skills: list[str] | None,
experience_type: ExperienceType | 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() user = await self.identity_provider.get_current_user()
old_resume = await self.resume_data_gateway.load(resume_id) old_resume = await self.resume_data_gateway.load(resume_id)
if old_resume.user_id != user.id: if old_resume.user_id != user.id:
raise ResumeDoesBelongUserError raise ResumeDoesBelongUserError
new_position = position if position is not None else old_resume.position 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_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_key_skills = key_skills if key_skills is not None else old_resume.key_skills
new_experience_type = experience_type if experience_type is not None else old_resume.experience_type new_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( new_resume = Resume.factory(
user_id=user.id, user_id=user.id,
position=new_position, position=new_position,
location=new_location,
about_me=new_about_me, about_me=new_about_me,
key_skills=new_key_skills, key_skills=new_key_skills,
experience_type=new_experience_type, experience_type=new_experience_type,
@@ -53,15 +119,74 @@ class EditResumeInteractor:
await self.unit_of_work.add(new_resume) 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: if old_resume.up_resume_id is None:
old_resume.up_resume_id = new_resume.id old_resume.up_resume_id = new_resume.id
await self.unit_of_work.commit() 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, id=new_resume.id,
position=new_resume.position, position=new_resume.position,
location=new_resume.location,
about_me=new_resume.about_me, about_me=new_resume.about_me,
key_skills=new_resume.key_skills, key_skills=new_resume.key_skills,
experience_type=new_resume.experience_type, 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
],
) )
@@ -1,10 +1,16 @@
from decimal import Decimal from decimal import Decimal
from template_project.application.common.data_structure import to_data_structure 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.identity_provider import IdentityProvider
from template_project.application.common.interactor import to_interactor 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.entity import ResumeId
from template_project.application.resume.errors import ResumeDoesBelongUserError from template_project.application.resume.errors import ResumeDoesBelongUserError
@@ -17,12 +23,37 @@ class ResumePredictionResponse:
@to_data_structure @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 id: ResumeId
position: str position: str
location: str
about_me: str about_me: str
key_skills: list[str] key_skills: list[str]
experience_type: ExperienceType experience_type: ExperienceType
experience: list[ExperienceItemResponse]
education: list[EducationItemResponse]
projects: list[ProjectItemResponse]
prediction: ResumePredictionResponse | None prediction: ResumePredictionResponse | None
@@ -31,11 +62,14 @@ class GetResumeInteractor:
identity_provider: IdentityProvider identity_provider: IdentityProvider
resume_data_gateway: ResumeDataGateway resume_data_gateway: ResumeDataGateway
resume_prediction_data_gateway: ResumePredictionDataGateway resume_prediction_data_gateway: ResumePredictionDataGateway
resume_experience_data_gateway: ResumeExperienceDataGateway
resume_education_data_gateway: ResumeEducationDataGateway
resume_project_data_gateway: ResumeProjectDataGateway
async def execute( async def execute(
self, self,
resume_id: ResumeId, resume_id: ResumeId,
) -> _Response: ) -> GetResumeResponse:
user = await self.identity_provider.get_current_user() user = await self.identity_provider.get_current_user()
resume = await self.resume_data_gateway.load(resume_id) resume = await self.resume_data_gateway.load(resume_id)
@@ -53,12 +87,41 @@ class GetResumeInteractor:
else: else:
prediction = None 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, id=resume.id,
position=resume.position, position=resume.position,
location=resume.location,
about_me=resume.about_me, about_me=resume.about_me,
key_skills=resume.key_skills, key_skills=resume.key_skills,
experience_type=resume.experience_type, 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, prediction=prediction,
) )
@@ -67,6 +130,7 @@ class GetResumeInteractor:
class ResumeListItemResponse: class ResumeListItemResponse:
id: ResumeId id: ResumeId
position: str position: str
location: str
about_me: str about_me: str
key_skills: list[str] key_skills: list[str]
experience_type: ExperienceType experience_type: ExperienceType
@@ -86,6 +150,7 @@ class GetResumeListInteractor:
ResumeListItemResponse( ResumeListItemResponse(
id=r.id, id=r.id,
position=r.position, position=r.position,
location=r.location,
about_me=r.about_me, about_me=r.about_me,
key_skills=r.key_skills, key_skills=r.key_skills,
experience_type=r.experience_type, experience_type=r.experience_type,
@@ -112,6 +177,7 @@ class GetResumeHistoryInteractor:
ResumeListItemResponse( ResumeListItemResponse(
id=r.id, id=r.id,
position=r.position, position=r.position,
location=r.location,
about_me=r.about_me, about_me=r.about_me,
key_skills=r.key_skills, key_skills=r.key_skills,
experience_type=r.experience_type, experience_type=r.experience_type,
+1 -3
View File
@@ -42,9 +42,7 @@ class PredictSalaryRequestModel(BaseModel):
key_skills: list[str] = Field( key_skills: list[str] = Field(
min_length=1, description="List of key skills from resume", examples=[["Python", "FastAPI", "PostgreSQL"]] min_length=1, description="List of key skills from resume", examples=[["Python", "FastAPI", "PostgreSQL"]]
) )
vacancies: list[VacancyInputModel] = Field( vacancies: list[VacancyInputModel] = Field(min_length=1, description="List of relevant vacancies", examples=[[]])
min_length=1, description="List of relevant vacancies", examples=[[]]
)
model_config = { model_config = {
"json_schema_extra": { "json_schema_extra": {
@@ -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.key_skills import KeySkillsDataGateway
from template_project.adapters.data_gateways.notification_device import DefaultNotificationDeviceDataGateway 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.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.user import DefaultUserDataGateway
from template_project.adapters.data_gateways.vacancy import DefaultVacancyDataGateway from template_project.adapters.data_gateways.vacancy import DefaultVacancyDataGateway
from template_project.adapters.unit_of_work import DefaultUnitOfWork from template_project.adapters.unit_of_work import DefaultUnitOfWork
@@ -25,4 +31,7 @@ class DataGatewayProvider(Provider):
WithParents[DefaultNotificationDeviceDataGateway], WithParents[DefaultNotificationDeviceDataGateway],
WithParents[DefaultResumeDataGateway], WithParents[DefaultResumeDataGateway],
WithParents[DefaultResumePredictionDataGateway], WithParents[DefaultResumePredictionDataGateway],
WithParents[DefaultResumeExperienceDataGateway],
WithParents[DefaultResumeEducationDataGateway],
WithParents[DefaultResumeProjectDataGateway],
) )
+287 -4
View File
@@ -8,11 +8,21 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from pydantic import BaseModel, Field 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.entity import ResumeId
from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError
from template_project.application.resume.interactors.add import AddResumeInteractor from template_project.application.resume.interactors.add import (
from template_project.application.resume.interactors.edit import EditResumeInteractor 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 ( from template_project.application.resume.interactors.get import (
GetResumeHistoryInteractor, GetResumeHistoryInteractor,
GetResumeInteractor, GetResumeInteractor,
@@ -23,18 +33,81 @@ security = HTTPBearer()
router = APIRouter(route_class=DishkaRoute, tags=["Resume"], dependencies=[Depends(security)]) 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): class CreateResumeRequest(BaseModel):
position: str = Field(min_length=1, max_length=200, description="Job position", examples=["Python Developer"]) position: str = Field(min_length=1, max_length=200, description="Job position", examples=["Python Developer"])
about_me: str = Field( about_me: str = Field(
min_length=1, min_length=1,
max_length=2000, max_length=2000,
description="About me section", description="About me section",
examples=["Experienced Python developer"], examples=["Experienced Python developer with 5 years of experience"],
) )
key_skills: list[str] = Field( key_skills: list[str] = Field(
min_length=1, description="List of key skills", examples=[["Python", "FastAPI", "PostgreSQL"]] 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]) 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 = { model_config = {
"json_schema_extra": { "json_schema_extra": {
@@ -43,6 +116,28 @@ class CreateResumeRequest(BaseModel):
"about_me": "Experienced Python developer with 5 years of experience", "about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
"experience_type": "between3And6", "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): class ResumeResponse(BaseModel):
id: ResumeId = Field(description="Resume ID") id: ResumeId = Field(description="Resume ID")
position: str = Field(description="Job position") position: str = Field(description="Job position")
location: str = Field(description="Location")
about_me: str = Field(description="About me section") about_me: str = Field(description="About me section")
key_skills: list[str] = Field(description="List of key skills") key_skills: list[str] = Field(description="List of key skills")
experience_type: ExperienceType = Field(description="Experience type") 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)") prediction: SalaryPrediction | None = Field(None, description="Salary prediction (can be null)")
model_config = { model_config = {
"json_schema_extra": { "json_schema_extra": {
"example": { "example": {
"position": "Python Developer", "position": "Python Developer",
"location": "Moscow",
"about_me": "Experienced Python developer with 5 years of experience", "about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
"experience_type": "between3And6", "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": { "prediction": {
"from_salary": "100000", "from_salary": "100000",
"to_salary": "150000", "to_salary": "150000",
@@ -98,6 +219,7 @@ class ResumeResponse(BaseModel):
class ResumeListItem(BaseModel): class ResumeListItem(BaseModel):
id: ResumeId = Field(description="Resume ID") id: ResumeId = Field(description="Resume ID")
position: str = Field(description="Job position") position: str = Field(description="Job position")
location: str = Field(description="Location")
about_me: str = Field(description="About me section") about_me: str = Field(description="About me section")
key_skills: list[str] = Field(description="List of key skills") key_skills: list[str] = Field(description="List of key skills")
experience_type: ExperienceType = Field(description="Experience type") experience_type: ExperienceType = Field(description="Experience type")
@@ -106,6 +228,7 @@ class ResumeListItem(BaseModel):
"json_schema_extra": { "json_schema_extra": {
"example": { "example": {
"position": "Python Developer", "position": "Python Developer",
"location": "Moscow",
"about_me": "Experienced Python developer with 5 years of experience", "about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL"], "key_skills": ["Python", "FastAPI", "PostgreSQL"],
"experience_type": "between3And6", "experience_type": "between3And6",
@@ -175,11 +298,39 @@ async def create_resume(
request: CreateResumeRequest, request: CreateResumeRequest,
interactor: FromDishka[AddResumeInteractor], interactor: FromDishka[AddResumeInteractor],
) -> CreateResumeResponse: ) -> 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( interactor_response = await interactor.execute(
position=request.position, position=request.position,
location=request.location,
about_me=request.about_me, about_me=request.about_me,
key_skills=request.key_skills, key_skills=request.key_skills,
experience_type=request.experience_type, experience_type=request.experience_type,
experience=experience,
education=education,
projects=projects,
) )
return CreateResumeResponse( return CreateResumeResponse(
resume_id=interactor_response, resume_id=interactor_response,
@@ -206,6 +357,7 @@ async def get_resume_list(
ResumeListItem( ResumeListItem(
id=r.id, id=r.id,
position=r.position, position=r.position,
location=r.location,
about_me=r.about_me, about_me=r.about_me,
key_skills=r.key_skills, key_skills=r.key_skills,
experience_type=r.experience_type, experience_type=r.experience_type,
@@ -246,9 +398,34 @@ async def get_resume(
return ResumeResponse( return ResumeResponse(
id=interactor_response.id, id=interactor_response.id,
position=interactor_response.position, position=interactor_response.position,
location=interactor_response.location,
about_me=interactor_response.about_me, about_me=interactor_response.about_me,
key_skills=interactor_response.key_skills, key_skills=interactor_response.key_skills,
experience_type=interactor_response.experience_type, 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( prediction=SalaryPrediction(
from_salary=interactor_response.prediction.from_salary, from_salary=interactor_response.prediction.from_salary,
to_salary=interactor_response.prediction.to_salary, to_salary=interactor_response.prediction.to_salary,
@@ -263,6 +440,7 @@ class PatchResumeRequest(BaseModel):
position: str | None = Field( position: str | None = Field(
None, min_length=1, max_length=200, description="Job position", examples=["Python Developer"] 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( about_me: str | None = Field(
None, min_length=1, max_length=2000, description="About me section", examples=["Experienced Python developer"] 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( experience_type: ExperienceType | None = Field(
None, description="Experience type", examples=[ExperienceType.BETWEEN_3_AND_6] 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 = { model_config = {
"json_schema_extra": { "json_schema_extra": {
"example": { "example": {
"position": "Python Developer", "position": "Python Developer",
"location": "Moscow",
"about_me": "Experienced Python developer with 5 years of experience", "about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
"experience_type": "between3And6", "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): class PatchResumeResponse(BaseModel):
id: ResumeId = Field(description="Resume ID") id: ResumeId = Field(description="Resume ID")
position: str = Field(description="Job position") position: str = Field(description="Job position")
location: str = Field(description="Location")
about_me: str = Field(description="About me section") about_me: str = Field(description="About me section")
key_skills: list[str] = Field(description="List of key skills") key_skills: list[str] = Field(description="List of key skills")
experience_type: ExperienceType = Field(description="Experience type") 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 = { model_config = {
"json_schema_extra": { "json_schema_extra": {
"example": { "example": {
"position": "Python Developer", "position": "Python Developer",
"location": "Moscow",
"about_me": "Experienced Python developer with 5 years of experience", "about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], "key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
"experience_type": "between3And6", "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( ResumeListItem(
id=r.id, id=r.id,
position=r.position, position=r.position,
location=r.location,
about_me=r.about_me, about_me=r.about_me,
key_skills=r.key_skills, key_skills=r.key_skills,
experience_type=r.experience_type, experience_type=r.experience_type,
@@ -363,12 +593,40 @@ async def patch_resume(
interactor: FromDishka[EditResumeInteractor], interactor: FromDishka[EditResumeInteractor],
) -> PatchResumeResponse: ) -> PatchResumeResponse:
try: 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( interactor_response = await interactor.execute(
resume_id=resume_id, resume_id=resume_id,
position=request.position, position=request.position,
location=request.location,
about_me=request.about_me, about_me=request.about_me,
key_skills=request.key_skills, key_skills=request.key_skills,
experience_type=request.experience_type, experience_type=request.experience_type,
experience=experience,
education=education,
projects=projects,
) )
except ResumeDoesBelongUserError as error: except ResumeDoesBelongUserError as error:
raise HTTPException( raise HTTPException(
@@ -384,7 +642,32 @@ async def patch_resume(
return PatchResumeResponse( return PatchResumeResponse(
id=interactor_response.id, id=interactor_response.id,
position=interactor_response.position, position=interactor_response.position,
location=interactor_response.location,
about_me=interactor_response.about_me, about_me=interactor_response.about_me,
key_skills=interactor_response.key_skills, key_skills=interactor_response.key_skills,
experience_type=interactor_response.experience_type, 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
],
) )
+16
View File
@@ -33,6 +33,7 @@ async def test_success_add_resume(
experience_type="noExperience", experience_type="noExperience",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
position="Position", position="Position",
location="Moscow",
) )
assert is_success_response(response) assert is_success_response(response)
assert response.json() == IsDict( assert response.json() == IsDict(
@@ -54,6 +55,7 @@ async def test_unauthorized_add_resume(
experience_type="noExperience", experience_type="noExperience",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
position="Position", position="Position",
location="Moscow",
) )
assert is_unauthorized_response(response) assert is_unauthorized_response(response)
@@ -75,6 +77,7 @@ async def test_success_get_resume(
experience_type="noExperience", experience_type="noExperience",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
position="Position", position="Position",
location="Moscow",
) )
response = await test_api_gateway.get_resume( response = await test_api_gateway.get_resume(
@@ -84,9 +87,13 @@ async def test_success_get_resume(
assert is_success_response(response) assert is_success_response(response)
assert response.json() == IsPartialDict( assert response.json() == IsPartialDict(
position="Position", position="Position",
location="Moscow",
about_me="About me", about_me="About me",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
experience_type="noExperience", experience_type="noExperience",
experience=[],
education=[],
projects=[],
prediction=None, prediction=None,
) )
@@ -108,6 +115,7 @@ async def test_unauthorized_get_resume(
experience_type="noExperience", experience_type="noExperience",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
position="Position", position="Position",
location="Moscow",
) )
response = await test_api_gateway.get_resume( response = await test_api_gateway.get_resume(
@@ -152,6 +160,7 @@ async def test_success_edit_resume(
experience_type="noExperience", experience_type="noExperience",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
position="Position", position="Position",
location="Moscow",
) )
resume_id = response.json()["resume_id"] resume_id = response.json()["resume_id"]
response = await test_api_gateway.edit_resume( response = await test_api_gateway.edit_resume(
@@ -161,13 +170,18 @@ async def test_success_edit_resume(
experience_type="between1And3", experience_type="between1And3",
key_skills=["i love python"], key_skills=["i love python"],
position="Updated Position", position="Updated Position",
location="St. Petersburg",
) )
assert is_success_response(response) assert is_success_response(response)
assert response.json() == IsPartialDict( assert response.json() == IsPartialDict(
position="Updated Position", position="Updated Position",
location="St. Petersburg",
about_me="Updated about me", about_me="Updated about me",
key_skills=["i love python"], key_skills=["i love python"],
experience_type="between1And3", experience_type="between1And3",
experience=[],
education=[],
projects=[],
) )
@@ -188,6 +202,7 @@ async def test_unauthorized_edit_resume(
experience_type="noExperience", experience_type="noExperience",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
position="Position", position="Position",
location="Moscow",
) )
resume_id = response.json()["resume_id"] resume_id = response.json()["resume_id"]
response = await test_api_gateway.edit_resume( response = await test_api_gateway.edit_resume(
@@ -239,6 +254,7 @@ async def test_forbidden_edit_resume(
experience_type="noExperience", experience_type="noExperience",
key_skills=["i love lisp", "i love rust"], key_skills=["i love lisp", "i love rust"],
position="Position", position="Position",
location="Moscow",
) )
response_other_sign_up = await test_api_gateway.sign_up_email("f" + unique_email, DEFAULT_PASSWORD) response_other_sign_up = await test_api_gateway.sign_up_email("f" + unique_email, DEFAULT_PASSWORD)
+40 -16
View File
@@ -58,18 +58,30 @@ class TestApiGateway:
self, self,
access_token: str, access_token: str,
position: str, position: str,
location: str,
about_me: str, about_me: str,
key_skills: list[str], key_skills: list[str],
experience_type: 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: ) -> 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( return await self._client.post(
"/resume", "/resume",
json={ json=json_data,
"position": position,
"about_me": about_me,
"key_skills": key_skills,
"experience_type": experience_type,
},
headers=make_auth_headers(access_token), headers=make_auth_headers(access_token),
) )
@@ -84,22 +96,34 @@ class TestApiGateway:
access_token: str, access_token: str,
resume_id: str, resume_id: str,
position: str | None = None, position: str | None = None,
location: str | None = None,
about_me: str | None = None, about_me: str | None = None,
key_skills: list[str] | None = None, key_skills: list[str] | None = None,
experience_type: 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: ) -> 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( return await self._client.patch(
f"/resume/{resume_id}", f"/resume/{resume_id}",
json={ json=json_data,
key: value
for key, value in {
"position": position,
"about_me": about_me,
"key_skills": key_skills,
"experience_type": experience_type,
}.items()
if value is not None
},
headers=make_auth_headers(access_token), headers=make_auth_headers(access_token),
) )