Add Organization router, tests etc.

This commit is contained in:
2025-04-04 14:27:03 +03:00
parent 4477c2aa7b
commit a17bc36831
15 changed files with 418 additions and 28 deletions
@@ -0,0 +1,19 @@
from tortoise import BaseDBAsyncClient
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "organization" ADD "street_name" TEXT;
ALTER TABLE "organization" ADD "country" VARCHAR(128);
ALTER TABLE "organization" ADD "zip_code" VARCHAR(128);
ALTER TABLE "organization" ADD "city" VARCHAR(128);
ALTER TABLE "organization" ADD "state" VARCHAR(128);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "organization" DROP COLUMN "street_name";
ALTER TABLE "organization" DROP COLUMN "country";
ALTER TABLE "organization" DROP COLUMN "zip_code";
ALTER TABLE "organization" DROP COLUMN "city";
ALTER TABLE "organization" DROP COLUMN "state";"""
@@ -0,0 +1,5 @@
from fastapi import APIRouter
router = APIRouter(prefix="/api/v1/acls", tags=["acl"])
+5 -5
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from typing import Annotated
import uuid
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.routing import APIRouter
import pytz
@@ -25,7 +25,7 @@ crypt = settings.CRYPT
@router.post("/login")
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> JSONResponse:
"""
Login
@@ -48,7 +48,7 @@ async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
@router.get("/logout", status_code=204)
async def logout(user: Annotated[User, Depends(get_current_active_user)]):
async def logout(user: Annotated[User, Depends(get_current_active_user)]) -> None:
"""
Logout
@@ -67,7 +67,7 @@ async def logout(user: Annotated[User, Depends(get_current_active_user)]):
@router.post("/refresh")
async def refresh_login(
refresh_token: Annotated[Token | None, Depends(get_tokens_from_logged_in_user)],
):
) -> JSONResponse:
"""
Refresh
@@ -111,7 +111,7 @@ async def refresh_login(
@router.post("/register", status_code=201, response_model=user_model)
async def register(user: register_model):
async def register(user: register_model) -> User:
# Prevent existing users from reapplying for our system.
existing_user: User | None = await User.filter(
Q(email=user.email)
@@ -9,6 +9,7 @@ from tortoise import fields
from mixins.CMDMixin import CMDMixin
class EnumField(fields.CharField):
"""
Serializes Enums to and from a str representation in the DB.
@@ -52,7 +53,6 @@ class OrganizationType(Enum):
EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 1000 - 5000+
class Organization(Model, CMDMixin):
"""
Organization
@@ -64,10 +64,15 @@ class Organization(Model, CMDMixin):
id: uuid.UUID = fields.UUIDField(primary_key=True)
name: str = fields.CharField(max_length=128)
type: str = EnumField(OrganizationType)
street_name: str | None = fields.TextField(null=True)
zip_code: str | None = fields.CharField(max_length=128, null=True)
state: str | None = fields.CharField(max_length=128, null=True)
city: str | None = fields.CharField(max_length=128, null=True)
country: str | None = fields.CharField(max_length=128, null=True)
users: uuid.UUID = fields.ManyToManyField(
"models.User",
related_name="members",
through="Membership",
through="membership",
forward_key="user_id",
backward_key="organization_id",
null=True,
@@ -85,5 +90,3 @@ class Organization(Model, CMDMixin):
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
@@ -1,17 +1,103 @@
from fastapi import APIRouter
import uuid
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated, List
from modules.organizations.models import Organization
from modules.organizations.schemas import organization_model, register_organization
from modules.users.utils import get_current_active_user
from modules.users.models import ACL, Membership, User
from tortoise.expressions import Q
router = APIRouter(prefix="/api/v1/organizations", tags=["orgs"])
router = APIRouter(prefix="/api/v1/organizations")
@router.get("/", response_model=List[organization_model])
async def all_active_organizations(
user: Annotated[User, Depends(get_current_active_user)],
) -> List[Organization]:
memberships: List[Membership] = list(
await Membership.filter(
Q(user_id=user.id) & Q(disabled=False)
).prefetch_related("organization")
)
organizations: List[Organization] = []
@router.get("/")
def all_organizations():
pass
if len(memberships) < 1:
raise HTTPException(status_code=404, detail="No active organizations found!")
@router.delete("/")
def delete_organization():
pass
for member in memberships:
organizations.append(member.organization)
@router.post("/create")
def create_organization():
pass
return organizations
@router.delete("/{org_id}", status_code=204)
async def delete_organization(
user: Annotated[User, Depends(get_current_active_user)], org_id: uuid.UUID
) -> None:
membership: Membership | None = (
await Membership.filter(Q(user_id=user.id) & Q(organization_id=org_id))
.get_or_none()
.prefetch_related("acl", "user", "organization")
)
if not membership:
raise HTTPException(
status_code=403,
detail="You are not part of the organization you wish to leave or remove.",
)
if membership.acl.ADMIN:
# Prepare to remove ALL members in the organization.
# We've already checked whether user is ADMIN.
all_memberships: List[Membership] = list(
await Membership.filter(Q(organization_id=org_id)).prefetch_related(
"acl", "user", "organization"
)
)
for member in all_memberships:
await member.acl.delete()
await member.delete()
# Completely remove organization.
await membership.organization.delete()
else:
await membership.delete()
return
@router.post("/", response_model=organization_model)
async def create_organization(
user: Annotated[User, Depends(get_current_active_user)],
register_organization: register_organization,
) -> Organization:
acl: ACL = await ACL.create(
READ=True, WRITE=True, REPORT=True, MANAGE=True, ADMIN=True
)
org: Organization = await Organization.create(
name=register_organization.name,
type=register_organization.type,
street_name=register_organization.street_name,
zip_code=register_organization.zip_code,
state=register_organization.state,
city=register_organization.city,
country=register_organization.country,
)
await Membership.create(organization=org, user=user, acl=acl)
return org
@router.put("/{org_id}", response_model=organization_model)
async def update_organization(
user: Annotated[User, Depends(get_current_active_user)],
org_id: uuid.UUID,
alter_organization: register_organization,
) -> Organization:
org: Organization | None = Organization.filter(
Q(users__id=user.id) & Q(id=org_id)
).get_or_none()
if not org:
raise HTTPException(status_code=404, detail="Organization could not be found.")
return await org.update_from_dict(**alter_organization)
@@ -1,6 +1,15 @@
from pydantic import BaseModel
from tortoise.contrib.pydantic import pydantic_model_creator
from modules.organizations.models import Organization
from modules.organizations.models import Organization, OrganizationType
OrganizationModel = pydantic_model_creator(Organization)
organization_model = pydantic_model_creator(Organization)
class register_organization(BaseModel):
name: str
type: OrganizationType
street_name: str | None
zip_code: str | None
state: str | None
city: str | None
country: str | None
@@ -1,4 +1,5 @@
from datetime import datetime
from typing import List
import uuid
from pydantic import EmailStr
import pytz
@@ -25,17 +26,16 @@ class User(Model, CMDMixin):
name: str = fields.TextField(max_length=128)
surname: str = fields.TextField(max_length=128)
password: str = fields.CharField(max_length=128, null=True)
organizations: uuid = fields.ManyToManyField(
organizations: List[Organization] = fields.ManyToManyField(
"models.Organization",
related_name="members",
through="Membership",
through="membership",
forward_key="organization_id",
backward_key="user_id",
null=True,
on_delete=fields.NO_ACTION,
)
disabled: bool = fields.BooleanField(default=False)
# tokens = fields.ForeignKeyField("models.Token")
def __str__(self) -> str:
return f"{self.id} - {self.name} {self.surname}"
@@ -98,9 +98,9 @@ class Membership(Model, CMDMixin):
"""
id: uuid.UUID = fields.UUIDField(primary_key=True)
organization: Organization = fields.ForeignKeyField("models.Organization")
user: User = fields.ForeignKeyField("models.User")
acl: ACL = fields.ForeignKeyField("models.ACL")
organization: Organization | None = fields.ForeignKeyField("models.Organization")
user: User | None = fields.ForeignKeyField("models.User")
acl: ACL | None = fields.ForeignKeyField("models.ACL")
disabled: bool = fields.BooleanField(default=False)
async def delete(self, force: bool = False) -> None:
@@ -1,5 +1,7 @@
from modules.auth.utils import create_jwt_tokens
from modules.organizations.models import Organization, OrganizationType
from modules.users.models import ACL, Membership, User
from modules.auth.models import Token
import pytest # type=ignore
from config import settings
@@ -84,3 +86,89 @@ async def use_admin_account():
},
)
return org, acl, user, membership
@pytest.fixture()
async def get_user_login_token():
org, _ = await Organization.get_or_create(
id="de001f44-1bb8-4667-9f9d-2d62d6ad7902",
defaults={
"name": "User's Organization",
"type": OrganizationType.SMALL_ORGANIZATION,
},
)
acl, _ = await ACL.get_or_create(
id="83c1bfe6-c2ed-4ba1-be03-0e5c1960eA32",
defaults={
"READ": True,
"WRITE": True,
"REPORT": True,
"MANAGE": True,
"ADMIN": True,
},
)
user, _ = await User.get_or_create(
id="24235427-9662-4ba3-a9c5-00000000000d",
defaults={
"email": "plainuser@localhost.com",
"username": "plainuser",
"name": "awesome",
"surname": "plainuser",
"password": crypt.hash("superplainuser"),
},
)
_, _ = await Membership.get_or_create(
id="393473ee-c218-4bcf-82cd-cb676c4d8C93",
defaults={
"organization": org,
"user": user,
"acl": acl,
},
)
token: Token = await create_jwt_tokens(user=user)
return token.access_token, token.refresh_token
@pytest.fixture()
async def get_admin_login_token():
org, _ = await Organization.get_or_create(
id="de001f44-1bb8-4667-9f9d-2d62d6ad7290",
defaults={
"name": "Superadmin's Organization",
"type": OrganizationType.SMALL_ORGANIZATION,
},
)
acl, _ = await ACL.get_or_create(
id="83c1bfe6-c2ed-4ba1-be03-0e5c1960ec40",
defaults={
"READ": True,
"WRITE": True,
"REPORT": True,
"MANAGE": True,
"ADMIN": True,
},
)
user, _ = await User.get_or_create(
id="24235427-9662-4ba3-a9c5-00000000000c",
defaults={
"email": "superadmin@localhost.com",
"username": "superadmin",
"name": "awesome",
"surname": "superadmin",
"password": crypt.hash("superadminpassword"),
},
)
_, _ = await Membership.get_or_create(
id="393473ee-c218-4bcf-82cd-cb676c4d8a40",
defaults={
"organization": org,
"user": user,
"acl": acl,
},
)
token: Token = await create_jwt_tokens(user=user)
return token.access_token, token.refresh_token
@@ -0,0 +1,180 @@
import pytest # type: ignore
from httpx import AsyncClient
from config import settings
from unittest.mock import ANY
crypt = settings.CRYPT
class TestOrganizationRoute(object):
@pytest.mark.asyncio
async def test_get_organizations_from_api(
self, client: AsyncClient, get_admin_login_token
):
access_token, _ = get_admin_login_token
organizations = await client.get(
"https://localhost/api/v1/organizations/",
headers={"Authorization": f"Bearer {access_token}"},
)
assert organizations.status_code == 200
assert organizations.json() == [
{
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"modified_at": ANY,
"name": "Superadmin's Organization",
"type": "non_profit",
"street_name": None,
"zip_code": None,
"state": None,
"city": None,
"country": None,
},
]
@pytest.mark.asyncio
async def test_create_organization(
self, client: AsyncClient, get_user_login_token
):
access_token, _ = get_user_login_token
organizations = await client.post(
"https://localhost/api/v1/organizations/",
json={
"name": "My new organization",
"type": "xl_org",
"street_name": "Alakaventie 5 A 188",
"zip_code": "00920",
"state": "uusimaa",
"city": "Helsinki",
"country": "Finland",
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert organizations.status_code == 200
assert organizations.json() == {
"created_at": ANY,
"modified_at": ANY,
"disabled_at": None,
"id": ANY,
"name": "My new organization",
"type": "xl_org",
"street_name": "Alakaventie 5 A 188",
"zip_code": "00920",
"state": "uusimaa",
"city": "Helsinki",
"country": "Finland",
"disabled": False,
}
@pytest.mark.asyncio
async def test_delete_organization(
self, client: AsyncClient, get_user_login_token
):
access_token, _ = get_user_login_token
organizations = await client.post(
"https://localhost/api/v1/organizations/",
json={
"name": "My new organization",
"type": "xl_org",
"street_name": "Alakaventie 5 A 188",
"zip_code": "00920",
"state": "uusimaa",
"city": "Helsinki",
"country": "Finland",
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert organizations.status_code == 200
assert organizations.json() == {
"created_at": ANY,
"modified_at": ANY,
"disabled_at": None,
"id": ANY,
"name": "My new organization",
"type": "xl_org",
"street_name": "Alakaventie 5 A 188",
"zip_code": "00920",
"state": "uusimaa",
"city": "Helsinki",
"country": "Finland",
"disabled": False,
}
org_id = organizations.json()["id"]
deleted_org = await client.delete(
f"https://localhost/api/v1/organizations/{org_id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert deleted_org.status_code == 204
# @pytest.mark.asyncio
# async def test_update_organization(
# self, client: AsyncClient, get_admin_login_token
# ):
# access_token, _ = get_admin_login_token
# organizations = await client.post(
# "https://localhost/api/v1/organizations/",
# json={
# "name": "My new organization",
# "type": "xl_org",
# "street_name": "Alakaventie 5 A 188",
# "zip_code": "00920",
# "state": "uusimaa",
# "city": "Helsinki",
# "country": "Finland",
# },
# headers={"Authorization": f"Bearer {access_token}"},
# )
# assert organizations.status_code == 200
# assert organizations.json() == {
# "created_at": ANY,
# "modified_at": ANY,
# "disabled_at": None,
# "id": ANY,
# "name": "My new organization",
# "type": "xl_org",
# "street_name": "Alakaventie 5 A 188",
# "zip_code": "00920",
# "state": "uusimaa",
# "city": "Helsinki",
# "country": "Finland",
# "disabled": False,
# }
# org_id = organizations.json()["id"]
# update_org = await client.put(
# f"https://localhost/api/v1/organizations/{org_id}",
# json={
# "name": "My awesome organization",
# },
# headers={"Authorization": f"Bearer {access_token}"},
# )
# assert update_org.json() == {
# "created_at": ANY,
# "modified_at": ANY,
# "disabled_at": None,
# "id": ANY,
# "name": "My new organization",
# "type": "xl_org",
# "street_name": "Alakaventie 5 A 188",
# "zip_code": "00920",
# "state": "uusimaa",
# "city": "Helsinki",
# "country": "Finland",
# "disabled": False,
# }