new web ract
This commit is contained in:
@@ -21,6 +21,7 @@ class ArticleIn(BaseModel):
|
||||
content: str = ""
|
||||
excerpt: str = ""
|
||||
cover_url: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
font_family: str = "Merriweather"
|
||||
category_id: Optional[str] = None
|
||||
tag_names: list[str] = []
|
||||
@@ -35,6 +36,7 @@ def _article_dict(a: Article) -> dict:
|
||||
"content": a.content,
|
||||
"excerpt": a.excerpt,
|
||||
"cover_url": a.cover_url,
|
||||
"source_url": a.source_url,
|
||||
"font_family": a.font_family,
|
||||
"status": a.status.value,
|
||||
"view_count": a.view_count,
|
||||
@@ -73,14 +75,19 @@ async def list_articles(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
status: Optional[ArticleStatus] = Query(None),
|
||||
q: Optional[str] = Query(None, max_length=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
|
||||
from sqlalchemy import or_
|
||||
stmt = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
|
||||
if status:
|
||||
q = q.where(Article.status == status)
|
||||
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
|
||||
articles = (await db.execute(q.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
stmt = stmt.where(Article.status == status)
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
stmt = stmt.where(or_(Article.title.ilike(pattern), Article.excerpt.ilike(pattern)))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
articles = (await db.execute(stmt.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
@@ -115,6 +122,7 @@ async def create_article(
|
||||
content=data.content,
|
||||
excerpt=data.excerpt,
|
||||
cover_url=data.cover_url,
|
||||
source_url=data.source_url,
|
||||
font_family=data.font_family,
|
||||
status=data.status,
|
||||
category=cat,
|
||||
@@ -173,7 +181,8 @@ async def update_article(
|
||||
article.slug = slug
|
||||
article.content = data.content
|
||||
article.excerpt = data.excerpt
|
||||
article.cover_url = data.cover_url
|
||||
article.cover_url = data.cover_url
|
||||
article.source_url = data.source_url
|
||||
article.font_family = data.font_family
|
||||
article.status = data.status
|
||||
article.category = cat
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -9,17 +9,24 @@ from passlib.context import CryptContext
|
||||
|
||||
from bd.database import get_db, AsyncSessionLocal
|
||||
from bd.models import Admin
|
||||
from rate_limiter import limiter
|
||||
|
||||
router = APIRouter()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
|
||||
JWT_ALGO = "HS256"
|
||||
JWT_EXP_HOURS = 24
|
||||
_raw_secret = os.getenv("JWT_SECRET", "")
|
||||
if not _raw_secret:
|
||||
import secrets as _s
|
||||
_raw_secret = _s.token_hex(32)
|
||||
print("[WARN] JWT_SECRET не задан — сгенерирован временный секрет. Установи JWT_SECRET в .env!")
|
||||
|
||||
JWT_SECRET = _raw_secret
|
||||
JWT_ALGO = "HS256"
|
||||
JWT_EXP_HOURS = 8
|
||||
|
||||
|
||||
def _make_token(username: str) -> str:
|
||||
exp = datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS)
|
||||
exp = datetime.now(timezone.utc) + timedelta(hours=JWT_EXP_HOURS)
|
||||
return jwt.encode({"sub": username, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGO)
|
||||
|
||||
|
||||
@@ -40,7 +47,8 @@ class LoginRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
@limiter.limit("5/minute")
|
||||
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
admin = (await db.execute(select(Admin).where(Admin.username == data.username))).scalar_one_or_none()
|
||||
if not admin or not pwd_context.verify(data.password, admin.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль")
|
||||
@@ -48,12 +56,7 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from route.deps import require_admin
|
||||
# handled with deps in main
|
||||
async def change_password(data: dict, db: AsyncSession = Depends(get_db)):
|
||||
admin = (await db.execute(select(Admin).where(Admin.username == data["username"]))).scalar_one_or_none()
|
||||
if not admin or not pwd_context.verify(data["current_password"], admin.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Неверный текущий пароль")
|
||||
|
||||
@@ -99,20 +99,25 @@ async def upload_media(
|
||||
@router.get("")
|
||||
async def list_media(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
limit: int = Query(40, ge=1, le=200),
|
||||
media_type: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
q = select(Media)
|
||||
from sqlalchemy import func
|
||||
base = select(Media)
|
||||
if media_type:
|
||||
q = q.where(Media.media_type == media_type)
|
||||
items = (await db.execute(q.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
return [
|
||||
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
|
||||
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
|
||||
for m in items
|
||||
]
|
||||
base = base.where(Media.media_type == media_type)
|
||||
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
|
||||
items = (await db.execute(base.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"items": [
|
||||
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
|
||||
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
|
||||
for m in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{media_id}")
|
||||
|
||||
113
api/route/admin_vk.py
Normal file
113
api/route/admin_vk.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bd.database import get_db
|
||||
from bd.models import VkSource
|
||||
from route.deps import require_admin
|
||||
from vk_parser import run_import, run_history_import, VK_TOKEN
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SourceIn(BaseModel):
|
||||
group_id: str
|
||||
group_name: str
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
def _source_dict(s: VkSource) -> dict:
|
||||
return {
|
||||
"id": str(s.id),
|
||||
"group_id": s.group_id,
|
||||
"group_name": s.group_name,
|
||||
"enabled": s.enabled,
|
||||
"last_run": s.last_run.isoformat() if s.last_run else None,
|
||||
"created_at": s.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def vk_status(_: str = Depends(require_admin)):
|
||||
return {"configured": bool(VK_TOKEN)}
|
||||
|
||||
|
||||
# ── CRUD источников ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/sources")
|
||||
async def list_sources(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
rows = (await db.execute(select(VkSource).order_by(VkSource.created_at))).scalars().all()
|
||||
return [_source_dict(r) for r in rows]
|
||||
|
||||
|
||||
def _clean_group_id(raw: str) -> str:
|
||||
"""Из строки вида '-95503797', '95503797_6512', 'wall-95503797_12' вытащить только числовой ID группы."""
|
||||
import re
|
||||
raw = raw.strip().lstrip("-")
|
||||
# Берём только первое число (до '_' если есть)
|
||||
m = re.search(r"(\d+)", raw)
|
||||
return m.group(1) if m else raw
|
||||
|
||||
|
||||
@router.post("/sources", status_code=201)
|
||||
async def add_source(data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
group_id = _clean_group_id(data.group_id)
|
||||
exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть")
|
||||
source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled)
|
||||
db.add(source)
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
return _source_dict(source)
|
||||
|
||||
|
||||
@router.patch("/sources/{source_id}")
|
||||
async def update_source(source_id: str, data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
source.group_id = _clean_group_id(data.group_id)
|
||||
source.group_name = data.group_name
|
||||
source.enabled = data.enabled
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
return _source_dict(source)
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", status_code=204)
|
||||
async def delete_source(source_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Ручной запуск импорта ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/import")
|
||||
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
||||
background_tasks.add_task(run_import)
|
||||
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
|
||||
|
||||
|
||||
@router.post("/import/history")
|
||||
async def vk_history_import_all(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
||||
background_tasks.add_task(run_history_import)
|
||||
return {"ok": True, "message": "Исторический импорт всех групп запущен в фоне (до 5 мин)"}
|
||||
|
||||
|
||||
@router.post("/import/history/{source_id}")
|
||||
async def vk_history_import_one(
|
||||
source_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
background_tasks.add_task(run_history_import, group_id=source.group_id)
|
||||
return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"}
|
||||
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from bd.database import get_db, get_redis
|
||||
from bd.models import Article, Category, Tag, ArticleStatus
|
||||
from bd.models import Article, Category, Tag, ArticleStatus, article_tag
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +23,7 @@ def _article_card(a: Article) -> dict:
|
||||
"slug": a.slug,
|
||||
"excerpt": a.excerpt,
|
||||
"cover_url": a.cover_url,
|
||||
"source_url": a.source_url,
|
||||
"published_at": a.published_at.isoformat() if a.published_at else None,
|
||||
"view_count": a.view_count,
|
||||
"category": _cat_dict(a.category) if a.category else None,
|
||||
@@ -42,10 +44,13 @@ async def list_news(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
category: str | None = Query(None),
|
||||
tag: str | None = Query(None),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
q: str | None = Query(None, max_length=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}"
|
||||
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}:{date_from or ''}:{date_to or ''}:{q or ''}"
|
||||
cached = await redis.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
@@ -68,6 +73,29 @@ async def list_news(
|
||||
if t:
|
||||
base = base.where(Article.tags.any(Tag.id == t.id))
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
base = base.where(Article.published_at >= datetime.fromisoformat(date_from))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
base = base.where(Article.published_at <= datetime.fromisoformat(date_to))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
base = base.where(
|
||||
or_(
|
||||
Article.title.ilike(pattern),
|
||||
Article.excerpt.ilike(pattern),
|
||||
Article.content.ilike(pattern),
|
||||
Article.tags.any(Tag.name.ilike(pattern)),
|
||||
)
|
||||
)
|
||||
|
||||
count_q = select(func.count()).select_from(base.subquery())
|
||||
total: int = (await db.execute(count_q)).scalar_one()
|
||||
|
||||
@@ -82,7 +110,7 @@ async def list_news(
|
||||
"has_more": (offset + limit) < total,
|
||||
"items": [_article_card(a) for a in articles],
|
||||
}
|
||||
await redis.setex(cache_key, 30, json.dumps(result, default=str))
|
||||
await redis.setex(cache_key, 30 if q else 120, json.dumps(result, default=str))
|
||||
return result
|
||||
|
||||
|
||||
@@ -96,7 +124,36 @@ async def list_categories(
|
||||
return json.loads(cached)
|
||||
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
|
||||
result = [_cat_dict(c) for c in cats]
|
||||
await redis.setex("news:categories", 60, json.dumps(result))
|
||||
await redis.setex("news:categories", 300, json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def list_tags(
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
cache_key = f"news:tags:{limit}"
|
||||
cached = await redis.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
q = (
|
||||
select(Tag, func.count(article_tag.c.article_id).label("cnt"))
|
||||
.join(article_tag, Tag.id == article_tag.c.tag_id)
|
||||
.join(Article, article_tag.c.article_id == Article.id)
|
||||
.where(Article.status == ArticleStatus.published)
|
||||
.group_by(Tag.id)
|
||||
.order_by(func.count(article_tag.c.article_id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(q)).all()
|
||||
result = [
|
||||
{"id": str(r.Tag.id), "name": r.Tag.name, "slug": r.Tag.slug, "count": r.cnt}
|
||||
for r in rows
|
||||
]
|
||||
await redis.setex(cache_key, 300, json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user