commit 5993c15b7079d63ddae49770d397c32a8aee71f6 Author: Azmat-7860 Date: Sun Oct 25 23:11:19 2026 +0530 adding base project diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc0fc67 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Application Settings +PROJECT_NAME="AuthSec FastAPI" +API_V1_STR="/api" +PORT=9119 +DEBUG=True + +# Security +SECRET_KEY="cns-secret-key-change-this-in-production-authsec" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# Database Configuration +# Default is SQLite for zero-setup local development: +DATABASE_URL="sqlite:///./authsec.db" + +# Or use MySQL (e.g. for RDS / Local MySQL): +# DATABASE_URL="mysql+pymysql://cnsdev:cnsdev1234@localhost:3306/authsec" + +# CORS Allowed Origins (Comma separated or * for all) +CORS_ORIGINS="http://localhost:4200,http://localhost:3000,http://127.0.0.1:4200,http://localhost:8080,*" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eefaf3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# Database files +*.sqlite +*.sqlite3 +*.db + +# Environment variables +.env + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# OS specific +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..e42f15d --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# AuthSec FastAPI - Base Project for Python + +A production-ready, modular **FastAPI** backend that serves as a direct drop-in alternative to the Spring Boot base project (`authsec-be`). + +It provides **identical API contracts and endpoint paths** so that frontends like **Angular Clarity** or **React** can communicate with it without modifying any frontend code. + +--- + +## 🌟 Key Features + +- **100% Endpoint Parity with Spring Boot**: + - `POST /token/session` - User login and JWT session generation + - `GET /token/logout` - User logout + - `GET /token/user` - Currently authenticated user details + - `GET /fndMenu/menuloadbyuser` - Hierarchical role-based menu tree for Angular Clarity sidebar + - `GET`, `POST`, `PUT`, `DELETE` for `/{{TABLE}}` and `/{{TABLE_LOWER}}` - Entity CRUD endpoints +- **Zero-Setup Database**: Runs on **SQLite** out of the box with automatic table creation & seeding on startup. Switchable to MySQL or PostgreSQL by simply editing `DATABASE_URL` in `.env`. +- **Pre-Seeded Accounts**: Comes with a default administrator account: + - **Email**: `admin@cns.com` + - **Password**: `admin123` +- **Dynamic Entity Router**: Any generated entity controller placed in `app/io8ref/*/controllers.py` is automatically registered upon startup. +- **Interactive Swagger UI**: Full interactive API documentation available at `http://localhost:9119/docs`. + +--- + +## 🚀 Quick Start (First Time on Windows) + +### Step 1: Install Python +If you don't have Python installed on your machine: +1. Download Python 3.11 or 3.12 from [python.org/downloads](https://www.python.org/downloads/). +2. Run the installer. +3. ⚠️ **IMPORTANT**: Check the box **"Add python.exe to PATH"** at the bottom of the first installer screen before clicking *Install Now*. + +### Step 2: Open PowerShell in Project Directory +Open PowerShell in `d:\WAP\Codeextractor\authsec-fastapi`: +```powershell +cd d:\WAP\Codeextractor\authsec-fastapi +``` + +### Step 3: Create and Activate Virtual Environment +```powershell +# Create a virtual environment named venv +python -m venv venv + +# Activate it +.\venv\Scripts\Activate.ps1 +``` +*(If PowerShell shows an execution policy error, run `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass` and then run the activate command again).* + +### Step 4: Install Dependencies +```powershell +pip install -r requirements.txt +``` + +### Step 5: Run the Server +```powershell +python run.py +``` + +The server will start on: +- **API URL**: `http://localhost:9119` +- **Interactive Swagger Documentation**: `http://localhost:9119/docs` + +--- + +## 📡 API Contract Reference + +### 1. Authentication +- **`POST /token/session`** (or `POST /token/generate-token`) + - **Request Body**: + ```json + { + "email": "admin@cns.com", + "password": "admin123" + } + ``` + - **Response Payload**: + ```json + { + "operationStatus": "SUCCESS", + "operationMessage": "Login Success", + "item": { + "userId": 1, + "email": "admin@cns.com", + "fullname": "Admin User", + "firstName": "Admin", + "username": "admin", + "roles": ["Admin"], + "token": "eyJhbG..." + } + } + ``` +- **`GET /token/logout`** +- **`GET /token/user`** (with `Authorization: Bearer `) + +### 2. Menu Navigation +- **`GET /fndMenu/menuloadbyuser`** + - Returns root navigation items with their respective sub-menus, action paths, and icons formatted for the Angular layout. + +### 3. Entity CRUD Operations +Every entity (such as reference entity `Azzu`) provides both uppercase and lowercase endpoints: +- `POST /Azzu` or `POST /azzu` - Add new record +- `GET /Azzu` or `GET /azzu` - Get all records +- `GET /Azzu/{id}` or `GET /azzu/{id}` - Get record by ID +- `PUT /Azzu/{id}` or `PUT /azzu/{id}` - Update record by ID +- `DELETE /Azzu/{id}` or `DELETE /azzu/{id}` - Delete record by ID + +--- + +## 🛠️ Code Extractor V2 Integration + +The pack configuration file is located at [`pack-fastapi-v1.0.json`](./pack-fastapi-v1.0.json). + +When Code Extractor generates a new entity (e.g. `Customer`), it produces: +1. `app/io8ref/Customer/models.py` - SQLAlchemy entity model +2. `app/io8ref/Customer/schemas.py` - Pydantic DTO schema +3. `app/io8ref/Customer/services.py` - Business logic and database operations +4. `app/io8ref/Customer/controllers.py` - REST API routes + +Because `app/main.py` has automatic discovery enabled, the new entity endpoints are instantly active without needing any manual code registration. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..dc533d0 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +""" +AuthSec FastAPI Application Package +""" diff --git a/app/auth/models.py b/app/auth/models.py new file mode 100644 index 0000000..c2f94c2 --- /dev/null +++ b/app/auth/models.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Table +from sqlalchemy.orm import relationship +from datetime import datetime +from app.core.database import Base + +# Association table for User <-> Role (many to many) +user_roles = Table( + "app_user_roles", + Base.metadata, + Column("user_id", Integer, ForeignKey("app_users.user_id"), primary_key=True), + Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True) +) + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(100), unique=True, index=True, nullable=False) + description = Column(String(255), nullable=True) + +class AppUser(Base): + __tablename__ = "app_users" + + user_id = Column(Integer, primary_key=True, index=True, autoincrement=True) + email = Column(String(255), unique=True, index=True, nullable=False) + username = Column(String(100), unique=True, index=True, nullable=False) + password = Column(String(255), nullable=True) # Can be null before initial verification + full_name = Column(String(255), nullable=True) + first_name = Column(String(100), nullable=True) + last_name = Column(String(100), nullable=True) + status = Column(String(50), default="ACTIVE") + user_grp_id = Column(Integer, default=1) + is_active = Column(Boolean, default=True) + random_no = Column(String(50), nullable=True) # OTP storage matching Spring Boot + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + roles = relationship("Role", secondary=user_roles, lazy="joined") diff --git a/app/auth/router.py b/app/auth/router.py new file mode 100644 index 0000000..9022088 --- /dev/null +++ b/app/auth/router.py @@ -0,0 +1,291 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request, Query, Body +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.core.deps import get_db, require_current_user, get_current_user_token +from app.core.security import verify_password, get_password_hash, create_access_token +from app.auth.models import AppUser, Role +from app.auth.schemas import ( + LoginRequest, + SessionResponse, + SessionItem, + RegistrationRequest, + SendEmailRequest, + EmailExistsRequest, + UserResponse +) + +router = APIRouter(prefix="/token", tags=["Authentication & User Session"]) + +@router.post("/session", response_model=SessionResponse) +def login_session(login_req: LoginRequest, db: Session = Depends(get_db)): + """ + Authenticates user and returns the exact SessionResponse expected by Angular Clarity login.service.ts. + """ + user = db.query(AppUser).filter(AppUser.email == login_req.email).first() + if not user or not verify_password(login_req.password, user.password): + return SessionResponse( + operationStatus="ERROR", + operationMessage="Login Failed", + item=None + ) + + # Collect role descriptions / names + roles = [r.description or r.name for r in user.roles] if user.roles else ["Admin"] + + # Generate JWT + token = create_access_token( + subject=user.user_id, + extra_claims={ + "email": user.email, + "username": user.username, + "roles": roles, + "user_grp": user.user_grp_id + } + ) + + session_item = SessionItem( + userId=user.user_id, + email=user.email, + fullname=user.full_name or user.username, + firstName=user.first_name or user.full_name or user.username, + username=user.username, + roles=roles, + token=token + ) + + return SessionResponse( + operationStatus="SUCCESS", + operationMessage="Login Success", + item=session_item + ) + +@router.post("/generate-token", response_model=SessionResponse) +def generate_token(login_req: LoginRequest, db: Session = Depends(get_db)): + """Alias for /token/session.""" + return login_session(login_req, db) + +@router.get("/logout") +@router.post("/logout") +def logout_user(): + """Logs out the user.""" + return {"operationStatus": "SUCCESS", "operationMessage": "Logged out successfully"} + +@router.get("/user") +def get_current_logged_in_user( + db: Session = Depends(get_db), + token_payload: dict = Depends(require_current_user) +): + """Returns current user details based on the JWT token.""" + user_id = token_payload.get("user_id") or token_payload.get("sub") + user = db.query(AppUser).filter(AppUser.user_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + roles = [r.description or r.name for r in user.roles] if user.roles else ["Admin"] + return { + "userId": user.user_id, + "email": user.email, + "username": user.username, + "fullname": user.full_name, + "roles": roles, + "status": user.status + } + +@router.get("/getuser/{user_id}") +def get_user_by_id(user_id: int, db: Session = Depends(get_db)): + """Returns user by ID.""" + user = db.query(AppUser).filter(AppUser.user_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + roles = [r.description or r.name for r in user.roles] if user.roles else ["Admin"] + return { + "userId": user.user_id, + "email": user.email, + "username": user.username, + "fullname": user.full_name, + "roles": roles + } + +@router.post("/email-exists") +def check_email_exists(req: EmailExistsRequest, db: Session = Depends(get_db)): + """Checks if email already exists.""" + user = db.query(AppUser).filter(AppUser.email == req.email).first() + return {"exists": user is not None} + +@router.post("/users/sysaccount/savesysaccount") +@router.post("/savesysaccount") +def save_sys_account(data: dict = Body(...)): + """ + Saves sysaccount during Angular user signup (mirrors Spring Boot SysAccountController /savesysaccount). + """ + account_id = data.get("account_id") or 1 + res = dict(data) + res["account_id"] = account_id + res["mongoid"] = f"acc_{account_id}" + return res + +@router.post("/addOneAppUser") +def register_user(reg: RegistrationRequest, db: Session = Depends(get_db)): + """ + Registers a new user or activates a pending user created during OTP step. + Mirrors Spring Boot SessionController /addOneAppUser. + """ + email = reg.email.strip() + user = db.query(AppUser).filter(AppUser.email == email).first() + + raw_pwd = reg.new_password or reg.password + if not raw_pwd: + raise HTTPException(status_code=400, detail="Password is required") + + hashed_pwd = get_password_hash(raw_pwd) + first_name = reg.first_name or reg.fullName or email.split("@")[0] + last_name = reg.last_name or "" + full_name = reg.fullName or f"{first_name} {last_name}".strip() + username = reg.username or email.split("@")[0] + + if user: + if user.status == "ACTIVE" and user.password: + raise HTTPException(status_code=400, detail="Email already exists") + # Activate pending user + user.password = hashed_pwd + user.first_name = first_name + user.last_name = last_name + user.full_name = full_name + user.username = username + user.status = "ACTIVE" + user.is_active = True + else: + user = AppUser( + email=email, + username=username, + password=hashed_pwd, + full_name=full_name, + first_name=first_name, + last_name=last_name, + status="ACTIVE", + user_grp_id=1, + is_active=True + ) + db.add(user) + + # Assign Admin role + admin_role = db.query(Role).filter(Role.name == "ROLE_ADMIN").first() + if admin_role and admin_role not in user.roles: + user.roles.append(admin_role) + + db.commit() + db.refresh(user) + + return { + "userId": user.user_id, + "email": user.email, + "username": user.username, + "fullName": user.full_name, + "firstName": user.first_name, + "roles": [r.description or r.name for r in user.roles], + "message": "User registered successfully" + } + +@router.post("/user/send_email") +def send_email_for_otp(req: SendEmailRequest, db: Session = Depends(get_db)): + """ + Sends OTP for email verification (mirrors Spring Boot SessionController /token/user/send_email). + """ + import random + from app.core.email import email_service + + email = req.email.strip() + user = db.query(AppUser).filter(AppUser.email == email).first() + if user and user.status == "ACTIVE" and user.password: # Already registered active user + raise HTTPException(status_code=400, detail=f"{email} already exists") + + otp = str(random.randint(100000, 999999)) + if not user: + username = req.username or email.split("@")[0] + user = AppUser( + email=email, + username=username, + password="", # Empty string satisfies NOT NULL constraint until password is set + full_name=req.fullName or username, + first_name=req.fullName or username, + status="PENDING", + random_no=otp, + is_active=False + ) + db.add(user) + else: + user.random_no = otp + db.commit() + + email_service.adduserviaadmin(email, "Email Verification", otp) + return { + "operationStatus": "SUCCESS", + "operationMessage": "Otp send successfully", + "msg": "Otp send successfully", + "otp": otp + } + +@router.post("/user/resend_otp") +def resend_otp( + email: Optional[str] = Query(None), + db: Session = Depends(get_db), + body: Optional[SendEmailRequest] = None +): + """ + Resends OTP for email verification (mirrors Spring Boot SessionController /token/user/resend_otp). + Supports query parameter (?email=...) or JSON body. + """ + import random + from app.core.email import email_service + + target_email = email or (body.email if body else None) + if not target_email: + raise HTTPException(status_code=400, detail="Email is required") + + target_email = target_email.strip() + user = db.query(AppUser).filter(AppUser.email == target_email).first() + if not user: + raise HTTPException(status_code=400, detail=f"{target_email} does not exist") + + otp = str(random.randint(100000, 999999)) + user.random_no = otp + db.commit() + + email_service.adduserviaadmin(target_email, "Email Verification - Resend OTP", otp) + return { + "operationStatus": "SUCCESS", + "operationMessage": "resend Otp send successfully", + "msg": "resend Otp send successfully", + "otp": otp + } + +@router.post("/user/otp_verification") +def otp_verification( + email: str = Query(...), + otp: str = Query(...), + db: Session = Depends(get_db) +): + """ + Verifies OTP entered by user (mirrors Spring Boot SessionController /token/user/otp_verification). + Query parameters: ?email=...&otp=... + """ + email_clean = email.replace(" ", "+").strip() + user = db.query(AppUser).filter(AppUser.email == email_clean).first() + if not user: + raise HTTPException(status_code=400, detail="User not found") + + if user.random_no and user.random_no.strip() == otp.strip(): + return { + "operationStatus": "SUCCESS", + "operationMessage": "OTP Verified", + "msg": "OTP Verified" + } + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Wrong OTP" + ) + + diff --git a/app/auth/schemas.py b/app/auth/schemas.py new file mode 100644 index 0000000..07bbeaa --- /dev/null +++ b/app/auth/schemas.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional, List, Any + +class LoginRequest(BaseModel): + email: str + password: str + +class SessionItem(BaseModel): + userId: int + email: str + fullname: Optional[str] = "" + firstName: Optional[str] = "" + username: Optional[str] = "" + roles: List[str] = [] + token: str + +class SessionResponse(BaseModel): + operationStatus: str # "SUCCESS" or "ERROR" + operationMessage: str # "Login Success" or "Login Failed" + item: Optional[SessionItem] = None + +class RegistrationRequest(BaseModel): + email: str + password: Optional[str] = None + new_password: Optional[str] = None + confirm_password: Optional[str] = None + fullName: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + username: Optional[str] = None + account_id: Optional[Any] = None + mob_no: Optional[Any] = None + gender: Optional[str] = None + date_of_birth: Optional[str] = None + how_you_know_aboutus: Optional[str] = None + accesstype: Optional[str] = None + +class SendEmailRequest(BaseModel): + email: str + password: Optional[str] = None + fullName: Optional[str] = None + username: Optional[str] = None + account_id: Optional[Any] = None + recaptcha: Optional[Any] = None + +class EmailExistsRequest(BaseModel): + email: str + +class UserResponse(BaseModel): + userId: int + email: str + username: str + fullName: Optional[str] = None + firstName: Optional[str] = None + roles: List[str] = [] + status: Optional[str] = "ACTIVE" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..5b4ef26 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,41 @@ +import os +from typing import List, Union +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + PROJECT_NAME: str = "AuthSec FastAPI" + API_V1_STR: str = "/api" + PORT: int = 9119 + DEBUG: bool = True + + # Security + SECRET_KEY: str = "cns-secret-key-change-this-in-production-authsec" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 hours + + # Database + DATABASE_URL: str = "sqlite:///./authsec.db" + + # CORS + CORS_ORIGINS: str = "http://localhost:4200,http://localhost:3000,http://127.0.0.1:4200,http://localhost:8080,*" + + # Email / SMTP Settings (mirrors Spring Boot JavaMailSender) + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_TLS: bool = True + EMAILS_FROM_EMAIL: str = "noreply@authsec.com" + + @property + def cors_origins_list(self) -> List[str]: + if not self.CORS_ORIGINS: + return ["*"] + return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()] + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = True + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..b3ab681 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,56 @@ +import re +from urllib.parse import urlparse +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +def ensure_database_exists(db_url: str): + """ + If using MySQL, automatically creates the target database if it does not exist + (mirrors spring.datasource.url createDatabaseIfNotExist=true). + """ + if db_url.startswith("mysql"): + try: + import pymysql + # Parse connection URL safely + clean_url = db_url.replace("mysql+pymysql://", "http://").replace("mysql://", "http://") + parsed = urlparse(clean_url) + db_name = parsed.path.lstrip("/").split("?")[0] + if db_name: + conn = pymysql.connect( + host=parsed.hostname, + port=parsed.port or 3306, + user=parsed.username, + password=parsed.password, + connect_timeout=5 + ) + with conn.cursor() as cur: + cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;") + conn.close() + except Exception as e: + print(f"Note on DB creation: {e}") + +ensure_database_exists(settings.DATABASE_URL) + +connect_args = {} +if settings.DATABASE_URL.startswith("sqlite"): + connect_args = {"check_same_thread": False} + +engine = create_engine( + settings.DATABASE_URL, + connect_args=connect_args, + pool_pre_ping=True +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + """Dependency for obtaining database session per request.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..c37def4 --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,34 @@ +from typing import Generator, Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from app.core.database import SessionLocal +from app.core.security import decode_access_token + +security = HTTPBearer(auto_error=False) + +def get_db() -> Generator: + """Dependency that yields a database session and ensures closure.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + +def get_current_user_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]: + """Extracts and verifies JWT token payload from Authorization header.""" + if not credentials: + return None + token = credentials.credentials + payload = decode_access_token(token) + return payload + +def require_current_user(payload: Optional[dict] = Depends(get_current_user_token)) -> dict: + """Ensures request is authenticated with a valid token.""" + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials or token expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + return payload diff --git a/app/core/email.py b/app/core/email.py new file mode 100644 index 0000000..c5d5947 --- /dev/null +++ b/app/core/email.py @@ -0,0 +1,98 @@ +import smtplib +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Optional, List +from app.core.config import settings + +logger = logging.getLogger(__name__) + +class EmailService: + """ + Email Service mirroring Spring Boot's JavaMailSender / EmailService. + Supports SMTP (Gmail, SendGrid, AWS SES, etc.). + If SMTP is not configured, logs the message to console (Dev Mode) so flows never crash. + """ + + def __init__(self): + self.host = getattr(settings, "SMTP_HOST", "") + self.port = getattr(settings, "SMTP_PORT", 587) + self.user = getattr(settings, "SMTP_USER", "") + self.password = getattr(settings, "SMTP_PASSWORD", "") + self.tls = getattr(settings, "SMTP_TLS", True) + self.from_email = getattr(settings, "EMAILS_FROM_EMAIL", "noreply@authsec.com") + + def is_configured(self) -> bool: + """Returns True if valid SMTP credentials are provided.""" + return bool(self.host and self.user and self.password) + + def send_simple_message(self, to: str, subject: str, text: str, from_email: Optional[str] = None) -> bool: + """Sends a plain text email (mirrors Spring Boot EmailService.sendSimpleMessage).""" + sender = from_email or self.from_email + + if not self.is_configured(): + print(f"\n==================== [DEV EMAIL SERVICE] ====================") + print(f"To: {to}") + print(f"From: {sender}") + print(f"Subject: {subject}") + print(f"Body:\n{text}") + print(f"=============================================================\n") + logger.info(f"[DEV EMAIL] Sent simulated email to {to}: {subject}") + return True + + try: + msg = MIMEMultipart() + msg["From"] = sender + msg["To"] = to + msg["Subject"] = subject + msg.attach(MIMEText(text, "plain")) + + with smtplib.SMTP(self.host, self.port) as server: + if self.tls: + server.starttls() + server.login(self.user, self.password) + server.send_message(msg) + + logger.info(f"Email successfully sent to {to}") + return True + except Exception as e: + logger.error(f"Failed to send email to {to}: {e}") + print(f"❌ Email sending error: {e}") + return False + + def send_html_message(self, to: str, subject: str, html_content: str, from_email: Optional[str] = None) -> bool: + """Sends an HTML formatted email.""" + sender = from_email or self.from_email + + if not self.is_configured(): + print(f"\n==================== [DEV EMAIL SERVICE (HTML)] ====================") + print(f"To: {to}") + print(f"Subject: {subject}") + print(f"HTML Body:\n{html_content}") + print(f"===================================================================\n") + return True + + try: + msg = MIMEMultipart("alternative") + msg["From"] = sender + msg["To"] = to + msg["Subject"] = subject + msg.attach(MIMEText(html_content, "html")) + + with smtplib.SMTP(self.host, self.port) as server: + if self.tls: + server.starttls() + server.login(self.user, self.password) + server.send_message(msg) + + return True + except Exception as e: + logger.error(f"Failed to send HTML email to {to}: {e}") + return False + + def adduserviaadmin(self, to: str, subject: str, otp_or_url: str): + """Mirrors Spring Boot EmailService.adduserviaadmin.""" + body = f"Your verification code is: {otp_or_url}" + return self.send_simple_message(to, subject, body) + +email_service = EmailService() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..5caa79d --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,58 @@ +import hashlib +import secrets +from datetime import datetime, timedelta +from typing import Optional, Any, Union +import jwt +from app.core.config import settings + +def get_password_hash(password: str) -> str: + """ + Hashes a password with PBKDF2-HMAC-SHA256 using Python's standard library. + Requires NO C compilers or external dependencies. + """ + salt = secrets.token_hex(16) + key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000) + return f"pbkdf2:sha256:100000${salt}${key.hex()}" + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verifies a plain password against the stored hash.""" + if not hashed_password: + return False + if "$" not in hashed_password: + # Fallback comparison for plain text + return plain_password == hashed_password + try: + parts = hashed_password.split("$") + if len(parts) == 3 and parts[0].startswith("pbkdf2:"): + _, salt, key_hex = parts + check_key = hashlib.pbkdf2_hmac("sha256", plain_password.encode("utf-8"), salt.encode("utf-8"), 100000) + return secrets.compare_digest(check_key.hex(), key_hex) + return plain_password == hashed_password + except Exception: + return False + +def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None, extra_claims: Optional[dict] = None) -> str: + """Creates a signed JWT token.""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = { + "exp": expire, + "sub": str(subject), + "user_id": subject + } + if extra_claims: + to_encode.update(extra_claims) + + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str) -> Optional[dict]: + """Decodes a JWT token.""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except Exception: + return None diff --git a/app/io8ref/Azzu/__init__.py b/app/io8ref/Azzu/__init__.py new file mode 100644 index 0000000..f6c02ad --- /dev/null +++ b/app/io8ref/Azzu/__init__.py @@ -0,0 +1,3 @@ +""" +Azzu Reference Module +""" diff --git a/app/io8ref/Azzu/controllers.py b/app/io8ref/Azzu/controllers.py new file mode 100644 index 0000000..3924e1a --- /dev/null +++ b/app/io8ref/Azzu/controllers.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from typing import List, Dict, Any + +from app.core.deps import get_db +from app.io8ref.Azzu.services import Service + +# Create two routers or one with route aliases +router = APIRouter(tags=["Azzu Entity CRUD"]) + +# add data +@router.post("/Azzu") +@router.post("/azzu") +def Savedata(data: Dict[str, Any], db: Session = Depends(get_db)): + saved = Service.Savedata(db, data) + return saved + +# get all +@router.get("/Azzu") +@router.get("/azzu") +def getdetails(db: Session = Depends(get_db)): + items = Service.getdetails(db) + return items + +# getby id +@router.get("/Azzu/{id}") +@router.get("/azzu/{id}") +def getdetailsbyId(id: int, db: Session = Depends(get_db)): + item = Service.getdetailsbyId(db, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item + +# update by id +@router.put("/Azzu/{id}") +@router.put("/azzu/{id}") +def update(data: Dict[str, Any], id: int, db: Session = Depends(get_db)): + updated = Service.update(db, data, id) + if not updated: + raise HTTPException(status_code=404, detail="Item not found") + return updated + +# delete by id +@router.delete("/Azzu/{id}") +@router.delete("/azzu/{id}") +def delete_by_id(id: int, db: Session = Depends(get_db)): + success = Service.delete_by_id(db, id) + if not success: + raise HTTPException(status_code=404, detail="Item not found") + return JSONResponse(status_code=status.HTTP_200_OK, content={"message": "Deleted successfully"}) diff --git a/app/io8ref/Azzu/models.py b/app/io8ref/Azzu/models.py new file mode 100644 index 0000000..b288b25 --- /dev/null +++ b/app/io8ref/Azzu/models.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, Text, Float, Date, DateTime, Boolean +from datetime import datetime +from app.core.database import Base + +class Azzu(Base): + __tablename__ = "azzu" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + # Reference demo fields + name = Column(String(255), nullable=True) + description = Column(Text, nullable=True) + status = Column(String(100), default="ACTIVE") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/app/io8ref/Azzu/schemas.py b/app/io8ref/Azzu/schemas.py new file mode 100644 index 0000000..a98895c --- /dev/null +++ b/app/io8ref/Azzu/schemas.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime, date + +class AzzuBase(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = "ACTIVE" + +class AzzuCreate(AzzuBase): + pass + +class AzzuUpdate(AzzuBase): + pass + +class AzzuResponse(AzzuBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True diff --git a/app/io8ref/Azzu/services.py b/app/io8ref/Azzu/services.py new file mode 100644 index 0000000..e9697c9 --- /dev/null +++ b/app/io8ref/Azzu/services.py @@ -0,0 +1,46 @@ +from sqlalchemy.orm import Session +from typing import List, Optional +from app.io8ref.Azzu.models import Azzu +from app.io8ref.Azzu.schemas import AzzuCreate, AzzuUpdate + +class AzzuService: + def Savedata(self, db: Session, data: dict) -> Azzu: + """Saves a new Azzu record.""" + # Filter out id if present + data_dict = {k: v for k, v in data.items() if k != "id" and hasattr(Azzu, k)} + obj = Azzu(**data_dict) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + def getdetails(self, db: Session) -> List[Azzu]: + """Returns all Azzu records.""" + return db.query(Azzu).all() + + def getdetailsbyId(self, db: Session, id_val: int) -> Optional[Azzu]: + """Returns an Azzu record by ID.""" + return db.query(Azzu).filter(Azzu.id == id_val).first() + + def update(self, db: Session, data: dict, id_val: int) -> Optional[Azzu]: + """Updates an Azzu record by ID.""" + obj = self.getdetailsbyId(db, id_val) + if not obj: + return None + for key, value in data.items(): + if key != "id" and hasattr(obj, key): + setattr(obj, key, value) + db.commit() + db.refresh(obj) + return obj + + def delete_by_id(self, db: Session, id_val: int) -> bool: + """Deletes an Azzu record by ID.""" + obj = self.getdetailsbyId(db, id_val) + if obj: + db.delete(obj) + db.commit() + return True + return False + +Service = AzzuService() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ff99a9a --- /dev/null +++ b/app/main.py @@ -0,0 +1,156 @@ +import os +import importlib +import pkgutil +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import Base, engine, SessionLocal +from app.core.security import get_password_hash +from app.auth.models import AppUser, Role +from app.menu.models import GrpMenuAccess + +from app.auth.router import router as auth_router +from app.menu.router import router as menu_router + +def init_db_and_seed(): + """Creates database tables and seeds initial administrator & menu access.""" + Base.metadata.create_all(bind=engine) + + # Auto-migration safeguard for existing databases + try: + from sqlalchemy import text + with engine.connect() as conn: + conn.execute(text("ALTER TABLE app_users ADD COLUMN random_no VARCHAR(50);")) + conn.commit() + except Exception: + pass + + db: Session = SessionLocal() + try: + # 1. Seed Roles + admin_role = db.query(Role).filter(Role.name == "ROLE_ADMIN").first() + if not admin_role: + admin_role = Role(name="ROLE_ADMIN", description="Admin") + db.add(admin_role) + + user_role = db.query(Role).filter(Role.name == "ROLE_USER").first() + if not user_role: + user_role = Role(name="ROLE_USER", description="User") + db.add(user_role) + db.commit() + + # 2. Seed Admin User + admin_user = db.query(AppUser).filter(AppUser.email == "admin@cns.com").first() + if not admin_user: + admin_user = AppUser( + email="admin@cns.com", + username="admin", + password=get_password_hash("admin123"), + full_name="Admin User", + first_name="Admin", + last_name="User", + status="ACTIVE", + user_grp_id=1, + is_active=True + ) + admin_user.roles.append(admin_role) + db.add(admin_user) + db.commit() + + # 3. Seed Default Menu Items + root_menu = db.query(GrpMenuAccess).filter(GrpMenuAccess.menu_id == 0, GrpMenuAccess.menu_item_id == 1).first() + if not root_menu: + root_menu = GrpMenuAccess( + menu_id=0, + menu_item_id=1, + menu_item_desc="Core Modules", + module_name="Core", + main_menu_action_name="/cns-portal", + main_menu_icon_name="cog", + item_seq=1, + status="A", + role="Admin", + usr_grp=1 + ) + db.add(root_menu) + db.commit() + + # Submenu pointing to Azzu entity + sub_menu = GrpMenuAccess( + menu_id=1, + menu_item_id=2, + menu_item_desc="Azzu Management", + module_name="Core", + main_menu_action_name="/cns-portal/azzu", + main_menu_icon_name="file", + item_seq=1, + status="A", + role="Admin", + usr_grp=1 + ) + db.add(sub_menu) + db.commit() + + finally: + db.close() + +def auto_discover_entity_routers(app: FastAPI): + """ + Automatically discovers and includes any entity controllers located under app/io8ref/*/controllers.py + """ + io8ref_dir = os.path.join(os.path.dirname(__file__), "io8ref") + if not os.path.exists(io8ref_dir): + return + + for item in os.listdir(io8ref_dir): + entity_dir = os.path.join(io8ref_dir, item) + if os.path.isdir(entity_dir) and os.path.exists(os.path.join(entity_dir, "controllers.py")): + try: + module = importlib.import_module(f"app.io8ref.{item}.controllers") + if hasattr(module, "router"): + app.include_router(module.router) + print(f"✓ Registered Entity Router: {item}") + except Exception as e: + print(f"✗ Failed to load entity router {item}: {e}") + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: + init_db_and_seed() + yield + # Shutdown + +app = FastAPI( + title=settings.PROJECT_NAME, + description="Drop-in Python FastAPI Base Project compatible with Angular Clarity, React, and Code Extractor V2", + version="1.0.0", + lifespan=lifespan +) + +# CORS Middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Core Routers +app.include_router(auth_router) +app.include_router(menu_router) + +# Dynamically Register Generated Entities +auto_discover_entity_routers(app) + +@app.get("/", tags=["Health"]) +@app.get("/health", tags=["Health"]) +def health_check(): + return { + "status": "UP", + "service": settings.PROJECT_NAME, + "docs_url": "/docs" + } diff --git a/app/menu/models.py b/app/menu/models.py new file mode 100644 index 0000000..cb7bb8e --- /dev/null +++ b/app/menu/models.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from datetime import datetime +from app.core.database import Base + +class GrpMenuAccess(Base): + __tablename__ = "grp_menu_access" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + menu_id = Column(Integer, default=0, index=True) # 0 represents root menu + menu_item_id = Column(Integer, default=1, index=True) + menu_item_desc = Column(String(255), nullable=True) + module_name = Column(String(255), nullable=True) + main_menu_action_name = Column(String(255), nullable=True) # Angular route path, e.g. /cns-portal/azzu + main_menu_icon_name = Column(String(255), nullable=True) # Icon name, e.g. vm-bug or cog + item_seq = Column(Integer, default=1) + status = Column(String(50), default="A") + role = Column(String(100), default="Admin") + usr_grp = Column(Integer, default=1) + m_create = Column(String(50), default="Y") + m_edit = Column(String(50), default="Y") + m_query = Column(String(50), default="Y") + m_delete = Column(String(50), default="Y") + m_export = Column(String(50), default="Y") + m_visible = Column(String(50), default="Y") + is_disable = Column(String(50), default="N") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/app/menu/router.py b/app/menu/router.py new file mode 100644 index 0000000..6ea9d22 --- /dev/null +++ b/app/menu/router.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Any, Optional + +from app.core.deps import get_db, get_current_user_token +from app.menu.models import GrpMenuAccess + +router = APIRouter(tags=["Menu & Navigation"]) + +@router.get("/fndMenu/menuloadbyuser") +@router.get("/MenuAccess/set_menu_for_user") +def menu_load_by_user( + db: Session = Depends(get_db), + token_payload: Optional[dict] = Depends(get_current_user_token) +): + """ + Returns menu hierarchy matching Spring Boot's LoadMenuController /menuloadbyuser. + Fetches all root menu items (menu_id = 0) and attaches their corresponding submenus. + """ + usr_grp = 1 + if token_payload: + usr_grp = token_payload.get("user_grp", 1) + + # 1. Fetch root menus (menu_id == 0) + roots = db.query(GrpMenuAccess).filter( + GrpMenuAccess.menu_id == 0, + GrpMenuAccess.usr_grp == usr_grp, + GrpMenuAccess.status == "A" + ).order_by(GrpMenuAccess.item_seq).all() + + response_list = [] + for r in roots: + # 2. Fetch submenus for each root + submenus = db.query(GrpMenuAccess).filter( + GrpMenuAccess.menu_id == r.menu_item_id, + GrpMenuAccess.usr_grp == usr_grp, + GrpMenuAccess.status == "A" + ).order_by(GrpMenuAccess.item_seq).all() + + sub_list = [] + for s in submenus: + sub_list.append({ + "id": s.id, + "menuId": s.menu_id, + "menuItemId": s.menu_item_id, + "menuItemDesc": s.menu_item_desc, + "moduleName": s.module_name, + "main_menu_action_name": s.main_menu_action_name, + "main_menu_icon_name": s.main_menu_icon_name, + "itemSeq": s.item_seq, + "status": s.status, + "role": s.role, + "mCreate": s.m_create, + "mEdit": s.m_edit, + "mQuery": s.m_query, + "mDelete": s.m_delete, + "mexport": s.m_export, + "mVisible": s.m_visible + }) + + response_list.append({ + "id": r.id, + "menuId": r.menu_id, + "menuItemId": r.menu_item_id, + "menuItemDesc": r.menu_item_desc, + "moduleName": r.module_name, + "main_menu_action_name": r.main_menu_action_name, + "main_menu_icon_name": r.main_menu_icon_name, + "itemSeq": r.item_seq, + "status": r.status, + "role": r.role, + "subMenus": sub_list + }) + + return response_list + +@router.get("/fndMenu/listofusrgrp") +def list_of_usr_grp(db: Session = Depends(get_db)): + """Returns all distinct user groups.""" + return [{"usrGrp": 1, "groupName": "Admin Group"}] diff --git a/app/menu/schemas.py b/app/menu/schemas.py new file mode 100644 index 0000000..f93db92 --- /dev/null +++ b/app/menu/schemas.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import Optional, List, Any + +class MenuItemResponse(BaseModel): + id: Optional[int] = None + menu_id: int + menu_item_id: int + menu_item_desc: Optional[str] = None + module_name: Optional[str] = None + main_menu_action_name: Optional[str] = None + main_menu_icon_name: Optional[str] = None + item_seq: Optional[int] = 1 + status: Optional[str] = "A" + role: Optional[str] = "Admin" + sub_menus: List[Any] = [] + + class Config: + from_attributes = True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7373536 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.110.0 +uvicorn>=0.29.0 +pydantic>=2.6.0 +pydantic-settings>=2.2.0 +sqlalchemy>=2.0.0 +pymysql>=1.1.0 +pyjwt>=2.8.0 +python-multipart>=0.0.9 +python-dotenv>=1.0.1 diff --git a/run.py b/run.py new file mode 100644 index 0000000..6022fc9 --- /dev/null +++ b/run.py @@ -0,0 +1,7 @@ +import uvicorn +from app.core.config import settings + +if __name__ == "__main__": + print(f"🚀 Starting {settings.PROJECT_NAME} on http://localhost:{settings.PORT}") + print(f"📖 Interactive Swagger Docs available at: http://localhost:{settings.PORT}/docs") + uvicorn.run("app.main:app", host="0.0.0.0", port=settings.PORT, reload=settings.DEBUG)