diff --git a/env.template b/env.template index 015a6ea..ee78795 100644 --- a/env.template +++ b/env.template @@ -11,3 +11,5 @@ REFRESH_SECRET_KEY="${REFRESH_SECRET_KEY}" # generate with $ openssl rand -hex 3 ALGORITHM="HS256" ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=30 + +API_KEY="${API_KEY}" # generate with $ openssl rand -hex 32 diff --git a/fooder/auth.py b/fooder/auth.py index e1596ed..4f40159 100644 --- a/fooder/auth.py +++ b/fooder/auth.py @@ -101,3 +101,11 @@ async def get_current_user( raise HTTPException(status_code=401, detail="Unathorized") return await User.get_by_username(session, username) + + +async def authorize_api_key( + session: AsyncSessionDependency, token: TokenDependency +) -> None: + if token == settings.API_KEY: + return None + raise HTTPException(status_code=401, detail="Unathorized") diff --git a/fooder/controller/base.py b/fooder/controller/base.py index 0fc3d0c..b7cbaf2 100644 --- a/fooder/controller/base.py +++ b/fooder/controller/base.py @@ -2,12 +2,13 @@ from typing import Annotated, Any from fastapi import Depends from sqlalchemy.ext.asyncio import async_sessionmaker from ..db import get_session -from ..auth import get_current_user +from ..auth import get_current_user, authorize_api_key from ..domain.user import User AsyncSession = Annotated[async_sessionmaker, Depends(get_session)] UserDependency = Annotated[User, Depends(get_current_user)] +ApiKeyDependency = Annotated[bool, Depends(authorize_api_key)] class BaseController: @@ -25,3 +26,8 @@ class AuthorizedController(BaseController): def __init__(self, session: AsyncSession, user: UserDependency) -> None: super().__init__(session) self.user = user + + +class TasksSessionController(BaseController): + def __init__(self, session: AsyncSession, api_key: ApiKeyDependency) -> None: + super().__init__(session) diff --git a/fooder/controller/tasks.py b/fooder/controller/tasks.py new file mode 100644 index 0000000..995fe23 --- /dev/null +++ b/fooder/controller/tasks.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException +from ..domain.product import Product as DBProduct +from .base import TasksSessionController + + +class CacheProductUsageData(TasksSessionController): + async def call(self) -> None: + async with self.async_session.begin() as session: + try: + await DBProduct.cache_usage_data(session) + await session.commit() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/fooder/domain/entry.py b/fooder/domain/entry.py index afbc3a5..e602f6b 100644 --- a/fooder/domain/entry.py +++ b/fooder/domain/entry.py @@ -1,8 +1,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship, joinedload -from sqlalchemy import ForeignKey, Integer, DateTime +from sqlalchemy import ForeignKey, Integer, DateTime, Boolean from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import IntegrityError -from sqlalchemy import select +from sqlalchemy import select, update from datetime import datetime from typing import Optional @@ -20,6 +20,7 @@ class Entry(Base, CommonMixin): last_changed: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow ) + processed: Mapped[bool] = mapped_column(Boolean, default=False) @property def amount(self) -> float: @@ -152,3 +153,12 @@ class Entry(Base, CommonMixin): """delete.""" await session.delete(self) await session.flush() + + @classmethod + async def mark_processed( + cls, + session: AsyncSession, + ) -> None: + stmt = update(cls).where(cls.processed is False).values(processed=True) + + await session.execute(stmt) diff --git a/fooder/domain/product.py b/fooder/domain/product.py index b97ff29..2c0efd6 100644 --- a/fooder/domain/product.py +++ b/fooder/domain/product.py @@ -1,5 +1,5 @@ -from sqlalchemy.orm import Mapped -from sqlalchemy import select +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import select, BigInteger, func, update from sqlalchemy.ext.asyncio import AsyncSession from typing import AsyncIterator, Optional @@ -18,6 +18,12 @@ class Product(Base, CommonMixin): hard_coded_calories: Mapped[Optional[float]] barcode: Mapped[Optional[str]] + usage_count_cached: Mapped[int] = mapped_column( + BigInteger, + default=0, + nullable=False, + ) + @property def calories(self) -> float: """calories. @@ -41,11 +47,13 @@ class Product(Base, CommonMixin): if q: q_list = q.split() - for qq in q_list: - query = query.filter(cls.name.ilike(f"%{qq.lower()}%")) + qq = "%" + "%".join(q_list) + "%" + query = query.filter(cls.name.ilike(f"%{qq.lower()}%")) query = query.offset(offset).limit(limit) - stream = await session.stream_scalars(query.order_by(cls.id)) + stream = await session.stream_scalars( + query.order_by(cls.usage_count_cached.desc()) + ) async for row in stream: yield row @@ -104,3 +112,28 @@ class Product(Base, CommonMixin): session.add(product) await session.flush() return product + + @classmethod + async def cache_usage_data( + cls, + session: AsyncSession, + ) -> None: + from .entry import Entry + + stmt = ( + update(cls) + .where( + cls.id.in_( + select(Entry.product_id).where(Entry.processed == False).distinct() + ) + ) + .values( + usage_count_cached=select(func.count(Entry.id)).where( + Entry.product_id == cls.id, + Entry.processed == False, + ) + ) + ) + + await session.execute(stmt) + await Entry.mark_processed(session) diff --git a/fooder/settings.py b/fooder/settings.py index 0a322f6..51fff2e 100644 --- a/fooder/settings.py +++ b/fooder/settings.py @@ -15,3 +15,5 @@ class Settings(BaseSettings): REFRESH_TOKEN_EXPIRE_DAYS: int = 30 ALLOWED_ORIGINS: List[str] = ["*"] + + API_KEY: str diff --git a/fooder/tasks_app.py b/fooder/tasks_app.py new file mode 100644 index 0000000..4b9dc73 --- /dev/null +++ b/fooder/tasks_app.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI +from .view.tasks import router +from .settings import Settings +from fastapi.middleware.cors import CORSMiddleware + + +app = FastAPI(title="Fooder Tasks admininstrative API") +app.include_router(router) + + +app.add_middleware( + CORSMiddleware, + allow_origins=Settings().ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) diff --git a/fooder/test/fixtures/client.py b/fooder/test/fixtures/client.py index 6a9bd15..a41ff71 100644 --- a/fooder/test/fixtures/client.py +++ b/fooder/test/fixtures/client.py @@ -1,7 +1,9 @@ from fooder.app import app +from fooder.tasks_app import app as tasks_app from httpx import AsyncClient import pytest import httpx +import os class Client: @@ -67,11 +69,29 @@ class Client: return await self.client.patch(path, **kwargs) +class TasksClient(Client): + def __init__(self, authorized: bool = True): + super().__init__() + self.client = AsyncClient(app=tasks_app, base_url="http://testserver/api") + self.client.headers["Accept"] = "application/json" + + if authorized: + self.client.headers["Authorization"] = "Bearer " + self.get_token() + + def get_token(self) -> str: + return os.getenv("API_KEY") + + @pytest.fixture def unauthorized_client() -> Client: return Client() +@pytest.fixture +def tasks_client() -> Client: + return TasksClient() + + @pytest.fixture async def client(user_payload) -> Client: client = Client() diff --git a/fooder/test/test_tasks.py b/fooder/test/test_tasks.py new file mode 100644 index 0000000..03bf7e9 --- /dev/null +++ b/fooder/test/test_tasks.py @@ -0,0 +1,15 @@ +import pytest + + +@pytest.mark.anyio +async def test_cache_product_usage(client, tasks_client): + response = await client.get("product") + assert response.status_code == 200, response.json() + old_data = response.json() + + response = await tasks_client.post("/cache_product_usage_data") + assert response.status_code == 200, response.json() + + response = await client.get("product") + assert response.status_code == 200, response.json() + assert response.json() != old_data diff --git a/fooder/view/tasks.py b/fooder/view/tasks.py new file mode 100644 index 0000000..233f7bc --- /dev/null +++ b/fooder/view/tasks.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Depends, Request +from ..controller.tasks import CacheProductUsageData + + +router = APIRouter(prefix="/api", tags=["tasks"]) + + +@router.post("/cache_product_usage_data") +async def create_user( + request: Request, + contoller: CacheProductUsageData = Depends(CacheProductUsageData), +): + return await contoller.call() diff --git a/setup.cfg b/setup.cfg index fd1473f..816439d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [flake8] max-line-length = 80 extend-select = B950 -extend-ignore = E203,E501,E701 +extend-ignore = E203,E501,E701,E712 extend-immutable-calls = Depends diff --git a/test.sh b/test.sh index 5c4bc8f..1e9c904 100755 --- a/test.sh +++ b/test.sh @@ -13,6 +13,7 @@ export DB_URI="sqlite+aiosqlite:///test.db" export ECHO_SQL=0 export SECRET_KEY=$(openssl rand -hex 32) export REFRESH_SECRET_KEY=$(openssl rand -hex 32) +export API_KEY=$(openssl rand -hex 32) python -m fooder --create-tables @@ -31,6 +32,7 @@ unset POSTGRES_DATABASE unset POSTGRES_PASSWORD unset SECRET_KEY unset REFRESH_SECRET +unset API_KEY # if exists, remove test.db [ -f test.db ] && rm test.db