From 2cedfa745e3b3b86214483ec2302f123db686544 Mon Sep 17 00:00:00 2001 From: wangxin688 <182467653@qq.com> Date: Sat, 6 Jul 2024 23:04:42 +0800 Subject: [PATCH] feat: update design add a lot of features change --- backend/_typos.toml | 1 + backend/pyproject.toml | 5 +- backend/requirements-dev.lock | 29 ++- backend/requirements.lock | 27 +++ backend/src/core/errors/err_codes.py | 3 - backend/src/core/repositories/repository.py | 10 +- backend/src/features/_types.py | 14 +- backend/src/features/admin/api.py | 70 +++--- backend/src/features/admin/schemas.py | 6 +- backend/src/features/admin/services.py | 6 +- backend/src/features/alert/models.py | 24 ++ backend/src/features/circuit/api.py | 71 +++--- backend/src/features/circuit/schemas.py | 18 +- backend/src/features/circuit/services.py | 18 +- backend/src/features/consts.py | 7 + backend/src/features/dcim/api.py | 101 ++++++-- backend/src/features/dcim/models.py | 14 +- backend/src/features/dcim/schemas.py | 157 +++++++++---- backend/src/features/dcim/services.py | 101 ++++++++ backend/src/features/intend/api.py | 113 +++++---- backend/src/features/intend/services.py | 49 ++++ backend/src/features/internal/schemas.py | 5 +- backend/src/features/ipam/api.py | 115 +++++---- backend/src/features/ipam/schemas.py | 12 +- backend/src/features/ipam/services.py | 42 ++++ backend/src/features/org/api.py | 144 ++++++++++-- backend/src/features/org/models.py | 33 ++- backend/src/features/org/schemas.py | 119 ++-------- backend/src/features/org/services.py | 93 ++++++-- backend/src/libs/countries/__init__.py | 4 +- backend/src/libs/countries/countries.py | 56 +++-- backend/src/libs/countries/timezone.json | 247 ++++++++++++++++++++ backend/src/libs/netty/factory.py | 52 ++++- backend/src/libs/netty/utils/__init__.py | 0 backend/src/libs/netty/utils/async_ping.py | 26 +++ backend/src/libs/netty/utils/async_snmp.py | 0 backend/tests/libs/__init__.py | 0 backend/tests/libs/test_country_sdk.py | 18 ++ 38 files changed, 1354 insertions(+), 456 deletions(-) create mode 100644 backend/src/libs/countries/timezone.json create mode 100644 backend/src/libs/netty/utils/__init__.py create mode 100644 backend/src/libs/netty/utils/async_ping.py create mode 100644 backend/src/libs/netty/utils/async_snmp.py create mode 100644 backend/tests/libs/__init__.py create mode 100644 backend/tests/libs/test_country_sdk.py diff --git a/backend/_typos.toml b/backend/_typos.toml index c48dcdf..a57b485 100644 --- a/backend/_typos.toml +++ b/backend/_typos.toml @@ -5,4 +5,5 @@ extend-exclude = [ [default] extend-ignore-identifiers-re = [ "selectin", + "equipments", ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 84ca52e..1cac489 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,6 +28,9 @@ dependencies = [ "passlib>=1.7.4", "cryptography>=42.0.8", "celery>=5.4.0", + "icmplib>=3.0.4", + "tcppinglib>=2.0.3", + "netmiko>=4.3.0", ] readme = "README.md" requires-python = ">= 3.11" @@ -43,7 +46,7 @@ dev-dependencies = [ "pre-commit>=3.5.0", "ruff>=0.1.11", "pytest-cov>=4.1.0", - "pytest-asyncio>=0.23.3", + "pytest-asyncio>=0.23.7", "black>=23.12.1", "mypy>=1.8.0", ] diff --git a/backend/requirements-dev.lock b/backend/requirements-dev.lock index 93b4a9f..a27f34a 100644 --- a/backend/requirements-dev.lock +++ b/backend/requirements-dev.lock @@ -22,6 +22,7 @@ asyncpg==0.29.0 # via netsight bcrypt==4.0.1 # via netsight + # via paramiko billiard==4.2.0 # via celery black==23.12.1 @@ -33,6 +34,7 @@ certifi==2023.11.17 # via sentry-sdk cffi==1.16.0 # via cryptography + # via pynacl cfgv==3.4.0 # via pre-commit click==8.1.7 @@ -53,6 +55,7 @@ coverage==7.4.0 # via pytest-cov cryptography==42.0.8 # via netsight + # via paramiko distlib==0.3.8 # via virtualenv dnspython==2.5.0 @@ -66,6 +69,8 @@ fastapi-cli==0.0.4 # via fastapi filelock==3.13.1 # via virtualenv +future==1.0.0 + # via textfsm greenlet==3.0.3 # via sqlalchemy gunicorn==21.2.0 @@ -80,6 +85,8 @@ httptools==0.6.1 httpx==0.26.0 # via fastapi # via netsight +icmplib==3.0.4 + # via netsight identify==2.5.33 # via pre-commit idna==3.6 @@ -105,8 +112,12 @@ mypy==1.8.0 mypy-extensions==1.0.0 # via black # via mypy +netmiko==4.3.0 + # via netsight nodeenv==1.8.0 # via pre-commit +ntc-templates==5.1.0 + # via netmiko numpy==1.26.4 # via netsight # via pandas @@ -118,6 +129,9 @@ packaging==23.2 # via pytest pandas==2.2.0 # via netsight +paramiko==3.4.0 + # via netmiko + # via scp passlib==1.7.4 # via netsight pathspec==0.12.1 @@ -148,10 +162,14 @@ pygments==2.18.0 # via rich pyjwt==2.8.0 # via netsight +pynacl==1.5.0 + # via paramiko +pyserial==3.5 + # via netmiko pytest==7.4.4 # via pytest-asyncio # via pytest-cov -pytest-asyncio==0.23.3 +pytest-asyncio==0.23.7 pytest-cov==4.1.0 python-dateutil==2.8.2 # via celery @@ -165,6 +183,7 @@ python-multipart==0.0.9 pytz==2024.1 # via pandas pyyaml==6.0.1 + # via netmiko # via pre-commit # via uvicorn redis==5.0.1 @@ -172,6 +191,8 @@ redis==5.0.1 rich==13.7.1 # via typer ruff==0.1.11 +scp==0.15.0 + # via netmiko sentry-sdk==1.39.2 # via netsight setuptools==69.0.3 @@ -180,6 +201,7 @@ shellingham==1.5.4 # via typer six==1.16.0 # via python-dateutil + # via textfsm sniffio==1.3.0 # via anyio # via httpx @@ -191,6 +213,11 @@ sqlalchemy-utils==0.41.1 # via netsight starlette==0.37.2 # via fastapi +tcppinglib==2.0.3 + # via netsight +textfsm==1.1.3 + # via netmiko + # via ntc-templates typer==0.12.3 # via fastapi-cli typing-extensions==4.9.0 diff --git a/backend/requirements.lock b/backend/requirements.lock index a2fcb5a..6064bec 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -22,6 +22,7 @@ asyncpg==0.29.0 # via netsight bcrypt==4.0.1 # via netsight + # via paramiko billiard==4.2.0 # via celery celery==5.4.0 @@ -32,6 +33,7 @@ certifi==2023.11.17 # via sentry-sdk cffi==1.16.0 # via cryptography + # via pynacl click==8.1.7 # via celery # via click-didyoumean @@ -47,6 +49,7 @@ click-repl==0.3.0 # via celery cryptography==42.0.8 # via netsight + # via paramiko dnspython==2.5.0 # via email-validator email-validator==2.1.0.post1 @@ -56,6 +59,8 @@ fastapi==0.111.0 # via netsight fastapi-cli==0.0.4 # via fastapi +future==1.0.0 + # via textfsm greenlet==3.0.3 # via sqlalchemy gunicorn==21.2.0 @@ -70,6 +75,8 @@ httptools==0.6.1 httpx==0.26.0 # via fastapi # via netsight +icmplib==3.0.4 + # via netsight idna==3.6 # via anyio # via email-validator @@ -87,6 +94,10 @@ markupsafe==2.1.3 # via mako mdurl==0.1.2 # via markdown-it-py +netmiko==4.3.0 + # via netsight +ntc-templates==5.1.0 + # via netmiko numpy==1.26.4 # via netsight # via pandas @@ -96,6 +107,9 @@ packaging==23.2 # via gunicorn pandas==2.2.0 # via netsight +paramiko==3.4.0 + # via netmiko + # via scp passlib==1.7.4 # via netsight phonenumbers==8.13.27 @@ -118,6 +132,10 @@ pygments==2.18.0 # via rich pyjwt==2.8.0 # via netsight +pynacl==1.5.0 + # via paramiko +pyserial==3.5 + # via netmiko python-dateutil==2.8.2 # via celery # via pandas @@ -130,17 +148,21 @@ python-multipart==0.0.9 pytz==2024.1 # via pandas pyyaml==6.0.1 + # via netmiko # via uvicorn redis==5.0.1 # via netsight rich==13.7.1 # via typer +scp==0.15.0 + # via netmiko sentry-sdk==1.39.2 # via netsight shellingham==1.5.4 # via typer six==1.16.0 # via python-dateutil + # via textfsm sniffio==1.3.0 # via anyio # via httpx @@ -152,6 +174,11 @@ sqlalchemy-utils==0.41.1 # via netsight starlette==0.37.2 # via fastapi +tcppinglib==2.0.3 + # via netsight +textfsm==1.1.3 + # via netmiko + # via ntc-templates typer==0.12.3 # via fastapi-cli typing-extensions==4.9.0 diff --git a/backend/src/core/errors/err_codes.py b/backend/src/core/errors/err_codes.py index 84cc13d..4b9b568 100644 --- a/backend/src/core/errors/err_codes.py +++ b/backend/src/core/errors/err_codes.py @@ -23,6 +23,3 @@ def dict(self): # noqa: ANN201 ERR_10003 = ErrorCode(10003, "admin.token_expired") ERR_10004 = ErrorCode(10004, "admin.token_invalid_for_refresh") ERR_10005 = ErrorCode(10005, "admin.permission_deny") - - -ERR_20001 = ErrorCode(20001, "admin.user_not_found") diff --git a/backend/src/core/repositories/repository.py b/backend/src/core/repositories/repository.py index 3f9282b..c0c2372 100644 --- a/backend/src/core/repositories/repository.py +++ b/backend/src/core/repositories/repository.py @@ -587,8 +587,8 @@ async def create( if m2m: for key, value in m2m.items(): if hasattr(obj_in, key) and getattr(obj_in, key) is not None: - dto_m2m = BaseRepository(value) - db_m2m = await dto_m2m.get_multi_by_pks_or_404(session, [r.id for r in getattr(obj_in, key)]) + service_m2m = BaseRepository(value) + db_m2m = await service_m2m.get_multi_by_pks_or_404(session, getattr(obj_in, key)) setattr(new_obj, key, db_m2m) setattr(obj_in, key, value) if commit: @@ -632,7 +632,7 @@ async def update( for key, value in m2m.items(): if hasattr(obj_in, key) and getattr(obj_in, key) is not None: await self.update_relationship_field( - session, db_obj, value, key, [r.id for r in getattr(obj_in, key)], self.id_attribute + session, db_obj, value, key, getattr(obj_in, key), self.id_attribute ) db_obj = self._update_mutable_tracking(obj_in, db_obj, excludes) if commit: @@ -671,9 +671,9 @@ async def update_relationship_field( if getattr(fk_value, relationship_pk_name) not in fk_values: getattr(obj, relationship_name).remove(fk_value) for fk_value in fk_values: - target_dto = BaseRepository(model=m2m_model) + target_service = BaseRepository(model=m2m_model) if fk_value not in local_fk_value_ids: - target_obj = await target_dto.get_one_or_404(session, fk_value) + target_obj = await target_service.get_one_or_404(session, fk_value) getattr(obj, relationship_name).append(target_obj) else: setattr(obj, relationship_name, None) diff --git a/backend/src/features/_types.py b/backend/src/features/_types.py index 312280c..9cb6f6a 100644 --- a/backend/src/features/_types.py +++ b/backend/src/features/_types.py @@ -43,17 +43,16 @@ class AuditTime(BaseModel): updated_at: datetime | None = None -class AuditUserBase(BaseModel): +class AuthUserBase(BaseModel): id: int name: str email: str | None = None - phone: str | None = None avatar: str | None = None -class AuditUser(BaseModel): - created_by: AuditUserBase | None = None - updated_by: AuditUserBase | None = None +class AuditUser(AuditTime): + created_by: AuthUserBase | None = None + updated_by: AuthUserBase | None = None class AuditLog(BaseModel): @@ -62,7 +61,7 @@ class AuditLog(BaseModel): request_id: str action: str diff: dict | None = None - user: AuditUserBase | None = None + user: AuthUserBase | None = None class ListT(BaseModel, Generic[T]): @@ -115,6 +114,3 @@ class I18nField(BaseModel): class IdResponse(BaseModel): id: int - - -class IdCreate(IdResponse): ... diff --git a/backend/src/features/admin/api.py b/backend/src/features/admin/api.py index 6724a0f..5d54152 100644 --- a/backend/src/features/admin/api.py +++ b/backend/src/features/admin/api.py @@ -12,7 +12,7 @@ from src.features.admin import schemas from src.features.admin.models import Group, Menu, Role, User from src.features.admin.security import generate_access_token_response -from src.features.admin.services import MenuDto, UserDto +from src.features.admin.services import MenuService, UserService from src.features.deps import auth, get_session router = APIRouter() @@ -23,8 +23,8 @@ async def login_pwd( user: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_session), ) -> schemas.AccessToken: - dto = UserDto(User) - result = await dto.verify_user(session, user) + service = UserService(User) + result = await service.verify_user(session, user) return generate_access_token_response(result.id) @@ -32,12 +32,12 @@ async def login_pwd( class UserAPI: user: User = Depends(auth) session: AsyncSession = Depends(get_session) - dto = UserDto(User) + service = UserService(User) @router.post("/users", operation_id="5091fff6-1adc-4a22-8a8c-ef0107122df7", summary="创建新用户/Create new user") async def create_user(self, user: schemas.UserCreate) -> IdResponse: - new_user = await self.dto.create(self.session, user) - result = await self.dto.commit(self.session, new_user) + new_user = await self.service.create(self.session, user) + result = await self.service.commit(self.session, new_user) return IdResponse(id=result.id) @router.get( @@ -46,7 +46,7 @@ async def create_user(self, user: schemas.UserCreate) -> IdResponse: summary="获取单个用户/Get user information by ID", ) async def get_user(self, id: int) -> schemas.User: - db_user = await self.dto.get_one_or_404( + db_user = await self.service.get_one_or_404( self.session, id, selectinload(User.role).load_only(Role.id, Role.name), @@ -56,7 +56,7 @@ async def get_user(self, id: int) -> schemas.User: @router.get("/users", operation_id="2485e2a2-4d81-4601-a6fd-c633b23ce5fc") async def get_users(self, query: schemas.UserQuery = Depends()) -> ListT[schemas.User]: - count, results = await self.dto.list_and_count( + count, results = await self.service.list_and_count( self.session, query, selectinload(User.role).load_only(Role.id, Role.name), @@ -69,14 +69,14 @@ async def update_user(self, id: int, user: schemas.UserUpdate) -> IdResponse: update_user = user.model_dump(exclude_unset=True) if "password" in update_user and update_user["password"] is None: raise GenerError(ERR_10005, status_code=status.HTTP_406_NOT_ACCEPTABLE) - db_user = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_user, user) + db_user = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_user, user) return IdResponse(id=id) @router.delete("/users/{id}", operation_id="78e48ceb-d7cf-46fe-bf9e-d04958aade7d") async def delete_user(self, id: int) -> IdResponse: - db_user = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_user) + db_user = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_user) return IdResponse(id=id) @@ -84,33 +84,33 @@ async def delete_user(self, id: int) -> IdResponse: class GroupAPI: user: User = Depends(auth) session: AsyncSession = Depends(get_session) - dto = BaseRepository(Group) + service = BaseRepository(Group) @router.post("/groups", operation_id="9e3e639d-c694-467d-9209-717b038cf267") async def create_group(self, group: schemas.GroupCreate) -> IdResponse: - new_group = await self.dto.create(self.session, group) + new_group = await self.service.create(self.session, group) return IdResponse(id=new_group.id) @router.get("/groups/{id}", operation_id="00327087-9443-4d24-8d04-e396e3244744") async def get_group(self, id: int) -> schemas.Group: - db_group = await self.dto.get_one_or_404(self.session, id, undefer_load=True) + db_group = await self.service.get_one_or_404(self.session, id, undefer_load=True) return schemas.Group.model_validate(db_group) @router.get("/groups", operation_id="a1d1f8f1-4d4d-4fab-868b-3f977df26e05") async def get_groups(self, query: schemas.GroupQuery = Depends()) -> ListT[schemas.Group]: - count, results = await self.dto.list_and_count(self.session, query) + count, results = await self.service.list_and_count(self.session, query) return ListT(count=count, results=[schemas.Group.model_validate(r) for r in results]) @router.put("/groups/{id}", operation_id="3d5badd1-665c-49f8-85c4-6f6d7f3a1b2a") async def update_group(self, id: int, group: schemas.GroupUpdate) -> IdResponse: - db_group = await self.dto.get_one_or_404(self.session, id, selectinload(Group.user)) - await self.dto.update(self.session, db_group, group) + db_group = await self.service.get_one_or_404(self.session, id, selectinload(Group.user)) + await self.service.update(self.session, db_group, group) return IdResponse(id=id) @router.delete("/groups/{id}", operation_id="e16830da-2973-4369-8e75-da9b4174ab72") async def delete_group(self, id: int) -> IdResponse: - db_group = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_group) + db_group = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_group) return IdResponse(id=id) @@ -118,33 +118,33 @@ async def delete_group(self, id: int) -> IdResponse: class RoleAPI: user: User = Depends(auth) session: AsyncSession = Depends(get_session) - dto = BaseRepository(Role) + service = BaseRepository(Role) @router.post("/roles", operation_id="a18a152b-e9e9-4128-b8be-8a8e9c842abb") async def create_role(self, role: schemas.RoleCreate) -> IdResponse: - new_role = await self.dto.create(self.session, role) + new_role = await self.service.create(self.session, role) return IdResponse(id=new_role.id) @router.get("/roles/{id}", operation_id="2b45f59a-77a1-45d4-bf43-94373da517e3") async def get_role(self, id: int) -> schemas.Role: - db_role = await self.dto.get_one_or_404(self.session, id, selectinload(Role.permission), undefer_load=True) + db_role = await self.service.get_one_or_404(self.session, id, selectinload(Role.permission), undefer_load=True) return schemas.Role.model_validate(db_role) @router.get("/roles", operation_id="c5f793b1-7adf-4b4e-a498-732b0fa7d758") async def get_roles(self, query: schemas.RoleQuery = Depends()) -> ListT[schemas.RoleList]: - count, results = await self.dto.list_and_count(self.session, query) + count, results = await self.service.list_and_count(self.session, query) return ListT(count=count, results=[schemas.RoleList.model_validate(r) for r in results]) @router.put("/roles/{id}", operation_id="2fda2e00-ad86-4296-a1d4-c7f02366b52e") async def update_role(self, id: int, role: schemas.RoleUpdate) -> IdResponse: - db_role = await self.dto.get_one_or_404(self.session, id, selectinload(Role.permission)) - await self.dto.update(self.session, db_role, role) + db_role = await self.service.get_one_or_404(self.session, id, selectinload(Role.permission)) + await self.service.update(self.session, db_role, role) return IdResponse(id=id) @router.delete("/roles/{id}", operation_id="c4e9e0e8-6b0c-4f6f-9e6c-8d9f9f9f9f9f") async def delete_role(self, id: int) -> IdResponse: - db_role = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_role) + db_role = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_role) return IdResponse(id=id) @@ -152,26 +152,26 @@ async def delete_role(self, id: int) -> IdResponse: class MenuAPI: user: User = Depends(auth) session: AsyncSession = Depends(get_session) - dto = MenuDto(Menu) + service = MenuService(Menu) @router.post("/menus", operation_id="008bf4d4-cc01-48b0-82b8-1a67c0348b31") async def create_menu(self, meun: schemas.MenuCreate) -> IdResponse: - new_menu = await self.dto.create(self.session, meun) + new_menu = await self.service.create(self.session, meun) return IdResponse(id=new_menu.id) @router.get("/menus", operation_id="cb7f25ab-798b-4668-a838-6339425e2889") async def get_menus(self) -> schemas.MenuTree: - results = await self.dto.get_all(self.session) + results = await self.service.get_all(self.session) data = list_to_tree([r.dict() for r in results]) return schemas.MenuTree.model_validate(data) @router.put("menus/{id}", operation_id="b4d7ac97-a182-4bd1-a75c-6ae44b5fcf0a") async def update_menu(self, id: int, meun: schemas.MenuUpdate) -> IdResponse: - db_menu = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_menu, meun) + db_menu = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_menu, meun) return IdResponse(id=id) async def delete_menu(self, id: int) -> IdResponse: - db_menu = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_menu) + db_menu = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_menu) return IdResponse(id=id) diff --git a/backend/src/features/admin/schemas.py b/backend/src/features/admin/schemas.py index 14685d1..51c49f9 100644 --- a/backend/src/features/admin/schemas.py +++ b/backend/src/features/admin/schemas.py @@ -2,7 +2,7 @@ from pydantic_extra_types.phone_numbers import PhoneNumber -from src.features._types import AuditTime, BaseModel, IdCreate, QueryParams +from src.features._types import AuditTime, BaseModel, QueryParams class AccessToken(BaseModel): @@ -125,11 +125,11 @@ class UserCreate(UserBase): class GroupCreate(GroupBase): password: str role_id: int - user: list[IdCreate] + user: list[int] class RoleCreate(RoleBase): - permission: list[IdCreate] + permission: list[int] class UserUpdate(UserCreate): diff --git a/backend/src/features/admin/services.py b/backend/src/features/admin/services.py index d0ea025..0f872ea 100644 --- a/backend/src/features/admin/services.py +++ b/backend/src/features/admin/services.py @@ -13,7 +13,7 @@ from src.features.admin.security import verify_password -class UserDto(BaseRepository[User, schemas.UserCreate, schemas.UserUpdate, schemas.UserQuery]): +class UserService(BaseRepository[User, schemas.UserCreate, schemas.UserUpdate, schemas.UserQuery]): async def verify_user(self, session: AsyncSession, user: OAuth2PasswordRequestForm) -> User: stmt = self._get_base_stmt().where(or_(self.model.email == user.username, self.model.phone == user.username)) db_user = await session.scalar(stmt) @@ -24,7 +24,7 @@ async def verify_user(self, session: AsyncSession, user: OAuth2PasswordRequestFo return db_user -class PermissionDto( +class PermissionService( BaseRepository[Permission, schemas.PermissionCreate, schemas.PermissionUpdate, schemas.PermissionQuery] ): async def create( @@ -52,7 +52,7 @@ async def delete(self, session: AsyncSession, db_obj: Permission) -> None: raise NotImplementedError -class MenuDto(BaseRepository[Menu, schemas.MenuCreate, schemas.MenuUpdate, schemas.MenuQuery]): +class MenuService(BaseRepository[Menu, schemas.MenuCreate, schemas.MenuUpdate, schemas.MenuQuery]): async def get_all(self, session: AsyncSession) -> Sequence[Menu]: return (await session.scalars(select(self.model))).all() diff --git a/backend/src/features/alert/models.py b/backend/src/features/alert/models.py index a9e95a6..0aab006 100644 --- a/backend/src/features/alert/models.py +++ b/backend/src/features/alert/models.py @@ -23,6 +23,19 @@ from src.features.dcim.models import Device, Interface from src.features.org.models import Site +__all__ = ( + "Alert", + "AlertUser", + "Event", + "EventGroup", + "EventOperation", + "Inhibitor", + "Correlation", + "Subscription", + "NotificationRecord", + "EventOperation", +) + class AlertUser(Base, AuditUserMixin): __tablename__ = "alert_user" @@ -100,6 +113,17 @@ class EventGroup(Base, EventMixin): ) +class Correlation(Base, AuditUserMixin): + __tablename__ = "correlation" + id: Mapped[int_pk] + name: Mapped[str] + description: Mapped[str | None] + order: Mapped[int] + source_match: Mapped[list[dict]] = mapped_column(JSON) + target_match: Mapped[list[dict]] = mapped_column(JSON) + equal_value: Mapped[list[str]] = mapped_column(JSON) + + class Inhibitor(Base, AuditUserMixin): __tablename__ = "inhibitor" id: Mapped[int_pk] diff --git a/backend/src/features/circuit/api.py b/backend/src/features/circuit/api.py index d08cc58..73c50e2 100644 --- a/backend/src/features/circuit/api.py +++ b/backend/src/features/circuit/api.py @@ -2,13 +2,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from src.core.repositories import BaseRepository from src.core.utils.cbv import cbv from src.features._types import AuditLog, IdResponse, ListT from src.features.admin.models import User from src.features.circuit import schemas from src.features.circuit.models import ISP, Circuit -from src.features.circuit.services import CircuitDto +from src.features.circuit.services import circuit_service, isp_service from src.features.dcim.models import Device, Interface from src.features.deps import auth, get_session from src.features.intend.models import CircuitType @@ -21,38 +20,38 @@ class IspAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(ISP) + service = isp_service @router.post("/isp", operation_id="1cbcccdd-10f3-4d7c-80c9-64b1dccdbe18") async def create_isp(self, isp: schemas.ISPCreate) -> IdResponse: - new_isp = await self.dto.create(self.session, isp) + new_isp = await self.service.create(self.session, isp) return IdResponse(id=new_isp.id) @router.put("/isp/{id}", operation_id="534e2c63-5fdf-494b-a73b-083d5316f646") async def update_isp(self, id: int, isp: schemas.ISPUpdate) -> IdResponse: - db_isp = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_isp, isp) + db_isp = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_isp, isp) return IdResponse(id=id) @router.get("/isp/{id}", operation_id="9c132285-b785-41b9-b3c9-9d13320dcd1a") async def get_isp(self, id: int) -> schemas.ISP: - db_isp = await self.dto.get_one_or_404(self.session, id, selectinload(ISP.asn).load_only(ASN.id, ASN.asn)) + db_isp = await self.service.get_one_or_404(self.session, id, selectinload(ISP.asn).load_only(ASN.id, ASN.asn)) return schemas.ISP.model_validate(db_isp) @router.get("/isps", operation_id="a0f9b45c-868a-4b55-9632-977648011e35") async def get_isps(self, q: schemas.ISPQuery = Depends()) -> ListT[schemas.ISPList]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.ISPList.model_validate(r) for r in results]) @router.delete("/isp/{id}", operation_id="45e468e9-c04c-4d13-999c-36c48265fe0d") async def delete_isp(self, id: int) -> IdResponse: - db_isp = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_isp) + db_isp = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_isp) return IdResponse(id=id) @router.get("/isp/{id}/auditlogs", operation_id="e926c14a-560e-416f-886d-7a4396b89da8") async def get_isp_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -62,84 +61,78 @@ async def get_isp_auditlogs(self, id: int) -> ListT[AuditLog]: class CircuitAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = CircuitDto(Circuit) + service = circuit_service @router.post("/circuits", operation_id="f194c1b6-8a13-4759-aad5-8c2e4e24757a") async def create_circuit(self, circuit: schemas.CircuitCreate) -> IdResponse: - site_a_id, device_a_id = await self.dto.get_interface_info(self.session, circuit.interface_a_id) - new_circuit = await self.dto.create(self.session, circuit, commit=False) + site_a_id, device_a_id = await self.service.get_interface_info(self.session, circuit.interface_a_id) + new_circuit = await self.service.create(self.session, circuit, commit=False) new_circuit.site_a_id = site_a_id new_circuit.device_a_id = device_a_id if circuit.interface_z_id: - site_z_id, device_z_id = await self.dto.get_interface_info(self.session, circuit.interface_z_id) + site_z_id, device_z_id = await self.service.get_interface_info(self.session, circuit.interface_z_id) new_circuit.site_z_id = site_z_id new_circuit.device_z_id = device_z_id - new_circuit = await self.dto.commit(self.session, new_circuit) + new_circuit = await self.service.commit(self.session, new_circuit) return IdResponse(id=new_circuit.id) @router.put("/circuits/{id}", operation_id="c35fb93d-6e66-4bc9-afc7-25eb098da1ce") async def update_circuit(self, id: int, circuit: schemas.CircuitUpdate) -> IdResponse: - db_circuit = await self.dto.get_one_or_404(self.session, id) + db_circuit = await self.service.get_one_or_404(self.session, id) if circuit.interface_z_id: - site_z_id, device_z_id = await self.dto.get_interface_info(self.session, circuit.interface_z_id) + site_z_id, device_z_id = await self.service.get_interface_info(self.session, circuit.interface_z_id) db_circuit.site_z_id = site_z_id db_circuit.device_z_id = device_z_id if circuit.interface_a_id: - site_a_id, device_a_id = await self.dto.get_interface_info(self.session, circuit.interface_a_id) + site_a_id, device_a_id = await self.service.get_interface_info(self.session, circuit.interface_a_id) db_circuit.site_a_id = site_a_id db_circuit.device_a_id = device_a_id - await self.dto.update(self.session, db_circuit, circuit, excludes={"interface_a_id", "interface_z_id"}) + await self.service.update(self.session, db_circuit, circuit, excludes={"interface_a_id", "interface_z_id"}) return IdResponse(id=id) @router.get("/circuits/{id}", operation_id="bf09c339-0fbd-4150-84c1-817a0e49d896") async def get_circuit(self, id: int) -> schemas.Circuit: - db_circuit = await self.dto.get_one_or_404( + db_circuit = await self.service.get_one_or_404( self.session, id, selectinload(Circuit.circuit_type).load_only(CircuitType.id, CircuitType.name), selectinload(Circuit.isp).load_only(ISP.id, ISP.name), selectinload(Circuit.site_a).load_only(Site.id, Site.name, Site.site_code), - selectinload(Circuit.device_a).load_only( - Device.id, Device.name, Device.management_ipv4, Device.management_ipv6 - ), + selectinload(Circuit.device_a).load_only(Device.id, Device.name, Device.management_ip), selectinload(Circuit.interface_a).load_only(Interface.id, Interface.name), selectinload(Circuit.site_z).load_only(Site.id, Site.name, Site.site_code), - selectinload(Circuit.device_z).load_only( - Device.id, Device.name, Device.management_ipv4, Device.management_ipv6 - ), + selectinload(Circuit.device_z).load_only(Device.id, Device.name, Device.management_ip), selectinload(Circuit.interface_z).load_only(Interface.id, Interface.name), + selectinload(Circuit.created_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(Circuit.updated_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), ) return schemas.Circuit.model_validate(db_circuit) @router.get("/circuits", operation_id="6eb35cf7-ec59-4bb3-8a6d-dd1d07375aca") async def get_circuits(self, q: schemas.CircuitQuery = Depends()) -> ListT[schemas.Circuit]: - count, results = await self.dto.list_and_count( + count, results = await self.service.list_and_count( self.session, q, selectinload(Circuit.circuit_type).load_only(CircuitType.id, CircuitType.name), selectinload(Circuit.isp).load_only(ISP.id, ISP.name), selectinload(Circuit.site_a).load_only(Site.id, Site.name, Site.site_code), - selectinload(Circuit.device_a).load_only( - Device.id, Device.name, Device.management_ipv4, Device.management_ipv6 - ), - selectinload(Circuit.interface_a).load_only(Interface.id, Interface.name), + selectinload(Circuit.device_a).load_only(Device.id, Device.name, Device.management_ip), + selectinload(Circuit.interface_a).load_only(Interface.id, Interface.name, Interface.description), selectinload(Circuit.site_z).load_only(Site.id, Site.name, Site.site_code), - selectinload(Circuit.device_z).load_only( - Device.id, Device.name, Device.management_ipv4, Device.management_ipv6 - ), - selectinload(Circuit.interface_z).load_only(Interface.id, Interface.name), + selectinload(Circuit.device_z).load_only(Device.id, Device.name, Device.management_ip), + selectinload(Circuit.interface_z).load_only(Interface.id, Interface.name, Interface.description), ) return ListT(count=count, results=[schemas.Circuit.model_validate(r) for r in results]) @router.delete("/circuits/{id}", operation_id="58ff4f23-f533-4eb7-bfa4-2c97d6e4be17") async def delete_circuit(self, id: int) -> IdResponse: - db_circuit = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_circuit) + db_circuit = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_circuit) return IdResponse(id=id) @router.get("/circuits/{id}/auditlogs", operation_id="71070890-5afc-4609-b4fe-c76dfd0fe701") async def get_circuit_audit_logs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) diff --git a/backend/src/features/circuit/schemas.py b/backend/src/features/circuit/schemas.py index 7cc9216..b817fb0 100644 --- a/backend/src/features/circuit/schemas.py +++ b/backend/src/features/circuit/schemas.py @@ -6,10 +6,10 @@ from src.features._types import ( AuditTime, AuditTimeQuery, + AuditUser, BaseModel, BatchUpdate, I18nField, - IdCreate, NameChineseStr, NameStr, QueryParams, @@ -31,7 +31,7 @@ class ISPBase(BaseModel): class ISPCreate(ISPBase): slug: NameStr - asn: list[IdCreate] | None = Field(default=None, description="List of asn id belong to current isp.") + asn: list[int] | None = Field(default=None, description="List of asn id belong to current isp.") class ISPUpdate(ISPCreate): @@ -109,7 +109,19 @@ class CircuitQuery(QueryParams, AuditTimeQuery): device_id: list[int] | None = Field(Query(default=[])) -class Circuit(CircuitBase, AuditTime): +class Circuit(CircuitBase, AuditUser): + id: int + isp: schemas.ISPBrief + circuit_type: schemas.CircuitTypeBrief + site_a: schemas.SiteBrief + devie_a: schemas.DeviceBrief + interface_a: schemas.InterfaceBrief + site_z: schemas.SiteBrief | None = None + devie_z: schemas.DeviceBrief | None = None + interface_z: schemas.InterfaceBrief | None = None + + +class CirctuiList(CircuitBase, AuditTime): id: int isp: schemas.ISPBrief circuit_type: schemas.CircuitTypeBrief diff --git a/backend/src/features/circuit/services.py b/backend/src/features/circuit/services.py index 328d40a..de986eb 100644 --- a/backend/src/features/circuit/services.py +++ b/backend/src/features/circuit/services.py @@ -4,21 +4,31 @@ from src.core.repositories import BaseRepository from src.features.circuit import schemas -from src.features.circuit.models import Circuit +from src.features.circuit.models import ISP, Circuit from src.features.dcim.models import Device, Interface from src.features.org.models import Site +__all__ = ("circuit_service", "isp_service") + if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession -class CircuitDto(BaseRepository[Circuit, schemas.CircuitCreate, schemas.CircuitUpdate, schemas.CircuitQuery]): +class CircuitService(BaseRepository[Circuit, schemas.CircuitCreate, schemas.CircuitUpdate, schemas.CircuitQuery]): async def get_interface_info(self, session: "AsyncSession", interface_id: int) -> tuple[int, int]: - interface_dto = BaseRepository(Interface) - interface = await interface_dto.get_one_or_404( + interface_service = BaseRepository(Interface) + interface = await interface_service.get_one_or_404( session, interface_id, selectinload(Interface.device).load_only(Device.id).selectinload(Device.site).load_only(Site.id), ) return interface.device.id, interface.device.site.id + + +class ISPService(BaseRepository[ISP, schemas.ISPCreate, schemas.ISPUpdate, schemas.ISPQuery]): + pass + + +circuit_service = CircuitService(Circuit) +isp_service = ISPService(ISP) diff --git a/backend/src/features/consts.py b/backend/src/features/consts.py index fad083b..d1c2a74 100644 --- a/backend/src/features/consts.py +++ b/backend/src/features/consts.py @@ -127,3 +127,10 @@ class CircuitType(StrEnum): ADSL = "ADSL" MPLS = "MPLS" BGP = "BGP" + + +class DeviceRoleSlug(StrEnum): + csw = "core-switch" + dsw = "distribution-switch" + asw = "access-switch" + ap = "access-pointt" diff --git a/backend/src/features/dcim/api.py b/backend/src/features/dcim/api.py index cce31b6..a4ab40a 100644 --- a/backend/src/features/dcim/api.py +++ b/backend/src/features/dcim/api.py @@ -1,15 +1,15 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Path from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from src.core.repositories import BaseRepository from src.core.utils.cbv import cbv from src.features._types import AuditLog, IdResponse, ListT from src.features.admin.models import User -from src.features.dcim import schemas +from src.features.dcim import schemas, services from src.features.dcim.models import Device from src.features.deps import auth, get_session -from src.features.intend.models import DeviceType +from src.features.intend.models import DeviceRole, DeviceType, Manufacturer, Platform +from src.features.org.models import Location, Site router = APIRouter() @@ -18,43 +18,110 @@ class DeviceAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(Device) + service = services.device_service @router.post("/devices", operation_id="8e4357aa-9de9-4daf-858c-78f92fbd7160") async def create_device(self, device: schemas.DeviceCreate) -> IdResponse: - new_device = await self.dto.create(self.session, device) + new_device = await self.service.create(self.session, device) return IdResponse(id=new_device.id) @router.put("/devices/{id}", operation_id="7770767a-1862-45ea-9352-375e8b83e3a0") async def update_device(self, id: int, device: schemas.DeviceUpdate) -> IdResponse: - db_device = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_device, device) + db_device = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_device, device) return IdResponse(id=id) @router.get("/devices/{id}", operation_id="b6ac2c56-099f-4e4c-b541-98c12a3022e7") async def get_device(self, id: int) -> schemas.Device: - db_device = await self.dto.get_one_or_404( + db_device = await self.service.get_one_or_404( self.session, id, - selectinload(Device.device_type).selectinload(DeviceType.platform).selectinload(DeviceType.manufacturer), + selectinload(Device.device_type).load_only(DeviceType.id, DeviceType.name), + selectinload(DeviceType.platform).load_only(Platform.id, Platform.name), + selectinload(DeviceType.manufacturer).load_only(Manufacturer.id, Manufacturer.name), + selectinload(Device.device_role).load_only(DeviceRole.id, DeviceRole.name), + selectinload(Device.location).load_only(Location.id, Location.name), + selectinload(Device.site).load_only(Site.id, Site.name), + selectinload(Device.created_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(Device.updated_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), undefer_load=True, ) return schemas.Device.model_validate(db_device) @router.get("/devices", operation_id="2474bb19-b2a6-46ec-95c8-e03d8bab0d76") - async def get_devices(self, q: schemas.DeviceQuery = Depends()) -> ListT[schemas.Device]: - count, results = await self.dto.list_and_count(self.session, q) - return ListT(count=count, results=[schemas.Device.model_validate(r) for r in results]) + async def get_devices(self, q: schemas.DeviceQuery = Depends()) -> ListT[schemas.DeviceList]: + count, results = await self.service.list_and_count( + self.session, + q, + selectinload(Device.device_type).load_only(DeviceType.id, DeviceType.name), + selectinload(DeviceType.platform).load_only(Platform.id, Platform.name), + selectinload(DeviceType.manufacturer).load_only(Manufacturer.id, Manufacturer.name), + selectinload(Device.device_role).load_only(DeviceRole.id, DeviceRole.name), + selectinload(Device.location).load_only(Location.id, Location.name), + selectinload(Device.site).load_only(Site.id, Site.name), + ) + return ListT(count=count, results=[schemas.DeviceList.model_validate(r) for r in results]) @router.delete("/devices/{id}", operation_id="5c7fe859-ca20-415d-b1d5-0020bf5a4c23") - async def delete_device(self, id: int) -> IdResponse: - db_device = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_device) + async def delete_device( + self, + id: int = Path( + ge=0, + description="When device is deleted, device related data, such as interfaces, stacks, modules and etc will also be deleted", + ), + ) -> IdResponse: + db_device = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_device) return IdResponse(id=id) @router.get("/devices/{id}/auditlogs", operation_id="b4d46b38-c3d9-4e93-9e46-7211b1884e69") async def get_device_audit_logs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) + + @router.post("/devices/{id}/modules", operation_id="144c5bbb-4344-46a7-87f5-2a855a3a589c") + async def sync_device_modules(self, id: int) -> IdResponse: ... + + @router.get("/devices/{id}/modules", operation_id="97e99f2d-40e7-459c-8362-5626b612a01d") + async def get_device_modules(self, id: int) -> list[schemas.DeviceModule]: ... + + @router.post("/devices/{id}/stacks", operation_id="71342e92-ded8-44f2-a0cf-2d910f592115") + async def sync_device_stacks(self, id: int) -> IdResponse: ... + + @router.get("/devices/{id}/stacks", operation_id="9f649f2d-db10-47e8-a4ce-227e24678892") + async def get_device_stacks(self, id: int) -> list[schemas.DeviceStack]: ... + + @router.post("/devices/{id}/interfaces", operation_id="3b32c20b-546a-44f8-9e36-a0cdba6a6820") + async def sync_device_interfaces(self, id: int) -> IdResponse: ... + + @router.get("/devices/{id}/interfaces", operation_id="557b29fe-7a70-4e3f-8f68-c3d3adbaa284") + async def get_device_interfaces(self, id: int) -> list[schemas.Interface]: ... + + @router.post("/devices/{id}/equipments", operation_id="a5e8f9a3-1c6a-4f8f-9a6e-9f1f9b9b9b9b") + async def sync_device_equipments(self, id: int) -> IdResponse: ... + + @router.get("/devices/{id}/equipment", operation_id="94a6902f-9b79-44fe-bebf-03a36c191f04") + async def get_device_equipment(self, id: int) -> list[schemas.DeviceEquipment]: ... + + @router.post("/devices/{id}/configurations", operation_id="7f848f5d-c75f-4fe1-b7f4-5ae734e9cb52") + async def configuration_backup(self, id: int) -> IdResponse: ... + + @router.get("/devices/{id}/configurations", operation_id="933fe8b9-005d-40df-b918-59f5005b6747") + async def get_device_configurations(self, id: int) -> list[schemas.Configuration]: ... + + @router.post("/devices/{id}/topology", operation_id="f9b7b9e0-9b9b-4f4f-9b9b-9b9b9b9b9b9b") + async def sync_device_topology(self, id: int) -> IdResponse: ... + + @router.get("/devices/{id}/topology", operation_id="51df6271-1cc2-47f3-b76b-8c5033db41f4") + async def get_device_topology(self, id: int) -> list[schemas.Topology]: ... + + @router.post("/devices/{id}/mac-address-tables", operation_id="7f848f5d-c75f-4fe1-b7f4-5ae734e9cb52") + async def sync_device_mac_address_table(self, id: int) -> IdResponse: ... + + @router.get("/devices/{id}/mac-address-tables", operation_id="6289d1b5-400a-43a5-bade-7b0fe38d6d49") + async def get_device_mac_address_table(self, id: int) -> list[schemas.MacAddress]: ... + + @router.get("/devices/{id}/routes", operation_id="cea3646d-12f9-4d93-8549-8f2ac5ca8c99") + async def get_device_routes(self, id: int) -> list[schemas.Route]: ... diff --git a/backend/src/features/dcim/models.py b/backend/src/features/dcim/models.py index 2aa2390..c9683b6 100644 --- a/backend/src/features/dcim/models.py +++ b/backend/src/features/dcim/models.py @@ -31,11 +31,11 @@ class Device(Base, AuditUserMixin, AuditLogMixin): management_ip: Mapped[IPvAnyAddress] = mapped_column(PgIpAddress, index=True) oob_ip: Mapped[IPvAnyAddress | None] = mapped_column(PgIpAddress, nullable=True) status: Mapped[DeviceStatus] = mapped_column(ChoiceType(DeviceStatus)) - version: Mapped[str | None] + software_version: Mapped[str | None] + software_patch: Mapped[str | None] comments: Mapped[str | None] serial_number: Mapped[str | None] = mapped_column(unique=True) # master asset_tag: Mapped[str | None] - position: Mapped[int | None] device_type_id: Mapped[int] = mapped_column(ForeignKey("device_type.id", ondelete="RESTRICT")) device_type: Mapped["DeviceType"] = relationship(backref="device", passive_deletes=True) device_role_id: Mapped[int] = mapped_column(ForeignKey("device_role.id", ondelete="RESTRICT")) @@ -57,13 +57,13 @@ class Device(Base, AuditUserMixin, AuditLogMixin): stack: Mapped[list["DeviceStack"]] = relationship(backref="device") equipment: Mapped[list["DeviceEquipment"]] = relationship(backref="device") - # these three are only used for AP device role. + # these three are only used for AP device role associated_wac_ip: Mapped[IPvAnyAddress | None] = mapped_column(PgIpAddress, nullable=True) ap_group: Mapped[str | None] ap_mode: Mapped[APMode | None] = mapped_column(ChoiceType(APMode), nullable=True) -class DeviceModule(Base): +class DeviceModule(Base, AuditTimeMixin): __tablename__ = "module" id: Mapped[int_pk] name: Mapped[str] # huawei: module @@ -76,17 +76,17 @@ class DeviceModule(Base): device_id: Mapped[int] = mapped_column(ForeignKey("device.id", ondelete="CASCADE")) -class DeviceStack(Base): +class DeviceStack(Base, AuditTimeMixin): __tablename__ = "stack" id: Mapped[int_pk] role: Mapped[str] - mac_address: Mapped[str | None] + mac_address: Mapped[str] priority: Mapped[int | None] device_type: Mapped[str] device_id: Mapped[int] = mapped_column(ForeignKey("device.id", ondelete="CASCADE")) -class DeviceEquipment(Base): +class DeviceEquipment(Base, AuditTimeMixin): # device FAN/Power/SFP Module __tablename__ = "equipment" __visible_name__ = {"en_US": "Equipment", "zh_CN": "Equipment"} diff --git a/backend/src/features/dcim/schemas.py b/backend/src/features/dcim/schemas.py index 128c341..c7b7b52 100644 --- a/backend/src/features/dcim/schemas.py +++ b/backend/src/features/dcim/schemas.py @@ -1,54 +1,68 @@ -from ipaddress import IPv4Address, IPv6Address +from datetime import datetime from fastapi import Query from pydantic import Field, IPvAnyAddress, model_validator from src.features._types import ( AuditTime, + AuditUser, BaseModel, NameStr, QueryParams, ) -from src.features.consts import DeviceStatus, EntityPhysicalClass +from src.features.consts import APMode, DeviceEquipmentType, DeviceStatus, InterfaceAdminStatus from src.features.internal import schemas class DeviceBase(BaseModel): name: str - management_ipv4: IPv4Address | None = None - management_ipv6: IPv6Address | None = None + management_ip: IPvAnyAddress oob_ip: IPvAnyAddress | None = None status: DeviceStatus - version: str | None = None + software_version: str | None = None + software_patch: str | None = None serial_num: str | None = None asset_tag: str | None = None comments: str | None = None + associated_wac_ip: IPvAnyAddress | None = None + ap_group: str | None = None + ap_mode: APMode | None = None class DeviceCreate(DeviceBase): name: NameStr device_type_id: int - device_role_id: int - site_id: int | None = None - location_id: int | None = None - - @model_validator(mode="after") - def validate_device_create(self): - if not self.management_ipv4 and not self.management_ipv6: - raise ValueError("Management IPv4 or IPv6 address must be specified any one of them") - if not self.site_id and not self.location_id: - raise ValueError("Site or Location must be specified any one of them") - return self + device_role_id: int = Field(description="when device role is not AP, AP related fields will be ignored") + site_id: int = Field(default=None) + location_id: int | None = Field( + default=None, + description="if location_id and site_id both set, location_id will be used as higher priority incase of conflict", + ) class DeviceUpdate(DeviceBase): + management_ip: IPvAnyAddress | None = None device_type_id: int | None = None - device_role_id: int | None = None - site_id: int | None = None - location_id: int | None = None + device_role_id: int | None = Field( + default=None, description="when device role is not AP, AP related fields will be ignored" + ) + site_id: int | None = Field( + default=None, + description="if location_id and site_id both set, location_id will be used as higher priority incase of conflict", + ) + location_id: int | None = Field( + default=None, + description="if location_id and site_id both set, location_id will be used as higher priority incase of conflict. if location_id changed, device's site_id will follow the new location's site_id", + ) name: NameStr | None = None status: DeviceStatus | None = None + @model_validator(mode="after") + def valiate_input(self): + if self.location_id and self.site_id: + self.site_id = None + return self + class DeviceQuery(QueryParams): name: list[NameStr] | None = Field(Query(default=[])) @@ -59,53 +73,98 @@ class DeviceQuery(QueryParams): platform_id: list[int] | None = Field(Query(default=[])) manufacturer_id: list[int] | None = Field(Query(default=[])) device_type_id: list[int] | None = Field(Query(default=[])) - management_ipv4: list[IPv4Address] | None = Field(Query(default=[])) - management_ipv6: list[IPv6Address] | None = Field(Query(default=[])) + management_ip: list[IPvAnyAddress] | None = Field(Query(default=[])) + associated_wac_ip: list[IPvAnyAddress] | None = Field(Query(default=[])) + ap_group: list[str] | None = Field(Query(default=[])) + ap_mode: APMode | None = Field(Query(default=None)) -class Device(DeviceBase, AuditTime): +class Device(DeviceBase, AuditUser): id: int device_type: schemas.DeviceTypeBrief device_role: schemas.DeviceRoleBrief + platform: schemas.PlatformBrief + manufacturer: schemas.ManufacturerBrief site: schemas.SiteBrief location: schemas.LocationBrief | None = None - interface_count: int - device_entity_count: int -class DeviceEntityBase(BaseModel): - index: str - entity_class: EntityPhysicalClass - hardware_version: str | None = None - software_version: str | None = None - serial_num: str | None = None - module_name: str | None = None - asset_id: str | None = None - order: int +class DeviceList(DeviceBase, AuditTime): + id: int + device_type: schemas.DeviceTypeBrief + device_role: schemas.DeviceRoleBrief + platform: schemas.PlatformBrief + manufacturer: schemas.ManufacturerBrief + site: schemas.SiteBrief + location: schemas.LocationBrief | None = None -class DeviceEntityCreate(DeviceEntityBase): - device_id: int +class DeviceModule(AuditTime): + id: int + name: str + description: str | None = None + serial_number: str | None = None + part_number: str | None = None + hardware_version: str | None = None + physical_index: int | None = None + replaceable: bool | None = None -class DeviceEntityUpdate(DeviceEntityBase): - index: str | None = None - entity_class: EntityPhysicalClass | None = None - order: int | None = None - device_id: int | None = None +class DeviceStack(AuditTime): + id: int + role: str + mac_address: str + priority: int | None = None + device_type: str -class DeviceEntityQuery(QueryParams): - device_id: list[int] | None = Field(Query(default=[])) - serial_num: list[str] | None = Field(Query(default=[])) +class DeviceEquipment(AuditTime): + id: int + name: str + eq_type: DeviceEquipmentType + description: str | None = None + device_type: str + serial_number: str | None = None -class DeviceEntity(DeviceEntityBase, AuditTime): +class Configuration(BaseModel): + id: int + configuration: str + total_lines: int + lines_added: int + lines_deleted: int + lines_updated: int + md5_checksum: str | None + created_by: str + created_at: datetime + change_event: dict | None + + +class Interface(AuditTime): id: int - device: schemas.DeviceBrief + name: str + description: str | None = None + if_index: int | None + speed: int | None + mode: str + interface_type: str | None + mtu: int | None + admin_status: InterfaceAdminStatus + vlan: schemas.VLANBrief | None = None + + +class Node(BaseModel): ... + + +class Link(BaseModel): ... + + +class Topology(BaseModel): + nodes: list[Node] + links: list[Link] + + +class MacAddress(BaseModel): ... -class ConfigSyslogEvent(BaseModel): - username: str - config_method: str - login_ip: IPvAnyAddress +class Route(BaseModel): ... diff --git a/backend/src/features/dcim/services.py b/backend/src/features/dcim/services.py index e69de29..18ab47e 100644 --- a/backend/src/features/dcim/services.py +++ b/backend/src/features/dcim/services.py @@ -0,0 +1,101 @@ +from typing import TYPE_CHECKING + +from src.core.repositories import BaseRepository +from src.features.consts import DeviceRoleSlug, DeviceStatus +from src.features.dcim import schemas +from src.features.dcim.models import Device +from src.features.intend.services import device_role_service +from src.features.org.services import location_service + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +__all__ = ("device_service",) + + +class DeviceService(BaseRepository[Device, schemas.DeviceCreate, schemas.DeviceUpdate, schemas.DeviceQuery]): + async def validate_location_and_site(self, session: "AsyncSession", location_id: int, site_id: int) -> None: + location_site_id = await location_service.get_location_site_id(session, location_id) + if location_site_id != site_id: + raise ValueError("location and site must be in the same site") + + async def valiate_create(self, session: "AsyncSession", obj_in: schemas.DeviceCreate) -> schemas.DeviceCreate: + device_role = await device_role_service.get_one_or_404(session, obj_in.device_role_id) + if device_role.slug != DeviceRoleSlug.ap: + obj_in.ap_mode = None + obj_in.associated_wac_ip = None + obj_in.ap_group = None + if obj_in.location_id: + site_id = await location_service.get_location_site_id(session, obj_in.location_id) + if obj_in.site_id: + site_id = await location_service.get_location_site_id(session, obj_in.location_id) + obj_in.site_id = site_id + return obj_in + + async def validate_update( + self, session: "AsyncSession", db_obj: Device, obj_in: schemas.DeviceUpdate + ) -> schemas.DeviceUpdate: + location_id, site_id = db_obj.location_id, db_obj.site_id + for key, value in obj_in.model_dump(exclude_unset=True).items(): + if key in ["location_id", "site_id"]: + if key == "location_id": + location_id = value + if key == "site_id": + site_id = value + elif key == "device_role_id": + if value != db_obj.device_role_id: + device_role = await device_role_service.get_one_or_404(session, value) + if device_role.slug != DeviceRoleSlug.ap: + obj_in.ap_mode = None + obj_in.associated_wac_ip = None + obj_in.ap_group = None + if location_id and site_id: + await self.validate_location_and_site(session, location_id, site_id) + return obj_in + + async def create( + self, + session: "AsyncSession", + obj_in: schemas.DeviceCreate, + excludes: set[str] | None = None, + exclude_unset: bool = False, + exclude_none: bool = False, + commit: bool | None = True, + ) -> Device: + obj_in = await self.valiate_create(session, obj_in) + return await super().create(session, obj_in, excludes, exclude_unset, exclude_none, commit) + + async def update( + self, + session: "AsyncSession", + db_obj: Device, + obj_in: schemas.DeviceUpdate, + excludes: set[str] | None = None, + commit: bool | None = True, + ) -> Device: + obj_in = await self.validate_update(session, db_obj, obj_in) + return await super().update(session, db_obj, obj_in, excludes, commit) + + +class DeviceScrapeService: + def __init__(self, device_id: int, session: "AsyncSession") -> None: + self.device_id = device_id + self.session = session + + async def validate_device(self) -> "Device": + device = await device_service.get_one_or_404(self.session, self.device_id) + if device.status != DeviceStatus.Active: + raise ValueError("device is not active") + return device + + async def device_icmp_reachable(self) -> bool: + return True + + async def device_ssh_reachable(self) -> bool: + return True + + async def device_snmpv2_reachable(self) -> bool: + return True + + +device_service = DeviceService(Device) diff --git a/backend/src/features/intend/api.py b/backend/src/features/intend/api.py index f55e70d..da73502 100644 --- a/backend/src/features/intend/api.py +++ b/backend/src/features/intend/api.py @@ -2,13 +2,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from src.core.repositories import BaseRepository from src.core.utils.cbv import cbv from src.features._types import AuditLog, IdResponse, ListT from src.features.admin.models import User from src.features.deps import auth, get_session -from src.features.intend import schemas -from src.features.intend.models import CircuitType, DeviceRole, DeviceType, IPRole, Manufacturer, Platform +from src.features.intend import schemas, services +from src.features.intend.models import DeviceType, Manufacturer, Platform router = APIRouter() @@ -17,38 +16,38 @@ class CircuitTypeAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(CircuitType) + service = services.circuit_type_service @router.post("/circuit-types", operation_id="be8f6f87-90f4-429d-8b80-85a3816bb466") async def create_circuit_type(self, circuit_type: schemas.CircuitTypeCreate) -> IdResponse: - new_obj = await self.dto.create(self.session, circuit_type) + new_obj = await self.service.create(self.session, circuit_type) return IdResponse(id=new_obj.id) @router.put("/circuit-types/{id}", operation_id="f59b05a7-21b4-4821-8c8c-0ae1736697a8") async def update_circuit_type(self, id: int, circuit_type: schemas.CircuitTypeUpdate) -> IdResponse: - db_obj = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_obj, circuit_type) + db_obj = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_obj, circuit_type) return IdResponse(id=id) @router.get("/circuit-types/{id}", operation_id="1c098d58-589e-497f-ac14-90fde0c9e34b") async def get_circuit_type(self, id: int) -> schemas.CircuitType: - db_obj = await self.dto.get_one_or_404(self.session, id, undefer_load=True) + db_obj = await self.service.get_one_or_404(self.session, id, undefer_load=True) return schemas.CircuitType.model_validate(db_obj) @router.get("/circuit-types", operation_id="da40d788-6220-4159-bfdc-4c9371e9c18e") async def get_circuit_types(self, q: schemas.CircuitTypeQuery = Depends()) -> ListT[schemas.CircuitType]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.CircuitType.model_validate(r) for r in results]) @router.delete("/circuit-types/{id}", operation_id="2648dce5-b9dd-4275-9cb8-de6619e3bcf2") async def delete_circuit_type(self, id: int) -> IdResponse: - db_obj = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_obj) + db_obj = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_obj) return IdResponse(id=id) @router.get("/circuit-types/{id}/auditlogs", operation_id="ab82f114-e360-47f3-9d31-4429b29ed2f5") async def get_circuit_type_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -58,38 +57,38 @@ async def get_circuit_type_auditlogs(self, id: int) -> ListT[AuditLog]: class DeviceRoleAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(DeviceRole) + service = services.device_role_service @router.post("/device-roles", operation_id="b266343a-2832-4984-97ca-5bcb9b1f13fc") async def create_device_role(self, device_role: schemas.DeviceRoleCreate) -> IdResponse: - new_obj = await self.dto.create(self.session, device_role) + new_obj = await self.service.create(self.session, device_role) return IdResponse(id=new_obj.id) @router.put("/device-roles/{id}", operation_id="f43827ee-d502-4ecf-b22d-e91a562c4461") async def update_device_role(self, id: int, device_role: schemas.DeviceRoleUpdate) -> IdResponse: - db_obj = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_obj, device_role) + db_obj = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_obj, device_role) return IdResponse(id=id) @router.get("/device-roles/{id}", operation_id="9644dc6a-f419-434b-990c-2339f37f02a6") async def get_device_role(self, id: int) -> schemas.DeviceRole: - db_obj = await self.dto.get_one_or_404(self.session, id, undefer_load=True) + db_obj = await self.service.get_one_or_404(self.session, id, undefer_load=True) return schemas.DeviceRole.model_validate(db_obj) @router.get("/device-roles", operation_id="5f670dd6-eba5-49f4-b00e-05ee430625b5") async def get_device_roles(self, q: schemas.DeviceRoleQuery = Depends()) -> ListT[schemas.DeviceRole]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.DeviceRole.model_validate(r) for r in results]) @router.delete("/device-roles/{id}", operation_id="a2d82d5f-0c8a-472a-b0eb-1bafe955ccd5") async def delete_device_role(self, id: int) -> IdResponse: - db_obj = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_obj) + db_obj = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_obj) return IdResponse(id=id) @router.get("/device-roles/{id}/auditlogs", operation_id="b5443838-9e09-4cfe-97d5-8bf7285399be") async def get_device_role_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -99,38 +98,38 @@ async def get_device_role_auditlogs(self, id: int) -> ListT[AuditLog]: class IPRoleAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(IPRole) + service = services.ip_role_service @router.post("/ip-roles", operation_id="6adadd9a-f2d9-49da-824f-58df420ab35e") async def create_ip_role(self, ip_role: schemas.IPRoleCreate) -> IdResponse: - new_obj = await self.dto.create(self.session, ip_role) + new_obj = await self.service.create(self.session, ip_role) return IdResponse(id=new_obj.id) @router.put("/ip-roles/{id}", operation_id="b582d431-db75-47fc-840b-0061f3cd1a2c") async def update_ip_role(self, id: int, ip_role: schemas.IPRoleUpdate) -> IdResponse: - db_obj = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_obj, ip_role) + db_obj = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_obj, ip_role) return IdResponse(id=id) @router.get("/ip-roles/{id}", operation_id="e37f0bc5-2e54-4cae-8f8b-bc226f61f862") async def get_ip_role(self, id: int) -> schemas.IPRole: - db_obj = await self.dto.get_one_or_404(self.session, id, undefer_load=True) + db_obj = await self.service.get_one_or_404(self.session, id, undefer_load=True) return schemas.IPRole.model_validate(db_obj) @router.get("/ip-roles", operation_id="333be12d-5f84-46ca-af12-2790708d9ef9") async def get_ip_roles(self, q: schemas.IPRoleQuery = Depends()) -> ListT[schemas.IPRole]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.IPRole.model_validate(r) for r in results]) @router.delete("/ip-roles/{id}", operation_id="188cb57e-1218-47c8-bd0d-fbe7a3b951ec") async def delete_ip_role(self, id: int) -> IdResponse: - db_obj = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_obj) + db_obj = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_obj) return IdResponse(id=id) @router.get("/ip-roles/{id}/auditlogs", operation_id="fff1a675-1652-42e7-9619-f8be929c1ab9") async def get_ip_role_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -140,38 +139,38 @@ async def get_ip_role_auditlogs(self, id: int) -> ListT[AuditLog]: class PlatformAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(Platform) + service = services.platform_service @router.post("/platforms", operation_id="38e18494-e38c-4060-962f-64dfd37a61af") async def create_platform(self, platform: schemas.PlatformCreate) -> IdResponse: - new_platform = await self.dto.create(self.session, platform) + new_platform = await self.service.create(self.session, platform) return IdResponse(id=new_platform.id) @router.put("/platforms/{id}", operation_id="a452765a-91b7-4b37-bca1-11864e1be028") async def update_platform(self, id: int, platform: schemas.PlatformUpdate) -> IdResponse: - db_platform = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_platform, platform) + db_platform = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_platform, platform) return IdResponse(id=id) @router.get("/platforms/{id}", operation_id="9324bde0-600f-4470-98da-343fc498289c") async def get_platform(self, id: int) -> schemas.Platform: - db_platform = await self.dto.get_one_or_404(self.session, id, undefer_load=True) + db_platform = await self.service.get_one_or_404(self.session, id, undefer_load=True) return schemas.Platform.model_validate(db_platform) @router.get("/platforms", operation_id="d47d8d64-f8cc-4ddc-9db9-51d6a1f3b9e3") async def get_platforms(self, q: schemas.PlatformQuery = Depends()) -> ListT[schemas.Platform]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.Platform.model_validate(r) for r in results]) @router.delete("/platforms/{id}", operation_id="73a00be4-be83-4d24-a034-d36926bae8e1") async def delete_platform(self, id: int) -> IdResponse: - db_platform = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_platform) + db_platform = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_platform) return IdResponse(id=id) @router.get("/platforms/{id}/auditlogs", operation_id="62ca0f32-56b3-47bb-838f-20402c2bb1f4") async def get_platform_audit_logs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -181,38 +180,38 @@ async def get_platform_audit_logs(self, id: int) -> ListT[AuditLog]: class ManufacturerAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(Manufacturer) + service = services.manufacturer_service @router.post("/manufacturers", operation_id="e56edca5-f270-494f-894b-e80b76ed6e5e") async def create_manufacturer(self, manufacturer: schemas.ManufacturerCreate) -> IdResponse: - new_manufacturer = await self.dto.create(self.session, manufacturer) + new_manufacturer = await self.service.create(self.session, manufacturer) return IdResponse(id=new_manufacturer.id) @router.put("/manufacturers/{id}", operation_id="f79ca68c-1768-4e6e-a568-020a6ff844e5") async def update_manufacturer(self, id: int, manufacturer: schemas.ManufacturerUpdate) -> IdResponse: - db_manufacturer = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_manufacturer, manufacturer) + db_manufacturer = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_manufacturer, manufacturer) return IdResponse(id=id) @router.get("/manufacturers/{id}", operation_id="f7500762-95de-4125-87b7-8b9dc4cb4201") async def get_manufacturer(self, id: int) -> schemas.Manufacturer: - db_manufacturer = await self.dto.get_one_or_404(self.session, id, undefer_load=True) + db_manufacturer = await self.service.get_one_or_404(self.session, id, undefer_load=True) return schemas.Manufacturer.model_validate(db_manufacturer) @router.get("/manufacturers", operation_id="a30fb40d-04b3-41fd-a7ba-3040270a191b") async def get_manufacturers(self, q: schemas.ManufacturerQuery = Depends()) -> ListT[schemas.Manufacturer]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.Manufacturer.model_validate(r) for r in results]) @router.delete("/manufacturers/{id}", operation_id="f9b8b6d9-6b7a-4c0e-8b6a-4b0e8b6d9f9b") async def delete_manufacturer(self, id: int) -> IdResponse: - db_manufacturer = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_manufacturer) + db_manufacturer = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_manufacturer) return IdResponse(id=id) @router.get("/manufacturers/{id}/auditlogs", operation_id="1cc7297f-b208-4381-8ef1-97ad092a82cb") async def get_manufacturer_audit_logs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -222,22 +221,22 @@ async def get_manufacturer_audit_logs(self, id: int) -> ListT[AuditLog]: class DeviceTypeAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(DeviceType) + service = services.device_type_service @router.post("/device-types", operation_id="cea5008c-0a32-4bdb-9c17-709230168e2b") async def create_device_type(self, device_type: schemas.DeviceTypeCreate) -> IdResponse: - new_device_type = await self.dto.create(self.session, device_type) + new_device_type = await self.service.create(self.session, device_type) return IdResponse(id=new_device_type.id) @router.put("/device-types/{id}", operation_id="505c9f48-1880-43cd-845b-c517a22d4fd5") async def update_device_type(self, id: int, device_type: schemas.DeviceTypeUpdate) -> IdResponse: - db_device_type = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_device_type, device_type) + db_device_type = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_device_type, device_type) return IdResponse(id=id) @router.get("/device-types/{id}", operation_id="653e2c8f-11a8-4e04-87c1-a674c0be55d1") async def get_device_type(self, id: int) -> schemas.DeviceType: - db_device_type = await self.dto.get_one_or_404( + db_device_type = await self.service.get_one_or_404( self.session, id, selectinload(DeviceType.manufacturer).load_only(Manufacturer.id, Manufacturer.name), @@ -248,7 +247,7 @@ async def get_device_type(self, id: int) -> schemas.DeviceType: @router.get("/device-types", operation_id="e67dcd2d-7b9c-4701-856c-55f95d2925a5") async def get_device_types(self, q: schemas.DeviceTypeQuery = Depends()) -> ListT[schemas.DeviceType]: - count, results = await self.dto.list_and_count( + count, results = await self.service.list_and_count( self.session, q, selectinload(DeviceType.manufacturer).load_only(Manufacturer.id, Manufacturer.name), @@ -258,13 +257,13 @@ async def get_device_types(self, q: schemas.DeviceTypeQuery = Depends()) -> List @router.delete("/device-types/{id}", operation_id="551aef93-9346-4db6-803c-14d88c2b69c7") async def delete_device_type(self, id: int) -> IdResponse: - db_device_type = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_device_type) + db_device_type = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_device_type) return IdResponse(id=id) @router.get("/device-types/{id}/auditlogs", operation_id="1b376206-f410-410c-87a9-e31d6ff2ae87") async def get_device_type_audit_logs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) diff --git a/backend/src/features/intend/services.py b/backend/src/features/intend/services.py index e69de29..cbb615e 100644 --- a/backend/src/features/intend/services.py +++ b/backend/src/features/intend/services.py @@ -0,0 +1,49 @@ +from src.core.repositories import BaseRepository +from src.features.intend import models, schemas + +__all__ = ( + "device_role_service", + "device_type_service", + "platform_service", + "circuit_type_service", + "manufacturer_service", + "ip_role_service", +) + + +class DeviceRoleService( + BaseRepository[models.DeviceRole, schemas.DeviceRoleCreate, schemas.DeviceRoleUpdate, schemas.DeviceRoleQuery] +): ... + + +class DeviceTypeService( + BaseRepository[models.DeviceType, schemas.DeviceTypeCreate, schemas.DeviceTypeUpdate, schemas.DeviceTypeQuery] +): ... + + +class PlatformService( + BaseRepository[models.Platform, schemas.PlatformCreate, schemas.PlatformUpdate, schemas.PlatformQuery] +): ... + + +class CircuitTypeService( + BaseRepository[models.CircuitType, schemas.CircuitTypeCreate, schemas.CircuitTypeUpdate, schemas.CircuitTypeQuery] +): ... + + +class ManufacturerService( + BaseRepository[ + models.Manufacturer, schemas.ManufacturerCreate, schemas.ManufacturerUpdate, schemas.ManufacturerQuery + ] +): ... + + +class IPRoleService(BaseRepository[models.IPRole, schemas.IPRoleCreate, schemas.IPRoleUpdate, schemas.IPRoleQuery]): ... + + +device_role_service = DeviceRoleService(models.DeviceRole) +device_type_service = DeviceTypeService(models.DeviceType) +platform_service = PlatformService(models.Platform) +circuit_type_service = CircuitTypeService(models.CircuitType) +manufacturer_service = ManufacturerService(models.Manufacturer) +ip_role_service = IPRoleService(models.IPRole) diff --git a/backend/src/features/internal/schemas.py b/backend/src/features/internal/schemas.py index 178dd5f..43b831d 100644 --- a/backend/src/features/internal/schemas.py +++ b/backend/src/features/internal/schemas.py @@ -1,5 +1,3 @@ -from ipaddress import IPv4Address, IPv6Address - from pydantic import IPvAnyAddress, IPvAnyNetwork from src.features._types import BaseModel, I18nField @@ -64,8 +62,7 @@ class RouteTargetBrief(BaseModel): class DeviceBrief(BaseModel): id: int name: str - management_ipv4: IPv4Address | None = None - management_ipv6: IPv6Address | None = None + management_ip: IPvAnyAddress class InterfaceBrief(BaseModel): diff --git a/backend/src/features/ipam/api.py b/backend/src/features/ipam/api.py index 50866cb..775904a 100644 --- a/backend/src/features/ipam/api.py +++ b/backend/src/features/ipam/api.py @@ -2,14 +2,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from src.core.repositories import BaseRepository from src.core.utils.cbv import cbv from src.features._types import AuditLog, IdResponse, ListT from src.features.admin.models import User from src.features.deps import auth, get_session from src.features.intend.models import IPRole -from src.features.ipam import schemas -from src.features.ipam.models import ASN, VLAN, VRF, Block, IPAddress, IPRange, Prefix +from src.features.ipam import schemas, services +from src.features.ipam.models import VLAN, VRF, Prefix from src.features.org.models import Site router = APIRouter() @@ -19,38 +18,38 @@ class BlockAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(Block) + service = services.block_service @router.post("/blocks", operation_id="2c7ff1ce-67da-4b95-86b4-b01d3398ba9e") async def create_block(self, block: schemas.BlockCreate) -> IdResponse: - new_block = await self.dto.create(self.session, block) + new_block = await self.service.create(self.session, block) return IdResponse(id=new_block.id) @router.put("/blocks/{id}", operation_id="9798ef27-8678-4557-ac27-9284db0b9cb0") async def update_block(self, id: int, block: schemas.BlockUpdate) -> IdResponse: - local_block = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, local_block, block) + local_block = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, local_block, block) return IdResponse(id=id) @router.get("/blocks/{id}", operation_id="ceeda791-0ec7-458d-9246-ab688ed40e5a") async def get_block(self, id: int) -> schemas.Block: - local_block = await self.dto.get_one_or_404(self.session, id) + local_block = await self.service.get_one_or_404(self.session, id) return schemas.Block.model_validate(local_block) @router.get("/blocks", operation_id="7c3c68e7-de01-4b15-9a0c-90fc328a759a") async def get_blocks(self, q: schemas.BlockQuery = Depends()) -> ListT[schemas.Block]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.Block.model_validate(r) for r in results]) @router.delete("/blocks/{id}", operation_id="c55f28df-9fe8-4ab7-92c7-aa98de2a53ef") async def delete_block(self, id: int) -> IdResponse: - local_block = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, local_block) + local_block = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, local_block) return IdResponse(id=id) @router.get("/blocks/{id}/auditlogs", operation_id="b81b96bf-dd24-4114-b57e-71fc835a0e76") async def get_block_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -60,22 +59,22 @@ async def get_block_auditlogs(self, id: int) -> ListT[AuditLog]: class PrefixAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(Prefix) + service = services.prefix_service @router.post("/prefixes", operation_id="d808fdd1-096b-400e-af49-8edf6d5db288") async def create_prefix(self, prefix: schemas.PrefixCreate) -> IdResponse: - new_prefix = await self.dto.create(self.session, prefix) + new_prefix = await self.service.create(self.session, prefix) return IdResponse(id=new_prefix.id) @router.put("/prefixes/{id}", operation_id="cf0f4e1c-02b3-409b-8e6d-3d1540e3e117") async def update_prefix(self, id: int, prefix: schemas.PrefixUpdate) -> IdResponse: - local_prefix = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, local_prefix, prefix) + local_prefix = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, local_prefix, prefix) return IdResponse(id=id) @router.get("/prefixes/{id}", operation_id="7b912ed7-09b6-4537-b4b9-6a79c589e7d9") async def get_prefix(self, id: int) -> schemas.Prefix: - local_prefix = await self.dto.get_one_or_404( + local_prefix = await self.service.get_one_or_404( self.session, id, selectinload(Prefix.site).load_only(Site.id, Site.name, Site.site_code), @@ -87,7 +86,7 @@ async def get_prefix(self, id: int) -> schemas.Prefix: @router.get("/prefixes", operation_id="9e8f9325-3aac-4b6f-9585-2abc03e1ed9c") async def get_prefixes(self, q: schemas.PrefixQuery = Depends()) -> ListT[schemas.Prefix]: - count, results = await self.dto.list_and_count( + count, results = await self.service.list_and_count( self.session, q, selectinload(Prefix.site).load_only(Site.id, Site.name, Site.site_code), @@ -95,17 +94,17 @@ async def get_prefixes(self, q: schemas.PrefixQuery = Depends()) -> ListT[schema selectinload(Prefix.role).load_only(IPRole.id, IPRole.name), selectinload(Prefix.vlan).load_only(VLAN.id, VLAN.name, VLAN.vid), ) - return ListT(count=count, results=[schemas.PrefixList.model_validate(r) for r in results]) + return ListT(count=count, results=[schemas.Prefix.model_validate(r) for r in results]) @router.delete("/prefixes/{id}", operation_id="18c5ce9e-97ce-427c-9cf1-fd5a34f9c9f8") async def delete_prefix(self, id: int) -> IdResponse: - local_prefix = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, local_prefix) + local_prefix = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, local_prefix) return IdResponse(id=id) @router.get("/prefixes/{id}/auditlogs", operation_id="fcf939e5-99b5-40af-be76-41df06aa4e08") async def get_prefix_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -115,38 +114,38 @@ async def get_prefix_auditlogs(self, id: int) -> ListT[AuditLog]: class ASNAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(ASN) + service = services.asn_service @router.post("/asn", operation_id="6fa6b751-8366-40c3-a5e7-8dcfa0b2e0a4") async def create_asn(self, asn: schemas.ASNCreate) -> IdResponse: - new_asn = await self.dto.create(self.session, asn) + new_asn = await self.service.create(self.session, asn) return IdResponse(id=new_asn.id) @router.put("/asn/{id}", operation_id="3713c5f2-868b-49a1-9b74-7fa22d9233de") async def update_asn(self, id: int, asn: schemas.ASNUpdate) -> IdResponse: - local_asn = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, local_asn, asn) + local_asn = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, local_asn, asn) return IdResponse(id=id) @router.get("/asn/{id}", operation_id="9d1deb3d-3ec8-419f-a05a-5f60a39b60e6") async def get_asn(self, id: int) -> schemas.ASN: - local_asn = await self.dto.get_one_or_404(self.session, id) + local_asn = await self.service.get_one_or_404(self.session, id) return schemas.ASN.model_validate(local_asn) @router.get("/asn", operation_id="c90a4645-c1d6-4e6d-afd5-fa89a2e38e5c") async def get_asns(self, q: schemas.ASNQuery = Depends()) -> ListT[schemas.ASNList]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.ASNList.model_validate(r) for r in results]) @router.delete("/asn/{id}", operation_id="bea2daf9-5a92-44e6-b52b-5f724f6924da") async def delete_asn(self, id: int) -> IdResponse: - local_asn = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, local_asn) + local_asn = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, local_asn) return IdResponse(id=id) @router.get("/asns/{id}/auditlogs", operation_id="9c70deb7-9d35-4b6b-8cc9-60dc6203dae8") async def get_asn_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -156,38 +155,38 @@ async def get_asn_auditlogs(self, id: int) -> ListT[AuditLog]: class IPRangeAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(IPRange) + service = services.ip_range_service @router.post("/ip-ranges", operation_id="9130ab51-bbf2-43ab-a1f5-7b52dbc6aebc") async def create_ip_range(self, ip_range: schemas.IPRangeCreate) -> IdResponse: - new_ip_range = await self.dto.create(self.session, ip_range) + new_ip_range = await self.service.create(self.session, ip_range) return IdResponse(id=new_ip_range.id) @router.put("/ip-ranges/{id}", operation_id="d223fc14-0b20-46bc-af3c-e57f08c81404") async def update_ip_range(self, id: int, ip_range: schemas.IPRangeUpdate) -> IdResponse: - local_ip_range = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, local_ip_range, ip_range) + local_ip_range = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, local_ip_range, ip_range) return IdResponse(id=id) @router.get("/ip-ranges/{id}", operation_id="6761156d-0328-47d0-8f3d-793ea5e16dd6") async def get_ip_range(self, id: int) -> schemas.IPRange: - local_ip_range = await self.dto.get_one_or_404(self.session, id) + local_ip_range = await self.service.get_one_or_404(self.session, id) return schemas.IPRange.model_validate(local_ip_range) @router.get("/ip-ranges", operation_id="79b4955b-3253-401e-92cd-2ad41f1306f2") async def get_ip_ranges(self, q: schemas.IPRangeQuery = Depends()) -> ListT[schemas.IPRange]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.IPRange.model_validate(r) for r in results]) @router.delete("/ip-ranges/{id}", operation_id="cf398770-377c-4435-b30e-ec019d92c05d") async def delete_ip_range(self, id: int) -> IdResponse: - local_ip_range = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, local_ip_range) + local_ip_range = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, local_ip_range) return IdResponse(id=id) @router.get("/ip-ranges/{id}/auditlogs", operation_id="3376d677-ac4b-4748-a31b-4fc0035230ef") async def get_ip_range_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -197,38 +196,38 @@ async def get_ip_range_auditlogs(self, id: int) -> ListT[AuditLog]: class IPAddressAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(IPAddress) + service = services.ip_address_service @router.post("/ip-addresses", operation_id="856265ea-244b-4587-a64d-34cbe51b146e") async def create_ip_address(self, ip_address: schemas.IPAddressCreate) -> IdResponse: - new_ip_address = await self.dto.create(self.session, ip_address) + new_ip_address = await self.service.create(self.session, ip_address) return IdResponse(id=new_ip_address.id) @router.put("/ip-addresses/{id}", operation_id="3551f799-33c9-43ea-b071-89162c319812") async def update_ip_address(self, id: int, ip_address: schemas.IPAddressUpdate) -> IdResponse: - local_ip_address = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, local_ip_address, ip_address) + local_ip_address = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, local_ip_address, ip_address) return IdResponse(id=id) @router.get("/ip-addresses/{id}", operation_id="4bf79652-8e21-41ff-bbdb-87ad5537506c") async def get_ip_address(self, id: int) -> schemas.IPAddress: - local_ip_address = await self.dto.get_one_or_404(self.session, id) + local_ip_address = await self.service.get_one_or_404(self.session, id) return schemas.IPAddress.model_validate(local_ip_address) @router.get("/ip-addresses", operation_id="06b038a0-7568-4ace-b090-295dd150afe1") async def get_ip_addresses(self, q: schemas.IPAddressQuery = Depends()) -> ListT[schemas.IPAddress]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.IPAddress.model_validate(r) for r in results]) @router.delete("/ip-addresses/{id}", operation_id="c2b70972-c9b0-404d-b433-42cedcc812d9") async def delete_ip_address(self, id: int) -> IdResponse: - local_ip_address = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, local_ip_address) + local_ip_address = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, local_ip_address) return IdResponse(id=id) @router.get("/ip-addresses/{id}/auditlogs", operation_id="83f8eb5e-385e-4b9c-b969-275fc5229ac1") async def get_ip_address_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) @@ -238,38 +237,38 @@ async def get_ip_address_auditlogs(self, id: int) -> ListT[AuditLog]: class VLANAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = BaseRepository(VLAN) + service = services.vlan_service @router.post("/vlans", operation_id="89b40e79-63fd-48c2-8af9-6a472cb72135") async def create_vlan(self, vlan: schemas.VLANCreate) -> IdResponse: - new_vlan = await self.dto.create(self.session, vlan) + new_vlan = await self.service.create(self.session, vlan) return IdResponse(id=new_vlan.id) @router.put("/vlans/{id}", operation_id="fef06094-f1dc-416b-8b99-497f8ecd3fde") async def update_vlan(self, id: int, vlan: schemas.VLANUpdate) -> IdResponse: - local_vlan = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, local_vlan, vlan) + local_vlan = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, local_vlan, vlan) return IdResponse(id=id) @router.get("/vlans/{id}", operation_id="1dc5abd9-e591-418d-bc64-440e1b406ccf") async def get_vlan(self, id: int) -> schemas.VLAN: - local_vlan = await self.dto.get_one_or_404(self.session, id) + local_vlan = await self.service.get_one_or_404(self.session, id) return schemas.VLAN.model_validate(local_vlan) @router.get("/vlans", operation_id="0e713497-6230-4cdb-bfdd-1b3016664c61") async def get_vlans(self, q: schemas.VLANQuery = Depends()) -> ListT[schemas.VLAN]: - count, results = await self.dto.list_and_count(self.session, q) + count, results = await self.service.list_and_count(self.session, q) return ListT(count=count, results=[schemas.VLAN.model_validate(r) for r in results]) @router.delete("/vlans/{id}", operation_id="b2878bc9-500f-4990-84b4-f67faed952ae") async def delete_vlan(self, id: int) -> IdResponse: - local_vlan = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, local_vlan) + local_vlan = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, local_vlan) return IdResponse(id=id) @router.get("/vlans/{id}/auditlogs", operation_id="d5138fed-4af4-4e81-a002-324b7c3e1050") async def get_vlan_auditlogs(self, id: int) -> ListT[AuditLog]: - count, results = await self.dto.get_audit_log(self.session, id) + count, results = await self.service.get_audit_log(self.session, id) if not results: return ListT(count=0, results=None) return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) diff --git a/backend/src/features/ipam/schemas.py b/backend/src/features/ipam/schemas.py index e3a1d7f..feaf183 100644 --- a/backend/src/features/ipam/schemas.py +++ b/backend/src/features/ipam/schemas.py @@ -1,7 +1,7 @@ from fastapi import Query from pydantic import Field, IPvAnyInterface, IPvAnyNetwork, model_validator -from src.features._types import AuditTime, BaseModel, IdCreate, NameChineseStr, NameStr, QueryParams +from src.features._types import AuditTime, BaseModel, NameChineseStr, NameStr, QueryParams from src.features.admin.schemas import UserBrief from src.features.consts import IPRangeStatus, PrefixStatus, VLANStatus from src.features.internal import schemas @@ -81,8 +81,8 @@ class ASNBase(BaseModel): class ASNCreate(ASNBase): asn: int = Field(ge=1, le=4294967295) - isp: list[IdCreate] | None = None - site: list[IdCreate] | None = None + isp: list[int] | None = None + site: list[int] | None = None class ASNUpdate(ASNCreate): @@ -140,7 +140,7 @@ class IPAddressBase(BaseModel): class IPAddressCreate(IPAddressBase): vrf_id: int | None = None - owner: list[IdCreate] | None = None + owner: list[int] | None = None interface_id: int | None = None @model_validator(mode="after") @@ -209,7 +209,7 @@ class VRFBase(BaseModel): class VRFCreate(VRFBase): - route_target: list[IdCreate] | None = None + route_target: list[int] | None = None class VRFUpdate(VRFCreate): @@ -234,7 +234,7 @@ class RouteTargetBase(BaseModel): class RouteTargetCreate(RouteTargetBase): - vrf: list[IdCreate] | None = None + vrf: list[int] | None = None class RouteTargetUpdate(RouteTargetCreate): diff --git a/backend/src/features/ipam/services.py b/backend/src/features/ipam/services.py index e69de29..9c3047f 100644 --- a/backend/src/features/ipam/services.py +++ b/backend/src/features/ipam/services.py @@ -0,0 +1,42 @@ +from src.core.repositories import BaseRepository +from src.features.ipam import models, schemas + + +class BlockService(BaseRepository[models.Block, schemas.BlockCreate, schemas.BlockUpdate, schemas.BlockQuery]): ... + + +class PrefixService(BaseRepository[models.Prefix, schemas.PrefixCreate, schemas.PrefixUpdate, schemas.PrefixQuery]): ... + + +class ASNService(BaseRepository[models.ASN, schemas.ASNCreate, schemas.ASNUpdate, schemas.ASNQuery]): ... + + +class IPRangeService( + BaseRepository[models.IPRange, schemas.IPRangeCreate, schemas.IPRangeUpdate, schemas.IPRangeQuery] +): ... + + +class IPAddressService( + BaseRepository[models.IPAddress, schemas.IPAddressCreate, schemas.IPAddressUpdate, schemas.IPAddressQuery] +): ... + + +class VLANService(BaseRepository[models.VLAN, schemas.VLANCreate, schemas.VLANUpdate, schemas.VLANQuery]): ... + + +class VRFService(BaseRepository[models.VRF, schemas.VRFCreate, schemas.VRFUpdate, schemas.VRFQuery]): ... + + +class RouteTargetService( + BaseRepository[models.RouteTarget, schemas.RouteTargetCreate, schemas.RouteTargetUpdate, schemas.RouteTargetQuery] +): ... + + +block_service = BlockService(models.Block) +prefix_service = PrefixService(models.Prefix) +asn_service = ASNService(models.ASN) +ip_range_service = IPRangeService(models.IPRange) +ip_address_service = IPAddressService(models.IPAddress) +vlan_service = VLANService(models.VLAN) +vrf_service = VRFService(models.VRF) +route_target_service = RouteTargetService(models.RouteTarget) diff --git a/backend/src/features/org/api.py b/backend/src/features/org/api.py index 36a9bb1..4ce0049 100644 --- a/backend/src/features/org/api.py +++ b/backend/src/features/org/api.py @@ -1,12 +1,14 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from src.core.utils.cbv import cbv -from src.features._types import IdResponse, ListT +from src.core.utils.validators import list_to_tree +from src.features._types import AuditLog, IdResponse, ListT from src.features.admin.models import User from src.features.deps import auth, get_session from src.features.org import schemas, services -from src.features.org.models import SiteGroup +from src.features.org.models import Location, Site, SiteGroup router = APIRouter() @@ -15,31 +17,145 @@ class SiteGroupAPI: session: AsyncSession = Depends(get_session) user: User = Depends(auth) - dto = services.SiteGroupDto(SiteGroup) + service = services.site_group_service @router.post("/site-groups", operation_id="4c6595c8-1aa1-4613-b128-37e7bca87e28") async def create_site_group(self, site_group: schemas.SiteGroupCreate) -> IdResponse: - new_group = await self.dto.create(self.session, site_group) + new_group = await self.service.create(self.session, site_group) return IdResponse(id=new_group.id) @router.put("site-groups/{id}", operation_id="f85e5555-546d-44a3-8f39-44ef62de8d87") async def update_site_group(self, id: int, site_group: schemas.SiteGroupUpdate) -> IdResponse: - db_group = await self.dto.get_one_or_404(self.session, id) - await self.dto.update(self.session, db_group, site_group) + db_group = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_group, site_group) return IdResponse(id=id) @router.get("/site-groups/{id}", operation_id="9c4e0278-7547-43a1-98ef-5703f7c1ea90") async def get_site_group(self, id: int) -> schemas.SiteGroup: - db_group = await self.dto.get_one_or_404(self.session, id) + db_group = await self.service.get_one_or_404( + self.session, + id, + selectinload(SiteGroup.created_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(SiteGroup.updated_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + ) return schemas.SiteGroup.model_validate(db_group) - @router.get("/groups", operation_id="150588da-6075-408c-8d63-9661e8fcd097") - async def get_groups(self, q: schemas.SiteGroupQuery = Depends()) -> ListT[schemas.SiteGroupList]: - count, results = await self.dto.list_and_count(self.session, q) + @router.get("/site-groups", operation_id="150588da-6075-408c-8d63-9661e8fcd097") + async def get_site_groups(self, q: schemas.SiteGroupQuery = Depends()) -> ListT[schemas.SiteGroupList]: + count, results = await self.service.list_and_count( + self.session, + q, + selectinload(SiteGroup.created_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(SiteGroup.updated_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + ) return ListT(count=count, results=[schemas.SiteGroupList.model_validate(r) for r in results]) - @router.delete("/groups/{id}", operation_id="506e84a1-5256-420d-bae1-7bb1f1676175") - async def delete_groups(self, id: int) -> IdResponse: - db_group = await self.dto.get_one_or_404(self.session, id) - await self.dto.delete(self.session, db_group) + @router.delete("/site-groups/{id}", operation_id="506e84a1-5256-420d-bae1-7bb1f1676175") + async def delete_site_groups(self, id: int) -> IdResponse: + db_group = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_group) return IdResponse(id=id) + + +@cbv(router) +class SiteAPI: + session: AsyncSession = Depends(get_session) + user: User = Depends(auth) + service = services.site_service + + @router.post("/sites", operation_id="3126a8cb-0e8b-44b4-80ab-25458a838a14") + async def create_site(self, site: schemas.SiteCreate) -> IdResponse: + new_site = await self.service.create(self.session, site) + return IdResponse(id=new_site.id) + + @router.put("site/{id}", operation_id="b1497fbc-5675-470a-9cfb-c829860b3a3d") + async def update_site(self, id: int, site: schemas.SiteUpdate) -> IdResponse: + db_site = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_site, site) + return IdResponse(id=id) + + @router.get("/sites/{id}", operation_id="d3bae13a-55fc-49c1-8665-339d51292e09") + async def get_site(self, id: int) -> schemas.Site: + db_site = await self.service.get_one_or_404( + self.session, + id, + selectinload(SiteGroup.created_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(SiteGroup.updated_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(Site.site_group).load_only(SiteGroup.id, SiteGroup.name), + selectinload(Site.network_contact).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(Site.it_contact).load_only(User.id, User.name, User.email, User.phone, User.avatar), + ) + return schemas.Site.model_validate(db_site) + + @router.get("/sites", operation_id="8528d436-f475-4dfb-9a35-f408fac650ff") + async def get_sites(self, q: schemas.SiteQuery = Depends()) -> ListT[schemas.Site]: + count, results = await self.service.list_and_count( + self.session, + q, + selectinload(SiteGroup.created_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(SiteGroup.updated_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(Site.site_group).load_only(SiteGroup.id, SiteGroup.name), + selectinload(Site.network_contact).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(Site.it_contact).load_only(User.id, User.name, User.email, User.phone, User.avatar), + ) + return ListT(count=count, results=[schemas.Site.model_validate(r) for r in results]) + + @router.delete("/sites/{id}", operation_id="1b349641-8fd1-42aa-b206-a6bb1bfa7de1") + async def delete_sites(self, id: int) -> IdResponse: + db_group = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_group) + return IdResponse(id=id) + + @router.get("/sites/{id}/auditlogs", operation_id="3927c00d-108c-46b2-88f3-1f27984b95a0") + async def get_site_auditlogs(self, id: int) -> ListT[AuditLog]: + count, results = await self.service.get_audit_log(self.session, id) + if not results: + return ListT(count=0, results=None) + return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) + + @router.get("/sites/{id}/locations", operation_id="6062a33d-e699-42b8-a775-1b48f6a30209") + async def get_site_locations(self, id: int) -> schemas.LocationTree: + locations = await self.service.get_site_locations(self.session, id) + tree = list_to_tree([location.dict() for location in locations]) + return schemas.LocationTree.model_validate(tree) + + +class LocationAPI: + session: AsyncSession = Depends(get_session) + user: User = Depends(auth) + service = services.location_service + + @router.post("/location", operation_id="0ffd1157-d326-49b8-a470-07027c962fed") + async def create_location(self, location: schemas.LocationCreate) -> IdResponse: + new_location = await self.service.create(self.session, location) + return IdResponse(id=new_location.id) + + @router.put("location/{id}", operation_id="bb6d3b19-70fc-4501-add1-cc6f8376dd49") + async def update_location(self, id: int, location: schemas.LocationUpdate) -> IdResponse: + db_location = await self.service.get_one_or_404(self.session, id) + await self.service.update(self.session, db_location, location) + return IdResponse(id=id) + + @router.get("/location/{id}", operation_id="740bbfd7-673e-4c66-884f-f1d83f43c946") + async def get_location(self, id: int) -> schemas.Location: + db_location = await self.service.get_one_or_404( + self.session, + id, + selectinload(Location.site).load_only(Site.id, Site.name, Site.site_code), + selectinload(Location.created_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + selectinload(Location.updated_by).load_only(User.id, User.name, User.email, User.phone, User.avatar), + ) + return schemas.Location.model_validate(db_location) + + @router.delete("/locations/{id}", operation_id="7bf1bc42-4f89-4333-af8c-053977e91f27") + async def delete_locations(self, id: int) -> IdResponse: + db_location = await self.service.get_one_or_404(self.session, id) + await self.service.delete(self.session, db_location) + return IdResponse(id=id) + + @router.get("/locations/{id}/auditlogs", operation_id="c665ca89-4eb3-4446-9770-0fe650887d56") + async def get_location_auditlogs(self, id: int) -> ListT[AuditLog]: + count, results = await self.service.get_audit_log(self.session, id) + if not results: + return ListT(count=0, results=None) + return ListT(count=count, results=[AuditLog.model_validate(r) for r in results]) diff --git a/backend/src/features/org/models.py b/backend/src/features/org/models.py index 5df2ac8..74ec7ec 100644 --- a/backend/src/features/org/models.py +++ b/backend/src/features/org/models.py @@ -6,7 +6,7 @@ from sqlalchemy_utils.types import ChoiceType from src.core.database.base import Base -from src.core.database.mixins import AuditLogMixin +from src.core.database.mixins import AuditLogMixin, AuditUserMixin from src.core.database.types import int_pk from src.features.consts import LocationStatus, LocationType, SiteStatus @@ -17,7 +17,7 @@ __all__ = ("SiteGroup", "Site", "Location") -class Site(Base, AuditLogMixin): +class Site(Base, AuditUserMixin, AuditLogMixin): """Office, Campus or data center""" __tablename__ = "site" @@ -28,7 +28,7 @@ class Site(Base, AuditLogMixin): site_code: Mapped[str] = mapped_column(unique=True, index=True) status: Mapped[SiteStatus] = mapped_column(ChoiceType(SiteStatus)) facility_code: Mapped[str | None] - time_zone: Mapped[str | None] + time_zone: Mapped[int | None] country: Mapped[str | None] city: Mapped[str | None] address: Mapped[str] @@ -44,8 +44,30 @@ class Site(Base, AuditLogMixin): network_contact: Mapped["User"] = relationship(back_populates="site_network_contact") it_contact: Mapped["User"] = relationship(back_populates="site_it_contact") + if TYPE_CHECKING: + device_count: Mapped[int] + circuit_count: Mapped[int] -class SiteGroup(Base, AuditLogMixin): + @classmethod + def __declare_last__(cls) -> None: + from sqlalchemy import func, or_, select + + from src.features.circuit.models import Circuit + from src.features.dcim.models import Device + + cls.device_count = column_property( + select(func.count(Device.id)).where(Device.site_id == cls.id).scalar_subquery(), + deferred=True, + ) + cls.circuit_count = column_property( + select(func.count(Circuit.id)) + .where(or_(Circuit.site_a_id == cls.id, Circuit.site_z_id == cls.id)) + .scalar_subquery(), + deferred=True, + ) + + +class SiteGroup(Base, AuditUserMixin): """A aggregation of site or a set of data centor(available zone). site group can be used to manage the configuration/security/intend policy for the sites under this group. @@ -56,7 +78,6 @@ class SiteGroup(Base, AuditLogMixin): __visible_name__ = {"en_US": "Site Group", "zh_CN": "站点组"} id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(unique=True) - slug: Mapped[str] = mapped_column(unique=True) description: Mapped[str | None] site: Mapped[list["Site"]] = relationship(back_populates="site_group") site_count: Mapped[int] = column_property( @@ -65,7 +86,7 @@ class SiteGroup(Base, AuditLogMixin): ) -class Location(Base, AuditLogMixin): +class Location(Base, AuditUserMixin, AuditLogMixin): """a sub location of site, like building, floor, idf, mdf and etc""" __tablename__ = "location" diff --git a/backend/src/features/org/schemas.py b/backend/src/features/org/schemas.py index b338b89..ca62d17 100644 --- a/backend/src/features/org/schemas.py +++ b/backend/src/features/org/schemas.py @@ -1,11 +1,10 @@ from fastapi import Query -from pydantic import EmailStr, Field, model_validator -from pydantic_extra_types.phone_numbers import PhoneNumber +from pydantic import Field, model_validator from src.features._types import ( - AuditTime, + AuditUser, + AuthUserBase, BaseModel, - IdCreate, NameChineseStr, NameStr, QueryParams, @@ -16,33 +15,29 @@ class SiteGroupBase(BaseModel): name: str - slug: str description: str | None = None class SiteGroupCreate(SiteGroupBase): name: NameChineseStr - slug: NameStr - site: list[IdCreate] + site: list[int] class SiteGroupUpdate(SiteGroupCreate): name: NameChineseStr | None = None - slug: NameStr | None = None - site: list[IdCreate] | None = None + site: list[int] | None = None class SiteGroupQuery(QueryParams): name: list[NameChineseStr] | None = Field(Query(default=[])) - slug: list[NameStr] | None = Field(Query(default=[])) -class SiteGroup(SiteGroupBase, AuditTime): +class SiteGroup(SiteGroupBase, AuditUser): id: int site: list[schemas.SiteBrief] | None = None -class SiteGroupList(SiteGroupBase, AuditTime): +class SiteGroupList(SiteGroupBase, AuditUser): id: int site_count: int @@ -52,8 +47,8 @@ class SiteBase(BaseModel): site_code: str status: SiteStatus facility_code: str | None = None - time_zone: str | None = None - country: str | None = None + time_zone: int | None = None + country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 code, timezone will be automatically set") city: str | None = None address: str latitude: float @@ -62,17 +57,13 @@ class SiteBase(BaseModel): comments: str | None -class SiteContactCreate(BaseModel): - contact_id: int - role_id: int - - class SiteCreate(SiteBase): name: NameChineseStr site_code: NameStr site_group_id: int | None = None - asn: list[IdCreate] | None = None - site_contact: list[SiteContactCreate] | None = None + asn: list[int] | None = None + it_contact_id: int | None = None + network_contact_id: int | None = None class SiteUpdate(SiteCreate): @@ -91,20 +82,20 @@ class SiteQuery(QueryParams): site_group_id: list[int] | None = Field(Query(default=[])) country: list[str] | None = Field(Query(default=[])) city: list[str] | None = Field(Query(default=[])) - time_zone: list[str] | None = Field(Query(default=[])) + time_zone: list[int] | None = Field(Query(default=[])) classification: list[int] | None = Field(Query(default=[])) + it_contact_id: list[int] | None = Field(Query(default=[])) + network_contact_id: list[int] | None = Field(Query(default=[])) -class Site(SiteBase): +class Site(SiteBase, AuditUser): id: int asn: list[schemas.ASNBrief] | None = None - site_contact: list["SiteContact"] | None = None - site_group: list[schemas.SiteGroupBrief] | None = None + it_contact: AuthUserBase | None = None + network_contact: AuthUserBase | None = None + site_group: schemas.SiteGroupBrief | None = None device_count: int circuit_count: int - ap_count: int - prefix_count: int - vlan_count: int class LocationBase(BaseModel): @@ -126,11 +117,11 @@ def check_parent_id(self): return self -class LocationUpdate(LocationCreate): +class LocationUpdate(LocationBase): name: NameChineseStr | None = None location_type: LocationType | None = None status: LocationStatus | None = None - site_id: int | None = None + parent_id: int | None = None class LocationQuery(QueryParams): @@ -141,71 +132,11 @@ class LocationQuery(QueryParams): parent_id: list[int] | None = Field(Query(default=[])) -class Location(LocationBase, AuditTime): - id: int - site: schemas.SiteBrief - children: list["Location"] | None = None - - -class ContactBase(BaseModel): - name: str - avatar: str | None = None - email: str | None = None - phone: str | None = None - - -class ContactCreate(ContactBase): - name: NameChineseStr - email: EmailStr | None = None - phone: PhoneNumber | None = None - - @model_validator(mode="after") - def check_email(self): - if not self.email or not self.phone: - raise ValueError("Email or phone should not be provided any one of it.") - return self - - -class ContactUpdate(ContactBase): - name: NameChineseStr | None = None - email: EmailStr | None = None - phone: PhoneNumber | None = None - - -class ContactQuery(QueryParams): - name: list[NameChineseStr] | None = Field(Query(default=[])) - email: list[EmailStr] | None = Field(Query(default=[])) - phone: list[PhoneNumber] | None = Field(Query(default=[])) - - -class Contact(ContactBase): +class LocationTree(LocationBase): id: int + children: list["LocationTree"] | None = None -class SiteContact(ContactBase): - contact_role: schemas.ContactRoleBrief - - -class ContactRoleBase(BaseModel): - name: str - description: str | None = None - - -class ContactRoleCreate(ContactRoleBase): - name: NameChineseStr - - -class ContactRoleUpdate(ContactRoleCreate): - name: NameChineseStr | None = None - - -class ContactRole(ContactRoleBase): +class Location(LocationBase, AuditUser): id: int - - -class ContactRoleQuery(QueryParams): - name: list[NameChineseStr] | None = Field(Query(default=[])) - - -class CircuitContact(ContactBase): - contact_role: schemas.ContactRoleBrief + site: schemas.SiteBrief diff --git a/backend/src/features/org/services.py b/backend/src/features/org/services.py index 2651a0c..cb2ccd3 100644 --- a/backend/src/features/org/services.py +++ b/backend/src/features/org/services.py @@ -1,33 +1,98 @@ +from collections.abc import Sequence from typing import TYPE_CHECKING -from src.core.errors.err_codes import ERR_20001 -from src.core.errors.exception_handlers import GenerError +from sqlalchemy import or_, select, update + from src.core.repositories import BaseRepository +from src.core.utils.context import locale_ctx +from src.features.circuit.models import Circuit +from src.features.consts import CircuitStatus, DeviceStatus, PrefixStatus, SiteStatus, VLANStatus +from src.features.dcim.models import Device +from src.features.ipam.models import VLAN, Prefix from src.features.org import schemas -from src.features.org.models import Contact, ContactRole, Location, Site, SiteGroup +from src.features.org.models import Location, Site, SiteGroup +from src.libs.countries import get_country_by_name if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession +__all__ = ("site_group_service", "site_service", "location_service") + -class SiteGroupDto( +class SiteGroupService( BaseRepository[SiteGroup, schemas.SiteGroupCreate, schemas.SiteGroupUpdate, schemas.SiteGroupQuery] ): ... -class SiteDto(BaseRepository[Site, schemas.SiteCreate, schemas.SiteUpdate, schemas.SiteQuery]): ... +class SiteService(BaseRepository[Site, schemas.SiteCreate, schemas.SiteUpdate, schemas.SiteQuery]): + def get_country_info(self, country: str) -> tuple[str, int | None]: + country_info = get_country_by_name(country, locale_ctx.get()) + if country_info: + country_name = country_info["visible_name"] + time_zone = country_info["timezone"] + return country_name, time_zone + return country, None + async def create( + self, + session: "AsyncSession", + obj_in: schemas.SiteCreate, + excludes: set[str] | None = None, + exclude_unset: bool = False, + exclude_none: bool = False, + commit: bool | None = False, + ) -> Site: + country_name, timezone = None, None + if obj_in.country: + country_name, timeozne = self.get_country_info(obj_in.country) + new_site = await super().create(session, obj_in, excludes, exclude_unset, exclude_none, commit) + new_site.country = country_name + new_site.time_zone = timezone + session.add(new_site) + await session.commit() + await session.refresh(new_site) + return new_site -class LocationDto(BaseRepository[Location, schemas.LocationCreate, schemas.LocationUpdate, schemas.LocationQuery]): - async def location_compatible(self, session: "AsyncSession", location_id: int, site_id: int) -> None: - db_location = await self.get_one_or_404(session, location_id) - if db_location.site_id != site_id: - raise GenerError(ERR_20001) + async def update( + self, + session: "AsyncSession", + db_obj: Site, + obj_in: schemas.SiteUpdate, + excludes: set[str] | None = None, + commit: bool | None = True, + ) -> Site: + if obj_in.country and obj_in.country != db_obj.country: + country_name, timeozne = self.get_country_info(obj_in.country) + db_obj.country = country_name + db_obj.time_zone = timeozne + if obj_in.status and obj_in.status == SiteStatus.Retired and db_obj.status != obj_in.status: + await session.execute( + update(Device).where(Device.site_id == db_obj.id).values(status=DeviceStatus.Offline.value) + ) + await session.execute( + update(Circuit) + .where(or_(Circuit.site_a_id == db_obj.id, Circuit.site_z_id == db_obj.id)) + .values(status=CircuitStatus.Offline.value) + ) + await session.execute( + update(VLAN).where(VLAN.site_id == db_obj.id).values(status=VLANStatus.Deprecated.value) + ) + await session.execute( + update(Prefix).where(Prefix.site_id == db_obj.id).values(status=PrefixStatus.Deprecated.value) + ) + return await super().update(session, db_obj, obj_in, excludes, commit) + async def get_site_locations(self, session: "AsyncSession", site_id: int) -> Sequence[Location]: + location_service = LocationService(Location) + stmt = location_service._get_base_stmt().where(Location.site_id == site_id) # noqa: SLF001 + return (await session.scalars(stmt)).all() -class ContactRoleDto( - BaseRepository[ContactRole, schemas.ContactRoleCreate, schemas.ContactRoleUpdate, schemas.ContactRoleQuery] -): ... + +class LocationService(BaseRepository[Location, schemas.LocationCreate, schemas.LocationUpdate, schemas.LocationQuery]): + async def get_location_site_id(self, session: "AsyncSession", location_id: int) -> int: + return (await session.scalars(select(Location.site_id).where(Location.id == location_id))).one() -class ContactDto(BaseRepository[Contact, schemas.ContactCreate, schemas.ContactUpdate, schemas.ContactQuery]): ... +site_group_service = SiteGroupService(SiteGroup) +site_service = SiteService(Site) +location_service = LocationService(Location) diff --git a/backend/src/libs/countries/__init__.py b/backend/src/libs/countries/__init__.py index 89c9f22..dbbf1a9 100644 --- a/backend/src/libs/countries/__init__.py +++ b/backend/src/libs/countries/__init__.py @@ -1,3 +1,3 @@ -from .countries import available_languages, countries_for_language +from .countries import get_country_by_name -__all__ = ["available_languages", "countries_for_language"] +__all__ = ["get_country_by_name"] diff --git a/backend/src/libs/countries/countries.py b/backend/src/libs/countries/countries.py index 8cd78c4..3117e4d 100644 --- a/backend/src/libs/countries/countries.py +++ b/backend/src/libs/countries/countries.py @@ -1,28 +1,48 @@ -import csv +import json from functools import lru_cache from pathlib import Path +from typing import TypedDict + + +class Country(TypedDict): + code: str + name: str + visible_name: str + timezone: int + # source data from https://github.com/umpirsky/country-list/tree/4d8f87526c891a7b110b530eabf910c3f7468ad6 # ISO 3166-1 codes -data_dir = Path(__file__).parent / "country_data" / "data" +data_dir = Path(__file__).parent + + +@lru_cache +def countries_for_language(language: str) -> list[Country]: + """Returns a list of countries for a given language.""" + country_data_path = data_dir / f"{language}.json" + time_zone_data_path = data_dir / "timezone.json" + with country_data_path.open(encoding="utf-8") as file: + country_data = json.load(file) + + with time_zone_data_path.open(encoding="utf-8") as file: + time_zone_data = json.load(file) + countries: list[Country] = [] + for country_code, country_name in country_data.items(): + countries.append( + Country( + code=country_code, + name=country_name, + visible_name=f"{country_name} ({country_code})", + timezone=time_zone_data.get(country_code), + ) + ) -@lru_cache(1) -def available_languages() -> list[str]: - return sorted(x.name for x in data_dir.iterdir() if (x / "country.csv").exists()) + return countries @lru_cache -def countries_for_language(lang: str) -> list[tuple[str, str]]: - path = data_dir / _clean_lang(lang) / "country.csv" - with path.open(encoding="utf-8") as file_: - return [(row["id"], row["value"]) for row in csv.DictReader(file_)] - - -def _clean_lang(lang: str) -> str: - cleaned_lang = lang.replace("-", "_").lower() - for language in available_languages(): - if cleaned_lang == language.lower(): - return language - msg = f"Language {lang} not found" - raise ValueError(msg) +def get_country_by_name(country_name: str, language: str) -> Country | None: + """Returns a country by its ISO 3166-1 alpha-2 code.""" + countries = countries_for_language(language) + return next((country for country in countries if country["name"] == country_name), None) diff --git a/backend/src/libs/countries/timezone.json b/backend/src/libs/countries/timezone.json new file mode 100644 index 0000000..216e40a --- /dev/null +++ b/backend/src/libs/countries/timezone.json @@ -0,0 +1,247 @@ +{ + "AF": 4.3, + "AL": 2, + "DZ": 2, + "AS": -11, + "AO": 1, + "AI": -4, + "AG": -4, + "AR": -3, + "AM": 4, + "AW": -4, + "AU": 10, + "AT": 1, + "AZ": 4, + "BS": -5, + "BH": 3, + "BD": 6, + "BB": -4, + "BY": 3, + "BE": 1, + "BZ": -6, + "BJ": 1, + "BM": -4, + "BT": 6, + "BO": -4, + "BA": 1, + "BW": 2, + "BR": -5, + "BG": 2, + "BF": 0, + "BI": 2, + "KH": 7, + "CM": 1, + "CA": -6, + "CV": -1, + "KY": -5, + "CF": 1, + "TD": 1, + "CL": -3, + "CN": 8, + "CX": 7, + "CC": 6.3, + "CO": -5, + "KM": 3, + "CD": 1, + "CK": -10, + "CR": -6, + "CI": 0, + "HR": 1, + "CY": 2, + "CZ": 1, + "DK": 1, + "DJ": 3, + "DM": -4, + "DO": -4, + "EC": -5, + "EG": 2, + "SV": -6, + "GQ": 1, + "ER": 3, + "EE": 2, + "ET": 3, + "FK": -3, + "FO": 0, + "FJ": 12, + "FI": 2, + "FR": 1, + "GF": -3, + "PF": -10, + "GA": 1, + "GM": 0, + "GE": 4, + "DE": 1, + "GH": 0, + "GI": 1, + "GR": 2, + "GL": -3, + "GD": -4, + "GP": -4, + "GU": 10, + "GT": -6, + "GG": 0, + "GN": 0, + "GW": 0, + "GY": -4, + "HT": -5, + "HM": 5, + "VA": 1, + "HN": -6, + "HK": 8, + "HU": 1, + "IS": 0, + "IN": 5.3, + "ID": 7, + "IR": 3.3, + "IQ": 3, + "IE": 0, + "IM": 0, + "IL": 2, + "IT": 1, + "JM": -5, + "JP": 9, + "JE": 0, + "JO": 2, + "KZ": 5, + "KE": 3, + "KI": 12, + "KP": 8.3, + "KR": 9, + "KW": 3, + "KG": 6, + "LA": 7, + "LV": 2, + "LB": 2, + "LS": 2, + "LR": 0, + "LI": 1, + "LT": 2, + "LU": 1, + "MK": 1, + "MG": 3, + "MW": 2, + "MY": 8, + "MV": 5, + "ML": 0, + "MT": 0, + "MH": 12, + "MQ": -4, + "MR": 0, + "MU": 4, + "YT": 3, + "MX": -6, + "FM": 10, + "MD": 2, + "MC": 1, + "MN": 8, + "MS": -4, + "MA": 0, + "MZ": 2, + "MM": 6.3, + "NA": 1, + "NR": 12, + "NP": 5.45, + "NL": 1, + "AN": -4, + "NZ": 12, + "NI": -6, + "NE": 1, + "NG": 1, + "NU": -11, + "NF": 11.3, + "MP": 10, + "NO": 1, + "OM": 4, + "PK": 5, + "PW": 9, + "PS": 2, + "PA": -5, + "PG": 10, + "PY": -4, + "PE": -5, + "PH": 8, + "PL": 1, + "PT": 0, + "PR": -4, + "QA": 3, + "RE": 4, + "RU": 0, + "RW": 2, + "SH": 0, + "KN": -4, + "LC": -4, + "PM": -3, + "VC": -4, + "WS": 13, + "SM": 1, + "ST": 0, + "SA": 3, + "SN": 0, + "SC": 4, + "SL": 0, + "SG": 8, + "SK": 1, + "SI": 1, + "SB": 11, + "SO": 3, + "ZA": 2, + "GS": -2, + "ES": 1, + "LK": 5.3, + "SD": 3, + "SR": -3, + "SJ": 1, + "SZ": 2, + "SE": 1, + "CH": 1, + "SY": 2, + "TW": 8, + "TJ": 5, + "TZ": 3, + "TH": 7, + "TG": 0, + "TK": 13, + "TO": 13, + "TT": 13, + "TN": 1, + "TR": 2, + "TM": 5, + "TV": 12, + "UG": 3, + "UA": 2, + "AE": 4, + "GB": 0, + "US": -6, + "UY": -3, + "UZ": 5, + "VU": 11, + "VE": -4.3, + "VN": 7, + "VG": -4, + "VI": -4, + "WF": 12, + "EH": 1, + "YE": 3, + "ZM": 1, + "ZW": 2, + "AX": 2, + "AD": 1, + "AQ": 13, + "BV": 1, + "IO": 6, + "BN": 8, + "CG": 1, + "CU": -5, + "TF": 5, + "XK": 1, + "LY": 2, + "MO": 8, + "NC": 11, + "PN": -8, + "RO": 2, + "RS": 1, + "ME": 1, + "TL": 9, + "TC": -5, + "UM": -11 +} diff --git a/backend/src/libs/netty/factory.py b/backend/src/libs/netty/factory.py index 654e7cf..d23c4c8 100644 --- a/backend/src/libs/netty/factory.py +++ b/backend/src/libs/netty/factory.py @@ -1,13 +1,11 @@ from typing import Protocol, TypeVar +from netmiko import BaseConnection, ConnectHandler, ConnectionException + SessionT = TypeVar("SessionT") class NettyFactory(Protocol): - def get_session(self) -> SessionT: ... - - def close(self) -> None: ... - def get_hostname(self) -> str: ... def get_manufacturer(self) -> str: ... @@ -44,6 +42,52 @@ def get_routing_table(self) -> list[str]: ... class NettySshFactory(NettyFactory): + session: BaseConnection | None = None + + def __init__( + self, + ip_address: str, + port: int, + username: str, + password: str, + platform: str, + secret: str | None = None, + timeout: int = 15, + ) -> None: + self.ip_address = ip_address + self.port = port + self.username = username + self.password = password + self.secret = secret + self.timeout = timeout + self.device_type = platform + + def __enter__(self) -> None: + if self.session is not None: + return + try: + session = ConnectHandler( + device_type=self.device_type, + host=self.ip_address, + username=self.username, + password=self.password, + secret=self.secret, + port=self.port, + timeout=self.timeout, + ) + except ConnectionException as e: + raise ValueError("Unable to connect to device.") from e # replace with custom exception + self.session = session + return + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ANN001 + """Close the session if it's open""" + if self.session is None: + return + self.session.disconnect() + self.session = None + return + def ping(self) -> None: ... def traceroute(self) -> None: ... diff --git a/backend/src/libs/netty/utils/__init__.py b/backend/src/libs/netty/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/libs/netty/utils/async_ping.py b/backend/src/libs/netty/utils/async_ping.py new file mode 100644 index 0000000..8834934 --- /dev/null +++ b/backend/src/libs/netty/utils/async_ping.py @@ -0,0 +1,26 @@ +from icmplib import Host, async_multiping, async_ping +from tcppinglib import TCPHost, async_multi_tcpping, async_tcpping + + +async def ping_alive(address: str) -> bool: + result = await async_ping(address=address, count=3, interval=0.2, privileged=False) + return result.is_alive + + +async def multi_ping_alive(addresses: list[str]) -> dict[str, bool]: + results: list[Host] = await async_multiping( + addresses=addresses, count=2, interval=0.2, timeout=2, concurrent_tasks=30, privileged=False + ) + return {result.address: result.is_alive for result in results} + + +async def ssh_alive(address: str, port: int = 22) -> bool: + result = await async_tcpping(address=address, port=port, count=3, interval=0.2) + return result.is_alive + + +async def multi_ssh_alive(addresses: list[str], port: int = 22) -> dict[str, bool]: + results: list[TCPHost] = await async_multi_tcpping( + addresses=addresses, port=port, count=2, interval=0.2, concurrent_tasks=30 + ) + return {result.ip_address: result.is_alive for result in results} diff --git a/backend/src/libs/netty/utils/async_snmp.py b/backend/src/libs/netty/utils/async_snmp.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/libs/__init__.py b/backend/tests/libs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/libs/test_country_sdk.py b/backend/tests/libs/test_country_sdk.py new file mode 100644 index 0000000..96b2530 --- /dev/null +++ b/backend/tests/libs/test_country_sdk.py @@ -0,0 +1,18 @@ +import pytest + +from src.libs.countries import get_country_by_name + + +@pytest.mark.parametrize( + ("name", "language", "expected"), + [ + ("中国", "zh_CN", {"code": "CN", "name": "中国", "visible_name": "中国 (CN)", "timezone": 8}), + ( + "United States", + "en_US", + {"code": "US", "name": "United States", "visible_name": "United States (US)", "timezone": -6}, + ), + ], +) +def test_get_country_by_name(name: str, language: str, expected: dict) -> None: + assert get_country_by_name(name, language) == expected