Repository: CentOS/duffy (language: python)

Issue:
Repurpose project ⇒ tenant
This is needed for #135.

# Story

As a developer of Duffy,
I want that project and admin access are evaluated on the same basis,
so that the code dealing with it doesn’t get too complicated.

# Acceptance Criteria

- [x] ORM classes and associated tables, API endpoints and Python files related to projects are renamed to "tenant(s)", as applicable.
- [x] The new `Tenant` ORM class (and following, the `tenants` table in the database) has a boolean column `is_admin` which would let requests authenticated by their token (yet to come, see #135) manipulate any object, not only those which are related to that tenant.

# Background

I don't know why we started calling these projects in the first place. :wink:


Discussion / hints:

Fix patch:
diff --git a/duffy/api_models/__init__.py b/duffy/api_models/__init__.py
index e29c8dd3..00597092 100644
--- a/duffy/api_models/__init__.py
+++ b/duffy/api_models/__init__.py
@@ -17,15 +17,15 @@
     concrete_node_create_models,
     concrete_node_models,
 )
-from .project import (  # noqa: F401
-    ProjectCreateModel,
-    ProjectModel,
-    ProjectResult,
-    ProjectResultCollection,
-)
 from .session import (  # noqa: F401
     SessionCreateModel,
     SessionModel,
     SessionResult,
     SessionResultCollection,
 )
+from .tenant import (  # noqa: F401
+    TenantCreateModel,
+    TenantModel,
+    TenantResult,
+    TenantResultCollection,
+)
diff --git a/duffy/api_models/project.py b/duffy/api_models/project.py
deleted file mode 100644
index a8dc5013..00000000
--- a/duffy/api_models/project.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from abc import ABC
-from typing import List
-
-from pydantic import BaseModel
-
-from .common import APIResult, CreatableMixin, RetirableMixin
-
-# project model
-
-
-class ProjectBase(BaseModel, ABC):
-    name: str
-    ssh_key: str
-
-    class Config:
-        orm_mode = True
-
-
-class ProjectCreateModel(ProjectBase):
-    pass
-
-
-class ProjectModel(ProjectBase, CreatableMixin, RetirableMixin):
-    id: int
-
-
-# API results
-
-
-class ProjectResult(APIResult):
-    project: ProjectModel
-
-
-class ProjectResultCollection(APIResult):
-    projects: List[ProjectModel]
diff --git a/duffy/api_models/session.py b/duffy/api_models/session.py
index 5ec403d0..6177889c 100644
--- a/duffy/api_models/session.py
+++ b/duffy/api_models/session.py
@@ -4,7 +4,7 @@
 from pydantic import BaseModel
 
 from .common import APIResult, CreatableMixin, RetirableMixin
-from .project import ProjectModel
+from .tenant import TenantModel
 
 # session model
 
@@ -15,12 +15,12 @@ class Config:
 
 
 class SessionCreateModel(SessionBase):
-    project_id: int
+    tenant_id: int
 
 
 class SessionModel(SessionBase, CreatableMixin, RetirableMixin):
     id: int
-    project: ProjectModel
+    tenant: TenantModel
 
 
 # API results
diff --git a/duffy/api_models/tenant.py b/duffy/api_models/tenant.py
new file mode 100644
index 00000000..d34fc332
--- /dev/null
+++ b/duffy/api_models/tenant.py
@@ -0,0 +1,36 @@
+from abc import ABC
+from typing import List
+
+from pydantic import BaseModel
+
+from .common import APIResult, CreatableMixin, RetirableMixin
+
+# tenant model
+
+
+class TenantBase(BaseModel, ABC):
+    name: str
+    is_admin: bool
+    ssh_key: str
+
+    class Config:
+        orm_mode = True
+
+
+class TenantCreateModel(TenantBase):
+    pass
+
+
+class TenantModel(TenantBase, CreatableMixin, RetirableMixin):
+    id: int
+
+
+# API results
+
+
+class TenantResult(APIResult):
+    tenant: TenantModel
+
+
+class TenantResultCollection(APIResult):
+    tenants: List[TenantModel]
diff --git a/duffy/app/controllers/project.py b/duffy/app/controllers/project.py
deleted file mode 100644
index 41e0aaae..00000000
--- a/duffy/app/controllers/project.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""
-This is the project controller.
-"""
-
-from fastapi import APIRouter, HTTPException
-from sqlalchemy import select
-from sqlalchemy.exc import IntegrityError
-from starlette.status import HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
-
-from ...api_models import ProjectCreateModel, ProjectResult, ProjectResultCollection
-from ...database import DBSession
-from ...database.model import Project
-
-router = APIRouter(prefix="/projects")
-
-
-# http get http://localhost:8080/api/v1/projects
-@router.get("", response_model=ProjectResultCollection)
-async def get_all_projects():
-    """
-    Return all projects
-    """
-    query = select(Project)
-    results = await DBSession.execute(query)
-
-    return {"action": "get", "projects": results.scalars().all()}
-
-
-# http get http://localhost:8080/api/v1/projects/2
-@router.get("/{id}", response_model=ProjectResult)
-async def get_project(id: int):
-    """
-    Return the project with the specified ID
-    """
-    project = (await DBSession.execute(select(Project).filter_by(id=id))).scalar_one_or_none()
-
-    if not project:
-        raise HTTPException(HTTP_404_NOT_FOUND)
-
-    return {"action": "get", "project": project}
-
-
-# http --json post http://localhost:8080/api/v1/projects name="A Project with a unique name"
-@router.post("", status_code=HTTP_201_CREATED, response_model=ProjectResult)
-async def create_project(data: ProjectCreateModel):
-    """
-    Create a project with the specified name
-    """
-    project = Project(name=data.name, ssh_key=data.ssh_key)
-
-    DBSession.add(project)
-    try:
-        await DBSession.commit()
-    except IntegrityError as exc:
-        raise HTTPException(HTTP_409_CONFLICT, str(exc))
-
-    return {"action": "post", "project": project}
-
-
-# http delete http://localhost:8080/api/v1/project/2
-@router.delete("/{id}", response_model=ProjectResult)
-async def delete_project(id: int):
-    """
-    Delete the project with the specified ID
-    """
-    project = (await DBSession.execute(select(Project).filter_by(id=id))).scalar_one_or_none()
-
-    if not project:
-        raise HTTPException(HTTP_404_NOT_FOUND)
-
-    await DBSession.delete(project)
-    await DBSession.commit()
-
-    return {"action": "delete", "project": project}
diff --git a/duffy/app/controllers/session.py b/duffy/app/controllers/session.py
index d29d5fee..aabb6a61 100644
--- a/duffy/app/controllers/session.py
+++ b/duffy/app/controllers/session.py
@@ -10,7 +10,7 @@
 
 from ...api_models import SessionCreateModel, SessionResult, SessionResultCollection
 from ...database import DBSession
-from ...database.model import Project, Session
+from ...database.model import Session, Tenant
 
 router = APIRouter(prefix="/sessions")
 
@@ -21,7 +21,7 @@ async def get_all_sessions():
     """
     Returns all sessions
     """
-    query = select(Session).options(selectinload(Session.project))
+    query = select(Session).options(selectinload(Session.tenant))
     results = await DBSession.execute(query)
     return {"action": "get", "sessions": results.scalars().all()}
 
@@ -34,7 +34,7 @@ async def get_session(id: int):
     """
     session = (
         await DBSession.execute(
-            select(Session).filter_by(id=id).options(selectinload(Session.project))
+            select(Session).filter_by(id=id).options(selectinload(Session.tenant))
         )
     ).scalar_one_or_none()
     if not session:
@@ -42,20 +42,20 @@ async def get_session(id: int):
     return {"action": "get", "session": session}
 
 
-# http --json post http://localhost:8080/api/v1/sessions project_id=2
+# http --json post http://localhost:8080/api/v1/sessions tenant_id=2
 @router.post("", status_code=HTTP_201_CREATED, response_model=SessionResult)
 async def create_session(data: SessionCreateModel):
     """
-    Creates a session with the specified project ID
+    Creates a session with the specified tenant ID
     """
-    project = (
-        await DBSession.execute(select(Project).filter_by(id=data.project_id))
+    tenant = (
+        await DBSession.execute(select(Tenant).filter_by(id=data.tenant_id))
     ).scalar_one_or_none()
-    if not project:
+    if not tenant:
         raise HTTPException(
-            HTTP_422_UNPROCESSABLE_ENTITY, f"can't find project with id {data.project_id}"
+            HTTP_422_UNPROCESSABLE_ENTITY, f"can't find tenant with id {data.tenant_id}"
         )
-    session = Session(project=project)
+    session = Session(tenant=tenant)
     DBSession.add(session)
     try:
         await DBSession.commit()
@@ -72,7 +72,7 @@ async def delete_session(id: int):
     """
     session = (
         await DBSession.execute(
-            select(Session).filter_by(id=id).options(selectinload(Session.project))
+            select(Session).filter_by(id=id).options(selectinload(Session.tenant))
         )
     ).scalar_one_or_none()
     if not session:
diff --git a/duffy/app/controllers/tenant.py b/duffy/app/controllers/tenant.py
new file mode 100644
index 00000000..a9c03616
--- /dev/null
+++ b/duffy/app/controllers/tenant.py
@@ -0,0 +1,74 @@
+"""
+This is the tenant controller.
+"""
+
+from fastapi import APIRouter, HTTPException
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from starlette.status import HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
+
+from ...api_models import TenantCreateModel, TenantResult, TenantResultCollection
+from ...database import DBSession
+from ...database.model import Tenant
+
+router = APIRouter(prefix="/tenants")
+
+
+# http get http://localhost:8080/api/v1/tenants
+@router.get("", response_model=TenantResultCollection)
+async def get_all_tenants():
+    """
+    Return all tenants
+    """
+    query = select(Tenant)
+    results = await DBSession.execute(query)
+
+    return {"action": "get", "tenants": results.scalars().all()}
+
+
+# http get http://localhost:8080/api/v1/tenants/2
+@router.get("/{id}", response_model=TenantResult)
+async def get_tenant(id: int):
+    """
+    Return the tenant with the specified ID
+    """
+    tenant = (await DBSession.execute(select(Tenant).filter_by(id=id))).scalar_one_or_none()
+
+    if not tenant:
+        raise HTTPException(HTTP_404_NOT_FOUND)
+
+    return {"action": "get", "tenant": tenant}
+
+
+# http --json post http://localhost:8080/api/v1/tenants name="A tenant with a unique name"
+@router.post("", status_code=HTTP_201_CREATED, response_model=TenantResult)
+async def create_tenant(data: TenantCreateModel):
+    """
+    Create a tenant with the specified name
+    """
+    tenant = Tenant(name=data.name, is_admin=data.is_admin, ssh_key=data.ssh_key)
+
+    DBSession.add(tenant)
+    try:
+        await DBSession.commit()
+    except IntegrityError as exc:
+        raise HTTPException(HTTP_409_CONFLICT, str(exc))
+
+    return {"action": "post", "tenant": tenant}
+
+
+# http delete http://localhost:8080/api/v1/tenant/2
+@router.delete("/{id}", response_model=TenantResult)
+async def delete_tenant(id: int):
+    """
+    Delete the tenant with the specified ID
+    """
+    tenant = (await DBSession.execute(select(Tenant).filter_by(id=id))).scalar_one_or_none()
+
+    if not tenant:
+        raise HTTPException(HTTP_404_NOT_FOUND)
+
+    await DBSession.delete(tenant)
+    await DBSession.commit()
+
+    return {"action": "delete", "tenant": tenant}
diff --git a/duffy/app/main.py b/duffy/app/main.py
index c22e4888..73a8d921 100644
--- a/duffy/app/main.py
+++ b/duffy/app/main.py
@@ -2,7 +2,7 @@
 
 from fastapi import FastAPI
 
-from .controllers import chassis, node, project, session
+from .controllers import chassis, node, session, tenant
 
 log = logging.getLogger(__name__)
 app = FastAPI()
@@ -13,5 +13,5 @@
 
 app.include_router(chassis.router, prefix=PREFIX)
 app.include_router(node.router, prefix=PREFIX)
-app.include_router(project.router, prefix=PREFIX)
+app.include_router(tenant.router, prefix=PREFIX)
 app.include_router(session.router, prefix=PREFIX)
diff --git a/duffy/database/model/__init__.py b/duffy/database/model/__init__.py
index 0b50867a..f38cfe53 100644
--- a/duffy/database/model/__init__.py
+++ b/duffy/database/model/__init__.py
@@ -10,5 +10,5 @@
     SessionNode,
     VirtualNode,
 )
-from .project import Project  # noqa: F401
 from .session import Session  # noqa: F401
+from .tenant import Tenant  # noqa: F401
diff --git a/duffy/database/model/session.py b/duffy/database/model/session.py
index 932efff6..8d71babf 100644
--- a/duffy/database/model/session.py
+++ b/duffy/database/model/session.py
@@ -3,12 +3,12 @@
 
 from .. import Base
 from ..util import CreatableMixin, RetirableMixin
-from .project import Project
+from .tenant import Tenant
 
 
 class Session(Base, CreatableMixin, RetirableMixin):
     __tablename__ = "sessions"
     __mapper_args__ = {"eager_defaults": True}
     id = Column(Integer, primary_key=True, nullable=False)
-    project_id = Column(Integer, ForeignKey(Project.id), nullable=False)
-    project = relationship(Project, backref="sessions")
+    tenant_id = Column(Integer, ForeignKey(Tenant.id), nullable=False)
+    tenant = relationship(Tenant, backref="sessions")
diff --git a/duffy/database/model/project.py b/duffy/database/model/tenant.py
similarity index 58%
rename from duffy/database/model/project.py
rename to duffy/database/model/tenant.py
index 0a0c10ad..c9c52c30 100644
--- a/duffy/database/model/project.py
+++ b/duffy/database/model/tenant.py
@@ -1,12 +1,13 @@
-from sqlalchemy import Column, Integer, UnicodeText
+from sqlalchemy import Boolean, Column, Integer, UnicodeText
 
 from .. import Base
 from ..util import CreatableMixin, RetirableMixin
 
 
-class Project(Base, CreatableMixin, RetirableMixin):
-    __tablename__ = "projects"
+class Tenant(Base, CreatableMixin, RetirableMixin):
+    __tablename__ = "tenants"
     __mapper_args__ = {"eager_defaults": True}
     id = Column(Integer, primary_key=True, nullable=False)
     name = Column(UnicodeText, unique=True, nullable=False)
+    is_admin = Column(Boolean, nullable=False, default=False)
     ssh_key = Column(UnicodeText, nullable=False)


Test patch:
diff --git a/tests/app/controllers/test_project.py b/tests/app/controllers/test_project.py
deleted file mode 100644
index fd3d32e6..00000000
--- a/tests/app/controllers/test_project.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from . import BaseTestController
-
-
-class TestProject(BaseTestController):
-
-    name = "project"
-    path = "/api/v1/projects"
-    attrs = {
-        "name": "Some Honky Project!",
-        "ssh_key": "With a honky SSH key!",
-    }
-    unique = "unique"
diff --git a/tests/app/controllers/test_session.py b/tests/app/controllers/test_session.py
index 69bd5c9a..76d06844 100644
--- a/tests/app/controllers/test_session.py
+++ b/tests/app/controllers/test_session.py
@@ -1,7 +1,7 @@
 from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
 
 from . import BaseTestController
-from .test_project import TestProject as _TestProject
+from .test_tenant import TestTenant as _TestTenant
 
 
 class TestSession(BaseTestController):
@@ -9,11 +9,11 @@ class TestSession(BaseTestController):
     name = "session"
     path = "/api/v1/sessions"
     attrs = {
-        "project_id": (_TestProject, "id"),
+        "tenant_id": (_TestTenant, "id"),
     }
 
-    async def test_create_unknown_project(self, client):
-        response = await self._create_obj(client, add_attrs={"project_id": 1})
+    async def test_create_unknown_tenant(self, client):
+        response = await self._create_obj(client, add_attrs={"tenant_id": 1})
         assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
         result = response.json()
         assert "detail" in result
diff --git a/tests/app/controllers/test_tenant.py b/tests/app/controllers/test_tenant.py
new file mode 100644
index 00000000..cfbfdb39
--- /dev/null
+++ b/tests/app/controllers/test_tenant.py
@@ -0,0 +1,13 @@
+from . import BaseTestController
+
+
+class TestTenant(BaseTestController):
+
+    name = "tenant"
+    path = "/api/v1/tenants"
+    attrs = {
+        "name": "Some Honky Tenant!",
+        "is_admin": False,
+        "ssh_key": "With a honky SSH key!",
+    }
+    unique = "unique"
diff --git a/tests/app/test_main.py b/tests/app/test_main.py
index 74092be9..0a24fa0c 100644
--- a/tests/app/test_main.py
+++ b/tests/app/test_main.py
@@ -10,7 +10,7 @@ class TestMain:
     api_paths = (
         "/api/v1/chassis",
         "/api/v1/nodes",
-        "/api/v1/projects",
+        "/api/v1/tenants",
         "/api/v1/sessions",
     )
 
diff --git a/tests/database/test_model.py b/tests/database/test_model.py
index 8e88fc7f..3978d0db 100644
--- a/tests/database/test_model.py
+++ b/tests/database/test_model.py
@@ -55,10 +55,11 @@ def _db_obj_get_dependencies(self):
         return {}
 
 
-class TestProject(ModelTestBase):
-    klass = model.Project
+class TestTenant(ModelTestBase):
+    klass = model.Tenant
     attrs = {
-        "name": "My Fancy Project",
+        "name": "My Fancy Tenant",
+        "is_admin": False,
         "ssh_key": "this is a public ssh key",
     }
 
@@ -67,8 +68,8 @@ class TestSession(ModelTestBase):
     klass = model.Session
 
     def _db_obj_get_dependencies(self):
-        project = model.Project(name="My Other Project", ssh_key="my other public SSH key")
-        return {"project": project}
+        tenant = model.Tenant(name="My Other Tenant", ssh_key="my other public SSH key")
+        return {"tenant": tenant}
 
 
 class TestChassis(ModelTestBase):
@@ -116,8 +117,8 @@ class TestSessionNode(ModelTestBase):
     attrs = {"distro_type": "CentOS", "distro_version": "8Stream"}
 
     def _db_obj_get_dependencies(self):
-        project = model.Project(name="World Domination", ssh_key="Muahahahaha!")
-        session = model.Session(project=project)
+        tenant = model.Tenant(name="World Domination", is_admin=True, ssh_key="Muahahahaha!")
+        session = model.Session(tenant=tenant)
         chassis = model.Chassis(name="Celestion Greenback")
         node = model.SeaMicroNode(**_gen_node_attrs(chassis=chassis))
 
diff --git a/tests/test_shell.py b/tests/test_shell.py
index 2e1e48e4..582b4655 100644
--- a/tests/test_shell.py
+++ b/tests/test_shell.py
@@ -35,7 +35,7 @@ def test_get_shell_variables(IPython, with_ipython, with_autoawait):
 
     assert variables["SyncDBSession"] is database.SyncDBSession
     # Do a spot check of one model class.
-    assert variables["Project"] is model.Project
+    assert variables["Tenant"] is model.Tenant
 
     if with_ipython and with_autoawait:
         assert variables["DBSession"] is database.DBSession