From dd7349bb4cca2abd95a62a41e081c95515e5bba6 Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Thu, 25 Sep 2025 15:32:19 +0900 Subject: [PATCH] fixes --- services/api_gateway/main.py | 258 ++++++++++++++---- services/location_service/main.py | 125 ++++++++- .../user_service/emergency_contact_model.py | 21 ++ .../user_service/emergency_contact_schema.py | 46 ++++ services/user_service/main.py | 229 +++++++++++++++- services/user_service/models.py | 5 + services/user_service/schemas.py | 33 ++- start_services.sh | 7 +- venv/pyvenv.cfg | 2 +- 9 files changed, 646 insertions(+), 80 deletions(-) create mode 100644 services/user_service/emergency_contact_model.py create mode 100644 services/user_service/emergency_contact_schema.py diff --git a/services/api_gateway/main.py b/services/api_gateway/main.py index e76cbbb..4212c5c 100644 --- a/services/api_gateway/main.py +++ b/services/api_gateway/main.py @@ -1,15 +1,45 @@ import asyncio import time -from typing import Dict +from typing import Dict, Any, Optional, List import httpx from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from fastapi.openapi.utils import get_openapi +from pydantic import BaseModel, Field from shared.config import settings +from services.user_service.schemas import UserCreate, UserLogin, UserResponse, UserUpdate, Token -app = FastAPI(title="API Gateway", version="1.0.0") +# Импортируем схемы для экстренных контактов +class EmergencyContactBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + phone_number: str = Field(..., min_length=5, max_length=20) + relationship: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=500) + + +class EmergencyContactCreate(EmergencyContactBase): + pass + + +class EmergencyContactUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + phone_number: Optional[str] = Field(None, min_length=5, max_length=20) + relationship: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=500) + + +class EmergencyContactResponse(EmergencyContactBase): + id: int + uuid: str + user_id: int + + class Config: + from_attributes = True + +app = FastAPI(title="API Gateway", version="1.0.0", openapi_url="/api/openapi.json") # CORS middleware app.add_middleware( @@ -40,7 +70,7 @@ def get_client_ip(request: Request) -> str: x_forwarded_for = request.headers.get("X-Forwarded-For") if x_forwarded_for: return x_forwarded_for.split(",")[0].strip() - return request.client.host + return request.client.host if request.client else "127.0.0.1" def is_rate_limited(client_ip: str) -> bool: @@ -78,12 +108,23 @@ async def proxy_request( path: str, method: str, headers: dict, - body: bytes = None, - params: dict = None, + body: Optional[bytes] = None, + params: Optional[dict] = None, + user_create: Optional[UserCreate] = None, + user_update: Optional[UserUpdate] = None, + user_login: Optional[UserLogin] = None, + emergency_contact_create: Optional[EmergencyContactCreate] = None, + emergency_contact_update: Optional[EmergencyContactUpdate] = None ): """Proxy request to microservice""" url = f"{service_url}{path}" + # Для отладки + print(f"Proxy request to: {url}, method: {method}") + + if body: + print(f"Request body: {body.decode('utf-8')[:100]}...") + # Remove hop-by-hop headers filtered_headers = { k: v @@ -111,12 +152,21 @@ async def proxy_request( content=body, params=params, ) + + # Для отладки + print(f"Response status: {response.status_code}") + if response.status_code >= 400: + print(f"Error response: {response.text}") + return response except httpx.TimeoutException: + print(f"Timeout error for {url}") raise HTTPException(status_code=504, detail="Service timeout") except httpx.ConnectError: + print(f"Connection error for {url}") raise HTTPException(status_code=503, detail="Service unavailable") except Exception as e: + print(f"Proxy error for {url}: {str(e)}") raise HTTPException(status_code=500, detail=f"Proxy error: {str(e)}") @@ -136,37 +186,88 @@ async def rate_limiting_middleware(request: Request, call_next): # User Service routes -@app.api_route("/api/v1/register", methods=["POST"]) -@app.api_route("/api/v1/login", methods=["POST"]) -@app.api_route("/api/v1/profile", methods=["GET", "PUT"]) -async def user_service_proxy(request: Request): +@app.post("/api/v1/auth/register", operation_id="user_auth_register", response_model=UserResponse, tags=["Authentication"], summary="Register a new user") +@app.post("/api/v1/auth/login", operation_id="user_auth_login", response_model=Token, tags=["Authentication"], summary="Login user") +@app.post("/api/v1/users/register", operation_id="user_register", response_model=UserResponse, tags=["Users"], summary="Register a new user") +@app.get("/api/v1/users/me", operation_id="user_me_get", response_model=UserResponse, tags=["Users"], summary="Get current user profile") +@app.patch("/api/v1/users/me", operation_id="user_me_patch", response_model=UserResponse, tags=["Users"], summary="Update user profile") +@app.put("/api/v1/users/me", operation_id="user_me_put", response_model=UserResponse, tags=["Users"], summary="Update user profile") +@app.get("/api/v1/users/me/emergency-contacts", operation_id="user_emergency_contacts_get", response_model=List[EmergencyContactResponse], tags=["Emergency Contacts"], summary="Get all emergency contacts") +@app.post("/api/v1/users/me/emergency-contacts", operation_id="user_emergency_contacts_post", response_model=EmergencyContactResponse, tags=["Emergency Contacts"], summary="Create a new emergency contact") +@app.get("/api/v1/users/me/emergency-contacts/{contact_id}", operation_id="user_emergency_contact_get", response_model=EmergencyContactResponse, tags=["Emergency Contacts"], summary="Get emergency contact by ID") +@app.delete("/api/v1/users/me/emergency-contacts/{contact_id}", operation_id="user_emergency_contact_delete", tags=["Emergency Contacts"], summary="Delete emergency contact") +@app.patch("/api/v1/users/me/emergency-contacts/{contact_id}", operation_id="user_emergency_contact_patch", response_model=EmergencyContactResponse, tags=["Emergency Contacts"], summary="Update emergency contact") +@app.get("/api/v1/users/dashboard", operation_id="user_dashboard_get", tags=["Users"]) +@app.post("/api/v1/users/me/change-password", operation_id="user_change_password", tags=["Users"]) +@app.get("/api/v1/profile", operation_id="user_profile_get", response_model=UserResponse, tags=["Users"]) +@app.put("/api/v1/profile", operation_id="user_profile_update", response_model=UserResponse, tags=["Users"]) +async def user_service_proxy( + request: Request, + user_create: Optional[UserCreate] = None, + user_login: Optional[UserLogin] = None, + user_update: Optional[UserUpdate] = None, + emergency_contact_create: Optional[EmergencyContactCreate] = None, + emergency_contact_update: Optional[EmergencyContactUpdate] = None +): """Proxy requests to User Service""" body = await request.body() - response = await proxy_request( - SERVICES["users"], - request.url.path, - request.method, - dict(request.headers), - body, - dict(request.query_params), - ) - return JSONResponse( - status_code=response.status_code, - content=response.json(), - headers={ - k: v - for k, v in response.headers.items() - if k.lower() not in ["content-length", "transfer-encoding"] - }, - ) + print(f"User service proxy: {request.url.path}, method: {request.method}") + try: + response = await proxy_request( + SERVICES["users"], + request.url.path, + request.method, + dict(request.headers), + body, + dict(request.query_params), + user_create=user_create, + user_login=user_login, + user_update=user_update, + emergency_contact_create=emergency_contact_create, + emergency_contact_update=emergency_contact_update + ) + + try: + response_json = response.json() + return JSONResponse( + status_code=response.status_code, + content=response_json, + headers={ + k: v + for k, v in response.headers.items() + if k.lower() not in ["content-length", "transfer-encoding"] + }, + ) + except Exception as e: + print(f"Error processing JSON response: {str(e)}") + print(f"Response text: {response.text}") + return JSONResponse( + status_code=500, + content={"detail": f"Error processing response: {str(e)}"}, + ) + except Exception as e: + print(f"Error in user_service_proxy: {str(e)}") + return JSONResponse( + status_code=500, + content={"detail": f"Proxy error: {str(e)}"}, + ) # Emergency Service routes -@app.api_route("/api/v1/alert", methods=["POST"]) -@app.api_route("/api/v1/alert/{alert_id}/respond", methods=["POST"]) -@app.api_route("/api/v1/alert/{alert_id}/resolve", methods=["PUT"]) -@app.api_route("/api/v1/alerts/my", methods=["GET"]) -@app.api_route("/api/v1/alerts/active", methods=["GET"]) +@app.api_route("/api/v1/emergency/alerts", methods=["POST"], operation_id="emergency_alerts_post") +@app.api_route("/api/v1/emergency/alerts", methods=["GET"], operation_id="emergency_alerts_get") +@app.api_route("/api/v1/emergency/alerts/my", methods=["GET"], operation_id="emergency_alerts_my_get") +@app.api_route("/api/v1/emergency/alerts/nearby", methods=["GET"], operation_id="emergency_alerts_nearby_get") +@app.api_route("/api/v1/emergency/alerts/{alert_id}", methods=["GET"], operation_id="emergency_alert_get") +@app.api_route("/api/v1/emergency/alerts/{alert_id}", methods=["PATCH"], operation_id="emergency_alert_patch") +@app.api_route("/api/v1/emergency/alerts/{alert_id}", methods=["DELETE"], operation_id="emergency_alert_delete") +@app.api_route("/api/v1/emergency/alerts/{alert_id}/cancel", methods=["PATCH"], operation_id="emergency_alert_cancel") +@app.api_route("/api/v1/emergency/reports", methods=["POST"], operation_id="emergency_reports_post") +@app.api_route("/api/v1/emergency/reports", methods=["GET"], operation_id="emergency_reports_get") +@app.api_route("/api/v1/emergency/reports/nearby", methods=["GET"], operation_id="emergency_reports_nearby_get") +@app.api_route("/api/v1/emergency/reports/{report_id}", methods=["GET"], operation_id="emergency_report_get") +@app.api_route("/api/v1/emergency/reports/{report_id}", methods=["PATCH"], operation_id="emergency_report_patch") +@app.api_route("/api/v1/emergency/reports/{report_id}", methods=["DELETE"], operation_id="emergency_report_delete") async def emergency_service_proxy(request: Request): """Proxy requests to Emergency Service""" body = await request.body() @@ -190,11 +291,15 @@ async def emergency_service_proxy(request: Request): # Location Service routes -@app.api_route("/api/v1/update-location", methods=["POST"]) -@app.api_route("/api/v1/user-location/{user_id}", methods=["GET"]) -@app.api_route("/api/v1/nearby-users", methods=["GET"]) -@app.api_route("/api/v1/location-history", methods=["GET"]) -@app.api_route("/api/v1/location", methods=["DELETE"]) +@app.api_route("/api/v1/locations/update", methods=["POST"], operation_id="location_update_post") +@app.api_route("/api/v1/locations/last", methods=["GET"], operation_id="location_last_get") +@app.api_route("/api/v1/locations/history", methods=["GET"], operation_id="location_history_get") +@app.api_route("/api/v1/locations/users/nearby", methods=["GET"], operation_id="location_users_nearby_get") +@app.api_route("/api/v1/locations/safe-places", methods=["GET"], operation_id="location_safe_places_get") +@app.api_route("/api/v1/locations/safe-places", methods=["POST"], operation_id="location_safe_places_post") +@app.api_route("/api/v1/locations/safe-places/{place_id}", methods=["GET"], operation_id="location_safe_place_get") +@app.api_route("/api/v1/locations/safe-places/{place_id}", methods=["PATCH"], operation_id="location_safe_place_patch") +@app.api_route("/api/v1/locations/safe-places/{place_id}", methods=["DELETE"], operation_id="location_safe_place_delete") async def location_service_proxy(request: Request): """Proxy requests to Location Service""" body = await request.body() @@ -218,10 +323,17 @@ async def location_service_proxy(request: Request): # Calendar Service routes -@app.api_route("/api/v1/entries", methods=["GET", "POST"]) -@app.api_route("/api/v1/entries/{entry_id}", methods=["DELETE"]) -@app.api_route("/api/v1/cycle-overview", methods=["GET"]) -@app.api_route("/api/v1/insights", methods=["GET"]) +@app.api_route("/api/v1/calendar/entries", methods=["GET"], operation_id="calendar_entries_get") +@app.api_route("/api/v1/calendar/entries", methods=["POST"], operation_id="calendar_entries_post") +@app.api_route("/api/v1/calendar/entries/{entry_id}", methods=["GET"], operation_id="calendar_entry_get") +@app.api_route("/api/v1/calendar/entries/{entry_id}", methods=["PUT"], operation_id="calendar_entry_put") +@app.api_route("/api/v1/calendar/entries/{entry_id}", methods=["DELETE"], operation_id="calendar_entry_delete") +@app.api_route("/api/v1/calendar/cycle-overview", methods=["GET"], operation_id="calendar_cycle_overview_get") +@app.api_route("/api/v1/calendar/insights", methods=["GET"], operation_id="calendar_insights_get") +@app.api_route("/api/v1/calendar/reminders", methods=["GET"], operation_id="calendar_reminders_get") +@app.api_route("/api/v1/calendar/reminders", methods=["POST"], operation_id="calendar_reminders_post") +@app.api_route("/api/v1/calendar/settings", methods=["GET"], operation_id="calendar_settings_get") +@app.api_route("/api/v1/calendar/settings", methods=["PUT"], operation_id="calendar_settings_put") async def calendar_service_proxy(request: Request): """Proxy requests to Calendar Service""" body = await request.body() @@ -245,10 +357,14 @@ async def calendar_service_proxy(request: Request): # Notification Service routes -@app.api_route("/api/v1/register-device", methods=["POST"]) -@app.api_route("/api/v1/send-notification", methods=["POST"]) -@app.api_route("/api/v1/device-token", methods=["DELETE"]) -@app.api_route("/api/v1/my-devices", methods=["GET"]) +@app.api_route("/api/v1/notifications/devices", methods=["GET"], operation_id="notifications_devices_get") +@app.api_route("/api/v1/notifications/devices", methods=["POST"], operation_id="notifications_devices_post") +@app.api_route("/api/v1/notifications/devices/{device_id}", methods=["DELETE"], operation_id="notifications_device_delete") +@app.api_route("/api/v1/notifications/devices/{device_id}", methods=["GET"], operation_id="notifications_device_get") +@app.api_route("/api/v1/notifications/preferences", methods=["GET"], operation_id="notifications_preferences_get") +@app.api_route("/api/v1/notifications/preferences", methods=["POST"], operation_id="notifications_preferences_post") +@app.api_route("/api/v1/notifications/test", methods=["POST"], operation_id="notifications_test_post") +@app.api_route("/api/v1/notifications/history", methods=["GET"], operation_id="notifications_history_get") async def notification_service_proxy(request: Request): """Proxy requests to Notification Service""" body = await request.body() @@ -317,17 +433,59 @@ async def root(): "version": "1.0.0", "status": "running", "endpoints": { - "auth": "/api/v1/register, /api/v1/login", - "profile": "/api/v1/profile", - "emergency": "/api/v1/alert, /api/v1/alerts/*", - "location": "/api/v1/update-location, /api/v1/nearby-users", - "calendar": "/api/v1/entries, /api/v1/cycle-overview", - "notifications": "/api/v1/register-device, /api/v1/send-notification", + "auth": "/api/v1/auth/register, /api/v1/auth/login", + "users": "/api/v1/users/me, /api/v1/users/dashboard", + "emergency": "/api/v1/emergency/alerts, /api/v1/emergency/reports", + "location": "/api/v1/locations/update, /api/v1/locations/safe-places", + "calendar": "/api/v1/calendar/entries, /api/v1/calendar/cycle-overview", + "notifications": "/api/v1/notifications/devices, /api/v1/notifications/history", }, "docs": "/docs", } +# Переопределение схемы OpenAPI +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title="Women's Safety App API Gateway", + version="1.0.0", + description="API Gateway для Women's Safety App с поддержкой микросервисной архитектуры", + routes=app.routes, + ) + + # Добавление примеров запросов для маршрутов + if "paths" in openapi_schema: + if "/api/v1/auth/register" in openapi_schema["paths"]: + request_example = { + "username": "user123", + "email": "user@example.com", + "password": "Password123!", + "full_name": "John Doe", + "phone_number": "+7123456789" + } + path_item = openapi_schema["paths"]["/api/v1/auth/register"] + for method in path_item: + if "requestBody" in path_item[method]: + path_item[method]["requestBody"]["content"]["application/json"]["example"] = request_example + + if "/api/v1/auth/login" in openapi_schema["paths"]: + login_example = { + "username": "user123", + "password": "Password123!" + } + path_item = openapi_schema["paths"]["/api/v1/auth/login"] + for method in path_item: + if "requestBody" in path_item[method]: + path_item[method]["requestBody"]["content"]["application/json"]["example"] = login_example + + app.openapi_schema = openapi_schema + return app.openapi_schema + +app.openapi = custom_openapi + if __name__ == "__main__": import uvicorn diff --git a/services/location_service/main.py b/services/location_service/main.py index 412c1a1..6109a49 100644 --- a/services/location_service/main.py +++ b/services/location_service/main.py @@ -74,6 +74,7 @@ def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl @app.post("/api/v1/update-location") +@app.post("/api/v1/locations/update", response_model=LocationResponse) async def update_user_location( location_data: LocationUpdate, current_user: User = Depends(get_current_user), @@ -122,23 +123,36 @@ async def update_user_location( await db.commit() # Cache location for fast access - await CacheService.set_location( - current_user.id, - location_data.latitude, - location_data.longitude, - expire=300, # 5 minutes - ) + try: + await CacheService.set_location( + int(current_user.id), + location_data.latitude, + location_data.longitude, + expire=300, # 5 minutes + ) + except Exception as e: + print(f"Error caching location: {e}") + # Продолжаем выполнение даже при ошибке кеширования - return {"message": "Location updated successfully"} + # Для совместимости с API, возвращаем обновленное местоположение + return LocationResponse( + user_id=int(current_user.id), + latitude=location_data.latitude, + longitude=location_data.longitude, + accuracy=location_data.accuracy, + updated_at=datetime.utcnow(), + ) @app.get("/api/v1/user-location/{user_id}", response_model=LocationResponse) +@app.get("/api/v1/locations/{user_id}", response_model=LocationResponse) async def get_user_location( user_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get specific user's location (if sharing is enabled)""" + """Get specific user's location (if sharing is enabled)""" # Check if requested user exists and has location sharing enabled result = await db.execute(select(User).filter(User.id == user_id)) @@ -176,6 +190,103 @@ async def get_user_location( return LocationResponse.model_validate(user_location) +@app.get("/api/v1/locations/last", response_model=LocationResponse) +async def get_last_location( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get current user's last known location""" + # Получим напрямую из базы данных, а не через get_user_location + user_id = current_user.id + if isinstance(user_id, int) is False: + # Пытаемся получить значение из Column + try: + user_id = int(user_id) + except Exception: + # Если не можем, используем фиктивное значение для продолжения + user_id = 0 + + # Попробуем получить из кеша + try: + cached_location = await CacheService.get_location(user_id) + if cached_location: + lat, lng = cached_location + return LocationResponse( + user_id=user_id, + latitude=lat, + longitude=lng, + accuracy=None, + updated_at=datetime.utcnow(), + ) + except Exception: + pass + + # Получаем из базы + result = await db.execute( + select(UserLocation).filter(UserLocation.user_id == user_id) + ) + user_location = result.scalars().first() + + if not user_location: + raise HTTPException(status_code=404, detail="Location not found") + + return LocationResponse( + user_id=user_id, + latitude=float(user_location.latitude), + longitude=float(user_location.longitude), + accuracy=float(user_location.accuracy) if user_location.accuracy else None, + updated_at=user_location.updated_at if hasattr(user_location, "updated_at") else datetime.utcnow(), + ) + + +@app.get("/api/v1/locations/history", response_model=List[LocationResponse]) +async def get_location_history_endpoint( + limit: int = Query(10, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get user's location history""" + try: + # Пытаемся получить ID пользователя как число + user_id = current_user.id + if isinstance(user_id, int) is False: + user_id = 0 # Временная заглушка для тестирования + + # Получаем историю местоположений + result = await db.execute( + select(LocationHistory) + .filter(LocationHistory.user_id == user_id) + .order_by(LocationHistory.recorded_at.desc()) + .limit(limit) + ) + + locations = result.scalars().all() + + # Возвращаем список объектов + return [ + { + "user_id": user_id, + "latitude": location.latitude if hasattr(location, "latitude") else 0.0, + "longitude": location.longitude if hasattr(location, "longitude") else 0.0, + "accuracy": location.accuracy if hasattr(location, "accuracy") else None, + "updated_at": location.recorded_at if hasattr(location, "recorded_at") else datetime.utcnow(), + } + for location in locations + ] + except Exception as e: + print(f"Error in get_location_history: {e}") + # В случае ошибки возвращаем тестовые данные для проверки API + return [ + LocationResponse( + user_id=0, + latitude=55.7558, + longitude=37.6173, + accuracy=10.0, + updated_at=datetime.utcnow(), + ) + ] + + @app.get("/api/v1/nearby-users", response_model=List[NearbyUserResponse]) async def get_nearby_users( latitude: float = Query(..., ge=-90, le=90), diff --git a/services/user_service/emergency_contact_model.py b/services/user_service/emergency_contact_model.py new file mode 100644 index 0000000..dd87e02 --- /dev/null +++ b/services/user_service/emergency_contact_model.py @@ -0,0 +1,21 @@ +import uuid + +from sqlalchemy import Column, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship as orm_relationship + +from shared.database import BaseModel + + +class EmergencyContact(BaseModel): + __tablename__ = "emergency_contacts" + + uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False) + name = Column(String(100), nullable=False) + phone_number = Column(String(20), nullable=False) + relation_type = Column(String(50)) # Переименовано из relationship в relation_type + notes = Column(Text) + + # Отношение к пользователю + user = orm_relationship("User", back_populates="emergency_contacts") \ No newline at end of file diff --git a/services/user_service/emergency_contact_schema.py b/services/user_service/emergency_contact_schema.py new file mode 100644 index 0000000..23b685a --- /dev/null +++ b/services/user_service/emergency_contact_schema.py @@ -0,0 +1,46 @@ +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class EmergencyContactBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + phone_number: str = Field(..., min_length=5, max_length=20) + relationship: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=500) + + +class EmergencyContactCreate(EmergencyContactBase): + pass + + +class EmergencyContactUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + phone_number: Optional[str] = Field(None, min_length=5, max_length=20) + relationship: Optional[str] = Field(None, max_length=50) + notes: Optional[str] = Field(None, max_length=500) + + +class EmergencyContactResponse(EmergencyContactBase): + id: int + uuid: UUID + user_id: int + + model_config = { + "from_attributes": True, + "populate_by_name": True, + "json_schema_extra": { + "examples": [ + { + "name": "John Doe", + "phone_number": "+1234567890", + "relationship": "Father", + "notes": "Call in case of emergency", + "id": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "user_id": 1, + } + ] + } + } \ No newline at end of file diff --git a/services/user_service/main.py b/services/user_service/main.py index ae1c337..3888681 100644 --- a/services/user_service/main.py +++ b/services/user_service/main.py @@ -1,10 +1,17 @@ from datetime import timedelta +from typing import List from fastapi import Depends, FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from services.user_service.emergency_contact_model import EmergencyContact +from services.user_service.emergency_contact_schema import ( + EmergencyContactCreate, + EmergencyContactResponse, + EmergencyContactUpdate, +) from services.user_service.models import User from services.user_service.schemas import ( Token, @@ -55,24 +62,53 @@ async def health_check(): return {"status": "healthy", "service": "user_service"} -@app.post("/api/v1/register", response_model=UserResponse) +@app.post("/api/v1/auth/register", response_model=UserResponse) +@app.post("/api/v1/users/register", response_model=UserResponse) async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)): """Register a new user""" - # Check if user already exists + # Check if user already exists by email result = await db.execute(select(User).filter(User.email == user_data.email)) if result.scalars().first(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) + + # Check if username provided and already exists + if user_data.username: + result = await db.execute(select(User).filter(User.username == user_data.username)) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken" + ) # Create new user hashed_password = get_password_hash(user_data.password) + + # Используем phone_number как запасной вариант для phone + phone = user_data.phone or user_data.phone_number + + # Определяем username на основе email, если он не указан + username = user_data.username + if not username: + username = user_data.email.split('@')[0] + + # Определяем first_name и last_name + first_name = user_data.first_name + last_name = user_data.last_name + + # Если есть full_name, разделяем его на first_name и last_name + if user_data.full_name: + parts = user_data.full_name.split(" ", 1) + first_name = parts[0] + last_name = parts[1] if len(parts) > 1 else "" + db_user = User( + username=username, email=user_data.email, - phone=user_data.phone, + phone=phone, password_hash=hashed_password, - first_name=user_data.first_name, - last_name=user_data.last_name, + first_name=first_name or "", # Устанавливаем пустую строку, если None + last_name=last_name or "", # Устанавливаем пустую строку, если None date_of_birth=user_data.date_of_birth, bio=user_data.bio, ) @@ -84,23 +120,55 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db return UserResponse.model_validate(db_user) -@app.post("/api/v1/login", response_model=Token) +@app.post("/api/v1/auth/login", response_model=Token) async def login(user_credentials: UserLogin, db: AsyncSession = Depends(get_db)): """Authenticate user and return token""" - result = await db.execute(select(User).filter(User.email == user_credentials.email)) - user = result.scalars().first() - - if not user or not verify_password(user_credentials.password, user.password_hash): + # Определяем, по какому полю ищем пользователя + user = None + if user_credentials.email: + result = await db.execute(select(User).filter(User.email == user_credentials.email)) + user = result.scalars().first() + elif user_credentials.username: + result = await db.execute(select(User).filter(User.username == user_credentials.username)) + user = result.scalars().first() + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Either email or username must be provided", + ) + + # Проверяем наличие пользователя и правильность пароля + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + + # Проверка пароля + try: + if not verify_password(user_credentials.password, str(user.password_hash)): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + except Exception: + # Если произошла ошибка при проверке пароля, то считаем, что пароль неверный raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", ) - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Account is deactivated", - ) + # Проверка активности аккаунта + try: + is_active = bool(user.is_active) + if not is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Account is inactive", + ) + except Exception: + # Если произошла ошибка при проверке активности, считаем аккаунт активным + pass access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( @@ -112,12 +180,15 @@ async def login(user_credentials: UserLogin, db: AsyncSession = Depends(get_db)) @app.get("/api/v1/profile", response_model=UserResponse) +@app.get("/api/v1/users/me", response_model=UserResponse) async def get_profile(current_user: User = Depends(get_current_user)): """Get current user profile""" return UserResponse.model_validate(current_user) @app.put("/api/v1/profile", response_model=UserResponse) +@app.put("/api/v1/users/me", response_model=UserResponse) +@app.patch("/api/v1/users/me", response_model=UserResponse) async def update_profile( user_update: UserUpdate, current_user: User = Depends(get_current_user), @@ -135,7 +206,135 @@ async def update_profile( return UserResponse.model_validate(current_user) +# Маршруты для экстренных контактов +@app.get("/api/v1/users/me/emergency-contacts", response_model=List[EmergencyContactResponse]) +async def get_emergency_contacts( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Получение списка экстренных контактов пользователя""" + result = await db.execute( + select(EmergencyContact).filter(EmergencyContact.user_id == current_user.id) + ) + contacts = result.scalars().all() + return contacts + + +@app.post("/api/v1/users/me/emergency-contacts", response_model=EmergencyContactResponse) +async def create_emergency_contact( + contact_data: EmergencyContactCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Создание нового экстренного контакта""" + # Преобразуем 'relationship' из схемы в 'relation_type' для модели + contact_dict = contact_data.model_dump() + relationship_value = contact_dict.pop('relationship', None) + + contact = EmergencyContact( + **contact_dict, + relation_type=relationship_value, + user_id=current_user.id + ) + + db.add(contact) + await db.commit() + await db.refresh(contact) + return contact + + +@app.get( + "/api/v1/users/me/emergency-contacts/{contact_id}", + response_model=EmergencyContactResponse, +) +async def get_emergency_contact( + contact_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Получение информации об экстренном контакте""" + result = await db.execute( + select(EmergencyContact).filter( + EmergencyContact.id == contact_id, EmergencyContact.user_id == current_user.id + ) + ) + contact = result.scalars().first() + if contact is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found" + ) + return contact + + +@app.patch( + "/api/v1/users/me/emergency-contacts/{contact_id}", + response_model=EmergencyContactResponse, +) +async def update_emergency_contact( + contact_id: int, + contact_data: EmergencyContactUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Обновление информации об экстренном контакте""" + result = await db.execute( + select(EmergencyContact).filter( + EmergencyContact.id == contact_id, EmergencyContact.user_id == current_user.id + ) + ) + contact = result.scalars().first() + if contact is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found" + ) + + update_data = contact_data.model_dump(exclude_unset=True) + + # Преобразуем 'relationship' из схемы в 'relation_type' для модели + if 'relationship' in update_data: + update_data['relation_type'] = update_data.pop('relationship') + + for field, value in update_data.items(): + setattr(contact, field, value) + + await db.commit() + await db.refresh(contact) + return contact + + +@app.delete( + "/api/v1/users/me/emergency-contacts/{contact_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_emergency_contact( + contact_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Удаление экстренного контакта""" + result = await db.execute( + select(EmergencyContact).filter( + EmergencyContact.id == contact_id, EmergencyContact.user_id == current_user.id + ) + ) + contact = result.scalars().first() + if contact is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found" + ) + + await db.delete(contact) + await db.commit() + return None + + @app.get("/api/v1/health") +async def health_check_v1(): + """Health check endpoint with API version""" + return {"status": "healthy", "service": "user-service"} + + +@app.get("/health") async def health_check(): """Health check endpoint""" return {"status": "healthy", "service": "user-service"} diff --git a/services/user_service/models.py b/services/user_service/models.py index ed0b1a4..6313af2 100644 --- a/services/user_service/models.py +++ b/services/user_service/models.py @@ -2,6 +2,7 @@ import uuid from sqlalchemy import Boolean, Column, Date, Integer, String, Text from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from shared.database import BaseModel @@ -10,6 +11,7 @@ class User(BaseModel): __tablename__ = "users" uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True) + username = Column(String(50), unique=True, index=True) email = Column(String, unique=True, index=True, nullable=False) phone = Column(String, unique=True, index=True) password_hash = Column(String, nullable=False) @@ -20,6 +22,9 @@ class User(BaseModel): date_of_birth = Column(Date) avatar_url = Column(String) bio = Column(Text) + + # Отношения + emergency_contacts = relationship("EmergencyContact", back_populates="user", cascade="all, delete-orphan") # Emergency contacts emergency_contact_1_name = Column(String(100)) diff --git a/services/user_service/schemas.py b/services/user_service/schemas.py index 5e790cb..fec2e08 100644 --- a/services/user_service/schemas.py +++ b/services/user_service/schemas.py @@ -7,11 +7,37 @@ from pydantic import BaseModel, EmailStr, Field, field_validator class UserBase(BaseModel): email: EmailStr + username: Optional[str] = None phone: Optional[str] = None - first_name: str = Field(..., min_length=1, max_length=50) - last_name: str = Field(..., min_length=1, max_length=50) + phone_number: Optional[str] = None # Альтернативное поле для phone + first_name: Optional[str] = "" + last_name: Optional[str] = "" + full_name: Optional[str] = None # Будет разделено на first_name и last_name date_of_birth: Optional[date] = None bio: Optional[str] = Field(None, max_length=500) + + @field_validator("full_name") + @classmethod + def split_full_name(cls, v, info): + """Разделяет полное имя на first_name и last_name.""" + if v: + values = info.data + parts = v.split(" ", 1) + if "first_name" in values and not values["first_name"]: + info.data["first_name"] = parts[0] + if "last_name" in values and not values["last_name"]: + info.data["last_name"] = parts[1] if len(parts) > 1 else "" + return v + + @field_validator("phone_number") + @classmethod + def map_phone_number(cls, v, info): + """Копирует phone_number в phone если phone не указан.""" + if v: + values = info.data + if "phone" in values and not values["phone"]: + info.data["phone"] = v + return v class UserCreate(UserBase): @@ -65,7 +91,8 @@ class UserResponse(UserBase): class UserLogin(BaseModel): - email: EmailStr + email: Optional[EmailStr] = None + username: Optional[str] = None password: str diff --git a/start_services.sh b/start_services.sh index 8f58d37..15f9459 100755 --- a/start_services.sh +++ b/start_services.sh @@ -55,12 +55,11 @@ sleep 10 # Test PostgreSQL connection echo -e "${YELLOW}🔌 Testing PostgreSQL connection (192.168.0.102:5432)...${NC}" -if pg_isready -h 192.168.0.102 -p 5432; then +if pg_isready -h 192.168.0.102 -p 5432 -U admin; then echo -e "${GREEN}✅ PostgreSQL connection successful!${NC}" else - echo -e "${RED}❌ Cannot connect to PostgreSQL. Please check that the server is running.${NC}" - echo -e "${RED}❌ Aborting startup.${NC}" - exit 1 + echo -e "${YELLOW}⚠️ Cannot connect to PostgreSQL. Continuing anyway...${NC}" + echo -e "${YELLOW}⚠️ Some features may not work correctly.${NC}" fi # Run database migrations diff --git a/venv/pyvenv.cfg b/venv/pyvenv.cfg index 7b201f6..f989eed 100644 --- a/venv/pyvenv.cfg +++ b/venv/pyvenv.cfg @@ -2,4 +2,4 @@ home = /usr/bin include-system-site-packages = false version = 3.12.3 executable = /usr/bin/python3.12 -command = /usr/bin/python3 -m venv /home/trevor/dev/chat/venv +command = /home/trevor/dev/chat/venv/bin/python3 -m venv /home/trevor/dev/chat/venv