diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json index abe20cb..6d36878 100644 --- a/backend/.vscode/launch.json +++ b/backend/.vscode/launch.json @@ -16,7 +16,7 @@ "type": "debugpy", "request": "launch", "module": "uvicorn", - "args": ["app.main:app", "--reload"], + "args": ["src.app:app", "--reload"], "jinja": true } ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 92f3023..ffb758a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "icmplib>=3.0.4", "tcppinglib>=2.0.3", "netmiko>=4.3.0", + "sqladmin>=0.18.0", + "itsdangerous>=2.2.0", ] readme = "README.md" requires-python = ">= 3.11" @@ -100,6 +102,7 @@ fixable = ["ALL"] "src/features/admin/schemas.py" = ["N815"] # frontend menu "alembic/*.py" = ["INP001", "UP007", "PLR0915", "E402", "F403"] "__init__.py" = ["F403", "F401"] +"views.py" = ["RUF012"] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = [ diff --git a/backend/requirements-dev.lock b/backend/requirements-dev.lock index a79e63a..2c55482 100644 --- a/backend/requirements-dev.lock +++ b/backend/requirements-dev.lock @@ -97,8 +97,11 @@ idna==3.6 # via httpx iniconfig==2.0.0 # via pytest +itsdangerous==2.2.0 + # via netsight jinja2==3.1.4 # via fastapi + # via sqladmin kombu==5.3.7 # via celery mako==1.3.0 @@ -108,6 +111,7 @@ markdown-it-py==3.0.0 markupsafe==2.1.3 # via jinja2 # via mako + # via wtforms mdurl==0.1.2 # via markdown-it-py mypy==1.8.0 @@ -189,6 +193,7 @@ python-dotenv==1.0.0 python-multipart==0.0.9 # via fastapi # via netsight + # via sqladmin pytz==2024.1 # via pandas pyyaml==6.0.1 @@ -214,14 +219,18 @@ six==1.16.0 sniffio==1.3.0 # via anyio # via httpx +sqladmin==0.18.0 + # via netsight sqlalchemy==2.0.31 # via alembic # via netsight + # via sqladmin # via sqlalchemy-utils sqlalchemy-utils==0.41.1 # via netsight starlette==0.37.2 # via fastapi + # via sqladmin tcppinglib==2.0.3 # via netsight termcolor==2.4.0 @@ -264,3 +273,5 @@ wcwidth==0.2.13 # via prompt-toolkit websockets==12.0 # via uvicorn +wtforms==3.1.2 + # via sqladmin diff --git a/backend/requirements.lock b/backend/requirements.lock index 6064bec..91e3af9 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -81,8 +81,11 @@ idna==3.6 # via anyio # via email-validator # via httpx +itsdangerous==2.2.0 + # via netsight jinja2==3.1.4 # via fastapi + # via sqladmin kombu==5.3.7 # via celery mako==1.3.0 @@ -92,6 +95,7 @@ markdown-it-py==3.0.0 markupsafe==2.1.3 # via jinja2 # via mako + # via wtforms mdurl==0.1.2 # via markdown-it-py netmiko==4.3.0 @@ -145,6 +149,7 @@ python-dotenv==1.0.0 python-multipart==0.0.9 # via fastapi # via netsight + # via sqladmin pytz==2024.1 # via pandas pyyaml==6.0.1 @@ -166,14 +171,18 @@ six==1.16.0 sniffio==1.3.0 # via anyio # via httpx +sqladmin==0.18.0 + # via netsight sqlalchemy==2.0.31 # via alembic # via netsight + # via sqladmin # via sqlalchemy-utils sqlalchemy-utils==0.41.1 # via netsight starlette==0.37.2 # via fastapi + # via sqladmin tcppinglib==2.0.3 # via netsight textfsm==1.1.3 @@ -210,3 +219,5 @@ wcwidth==0.2.13 # via prompt-toolkit websockets==12.0 # via uvicorn +wtforms==3.1.2 + # via sqladmin diff --git a/backend/src/app.py b/backend/src/app.py index 7d319e7..f31e7a8 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -5,11 +5,14 @@ import sentry_sdk from fastapi import FastAPI from fastapi.responses import HTMLResponse +from sqladmin import Admin from starlette.middleware.cors import CORSMiddleware from starlette.middleware.errors import ServerErrorMiddleware from src.core.config import _Env, settings +from src.core.database.session import async_engine from src.core.errors.exception_handlers import default_exception_handler, exception_handlers, sentry_ignore_errors +from src.features.admin.views import AdminAuth from src.libs.redis import session from src.register.middlewares import RequestMiddleware from src.register.openapi import get_open_api_intro, get_stoplight_elements_html @@ -69,4 +72,18 @@ def get_stoplight_elements() -> HTMLResponse: return app +def add_views(admin: Admin) -> None: + # remove the default admin views if I have time in future to build frontend by React + # it's only and POC for now and simplified for the demo to admin user. + # all views should be added to views.py without any migical implementation + from src.features.admin.views import GroupView, RoleView, UserView + + admin.add_view(GroupView) + admin.add_view(RoleView) + admin.add_view(UserView) + + +auth_backend = AdminAuth(secret_key=settings.SECRET_KEY) app = create_app() +admin = Admin(app=app, engine=async_engine, authentication_backend=auth_backend) +add_views(admin) diff --git a/backend/src/core/database/base.py b/backend/src/core/database/base.py index 7d61701..be87b4a 100644 --- a/backend/src/core/database/base.py +++ b/backend/src/core/database/base.py @@ -23,5 +23,10 @@ def dict(self, exclude: set[str] | None = None, native_dict: bool = False) -> di def __getattribute__(self, name: str) -> Any: return super().__getattribute__(name) + def __str__(self) -> str: + if hasattr(self, "name"): + return f"{type(self).__name__}: {self.name}" + return f"{type(self).__name__: self.id}" + ModelT = TypeVar("ModelT", bound=Base) diff --git a/backend/src/features/admin/models.py b/backend/src/features/admin/models.py index 4b73a15..fac88ea 100644 --- a/backend/src/features/admin/models.py +++ b/backend/src/features/admin/models.py @@ -27,16 +27,39 @@ class RoleMenu(Base): menu_id: Mapped[UUID] = mapped_column(ForeignKey("menu.id"), primary_key=True) -class Role(Base, AuditTimeMixin): - __tablename__ = "role" - __search_fields__: ClassVar = {"name"} - __visible_name__ = {"en_US": "Role", "zh_CN": "用户角色"} +class User(Base, AuditTimeMixin): + __tablename__ = "user" + __search_fields__: ClassVar = {"email", "name", "phone"} + __visible_name__ = {"en_US": "User", "zh_CN": "用户"} id: Mapped[int_pk] name: Mapped[str] - slug: Mapped[str] + email: Mapped[str | None] = mapped_column(unique=True) + phone: Mapped[str | None] = mapped_column(unique=True) + password: Mapped[str] + avatar: Mapped[str | None] + last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + is_active: Mapped[bool_true] + group_id: Mapped[int] = mapped_column(ForeignKey("group.id", ondelete="CASCADE")) + group: Mapped["Group"] = relationship(back_populates="user", passive_deletes=True) + role_id: Mapped[int] = mapped_column(ForeignKey("role.id", ondelete="CASCADE")) + role: Mapped["Role"] = relationship(backref="user", passive_deletes=True) + auth_info: Mapped[dict | None] = mapped_column(MutableDict.as_mutable(JSON)) + + +class Group(Base, AuditTimeMixin): + __tablename__ = "group" + __search_fields__: ClassVar = {"name"} + __visible_name__ = {"en_US": "Group", "zh_CN": "用户组"} + id: Mapped[int_pk] = mapped_column(nullable=False) + name: Mapped[str] description: Mapped[str | None] - permission: Mapped[list["Permission"]] = relationship(secondary="role_permission", back_populates="role") - menu: Mapped[list["Menu"]] = relationship(secondary="role_menu", back_populates="role") + role_id: Mapped[int] = mapped_column(ForeignKey("role.id", ondelete="CASCADE")) + role: Mapped["Role"] = relationship(backref="group", passive_deletes=True) + user: Mapped[list["User"]] = relationship(back_populates="group") + user_count: Mapped[int] = column_property( + select(func.count(User.id)).where(User.group_id == id).scalar_subquery(), + deferred=True, + ) class Permission(Base): @@ -50,35 +73,27 @@ class Permission(Base): role: Mapped[list["Role"]] = relationship(secondary="role_permission", back_populates="permission") -class Group(Base, AuditTimeMixin): - __tablename__ = "group" +class Role(Base, AuditTimeMixin): + __tablename__ = "role" __search_fields__: ClassVar = {"name"} - __visible_name__ = {"en_US": "Group", "zh_CN": "用户组"} - id: Mapped[int_pk] + __visible_name__ = {"en_US": "Role", "zh_CN": "用户角色"} + id: Mapped[int_pk] = mapped_column(nullable=False) name: Mapped[str] + slug: Mapped[str] description: Mapped[str | None] - role_id: Mapped[int] = mapped_column(ForeignKey(Role.id, ondelete="CASCADE")) - role: Mapped["Role"] = relationship(backref="group", passive_deletes=True) - user: Mapped[list["User"]] = relationship(back_populates="group") - + permission: Mapped[list["Permission"]] = relationship(secondary="role_permission", back_populates="role") + menu: Mapped[list["Menu"]] = relationship(secondary="role_menu", back_populates="role") -class User(Base, AuditTimeMixin): - __tablename__ = "user" - __search_fields__: ClassVar = {"email", "name", "phone"} - __visible_name__ = {"en_US": "User", "zh_CN": "用户"} - id: Mapped[int_pk] - name: Mapped[str] - email: Mapped[str | None] = mapped_column(unique=True) - phone: Mapped[str | None] = mapped_column(unique=True) - password: Mapped[str] - avatar: Mapped[str | None] - last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - is_active: Mapped[bool_true] - group_id: Mapped[int] = mapped_column(ForeignKey(Group.id, ondelete="CASCADE")) - group: Mapped["Group"] = relationship(back_populates="user", passive_deletes=True) - role_id: Mapped[int] = mapped_column(ForeignKey(Role.id, ondelete="CASCADE")) - role: Mapped["Role"] = relationship(backref="user", passive_deletes=True) - auth_info: Mapped[dict | None] = mapped_column(MutableDict.as_mutable(JSON)) + permission_count: Mapped[int] = column_property( + select(func.count(Permission.id)) + .where(and_(RolePermission.role_id == id, RolePermission.permission_id == Permission.id)) + .scalar_subquery(), + deferred=True, + ) + user_count: Mapped[int] = column_property( + select(func.count(User.id)).where(User.role_id == id).scalar_subquery(), + deferred=True, + ) class Menu(Base): @@ -102,20 +117,3 @@ class Menu(Base): collection_class=attribute_mapped_collection("name"), ) role: Mapped[list["Role"]] = relationship(back_populates="menu", secondary="role_menu") - - -Group.user_count = column_property( - select(func.count(User.id)).where(User.group_id == Group.id).correlate_except(Group).scalar_subquery(), - deferred=True, -) -Role.permission_count = column_property( - select(func.count(Permission.id)) - .where(and_(RolePermission.role_id == Role.id, RolePermission.permission_id == Permission.id)) - .correlate_except(Role) - .scalar_subquery(), - deferred=True, -) -Role.user_count = column_property( - select(func.count(User.id)).where(User.role_id == Role.id).correlate_except(Role).scalar_subquery(), - deferred=True, -) diff --git a/backend/src/features/admin/services.py b/backend/src/features/admin/services.py index 797d713..d91505c 100644 --- a/backend/src/features/admin/services.py +++ b/backend/src/features/admin/services.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from src.core.errors.exception_handlers import NotFoundError, PermissionDenyError @@ -29,6 +29,9 @@ async def verify_user(self, session: AsyncSession, user: OAuth2PasswordRequestFo raise NotFoundError(self.model.__visible_name__[locale_ctx.get()], "username", user.username) if not verify_password(user.password, db_user.password): raise PermissionDenyError + db_user.last_login = func.now() + session.add(db_user) + await session.commit() return db_user diff --git a/backend/src/features/admin/views.py b/backend/src/features/admin/views.py new file mode 100644 index 0000000..f328ffc --- /dev/null +++ b/backend/src/features/admin/views.py @@ -0,0 +1,102 @@ +from fastapi.requests import Request +from fastapi.security import OAuth2PasswordRequestForm +from sqladmin import ModelView +from sqladmin.authentication import AuthenticationBackend + +from src.core.database.session import async_session +from src.core.utils.context import locale_ctx +from src.features.admin import models +from src.features.admin.security import generate_access_token_response +from src.features.admin.services import user_service +from src.features.deps import sqladmin_auth + + +class AdminAuth(AuthenticationBackend): + async def login(self, request: Request) -> bool: + form = await request.form() + user_form = OAuth2PasswordRequestForm(username=form["username"], password=form["password"]) + async with async_session() as session: + user = await user_service.verify_user(session, user_form) + token = generate_access_token_response(user.id) + request.session.update({"Authorization": f"Bearer {token.access_token}"}) + return True + + async def logout(self, request: Request) -> bool: + request.session.clear() + return True + + async def authenticate(self, request: Request) -> bool: + token = request.session.get("Authorization") + if not token or "Bearer " not in token: + return False + token = token.split("Bearer ")[1] + await sqladmin_auth(token) + return True + + +class UserView(ModelView, model=models.User): + name = models.User.__visible_name__[locale_ctx.get()] + + column_list = [ + models.User.id, + models.User.name, + models.User.email, + models.User.phone, + models.User.avatar, + models.User.last_login, + models.User.is_active, + models.User.group, + models.User.role, + models.User.last_login, + models.User.created_at, + models.User.updated_at, + ] + column_searchable_list = [models.User.email, models.User.phone] + column_filters = [models.User.email, models.User.phone] + + page_size = 20 + page_size_options = [20, 50, 100, 200] + + form_ajax_refs = {"group": {"fields": ("name",), "order_by": "id"}, "role": {"fields": ("name",), "order_by": "id"}} + + +class GroupView(ModelView, model=models.Group): + name = models.Group.__visible_name__[locale_ctx.get()] + + column_list = [ + models.Group.id, + models.Group.name, + models.Group.description, + models.Group.role, + models.Group.user_count, + models.Group.created_at, + models.Group.updated_at, + ] + column_searchable_list = [models.Group.name] + column_filters = [models.Group.name] + + page_size = 20 + page_size_options = [20, 50, 100, 200] + + form_ajax_refs = {"role": {"fields": ("name",), "order_by": "id"}} + + +class RoleView(ModelView, model=models.Role): + name = models.Role.__visible_name__[locale_ctx.get()] + + column_list = [ + models.Role.id, + models.Role.name, + models.Role.description, + models.Role.permission_count, + models.Role.user_count, + models.Role.created_at, + models.Role.updated_at, + ] + column_searchable_list = [models.Role.name] + column_filters = [models.Role.name] + + page_size = 20 + page_size_options = [20, 50, 100, 200] + + form_ajax_refs = {"group": {"fields": ("name",), "order_by": "id"}} diff --git a/backend/src/features/deps.py b/backend/src/features/deps.py index 3d4b402..05946b2 100644 --- a/backend/src/features/deps.py +++ b/backend/src/features/deps.py @@ -61,6 +61,25 @@ async def auth( return user +async def sqladmin_auth(token: str) -> User: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[JWT_ALGORITHM]) + except jwt.DecodeError as e: + raise TokenInvalidError from e + token_data = JwtTokenPayload(**payload) + if token_data.refresh: + raise TokenInvalidError + now = datetime.now(tz=UTC) + if now < token_data.issued_at or now > token_data.expires_at: + raise TokenExpireError + async with async_session() as session: + user = await user_service.get_one_or_404(session, token_data.sub, selectinload(User.role)) + check_user_active(user.is_active) + if user.role.slug == ReservedRoleSlug.ADMIN: + return user + raise PermissionDenyError + + def check_user_active(is_active: bool) -> None: if not is_active: raise PermissionDenyError diff --git a/backend/tests/test_org.py b/backend/tests/test_org.py new file mode 100644 index 0000000..05b5a1a --- /dev/null +++ b/backend/tests/test_org.py @@ -0,0 +1,28 @@ +import pytest + +from src.features.org.services import site_service +from tests import factoreis + + +class TestSiteGroup: + @pytest.fixture() + async def sites(self, session): + sites = [ + factoreis.SiteCreateFactory.build(country="China", status="Active"), + factoreis.SiteCreateFactory.build(country="United States", status="Active"), + ] + db_sites = [] + for site in sites: + new = await site_service.create(session, site) + db_sites.append(new) + return db_sites + + async def test_create_site_group(self, client, sites): + new_site_group = factoreis.SiteGroupCreateFactory.build() + new_site_group.site = [] + response = await client.post("/api/dcim/site-groups", json=new_site_group.model_dump()) + assert response.status_code == 200 + + new_site_group.site = [site.id for site in sites] + response = await client.post("/api/dcim/site-groups", json=new_site_group.model_dump()) + assert response.status_code == 200