Skip to content

Commit

Permalink
MPT-4905 add create organization endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Francesco Faraone committed Dec 27, 2024
1 parent 63b596c commit fae1001
Show file tree
Hide file tree
Showing 13 changed files with 407 additions and 32 deletions.
3 changes: 3 additions & 0 deletions app/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class Settings(BaseSettings):
postgres_host: str
postgres_port: int

api_modifier_base_url: str
api_modifier_jwt_secret: str

debug: bool = False

@computed_field
Expand Down
4 changes: 4 additions & 0 deletions app/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
API_MODIFIER_JWT_ALGORITHM = "HS256"
API_MODIFIER_JWT_ISSUER = "SWO"
API_MODIFIER_JWT_AUDIENCE = "modifier"
API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS = 300
17 changes: 12 additions & 5 deletions app/db/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import IntegrityError, NoResultFound
from sqlmodel import select, update
from sqlmodel.ext.asyncio.session import AsyncSession

Expand All @@ -18,16 +18,23 @@ class NotFoundError(DBError):
pass


class ConstraintViolationError(DBError):
pass


class ModelHandler[T: UUIDModel]:
def __init__(self, model_cls: type[T], session: AsyncSession) -> None:
self.model_cls: type[T] = model_cls
self.session: AsyncSession = session

async def create(self, data: BaseModel) -> T:
obj = self.model_cls(**data.model_dump())
self.session.add(obj)
await self.session.commit()
await self.session.refresh(obj)
try:
obj = self.model_cls(**data.model_dump())
self.session.add(obj)
await self.session.commit()
await self.session.refresh(obj)
except IntegrityError as e:
raise ConstraintViolationError(f"Failed to create {self.model_cls.__name__}: {e}")

return obj

Expand Down
12 changes: 11 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class Entitlement(EntitlementBase, TimestampModel, UUIDModel, table=True):

class EntitlementRead(EntitlementBase, UUIDModel):
created_at: datetime.datetime
updated_at: datetime.datetime
activated_at: datetime.datetime | None
status: EntitlementStatus

Expand All @@ -98,6 +99,15 @@ class OrganizationBase(SQLModel):
class Organization(OrganizationBase, TimestampModel, UUIDModel, table=True):
__tablename__ = "organizations"

organization_id: str | None = Field(max_length=255, nullable=True, index=True)


class OrganizationRead(OrganizationBase, UUIDModel):
pass
created_at: datetime.datetime
updated_at: datetime.datetime
organization_id: str | None


class OrganizationCreate(OrganizationBase):
user_id: str
currency: str
44 changes: 42 additions & 2 deletions app/routers/organizations.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,53 @@
from fastapi import APIRouter
import httpx
from fastapi import APIRouter, HTTPException, status
from fastapi_pagination.limit_offset import LimitOffsetPage

from app.models import OrganizationRead
from app import settings
from app.db.handlers import ConstraintViolationError
from app.models import Organization, OrganizationCreate, OrganizationRead
from app.pagination import paginate
from app.repositories import OrganizationRepository
from app.utils import get_api_modifier_jwt_token

router = APIRouter()


@router.get("/", response_model=LimitOffsetPage[OrganizationRead])
async def get_organizations(organization_repo: OrganizationRepository):
return await paginate(organization_repo)


@router.post("/", response_model=OrganizationRead, status_code=status.HTTP_201_CREATED)
async def create_organization(data: OrganizationCreate, organization_repo: OrganizationRepository):
organization: Organization | None = None
try:
organization = await organization_repo.create(data)
except ConstraintViolationError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Organization with this external ID already exists: {data.external_id}.",
)

try:
async with httpx.AsyncClient(base_url=settings.api_modifier_base_url) as client:
response = await client.post(
"/admin/organizations",
headers={"Authorization": f"Bearer {get_api_modifier_jwt_token()}"},
json={
"org_name": data.name,
"user_id": data.user_id,
"currency": data.currency,
},
)
response.raise_for_status()
ffc_organization = response.json()
organization.organization_id = ffc_organization["id"]
return await organization_repo.update(organization.id, organization)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(
"Error creating organization in FinOps for Cloud: "
f"{e.response.status_code} - {e.response.text}.",
),
)
28 changes: 28 additions & 0 deletions app/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from datetime import UTC, datetime, timedelta

import jwt

from app import settings
from app.constants import (
API_MODIFIER_JWT_ALGORITHM,
API_MODIFIER_JWT_AUDIENCE,
API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS,
API_MODIFIER_JWT_ISSUER,
)


def get_api_modifier_jwt_token() -> str:
now = datetime.now(UTC)
return jwt.encode(
{
"iss": API_MODIFIER_JWT_ISSUER,
"aud": API_MODIFIER_JWT_AUDIENCE,
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"exp": int(
(now + timedelta(seconds=API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS)).timestamp()
),
},
settings.api_modifier_jwt_secret,
algorithm=API_MODIFIER_JWT_ALGORITHM,
)
2 changes: 2 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ FFC_OPERATIONS_POSTGRES_USER=postgres
FFC_OPERATIONS_POSTGRES_PASSWORD=mysecurepass
FFC_OPERATIONS_POSTGRES_HOST=0.0.0.0
FFC_OPERATIONS_POSTGRES_PORT=5432
FFC_OPERATIONS_API_MODIFIER_BASE_URL=https://api-modifier.ffc.com
FFC_OPERATIONS_API_MODIFIER_JWT_SECRET=test_jwt_secret
33 changes: 33 additions & 0 deletions migrations/versions/7915164f66be_added_organization_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Added organization_id
Revision ID: 7915164f66be
Revises: 8a5758ec8e50
Create Date: 2024-12-27 10:27:37.156714
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


# revision identifiers, used by Alembic.
revision: str = '7915164f66be'
down_revision: Union[str, None] = '8a5758ec8e50'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('organizations', sa.Column('organization_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True))
op.create_index(op.f('ix_organizations_organization_id'), 'organizations', ['organization_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_organizations_organization_id'), table_name='organizations')
op.drop_column('organizations', 'organization_id')
# ### end Alembic commands ###
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ requires-python = ">=3.12,<4"
dependencies = [
"alembic==1.14.*",
"asyncpg==0.30.*",
"fastapi-async-sqlalchemy==0.6.*",
"fastapi-pagination==0.12.*",
"fastapi[standard]==0.115.*",
"pycountry==24.6.*",
Expand All @@ -20,21 +19,26 @@ dependencies = [
"python-dotenv==1.0.*",
"sqlalchemy[asyncio]==2.0.*",
"sqlmodel==0.0.*",
"alembic-postgresql-enum==1.4.*",
"httpx==0.27.*",
"pyjwt==2.10.*",
]

[dependency-groups]
dev = [
"alembic-postgresql-enum>=1.4.0",
"bandit>=1.8.0,<2.0",
"faker>=33.1.0,<34.0",
"httpx>=0.27.2,<1.0",
"ipython>=8.30.0,<9.0",
"mypy>=1.14.0,<2.0",
"pre-commit>=4.0.1,<5.0",
"pytest-httpx>=0.34.0,<1.0",
"pytest>=8.3.3,<9.0",
"pytest-asyncio>=0.24.0,<1.0",
"pytest-cov>=6.0.0,<7.0",
"ruff>=0.8.0,<1.0",
"typer>=0.13.1,<1.0",
"pytest-mock>=3.14.0",
"freezegun>=1.5.1,<2.0",
]


Expand Down
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from collections.abc import AsyncGenerator, Awaitable, Callable

import fastapi_pagination
Expand All @@ -6,6 +7,7 @@
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from pytest_asyncio import is_async_test
from pytest_mock import MockerFixture
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
Expand Down Expand Up @@ -110,3 +112,14 @@ async def _organization(
)

return _organization


@pytest.fixture
def mock_settings(mocker: MockerFixture) -> None:
mocker.patch.dict(
os.environ,
{
"FFC_OPERATIONS_API_MODIFIER_BASE_URL": "https://api-modifier.ffc.com",
"FFC_OPERATIONS_API_MODIFIER_JWT_SECRET": "test_jwt_secret",
},
)
Loading

0 comments on commit fae1001

Please sign in to comment.