Add routes for organization management
This commit is contained in:
@@ -23,7 +23,7 @@ TEST_TORTOISE_ORM = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
PROD_TORTOISE_ORM = {
|
TORTOISE_ORM = {
|
||||||
"connections": {
|
"connections": {
|
||||||
"default": {
|
"default": {
|
||||||
"engine": "tortoise.backends.asyncpg",
|
"engine": "tortoise.backends.asyncpg",
|
||||||
@@ -45,7 +45,7 @@ PROD_TORTOISE_ORM = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def migrate_db(tortoise_config=PROD_TORTOISE_ORM):
|
async def migrate_db(tortoise_config=TORTOISE_ORM):
|
||||||
if settings.IS_TESTING:
|
if settings.IS_TESTING:
|
||||||
tortoise_config=TEST_TORTOISE_ORM
|
tortoise_config=TEST_TORTOISE_ORM
|
||||||
aerich = Command(tortoise_config)
|
aerich = Command(tortoise_config)
|
||||||
|
|||||||
+5
@@ -18,6 +18,11 @@ CREATE TABLE IF NOT EXISTS "organization" (
|
|||||||
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
"name" VARCHAR(128) NOT NULL,
|
"name" VARCHAR(128) NOT NULL,
|
||||||
"type" VARCHAR(128) NOT NULL,
|
"type" VARCHAR(128) NOT NULL,
|
||||||
|
"street_name" TEXT,
|
||||||
|
"zip_code" VARCHAR(128),
|
||||||
|
"state" VARCHAR(128),
|
||||||
|
"city" VARCHAR(128),
|
||||||
|
"country" VARCHAR(128),
|
||||||
"disabled" INT NOT NULL DEFAULT 0
|
"disabled" INT NOT NULL DEFAULT 0
|
||||||
) /* Organization */;
|
) /* Organization */;
|
||||||
CREATE TABLE IF NOT EXISTS "user" (
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
@@ -64,6 +64,11 @@ class Organization(Model, CMDMixin):
|
|||||||
id: uuid.UUID = fields.UUIDField(primary_key=True)
|
id: uuid.UUID = fields.UUIDField(primary_key=True)
|
||||||
name: str = fields.CharField(max_length=128)
|
name: str = fields.CharField(max_length=128)
|
||||||
type: str = EnumField(OrganizationType)
|
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(
|
users: uuid.UUID = fields.ManyToManyField(
|
||||||
"models.User",
|
"models.User",
|
||||||
related_name="members",
|
related_name="members",
|
||||||
|
|||||||
@@ -1,17 +1,124 @@
|
|||||||
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,
|
||||||
|
update_org,
|
||||||
|
)
|
||||||
|
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("/")
|
if len(memberships) < 1:
|
||||||
def all_organizations():
|
raise HTTPException(status_code=404, detail="No active organizations found!")
|
||||||
pass
|
|
||||||
|
|
||||||
@router.delete("/")
|
for member in memberships:
|
||||||
def delete_organization():
|
organizations.append(member.organization)
|
||||||
pass
|
|
||||||
|
|
||||||
@router.post("/create")
|
return organizations
|
||||||
def create_organization():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
|
@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.get_or_none(
|
||||||
|
Q(user=user) & Q(organization_id=org_id)
|
||||||
|
).prefetch_related("acl")
|
||||||
|
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
for member in all_memberships:
|
||||||
|
await member.acl.delete()
|
||||||
|
await member.delete()
|
||||||
|
|
||||||
|
await membership.acl.delete()
|
||||||
|
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: update_org,
|
||||||
|
) -> Organization:
|
||||||
|
membership: Membership | None = await Membership.get_or_none(
|
||||||
|
organization__id=org_id,
|
||||||
|
user=user,
|
||||||
|
).prefetch_related("acl")
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="It seems you are not part of the organization or are an admin of the said organization.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not membership.acl.ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="It seems you are not part of the organization or are an admin of the said organization.",
|
||||||
|
)
|
||||||
|
|
||||||
|
org: Organization = await Organization.get(id=org_id)
|
||||||
|
org.name = alter_organization.name
|
||||||
|
org.type = alter_organization.type
|
||||||
|
org.street_name = alter_organization.street_name
|
||||||
|
org.zip_code = alter_organization.zip_code
|
||||||
|
org.state = alter_organization.state
|
||||||
|
org.city = alter_organization.city
|
||||||
|
org.country = alter_organization.country
|
||||||
|
await org.save()
|
||||||
|
|
||||||
|
return org
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
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
|
||||||
|
|
||||||
|
class update_org(BaseModel):
|
||||||
|
name: str | None
|
||||||
|
type: OrganizationType | None
|
||||||
|
street_name: str | None
|
||||||
|
zip_code: str | None
|
||||||
|
state: str | None
|
||||||
|
city: str | None
|
||||||
|
country: str | None
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
from httpx import AsyncClient
|
||||||
|
from modules.users.models import ACL, Membership
|
||||||
|
from modules.organizations.models import Organization
|
||||||
|
from config import settings
|
||||||
|
from unittest.mock import ANY
|
||||||
|
from tests.base_test import Test
|
||||||
|
|
||||||
|
crypt = settings.CRYPT
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrganizationRoute(Test):
|
||||||
|
async def test_get_organizations_from_api(
|
||||||
|
self, client: AsyncClient, create_user_with_org
|
||||||
|
):
|
||||||
|
_, _, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
organizations = await client.get(
|
||||||
|
"https://localhost/api/v1/organizations/",
|
||||||
|
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert organizations.status_code == 200
|
||||||
|
assert organizations.json() == [
|
||||||
|
{
|
||||||
|
"created_at": ANY,
|
||||||
|
"disabled": False,
|
||||||
|
"disabled_at": None,
|
||||||
|
"id": ANY,
|
||||||
|
"modified_at": ANY,
|
||||||
|
"name": "simple organization",
|
||||||
|
"type": "home",
|
||||||
|
"street_name": None,
|
||||||
|
"zip_code": None,
|
||||||
|
"state": None,
|
||||||
|
"city": None,
|
||||||
|
"country": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_create_organization(self, client: AsyncClient, create_user_with_org):
|
||||||
|
_, _, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
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 {tokens.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,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_delete_organization(self, client: AsyncClient, create_user_with_org):
|
||||||
|
_, _, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
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 {tokens.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 {tokens.access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert deleted_org.status_code == 204
|
||||||
|
|
||||||
|
async def test_cannot_delete_organization_you_are_not_a_part_of(
|
||||||
|
self, client: AsyncClient, create_user_with_org
|
||||||
|
):
|
||||||
|
_, _, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
organization: Organization = await Organization.create(
|
||||||
|
name="My Pretty Organization",
|
||||||
|
type="xl_org",
|
||||||
|
street_name="Alakaventie 5 A 188",
|
||||||
|
zip_code="00920",
|
||||||
|
state="uusimaa",
|
||||||
|
city="Helsinki",
|
||||||
|
country="Finland",
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_org = await client.delete(
|
||||||
|
f"https://localhost/api/v1/organizations/{organization.id}",
|
||||||
|
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert deleted_org.status_code == 403
|
||||||
|
|
||||||
|
async def test_delete_membership_of_organization(
|
||||||
|
self, client: AsyncClient, create_user_with_org
|
||||||
|
):
|
||||||
|
user, _, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
organization: Organization = await Organization.create(
|
||||||
|
name="My Pretty Organization",
|
||||||
|
type="xl_org",
|
||||||
|
street_name="Alakaventie 5 A 188",
|
||||||
|
zip_code="00920",
|
||||||
|
state="uusimaa",
|
||||||
|
city="Helsinki",
|
||||||
|
country="Finland",
|
||||||
|
)
|
||||||
|
|
||||||
|
acl: ACL = await ACL.create(
|
||||||
|
READ=True, WRITE=True, REPORT=True, MANAGE=False, ADMIN=False
|
||||||
|
)
|
||||||
|
|
||||||
|
await Membership.create(
|
||||||
|
user=user, organization=organization, acl=acl
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_org = await client.delete(
|
||||||
|
f"https://localhost/api/v1/organizations/{organization.id}",
|
||||||
|
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert deleted_org.status_code == 204
|
||||||
|
|
||||||
|
async def test_update_organization(self, client: AsyncClient, create_user_with_org):
|
||||||
|
_, _, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
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 {tokens.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",
|
||||||
|
"type": "xl_org",
|
||||||
|
"street_name": "Alakaventie 5 A 188",
|
||||||
|
"zip_code": "00920",
|
||||||
|
"state": "uusimaa",
|
||||||
|
"city": "Helsinki",
|
||||||
|
"country": "Finland",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update_org.json() == {
|
||||||
|
"created_at": ANY,
|
||||||
|
"modified_at": ANY,
|
||||||
|
"disabled_at": None,
|
||||||
|
"id": ANY,
|
||||||
|
"name": "My awesome organization",
|
||||||
|
"type": "xl_org",
|
||||||
|
"street_name": "Alakaventie 5 A 188",
|
||||||
|
"zip_code": "00920",
|
||||||
|
"state": "uusimaa",
|
||||||
|
"city": "Helsinki",
|
||||||
|
"country": "Finland",
|
||||||
|
"disabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_cannot_update_organization_you_are_not_a_part_of(
|
||||||
|
self, client: AsyncClient, create_user_with_org
|
||||||
|
):
|
||||||
|
_, _, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
organization: Organization = await Organization.create(
|
||||||
|
name="My Pretty Organization",
|
||||||
|
type="xl_org",
|
||||||
|
street_name="Alakaventie 5 A 188",
|
||||||
|
zip_code="00920",
|
||||||
|
state="uusimaa",
|
||||||
|
city="Helsinki",
|
||||||
|
country="Finland",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_org = await client.put(
|
||||||
|
f"https://localhost/api/v1/organizations/{organization.id}",
|
||||||
|
json={
|
||||||
|
"name": "My awesome organization",
|
||||||
|
"type": "xl_org",
|
||||||
|
"street_name": "Alakaventie 5 A 188",
|
||||||
|
"zip_code": "00920",
|
||||||
|
"state": "uusimaa",
|
||||||
|
"city": "Helsinki",
|
||||||
|
"country": "Finland",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update_org.status_code == 403
|
||||||
|
assert update_org.json() == {
|
||||||
|
"detail": "It seems you are not part of the organization or are an admin of the said "
|
||||||
|
"organization.",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_cannot_update_organization_you_are_not_an_admin_of(
|
||||||
|
self, client: AsyncClient, create_user_with_org
|
||||||
|
):
|
||||||
|
_, organization, _, tokens = await create_user_with_org()
|
||||||
|
|
||||||
|
update_org = await client.put(
|
||||||
|
f"https://localhost/api/v1/organizations/{organization.id}",
|
||||||
|
json={
|
||||||
|
"name": "My awesome organization",
|
||||||
|
"type": "xl_org",
|
||||||
|
"street_name": "Alakaventie 5 A 188",
|
||||||
|
"zip_code": "00920",
|
||||||
|
"state": "uusimaa",
|
||||||
|
"city": "Helsinki",
|
||||||
|
"country": "Finland",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update_org.status_code == 403
|
||||||
|
assert update_org.json() == {
|
||||||
|
"detail": "It seems you are not part of the organization or are an admin of the said "
|
||||||
|
"organization.",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user