[tasks] basic implementation of the concept

This commit is contained in:
Piotr Domański 2024-05-21 11:11:47 +02:00
parent 969b57e993
commit 5d9c2e8bd8
13 changed files with 150 additions and 9 deletions

View file

@ -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

View file

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

View file

@ -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)

View file

@ -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))

View file

@ -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)

View file

@ -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:
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)

View file

@ -15,3 +15,5 @@ class Settings(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
ALLOWED_ORIGINS: List[str] = ["*"]
API_KEY: str

17
fooder/tasks_app.py Normal file
View file

@ -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=["*"],
)

View file

@ -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()

15
fooder/test/test_tasks.py Normal file
View file

@ -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

13
fooder/view/tasks.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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