diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index adbafe2..233ac3c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -10,6 +10,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Init daemon.json + run: sudo touch /etc/docker/daemon.json - name: Set up docker uses: docker-practice/actions-setup-docker@master - name: Run postgres diff --git a/rating_api/routes/base.py b/rating_api/routes/base.py index d320a86..3d725d7 100644 --- a/rating_api/routes/base.py +++ b/rating_api/routes/base.py @@ -1,14 +1,15 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi_sqlalchemy import DBSessionMiddleware from rating_api import __version__ from rating_api.routes.comment import comment from rating_api.routes.lecturer import lecturer -from rating_api.settings import get_settings +from rating_api.settings import Settings, get_settings +from rating_api.utils.logging_utils import get_request_body, log_request -settings = get_settings() +settings: Settings = get_settings() app = FastAPI( title='Рейтинг преподавателей', description='Хранение и работа с рейтингом преподавателей и отзывами на них.', @@ -19,7 +20,6 @@ redoc_url=None, ) - app.add_middleware( DBSessionMiddleware, db_url=str(settings.DB_DSN), @@ -36,3 +36,19 @@ app.include_router(lecturer) app.include_router(comment) + + +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + """Основной middleware, который логирует запрос и восстанавливает тело.""" + try: + request, json_body = await get_request_body(request) # Получаем тело и восстанавливаем request + response: Response = await call_next(request) + status_code = response.status_code + except Exception: + status_code = 500 + response = Response(content="Internal server error", status_code=500) + if __version__ != "dev": # Локально не отправляем логи в маркетинг + await log_request(request, status_code, json_body) # Логируем запрос + + return response diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index 1dfa07c..ee58be5 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -2,6 +2,7 @@ from typing import Literal from uuid import UUID +import aiohttp from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, Query from fastapi_sqlalchemy import db @@ -94,6 +95,29 @@ async def create_comment(lecturer_id: int, comment_info: CommentPost, user=Depen user_id=user_id, review_status=ReviewStatus.PENDING, ) + + # Выдача аччивки юзеру за первый комментарий + async with aiohttp.ClientSession() as session: + give_achievement = True + async with session.get( + settings.API_URL + f"achievement/user/{user.get('id'):}", + headers={"Accept": "application/json"}, + ) as response: + if response.status == 200: + user_achievements = await response.json() + for achievement in user_achievements.get("achievement", []): + if achievement.get("id") == settings.FIRST_COMMENT_ACHIEVEMENT_ID: + give_achievement = False + break + else: + give_achievement = False + if give_achievement: + session.post( + settings.API_URL + + f"achievement/achievement/{settings.FIRST_COMMENT_ACHIEVEMENT_ID}/reciever/{user.get('id'):}", + headers={"Accept": "application/json", "Authorization": settings.ACHIEVEMENT_GIVE_TOKEN}, + ) + return CommentGet.model_validate(new_comment) @@ -182,6 +206,7 @@ async def get_comments( result.comments.sort(key=lambda comment: comment.create_ts, reverse=True) result.total = len(result.comments) result.comments = [CommentGet.model_validate(comment) for comment in result.comments] + result.comments.sort(key=lambda comment: comment.create_ts, reverse=True) return result diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index 1bdd563..e7c0e4d 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -59,7 +59,7 @@ async def get_lecturer(id: int, info: list[Literal["comments", "mark"]] = Query( if comment.review_status is ReviewStatus.APPROVED ] if "comments" in info and approved_comments: - result.comments = approved_comments + result.comments = sorted(approved_comments, key=lambda comment: comment.create_ts, reverse=True) if "mark" in info and approved_comments: result.mark_freebie = sum(comment.mark_freebie for comment in approved_comments) / len(approved_comments) result.mark_kindness = sum(comment.mark_kindness for comment in approved_comments) / len(approved_comments) @@ -135,7 +135,9 @@ async def get_lecturers( if comment.review_status is ReviewStatus.APPROVED ] if "comments" in info and approved_comments: - lecturer_to_result.comments = approved_comments + lecturer_to_result.comments = sorted( + approved_comments, key=lambda comment: comment.create_ts, reverse=True + ) if "mark" in info and approved_comments: lecturer_to_result.mark_freebie = sum([comment.mark_freebie for comment in approved_comments]) / len( approved_comments diff --git a/rating_api/settings.py b/rating_api/settings.py index 1f7612c..a67692b 100644 --- a/rating_api/settings.py +++ b/rating_api/settings.py @@ -5,6 +5,13 @@ from pydantic_settings import BaseSettings +LOGGING_MARKETING_URLS = { + "dev": f"http://localhost:{os.getenv('MARKETING_PORT', 8000)}/v1/action", + "test": "https://api.test.profcomff.com/marketing/v1/action", + "prod": "https://api.profcomff.com/marketing/v1/action", +} + + class Settings(BaseSettings): """Application settings""" @@ -18,6 +25,15 @@ class Settings(BaseSettings): CORS_ALLOW_CREDENTIALS: bool = True CORS_ALLOW_METHODS: list[str] = ['*'] CORS_ALLOW_HEADERS: list[str] = ['*'] + LOGGING_MARKETING_URL: str = LOGGING_MARKETING_URLS.get( + os.getenv("APP_VERSION", "dev"), LOGGING_MARKETING_URLS["test"] + ) + + '''Temp settings''' + + API_URL: str = "https://api.test.profcomff.com/" + FIRST_COMMENT_ACHIEVEMENT_ID: int = 12 + ACHIEVEMENT_GIVE_TOKEN: str = "" model_config = ConfigDict(case_sensitive=True, env_file=".env", extra="ignore") diff --git a/rating_api/utils/logging_utils.py b/rating_api/utils/logging_utils.py new file mode 100644 index 0000000..18c4a10 --- /dev/null +++ b/rating_api/utils/logging_utils.py @@ -0,0 +1,83 @@ +import asyncio +import json +import logging + +import httpx +from auth_lib.fastapi import UnionAuth +from fastapi import Request + +from rating_api.settings import Settings, get_settings + + +settings: Settings = get_settings() + +log = logging.getLogger(__name__) + +RETRY_DELAYS = [2, 4, 8] # Задержки перед повторными попытками (в секундах) + + +async def send_log(log_data): + """Отправляем лог на внешний сервис асинхронно с обработкой ошибок и ретраями""" + async with httpx.AsyncClient() as client: + for attempt, sleep_time in enumerate(RETRY_DELAYS, start=1): + try: + response = await client.post(settings.LOGGING_MARKETING_URL, json=log_data) + + if response.status_code not in {408, 409, 429, 500, 502, 503, 504}: + log.info(f"Ответ записи логов от markting status_code: {response.status_code}") + break # Успешно или ошибки, которые не стоит повторять (например, неправильные данные) + + except httpx.HTTPStatusError as e: + log.warning(f"HTTP ошибка ({e.response.status_code}): {e.response.text}") + + except httpx.RequestError as e: + log.warning(f"Ошибка сети: {e}") + + except Exception as e: + log.warning(f"Неизвестная ошибка: {e}") + + await asyncio.sleep(sleep_time) # Ожидание перед повторной попыткой + + else: + log.warning("Не удалось отправить лог после нескольких попыток.") + + +async def get_request_body(request: Request) -> tuple[Request, str]: + """Читает тело запроса и возвращает новый request и тело в виде JSON.""" + body = await request.body() + json_body = json.loads(body) if body else {} # В json(dict) from byte string + + async def new_stream(): + yield body + + return Request(request.scope, receive=new_stream()), json_body + + +async def get_user_id(request: Request): + """Получает user_id из UnionAuth""" + try: + user_id = UnionAuth()(request).get('id') + except Exception as e: + user_id = "Not auth" # Или лучше -1? чтобы типизация :int была? + log.error(f"USER_AUTH: {e}") + + return user_id + + +async def log_request(request: Request, status_code: int, json_body: dict): + """Формирует лог и отправляет его в асинхронную задачу.""" + + additional_data = { + "response_status_code": status_code, + "auth_user_id": await get_user_id(request), + "query": request.url.path + "?" + request.url.query, + "request": json_body, + } + log_data = { + "user_id": -3, + "action": request.method, + "additional_data": json.dumps(additional_data), + "path_from": '', # app.root_path + "path_to": request.url.path, + } + asyncio.create_task(send_log(log_data)) diff --git a/requirements.txt b/requirements.txt index 1b6d4e1..ed9f291 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ alembic auth-lib-profcomff[fastapi] +aiohttp fastapi fastapi-sqlalchemy gunicorn