added RAG, Multiuser, TG bot

This commit is contained in:
2026-06-13 20:20:56 +00:00
parent 66e1b0e29e
commit c8a9429bed
142 changed files with 19901 additions and 8790 deletions
+20 -17
View File
@@ -1,17 +1,20 @@
from fastapi import APIRouter
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
from fastapi import APIRouter
from app.api.routes import auth, character, chat, documents, fitness, health, homelab, media, memory, pomodoro, projects, reminders, settings, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router)
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
api_router.include_router(settings.router, tags=["settings"])
api_router.include_router(documents.router, tags=["documents"])
@@ -0,0 +1,17 @@
from fastapi import APIRouter
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
api_router.include_router(homelab.router, tags=["homelab"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(reminders.router, prefix="/reminders", tags=["reminders"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
+73
View File
@@ -0,0 +1,73 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.auth.service import create_user, find_user_by_token, user_to_dict
from app.db.base import get_db
from app.db.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
token: str = Field(min_length=8, max_length=256)
class CreateUserRequest(BaseModel):
username: str = Field(min_length=2, max_length=64)
display_name: str = ""
token: str | None = Field(default=None, min_length=8, max_length=256)
@router.post("/login")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict[str, Any]:
user = find_user_by_token(db, payload.token)
if not user:
raise HTTPException(status_code=401, detail="Неверный токен")
return {"ok": True, "user": user_to_dict(user), "token": payload.token.strip()}
@router.get("/me")
def me(user: User = Depends(get_current_user)) -> dict[str, Any]:
return {"ok": True, "user": user_to_dict(user)}
@router.get("/users")
def list_users(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
rows = db.scalars(select(User).where(User.is_active.is_(True)).order_by(User.id)).all()
return {
"ok": True,
"users": [user_to_dict(row) for row in rows],
"current_user_id": user.id,
}
@router.post("/users")
def register_user(
payload: CreateUserRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
new_user, plain_token = create_user(
db,
username=payload.username,
display_name=payload.display_name,
api_token=payload.token,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"ok": True,
"user": user_to_dict(new_user),
"token": plain_token,
"created_by": user.username,
}
+80 -62
View File
@@ -1,62 +1,80 @@
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.character.service import CharacterService
router = APIRouter()
class CharacterCardData(BaseModel):
name: str = "Ассистент"
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
tags: list[str] = Field(default_factory=list)
creator: str = ""
creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0"
appearance_tags: str = ""
appearance_prose: str = ""
lora_name: str = ""
lora_weight: float = 0.8
rp_persona_id: str = ""
sd_enabled: bool = True
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2"
spec_version: str = "2.0"
data: CharacterCardData
@router.get("/character")
def get_character() -> dict[str, Any]:
return CharacterService().get_card()
@router.put("/character")
def update_character(payload: CharacterCardV2) -> dict[str, Any]:
return CharacterService().save_card(payload.model_dump())
@router.get("/character/prompt")
def get_character_prompt() -> dict[str, str]:
service = CharacterService()
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(payload: dict[str, Any]) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService().save_card(payload)
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.character.service import CharacterService
from app.db.base import get_db
from app.db.models import User
router = APIRouter()
class CharacterCardData(BaseModel):
name: str = "Ассистент"
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
tags: list[str] = Field(default_factory=list)
creator: str = ""
creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0"
appearance_tags: str = ""
appearance_prose: str = ""
lora_name: str = ""
lora_weight: float = 0.8
rp_persona_id: str = ""
sd_enabled: bool = True
class CharacterCardV2(BaseModel):
spec: str = "chara_card_v2"
spec_version: str = "2.0"
data: CharacterCardData
@router.get("/character")
def get_character(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).get_card()
@router.put("/character")
def update_character(
payload: CharacterCardV2,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
return CharacterService(db, user.id).save_card(payload.model_dump())
@router.get("/character/prompt")
def get_character_prompt(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, str]:
service = CharacterService(db, user.id)
return {
"system_prompt": service.get_system_prompt(),
"first_mes": service.get_card().get("data", {}).get("first_mes", ""),
}
@router.post("/character/import")
def import_character(
payload: dict[str, Any],
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="Empty card")
return CharacterService(db, user.id).save_card(payload)
+158 -70
View File
@@ -1,70 +1,158 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
from app.chat.service import ChatService
from app.db.base import get_db
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
service = ChatService(db)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
service = ChatService(db)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
service = ChatService(db)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
service = ChatService(db)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db),
) -> StreamingResponse:
service = ChatService(db)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
async def event_stream():
async for chunk in service.stream_response(
session_id,
payload.content,
user_message_saved=True,
):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.chat_schemas import GenerationStatusOut, MessagesPageOut
from app.api.schemas import (
MessageCreate,
SessionCreate,
SessionDetailOut,
SessionOut,
)
from app.chat.generation import (
GenerationBusyError,
get_active_handle,
is_generation_active,
start_generation,
subscribe_generation,
)
from app.chat.service import ChatService
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionOut:
service = ChatService(db, user.id)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[SessionOut]:
service = ChatService(db, user.id)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> SessionDetailOut:
service = ChatService(db, user.id)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.get("/sessions/{session_id}/messages", response_model=MessagesPageOut)
def list_messages(
session_id: int,
limit: int = 30,
before_id: int | None = None,
after_id: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> MessagesPageOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
messages, has_more = service.list_messages(
session_id,
limit=min(max(limit, 1), 100),
before_id=before_id,
after_id=after_id,
)
return MessagesPageOut(messages=messages, has_more=has_more)
@router.get("/sessions/{session_id}/generation", response_model=GenerationStatusOut)
def generation_status(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> GenerationStatusOut:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return GenerationStatusOut(active=is_generation_active(session_id))
@router.get("/sessions/{session_id}/generation/stream")
async def generation_stream(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
handle = get_active_handle(session_id)
if not handle:
raise HTTPException(status_code=404, detail="No active generation")
async def event_stream():
async for chunk in subscribe_generation(handle):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
service = ChatService(db, user.id)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> StreamingResponse:
service = ChatService(db, user.id)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
if is_generation_active(session_id):
raise HTTPException(status_code=409, detail="Generation already in progress")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
try:
handle = await start_generation(session_id, user.id, payload.content)
except GenerationBusyError:
raise HTTPException(status_code=409, detail="Generation already in progress") from None
async def event_stream():
try:
async for chunk in subscribe_generation(handle):
yield chunk
except asyncio.CancelledError:
raise
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/sessions/{session_id}/context-preview")
def context_preview(
session_id: int,
query: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
service = ChatService(db, user.id)
return service.context_preview(session_id, query=query)
@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.schemas import MessageCreate, MessageOut, SessionCreate, SessionDetailOut, SessionOut
from app.chat.service import ChatService
from app.db.base import get_db
router = APIRouter()
@router.post("/sessions", response_model=SessionOut)
def create_session(payload: SessionCreate, db: Session = Depends(get_db)) -> SessionOut:
service = ChatService(db)
return service.create_session(title=payload.title)
@router.get("/sessions", response_model=list[SessionOut])
def list_sessions(db: Session = Depends(get_db)) -> list[SessionOut]:
service = ChatService(db)
return service.list_sessions()
@router.get("/sessions/{session_id}", response_model=SessionDetailOut)
def get_session(session_id: int, db: Session = Depends(get_db)) -> SessionDetailOut:
service = ChatService(db)
session = service.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.delete("/sessions/{session_id}")
def delete_session(session_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
service = ChatService(db)
if not service.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@router.post("/sessions/{session_id}/messages")
async def send_message(
session_id: int,
payload: MessageCreate,
db: Session = Depends(get_db),
) -> StreamingResponse:
service = ChatService(db)
if not service.get_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
# Сохраняем user до стрима: иначе при обрыве SSE сообщение не попадает в БД.
service.save_user_message(session_id, payload.content)
async def event_stream():
async for chunk in service.stream_response(
session_id,
payload.content,
user_message_saved=True,
):
yield chunk
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+51
View File
@@ -0,0 +1,51 @@
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.db.models import Document
from app.rag.ingest import ingest_document_file
router = APIRouter()
@router.get("/documents")
def list_documents(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
docs = db.scalars(select(Document).where(Document.user_id == user.id).order_by(Document.created_at.desc())).all()
return [
{
"id": d.id,
"title": d.title,
"filename": d.filename,
"size_bytes": d.size_bytes,
"created_at": d.created_at.isoformat() if d.created_at else None,
}
for d in docs
]
@router.post("/documents/upload")
async def upload_document(
file: UploadFile = File(...),
title: str = Form(""),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Empty file")
try:
doc = await ingest_document_file(
db,
user_id=user.id,
title=title.strip() or (file.filename or "document"),
filename=file.filename or "upload.txt",
raw_bytes=raw,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "document": doc}
+308 -223
View File
@@ -1,223 +1,308 @@
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
router = APIRouter()
class ProfileUpdate(BaseModel):
sex: str | None = None
age: int | None = None
height_cm: float | None = None
weight_kg: float | None = None
activity_level: str | None = None
goal: str | None = None
target_weight_kg: float | None = None
weekly_workouts: int | None = None
class MealCreate(BaseModel):
text: str = Field(min_length=1)
meal_type: str | None = None
class WaterCreate(BaseModel):
amount_ml: int = Field(gt=0)
class WeightCreate(BaseModel):
weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None
notes: str = ""
class WorkoutCreate(BaseModel):
text: str = Field(min_length=1)
class ReminderUpdate(BaseModel):
enabled: bool | None = None
hour: int | None = Field(default=None, ge=0, le=23)
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.get("/fitness")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return FitnessService(db).snapshot()
@router.get("/fitness/summary")
def get_summary(
day: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
d = date.fromisoformat(day) if day else None
return FitnessService(db).get_daily_summary(d)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db).get_history(days=days, end_day=end_day)
@router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
profile = FitnessService(db).get_profile()
return profile or {"configured": False}
@router.put("/fitness/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).set_profile(payload.model_dump(exclude_none=True))
@router.post("/fitness/profile/calc")
def calc_targets(
payload: ProfileUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
params = payload.model_dump(exclude_none=True)
if not params:
raise HTTPException(status_code=400, detail="No parameters")
return FitnessService(db).calc_targets(params)
@router.post("/fitness/meals")
async def create_meal(
payload: MealCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
service = FitnessService(db)
try:
structured = await structure_meal(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_meal(
description=structured.get("description") or payload.text,
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
source="llm",
estimated=bool(structured.get("estimated", True)),
)
@router.post("/fitness/water")
def create_water(
payload: WaterCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).log_water(payload.amount_ml)
@router.post("/fitness/weight")
def create_weight(
payload: WeightCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).log_weight(
payload.weight_kg,
body_fat_pct=payload.body_fat_pct,
chest_cm=payload.chest_cm,
waist_cm=payload.waist_cm,
notes=payload.notes,
)
@router.post("/fitness/workouts")
async def create_workout(
payload: WorkoutCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
service = FitnessService(db)
try:
structured = await structure_workout(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or payload.text,
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
)
@router.get("/fitness/body-metrics")
def list_metrics(
limit: int = 30,
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return FitnessService(db).list_body_metrics(limit=limit)
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
if not FitnessService(db).delete_food_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/water/{log_id}")
def delete_water(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
if not FitnessService(db).delete_water_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/workouts/{log_id}")
def delete_workout(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
if not FitnessService(db).delete_workout_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.get("/fitness/reminders")
def list_reminders(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return FitnessService(db).list_reminders()
@router.put("/fitness/reminders/{kind}")
def update_reminder(
kind: str,
payload: ReminderUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return FitnessService(db).set_reminder(
kind,
enabled=payload.enabled,
hour=payload.hour,
minute=payload.minute,
interval_hours=payload.interval_hours,
)
@router.get("/fitness/lookup/food")
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
return OpenFoodFactsClient().search(q, limit=limit)
@router.get("/fitness/lookup/exercise")
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
return WgerClient().search_exercises(q, limit=limit)
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
router = APIRouter()
class ProfileUpdate(BaseModel):
sex: str | None = None
age: int | None = None
height_cm: float | None = None
weight_kg: float | None = None
activity_level: str | None = None
goal: str | None = None
target_weight_kg: float | None = None
weekly_workouts: int | None = None
baseline_steps: int | None = None
baseline_workout_kcal: float | None = None
class MealCreate(BaseModel):
text: str = Field(min_length=1)
meal_type: str | None = None
class WaterCreate(BaseModel):
amount_ml: int = Field(gt=0)
class WeightCreate(BaseModel):
weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None
neck_cm: float | None = None
hip_cm: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
recorded_at: str | None = None
class BodyCompositionCalc(BaseModel):
weight_kg: float | None = None
height_cm: float | None = None
sex: str | None = None
neck_cm: float | None = None
waist_cm: float | None = None
hip_cm: float | None = None
body_fat_pct: float | None = None
class StepsCreate(BaseModel):
steps: int = Field(ge=0)
active_calories: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class WorkoutCreate(BaseModel):
text: str = Field(min_length=1)
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class ReminderUpdate(BaseModel):
enabled: bool | None = None
hour: int | None = Field(default=None, ge=0, le=23)
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.get("/fitness")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return FitnessService(db, user.id).snapshot()
@router.get("/fitness/summary")
def get_summary(
day: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
d = date.fromisoformat(day) if day else None
return FitnessService(db, user.id).get_daily_summary(d)
@router.get("/fitness/workout-stats")
def get_workout_stats(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
@router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
profile = FitnessService(db, user.id).get_profile()
return profile or {"configured": False}
@router.put("/fitness/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True))
@router.post("/fitness/profile/calc")
def calc_targets(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
params = payload.model_dump(exclude_none=True)
if not params:
raise HTTPException(status_code=400, detail="No parameters")
return FitnessService(db, user.id).calc_targets(params)
@router.post("/fitness/meals")
async def create_meal(
payload: MealCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_meal(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_meal(
description=structured.get("description") or payload.text,
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
source="llm",
estimated=bool(structured.get("estimated", True)),
)
@router.post("/fitness/water")
def create_water(
payload: WaterCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).log_water(payload.amount_ml)
@router.post("/fitness/weight")
def create_weight(
payload: WeightCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_weight(
payload.weight_kg,
body_fat_pct=payload.body_fat_pct,
chest_cm=payload.chest_cm,
waist_cm=payload.waist_cm,
neck_cm=payload.neck_cm,
hip_cm=payload.hip_cm,
notes=payload.notes,
recorded_at=payload.recorded_at,
day=day,
days_ago=payload.days_ago,
)
@router.post("/fitness/body-composition/calc")
def calc_body_composition(
payload: BodyCompositionCalc,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True))
@router.post("/fitness/steps")
def create_steps(
payload: StepsCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_steps(
payload.steps,
active_calories=payload.active_calories,
notes=payload.notes,
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.delete("/fitness/steps/{log_id}")
def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_step_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.post("/fitness/workouts")
async def create_workout(
payload: WorkoutCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_workout(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
day = date.fromisoformat(payload.day) if payload.day else None
return service.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or payload.text,
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"),
steps=structured.get("steps"),
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.get("/fitness/body-metrics")
def list_metrics(
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_body_metrics(limit=limit)
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_food_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/water/{log_id}")
def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_water_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/workouts/{log_id}")
def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_workout_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.get("/fitness/reminders")
def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_reminders()
@router.put("/fitness/reminders/{kind}")
def update_reminder(
kind: str,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_reminder(
kind,
enabled=payload.enabled,
hour=payload.hour,
minute=payload.minute,
interval_hours=payload.interval_hours,
)
@router.get("/fitness/lookup/food")
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
return OpenFoodFactsClient().search(q, limit=limit)
@router.get("/fitness/lookup/exercise")
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
return WgerClient().search_exercises(q, limit=limit)
+130 -127
View File
@@ -1,127 +1,130 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.db.models import ChatSession
from app.memory.extract import extract_after_turn
from app.memory.service import MemoryService
router = APIRouter()
class ProfileUpdate(BaseModel):
updates: dict[str, Any] = Field(default_factory=dict)
class FactCreate(BaseModel):
content: str = Field(min_length=1)
category: str = "fact"
importance: int = Field(default=3, ge=1, le=5)
session_id: int | None = None
class SessionSummaryUpdate(BaseModel):
summary: str = Field(min_length=1)
message_count: int = 0
class ExtractRequest(BaseModel):
session_id: int
user_text: str = Field(min_length=1)
assistant_text: str = ""
force: bool = False
@router.get("/memory")
def get_memory_snapshot(
session_id: int | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return MemoryService(db).snapshot(session_id)
@router.get("/profile")
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
return MemoryService(db).get_profile()
@router.put("/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return MemoryService(db).update_profile(payload.updates)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/memory/facts")
def list_facts(
query: str | None = None,
category: str | None = None,
limit: int = 30,
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return MemoryService(db).recall_memories(query=query, category=category, limit=limit)
@router.post("/memory/facts")
def create_fact(
payload: FactCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return MemoryService(db).remember_fact(
payload.content,
category=payload.category,
session_id=payload.session_id,
importance=payload.importance,
source="api",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/memory/facts/{memory_id}")
def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return MemoryService(db).forget_memory(memory_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/memory/extract")
async def extract_memories(
payload: ExtractRequest,
db: Session = Depends(get_db),
) -> dict:
session = db.get(ChatSession, payload.session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return await extract_after_turn(
db,
payload.session_id,
payload.user_text,
payload.assistant_text,
force=payload.force,
)
@router.put("/memory/sessions/{session_id}/summary")
def update_session_summary(
session_id: int,
payload: SessionSummaryUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return MemoryService(db).update_session_summary(
session_id,
payload.summary,
message_count=payload.message_count,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.db.models import ChatSession
from app.memory.extract import extract_after_turn
from app.memory.service import MemoryService
router = APIRouter()
class ProfileUpdate(BaseModel):
updates: dict[str, Any] = Field(default_factory=dict)
class FactCreate(BaseModel):
content: str = Field(min_length=1)
category: str = "fact"
importance: int = Field(default=3, ge=1, le=5)
session_id: int | None = None
class SessionSummaryUpdate(BaseModel):
summary: str = Field(min_length=1)
message_count: int = 0
class ExtractRequest(BaseModel):
session_id: int
user_text: str = Field(min_length=1)
assistant_text: str = ""
force: bool = False
@router.get("/memory")
def get_memory_snapshot(
session_id: int | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return MemoryService(db, user.id).snapshot(session_id)
@router.get("/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return MemoryService(db, user.id).get_profile()
@router.put("/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).update_profile(payload.updates)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/memory/facts")
def list_facts(
query: str | None = None,
category: str | None = None,
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return MemoryService(db, user.id).recall_memories(query=query, category=category, limit=limit)
@router.post("/memory/facts")
def create_fact(
payload: FactCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).remember_fact(
payload.content,
category=payload.category,
session_id=payload.session_id,
importance=payload.importance,
source="api",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/memory/facts/{memory_id}")
def forget_fact(memory_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return MemoryService(db, user.id).forget_memory(memory_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/memory/extract")
async def extract_memories(
payload: ExtractRequest,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict:
session = db.get(ChatSession, payload.session_id)
if not session or session.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found")
return await extract_after_turn(
db,
payload.session_id,
payload.user_text,
payload.assistant_text,
user_id=user.id,
force=payload.force,
)
@router.put("/memory/sessions/{session_id}/summary")
def update_session_summary(
session_id: int,
payload: SessionSummaryUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return MemoryService(db, user.id).update_session_summary(
session_id,
payload.summary,
message_count=payload.message_count,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+102 -100
View File
@@ -1,100 +1,102 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop
from app.db.base import get_db
from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).stop(
result=payload.result,
completed=payload.completed,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]:
return PomodoroService(db).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_work(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_short_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).start_long_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict:
return PomodoroService(db).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db)) -> dict:
try:
return PomodoroService(db).skip_phase()
except ValueError as exc:
raise _handle_value_error(exc) from exc
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.schemas import PomodoroStart, PomodoroStop
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.pomodoro.service import PomodoroService
router = APIRouter()
def _handle_value_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
@router.get("/status")
def get_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).get_status()
@router.post("/start")
def start_pomodoro(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/pause")
def pause_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).pause()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/resume")
def resume_pomodoro(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).resume()
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/stop")
def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).stop(
result=payload.result,
completed=payload.completed,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.get("/history")
def get_history(limit: int = 20, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
return PomodoroService(db, user.id).history(limit=limit)
@router.post("/work/start")
def start_work(payload: PomodoroStart, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_work(
duration_min=payload.duration_min,
task_note=payload.task_note,
)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/short/start")
def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_short_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/break/long/start")
def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).start_long_break(duration_min=duration_min)
except ValueError as exc:
raise _handle_value_error(exc) from exc
@router.post("/cycle/reset")
def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
return PomodoroService(db, user.id).reset_cycle(clear_task=clear_task)
@router.post("/skip")
def skip_phase(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
try:
return PomodoroService(db, user.id).skip_phase()
except ValueError as exc:
raise _handle_value_error(exc) from exc
+78 -76
View File
@@ -1,76 +1,78 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.projects.service import ProjectService
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return ProjectService(db).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
try:
return ProjectService(db).sync_taiga_projects()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
taiga_slug: str,
payload: GiteaBinding,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return ProjectService(db).bind_gitea(
taiga_slug,
payload.gitea_owner,
payload.gitea_repo,
payload.default_branch,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
payload: WorkItemCreate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return await ProjectService(db).create_work_item(
payload.text,
project_slug=payload.project_slug,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
limit: int = 30,
status: str | None = None,
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return ProjectService(db).list_work_items(limit=limit, status=status)
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.projects.service import ProjectService
router = APIRouter()
class GiteaBinding(BaseModel):
gitea_owner: str = Field(min_length=1)
gitea_repo: str = Field(min_length=1)
default_branch: str = "main"
class WorkItemCreate(BaseModel):
text: str = Field(min_length=1)
project_slug: str | None = None
@router.get("/projects")
def list_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_projects()
@router.post("/projects/sync-taiga")
def sync_taiga_projects(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
try:
return ProjectService(db, user.id).sync_taiga_projects()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/projects/{taiga_slug}/gitea")
def bind_gitea(
taiga_slug: str,
payload: GiteaBinding,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return ProjectService(db, user.id).bind_gitea(
taiga_slug,
payload.gitea_owner,
payload.gitea_repo,
payload.default_branch,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/work-items")
async def create_work_item(
payload: WorkItemCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return await ProjectService(db, user.id).create_work_item(
payload.text,
project_slug=payload.project_slug,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/work-items")
def list_work_items(
limit: int = 30,
status: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return ProjectService(db, user.id).list_work_items(limit=limit, status=status)
+128 -124
View File
@@ -1,124 +1,128 @@
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.homelab.context import resolve_timezone
from app.reminders.service import RemindersService
router = APIRouter()
class ReminderCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00")
notes: str = ""
all_day: bool = False
recurrence: str = "none"
class ReminderUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
due_at: str | None = None
notes: str | None = None
all_day: bool | None = None
recurrence: str | None = None
enabled: bool | None = None
@router.get("")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return RemindersService(db).snapshot()
@router.get("/upcoming")
def list_upcoming(
limit: int = Query(30, ge=1, le=100),
db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
return RemindersService(db).list_upcoming(limit=limit)
@router.get("/calendar")
def calendar(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
db: Session = Depends(get_db),
) -> dict[str, Any]:
tz_name = resolve_timezone(db)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
start = datetime(year, month, 1, tzinfo=tz)
if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=tz)
else:
end = datetime(year, month + 1, 1, tzinfo=tz)
service = RemindersService(db)
items = service.list_in_range(
date_from=start.astimezone(timezone.utc),
date_to=end.astimezone(timezone.utc),
)
return {
"year": year,
"month": month,
"timezone": tz_name,
"reminders": items,
}
@router.post("")
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).create(
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/{reminder_id}")
def update_reminder(
reminder_id: int,
payload: ReminderUpdate,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return RemindersService(db).update(
reminder_id,
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
enabled=payload.enabled,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{reminder_id}")
def delete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).delete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{reminder_id}/complete")
def complete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return RemindersService(db).complete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.homelab.context import resolve_timezone
from app.reminders_scoped.service import RemindersService
router = APIRouter()
class ReminderCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00")
notes: str = ""
all_day: bool = False
recurrence: str = "none"
class ReminderUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
due_at: str | None = None
notes: str | None = None
all_day: bool | None = None
recurrence: str | None = None
enabled: bool | None = None
@router.get("")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return RemindersService(db, user.id).snapshot()
@router.get("/upcoming")
def list_upcoming(
limit: int = Query(30, ge=1, le=100),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return RemindersService(db, user.id).list_upcoming(limit=limit)
@router.get("/calendar")
def calendar(
year: int = Query(..., ge=2000, le=2100),
month: int = Query(..., ge=1, le=12),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
tz_name = resolve_timezone(db, user.id)
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Moscow")
start = datetime(year, month, 1, tzinfo=tz)
if month == 12:
end = datetime(year + 1, 1, 1, tzinfo=tz)
else:
end = datetime(year, month + 1, 1, tzinfo=tz)
service = RemindersService(db, user.id)
items = service.list_in_range(
date_from=start.astimezone(timezone.utc),
date_to=end.astimezone(timezone.utc),
)
return {
"year": year,
"month": month,
"timezone": tz_name,
"reminders": items,
}
@router.post("")
def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).create(
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/{reminder_id}")
def update_reminder(
reminder_id: int,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return RemindersService(db, user.id).update(
reminder_id,
title=payload.title,
due_at=payload.due_at,
notes=payload.notes,
all_day=payload.all_day,
recurrence=payload.recurrence,
enabled=payload.enabled,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{reminder_id}")
def delete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).delete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{reminder_id}/complete")
def complete_reminder(reminder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return RemindersService(db, user.id).complete(reminder_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+34
View File
@@ -0,0 +1,34 @@
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.settings.service import SETTING_KEYS, SettingsService
router = APIRouter()
class SettingsPatch(BaseModel):
openrouter_model: str | None = None
memory_extract_model: str | None = None
openrouter_reasoning_effort: str | None = None
rag_enabled: bool | None = None
rag_top_k: int | None = Field(default=None, ge=1, le=50)
@router.get("/settings")
def get_settings_route(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return SettingsService(db).snapshot()
@router.patch("/settings")
def patch_settings_route(
payload: SettingsPatch,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
return SettingsService(db).patch(updates)
+118 -116
View File
@@ -1,116 +1,118 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.shopping.service import ShoppingService
router = APIRouter()
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ListRename(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ItemInput(BaseModel):
text: str = Field(min_length=1, max_length=500)
quantity: float | None = None
unit: str = ""
class ItemsAdd(BaseModel):
list_id: int | None = None
list_name: str | None = None
items: list[ItemInput] = Field(min_length=1)
class ItemChecked(BaseModel):
checked: bool
@router.get("")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return ShoppingService(db).snapshot()
@router.get("/lists")
def list_lists(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return ShoppingService(db).list_lists(include_items=True)
@router.post("/lists")
def create_list(payload: ListCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).create_list(payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/lists/{list_id}")
def get_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
data = ShoppingService(db).get_list(list_id=list_id)
if not data:
raise HTTPException(status_code=404, detail="List not found")
return data
@router.patch("/lists/{list_id}")
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).rename_list(list_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/lists/{list_id}")
def delete_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).delete_list(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/items")
def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).add_items(
[i.model_dump() for i in payload.items],
list_id=payload.list_id,
list_name=payload.list_name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/items/{item_id}")
def set_item_checked(
item_id: int,
payload: ItemChecked,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return ShoppingService(db).set_item_checked(item_id, payload.checked)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/items/{item_id}")
def remove_item(item_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).remove_item(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/lists/{list_id}/clear-checked")
def clear_checked(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).clear_checked(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.shopping.service import ShoppingService
router = APIRouter()
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ListRename(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ItemInput(BaseModel):
text: str = Field(min_length=1, max_length=500)
quantity: float | None = None
unit: str = ""
class ItemsAdd(BaseModel):
list_id: int | None = None
list_name: str | None = None
items: list[ItemInput] = Field(min_length=1)
class ItemChecked(BaseModel):
checked: bool
@router.get("")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return ShoppingService(db, user.id).snapshot()
@router.get("/lists")
def list_lists(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return ShoppingService(db, user.id).list_lists(include_items=True)
@router.post("/lists")
def create_list(payload: ListCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).create_list(payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/lists/{list_id}")
def get_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
data = ShoppingService(db, user.id).get_list(list_id=list_id)
if not data:
raise HTTPException(status_code=404, detail="List not found")
return data
@router.patch("/lists/{list_id}")
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).rename_list(list_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/lists/{list_id}")
def delete_list(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).delete_list(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/items")
def add_items(payload: ItemsAdd, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).add_items(
[i.model_dump() for i in payload.items],
list_id=payload.list_id,
list_name=payload.list_name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/items/{item_id}")
def set_item_checked(
item_id: int,
payload: ItemChecked,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).set_item_checked(item_id, payload.checked)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/items/{item_id}")
def remove_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).remove_item(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/lists/{list_id}/clear-checked")
def clear_checked(list_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
try:
return ShoppingService(db, user.id).clear_checked(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+106 -118
View File
@@ -1,118 +1,106 @@
import hashlib
import hmac
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db.base import SessionLocal, get_db
from app.db.models import ChatSession, Message, ProjectBinding
from app.projects.service import ProjectService
router = APIRouter()
logger = logging.getLogger(__name__)
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret:
return True
if not signature:
return False
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def _post_close_notice(results: list[dict[str, Any]], owner: str, repo: str) -> None:
if not results:
return
db = SessionLocal()
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Git")
db.add(session)
db.commit()
db.refresh(session)
lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results:
if "closed" in item:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
elif "error" in item:
lines.append(f"- ошибка: {item['error']}")
db.add(Message(session_id=session.id, role="notice", content="\n".join(lines)))
db.commit()
finally:
db.close()
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body()
settings = get_settings()
signature = (
request.headers.get("X-Gitea-Signature")
or request.headers.get("X-Gogs-Signature")
or request.headers.get("X-Hub-Signature-256")
)
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
payload = json.loads(body)
if payload.get("secret") and settings.gitea_webhook_secret:
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = request.headers.get("X-Gitea-Event", "")
if event != "push":
return {"ok": True, "skipped": event}
repo = payload.get("repository", {})
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="Missing repository info")
binding = db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo_name,
)
)
if not binding:
return {"ok": True, "skipped": "unknown repo"}
commits = list(payload.get("commits") or [])
if not commits:
head = payload.get("head_commit")
if head:
commits = [head]
logger.info(
"Gitea push %s/%s ref=%s commits=%d",
owner,
repo_name,
payload.get("ref", ""),
len(commits),
)
service = ProjectService(db)
results = service.process_push(owner, repo_name, commits)
if results:
logger.info("Gitea push results: %s", results)
else:
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
_post_close_notice(results, owner, repo_name)
return {"ok": True, "results": results, "commits_processed": len(commits)}
import hashlib
import hmac
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.chat.notice_inbox import post_notice_to_latest_chat
from app.config import get_settings
from app.db.base import get_db
from app.db.models import ProjectBinding
from app.projects.service import ProjectService
router = APIRouter()
logger = logging.getLogger(__name__)
def _verify_gitea_signature(body: bytes, signature: str | None, secret: str) -> bool:
if not secret:
return True
if not signature:
return False
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def _post_close_notice(
results: list[dict[str, Any]], owner: str, repo: str, user_id: int
) -> None:
if not results:
return
lines = [f"🔀 **Push** `{owner}/{repo}`"]
for item in results:
if "closed" in item:
lines.append(f"- `{item.get('commit', '?')}`: закрыто {item['closed']}")
elif "error" in item:
lines.append(f"- ошибка: {item['error']}")
post_notice_to_latest_chat("\n".join(lines), user_id)
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
body = await request.body()
settings = get_settings()
signature = (
request.headers.get("X-Gitea-Signature")
or request.headers.get("X-Gogs-Signature")
or request.headers.get("X-Hub-Signature-256")
)
if not _verify_gitea_signature(body, signature, settings.gitea_webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
payload = json.loads(body)
if payload.get("secret") and settings.gitea_webhook_secret:
if payload.get("secret") != settings.gitea_webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = request.headers.get("X-Gitea-Event", "")
if event != "push":
return {"ok": True, "skipped": event}
repo = payload.get("repository", {})
owner = repo.get("owner", {}).get("login", "")
repo_name = repo.get("name", "")
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="Missing repository info")
binding = db.scalar(
select(ProjectBinding).where(
ProjectBinding.gitea_owner == owner,
ProjectBinding.gitea_repo == repo_name,
)
)
if not binding:
return {"ok": True, "skipped": "unknown repo"}
commits = list(payload.get("commits") or [])
if not commits:
head = payload.get("head_commit")
if head:
commits = [head]
logger.info(
"Gitea push %s/%s ref=%s commits=%d",
owner,
repo_name,
payload.get("ref", ""),
len(commits),
)
service = ProjectService(db, binding.user_id)
results = service.process_push(owner, repo_name, commits)
if results:
logger.info("Gitea push results: %s", results)
else:
logger.warning("Gitea push: no close actions for %s/%s", owner, repo_name)
_post_close_notice(results, owner, repo_name, binding.user_id)
return {"ok": True, "results": results, "commits_processed": len(commits)}