From 93fbd14cc48f2d6d9aa7ce18349dd416b754d9ba Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Sat, 6 Dec 2025 00:14:04 +0500 Subject: [PATCH] test news --- app/api/news.py | 86 +++++++++++++++++++++++++++++++++++++++ app/models/news.py | 28 +++++++++++++ app/models/user.py | 1 + app/services/auth.py | 14 ++++++- app/services/news.py | 97 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 app/api/news.py create mode 100644 app/models/news.py create mode 100644 app/services/news.py diff --git a/app/api/news.py b/app/api/news.py new file mode 100644 index 0000000..eef9353 --- /dev/null +++ b/app/api/news.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, HTTPException, Query, Depends, Form +from typing import List + +from app.services.auth import AuthService +from app.services.news import NewsService +from app.models.news import NewsCreate, NewsUpdate, NewsInDB + +router = APIRouter(tags=["News"]) + +news_service = NewsService() + +# --- Публичные эндпоинты для лаунчера --- + +@router.get("/news", response_model=List[NewsInDB]) +async def list_news( + limit: int = Query(20, ge=1, le=100), + skip: int = Query(0, ge=0), +): + """ + Список опубликованных новостей (для лаунчера). + """ + return await news_service.list_news(limit=limit, skip=skip, include_unpublished=False) + + +@router.get("/news/{news_id}", response_model=NewsInDB) +async def get_news(news_id: str): + """ + Получить одну новость по id (для лаунчера). + """ + return await news_service.get_news(news_id) + +# --- Админские эндпоинты (создание/редактирование) --- + +async def validate_admin(accessToken: str, clientToken: str): + auth = AuthService() + if not await auth.is_admin(accessToken, clientToken): + raise HTTPException(status_code=403, detail="Admin privileges required") + +@router.post("/news", response_model=NewsInDB) +async def create_news( + accessToken: str = Form(...), + clientToken: str = Form(...), + title: str = Form(...), + markdown: str = Form(...), + preview: str = Form(""), + is_published: bool = Form(True), +): + await validate_admin(accessToken, clientToken) + payload = NewsCreate( + title=title, + markdown=markdown, + preview=preview or None, + is_published=is_published, + tags=[], + ) + return await news_service.create_news(payload) + + +@router.put("/news/{news_id}", response_model=NewsInDB) +async def update_news( + news_id: str, + accessToken: str = Form(...), + clientToken: str = Form(...), + title: str | None = Form(None), + markdown: str | None = Form(None), + preview: str | None = Form(None), + is_published: bool | None = Form(None), +): + await validate_admin(accessToken, clientToken) + payload = NewsUpdate( + title=title, + markdown=markdown, + preview=preview, + is_published=is_published, + ) + return await news_service.update_news(news_id, payload) + + +@router.delete("/news/{news_id}") +async def delete_news( + news_id: str, + accessToken: str, + clientToken: str, +): + await validate_admin(accessToken, clientToken) + return await news_service.delete_news(news_id) diff --git a/app/models/news.py b/app/models/news.py new file mode 100644 index 0000000..57bc6f7 --- /dev/null +++ b/app/models/news.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class NewsBase(BaseModel): + title: str = Field(..., max_length=200) + markdown: str # полный текст в Markdown + preview: Optional[str] = None # краткий текст/анонс (тоже можно в MD) + tags: List[str] = [] + is_published: bool = True + +class NewsCreate(NewsBase): + pass + +class NewsUpdate(BaseModel): + title: Optional[str] = None + markdown: Optional[str] = None + preview: Optional[str] = None + tags: Optional[List[str]] = None + is_published: Optional[bool] = None + +class NewsInDB(NewsBase): + id: str + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True diff --git a/app/models/user.py b/app/models/user.py index e49038c..a527829 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,6 +25,7 @@ class UserInDB(BaseModel): telegram_id: Optional[str] = None is_verified: bool = False code_expires_at: Optional[datetime] = None + is_admin: bool = False class Session(BaseModel): access_token: str client_token: str diff --git a/app/services/auth.py b/app/services/auth.py index 11f22c3..4395652 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -42,7 +42,8 @@ class AuthService: uuid=user_uuid, is_verified=False, code=None, - code_expires_at=None + code_expires_at=None, + is_admin=False ) await users_collection.insert_one(new_user.dict()) return {"status": "success", "uuid": user_uuid} @@ -130,6 +131,17 @@ class AuthService: if not session or datetime.utcnow() > session["expires_at"]: return False return True + + async def is_admin(self, access_token: str, client_token: str) -> bool: + session = await sessions_collection.find_one({ + "access_token": access_token, + "client_token": client_token, + }) + if not session: + return False + + user = await users_collection.find_one({"uuid": session["user_uuid"]}) + return user and user.get("is_admin") is True async def refresh(self, access_token: str, client_token: str): if not await self.validate(access_token, client_token): diff --git a/app/services/news.py b/app/services/news.py new file mode 100644 index 0000000..5da8774 --- /dev/null +++ b/app/services/news.py @@ -0,0 +1,97 @@ +from datetime import datetime +from typing import List, Optional +from fastapi import HTTPException +from app import db +from bson import ObjectId +from app.models.news import NewsCreate, NewsUpdate, NewsInDB + +news_collection = db["news"] + +class NewsService: + @staticmethod + def _to_news_in_db(doc) -> NewsInDB: + return NewsInDB( + id=str(doc["_id"]), + title=doc["title"], + markdown=doc["markdown"], + preview=doc.get("preview"), + tags=doc.get("tags", []), + is_published=doc.get("is_published", True), + created_at=doc["created_at"], + updated_at=doc["updated_at"], + ) + + async def list_news(self, limit: int = 20, skip: int = 0, include_unpublished: bool = False) -> List[NewsInDB]: + query = {} + if not include_unpublished: + query["is_published"] = True + + cursor = ( + news_collection + .find(query) + .sort("created_at", -1) + .skip(skip) + .limit(limit) + ) + docs = await cursor.to_list(length=limit) + return [self._to_news_in_db(d) for d in docs] + + async def get_news(self, news_id: str) -> NewsInDB: + try: + oid = ObjectId(news_id) + except: + raise HTTPException(status_code=400, detail="Invalid news id") + + doc = await news_collection.find_one({"_id": oid}) + if not doc: + raise HTTPException(status_code=404, detail="News not found") + return self._to_news_in_db(doc) + + async def create_news(self, payload: NewsCreate) -> NewsInDB: + now = datetime.utcnow() + doc = { + "title": payload.title, + "markdown": payload.markdown, + "preview": payload.preview, + "tags": payload.tags, + "is_published": payload.is_published, + "created_at": now, + "updated_at": now, + } + result = await news_collection.insert_one(doc) + doc["_id"] = result.inserted_id + return self._to_news_in_db(doc) + + async def update_news(self, news_id: str, payload: NewsUpdate) -> NewsInDB: + try: + oid = ObjectId(news_id) + except: + raise HTTPException(status_code=400, detail="Invalid news id") + + update_data = {k: v for k, v in payload.dict(exclude_unset=True).items()} + if not update_data: + return await self.get_news(news_id) + + update_data["updated_at"] = datetime.utcnow() + + result = await news_collection.find_one_and_update( + {"_id": oid}, + {"$set": update_data}, + return_document=True, + ) + + if not result: + raise HTTPException(status_code=404, detail="News not found") + + return self._to_news_in_db(result) + + async def delete_news(self, news_id: str): + try: + oid = ObjectId(news_id) + except: + raise HTTPException(status_code=400, detail="Invalid news id") + + result = await news_collection.delete_one({"_id": oid}) + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="News not found") + return {"status": "success"}