Skip to content

Commit

Permalink
Merge pull request #6 from pbs-data-solutions/db
Browse files Browse the repository at this point in the history
Add user model and db connection
  • Loading branch information
sanders41 authored Dec 7, 2023
2 parents e98b885 + 93aa0e7 commit dd52a52
Show file tree
Hide file tree
Showing 17 changed files with 260 additions and 16 deletions.
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: [sanders41]
8 changes: 5 additions & 3 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.8"
python-version: "3.10"
cache: "poetry"
- name: Install Dependencies
run: poetry install
Expand Down Expand Up @@ -54,10 +54,12 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install just
uses: taiki-e/install-action@just
- name: Install Poetry
run: pipx install poetry
- name: Configure poetry
Expand All @@ -72,4 +74,4 @@ jobs:
- name: Install Dependencies
run: poetry install
- name: Test with pytest
run: poetry run pytest
run: just test-ci
8 changes: 7 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,11 @@
@ruff-format:
poetry run ruff format open_edc tests

@test:
@start-db:
docker compose up -d db

@test: start-db && docker-stop
poetry run pytest -x

@test-ci: start-db && docker-stop
poetry run pytest
2 changes: 2 additions & 0 deletions open_edc/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import uvicorn # pragma: no cover

from open_edc.main import app # pragma: no cover
Expand Down
16 changes: 16 additions & 0 deletions open_edc/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
from __future__ import annotations

import logging
from typing import Annotated

from fastapi import Depends
from motor.motor_asyncio import AsyncIOMotorClient

from open_edc.core.config import config
from open_edc.db import db_client

logging.basicConfig(format="%asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s")
logging.root.setLevel(level=config.log_level)
logger = logging


# motor 3.3.0 broke types see: https://www.mongodb.com/community/forums/t/motor-3-3-0-released/241116
# and https://jira.mongodb.org/browse/MOTOR-1177
def get_db_client() -> AsyncIOMotorClient: # type: ignore
return db_client


MongoClient = Annotated[AsyncIOMotorClient, Depends(get_db_client)] # type: ignore
2 changes: 2 additions & 0 deletions open_edc/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from open_edc.api.v1.routes import health
from open_edc.core.utils import APIRouter

Expand Down
18 changes: 15 additions & 3 deletions open_edc/api/v1/routes/health.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
from typing import Dict
from __future__ import annotations

from open_edc.api.deps import MongoClient, logger
from open_edc.core.config import config
from open_edc.core.utils import APIRouter

router = APIRouter(tags=["Health"], prefix=config.V1_API_PREFIX)


@router.get("/health", include_in_schema=False)
async def health() -> Dict[str, str]:
return {"system": "healthy"}
async def health(mongo_client: MongoClient) -> dict[str, str]:
status = {"system": "healthy"}
logger.info("Checking MongoDb health")
try:
# motor 3.3.0 broke types see: https://www.mongodb.com/community/forums/t/motor-3-3-0-released/241116
# and https://jira.mongodb.org/browse/MOTOR-1177
await mongo_client.server_info() # type: ignore[attr-defined]
status["db"] = "healthy"
except Exception as e:
logger.error("%s: %s", type(e).__name__, e)
status["db"] = "unhealthy"

return status
7 changes: 7 additions & 0 deletions open_edc/core/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import logging
from typing import Final

Expand All @@ -8,6 +10,11 @@ class Settings(BaseSettings):
V1_API_PREFIX: Final[str] = "/api/v1"

log_level: int = logging.INFO
mongo_initdb_database: str = "mongo_test"
mongo_initdb_root_username: str = "mongo"
mongo_initdb_root_password: str = "mongo_password"
mongo_host: str = "mongodb://127.0.0.1"
mongo_port: int = 27017


config = Settings() # type: ignore
18 changes: 18 additions & 0 deletions open_edc/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from __future__ import annotations

from beanie import init_beanie
from motor.motor_asyncio import AsyncIOMotorClient

from open_edc.core.config import config
from open_edc.models.user import User

db_client = AsyncIOMotorClient( # type: ignore
host=config.mongo_host,
username=config.mongo_initdb_root_username,
password=config.mongo_initdb_root_password,
port=config.mongo_port,
)


async def init_db() -> None:
await init_beanie(database=db_client.db_name, document_models=[User]) # type: ignore[arg-type]
9 changes: 6 additions & 3 deletions open_edc/main.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
from __future__ import annotations

from contextlib import asynccontextmanager
from typing import AsyncGenerator

import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from open_edc.api.deps import logger
from open_edc.api.v1.api import api_router
from open_edc.db import init_db


@asynccontextmanager # type: ignore
async def lifespan(app: FastAPI) -> AsyncGenerator:
# load beanie models
# await init_db()
logger.info("Initializing the database")
await init_db()
yield
# cleanup


app = FastAPI(lifespan=lifespan)
Expand Down
Empty file added open_edc/models/__init__.py
Empty file.
49 changes: 49 additions & 0 deletions open_edc/models/object_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from typing import Annotated, Any, Callable

from bson import ObjectId
from pydantic import GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import core_schema


# Based on https://docs.pydantic.dev/latest/usage/types/custom/#handling-third-party-types
class _ObjectIdPydanticAnnotation:
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: Any,
_handler: Callable[[Any], core_schema.CoreSchema],
) -> core_schema.CoreSchema:
def validate_from_str(id_: str) -> ObjectId:
return ObjectId(id_)

from_str_schema = core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(validate_from_str),
]
)

return core_schema.json_or_python_schema(
json_schema=from_str_schema,
python_schema=core_schema.union_schema(
[
# check if it's an instance first before doing any further work
core_schema.is_instance_schema(ObjectId),
from_str_schema,
]
),
serialization=core_schema.plain_serializer_function_ser_schema(lambda x: str(x)),
)

@classmethod
def __get_pydantic_json_schema__(
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
# Use the same schema that would be used for `str`
return handler(core_schema.str_schema())


ObjectIdStr = Annotated[ObjectId, _ObjectIdPydanticAnnotation]
76 changes: 76 additions & 0 deletions open_edc/models/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

from datetime import datetime

from beanie import Document
from camel_converter.pydantic_base import CamelBase
from pydantic import BaseModel, Field
from pymongo import ASCENDING, IndexModel

from open_edc.models.object_id import ObjectIdStr


class PasswordReset(CamelBase):
user_name: str
security_question_answer: str
new_password: str


class UserCreate(CamelBase):
user_name: str
first_name: str
last_name: str
password: str
security_question_answer: str


class UserNoPassword(BaseModel):
id: ObjectIdStr
user_name: str
first_name: str
last_name: str
country: str | None = None

class Settings:
projection = {
"id": "$_id",
"user_name": "$user_name",
"first_name": "$first_name",
"last_name": "$last_name",
}


class UserUpdateMe(CamelBase):
id: ObjectIdStr
password: str
user_name: str
first_name: str
last_name: str
security_question_answer: str
country: str | None = None


class UserUpdate(UserUpdateMe):
is_active: bool
is_admin: bool


class User(Document):
user_name: str
first_name: str
last_name: str
hashed_password: str
security_question_answer: str
is_active: bool = True
is_admin: bool = False
date_created: datetime = Field(default_factory=datetime.now)
last_update: datetime = Field(default_factory=datetime.now)
last_login: datetime = Field(default_factory=datetime.now)

class Settings:
name = "users"
indexes = [
IndexModel(keys=[("user_name", ASCENDING)], name="user_name", unique=True),
IndexModel(keys=[("is_active", ASCENDING)], name="is_active"),
IndexModel(keys=[("is_admin", ASCENDING)], name="is_admin"),
]
7 changes: 2 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ license = "MIT"
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.8"
python = "^3.10"
beanie = "1.23.6"
camel-converter = {version = "3.1.1", extras = ["pydantic"]}
fastapi = "0.104.1"
Expand Down
17 changes: 17 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import pytest
from httpx import AsyncClient
from pymongo.errors import OperationFailure

from open_edc.core.config import config
from open_edc.db import init_db
from open_edc.main import app
from open_edc.models.user import User


@pytest.fixture
async def initialize_db():
try:
await init_db()
except OperationFailure: # init_db already ran
pass


# @pytest.fixture(autouse=True)
async def clear_db():
yield
await User.delete_all()


@pytest.fixture
Expand Down
36 changes: 36 additions & 0 deletions tests/test_health_routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
import pytest
from httpx import AsyncClient
from motor.motor_asyncio import AsyncIOMotorClient


@pytest.fixture
async def test_client_bad_db():
from open_edc.api.deps import get_db_client
from open_edc.core.config import config
from open_edc.main import app

def get_db_client_override():
return AsyncIOMotorClient(
host="bad",
username=config.mongo_initdb_root_username,
password=config.mongo_initdb_root_password,
port=config.mongo_port,
serverSelectionTimeoutMS=100,
)

app.dependency_overrides[get_db_client] = get_db_client_override

async with AsyncClient(app=app, base_url=f"http://127.0.0.1{config.V1_API_PREFIX}") as client:
yield client

app.dependency_overrides = {}


async def test_health(test_client):
result = await test_client.get("health")

assert result.status_code == 200
assert result.json()["system"] == "healthy"
assert result.json()["db"] == "healthy"


async def test_health_unhealthy_db(test_client_bad_db):
result = await test_client_bad_db.get("health")

assert result.status_code == 200
assert result.json()["db"] == "unhealthy"

0 comments on commit dd52a52

Please sign in to comment.