From f7ba9c38819f1c7ac0c775ceb2c17e1bd9b90c39 Mon Sep 17 00:00:00 2001 From: Helmut Merz Date: Wed, 4 Nov 2026 11:54:24 +0100 Subject: [PATCH] api, work in progress: authn/authz, starting with user info --- scopes/api/auth/__init__.py | 1 + scopes/api/auth/user.py | 28 ++++++++++++++++++++++++++++ scopes/api/deps.py | 9 +++++++-- scopes/api/main.py | 2 ++ scopes/api/tracking.py | 13 ++++--------- scopes/storage/common.py | 5 +++-- scopes/storage/db/postgres.py | 4 ++-- scopes/storage/tracking.py | 9 ++++++++- scopes/storage/user.py | 19 +++++++++++++++++++ tests/test.env | 2 ++ tests/test_api.py | 13 ++++++++++++- tests/test_storage.py | 3 +-- 12 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 scopes/api/auth/__init__.py create mode 100644 scopes/api/auth/user.py create mode 100644 scopes/storage/user.py diff --git a/scopes/api/auth/__init__.py b/scopes/api/auth/__init__.py new file mode 100644 index 0000000..ffff4a3 --- /dev/null +++ b/scopes/api/auth/__init__.py @@ -0,0 +1 @@ +"""package scopes.api.auth""" diff --git a/scopes/api/auth/user.py b/scopes/api/auth/user.py new file mode 100644 index 0000000..989eedc --- /dev/null +++ b/scopes/api/auth/user.py @@ -0,0 +1,28 @@ +# scopes.api.auth user + +from fastapi import APIRouter +from pydantic import BaseModel, Field +from typing import List + +from scopes.api import deps +from scopes.storage import user as st_user + +class User(BaseModel): + uid: str | None = None + name: str + login: str + display_name: str + email: str | None = None + pw_hash: str | None = None + groups: List[str] = Field(default_factory=list) + +# routes + +router = APIRouter() + +@router.get('/users', response_model=list[User]) +async def get_users(storage: deps.Storage): + users = storage.create(st_user.Users) + result = [tr.toModel(User) for tr in users.query()] + return result + diff --git a/scopes/api/deps.py b/scopes/api/deps.py index 1592997..77d8f83 100644 --- a/scopes/api/deps.py +++ b/scopes/api/deps.py @@ -1,11 +1,16 @@ # scopes.api.deps -"""Common API dependencies""" +"""Common dependencies and other definitions for Web API""" + +from fastapi import Depends +from typing import Annotated from scopes import config +from scopes.storage import common + +type Storage = Annotated[common.Storage, Depends(storage)] def storage(): stf = config.StorageFactory(config.settings) return stf(config.settings.dbschema) - diff --git a/scopes/api/main.py b/scopes/api/main.py index 67c988e..664252e 100644 --- a/scopes/api/main.py +++ b/scopes/api/main.py @@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse import transaction from scopes.api import tracking +from scopes.api.auth import user # app and routes @@ -24,6 +25,7 @@ async def handle_transactions(request: Request, call_next): return response app.include_router(tracking.router) +app.include_router(user.router) @app.get('/') async def read_root(): diff --git a/scopes/api/tracking.py b/scopes/api/tracking.py index c99e31e..d8bfc9d 100644 --- a/scopes/api/tracking.py +++ b/scopes/api/tracking.py @@ -4,35 +4,30 @@ from datetime import datetime from fastapi import APIRouter, Body, Depends -from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field -import transaction from typing import Annotated, Any, Self from scopes.api import deps from scopes.storage import tracking -from scopes.storage.common import Storage class Track(BaseModel): - uid: str = None + uid: str | None = None head: dict[str, str] data: dict[str, Any] - timeStamp: datetime = None + timeStamp: datetime | None = None # routes router = APIRouter() -type Storage = Annotated[Storage, Depends(deps.storage)] - @router.get('/tracks', response_model=list[Track]) -async def get_tracks(storage: Storage): +async def get_tracks(storage: deps.Storage): tracks = storage.create(tracking.Container) result = [tr.asDict() for tr in tracks.query()] return result @router.post('/tracks', response_model=Track) -async def post_track(track: Annotated[Track, Body()], storage: Storage): +async def post_track(track: Annotated[Track, Body()], storage: deps.Storage): tracks = storage.create(tracking.Container) hd = track.head tr = tracking.Track(hd['taskId'], hd['userName'], data=track.data) diff --git a/scopes/storage/common.py b/scopes/storage/common.py index 62d479f..2529e9f 100644 --- a/scopes/storage/common.py +++ b/scopes/storage/common.py @@ -7,6 +7,7 @@ from sqlalchemy import create_engine, MetaData, text from sqlalchemy import Integer from sqlalchemy.dialects.sqlite import JSON import threading +from typing import Any class Storage(object): @@ -85,7 +86,7 @@ class StorageFactory: conn.commit() IdType = Integer - JsonType = JSON + JsonType: Any = JSON storageClass = Storage @@ -101,7 +102,7 @@ class StorageFactory: # store information about container implementations, identified by a uid prefix. -registry = {} +registry: dict[str, Any] = {} def registerContainerClass(cls): prefix = cls.itemFactory.prefix diff --git a/scopes/storage/db/postgres.py b/scopes/storage/db/postgres.py index c7a557f..578b04a 100644 --- a/scopes/storage/db/postgres.py +++ b/scopes/storage/db/postgres.py @@ -9,10 +9,10 @@ from sqlalchemy.orm import scoped_session, sessionmaker import transaction from zope.sqlalchemy import register, mark_changed -from scopes.storage.common import StorageFactory +from scopes.storage.common import StorageFactory as BaseStorageFactory -class StorageFactory(StorageFactory): +class StorageFactory(BaseStorageFactory): def sessionFactory(self): Session = scoped_session(sessionmaker(bind=self.engine, twophase=True)) diff --git a/scopes/storage/tracking.py b/scopes/storage/tracking.py index ea1160a..07852bd 100644 --- a/scopes/storage/tracking.py +++ b/scopes/storage/tracking.py @@ -29,6 +29,13 @@ class Record(shape.Record): return dict(uid=self.uid, head=self.head, data=self.data, timeStamp=str(self.timeStamp)[:19]) + def toModel(self, cls): + data = dict(uid=self.uid) + data.update(self.head) + for k, v in self.data.items(): + data[k] = v + return cls(**data) + @property def uid(self): if self.trackId is None: @@ -53,7 +60,7 @@ class Record(shape.Record): class Track(Record): - headFields = ['taskId', 'userName'] + headFields = ('taskId', 'userName') prefix = 'rec' diff --git a/scopes/storage/user.py b/scopes/storage/user.py new file mode 100644 index 0000000..8a0f460 --- /dev/null +++ b/scopes/storage/user.py @@ -0,0 +1,19 @@ +# scopes.storage.user + +from scopes.storage.common import registerContainerClass +from scopes.storage import concept + +class User(concept.Concept): + + headFields = ('name', 'login') + prefix = 'user' + + + +@registerContainerClass +class Users(concept.Concepts): + + itemFactory = User + indexes = [('name',), ('login',)] + tableName = 'users' + diff --git a/tests/test.env b/tests/test.env index a2bc74c..4dad37c 100644 --- a/tests/test.env +++ b/tests/test.env @@ -1,5 +1,7 @@ # tests/test.env +LOG_FILE=tests/log/scopes-test.log + BASE_URL=http://test DBNAME=testdb diff --git a/tests/test_api.py b/tests/test_api.py index a258c63..5442d92 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,12 +11,14 @@ from httpx2 import ASGITransport, AsyncClient from scopes.api import main from scopes.api.main import app -from scopes.storage import tracking +from scopes.storage import tracking, user as st_user +from scopes.api.auth import user as api_user @pytest.fixture(scope='module') def storage(): storage = config.StorageFactory(config.settings)(config.settings.dbschema) storage.dropTable('tracks') + storage.dropTable('users') storage.commit() return storage @@ -57,3 +59,12 @@ def test_tracking(storage, caplog): #print('\n*** Logs: ', caplog.text) #print('*** tr02', tr02) +def test_user(storage, caplog): + users = storage.create(st_user.Users) + u01 = st_user.User('jim', 'jim', data=dict(display_name='Jim')) + u01id = users.save(u01) + storage.commit() + with TestClient(app) as clt: + caplog.set_level(config.settings.log_level) + users = clt.get('/users').json() + assert len(users) == 1 diff --git a/tests/test_storage.py b/tests/test_storage.py index 53fe3bc..cf57492 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -8,7 +8,6 @@ config.settings = config.Settings(_env_file='tests/test.env') from datetime import datetime import pytest -from scopes.api import main from scopes.storage import concept, folder, message, topic, tracking from scopes.util import keywords as k @@ -89,7 +88,7 @@ def test_type(storage): concept.setupCoreTypes(storage) types = storage.getContainer(concept.Type) tps = list(types.query()) - assert len(tps) == 7 + assert len(tps) == 8 tfolder = types.queryLast(name='folder') fldrs = list(tfolder.values()) assert len(fldrs) == 2