Skip to content
Merged

Merge #101

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions rating_api/routes/base.py
Original file line number Diff line number Diff line change
@@ -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='Хранение и работа с рейтингом преподавателей и отзывами на них.',
Expand All @@ -19,7 +20,6 @@
redoc_url=None,
)


app.add_middleware(
DBSessionMiddleware,
db_url=str(settings.DB_DSN),
Expand All @@ -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
25 changes: 25 additions & 0 deletions rating_api/routes/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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


Expand Down
6 changes: 4 additions & 2 deletions rating_api/routes/lecturer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions rating_api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand All @@ -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")

Expand Down
83 changes: 83 additions & 0 deletions rating_api/utils/logging_utils.py
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
alembic
auth-lib-profcomff[fastapi]
aiohttp
fastapi
fastapi-sqlalchemy
gunicorn
Expand Down