Merge branch 'main' of gitlab.prodcontest.com:team-39/backend

* 'main' of gitlab.prodcontest.com:team-39/backend:
  feat(): openapi docs for resume routes
  feat(): add validation and documentation to input schemas
This commit is contained in:
ITQ
2025-11-21 20:14:20 +03:00
9 changed files with 474 additions and 57 deletions
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"aioboto3==15.5.0",
"prometheus-fastapi-instrumentator>=7.1.0",
"python-multipart>=0.0.20",
"pydantic[email]>=2.12.4",
]
[dependency-groups]
+2 -1
View File
@@ -19,7 +19,7 @@ from prometheus_fastapi_instrumentator import Instrumentator
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, storage
from template_project.web_api.routes import auth, healthcheck, notification, profile, resume, storage
LOG_CONFIG: Final = {
"version": 1,
@@ -74,6 +74,7 @@ def make_asgi_application(
app.include_router(profile.router)
app.include_router(notification.router)
app.include_router(storage.router)
app.include_router(resume.router)
Instrumentator().instrument(app).expose(app)
setup_dishka(container=ioc, app=app)
+67 -15
View File
@@ -1,7 +1,7 @@
from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, SecretStr
from pydantic import BaseModel, EmailStr, Field, SecretStr
from template_project.application.auth_identity.errors import (
AuthError,
@@ -18,15 +18,29 @@ router = APIRouter(route_class=DishkaRoute, tags=["Auth"])
class EmailSignUpRequest(BaseModel):
email: str
password: SecretStr
email: EmailStr = Field(..., description="User email address", examples=["user@example.com"])
password: SecretStr = Field(
..., min_length=8, description="User password (minimum 8 characters)", examples=["password123"]
)
model_config = {"json_schema_extra": {"example": {"email": "user@example.com", "password": "password123"}}}
class EmailSignUpResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_up/email")
@router.post(
"/auth/sign_up/email",
summary="Sign up with email",
description="Register a new user with email and password",
responses={
200: {"description": "User successfully registered", "model": EmailSignUpResponse},
409: {"description": "User with this email already exists"},
},
)
async def sign_up_email(
request: EmailSignUpRequest,
interactor: FromDishka[SignUpInteractor],
@@ -44,14 +58,26 @@ async def sign_up_email(
class YandexSignUpRequest(BaseModel):
code: str
code: str = Field(..., min_length=1, description="Yandex OAuth authorization code", examples=["abc123def456"])
model_config = {"json_schema_extra": {"example": {"code": "abc123def456"}}}
class YandexSignUpResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_up/yandex")
@router.post(
"/auth/sign_up/yandex",
summary="Sign up with Yandex",
description="Register a new user using Yandex OAuth",
responses={
200: {"description": "User successfully registered", "model": YandexSignUpResponse},
401: {"description": "Authentication failed - invalid code or OAuth error"},
},
)
async def sign_up_yandex(
request: YandexSignUpRequest,
interactor: FromDishka[SignUpInteractor],
@@ -66,15 +92,28 @@ async def sign_up_yandex(
class EmailSignInRequest(BaseModel):
email: str
password: SecretStr
email: EmailStr = Field(..., description="User email address", examples=["user@example.com"])
password: SecretStr = Field(..., min_length=1, description="User password", examples=["password123"])
model_config = {"json_schema_extra": {"example": {"email": "user@example.com", "password": "password123"}}}
class EmailSignInResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_in/email")
@router.post(
"/auth/sign_in/email",
summary="Sign in with email",
description="Authenticate user with email and password",
responses={
200: {"description": "User successfully authenticated", "model": EmailSignInResponse},
401: {"description": "Invalid credentials"},
404: {"description": "User not found"},
},
)
async def sign_in_email(
request: EmailSignInRequest,
interactor: FromDishka[SignInInteractor],
@@ -94,14 +133,27 @@ async def sign_in_email(
class YandexSignInRequest(BaseModel):
code: str
code: str = Field(..., min_length=1, description="Yandex OAuth authorization code", examples=["abc123def456"])
model_config = {"json_schema_extra": {"example": {"code": "abc123def456"}}}
class YandexSignInResponse(BaseModel):
access_token: str
access_token: str = Field(..., description="JWT access token for authentication")
model_config = {"json_schema_extra": {"example": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}
@router.post("/auth/sign_in/yandex")
@router.post(
"/auth/sign_in/yandex",
summary="Sign in with Yandex",
description="Authenticate user using Yandex OAuth",
responses={
200: {"description": "User successfully authenticated", "model": YandexSignInResponse},
401: {"description": "Invalid authorization code or authentication failed"},
404: {"description": "User not found"},
},
)
async def sign_in_yandex(
request: YandexSignInRequest,
interactor: FromDishka[SignInInteractor],
@@ -1,14 +1,23 @@
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import BaseModel, Field
router = APIRouter(route_class=DishkaRoute, tags=["Health"])
class HealthcheckResponse(BaseModel):
ok: bool
ok: bool = Field(..., description="Service health status")
model_config = {"json_schema_extra": {"example": {"ok": True}}}
@router.get("/healthcheck")
@router.get(
"/healthcheck",
summary="Health check",
description="Check if the service is running and healthy",
responses={
200: {"description": "Service is healthy", "model": HealthcheckResponse},
},
)
async def healthcheck() -> HealthcheckResponse:
return HealthcheckResponse(ok=True)
@@ -2,7 +2,7 @@ from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer
from pydantic import BaseModel
from pydantic import BaseModel, Field
from template_project.application.notification_device.errors import NotificationDeviceNotFoundError
from template_project.application.notification_device.interactors.register_device import (
@@ -19,15 +19,36 @@ router = APIRouter(route_class=DishkaRoute, tags=["Notifications"], dependencies
class SendNotificationRequestModel(BaseModel):
title: str
body: str
title: str = Field(..., min_length=1, max_length=100, description="Notification title", examples=["New Message"])
body: str = Field(
..., min_length=1, max_length=500, description="Notification body text", examples=["You have a new message"]
)
model_config = {"json_schema_extra": {"example": {"title": "New Message", "body": "You have a new message"}}}
class RegisterNotificationDeviceRequestModel(BaseModel):
device_id: str
device_id: str = Field(
...,
min_length=1,
max_length=200,
description="Device ID for push notifications (FCM token)",
examples=["fcm_token_123456789"],
)
model_config = {"json_schema_extra": {"example": {"device_id": "fcm_token_123456789"}}}
@router.post("/notifications/send")
@router.post(
"/notifications/send",
summary="Send notification",
description="Send a push notification to the current user's registered device",
responses={
200: {"description": "Notification sent successfully"},
401: {"description": "Unauthorized - invalid or missing token"},
404: {"description": "Notification device not found - user needs to register device first"},
},
)
async def send_notification(
request: SendNotificationRequestModel,
interactor: FromDishka[NotificationInteractor],
@@ -39,7 +60,15 @@ async def send_notification(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification device not found") from error
@router.post("/notifications/register_device")
@router.post(
"/notifications/register_device",
summary="Register notification device",
description="Register a device ID (FCM token) for push notifications for the current user",
responses={
200: {"description": "Device registered successfully"},
401: {"description": "Unauthorized - invalid or missing token"},
},
)
async def register_notification_device(
request: RegisterNotificationDeviceRequestModel,
interactor: FromDishka[RegisterNotificationDeviceInteractor],
+84 -24
View File
@@ -2,7 +2,7 @@ from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr, Field, HttpUrl
from template_project.application.user.entity import UserId
from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor
@@ -16,16 +16,39 @@ router = APIRouter(route_class=DishkaRoute, tags=["Profile"], dependencies=[Depe
class GetProfileResponse(BaseModel):
id: UserId
email: str | None
display_name: str | None
first_name: str | None
last_name: str | None
avatar_url: str | None
phone: str | None
id: UserId = Field(..., description="User ID")
email: str | None = Field(None, description="User email address")
display_name: str | None = Field(None, description="User display name")
first_name: str | None = Field(None, description="User first name")
last_name: str | None = Field(None, description="User last name")
avatar_url: str | None = Field(None, description="URL to user avatar")
phone: str | None = Field(None, description="User phone number")
model_config = {
"json_schema_extra": {
"example": {
"id": "01234567-89ab-cdef-0123-456789abcdef",
"email": "user@example.com",
"display_name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"avatar_url": "https://example.com/avatar.jpg",
"phone": "+1234567890",
}
}
}
@router.get("/profile")
@router.get(
"/profile",
summary="Get user profile",
description="Retrieve the current authenticated user's profile information",
responses={
200: {"description": "Profile retrieved successfully", "model": GetProfileResponse},
401: {"description": "Unauthorized - invalid or missing token"},
404: {"description": "Profile not found"},
},
)
async def get_profile(
interactor: FromDishka[GetProfileInteractor],
) -> GetProfileResponse:
@@ -44,25 +67,62 @@ async def get_profile(
class PatchProfileRequestModel(BaseModel):
email: str | None = None
display_name: str | None = None
first_name: str | None = None
last_name: str | None = None
avatar_url: str | None = None
phone: str | None = None
email: EmailStr | None = Field(None, description="User email address", examples=["user@example.com"])
display_name: str | None = Field(None, max_length=100, description="User display name", examples=["John Doe"])
first_name: str | None = Field(None, max_length=50, description="User first name", examples=["John"])
last_name: str | None = Field(None, max_length=50, description="User last name", examples=["Doe"])
avatar_url: HttpUrl | None = Field(
None, description="URL to user avatar", examples=["https://example.com/avatar.jpg"]
)
phone: str | None = Field(None, max_length=20, description="User phone number", examples=["+1234567890"])
model_config = {
"json_schema_extra": {
"example": {
"email": "user@example.com",
"display_name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"avatar_url": "https://example.com/avatar.jpg",
"phone": "+1234567890",
}
}
}
class PatchProfileResponse(BaseModel):
id: UserId
email: str | None
display_name: str | None
first_name: str | None
last_name: str | None
avatar_url: str | None
phone: str | None
id: UserId = Field(..., description="User ID")
email: str | None = Field(None, description="User email address")
display_name: str | None = Field(None, description="User display name")
first_name: str | None = Field(None, description="User first name")
last_name: str | None = Field(None, description="User last name")
avatar_url: str | None = Field(None, description="URL to user avatar")
phone: str | None = Field(None, description="User phone number")
model_config = {
"json_schema_extra": {
"example": {
"id": "01234567-89ab-cdef-0123-456789abcdef",
"email": "user@example.com",
"display_name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"avatar_url": "https://example.com/avatar.jpg",
"phone": "+1234567890",
}
}
}
@router.patch("/profile")
@router.patch(
"/profile",
summary="Update user profile",
description="Update the current authenticated user's profile information. All fields are optional.",
responses={
200: {"description": "Profile updated successfully", "model": PatchProfileResponse},
401: {"description": "Unauthorized - invalid or missing token"},
},
)
async def patch_profile(
request: PatchProfileRequestModel,
interactor: FromDishka[PatchProfileInteractor],
@@ -72,7 +132,7 @@ async def patch_profile(
display_name=request.display_name,
first_name=request.first_name,
last_name=request.last_name,
avatar_url=request.avatar_url,
avatar_url=str(request.avatar_url) if request.avatar_url else None,
phone=request.phone,
)
response = await interactor.execute(patch_request)
@@ -0,0 +1,224 @@
from decimal import Decimal
from enum import StrEnum
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, Query
from fastapi.security import HTTPBearer
from pydantic import BaseModel, Field
security = HTTPBearer()
router = APIRouter(route_class=DishkaRoute, tags=["Resume"], dependencies=[Depends(security)])
class ExperienceTypeEnum(StrEnum):
NO_EXPERIENCE = "no_experience"
BETWEEN_1_AND_3 = "between_1_and_3"
BETWEEN_3_AND_6 = "between_3_and_6"
MORE_THAN_6 = "more_than_6"
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"]
)
key_skills: list[str] = Field(
..., min_length=1, description="List of key skills", examples=[["Python", "FastAPI", "PostgreSQL"]]
)
experience_type: ExperienceTypeEnum = Field(
..., description="Experience type", examples=[ExperienceTypeEnum.BETWEEN_3_AND_6]
)
model_config = {
"json_schema_extra": {
"example": {
"position": "Python Developer",
"about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
"experience_type": "between_3_and_6",
}
}
}
class CreateResumeResponse(BaseModel):
resume_id: str = Field(..., description="Created resume ID")
model_config = {"json_schema_extra": {"example": {"resume_id": "01234567-89ab-cdef-0123-456789abcdef"}}}
class SalaryPrediction(BaseModel):
from_salary: Decimal = Field(..., description="Minimum predicted salary", examples=[Decimal(100000)])
to_salary: Decimal = Field(..., description="Maximum predicted salary", examples=[Decimal(150000)])
recommended_skills: list[str] = Field(
..., description="Recommended skills to add", examples=[["Kubernetes", "Redis"]]
)
model_config = {
"json_schema_extra": {
"example": {
"from_salary": "100000",
"to_salary": "150000",
"recommended_skills": ["Kubernetes", "Redis"],
}
}
}
class ResumeResponse(BaseModel):
position: str = Field(..., description="Job position")
about_me: str = Field(..., description="About me section")
key_skills: list[str] = Field(..., description="List of key skills")
experience_type: ExperienceTypeEnum = Field(..., description="Experience type")
prediction: SalaryPrediction | None = Field(None, description="Salary prediction (can be null)")
model_config = {
"json_schema_extra": {
"example": {
"position": "Python Developer",
"about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
"experience_type": "between_3_and_6",
"prediction": {
"from_salary": "100000",
"to_salary": "150000",
"recommended_skills": ["Kubernetes", "Redis"],
},
}
}
}
class ResumeListItem(BaseModel):
position: str = Field(..., description="Job position")
about_me: str = Field(..., description="About me section")
key_skills: list[str] = Field(..., description="List of key skills")
experience_type: ExperienceTypeEnum = Field(..., description="Experience type")
model_config = {
"json_schema_extra": {
"example": {
"position": "Python Developer",
"about_me": "Experienced Python developer with 5 years of experience",
"key_skills": ["Python", "FastAPI", "PostgreSQL"],
"experience_type": "between_3_and_6",
}
}
}
class GetResumeListRequest(BaseModel):
limit: int = Field(..., ge=1, le=100, description="Number of resumes to return", examples=[10])
offset: int = Field(..., ge=0, description="Number of resumes to skip", examples=[0])
class GetResumeListResponse(BaseModel):
resumes: list[ResumeListItem] = Field(..., description="List of resumes")
model_config = {
"json_schema_extra": {
"example": {
"resumes": [
{
"position": "Python Developer",
"about_me": "Experienced Python developer",
"key_skills": ["Python", "FastAPI"],
"experience_type": "between_3_and_6",
}
]
}
}
}
class GetResumeHistoryRequest(BaseModel):
limit: int = Field(..., ge=1, le=100, description="Number of resumes to return", examples=[10])
offset: int = Field(..., ge=0, description="Number of resumes to skip", examples=[0])
class GetResumeHistoryResponse(BaseModel):
resumes: list[ResumeListItem] = Field(..., description="List of resume history items")
model_config = {
"json_schema_extra": {
"example": {
"resumes": [
{
"position": "Python Developer",
"about_me": "Experienced Python developer",
"key_skills": ["Python", "FastAPI"],
"experience_type": "between_3_and_6",
}
]
}
}
}
@router.post(
"/resume",
summary="Create resume",
description="Create a new resume with position, about me, key skills and experience type",
responses={
200: {"description": "Resume created successfully", "model": CreateResumeResponse},
401: {"description": "Unauthorized - invalid or missing token"},
},
)
async def create_resume(
request: CreateResumeRequest,
) -> CreateResumeResponse:
# TODO: Implement resume creation
raise NotImplementedError
@router.get(
"/resume/{resume_id}",
summary="Get resume",
description="Get resume by ID with optional salary prediction",
responses={
200: {"description": "Resume retrieved successfully", "model": ResumeResponse},
401: {"description": "Unauthorized - invalid or missing token"},
404: {"description": "Resume not found"},
},
)
async def get_resume(
resume_id: str = Field(..., description="Resume ID"),
) -> ResumeResponse:
# TODO: Implement resume retrieval
raise NotImplementedError
@router.get(
"/resume/list",
summary="Get resume list",
description="Get paginated list of resumes",
responses={
200: {"description": "Resume list retrieved successfully", "model": GetResumeListResponse},
401: {"description": "Unauthorized - invalid or missing token"},
},
)
async def get_resume_list(
limit: int = Query(..., ge=1, le=100, description="Number of resumes to return", examples=[10]),
offset: int = Query(..., ge=0, description="Number of resumes to skip", examples=[0]),
) -> GetResumeListResponse:
# TODO: Implement resume list retrieval
raise NotImplementedError
@router.get(
"/resume/{resume_id}/history",
summary="Get resume history",
description="Get paginated history of resume changes",
responses={
200: {"description": "Resume history retrieved successfully", "model": GetResumeHistoryResponse},
401: {"description": "Unauthorized - invalid or missing token"},
404: {"description": "Resume not found"},
},
)
async def get_resume_history(
resume_id: str = Field(..., description="Resume ID"),
limit: int = Query(..., ge=1, le=100, description="Number of history items to return", examples=[10]),
offset: int = Query(..., ge=0, description="Number of history items to skip", examples=[0]),
) -> GetResumeHistoryResponse:
# TODO: Implement resume history retrieval
raise NotImplementedError
+20 -8
View File
@@ -2,9 +2,9 @@ from io import BytesIO
from dishka import FromDishka
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Depends, UploadFile, status
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, UploadFile
from fastapi.security import HTTPBearer
from pydantic import BaseModel, Field
from uuid_utils.compat import uuid7
from template_project.application.common.file_storage import FileStorage
@@ -13,16 +13,28 @@ security = HTTPBearer()
router = APIRouter(route_class=DishkaRoute, tags=["Storage"], dependencies=[Depends(security)])
@router.post("/storage/upload_file")
class UploadFileResponse(BaseModel):
path: str = Field(..., description="Generated file path (UUID7)")
model_config = {"json_schema_extra": {"example": {"path": "01234567-89ab-cdef-0123-456789abcdef"}}}
@router.post(
"/storage/upload_file",
summary="Upload file",
description="Upload a file to storage. The file path is automatically generated using UUID7.",
responses={
200: {"description": "File uploaded successfully", "model": UploadFileResponse},
401: {"description": "Unauthorized - invalid or missing token"},
413: {"description": "File too large"},
},
)
async def upload_file(
file: UploadFile,
file_storage: FromDishka[FileStorage],
) -> JSONResponse:
) -> UploadFileResponse:
path = str(uuid7())
file_content = await file.read()
file_io = BytesIO(file_content)
await file_storage.upload(path=path, image=file_io)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"path": path},
)
return UploadFileResponse(path=path)
Generated
+29
View File
@@ -632,6 +632,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/89381173b4f336e986d72471198614806cd313e0f85c143ccb677c310223/dishka-1.7.2-py3-none-any.whl", hash = "sha256:f6faa6ab321903926b825b3337d77172ee693450279b314434864978d01fbad3", size = 94774, upload-time = "2025-09-24T21:23:03.246Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "fastapi"
version = "0.119.0"
@@ -1609,6 +1631,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
@@ -1961,6 +1988,7 @@ dependencies = [
{ name = "httpx" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic", extra = ["email"] },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "uuid-utils" },
@@ -2012,6 +2040,7 @@ requires-dist = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.4" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "sqlalchemy", specifier = "==2.0.44" },
{ name = "uuid-utils", specifier = "==0.11.1" },