diff --git a/src/template_project/adapters/data_gateways/key_skills.py b/src/template_project/adapters/data_gateways/key_skills.py new file mode 100644 index 0000000..09c3a23 --- /dev/null +++ b/src/template_project/adapters/data_gateways/key_skills.py @@ -0,0 +1,27 @@ +from collections.abc import Sequence + +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from template_project.adapters.data_gateways.tables import key_skills_table + + +class KeySkillsDataGateway: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def query(self, query: str) -> Sequence[str]: + statement = ( + select(key_skills_table.c.name) + .where(key_skills_table.c.name.ilike(f"{query}%")) + .order_by(key_skills_table.c.name) + .limit(30) + ) + result = await self._session.execute(statement) + return result.scalars().all() + + async def add_skills(self, name: list[str]) -> None: + insert_statement = insert(key_skills_table).values( + [{"name": _} for _ in name] + ) + await self._session.execute(insert_statement) diff --git a/src/template_project/adapters/data_gateways/tables.py b/src/template_project/adapters/data_gateways/tables.py index 40d3e98..dae2a87 100644 --- a/src/template_project/adapters/data_gateways/tables.py +++ b/src/template_project/adapters/data_gateways/tables.py @@ -8,6 +8,7 @@ from sqlalchemy import ( DateTime, Enum, ForeignKey, + Integer, MetaData, Numeric, String, @@ -118,6 +119,12 @@ resume_prediction_table: Final = Table( Column("to_salary", Numeric, nullable=False), Column("recommended_skills", ARRAY(String, as_tuple=True), nullable=False), ) +key_skills_table: Final = Table( + "key_skills", + meta_data, + Column("id", Integer, autoincrement=True, primary_key=True), + Column("name", String, nullable=False, unique=True) +) mapper_registry.map_imperatively(User, user_table) diff --git a/src/template_project/migrations/versions/2ebcb2592cab_add_key_skills.py b/src/template_project/migrations/versions/2ebcb2592cab_add_key_skills.py new file mode 100644 index 0000000..0460581 --- /dev/null +++ b/src/template_project/migrations/versions/2ebcb2592cab_add_key_skills.py @@ -0,0 +1,37 @@ +"""add key skills + +Revision ID: 2ebcb2592cab +Revises: 5b7a1ca1f06b +Create Date: 2025-11-22 03:59:55.147083 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2ebcb2592cab' +down_revision: Union[str, Sequence[str], None] = '5b7a1ca1f06b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('key_skills', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('key_skills') + # ### end Alembic commands ### diff --git a/src/template_project/web_api/entry_point.py b/src/template_project/web_api/entry_point.py index 70a9e4c..0ddb565 100644 --- a/src/template_project/web_api/entry_point.py +++ b/src/template_project/web_api/entry_point.py @@ -21,7 +21,7 @@ from prometheus_fastapi_instrumentator import Instrumentator from template_project.application.auth_identity.errors import AuthError from template_project.web_api.configuration import Configuration, load_configuration from template_project.web_api.ioc.make import make_ioc -from template_project.web_api.routes import auth, healthcheck, notification, profile, resume, storage +from template_project.web_api.routes import auth, healthcheck, key_skills, notification, profile, resume, storage LOG_CONFIG: Final = { "version": 1, @@ -82,6 +82,7 @@ def make_asgi_application( app.include_router(auth.router) app.include_router(healthcheck.router) app.include_router(profile.router) + app.include_router(key_skills.router) app.include_router(notification.router) app.include_router(storage.router) app.include_router(resume.router) diff --git a/src/template_project/web_api/ioc/data_gateway.py b/src/template_project/web_api/ioc/data_gateway.py index a62f635..6b36472 100644 --- a/src/template_project/web_api/ioc/data_gateway.py +++ b/src/template_project/web_api/ioc/data_gateway.py @@ -2,6 +2,7 @@ from dishka import BaseScope, Provider, Scope, WithParents, provide, provide_all from template_project.adapters.data_gateways.access_token import DefaultAccessTokenDataGateway from template_project.adapters.data_gateways.auth_identity import DefaultAuthIdentityDataGateway +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 @@ -14,6 +15,7 @@ class DataGatewayProvider(Provider): unit_of_work = provide(WithParents[DefaultUnitOfWork]) data_gateways = provide_all( + KeySkillsDataGateway, WithParents[DefaultUserDataGateway], WithParents[DefaultAccessTokenDataGateway], WithParents[DefaultAuthIdentityDataGateway], diff --git a/src/template_project/web_api/routes/key_skills.py b/src/template_project/web_api/routes/key_skills.py new file mode 100644 index 0000000..e71e328 --- /dev/null +++ b/src/template_project/web_api/routes/key_skills.py @@ -0,0 +1,42 @@ +from collections.abc import Sequence +from typing import Annotated + +from dishka import FromDishka +from dishka.integrations.fastapi import DishkaRoute +from fastapi import APIRouter, Query +from pydantic import BaseModel, Field + +from template_project.adapters.data_gateways.key_skills import KeySkillsDataGateway +from template_project.application.common.unit_of_work import UnitOfWork + +router = APIRouter( + route_class=DishkaRoute, + tags=["Key Skills"], +) + + +class KeySkill(BaseModel): + name: str = Field(description="The name of the key skill") + + +@router.get("/key_skills") +async def query_key_skills( + query: Annotated[str, Query()], + data_gateway: FromDishka[KeySkillsDataGateway], +) -> Sequence[KeySkill]: + skills = await data_gateway.query(query) + return [KeySkill(name=skill) for skill in skills] + + +class AddKeySkillsRequest(BaseModel): + key_skills: list[str] + + +@router.post("/key_skills") +async def add_key_skill( + request: AddKeySkillsRequest, + unit_of_work: FromDishka[UnitOfWork], + data_gateway: FromDishka[KeySkillsDataGateway], +) -> None: + await data_gateway.add_skills(request.key_skills) + await unit_of_work.commit() diff --git a/tests/web_api/e2e/test_key_skills.py b/tests/web_api/e2e/test_key_skills.py new file mode 100644 index 0000000..47f4bdb --- /dev/null +++ b/tests/web_api/e2e/test_key_skills.py @@ -0,0 +1,29 @@ +from dishka import FromDishka + +from tests.web_api.helpers import is_success_response +from tests.web_api.ioc import DatabaseClearer, inject +from tests.web_api.test_api_gateway import TestApiGateway + + +@inject +async def test_add_key_skill( + client: FromDishka[TestApiGateway], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + response = await client.add_key_skill(key_skills=["Python", "Django", "REST APIs"]) + assert is_success_response(response) + + +@inject +async def test_search_key_skills( + client: FromDishka[TestApiGateway], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + await client.add_key_skill(key_skills=["Python", "Django", "Python3.12", "REST APIs"]) + response = await client.search_key_skills("p") + assert is_success_response(response) + assert {name["name"] for name in response.json()} == {"Python", "Python3.12"} diff --git a/tests/web_api/test_api_gateway.py b/tests/web_api/test_api_gateway.py index b24f81c..52f3697 100644 --- a/tests/web_api/test_api_gateway.py +++ b/tests/web_api/test_api_gateway.py @@ -102,3 +102,18 @@ class TestApiGateway: }, headers=make_auth_headers(access_token), ) + + async def add_key_skill( + self, + key_skills: list[str], + ) -> Response: + return await self._client.post( + "/key_skills", + json={"key_skills": key_skills}, + ) + + async def search_key_skills(self, query: str) -> Response: + return await self._client.get( + "/key_skills", + params={"query": query}, + )