diff --git a/api/asset_manager/src/database.py b/api/asset_manager/src/database.py index 0c374e1a..5213f465 100644 --- a/api/asset_manager/src/database.py +++ b/api/asset_manager/src/database.py @@ -23,7 +23,7 @@ TEST_TORTOISE_ORM = { }, } -PROD_TORTOISE_ORM = { +TORTOISE_ORM = { "connections": { "default": { "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: tortoise_config=TEST_TORTOISE_ORM aerich = Command(tortoise_config) diff --git a/api/asset_manager/src/migrations/models/0_20250623123107_init.py b/api/asset_manager/src/migrations/models/0_20250625121149_init.py similarity index 95% rename from api/asset_manager/src/migrations/models/0_20250623123107_init.py rename to api/asset_manager/src/migrations/models/0_20250625121149_init.py index 92eb1c66..a1585f4a 100644 --- a/api/asset_manager/src/migrations/models/0_20250623123107_init.py +++ b/api/asset_manager/src/migrations/models/0_20250625121149_init.py @@ -18,6 +18,11 @@ CREATE TABLE IF NOT EXISTS "organization" ( "id" CHAR(36) NOT NULL PRIMARY KEY, "name" 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 ) /* Organization */; CREATE TABLE IF NOT EXISTS "user" ( diff --git a/api/asset_manager/src/modules/organizations/models.py b/api/asset_manager/src/modules/organizations/models.py index b8e4bf70..970ea68b 100644 --- a/api/asset_manager/src/modules/organizations/models.py +++ b/api/asset_manager/src/modules/organizations/models.py @@ -64,6 +64,11 @@ 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", diff --git a/api/asset_manager/src/modules/organizations/router.py b/api/asset_manager/src/modules/organizations/router.py index 9cb14a5b..07922520 100644 --- a/api/asset_manager/src/modules/organizations/router.py +++ b/api/asset_manager/src/modules/organizations/router.py @@ -1,17 +1,131 @@ -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("/") -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.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) + if alter_organization.name: + org.name = alter_organization.name + if alter_organization.type: + org.type = alter_organization.type + if alter_organization.street_name: + org.street_name = alter_organization.street_name + if alter_organization.zip_code: + org.zip_code = alter_organization.zip_code + if alter_organization.state: + org.state = alter_organization.state + if alter_organization.city: + org.city = alter_organization.city + if alter_organization.country: + org.country = alter_organization.country + await org.save() + + return org diff --git a/api/asset_manager/src/modules/organizations/schemas.py b/api/asset_manager/src/modules/organizations/schemas.py index 272a700d..57864929 100644 --- a/api/asset_manager/src/modules/organizations/schemas.py +++ b/api/asset_manager/src/modules/organizations/schemas.py @@ -1,6 +1,24 @@ +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 + +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 \ No newline at end of file diff --git a/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py b/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py new file mode 100644 index 00000000..13a8335c --- /dev/null +++ b/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py @@ -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.", + }