Skip to content

Commit

Permalink
feat(admin): add POC admin page
Browse files Browse the repository at this point in the history
  • Loading branch information
wangxin688 committed Jul 8, 2024
1 parent 0983067 commit bd410e8
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 51 deletions.
2 changes: 1 addition & 1 deletion backend/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": ["app.main:app", "--reload"],
"args": ["src.app:app", "--reload"],
"jinja": true
}
]
Expand Down
3 changes: 3 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = [
Expand Down
11 changes: 11 additions & 0 deletions backend/requirements-dev.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -264,3 +273,5 @@ wcwidth==0.2.13
# via prompt-toolkit
websockets==12.0
# via uvicorn
wtforms==3.1.2
# via sqladmin
11 changes: 11 additions & 0 deletions backend/requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -210,3 +219,5 @@ wcwidth==0.2.13
# via prompt-toolkit
websockets==12.0
# via uvicorn
wtforms==3.1.2
# via sqladmin
17 changes: 17 additions & 0 deletions backend/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
5 changes: 5 additions & 0 deletions backend/src/core/database/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
96 changes: 47 additions & 49 deletions backend/src/features/admin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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,
)
5 changes: 4 additions & 1 deletion backend/src/features/admin/services.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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


Expand Down
Loading

0 comments on commit bd410e8

Please sign in to comment.