From 11c16e2a9253bfc49531bad9a2488b6c87155310 Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Wed, 7 Jan 2026 09:20:19 +0900 Subject: [PATCH 01/15] added github workflow for python app and fix for flake8 issue. --- .github/workflows/python-app.yml | 44 ++++++++++++++++++++++++++++++++ auth-service/requirements.txt | 1 + engine/api/dependencies.py | 4 +-- engine/requirements.txt | 1 + 4 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..1d02fc2 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,44 @@ +name: Python application + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + service: [auth-service, engine] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install dependencies + working-directory: ./${{ matrix.service }} + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Lint with flake8 + working-directory: ./${{ matrix.service }} + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Test with pytest + working-directory: ./${{ matrix.service }} + run: | + pytest diff --git a/auth-service/requirements.txt b/auth-service/requirements.txt index 4510c58..c162d62 100644 --- a/auth-service/requirements.txt +++ b/auth-service/requirements.txt @@ -10,3 +10,4 @@ email-validator pytest httpx pytest-cov +flake8 diff --git a/engine/api/dependencies.py b/engine/api/dependencies.py index 5a93984..4d859ef 100644 --- a/engine/api/dependencies.py +++ b/engine/api/dependencies.py @@ -15,12 +15,12 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") def connect_db(): + # Use global variables to maintain a singleton database connection pool global client, db client = AsyncIOMotorClient(settings.mongodb_url) db = client[settings.database_name] def disconnect_db(): - global client if client: client.close() @@ -49,7 +49,6 @@ async def require_system_admin(current_user: dict = Depends(verify_token)): return current_user def get_record_use_case() -> RecordUseCase: - global db if db is None: connect_db() @@ -61,7 +60,6 @@ def get_record_use_case() -> RecordUseCase: return RecordUseCase(record_repo, schema_repo) def get_schema_service() -> SchemaApplicationService: - global db if db is None: connect_db() diff --git a/engine/requirements.txt b/engine/requirements.txt index 7e27e2d..64f0b62 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -12,3 +12,4 @@ email-validator python-jose[cryptography] mongomock mongomock-motor +flake8 From 98c8be127809e976406e570dc04539dd9ebf1591 Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Wed, 7 Jan 2026 09:28:17 +0900 Subject: [PATCH 02/15] refined the README --- README.ja.md | 12 ++++++++++-- README.md | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index 330f398..03e7ecb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,8 @@ -# Dynaman - 動的スキーマ PoC +# Dynaman - Dynamic Schema PoC -[Read in English](README.md) +[![Python application](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml/badge.svg)](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml) + +[English](README.md) **Dynaman** は、現代の **NoCode/LowCode プラットフォーム** における中心的なパターンである **未固定かつ動的なスキーマ** のアーキテクチャと実装を実証するために設計された Proof of Concept (PoC) プロジェクトです。 @@ -31,6 +33,8 @@ ### インフラストラクチャ * **コンテナ化**: Docker & Docker Compose * **ゲートウェイ**: Nginx (リバースプロキシ) +* **IaC**: Terraform (Infrastructure as Code) +* **クラウドプロバイダー**: AWS (ECS Fargate/EC2, ALB, ECR, CodePipeline) ### フロントエンド (`/dynaman-ui`) * **フレームワーク**: [React](https://react.dev/) (with Vite) @@ -81,6 +85,10 @@ dynaman/ │ │ ├── pages/ # アプリケーションビュー │ │ └── context/ # [NEW] AuthContext │ +├── infrastructure/ # [NEW] Terraform & AWS 設定 +│ ├── terraform/ # IaC 定義 +│ └── README.md # デプロイガイド +│ ├── docker-compose.yml # [NEW] コンテナオーケストレーション └── nginx-gateway.conf # [NEW] API ゲートウェイ設定 ``` diff --git a/README.md b/README.md index fd7bc80..b9d9e8d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Dynaman - Dynamic Schema PoC +[![Python application](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml/badge.svg)](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml) + [日本語で読む](README.ja.md) **Dynaman** is a Proof of Concept (PoC) project designed to demonstrate the architecture and implementation of **unfixed, dynamic schemas**—a core pattern found in modern **NoCode/LowCode platforms**. @@ -31,6 +33,8 @@ The primary goal is to showcase how to handle **User-Defined Requirements** wher ### Infrastructure * **Containerization**: Docker & Docker Compose * **Gateway**: Nginx (Reverse Proxy) +* **IaC**: Terraform (Infrastructure as Code) +* **Cloud Provider**: AWS (ECS Fargate/EC2, ALB, ECR, CodePipeline) ### Frontend (`/dynaman-ui`) * **Framework**: [React](https://react.dev/) (with Vite) @@ -81,6 +85,10 @@ dynaman/ │ │ ├── pages/ # Application Views │ │ └── context/ # [NEW] AuthContext │ +├── infrastructure/ # [NEW] Terraform & AWS Configuration +│ ├── terraform/ # IaC Definitions +│ └── README.md # Deployment Guide +│ ├── docker-compose.yml # [NEW] Container Orchestration └── nginx-gateway.conf # [NEW] API Gateway Config ``` From 8ccac17b26dfdaa8abdfbb4ff6bb6217d75c128a Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Wed, 7 Jan 2026 15:20:26 +0900 Subject: [PATCH 03/15] route UI also from nginx gateway --- nginx-gateway.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nginx-gateway.conf b/nginx-gateway.conf index 02d2dfe..469fcf8 100644 --- a/nginx-gateway.conf +++ b/nginx-gateway.conf @@ -26,5 +26,11 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header Authorization $http_authorization; } + + location / { + proxy_pass http://dynaman-ui:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } } } \ No newline at end of file From f410622594fdb0f83d1595d6895bae8486f3233c Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Wed, 7 Jan 2026 15:20:34 +0900 Subject: [PATCH 04/15] added infra diagram --- infrastructure/README.md | 46 +++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/infrastructure/README.md b/infrastructure/README.md index 25880e5..6e141df 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -2,6 +2,42 @@ This directory contains the Terraform configuration to deploy the Dynaman application to AWS ECS. +## Architecture + +```mermaid +graph TD + User((User)) -->|HTTPS| ALB(Application Load Balancer) + + subgraph "AWS Cloud (US-East-1)" + subgraph "VPC" + ALB -->|/| UI[Dynaman UI] + ALB -->|/api/v1/auth| Auth[Auth Service] + ALB -->|/api/v1/schemas| Meta[Engine: Metadata] + ALB -->|/api/v1/data| Exec[Engine: Execution] + + subgraph "ECS Cluster (EC2 Launch Type)" + subgraph "t4g.small (ARM64)" + UI + Auth + Meta + Exec + end + end + end + + subgraph "CI/CD Pipeline" + GitHub[GitHub Repo] -->|Webhook| CP[CodePipeline] + CP --> CB["CodeBuild (Tests & Build)"] + CB -->|Push Images| ECR[Amazon ECR] + CB -->|Deploy| ECS_Service[Update ECS Services] + end + end + + Auth -->|Connect| Atlas[(MongoDB Atlas)] + Meta -->|Connect| Atlas + Exec -->|Connect| Atlas +``` + ## Prerequisites 1. **AWS CLI**: Installed and configured with `aws configure`. @@ -16,11 +52,11 @@ This directory contains the Terraform configuration to deploy the Dynaman applic ```bash cp terraform/terraform.tfvars.example terraform/terraform.tfvars ``` - Edit `terraform/terraform.tfvars` and fill in your details: - * `mongodb_url`: Your Atlas connection string. - * `jwt_secret_key`: A random string for security. - * `github_repo_owner`: Your GitHub username. - * `github_repo_name`: `dynaman` (or your repo name). + Edit `terraform/terraform.tfvars` and fill in your details (values must be in double quotes): + * `mongodb_url`: "Your Atlas connection string" + * `jwt_secret_key`: "A random string for security" + * `github_repo_owner`: "Your GitHub username" + * `github_repo_name`: "dynaman" ## Deployment (Start) From aa11a548df5f359b2c46ed6ba6fc2596ae4545b2 Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Thu, 8 Jan 2026 22:04:55 +0900 Subject: [PATCH 05/15] feat: added UserGroup management for user and form layout for dynamic UI --- auth-service/api/dependencies.py | 4 + auth-service/api/v1/router_auth.py | 35 ++++- auth-service/api/v1/router_groups.py | 70 +++++++++ auth-service/application/auth_use_cases.py | 14 +- auth-service/domain/entities/user.py | 9 ++ auth-service/domain/entities/user_group.py | 25 ++++ .../infrastructure/user_group_repository.py | 65 ++++++++ .../infrastructure/user_repository.py | 16 ++ auth-service/main.py | 2 + auth-service/pytest.ini | 4 +- .../tests/test_user_group_repository.py | 82 ++++++++++ auth-service/tests/test_user_groups.py | 124 +++++++++++++++ engine/api/dependencies.py | 13 +- engine/api/v1/router_layouts.py | 82 ++++++++++ engine/main.py | 2 + .../domain/entities/form_layout.py | 47 ++++++ .../infrastructure/form_layout_repository.py | 54 +++++++ engine/pytest.ini | 1 + engine/tests/test_form_layout_repository.py | 69 +++++++++ engine/tests/test_form_layouts.py | 141 ++++++++++++++++++ 20 files changed, 852 insertions(+), 7 deletions(-) create mode 100644 auth-service/api/v1/router_groups.py create mode 100644 auth-service/domain/entities/user_group.py create mode 100644 auth-service/infrastructure/user_group_repository.py create mode 100644 auth-service/tests/test_user_group_repository.py create mode 100644 auth-service/tests/test_user_groups.py create mode 100644 engine/api/v1/router_layouts.py create mode 100644 engine/metadata_context/domain/entities/form_layout.py create mode 100644 engine/metadata_context/infrastructure/form_layout_repository.py create mode 100644 engine/tests/test_form_layout_repository.py create mode 100644 engine/tests/test_form_layouts.py diff --git a/auth-service/api/dependencies.py b/auth-service/api/dependencies.py index 774854c..9db7d08 100644 --- a/auth-service/api/dependencies.py +++ b/auth-service/api/dependencies.py @@ -1,6 +1,7 @@ from motor.motor_asyncio import AsyncIOMotorClient from config import settings from infrastructure.user_repository import UserRepository +from infrastructure.user_group_repository import UserGroupRepository from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError @@ -17,6 +18,9 @@ async def get_db(): async def get_user_repository(db = Depends(get_db)): return UserRepository(db) +async def get_user_group_repository(db = Depends(get_db)): + return UserGroupRepository(db) + async def get_current_user( token: str = Depends(oauth2_scheme), user_repo: UserRepository = Depends(get_user_repository) diff --git a/auth-service/api/v1/router_auth.py b/auth-service/api/v1/router_auth.py index 9d18a47..2abb7c8 100644 --- a/auth-service/api/v1/router_auth.py +++ b/auth-service/api/v1/router_auth.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from domain.entities.user import User, UserCreate, UserRole +from domain.entities.user import User, UserCreate, UserUpdate, UserRole from application.auth_use_cases import AuthUseCases from infrastructure.user_repository import UserRepository from api.dependencies import get_user_repository, get_current_user @@ -48,6 +48,37 @@ async def list_users( use_cases = AuthUseCases(user_repo) return await use_cases.list_users() +@router.put("/users/{user_id}", response_model=User) +async def update_user( + user_id: str, + user_in: UserUpdate, + user_repo: Annotated[UserRepository, Depends(get_user_repository)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.role == UserRole.USER: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update users" + ) + + use_cases = AuthUseCases(user_repo) + target_user = await user_repo.get_by_id(user_id) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # Permission checks + if current_user.role == UserRole.USER_ADMIN: + if target_user.role == UserRole.SYSTEM_ADMIN: + raise HTTPException(status_code=403, detail="User Admins cannot update System Admins") + if user_in.role == UserRole.SYSTEM_ADMIN: + raise HTTPException(status_code=403, detail="User Admins cannot promote to System Admin") + + updated_user = await use_cases.update_user(user_id, user_in) + if not updated_user: + raise HTTPException(status_code=404, detail="User not found") + + return updated_user + @router.delete("/users/{user_id}") async def delete_user( user_id: str, @@ -90,5 +121,5 @@ async def login( detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) - access_token = SecurityService.create_access_token(data={"sub": user.email, "role": user.role}) + access_token = SecurityService.create_access_token(data={"sub": user.email, "role": user.role, "groups": user.group_ids}) return {"access_token": access_token, "token_type": "bearer"} diff --git a/auth-service/api/v1/router_groups.py b/auth-service/api/v1/router_groups.py new file mode 100644 index 0000000..b807bd5 --- /dev/null +++ b/auth-service/api/v1/router_groups.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from typing import Annotated +from domain.entities.user import User, UserRole +from domain.entities.user_group import UserGroup, UserGroupCreate, UserGroupUpdate +from infrastructure.user_group_repository import UserGroupRepository +from api.dependencies import get_user_group_repository, get_current_user + +router = APIRouter() + +def require_system_admin(current_user: User = Depends(get_current_user)): + if current_user.role != UserRole.SYSTEM_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Requires System Admin privileges" + ) + return current_user + +@router.post("/", response_model=UserGroup, status_code=status.HTTP_201_CREATED) +async def create_group( + group_in: UserGroupCreate, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + existing = await repo.get_by_name(group_in.name) + if existing: + raise HTTPException(status_code=400, detail="Group with this name already exists") + + group = UserGroup(**group_in.model_dump()) + return await repo.create(group) + +@router.get("/", response_model=list[UserGroup]) +async def list_groups( + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + return await repo.get_all() + +@router.get("/{group_id}", response_model=UserGroup) +async def get_group( + group_id: str, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + group = await repo.get_by_id(group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + return group + +@router.put("/{group_id}", response_model=UserGroup) +async def update_group( + group_id: str, + group_in: UserGroupUpdate, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + updated_group = await repo.update(group_id, group_in.model_dump(exclude_unset=True)) + if not updated_group: + raise HTTPException(status_code=404, detail="Group not found") + return updated_group + +@router.delete("/{group_id}") +async def delete_group( + group_id: str, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + success = await repo.delete(group_id) + if not success: + raise HTTPException(status_code=404, detail="Group not found") + return {"message": "Group deleted successfully"} diff --git a/auth-service/application/auth_use_cases.py b/auth-service/application/auth_use_cases.py index 003dd7d..01ff9b4 100644 --- a/auth-service/application/auth_use_cases.py +++ b/auth-service/application/auth_use_cases.py @@ -1,4 +1,4 @@ -from domain.entities.user import User, UserCreate +from domain.entities.user import User, UserCreate, UserUpdate from domain.services.security_service import SecurityService from infrastructure.user_repository import UserRepository from fastapi import HTTPException, status @@ -19,7 +19,8 @@ async def register_user(self, user_in: UserCreate): new_user = User( email=user_in.email, hashed_password=hashed_password, - role=user_in.role + role=user_in.role, + group_ids=user_in.group_ids ) created_user = await self.user_repo.create(new_user) return created_user @@ -35,5 +36,14 @@ async def authenticate_user(self, email: str, password: str): async def list_users(self): return await self.user_repo.get_all() + async def update_user(self, user_id: str, user_in: UserUpdate): + update_data = user_in.model_dump(exclude_unset=True) + if "password" in update_data: + hashed_password = SecurityService.get_password_hash(update_data["password"]) + update_data["hashed_password"] = hashed_password + del update_data["password"] + + return await self.user_repo.update(user_id, update_data) + async def delete_user(self, user_id: str): return await self.user_repo.delete(user_id) diff --git a/auth-service/domain/entities/user.py b/auth-service/domain/entities/user.py index 65d02e9..98d95af 100644 --- a/auth-service/domain/entities/user.py +++ b/auth-service/domain/entities/user.py @@ -17,6 +17,7 @@ class User(BaseModel): hashed_password: str is_active: bool = True role: UserRole = UserRole.USER + group_ids: list[str] = Field(default_factory=list, description="List of UserGroup IDs this user belongs to") provider: str = "local" # local, google, etc. model_config = ConfigDict( @@ -29,3 +30,11 @@ class UserCreate(BaseModel): email: EmailStr password: str role: UserRole = UserRole.USER + group_ids: list[str] = Field(default_factory=list) + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + password: Optional[str] = None + is_active: Optional[bool] = None + role: Optional[UserRole] = None + group_ids: Optional[list[str]] = None \ No newline at end of file diff --git a/auth-service/domain/entities/user_group.py b/auth-service/domain/entities/user_group.py new file mode 100644 index 0000000..462efba --- /dev/null +++ b/auth-service/domain/entities/user_group.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field, BeforeValidator, ConfigDict +from typing import Optional, Annotated +from bson import ObjectId + +# Helper for Pydantic v2 + BSON +PyObjectId = Annotated[str, BeforeValidator(str)] + +class UserGroup(BaseModel): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = Field(..., min_length=1, description="Unique name of the group (e.g., 'Sales')") + description: Optional[str] = Field(default=None, description="Description of the group's purpose") + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str} + ) + +class UserGroupCreate(BaseModel): + name: str + description: Optional[str] = None + +class UserGroupUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None diff --git a/auth-service/infrastructure/user_group_repository.py b/auth-service/infrastructure/user_group_repository.py new file mode 100644 index 0000000..3ee3ef8 --- /dev/null +++ b/auth-service/infrastructure/user_group_repository.py @@ -0,0 +1,65 @@ +from motor.motor_asyncio import AsyncIOMotorDatabase +from domain.entities.user_group import UserGroup +from bson import ObjectId + +class UserGroupRepository: + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["user_groups"] + + async def get_by_id(self, group_id: str): + try: + oid = ObjectId(group_id) + except Exception: + return None + doc = await self.collection.find_one({"_id": oid}) + if doc: + return UserGroup(**doc) + return None + + async def get_by_name(self, name: str): + doc = await self.collection.find_one({"name": name}) + if doc: + return UserGroup(**doc) + return None + + async def get_all(self): + cursor = self.collection.find() + groups = [] + async for doc in cursor: + groups.append(UserGroup(**doc)) + return groups + + async def create(self, group: UserGroup): + group_dict = group.model_dump(by_alias=True, exclude={"id"}) + result = await self.collection.insert_one(group_dict) + group.id = str(result.inserted_id) + return group + + async def update(self, group_id: str, update_data: dict): + # update_data should match UserGroupUpdate fields + if not update_data: + return None + + try: + oid = ObjectId(group_id) + except Exception: + return None + + result = await self.collection.update_one( + {"_id": oid}, + {"$set": update_data} + ) + if result.modified_count > 0: + return await self.get_by_id(group_id) + # If no document matched, return None. If matched but no change, fetch and return. + if result.matched_count > 0: + return await self.get_by_id(group_id) + return None + + async def delete(self, group_id: str): + try: + oid = ObjectId(group_id) + except Exception: + return False + result = await self.collection.delete_one({"_id": oid}) + return result.deleted_count > 0 diff --git a/auth-service/infrastructure/user_repository.py b/auth-service/infrastructure/user_repository.py index 0c3468d..88f54c8 100644 --- a/auth-service/infrastructure/user_repository.py +++ b/auth-service/infrastructure/user_repository.py @@ -35,6 +35,22 @@ async def get_all(self): users.append(User(**doc)) return users + async def update(self, user_id: str, update_data: dict): + if not update_data: + return None + try: + oid = ObjectId(user_id) + except Exception: + return None + + result = await self.collection.update_one( + {"_id": oid}, + {"$set": update_data} + ) + if result.modified_count > 0 or result.matched_count > 0: + return await self.get_by_id(user_id) + return None + async def delete(self, user_id: str): result = await self.collection.delete_one({"_id": ObjectId(user_id)}) return result.deleted_count > 0 diff --git a/auth-service/main.py b/auth-service/main.py index 8987e8c..eb964ba 100644 --- a/auth-service/main.py +++ b/auth-service/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.v1.router_auth import router as auth_router +from api.v1.router_groups import router as groups_router from contextlib import asynccontextmanager from api.dependencies import get_user_repository, get_db from domain.entities.user import User, UserRole @@ -52,3 +53,4 @@ async def health_check(): return {"status": "ok"} app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"]) +app.include_router(groups_router, prefix="/api/v1/groups", tags=["groups"]) diff --git a/auth-service/pytest.ini b/auth-service/pytest.ini index fd2bcbd..013c77c 100644 --- a/auth-service/pytest.ini +++ b/auth-service/pytest.ini @@ -1,4 +1,6 @@ [pytest] pythonpath = . testpaths = tests -addopts = --cov=. --cov-report=term-missing +python_functions = test_* +asyncio_mode = auto +addopts = -v --cov=. --cov-report=term-missing diff --git a/auth-service/tests/test_user_group_repository.py b/auth-service/tests/test_user_group_repository.py new file mode 100644 index 0000000..11fc7fd --- /dev/null +++ b/auth-service/tests/test_user_group_repository.py @@ -0,0 +1,82 @@ +import pytest +from infrastructure.user_group_repository import UserGroupRepository +from domain.entities.user_group import UserGroup +from mongomock_motor import AsyncMongoMockClient +from bson import ObjectId + +@pytest.fixture +async def mock_db(): + client = AsyncMongoMockClient() + db = client["test_db"] + return db + +@pytest.fixture +async def repo(mock_db): + return UserGroupRepository(mock_db) + +@pytest.mark.asyncio +async def test_create_and_get_group(repo): + group = UserGroup(name="Sales", description="Sales Team") + created = await repo.create(group) + assert created.id is not None + + fetched = await repo.get_by_id(created.id) + assert fetched.name == "Sales" + assert fetched.description == "Sales Team" + +@pytest.mark.asyncio +async def test_get_by_name(repo): + await repo.create(UserGroup(name="Marketing")) + + group = await repo.get_by_name("Marketing") + assert group is not None + assert group.name == "Marketing" + + none_group = await repo.get_by_name("NonExistent") + assert none_group is None + +@pytest.mark.asyncio +async def test_update_group(repo): + group = await repo.create(UserGroup(name="Dev")) + + updated = await repo.update(group.id, {"name": "Engineering", "description": "Tech"}) + assert updated.name == "Engineering" + assert updated.description == "Tech" + + # Verify persistence + fetched = await repo.get_by_id(group.id) + assert fetched.name == "Engineering" + + # Update non-existent + result = await repo.update(str(ObjectId()), {"name": "Ghost"}) + assert result is None + +@pytest.mark.asyncio +async def test_delete_group(repo): + group = await repo.create(UserGroup(name="Temp")) + + success = await repo.delete(group.id) + assert success is True + + fetched = await repo.get_by_id(group.id) + assert fetched is None + + # Delete non-existent + success = await repo.delete(str(ObjectId())) + assert success is False + +@pytest.mark.asyncio +async def test_get_all(repo): + await repo.create(UserGroup(name="A")) + await repo.create(UserGroup(name="B")) + + groups = await repo.get_all() + assert len(groups) == 2 + names = sorted([g.name for g in groups]) + assert names == ["A", "B"] + +@pytest.mark.asyncio +async def test_invalid_id_handling(repo): + assert await repo.get_by_id("invalid-oid") is None + assert await repo.update("invalid-oid", {}) is None + assert await repo.delete("invalid-oid") is False diff --git a/auth-service/tests/test_user_groups.py b/auth-service/tests/test_user_groups.py new file mode 100644 index 0000000..46d7e53 --- /dev/null +++ b/auth-service/tests/test_user_groups.py @@ -0,0 +1,124 @@ +import pytest +from httpx import AsyncClient +from unittest.mock import AsyncMock +from domain.entities.user import User, UserRole +from domain.entities.user_group import UserGroup +from api.dependencies import get_user_group_repository, get_current_user +from main import app + +@pytest.fixture +def mock_group_repo(): + return AsyncMock() + +@pytest.fixture +def system_admin_user(): + return User( + email="admin@example.com", + hashed_password="hash", + role=UserRole.SYSTEM_ADMIN, + group_ids=[] + ) + +@pytest.mark.asyncio +async def test_create_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.get_by_name.return_value = None + # Simulate create returning the object + async def create_side_effect(group): + group.id = "mock_id" + return group + mock_group_repo.create.side_effect = create_side_effect + + response = await client.post("/api/v1/groups/", json={ + "name": "Sales", + "description": "Sales Team" + }) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Sales" + assert data["description"] == "Sales Team" + assert "_id" in data # Aliased id + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_list_groups(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.get_all.return_value = [ + UserGroup(id="1", name="G1"), + UserGroup(id="2", name="G2") + ] + + response = await client.get("/api/v1/groups/") + assert response.status_code == 200 + assert len(response.json()) == 2 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_get_group_by_id(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group = UserGroup(id="123", name="G1") + mock_group_repo.get_by_id.return_value = mock_group + + response = await client.get("/api/v1/groups/123") + assert response.status_code == 200 + assert response.json()["name"] == "G1" + + mock_group_repo.get_by_id.return_value = None + response = await client.get("/api/v1/groups/999") + assert response.status_code == 404 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_update_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + updated_group = UserGroup(id="123", name="Updated Name") + mock_group_repo.update.return_value = updated_group + + response = await client.put("/api/v1/groups/123", json={"name": "Updated Name"}) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Name" + + mock_group_repo.update.return_value = None + response = await client.put("/api/v1/groups/999", json={"name": "Ghost"}) + assert response.status_code == 404 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_delete_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.delete.return_value = True + response = await client.delete("/api/v1/groups/123") + assert response.status_code == 200 + + mock_group_repo.delete.return_value = False + response = await client.delete("/api/v1/groups/999") + assert response.status_code == 404 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_create_duplicate_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.get_by_name.return_value = UserGroup(id="1", name="Sales") + + response = await client.post("/api/v1/groups/", json={"name": "Sales"}) + assert response.status_code == 400 + + app.dependency_overrides.clear() diff --git a/engine/api/dependencies.py b/engine/api/dependencies.py index 4d859ef..5ba50fa 100644 --- a/engine/api/dependencies.py +++ b/engine/api/dependencies.py @@ -1,6 +1,7 @@ from motor.motor_asyncio import AsyncIOMotorClient from building_blocks.config import settings from metadata_context.infrastructure.schema_repository import SchemaRepository +from metadata_context.infrastructure.form_layout_repository import FormLayoutRepository from execution_context.infrastructure.record_repository import RecordRepository from execution_context.application.record_use_cases import RecordUseCase from metadata_context.application.schema_use_cases import SchemaApplicationService @@ -34,9 +35,10 @@ async def verify_token(token: str = Depends(oauth2_scheme)): payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) email: str = payload.get("sub") role: str = payload.get("role", "user") # Default to user if role not present + groups: list = payload.get("groups", []) if email is None: raise credentials_exception - return {"email": email, "role": role} + return {"email": email, "role": role, "groups": groups} except JWTError: raise credentials_exception @@ -64,4 +66,11 @@ def get_schema_service() -> SchemaApplicationService: connect_db() repo = SchemaRepository(db) - return SchemaApplicationService(repo) \ No newline at end of file + return SchemaApplicationService(repo) + +def get_layout_repository() -> FormLayoutRepository: + if db is None: + connect_db() + return FormLayoutRepository(db) + + \ No newline at end of file diff --git a/engine/api/v1/router_layouts.py b/engine/api/v1/router_layouts.py new file mode 100644 index 0000000..1b0bc6d --- /dev/null +++ b/engine/api/v1/router_layouts.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from typing import Annotated, List, Optional +from metadata_context.domain.entities.form_layout import FormLayout, FormLayoutCreate, FormLayoutUpdate +from metadata_context.infrastructure.form_layout_repository import FormLayoutRepository +from api.dependencies import get_layout_repository, require_system_admin, verify_token + +router = APIRouter() + +@router.get("/resolve/{schema_name}", response_model=Optional[FormLayout]) +async def resolve_layout( + schema_name: str, + repo: Annotated[FormLayoutRepository, Depends(get_layout_repository)], + user: Annotated[dict, Depends(verify_token)] +): + layouts = await repo.get_by_schema(schema_name) + user_groups = set(user["groups"]) + + # Priority 1: Specific Group Match + for layout in layouts: + layout_groups = set(layout.target_group_ids) + if not user_groups.isdisjoint(layout_groups): + return layout + + # Priority 2: Default Layout + for layout in layouts: + if layout.is_default: + return layout + + # Priority 3: None (Frontend should render default vertical list) + return None + +@router.post("/", response_model=FormLayout, status_code=status.HTTP_201_CREATED) +async def create_layout( + layout_in: FormLayoutCreate, + repo: Annotated[FormLayoutRepository, Depends(get_layout_repository)], + _: Annotated[dict, Depends(require_system_admin)] +): + layout = FormLayout(**layout_in.model_dump()) + return await repo.create(layout) + +@router.get("/by-schema/{schema_name}", response_model=List[FormLayout]) +async def get_layouts_by_schema( + schema_name: str, + repo: Annotated[FormLayoutRepository, Depends(get_layout_repository)], + user: Annotated[dict, Depends(verify_token)] +): + # TODO: Filter by user permissions if not admin + return await repo.get_by_schema(schema_name) + +@router.get("/{layout_id}", response_model=FormLayout) +async def get_layout( + layout_id: str, + repo: Annotated[FormLayoutRepository, Depends(get_layout_repository)], + user: Annotated[dict, Depends(verify_token)] +): + layout = await repo.get_by_id(layout_id) + if not layout: + raise HTTPException(status_code=404, detail="Layout not found") + return layout + +@router.put("/{layout_id}", response_model=FormLayout) +async def update_layout( + layout_id: str, + layout_in: FormLayoutUpdate, + repo: Annotated[FormLayoutRepository, Depends(get_layout_repository)], + _: Annotated[dict, Depends(require_system_admin)] +): + updated = await repo.update(layout_id, layout_in.model_dump(exclude_unset=True)) + if not updated: + raise HTTPException(status_code=404, detail="Layout not found") + return updated + +@router.delete("/{layout_id}") +async def delete_layout( + layout_id: str, + repo: Annotated[FormLayoutRepository, Depends(get_layout_repository)], + _: Annotated[dict, Depends(require_system_admin)] +): + success = await repo.delete(layout_id) + if not success: + raise HTTPException(status_code=404, detail="Layout not found") + return {"message": "Layout deleted successfully"} diff --git a/engine/main.py b/engine/main.py index a6b5261..67230ec 100644 --- a/engine/main.py +++ b/engine/main.py @@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from api.v1.router_metadata import router as metadata_router +from api.v1.router_layouts import router as layout_router from api.v1.router_execution import router as execution_router from building_blocks.config import settings @@ -57,6 +58,7 @@ async def lifespan(app: FastAPI): # Include Versioned Routers based on APP_MODE if settings.app_mode in ["all", "metadata"]: app.include_router(metadata_router) + app.include_router(layout_router, prefix="/api/v1/layouts", tags=["layouts"]) if settings.app_mode in ["all", "execution"]: app.include_router(execution_router) diff --git a/engine/metadata_context/domain/entities/form_layout.py b/engine/metadata_context/domain/entities/form_layout.py new file mode 100644 index 0000000..e9d8aab --- /dev/null +++ b/engine/metadata_context/domain/entities/form_layout.py @@ -0,0 +1,47 @@ +from __future__ import annotations +from pydantic import BaseModel, Field, ConfigDict, BeforeValidator +from typing import List, Optional, Any, Dict, Annotated +from datetime import datetime, timezone +from bson import ObjectId + +# Helper for Pydantic v2 + BSON +PyObjectId = Annotated[str, BeforeValidator(str)] + +class LayoutComponent(BaseModel): + id: str + type: str + children: List[LayoutComponent] = Field(default_factory=list) + field_name: Optional[str] = None + props: Dict[str, Any] = Field(default_factory=dict) + +class FormLayout(BaseModel): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + schema_name: str = Field(..., description="The entity_name of the schema this layout applies to") + name: str + description: Optional[str] = None + target_group_ids: List[str] = Field(default_factory=list) + definition: List[LayoutComponent] + is_default: bool = False + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str} + ) + +class FormLayoutCreate(BaseModel): + schema_name: str + name: str + description: Optional[str] = None + target_group_ids: List[str] = Field(default_factory=list) + definition: List[LayoutComponent] + is_default: bool = False + +class FormLayoutUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + target_group_ids: Optional[List[str]] = None + definition: Optional[List[LayoutComponent]] = None + is_default: Optional[bool] = None diff --git a/engine/metadata_context/infrastructure/form_layout_repository.py b/engine/metadata_context/infrastructure/form_layout_repository.py new file mode 100644 index 0000000..c732c58 --- /dev/null +++ b/engine/metadata_context/infrastructure/form_layout_repository.py @@ -0,0 +1,54 @@ +from motor.motor_asyncio import AsyncIOMotorDatabase +from metadata_context.domain.entities.form_layout import FormLayout +from bson import ObjectId + +class FormLayoutRepository: + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["form_layouts"] + + async def get_by_id(self, layout_id: str): + try: + oid = ObjectId(layout_id) + except Exception: + return None + doc = await self.collection.find_one({"_id": oid}) + if doc: + return FormLayout(**doc) + return None + + async def get_by_schema(self, schema_name: str): + cursor = self.collection.find({"schema_name": schema_name}) + layouts = [] + async for doc in cursor: + layouts.append(FormLayout(**doc)) + return layouts + + async def create(self, layout: FormLayout): + layout_dict = layout.model_dump(by_alias=True, exclude={"id"}) + result = await self.collection.insert_one(layout_dict) + layout.id = str(result.inserted_id) + return layout + + async def update(self, layout_id: str, update_data: dict): + if not update_data: + return None + try: + oid = ObjectId(layout_id) + except Exception: + return None + + result = await self.collection.update_one( + {"_id": oid}, + {"$set": update_data} + ) + if result.modified_count > 0 or result.matched_count > 0: + return await self.get_by_id(layout_id) + return None + + async def delete(self, layout_id: str): + try: + oid = ObjectId(layout_id) + except Exception: + return False + result = await self.collection.delete_one({"_id": oid}) + return result.deleted_count > 0 diff --git a/engine/pytest.ini b/engine/pytest.ini index fd2bcbd..b3ffbb9 100644 --- a/engine/pytest.ini +++ b/engine/pytest.ini @@ -1,4 +1,5 @@ [pytest] pythonpath = . testpaths = tests +asyncio_mode = auto addopts = --cov=. --cov-report=term-missing diff --git a/engine/tests/test_form_layout_repository.py b/engine/tests/test_form_layout_repository.py new file mode 100644 index 0000000..bf7600d --- /dev/null +++ b/engine/tests/test_form_layout_repository.py @@ -0,0 +1,69 @@ +import pytest +from metadata_context.infrastructure.form_layout_repository import FormLayoutRepository +from metadata_context.domain.entities.form_layout import FormLayout +from mongomock_motor import AsyncMongoMockClient +from bson import ObjectId + +@pytest.fixture +async def mock_db(): + client = AsyncMongoMockClient() + db = client["test_engine_db"] + return db + +@pytest.fixture +async def repo(mock_db): + return FormLayoutRepository(mock_db) + +@pytest.mark.asyncio +async def test_create_and_get_layout(repo): + layout = FormLayout( + schema_name="customer", + name="Admin View", + definition=[{"id": "1", "type": "row"}] + ) + created = await repo.create(layout) + assert created.id is not None + + fetched = await repo.get_by_id(created.id) + assert fetched.name == "Admin View" + assert fetched.schema_name == "customer" + +@pytest.mark.asyncio +async def test_get_by_schema(repo): + await repo.create(FormLayout(schema_name="customer", name="View 1", definition=[])) + await repo.create(FormLayout(schema_name="customer", name="View 2", definition=[])) + await repo.create(FormLayout(schema_name="order", name="Order View", definition=[])) + + layouts = await repo.get_by_schema("customer") + assert len(layouts) == 2 + names = sorted([l.name for l in layouts]) + assert names == ["View 1", "View 2"] + +@pytest.mark.asyncio +async def test_update_layout(repo): + layout = await repo.create(FormLayout(schema_name="customer", name="Old Name", definition=[])) + + updated = await repo.update(layout.id, {"name": "New Name"}) + assert updated.name == "New Name" + + fetched = await repo.get_by_id(layout.id) + assert fetched.name == "New Name" + + # Update non-existent + assert await repo.update(str(ObjectId()), {}) is None + +@pytest.mark.asyncio +async def test_delete_layout(repo): + layout = await repo.create(FormLayout(schema_name="customer", name="Temp", definition=[])) + + success = await repo.delete(layout.id) + assert success is True + + assert await repo.get_by_id(layout.id) is None + assert await repo.delete(str(ObjectId())) is False + +@pytest.mark.asyncio +async def test_invalid_id_handling(repo): + assert await repo.get_by_id("invalid-oid") is None + assert await repo.update("invalid-oid", {}) is None + assert await repo.delete("invalid-oid") is False diff --git a/engine/tests/test_form_layouts.py b/engine/tests/test_form_layouts.py new file mode 100644 index 0000000..625e2dd --- /dev/null +++ b/engine/tests/test_form_layouts.py @@ -0,0 +1,141 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from main import app +from api.dependencies import verify_token + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + +@pytest.mark.asyncio +async def test_create_layout(client): + payload = { + "schema_name": "customer", + "name": "Admin View", + "definition": [ + { + "id": "root", + "type": "row", + "children": [] + } + ] + } + response = await client.post("/api/v1/layouts/", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Admin View" + assert data["definition"][0]["type"] == "row" + +@pytest.mark.asyncio +async def test_resolve_layout(client): + # 1. Create Default + await client.post("/api/v1/layouts/", json={ + "schema_name": "customer", + "name": "Default View", + "is_default": True, + "definition": [] + }) + + # 2. Create Specific + await client.post("/api/v1/layouts/", json={ + "schema_name": "customer", + "name": "Sales View", + "target_group_ids": ["sales_group_id"], + "definition": [] + }) + + # 3. Resolve for Sales User + async def mock_sales_user(): + return {"email": "sales@example.com", "role": "user", "groups": ["sales_group_id"]} + + app.dependency_overrides[verify_token] = mock_sales_user + + response = await client.get("/api/v1/layouts/resolve/customer") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Sales View" + + # 4. Resolve for Other User (should fallback to default) + async def mock_other_user(): + return {"email": "other@example.com", "role": "user", "groups": ["other_group"]} + + app.dependency_overrides[verify_token] = mock_other_user + + response = await client.get("/api/v1/layouts/resolve/customer") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Default View" + + app.dependency_overrides = {} + +@pytest.mark.asyncio +async def test_get_layouts_by_schema(client): + # Setup: Create 2 layouts + await client.post("/api/v1/layouts/", json={ + "schema_name": "customer", "name": "L1", "definition": [] + }) + await client.post("/api/v1/layouts/", json={ + "schema_name": "customer", "name": "L2", "definition": [] + }) + + response = await client.get("/api/v1/layouts/by-schema/customer") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + +@pytest.mark.asyncio +async def test_get_layout_by_id(client): + # Setup + create_res = await client.post("/api/v1/layouts/", json={ + "schema_name": "customer", "name": "L1", "definition": [] + }) + layout_id = create_res.json()["_id"] + + # Test Success + response = await client.get(f"/api/v1/layouts/{layout_id}") + assert response.status_code == 200 + assert response.json()["name"] == "L1" + + # Test 404 + response = await client.get(f"/api/v1/layouts/{'0'*24}") # Valid ObjectId but missing + assert response.status_code == 404 + +@pytest.mark.asyncio +async def test_update_layout(client): + # Setup + create_res = await client.post("/api/v1/layouts/", json={ + "schema_name": "customer", "name": "Old", "definition": [] + }) + layout_id = create_res.json()["_id"] + + # Test Success + response = await client.put(f"/api/v1/layouts/{layout_id}", json={ + "name": "New" + }) + assert response.status_code == 200 + assert response.json()["name"] == "New" + + # Test 404 + response = await client.put(f"/api/v1/layouts/{'0'*24}", json={"name": "Ghost"}) + assert response.status_code == 404 + +@pytest.mark.asyncio +async def test_delete_layout(client): + # Setup + create_res = await client.post("/api/v1/layouts/", json={ + "schema_name": "customer", "name": "Temp", "definition": [] + }) + layout_id = create_res.json()["_id"] + + # Test Success + response = await client.delete(f"/api/v1/layouts/{layout_id}") + assert response.status_code == 200 + + # Verify Deletion + get_res = await client.get(f"/api/v1/layouts/{layout_id}") + assert get_res.status_code == 404 + + # Test 404 on repeat delete + response = await client.delete(f"/api/v1/layouts/{layout_id}") + assert response.status_code == 404 From 612adf22035319b414676e44de978a8b22728428 Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Thu, 8 Jan 2026 23:39:07 +0900 Subject: [PATCH 06/15] fix: for trailing slash ("/") issue fixed and added route in api-gateway --- auth-service/api/v1/router_groups.py | 4 ++-- auth-service/tests/test_user_groups.py | 6 +++--- dynaman-ui/vite.config.ts | 8 ++++++++ nginx-gateway.conf | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/auth-service/api/v1/router_groups.py b/auth-service/api/v1/router_groups.py index b807bd5..6d81361 100644 --- a/auth-service/api/v1/router_groups.py +++ b/auth-service/api/v1/router_groups.py @@ -15,7 +15,7 @@ def require_system_admin(current_user: User = Depends(get_current_user)): ) return current_user -@router.post("/", response_model=UserGroup, status_code=status.HTTP_201_CREATED) +@router.post("", response_model=UserGroup, status_code=status.HTTP_201_CREATED) async def create_group( group_in: UserGroupCreate, repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], @@ -28,7 +28,7 @@ async def create_group( group = UserGroup(**group_in.model_dump()) return await repo.create(group) -@router.get("/", response_model=list[UserGroup]) +@router.get("", response_model=list[UserGroup]) async def list_groups( repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], _: Annotated[User, Depends(require_system_admin)] diff --git a/auth-service/tests/test_user_groups.py b/auth-service/tests/test_user_groups.py index 46d7e53..9d16f95 100644 --- a/auth-service/tests/test_user_groups.py +++ b/auth-service/tests/test_user_groups.py @@ -31,7 +31,7 @@ async def create_side_effect(group): return group mock_group_repo.create.side_effect = create_side_effect - response = await client.post("/api/v1/groups/", json={ + response = await client.post("/api/v1/groups", json={ "name": "Sales", "description": "Sales Team" }) @@ -54,7 +54,7 @@ async def test_list_groups(client, mock_group_repo, system_admin_user): UserGroup(id="2", name="G2") ] - response = await client.get("/api/v1/groups/") + response = await client.get("/api/v1/groups") assert response.status_code == 200 assert len(response.json()) == 2 @@ -118,7 +118,7 @@ async def test_create_duplicate_group(client, mock_group_repo, system_admin_user mock_group_repo.get_by_name.return_value = UserGroup(id="1", name="Sales") - response = await client.post("/api/v1/groups/", json={"name": "Sales"}) + response = await client.post("/api/v1/groups", json={"name": "Sales"}) assert response.status_code == 400 app.dependency_overrides.clear() diff --git a/dynaman-ui/vite.config.ts b/dynaman-ui/vite.config.ts index a36222a..f9cff57 100644 --- a/dynaman-ui/vite.config.ts +++ b/dynaman-ui/vite.config.ts @@ -14,4 +14,12 @@ export default defineConfig({ "@": resolve(__dirname, "./src"), }, }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + } + } + } }) diff --git a/nginx-gateway.conf b/nginx-gateway.conf index 469fcf8..ba78a5c 100644 --- a/nginx-gateway.conf +++ b/nginx-gateway.conf @@ -27,6 +27,20 @@ http { proxy_set_header Authorization $http_authorization; } + location /api/v1/groups { + proxy_pass http://auth-service:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Authorization $http_authorization; + } + + location /api/v1/layouts { + proxy_pass http://engine-metadata:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Authorization $http_authorization; + } + location / { proxy_pass http://dynaman-ui:80; proxy_set_header Host $host; From dc20161492d70c41214c5c2c762c185af2b616e0 Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Thu, 8 Jan 2026 23:39:19 +0900 Subject: [PATCH 07/15] feat: added user group management in UI and adopted to change user management. also adding basic feature of DynamicForm. --- dynaman-ui/src/App.tsx | 6 + dynaman-ui/src/components/DynamicForm.tsx | 53 ++++++++ dynaman-ui/src/components/Layout.tsx | 8 ++ dynaman-ui/src/context/AuthContext.tsx | 1 + dynaman-ui/src/lib/api.ts | 76 ++++++++++++ dynaman-ui/src/pages/AdminGroups.tsx | 140 ++++++++++++++++++++++ dynaman-ui/src/pages/AdminUsers.tsx | 56 ++++++++- dynaman-ui/src/pages/DataExplorer.tsx | 5 +- 8 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 dynaman-ui/src/components/DynamicForm.tsx create mode 100644 dynaman-ui/src/pages/AdminGroups.tsx diff --git a/dynaman-ui/src/App.tsx b/dynaman-ui/src/App.tsx index 206e07c..4213598 100644 --- a/dynaman-ui/src/App.tsx +++ b/dynaman-ui/src/App.tsx @@ -8,6 +8,7 @@ import { AuthProvider } from '@/context/AuthContext'; import { RequireAuth } from '@/components/RequireAuth'; import { RequireSystemAdmin } from '@/components/RequireSystemAdmin'; import { AdminUsers } from '@/pages/AdminUsers'; +import AdminGroups from '@/pages/AdminGroups'; import './App.css'; function App() { @@ -37,6 +38,11 @@ function App() { } /> } /> + + + + } /> 404 - Page Not Found} /> diff --git a/dynaman-ui/src/components/DynamicForm.tsx b/dynaman-ui/src/components/DynamicForm.tsx new file mode 100644 index 0000000..3a1a3e7 --- /dev/null +++ b/dynaman-ui/src/components/DynamicForm.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from 'react'; +import { layoutApi, type FormLayout } from '@/lib/api'; +import DataInputForm from './DataInputForm'; + +// Match DataInputForm props +interface DynamicFormProps { + schemaName: string; // Extra prop we need + schema: any; + isOpen: boolean; + onClose: () => void; + onSave: () => void; + recordId?: string; + initialData?: any; +} + +export const DynamicForm: React.FC = (props) => { + const { schemaName } = props; + const [layout, setLayout] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!props.isOpen) return; // Don't fetch if closed + + const fetchLayout = async () => { + setLoading(true); + const resolvedLayout = await layoutApi.resolve(schemaName); + setLayout(resolvedLayout); + setLoading(false); + }; + fetchLayout(); + }, [schemaName, props.isOpen]); + + if (loading && props.isOpen) { + // Render a loading modal placeholder? Or just null? + // Existing DataInputForm renders a modal. We should probably render a loading state inside a modal structure + // but for now, let's just let it load. + return
Loading layout...
; + } + + // TODO: In Phase 4, pass 'layout' to DataInputForm or a new renderer. + // For now, we just pass the props through. + + if (layout) { + console.log("Using Layout:", layout.name); + // We will eventually pass 'layout={layout}' to DataInputForm + } + + return ( + + ); +}; \ No newline at end of file diff --git a/dynaman-ui/src/components/Layout.tsx b/dynaman-ui/src/components/Layout.tsx index e976d94..5dd1405 100644 --- a/dynaman-ui/src/components/Layout.tsx +++ b/dynaman-ui/src/components/Layout.tsx @@ -61,6 +61,14 @@ export default function DashboardLayout() { )} + {isSystemAdmin && ( + + + + )} {isSystemAdmin && ( diff --git a/dynaman-ui/src/context/AuthContext.tsx b/dynaman-ui/src/context/AuthContext.tsx index f7980d9..5e146c0 100644 --- a/dynaman-ui/src/context/AuthContext.tsx +++ b/dynaman-ui/src/context/AuthContext.tsx @@ -6,6 +6,7 @@ export interface User { email: string; role: 'system_admin' | 'user_admin' | 'user'; is_active: boolean; + group_ids?: string[]; } interface AuthContextType { diff --git a/dynaman-ui/src/lib/api.ts b/dynaman-ui/src/lib/api.ts index 7c703a6..4200da0 100644 --- a/dynaman-ui/src/lib/api.ts +++ b/dynaman-ui/src/lib/api.ts @@ -34,4 +34,80 @@ api.interceptors.response.use( } ); +export interface UserGroup { + _id: string; + name: string; + description?: string; +} + +export interface User { + _id: string; + email: string; + role: string; + is_active: boolean; + group_ids?: string[]; +} + +export const groupApi = { + list: async () => { + const response = await api.get('/api/v1/groups'); + return response.data; + }, + create: async (data: { name: string; description?: string }) => { + const response = await api.post('/api/v1/groups', data); + return response.data; + }, + get: async (id: string) => { + const response = await api.get(`/api/v1/groups/${id}`); + return response.data; + }, + update: async (id: string, data: { name?: string; description?: string }) => { + const response = await api.put(`/api/v1/groups/${id}`, data); + return response.data; + }, + delete: async (id: string) => { + await api.delete(`/api/v1/groups/${id}`); + }, +}; + +export const userApi = { + list: async () => { + const response = await api.get('/api/v1/auth/users'); + return response.data; + }, + update: async (id: string, data: Partial) => { + const response = await api.put(`/api/v1/auth/users/${id}`, data); + return response.data; + } +}; + +export interface FormLayout { + _id: string; + schema_name: string; + name: string; + definition: any[]; // JSON structure + target_group_ids: string[]; + is_default: boolean; +} + +export const layoutApi = { + resolve: async (schemaName: string) => { + try { + const response = await api.get(`/api/v1/layouts/resolve/${schemaName}`); + return response.data; + } catch (error) { + console.error("Failed to resolve layout", error); + return null; + } + }, + create: async (data: Omit) => { + const response = await api.post('/api/v1/layouts/', data); + return response.data; + }, + listBySchema: async (schemaName: string) => { + const response = await api.get(`/api/v1/layouts/by-schema/${schemaName}`); + return response.data; + } +}; + export default api; diff --git a/dynaman-ui/src/pages/AdminGroups.tsx b/dynaman-ui/src/pages/AdminGroups.tsx new file mode 100644 index 0000000..83ba39f --- /dev/null +++ b/dynaman-ui/src/pages/AdminGroups.tsx @@ -0,0 +1,140 @@ +import { useState, useEffect } from 'react'; +import { groupApi, type UserGroup } from '../lib/api'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Label } from '../components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; + +export default function AdminGroups() { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Form State + const [isCreating, setIsCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [newDesc, setNewDesc] = useState(''); + + const fetchGroups = async () => { + try { + setLoading(true); + const data = await groupApi.list(); + setGroups(data); + } catch (err) { + setError('Failed to load groups'); + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchGroups(); + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await groupApi.create({ name: newName, description: newDesc }); + setNewName(''); + setNewDesc(''); + setIsCreating(false); + fetchGroups(); + } catch (err) { + alert('Failed to create group'); + console.error(err); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this group?')) return; + try { + await groupApi.delete(id); + fetchGroups(); + } catch (err) { + alert('Failed to delete group'); + console.error(err); + } + }; + + if (loading) return
Loading...
; + if (error) return
{error}
; + + return ( +
+
+

User Groups

+ +
+ + {isCreating && ( +
+
+
+ + setNewName(e.target.value)} + required + /> +
+
+ + setNewDesc(e.target.value)} + /> +
+ +
+
+ )} + + + + + Name + Description + Actions + + + + {Array.isArray(groups) && groups.length > 0 ? ( + groups.map((group) => ( + + {group.name} + {group.description} + + + + + )) + ) : ( + + + No groups found. + + + )} + +
+
+ ); +} diff --git a/dynaman-ui/src/pages/AdminUsers.tsx b/dynaman-ui/src/pages/AdminUsers.tsx index 4b3772a..f84538c 100644 --- a/dynaman-ui/src/pages/AdminUsers.tsx +++ b/dynaman-ui/src/pages/AdminUsers.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import api from '@/lib/api'; +import api, { groupApi, type UserGroup } from '@/lib/api'; import { useAuth, type User } from '@/context/AuthContext'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -16,6 +16,7 @@ import { Trash2, UserPlus } from 'lucide-react'; export const AdminUsers: React.FC = () => { const [users, setUsers] = useState([]); + const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const { user: currentUser } = useAuth(); @@ -24,11 +25,12 @@ export const AdminUsers: React.FC = () => { const [newUserEmail, setNewUserEmail] = useState(''); const [newUserPassword, setNewUserPassword] = useState(''); const [newUserRole, setNewUserRole] = useState('user'); + const [selectedGroups, setSelectedGroups] = useState([]); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); useEffect(() => { - fetchUsers(); + Promise.all([fetchUsers(), fetchGroups()]); }, []); const fetchUsers = async () => { @@ -42,6 +44,15 @@ export const AdminUsers: React.FC = () => { } }; + const fetchGroups = async () => { + try { + const data = await groupApi.list(); + setGroups(data); + } catch (err) { + console.error('Failed to fetch groups', err); + } + }; + const handleDelete = async (userId: string) => { if (!window.confirm("Are you sure you want to delete this user?")) return; try { @@ -62,7 +73,8 @@ export const AdminUsers: React.FC = () => { const payload = { email: newUserEmail, password: newUserPassword, - role: newUserRole + role: newUserRole, + group_ids: selectedGroups }; const response = await api.post('/api/v1/auth/users', payload); setUsers([...users, response.data]); @@ -71,6 +83,7 @@ export const AdminUsers: React.FC = () => { setNewUserEmail(''); setNewUserPassword(''); setNewUserRole('user'); + setSelectedGroups([]); } catch (err: any) { if (err.response?.data?.detail) { setError(err.response.data.detail); @@ -80,6 +93,14 @@ export const AdminUsers: React.FC = () => { } }; + const toggleGroup = (groupId: string) => { + setSelectedGroups(prev => + prev.includes(groupId) + ? prev.filter(id => id !== groupId) + : [...prev, groupId] + ); + }; + if (loading) return
Loading users...
; return ( @@ -117,6 +138,28 @@ export const AdminUsers: React.FC = () => { +
+ +
+ {Array.isArray(groups) && groups.map(group => ( +
+ toggleGroup(group._id)} + className="rounded border-gray-300 text-primary focus:ring-primary h-4 w-4" + /> + +
+ ))} + {(!Array.isArray(groups) || groups.length === 0) && ( +
No groups available
+ )} +
+
@@ -130,6 +173,7 @@ export const AdminUsers: React.FC = () => { Email Role + Groups Status Actions @@ -139,6 +183,12 @@ export const AdminUsers: React.FC = () => { {user.email} {user.role} + + {user.group_ids?.map((gid: string) => { + const g = groups.find(g => g._id === gid); + return g ? g.name : gid; + }).join(', ')} + {user.is_active ? 'Active' : 'Inactive'} {user.email !== currentUser?.email && ( diff --git a/dynaman-ui/src/pages/DataExplorer.tsx b/dynaman-ui/src/pages/DataExplorer.tsx index b72419b..bf4392f 100644 --- a/dynaman-ui/src/pages/DataExplorer.tsx +++ b/dynaman-ui/src/pages/DataExplorer.tsx @@ -20,7 +20,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import DataInputForm from '@/components/DataInputForm'; +import { DynamicForm } from '@/components/DynamicForm'; import { useLanguage } from '@/lib/i18n'; interface SchemaField { @@ -280,7 +280,8 @@ export default function DataExplorer() { {showDataInputForm && schema && ( - { From a40885b904d68c26be828495a0dcbb5a2c1c7a63 Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Fri, 9 Jan 2026 00:44:27 +0900 Subject: [PATCH 08/15] feat: add basic functions of dynamic form layout. --- dynaman-ui/package-lock.json | 95 ++++- dynaman-ui/package.json | 26 +- dynaman-ui/src/App.tsx | 6 + dynaman-ui/src/components/DynamicForm.tsx | 136 +++++- dynaman-ui/src/lib/api.ts | 7 + dynaman-ui/src/pages/DataExplorer.tsx | 13 + dynaman-ui/src/pages/LayoutDesigner.tsx | 400 ++++++++++++++++++ .../domain/entities/form_layout.py | 7 +- 8 files changed, 659 insertions(+), 31 deletions(-) create mode 100644 dynaman-ui/src/pages/LayoutDesigner.tsx diff --git a/dynaman-ui/package-lock.json b/dynaman-ui/package-lock.json index 55299b8..16b7a30 100644 --- a/dynaman-ui/package-lock.json +++ b/dynaman-ui/package-lock.json @@ -8,6 +8,9 @@ "name": "dynaman-ui", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-slot": "^1.1.1", "@tanstack/react-table": "^8.20.6", @@ -15,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.471.0", + "nanoid": "^5.1.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.1.1", @@ -334,6 +338,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -3467,10 +3525,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -3479,10 +3536,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -3632,6 +3689,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3823,6 +3881,25 @@ "dev": true, "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4323,6 +4400,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/dynaman-ui/package.json b/dynaman-ui/package.json index 4838c99..a3603a2 100644 --- a/dynaman-ui/package.json +++ b/dynaman-ui/package.json @@ -10,17 +10,21 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.2.0", - "react-dom": "^19.2.0", - "lucide-react": "^0.471.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-slot": "^1.1.1", "@tanstack/react-table": "^8.20.6", "axios": "^1.7.9", - "clsx": "^2.1.1", - "tailwind-merge": "^2.6.0", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.471.0", + "nanoid": "^5.1.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", "react-router-dom": "^7.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-label": "^2.1.1" + "tailwind-merge": "^2.6.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -28,15 +32,15 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.20", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4", - "tailwindcss": "^3.4.17", - "postcss": "^8.5.1", - "autoprefixer": "^10.4.20" + "vite": "^7.2.4" } } diff --git a/dynaman-ui/src/App.tsx b/dynaman-ui/src/App.tsx index 4213598..0b86070 100644 --- a/dynaman-ui/src/App.tsx +++ b/dynaman-ui/src/App.tsx @@ -9,6 +9,7 @@ import { RequireAuth } from '@/components/RequireAuth'; import { RequireSystemAdmin } from '@/components/RequireSystemAdmin'; import { AdminUsers } from '@/pages/AdminUsers'; import AdminGroups from '@/pages/AdminGroups'; +import LayoutDesigner from '@/pages/LayoutDesigner'; import './App.css'; function App() { @@ -36,6 +37,11 @@ function App() { } /> + + + + } /> } /> void +}) => { + if (!items) return null; + + return ( +
+ {items.map(item => { + if (item.type === 'field' && item.fieldName) { + return ( +
+ + onChange(item.fieldName!, e.target.value)} + placeholder={`Enter ${item.label}`} + /> +
+ ); + } + // Placeholder for structure + if (item.type === 'structure') { + return ( +
+

{item.label}

+ +
+ ) + } + return null; + })} +
+ ); +}; + export const DynamicForm: React.FC = (props) => { - const { schemaName } = props; + const { schemaName, recordId, initialData } = props; const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(true); + const [formData, setFormData] = useState({}); + const [error, setError] = useState(null); + const { t } = useLanguage(); useEffect(() => { - if (!props.isOpen) return; // Don't fetch if closed + if (!props.isOpen) return; + // Reset form data + if (initialData) { + setFormData(initialData); + } else { + setFormData({}); + } + const fetchLayout = async () => { setLoading(true); const resolvedLayout = await layoutApi.resolve(schemaName); @@ -28,23 +94,67 @@ export const DynamicForm: React.FC = (props) => { setLoading(false); }; fetchLayout(); - }, [schemaName, props.isOpen]); + }, [schemaName, props.isOpen, initialData]); - if (loading && props.isOpen) { - // Render a loading modal placeholder? Or just null? - // Existing DataInputForm renders a modal. We should probably render a loading state inside a modal structure - // but for now, let's just let it load. + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + if (recordId) { + await api.put(`/api/v1/data/${schemaName}/${recordId}`, formData); + } else { + await api.post(`/api/v1/data/${schemaName}`, formData); + } + props.onSave(); + } catch (err: any) { + console.error(err); + setError("Failed to save data"); + } finally { + setLoading(false); + } + }; + + if (loading && props.isOpen && !layout) { return
Loading layout...
; } - // TODO: In Phase 4, pass 'layout' to DataInputForm or a new renderer. - // For now, we just pass the props through. - - if (layout) { - console.log("Using Layout:", layout.name); - // We will eventually pass 'layout={layout}' to DataInputForm + // If we have a custom layout definition, use our custom renderer + if (layout && layout.definition && layout.definition.length > 0) { + return ( +
+
+
+

+ {recordId ? t('form.editTitle', { entity: schemaName }) : t('form.createTitle', { entity: schemaName })} +

+ {layout.name} +
+ + {error &&
{error}
} + +
+ setFormData((prev: any) => ({ ...prev, [field]: val }))} + /> + +
+ + +
+ +
+
+ ); } + // Fallback to default DataInputForm return ( { const response = await api.get(`/api/v1/layouts/by-schema/${schemaName}`); return response.data; + }, + update: async (id: string, data: Partial) => { + const response = await api.put(`/api/v1/layouts/${id}`, data); + return response.data; + }, + delete: async (id: string) => { + await api.delete(`/api/v1/layouts/${id}`); } }; diff --git a/dynaman-ui/src/pages/DataExplorer.tsx b/dynaman-ui/src/pages/DataExplorer.tsx index bf4392f..ea4d718 100644 --- a/dynaman-ui/src/pages/DataExplorer.tsx +++ b/dynaman-ui/src/pages/DataExplorer.tsx @@ -23,6 +23,10 @@ import { import { DynamicForm } from '@/components/DynamicForm'; import { useLanguage } from '@/lib/i18n'; +import { useAuth } from '@/context/AuthContext'; +import { Link } from 'react-router-dom'; +import { LayoutTemplate } from 'lucide-react'; + interface SchemaField { name: string; field_type: string; @@ -52,6 +56,7 @@ export default function DataExplorer() { // Search const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); + const { user } = useAuth(); // Sorting const [sorting, setSorting] = useState([]); @@ -199,6 +204,14 @@ export default function DataExplorer() { /> + {user?.role === 'system_admin' && ( + + + + )} diff --git a/dynaman-ui/src/pages/LayoutDesigner.tsx b/dynaman-ui/src/pages/LayoutDesigner.tsx new file mode 100644 index 0000000..eccc2ae --- /dev/null +++ b/dynaman-ui/src/pages/LayoutDesigner.tsx @@ -0,0 +1,400 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import api, { layoutApi, groupApi, type FormLayout, type UserGroup } from '@/lib/api'; +import { DndContext, DragOverlay, useDraggable, useDroppable, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Plus, Save, Trash2, Settings } from 'lucide-react'; +import { nanoid } from 'nanoid'; + +interface SchemaField { + name: string; + label: string; + field_type: string; +} + +interface Schema { + entity_name: string; + fields: SchemaField[]; +} + +interface LayoutItem { + id: string; + type: 'field' | 'structure'; + label: string; + // Field props + fieldName?: string; + fieldType?: string; + // Structure props + structureType?: string; + children?: LayoutItem[]; +} + +// Draggable Toolbox Item Component +function ToolboxItem({ id, label, type }: { id: string, label: string, type: string }) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: id, + data: { + type: 'field', + label, + fieldName: id.replace('field-', ''), + fieldType: type + } + }); + + const style = transform ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } : undefined; + + return ( +
+ {label} ({type}) +
+ ); +} + +// Draggable Structure Item +function ToolboxStructure({ id, label }: { id: string, label: string }) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: id, + data: { + type: 'structure', + label, + structureType: id.replace('structure-', '') + } + }); + + const style = transform ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } : undefined; + + return ( +
+ {label} +
+ ); +} + +// Render Item on Canvas +function CanvasItem({ item, onDelete }: { item: LayoutItem, onDelete: (id: string) => void }) { + if (item.type === 'field') { + return ( +
+
+ +
+
+ +
+ ); + } + + if (item.type === 'structure') { + return ( +
+
{item.label}
+ +
+ {/* Nested children would go here */} +
+
+ ); + } + return null; +} + +export default function LayoutDesigner() { + const { entity } = useParams<{ entity: string }>(); + const [schema, setSchema] = useState(null); + const [layouts, setLayouts] = useState([]); + const [groups, setGroups] = useState([]); + const [currentLayout, setCurrentLayout] = useState(null); + const [definition, setDefinition] = useState([]); // Local state for editor + const [loading, setLoading] = useState(true); + const [activeDragId, setActiveDragId] = useState(null); + const [showSettings, setShowSettings] = useState(false); // For settings modal + + // Load Schema, Layouts, and Groups + useEffect(() => { + if (!entity) return; + const fetchData = async () => { + try { + setLoading(true); + const [schemaRes, layoutsRes, groupsRes] = await Promise.all([ + api.get(`/api/v1/schemas/${entity}`), + layoutApi.listBySchema(entity), + groupApi.list() + ]); + setSchema(schemaRes.data); + setLayouts(layoutsRes); + setGroups(groupsRes); + + if (layoutsRes.length > 0) { + setCurrentLayout(layoutsRes[0]); + setDefinition(layoutsRes[0].definition || []); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [entity]); + + // Sync local definition when layout changes + useEffect(() => { + if (currentLayout) { + setDefinition(currentLayout.definition || []); + } else { + setDefinition([]); + } + }, [currentLayout]); + + const handleCreateLayout = async () => { + if (!entity) return; + const name = prompt("Enter layout name (e.g., 'Manager View'):"); + if (!name) return; + + try { + const newLayout = await layoutApi.create({ + schema_name: entity, + name, + definition: [], + target_group_ids: [], + is_default: false + }); + setLayouts([...layouts, newLayout]); + setCurrentLayout(newLayout); + } catch (err) { + alert("Failed to create layout"); + } + }; + + const handleSave = async () => { + if (!currentLayout) return; + try { + const updated = await layoutApi.update(currentLayout._id, { + definition: definition, + target_group_ids: currentLayout.target_group_ids, + is_default: currentLayout.is_default + }); + // Update local list + setLayouts(layouts.map(l => l._id === updated._id ? updated : l)); + alert("Layout saved!"); + } catch(err) { + alert("Failed to save layout"); + } + }; + + const handleDeleteLayout = async () => { + if (!currentLayout || !confirm("Are you sure you want to delete this layout?")) return; + try { + await layoutApi.delete(currentLayout._id); + const newLayouts = layouts.filter(l => l._id !== currentLayout._id); + setLayouts(newLayouts); + setCurrentLayout(newLayouts.length > 0 ? newLayouts[0] : null); + alert("Layout deleted"); + } catch (err) { + alert("Failed to delete layout"); + } + }; + + const handleDeleteItem = (id: string) => { + setDefinition(prev => prev.filter(item => item.id !== id)); + }; + + const handleDragStart = (event: DragStartEvent) => { + setActiveDragId(event.active.id as string); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragId(null); + const { active, over } = event; + + if (over && over.id === 'canvas') { + const data = active.data.current; + if (!data) return; + + const newItem: LayoutItem = { + id: nanoid(), + type: data.type, + label: data.label, + fieldName: data.fieldName, + fieldType: data.fieldType, + structureType: data.structureType, + children: [] + }; + + setDefinition(prev => [...prev, newItem]); + } + }; + + // Canvas Droppable + const Canvas = () => { + const { setNodeRef, isOver } = useDroppable({ + id: 'canvas', + }); + + return ( +
+ {!currentLayout ? ( +
Select or create a layout to start editing
+ ) : ( +
+ {definition.length === 0 && ( +
+ Drop fields here +
+ )} + {definition.map(item => ( + + ))} +
+ )} +
+ ); + }; + + if (loading) return
Loading designer...
; + if (!schema) return
Schema not found
; + + return ( + +
+ {/* Header */} +
+
+

Layout Designer: {entity}

+ + +
+
+ + + +
+
+ + {/* Settings Modal */} + {showSettings && currentLayout && ( +
+
+

Layout Settings

+ +
+
+ setCurrentLayout({...currentLayout, is_default: e.target.checked})} + className="rounded border-gray-300" + /> + +
+ +
+ +
+ {groups.map(group => ( +
+ { + const ids = currentLayout.target_group_ids; + const newIds = ids.includes(group._id) + ? ids.filter(i => i !== group._id) + : [...ids, group._id]; + setCurrentLayout({...currentLayout, target_group_ids: newIds}); + }} + className="rounded border-gray-300" + /> + +
+ ))} +
+
+
+ +
+ +
+
+
+ )} + + {/* Main Workspace */} +
+ {/* Left Sidebar: Toolbox */} + + + {/* Center: Canvas */} +
+ +
+ + {/* Right Sidebar: Properties */} + +
+ + + {activeDragId ? ( +
+ Item +
+ ) : null} +
+
+
+ ); +} diff --git a/engine/metadata_context/domain/entities/form_layout.py b/engine/metadata_context/domain/entities/form_layout.py index e9d8aab..15ffb73 100644 --- a/engine/metadata_context/domain/entities/form_layout.py +++ b/engine/metadata_context/domain/entities/form_layout.py @@ -10,10 +10,15 @@ class LayoutComponent(BaseModel): id: str type: str + label: Optional[str] = None children: List[LayoutComponent] = Field(default_factory=list) - field_name: Optional[str] = None + field_name: Optional[str] = Field(None, alias="fieldName") + field_type: Optional[str] = Field(None, alias="fieldType") + structure_type: Optional[str] = Field(None, alias="structureType") props: Dict[str, Any] = Field(default_factory=dict) + model_config = ConfigDict(populate_by_name=True) + class FormLayout(BaseModel): id: Optional[PyObjectId] = Field(alias="_id", default=None) schema_name: str = Field(..., description="The entity_name of the schema this layout applies to") From 4461e3101c7809dd08e728c7463fa192f93b9e0e Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Fri, 9 Jan 2026 01:44:40 +0900 Subject: [PATCH 09/15] fix: for type rendering in dynamic form --- dynaman-ui/src/components/DynamicForm.tsx | 42 +++++++++++++++++++---- dynaman-ui/src/pages/LayoutDesigner.tsx | 27 ++++++++++++--- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/dynaman-ui/src/components/DynamicForm.tsx b/dynaman-ui/src/components/DynamicForm.tsx index 48be920..76be6c6 100644 --- a/dynaman-ui/src/components/DynamicForm.tsx +++ b/dynaman-ui/src/components/DynamicForm.tsx @@ -45,12 +45,42 @@ const LayoutRenderer = ({ if (item.type === 'field' && item.fieldName) { return (
- - onChange(item.fieldName!, e.target.value)} - placeholder={`Enter ${item.label}`} - /> + + {item.fieldType === 'boolean' ? ( +
+ onChange(item.fieldName!, e.target.checked)} + /> + Yes +
+ ) : item.fieldType === 'number' ? ( + onChange(item.fieldName!, Number(e.target.value))} + placeholder={`Enter ${item.label}`} + /> + ) : item.fieldType === 'date' ? ( + onChange(item.fieldName!, e.target.value)} + /> + ) : ( + onChange(item.fieldName!, e.target.value)} + placeholder={`Enter ${item.label}`} + /> + )}
); } diff --git a/dynaman-ui/src/pages/LayoutDesigner.tsx b/dynaman-ui/src/pages/LayoutDesigner.tsx index eccc2ae..4b6c4cb 100644 --- a/dynaman-ui/src/pages/LayoutDesigner.tsx +++ b/dynaman-ui/src/pages/LayoutDesigner.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'; import api, { layoutApi, groupApi, type FormLayout, type UserGroup } from '@/lib/api'; import { DndContext, DragOverlay, useDraggable, useDroppable, type DragStartEvent, type DragEndEvent } from '@dnd-kit/core'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Plus, Save, Trash2, Settings } from 'lucide-react'; import { nanoid } from 'nanoid'; @@ -91,12 +92,28 @@ function ToolboxStructure({ id, label }: { id: string, label: string }) { function CanvasItem({ item, onDelete }: { item: LayoutItem, onDelete: (id: string) => void }) { if (item.type === 'field') { return ( -
-
- -
+
+
+ + {item.fieldType === 'boolean' ? ( +
+ + Checkbox +
+ ) : item.fieldType === 'number' ? ( + + ) : item.fieldType === 'date' ? ( + + ) : ( + + )}
-
From f88b3359cbf5604e3eaeba8f82e6f8f7888f629d Mon Sep 17 00:00:00 2001 From: San Lin Naing Date: Sat, 10 Jan 2026 22:07:44 +0900 Subject: [PATCH 10/15] change left side panel layout and added new schema list page for UX better. --- dynaman-ui/src/App.tsx | 8 + dynaman-ui/src/components/Layout.tsx | 181 ++++++++++++++------- dynaman-ui/src/components/RelativeTime.tsx | 57 +++++++ dynaman-ui/src/lib/i18n.tsx | 86 +++++++--- dynaman-ui/src/pages/AdminGroups.tsx | 34 ++-- dynaman-ui/src/pages/DataExplorer.tsx | 7 +- dynaman-ui/src/pages/SchemaListPage.tsx | 101 ++++++++++++ 7 files changed, 379 insertions(+), 95 deletions(-) create mode 100644 dynaman-ui/src/components/RelativeTime.tsx create mode 100644 dynaman-ui/src/pages/SchemaListPage.tsx diff --git a/dynaman-ui/src/App.tsx b/dynaman-ui/src/App.tsx index 0b86070..980bff4 100644 --- a/dynaman-ui/src/App.tsx +++ b/dynaman-ui/src/App.tsx @@ -3,6 +3,7 @@ import DashboardLayout from '@/components/Layout'; import Home from '@/pages/Home'; import DataExplorer from '@/pages/DataExplorer'; import SchemaEditor from '@/pages/SchemaEditor'; +import SchemaListPage from '@/pages/SchemaListPage'; import { Login } from '@/pages/Login'; import { AuthProvider } from '@/context/AuthContext'; import { RequireAuth } from '@/components/RequireAuth'; @@ -25,8 +26,15 @@ function App() { }> } /> + + } /> } /> + + + + } /> diff --git a/dynaman-ui/src/components/Layout.tsx b/dynaman-ui/src/components/Layout.tsx index 5dd1405..144b5ed 100644 --- a/dynaman-ui/src/components/Layout.tsx +++ b/dynaman-ui/src/components/Layout.tsx @@ -1,29 +1,60 @@ import { useEffect, useState } from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { Table as TableIcon, LayoutDashboard, Menu, PlusCircle, Globe, LogOut, Users } from 'lucide-react'; -import api from '@/lib/api'; +import { Table as TableIcon, LayoutDashboard, Menu, Globe, LogOut, Users, History, Database } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useLanguage } from '@/lib/i18n'; import { useAuth } from '@/context/AuthContext'; export default function DashboardLayout() { - const [schemas, setSchemas] = useState([]); const location = useLocation(); const navigate = useNavigate(); const { t, language, setLanguage } = useLanguage(); const { logout, user } = useAuth(); - useEffect(() => { - fetchSchemas(); - }, []); + const [recentExplore, setRecentExplore] = useState(() => { + try { + const saved = localStorage.getItem('recentExplore'); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); - const fetchSchemas = async () => { + const [recentEdit, setRecentEdit] = useState(() => { try { - const response = await api.get('/api/v1/schemas/'); - setSchemas(response.data); - } catch (error) { - console.error('Failed to fetch schemas', error); + const saved = localStorage.getItem('recentEdit'); + return saved ? JSON.parse(saved) : []; + } catch { + return []; } + }); + + useEffect(() => { + const path = location.pathname; + if (path.startsWith('/explorer/')) { + const parts = path.split('/'); + const schema = parts[2]; // /explorer/schema_name + if (schema) addToRecent(schema, 'explore'); + } else if (path.startsWith('/schemas/')) { + const parts = path.split('/'); + const schema = parts[2]; // /schemas/schema_name/edit + // specific check to avoid adding 'new' or if it's just /schemas (list) + if (schema && schema !== 'new' && parts.length > 2) { + addToRecent(schema, 'edit'); + } + } + }, [location]); + + const addToRecent = (schema: string, type: 'explore' | 'edit') => { + const setFunc = type === 'explore' ? setRecentExplore : setRecentEdit; + const storageKey = type === 'explore' ? 'recentExplore' : 'recentEdit'; + + setFunc(prev => { + // Remove if exists, add to front, take top 3 + const newRecent = [schema, ...prev.filter(s => s !== schema)].slice(0, 3); + localStorage.setItem(storageKey, JSON.stringify(newRecent)); + return newRecent; + }); }; const handleLogout = () => { @@ -31,6 +62,11 @@ export default function DashboardLayout() { navigate('/login'); }; + // Dummy refresh function to satisfy potential outlet context consumers + const refreshSchemas = async () => { + // No longer needed for sidebar, but kept for interface compatibility + }; + const isAdmin = user?.role === 'system_admin' || user?.role === 'user_admin'; const isSystemAdmin = user?.role === 'system_admin'; @@ -44,7 +80,7 @@ export default function DashboardLayout() {
{t('app.title')}
-