[tasks] basic implementation of the concept
This commit is contained in:
parent
969b57e993
commit
5d9c2e8bd8
13 changed files with 150 additions and 9 deletions
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
13
fooder/controller/tasks.py
Normal file
13
fooder/controller/tasks.py
Normal 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))
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
17
fooder/tasks_app.py
Normal 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=["*"],
|
||||
)
|
20
fooder/test/fixtures/client.py
vendored
20
fooder/test/fixtures/client.py
vendored
|
@ -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
15
fooder/test/test_tasks.py
Normal 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
13
fooder/view/tasks.py
Normal 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()
|
|
@ -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
|
||||
|
|
2
test.sh
2
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
|
||||
|
|
Loading…
Reference in a new issue