API refactor
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-10-07 16:25:52 +09:00
parent 76d0d86211
commit 91c7e04474
1171 changed files with 81940 additions and 44117 deletions

View File

@@ -1,10 +1,10 @@
from datetime import date, datetime, timedelta
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Any
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sqlalchemy import and_, desc, select
from sqlalchemy import and_, desc, select, func
from sqlalchemy.ext.asyncio import AsyncSession
from services.calendar_service.models import CalendarEntry, CycleData, HealthInsights
@@ -14,6 +14,10 @@ from services.calendar_service.schemas import (CalendarEntryCreate, CalendarEntr
FlowIntensity, HealthInsightResponse, MoodType,
CalendarEventCreate)
from services.calendar_service.mobile_endpoint import MobileCalendarEntryCreate, mobile_create_calendar_entry
from services.calendar_service.mobile_responses import (MobileCalendarEntryResponse,
MobileCalendarPeriodInfo,
MobilePredictionInfo,
MobileCalendarResponse)
from shared.auth import get_current_user_from_token as get_current_user
from shared.config import settings
from shared.database import get_db
@@ -349,7 +353,7 @@ async def get_all_calendar_entries(
if end_date:
query = query.filter(CalendarEntry.entry_date <= end_date)
if entry_type:
query = query.filter(CalendarEntry.entry_type == entry_type)
query = query.filter(CalendarEntry.entry_type == entry_type.value)
query = query.order_by(CalendarEntry.entry_date.desc()).limit(limit)
@@ -679,7 +683,191 @@ async def create_mobile_calendar_entry(
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
from .utils import safe_int, safe_str, safe_bool, safe_date, safe_datetime, safe_get_column_value
# Новый эндпоинт для мобильного приложения для получения записей календаря с фильтрацией по датам
@app.get("/api/v1/calendar/entries/mobile", response_model=MobileCalendarResponse)
async def get_mobile_calendar_entries(
start_date: Optional[date] = Query(None, description="Начальная дата для фильтрации (включительно)"),
end_date: Optional[date] = Query(None, description="Конечная дата для фильтрации (включительно)"),
entry_type: Optional[str] = Query(None, description="Тип записи (MENSTRUATION, OVULATION и т.д.)"),
current_user: Dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
limit: int = Query(100, ge=1, le=500, description="Максимальное количество записей"),
):
"""Получить записи календаря для мобильного приложения с фильтрацией по датам"""
import logging
logging.info(f"Запрос мобильных данных. start_date={start_date}, end_date={end_date}, entry_type={entry_type}")
try:
# Получаем записи из базы данных
query = select(CalendarEntry).filter(CalendarEntry.user_id == current_user["user_id"])
# Фильтрация по датам
if start_date:
query = query.filter(CalendarEntry.entry_date >= start_date)
if end_date:
query = query.filter(CalendarEntry.entry_date <= end_date)
# Фильтрация по типу записи
if entry_type:
# Преобразуем тип из мобильного формата в серверный
server_entry_type = None
if entry_type == "MENSTRUATION":
server_entry_type = EntryType.PERIOD.value
elif entry_type == "OVULATION":
server_entry_type = EntryType.OVULATION.value
elif entry_type == "SYMPTOMS":
server_entry_type = EntryType.SYMPTOMS.value
if server_entry_type:
query = query.filter(CalendarEntry.entry_type == server_entry_type)
# Сортировка и ограничение количества записей
query = query.order_by(desc(CalendarEntry.entry_date)).limit(limit)
# Выполнение запроса
result = await db.execute(query)
entries = result.scalars().all()
# Преобразуем записи в формат мобильного приложения
mobile_entries = []
for entry in entries:
# Преобразуем тип записи из серверного формата в мобильный
mobile_type = "SYMPTOMS" # По умолчанию
entry_type_value = safe_str(entry.entry_type, "")
if entry_type_value == EntryType.PERIOD.value:
mobile_type = "MENSTRUATION"
elif entry_type_value == EntryType.OVULATION.value:
mobile_type = "OVULATION"
# Преобразуем симптомы из строки в список
symptoms_list = []
entry_symptoms = safe_str(entry.symptoms, "")
if entry_symptoms:
symptoms_list = [s.strip() for s in entry_symptoms.split(",")]
# Преобразуем flow_intensity, если есть
flow_intensity_value = None
entry_flow = safe_str(entry.flow_intensity, "")
if entry_flow in ['1', '2', '3', '4', '5']:
flow_intensity_value = int(entry_flow)
# Преобразуем mood, если есть
mood_value = None
entry_mood = safe_str(entry.mood, "")
if entry_mood:
mood_value = entry_mood.upper()
# Преобразуем notes, если есть
notes_value = safe_str(entry.notes, "")
# Получаем created_at
created_at_value = safe_datetime(
getattr(entry, 'created_at', None),
datetime.now()
).isoformat()
# Получаем is_predicted
is_predicted_value = safe_bool(
getattr(entry, 'is_predicted', None),
False
)
# Создаем мобильную запись
mobile_entry = MobileCalendarEntryResponse(
id=safe_int(entry.id),
uuid=str(safe_int(entry.id)), # Используем ID как UUID
date=safe_date(entry.entry_date, date.today()).isoformat(),
type=mobile_type,
flow_intensity=flow_intensity_value,
mood=mood_value,
symptoms=symptoms_list,
notes=notes_value,
created_at=created_at_value,
is_predicted=is_predicted_value
)
mobile_entries.append(mobile_entry)
# Получаем информацию о текущем цикле
current_cycle = await db.execute(
select(CycleData)
.filter(CycleData.user_id == current_user["user_id"])
.order_by(desc(CycleData.cycle_start_date))
.limit(1)
)
cycle_data = current_cycle.scalars().first()
# Создаем информацию о периоде
period_info = MobileCalendarPeriodInfo()
prediction_info = MobilePredictionInfo()
if cycle_data:
# Заполняем информацию о текущем цикле
cycle_start_date = safe_date(cycle_data.cycle_start_date)
if cycle_start_date:
period_info.current_cycle_start = cycle_start_date.isoformat()
cycle_length = safe_int(cycle_data.cycle_length)
if cycle_length > 0:
period_info.cycle_length = cycle_length
avg_cycle_length = safe_int(cycle_data.avg_cycle_length)
if avg_cycle_length > 0:
period_info.average_cycle_length = avg_cycle_length
# Если есть информация о периоде, рассчитываем дату окончания
period_length = safe_int(cycle_data.period_length)
if period_length > 0 and cycle_start_date:
end_date_value = cycle_start_date + timedelta(days=period_length)
period_info.expected_period_end = end_date_value.isoformat()
# Заполняем информацию о фертильном окне и овуляции
if cycle_start_date:
ovulation_day = avg_cycle_length // 2
ovulation_date = cycle_start_date + timedelta(days=ovulation_day)
period_info.ovulation_date = ovulation_date.isoformat()
# Фертильное окно начинается за 5 дней до овуляции и заканчивается через 1 день после
period_info.fertility_window_start = (ovulation_date - timedelta(days=5)).isoformat()
period_info.fertility_window_end = (ovulation_date + timedelta(days=1)).isoformat()
# Заполняем прогноз
next_period = safe_date(cycle_data.next_period_predicted)
if next_period:
prediction_info.next_period_date = next_period.isoformat()
prediction_info.confidence_level = 80 # Приблизительное значение уверенности
# Рассчитываем следующее фертильное окно и овуляцию
if avg_cycle_length > 0:
next_ovulation = next_period - timedelta(days=avg_cycle_length // 2)
prediction_info.next_ovulation_date = next_ovulation.isoformat()
prediction_info.next_fertile_window_start = (next_ovulation - timedelta(days=5)).isoformat()
prediction_info.next_fertile_window_end = (next_ovulation + timedelta(days=1)).isoformat()
# Собираем полный ответ
response = MobileCalendarResponse(
entries=mobile_entries,
period_info=period_info,
prediction=prediction_info
)
return response
except Exception as e:
logging.error(f"Ошибка при получении записей календаря: {str(e)}")
raise HTTPException(status_code=500, detail=f"Ошибка сервера: {str(e)}")
if __name__ == "__main__":
import uvicorn
from .mobile_responses import (
MobileCalendarEntryResponse,
MobileCalendarPeriodInfo,
MobilePredictionInfo,
MobileCalendarResponse
)
from .schemas_mobile import MobileFlowIntensity, MobileMood
uvicorn.run(app, host="0.0.0.0", port=8004)

View File

@@ -0,0 +1,47 @@
from datetime import date, datetime
from typing import List, Dict, Optional, Any
from uuid import UUID
from pydantic import BaseModel, Field
class MobileCalendarEntryResponse(BaseModel):
"""Формат ответа для мобильного приложения"""
id: int
uuid: str
date: str # ISO формат даты YYYY-MM-DD
type: str # Тип записи в формате мобильного приложения (MENSTRUATION, OVULATION, etc.)
flow_intensity: Optional[int] = None # Шкала интенсивности 1-5
mood: Optional[str] = None
symptoms: List[str] = Field(default_factory=list)
notes: Optional[str] = None
created_at: str # ISO формат даты и времени
is_predicted: bool = False
class MobileCalendarPeriodInfo(BaseModel):
"""Информация о текущем цикле для мобильного приложения"""
current_cycle_start: Optional[str] = None # ISO формат даты YYYY-MM-DD
expected_period_end: Optional[str] = None # ISO формат даты YYYY-MM-DD
cycle_length: Optional[int] = None
period_length: Optional[int] = None
average_cycle_length: Optional[int] = None
fertility_window_start: Optional[str] = None # ISO формат даты YYYY-MM-DD
fertility_window_end: Optional[str] = None # ISO формат даты YYYY-MM-DD
ovulation_date: Optional[str] = None # ISO формат даты YYYY-MM-DD
class MobilePredictionInfo(BaseModel):
"""Информация о прогнозах для мобильного приложения"""
next_period_date: Optional[str] = None # ISO формат даты YYYY-MM-DD
confidence_level: Optional[int] = None # 0-100 уровень уверенности
next_fertile_window_start: Optional[str] = None # ISO формат даты YYYY-MM-DD
next_fertile_window_end: Optional[str] = None # ISO формат даты YYYY-MM-DD
next_ovulation_date: Optional[str] = None # ISO формат даты YYYY-MM-DD
class MobileCalendarResponse(BaseModel):
"""Полный ответ для мобильного приложения"""
entries: List[MobileCalendarEntryResponse]
period_info: MobileCalendarPeriodInfo = Field(default_factory=MobileCalendarPeriodInfo)
prediction: MobilePredictionInfo = Field(default_factory=MobilePredictionInfo)

View File

@@ -1,6 +1,6 @@
import uuid
from sqlalchemy import Boolean, Column, Date, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, Column, Date, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from shared.database import BaseModel
@@ -10,7 +10,7 @@ class CalendarEntry(BaseModel):
__tablename__ = "calendar_entries"
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
user_id = Column(Integer, nullable=False, index=True) # Убран ForeignKey
entry_date = Column(Date, nullable=False, index=True)
entry_type = Column(
@@ -42,7 +42,7 @@ class CalendarEntry(BaseModel):
class CycleData(BaseModel):
__tablename__ = "cycle_data"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
user_id = Column(Integer, nullable=False, index=True) # Убран ForeignKey
cycle_start_date = Column(Date, nullable=False)
cycle_length = Column(Integer) # Length of this cycle
period_length = Column(Integer) # Length of period in this cycle
@@ -65,7 +65,7 @@ class CycleData(BaseModel):
class HealthInsights(BaseModel):
__tablename__ = "health_insights"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
user_id = Column(Integer, nullable=False, index=True) # Убран ForeignKey
insight_type = Column(
String(50), nullable=False
) # cycle_pattern, symptom_pattern, etc.

View File

@@ -1,8 +1,10 @@
from datetime import date, datetime
from enum import Enum
from typing import List, Optional, Union
from typing import List, Optional, Union, Any
import uuid
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer, ConfigDict
class EntryType(str, Enum):
@@ -175,29 +177,36 @@ class CalendarEntryCreate(CalendarEntryBase):
class CalendarEntryResponse(BaseModel):
id: int
uuid: str
uuid: UUID # Используем UUID из uuid модуля
entry_date: date
entry_type: str
flow_intensity: Optional[str]
period_symptoms: Optional[str]
mood: Optional[str]
energy_level: Optional[int]
sleep_hours: Optional[int]
symptoms: Optional[str]
medications: Optional[str]
notes: Optional[str]
flow_intensity: Optional[str] = None
period_symptoms: Optional[str] = None
mood: Optional[str] = None
energy_level: Optional[int] = None
sleep_hours: Optional[int] = None
symptoms: Optional[str] = None
medications: Optional[str] = None
notes: Optional[str] = None
is_predicted: bool
confidence_score: Optional[int]
confidence_score: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
is_active: Optional[bool] = None
user_id: Optional[int] = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@field_serializer('uuid')
def serialize_uuid(self, uuid_val: UUID) -> str:
"""Преобразование UUID в строку для JSON-сериализации"""
return str(uuid_val)
# Модель ответа для мобильного приложения
class CalendarEvent(BaseModel):
id: int
uuid: str
uuid: UUID # Используем тип UUID из модуля uuid
date: date
type: str
flow_intensity: Optional[int] = None
@@ -206,6 +215,8 @@ class CalendarEvent(BaseModel):
notes: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_server_response(cls, entry: CalendarEntryResponse) -> 'CalendarEvent':
"""Преобразовать из серверной модели в модель для мобильного приложения"""

View File

@@ -0,0 +1,103 @@
"""Утилиты для работы с SQLAlchemy моделями"""
from datetime import date, datetime
from typing import Any, Optional, TypeVar, Type, cast
T = TypeVar('T')
def safe_get_column_value(obj: Any, column_name: str, default_value: Optional[T] = None) -> Optional[T]:
"""
Безопасное получение значения колонки из SQLAlchemy модели.
Args:
obj: Объект SQLAlchemy модели
column_name: Имя колонки
default_value: Значение по умолчанию, если колонка отсутствует или значение None
Returns:
Значение колонки или значение по умолчанию
"""
if not hasattr(obj, column_name):
return default_value
value = getattr(obj, column_name)
if value is None:
return default_value
# Если значение - дата, преобразуем его в Python date
if hasattr(value, 'isoformat'): # Дата или дата-время
return cast(T, value)
# Для других типов просто возвращаем значение
return cast(T, value)
def safe_int(value: Any, default: int = 0) -> int:
"""Безопасно преобразует значение в целое число"""
if value is None:
return default
try:
return int(value)
except (ValueError, TypeError):
return default
def safe_str(value: Any, default: str = "") -> str:
"""Безопасно преобразует значение в строку"""
if value is None:
return default
try:
return str(value)
except (ValueError, TypeError):
return default
def safe_bool(value: Any, default: bool = False) -> bool:
"""Безопасно преобразует значение в булево значение"""
if value is None:
return default
if isinstance(value, bool):
return value
try:
return bool(value)
except (ValueError, TypeError):
return default
def safe_date(value: Any, default: Optional[date] = None) -> date:
"""Безопасно преобразует значение в дату"""
if default is None:
default = date.today()
if value is None:
return default
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
try:
return date.fromisoformat(str(value))
except (ValueError, TypeError):
return default
def safe_datetime(value: Any, default: Optional[datetime] = None) -> datetime:
"""Безопасно преобразует значение в дату-время"""
if default is None:
default = datetime.now()
if value is None:
return default
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
try:
return datetime.fromisoformat(str(value))
except (ValueError, TypeError):
return default