pharmacy-erp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +34 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/backend/Dockerfile +44 -0
- package/backend/app/__init__.py +1 -0
- package/backend/app/core/__init__.py +1 -0
- package/backend/app/core/audit.py +33 -0
- package/backend/app/core/config.py +16 -0
- package/backend/app/core/database.py +26 -0
- package/backend/app/core/deps.py +49 -0
- package/backend/app/core/exceptions.py +43 -0
- package/backend/app/core/security.py +32 -0
- package/backend/app/main.py +54 -0
- package/backend/app/modules/__init__.py +0 -0
- package/backend/app/modules/admin/__init__.py +1 -0
- package/backend/app/modules/admin/models.py +15 -0
- package/backend/app/modules/admin/router.py +353 -0
- package/backend/app/modules/auth/__init__.py +0 -0
- package/backend/app/modules/auth/models.py +16 -0
- package/backend/app/modules/auth/router.py +73 -0
- package/backend/app/modules/auth/schemas.py +29 -0
- package/backend/app/modules/cash_sessions/__init__.py +0 -0
- package/backend/app/modules/cash_sessions/models.py +19 -0
- package/backend/app/modules/cash_sessions/router.py +85 -0
- package/backend/app/modules/cash_sessions/schemas.py +31 -0
- package/backend/app/modules/customers/__init__.py +0 -0
- package/backend/app/modules/customers/models.py +37 -0
- package/backend/app/modules/customers/router.py +296 -0
- package/backend/app/modules/customers/schemas.py +72 -0
- package/backend/app/modules/inventory/__init__.py +0 -0
- package/backend/app/modules/inventory/models.py +16 -0
- package/backend/app/modules/inventory/router.py +150 -0
- package/backend/app/modules/inventory/schemas.py +24 -0
- package/backend/app/modules/medicines/__init__.py +0 -0
- package/backend/app/modules/medicines/models.py +54 -0
- package/backend/app/modules/medicines/router.py +631 -0
- package/backend/app/modules/medicines/schemas.py +153 -0
- package/backend/app/modules/medicines/template_generator.py +118 -0
- package/backend/app/modules/notifications/__init__.py +1 -0
- package/backend/app/modules/notifications/models.py +15 -0
- package/backend/app/modules/notifications/router.py +89 -0
- package/backend/app/modules/notifications/schemas.py +22 -0
- package/backend/app/modules/organizations/__init__.py +0 -0
- package/backend/app/modules/organizations/models.py +16 -0
- package/backend/app/modules/organizations/router.py +49 -0
- package/backend/app/modules/organizations/schemas.py +31 -0
- package/backend/app/modules/reports/__init__.py +0 -0
- package/backend/app/modules/reports/router.py +409 -0
- package/backend/app/modules/roles/__init__.py +1 -0
- package/backend/app/modules/roles/models.py +43 -0
- package/backend/app/modules/roles/router.py +89 -0
- package/backend/app/modules/roles/schemas.py +55 -0
- package/backend/app/modules/sales/__init__.py +0 -0
- package/backend/app/modules/sales/models.py +44 -0
- package/backend/app/modules/sales/router.py +63 -0
- package/backend/app/modules/sales/schemas.py +42 -0
- package/backend/app/modules/sales/service.py +128 -0
- package/backend/app/modules/suppliers/__init__.py +0 -0
- package/backend/app/modules/suppliers/models.py +14 -0
- package/backend/app/modules/suppliers/router.py +56 -0
- package/backend/app/modules/suppliers/schemas.py +27 -0
- package/backend/app/modules/users/__init__.py +0 -0
- package/backend/app/modules/users/router.py +74 -0
- package/backend/app/seed.py +275 -0
- package/backend/data/.gitkeep +1 -0
- package/backend/requirements.txt +17 -0
- package/bin/cli.js +288 -0
- package/docker-compose.dev.yml +42 -0
- package/docker-compose.yml +59 -0
- package/frontend/Dockerfile +56 -0
- package/frontend/next.config.ts +19 -0
- package/frontend/package.json +60 -0
- package/frontend/pnpm-lock.yaml +5678 -0
- package/frontend/pnpm-workspace.yaml +6 -0
- package/frontend/postcss.config.mjs +6 -0
- package/frontend/public/logo.png +0 -0
- package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
- package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
- package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
- package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
- package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
- package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
- package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
- package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
- package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
- package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
- package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
- package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
- package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
- package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
- package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
- package/frontend/src/app/[locale]/layout.tsx +30 -0
- package/frontend/src/app/[locale]/page.tsx +6 -0
- package/frontend/src/app/globals.css +77 -0
- package/frontend/src/app/layout.tsx +23 -0
- package/frontend/src/app/print.css +18 -0
- package/frontend/src/components/pos/CartPanel.tsx +525 -0
- package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
- package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
- package/frontend/src/components/pos/PaymentModal.tsx +266 -0
- package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
- package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
- package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
- package/frontend/src/components/ui/badge.tsx +28 -0
- package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
- package/frontend/src/components/ui/button.tsx +60 -0
- package/frontend/src/components/ui/card.tsx +13 -0
- package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
- package/frontend/src/components/ui/input.tsx +32 -0
- package/frontend/src/components/ui/notification-center.tsx +228 -0
- package/frontend/src/components/ui/select.tsx +59 -0
- package/frontend/src/components/ui/sidebar.tsx +91 -0
- package/frontend/src/components/ui/slide-over.tsx +43 -0
- package/frontend/src/components/ui/stat-card.tsx +40 -0
- package/frontend/src/components/ui/switch.tsx +38 -0
- package/frontend/src/components/ui/topbar.tsx +97 -0
- package/frontend/src/i18n/request.ts +13 -0
- package/frontend/src/i18n/routing.ts +9 -0
- package/frontend/src/lib/api.ts +240 -0
- package/frontend/src/lib/constants.ts +30 -0
- package/frontend/src/lib/utils.ts +32 -0
- package/frontend/src/messages/ar.json +106 -0
- package/frontend/src/messages/en.json +106 -0
- package/frontend/src/middleware.ts +8 -0
- package/frontend/src/store/app-store.ts +37 -0
- package/frontend/src/store/auth-store.ts +52 -0
- package/frontend/src/store/payment-methods-store.ts +144 -0
- package/frontend/src/store/pos-store.ts +344 -0
- package/frontend/tailwind.config.ts +85 -0
- package/frontend/tsconfig.json +27 -0
- package/img/backgraund-logo.png +0 -0
- package/img/full-logo.png +0 -0
- package/img/logo.png +0 -0
- package/nginx/nginx.conf +67 -0
- package/package.json +88 -0
- package/start.bat +90 -0
- package/start.sh +64 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from fastapi import Depends, Request
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from app.core.database import get_db
|
|
5
|
+
from app.core.security import verify_token
|
|
6
|
+
from app.core.exceptions import AuthError, PermissionError
|
|
7
|
+
from app.modules.auth.models import User
|
|
8
|
+
|
|
9
|
+
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)):
|
|
10
|
+
token = request.cookies.get("access_token")
|
|
11
|
+
if not token:
|
|
12
|
+
raise AuthError("Not authenticated")
|
|
13
|
+
try:
|
|
14
|
+
payload = verify_token(token)
|
|
15
|
+
user_id = payload.get("sub")
|
|
16
|
+
if not user_id:
|
|
17
|
+
raise AuthError("Invalid token")
|
|
18
|
+
|
|
19
|
+
import uuid
|
|
20
|
+
try:
|
|
21
|
+
uid = uuid.UUID(user_id)
|
|
22
|
+
except:
|
|
23
|
+
uid = user_id
|
|
24
|
+
|
|
25
|
+
result = await db.execute(select(User).where(User.id == uid))
|
|
26
|
+
user = result.scalar_one_or_none()
|
|
27
|
+
if not user or not user.is_active:
|
|
28
|
+
raise AuthError("User not found or inactive")
|
|
29
|
+
return user
|
|
30
|
+
except Exception:
|
|
31
|
+
raise AuthError("Invalid or expired token")
|
|
32
|
+
|
|
33
|
+
def require_role(*roles):
|
|
34
|
+
async def role_checker(current_user: User = Depends(get_current_user)):
|
|
35
|
+
user_role = current_user.role.upper() if current_user.role else ""
|
|
36
|
+
allowed_roles = [r.upper() for r in roles]
|
|
37
|
+
if user_role not in allowed_roles and user_role not in ["SUPER_ADMIN", "ADMIN"]:
|
|
38
|
+
raise PermissionError("Not enough permissions")
|
|
39
|
+
return current_user
|
|
40
|
+
return role_checker
|
|
41
|
+
|
|
42
|
+
def require_permission(permission: str):
|
|
43
|
+
async def permission_checker(current_user: User = Depends(get_current_user)):
|
|
44
|
+
# Granular check logic here
|
|
45
|
+
user_role = current_user.role.upper() if current_user.role else ""
|
|
46
|
+
if user_role not in ["SUPER_ADMIN", "ADMIN"]:
|
|
47
|
+
raise PermissionError("Not enough permissions")
|
|
48
|
+
return current_user
|
|
49
|
+
return permission_checker
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
class AppException(Exception):
|
|
6
|
+
def __init__(self, code: str, message: str, status_code: int = 400, fields: dict = None):
|
|
7
|
+
self.code = code
|
|
8
|
+
self.message = message
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.fields = fields or {}
|
|
11
|
+
|
|
12
|
+
class NotFoundError(AppException):
|
|
13
|
+
def __init__(self, message: str = "Resource not found"):
|
|
14
|
+
super().__init__("NOT_FOUND", message, 404)
|
|
15
|
+
|
|
16
|
+
class ValidationError(AppException):
|
|
17
|
+
def __init__(self, message: str = "Validation error", fields: dict = None):
|
|
18
|
+
super().__init__("VALIDATION_ERROR", message, 422, fields)
|
|
19
|
+
|
|
20
|
+
class AuthError(AppException):
|
|
21
|
+
def __init__(self, message: str = "Authentication failed"):
|
|
22
|
+
super().__init__("AUTH_ERROR", message, 401)
|
|
23
|
+
|
|
24
|
+
class PermissionError(AppException):
|
|
25
|
+
def __init__(self, message: str = "Permission denied"):
|
|
26
|
+
super().__init__("PERMISSION_DENIED", message, 403)
|
|
27
|
+
|
|
28
|
+
class ConflictError(AppException):
|
|
29
|
+
def __init__(self, message: str = "Resource conflict"):
|
|
30
|
+
super().__init__("CONFLICT", message, 409)
|
|
31
|
+
|
|
32
|
+
async def app_exception_handler(request: Request, exc: AppException):
|
|
33
|
+
return JSONResponse(
|
|
34
|
+
status_code=exc.status_code,
|
|
35
|
+
content={
|
|
36
|
+
"error": {
|
|
37
|
+
"code": exc.code,
|
|
38
|
+
"message": exc.message,
|
|
39
|
+
"correlation_id": str(uuid.uuid4()),
|
|
40
|
+
"fields": exc.fields
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from jose import jwt
|
|
4
|
+
from passlib.context import CryptContext
|
|
5
|
+
from app.core.config import settings
|
|
6
|
+
|
|
7
|
+
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
|
8
|
+
ALGORITHM = "HS256"
|
|
9
|
+
|
|
10
|
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
11
|
+
to_encode = data.copy()
|
|
12
|
+
if expires_delta:
|
|
13
|
+
expire = datetime.utcnow() + expires_delta
|
|
14
|
+
else:
|
|
15
|
+
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
16
|
+
to_encode.update({"exp": expire})
|
|
17
|
+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
|
18
|
+
|
|
19
|
+
def create_refresh_token(data: dict) -> str:
|
|
20
|
+
to_encode = data.copy()
|
|
21
|
+
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
|
22
|
+
to_encode.update({"exp": expire})
|
|
23
|
+
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
|
24
|
+
|
|
25
|
+
def verify_token(token: str) -> dict:
|
|
26
|
+
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
|
27
|
+
|
|
28
|
+
def hash_password(password: str) -> str:
|
|
29
|
+
return pwd_context.hash(password)
|
|
30
|
+
|
|
31
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
32
|
+
return pwd_context.verify(plain, hashed)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
3
|
+
from app.core.config import settings
|
|
4
|
+
from app.core.database import init_db
|
|
5
|
+
from app.core.exceptions import AppException, app_exception_handler
|
|
6
|
+
|
|
7
|
+
# Ensure all models are imported before init_db
|
|
8
|
+
from app.modules.auth.models import User
|
|
9
|
+
from app.modules.organizations.models import Branch
|
|
10
|
+
from app.modules.medicines.models import Medicine, MedicineCategory, MedicineBatch
|
|
11
|
+
from app.modules.suppliers.models import Supplier
|
|
12
|
+
from app.modules.inventory.models import InventoryMovement
|
|
13
|
+
from app.modules.sales.models import Sale, SaleItem, HeldSale
|
|
14
|
+
from app.modules.customers.models import Customer, CustomerPayment
|
|
15
|
+
from app.modules.cash_sessions.models import CashSession
|
|
16
|
+
from app.modules.roles.models import Role, Permission
|
|
17
|
+
from app.modules.admin.models import AuditLog
|
|
18
|
+
from app.modules.notifications.models import Notification
|
|
19
|
+
|
|
20
|
+
from app.modules.auth.router import router as auth_router
|
|
21
|
+
from app.modules.organizations.router import router as org_router
|
|
22
|
+
from app.modules.medicines.router import router as medicine_router
|
|
23
|
+
from app.modules.sales.router import router as sales_router
|
|
24
|
+
from app.modules.inventory.router import router as inventory_router
|
|
25
|
+
from app.modules.suppliers.router import router as suppliers_router
|
|
26
|
+
from app.modules.customers.router import router as customers_router
|
|
27
|
+
from app.modules.cash_sessions.router import router as cash_sessions_router
|
|
28
|
+
from app.modules.reports.router import router as reports_router
|
|
29
|
+
from app.modules.users.router import router as users_router
|
|
30
|
+
from app.modules.roles.router import router as roles_router
|
|
31
|
+
from app.modules.admin.router import router as admin_router
|
|
32
|
+
from app.modules.notifications.router import router as notifications_router
|
|
33
|
+
|
|
34
|
+
app = FastAPI(title=settings.APP_NAME)
|
|
35
|
+
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
|
36
|
+
app.add_exception_handler(AppException, app_exception_handler)
|
|
37
|
+
|
|
38
|
+
app.include_router(auth_router, prefix="/api/v1/auth")
|
|
39
|
+
app.include_router(org_router, prefix="/api/v1/branches")
|
|
40
|
+
app.include_router(medicine_router, prefix="/api/v1/medicines")
|
|
41
|
+
app.include_router(sales_router, prefix="/api/v1/sales")
|
|
42
|
+
app.include_router(inventory_router, prefix="/api/v1/inventory")
|
|
43
|
+
app.include_router(suppliers_router, prefix="/api/v1/suppliers")
|
|
44
|
+
app.include_router(customers_router, prefix="/api/v1/customers")
|
|
45
|
+
app.include_router(cash_sessions_router, prefix="/api/v1/cash-sessions")
|
|
46
|
+
app.include_router(reports_router, prefix="/api/v1/reports")
|
|
47
|
+
app.include_router(users_router, prefix="/api/v1/users")
|
|
48
|
+
app.include_router(roles_router, prefix="/api/v1/roles")
|
|
49
|
+
app.include_router(admin_router, prefix="/api/v1/admin")
|
|
50
|
+
app.include_router(notifications_router, prefix="/api/v1/notifications")
|
|
51
|
+
|
|
52
|
+
@app.on_event("startup")
|
|
53
|
+
async def startup():
|
|
54
|
+
await init_db()
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Admin module
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
2
|
+
from sqlalchemy import String, ForeignKey, Text
|
|
3
|
+
from app.core.database import Base
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class AuditLog(Base):
|
|
7
|
+
__tablename__ = "audit_logs"
|
|
8
|
+
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=True)
|
|
9
|
+
user_email: Mapped[str] = mapped_column(String, nullable=True)
|
|
10
|
+
action: Mapped[str] = mapped_column(String) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT, IMPORT, WIPE, EXPORT
|
|
11
|
+
module: Mapped[str] = mapped_column(String) # Auth, Medicine, Sales, Inventory, Customer, Admin, System
|
|
12
|
+
entity_type: Mapped[str] = mapped_column(String, nullable=True) # Medicine, Sale, Batch, etc.
|
|
13
|
+
entity_id: Mapped[str] = mapped_column(String, nullable=True) # UUID of affected record
|
|
14
|
+
details: Mapped[str] = mapped_column(Text) # Human-readable description
|
|
15
|
+
ip_address: Mapped[str] = mapped_column(String, nullable=True)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, Response, UploadFile, File, HTTPException, Request
|
|
2
|
+
from fastapi.responses import FileResponse
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlalchemy import text, select, func, desc
|
|
5
|
+
from app.core.database import get_db
|
|
6
|
+
from app.core.deps import get_current_user, require_role
|
|
7
|
+
from app.modules.auth.models import User
|
|
8
|
+
from app.core.security import hash_password
|
|
9
|
+
from app.modules.admin.models import AuditLog
|
|
10
|
+
from app.core.audit import log_action
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import shutil
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
router = APIRouter(tags=["Admin"])
|
|
19
|
+
|
|
20
|
+
# ─── Schemas ─────────────────────────────────────────────────────────
|
|
21
|
+
class AuditLogResponse(BaseModel):
|
|
22
|
+
id: str
|
|
23
|
+
user_email: Optional[str] = None
|
|
24
|
+
action: str
|
|
25
|
+
module: str
|
|
26
|
+
entity_type: Optional[str] = None
|
|
27
|
+
entity_id: Optional[str] = None
|
|
28
|
+
details: str
|
|
29
|
+
ip_address: Optional[str] = None
|
|
30
|
+
created_at: datetime
|
|
31
|
+
model_config = {"from_attributes": True}
|
|
32
|
+
|
|
33
|
+
class AuditLogPage(BaseModel):
|
|
34
|
+
items: List[AuditLogResponse]
|
|
35
|
+
total: int
|
|
36
|
+
page: int
|
|
37
|
+
pages: int
|
|
38
|
+
|
|
39
|
+
class BackupInfo(BaseModel):
|
|
40
|
+
filename: str
|
|
41
|
+
size_bytes: int
|
|
42
|
+
size_display: str
|
|
43
|
+
created_at: str
|
|
44
|
+
|
|
45
|
+
BACKUP_DIR = Path("data/backups")
|
|
46
|
+
DB_PATH = Path("data/pharmacy.db")
|
|
47
|
+
|
|
48
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
49
|
+
# AUDIT LOG ENDPOINTS
|
|
50
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
@router.get("/audit-logs", response_model=AuditLogPage)
|
|
53
|
+
async def get_audit_logs(
|
|
54
|
+
page: int = 1,
|
|
55
|
+
per_page: int = 50,
|
|
56
|
+
module: Optional[str] = None,
|
|
57
|
+
action: Optional[str] = None,
|
|
58
|
+
search: Optional[str] = None,
|
|
59
|
+
db: AsyncSession = Depends(get_db),
|
|
60
|
+
admin: User = Depends(require_role("SUPER_ADMIN", "ADMIN"))
|
|
61
|
+
):
|
|
62
|
+
"""Get paginated audit logs with optional filters."""
|
|
63
|
+
query = select(AuditLog)
|
|
64
|
+
count_query = select(func.count(AuditLog.id))
|
|
65
|
+
|
|
66
|
+
if module and module != 'all':
|
|
67
|
+
query = query.where(AuditLog.module == module)
|
|
68
|
+
count_query = count_query.where(AuditLog.module == module)
|
|
69
|
+
if action and action != 'all':
|
|
70
|
+
query = query.where(AuditLog.action == action)
|
|
71
|
+
count_query = count_query.where(AuditLog.action == action)
|
|
72
|
+
if search:
|
|
73
|
+
query = query.where(AuditLog.details.ilike(f"%{search}%"))
|
|
74
|
+
count_query = count_query.where(AuditLog.details.ilike(f"%{search}%"))
|
|
75
|
+
|
|
76
|
+
total_result = await db.execute(count_query)
|
|
77
|
+
total = total_result.scalar() or 0
|
|
78
|
+
pages = max(1, (total + per_page - 1) // per_page)
|
|
79
|
+
|
|
80
|
+
query = query.order_by(desc(AuditLog.created_at)).offset((page - 1) * per_page).limit(per_page)
|
|
81
|
+
result = await db.execute(query)
|
|
82
|
+
items = result.scalars().all()
|
|
83
|
+
|
|
84
|
+
return AuditLogPage(
|
|
85
|
+
items=[AuditLogResponse(
|
|
86
|
+
id=str(log.id),
|
|
87
|
+
user_email=log.user_email,
|
|
88
|
+
action=log.action,
|
|
89
|
+
module=log.module,
|
|
90
|
+
entity_type=log.entity_type,
|
|
91
|
+
entity_id=log.entity_id,
|
|
92
|
+
details=log.details,
|
|
93
|
+
ip_address=log.ip_address,
|
|
94
|
+
created_at=log.created_at
|
|
95
|
+
) for log in items],
|
|
96
|
+
total=total,
|
|
97
|
+
page=page,
|
|
98
|
+
pages=pages
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
103
|
+
# BACKUP ENDPOINTS
|
|
104
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
105
|
+
|
|
106
|
+
def _format_size(size_bytes: int) -> str:
|
|
107
|
+
if size_bytes < 1024:
|
|
108
|
+
return f"{size_bytes} B"
|
|
109
|
+
elif size_bytes < 1024 * 1024:
|
|
110
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
111
|
+
else:
|
|
112
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.post("/backups", response_model=BackupInfo)
|
|
116
|
+
async def create_backup(
|
|
117
|
+
request: Request,
|
|
118
|
+
db: AsyncSession = Depends(get_db),
|
|
119
|
+
admin: User = Depends(require_role("SUPER_ADMIN"))
|
|
120
|
+
):
|
|
121
|
+
"""Create a new database backup."""
|
|
122
|
+
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
125
|
+
backup_filename = f"backup_{timestamp}.db"
|
|
126
|
+
backup_path = BACKUP_DIR / backup_filename
|
|
127
|
+
|
|
128
|
+
shutil.copy2(str(DB_PATH), str(backup_path))
|
|
129
|
+
|
|
130
|
+
size = backup_path.stat().st_size
|
|
131
|
+
|
|
132
|
+
await log_action(
|
|
133
|
+
db=db, action="BACKUP", module="Admin",
|
|
134
|
+
details=f"Database backup created: {backup_filename}",
|
|
135
|
+
user=admin, ip_address=request.client.host if request.client else None
|
|
136
|
+
)
|
|
137
|
+
await db.commit()
|
|
138
|
+
|
|
139
|
+
return BackupInfo(
|
|
140
|
+
filename=backup_filename,
|
|
141
|
+
size_bytes=size,
|
|
142
|
+
size_display=_format_size(size),
|
|
143
|
+
created_at=datetime.now().isoformat()
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.get("/backups", response_model=List[BackupInfo])
|
|
148
|
+
async def list_backups(
|
|
149
|
+
admin: User = Depends(require_role("SUPER_ADMIN"))
|
|
150
|
+
):
|
|
151
|
+
"""List all available backups."""
|
|
152
|
+
if not BACKUP_DIR.exists():
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
backups = []
|
|
156
|
+
for f in sorted(BACKUP_DIR.glob("*.db"), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
157
|
+
stat = f.stat()
|
|
158
|
+
backups.append(BackupInfo(
|
|
159
|
+
filename=f.name,
|
|
160
|
+
size_bytes=stat.st_size,
|
|
161
|
+
size_display=_format_size(stat.st_size),
|
|
162
|
+
created_at=datetime.fromtimestamp(stat.st_mtime).isoformat()
|
|
163
|
+
))
|
|
164
|
+
return backups
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.get("/backups/{filename}/download")
|
|
168
|
+
async def download_backup(
|
|
169
|
+
filename: str,
|
|
170
|
+
admin: User = Depends(require_role("SUPER_ADMIN"))
|
|
171
|
+
):
|
|
172
|
+
"""Download a specific backup file."""
|
|
173
|
+
backup_path = BACKUP_DIR / filename
|
|
174
|
+
if not backup_path.exists() or not backup_path.name.endswith('.db'):
|
|
175
|
+
raise HTTPException(status_code=404, detail="Backup not found")
|
|
176
|
+
return FileResponse(str(backup_path), media_type="application/octet-stream", filename=filename)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@router.delete("/backups/{filename}")
|
|
180
|
+
async def delete_backup(
|
|
181
|
+
filename: str,
|
|
182
|
+
request: Request,
|
|
183
|
+
db: AsyncSession = Depends(get_db),
|
|
184
|
+
admin: User = Depends(require_role("SUPER_ADMIN"))
|
|
185
|
+
):
|
|
186
|
+
"""Delete a backup file."""
|
|
187
|
+
backup_path = BACKUP_DIR / filename
|
|
188
|
+
if not backup_path.exists():
|
|
189
|
+
raise HTTPException(status_code=404, detail="Backup not found")
|
|
190
|
+
backup_path.unlink()
|
|
191
|
+
|
|
192
|
+
await log_action(
|
|
193
|
+
db=db, action="DELETE", module="Admin",
|
|
194
|
+
details=f"Backup deleted: {filename}",
|
|
195
|
+
user=admin, ip_address=request.client.host if request.client else None
|
|
196
|
+
)
|
|
197
|
+
await db.commit()
|
|
198
|
+
|
|
199
|
+
return {"message": f"Backup {filename} deleted"}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@router.post("/backups/restore")
|
|
203
|
+
async def restore_backup(
|
|
204
|
+
file: UploadFile = File(...),
|
|
205
|
+
request: Request = None,
|
|
206
|
+
db: AsyncSession = Depends(get_db),
|
|
207
|
+
admin: User = Depends(require_role("SUPER_ADMIN"))
|
|
208
|
+
):
|
|
209
|
+
"""Restore database from an uploaded backup file. Creates a safety backup first."""
|
|
210
|
+
if not file.filename.endswith('.db'):
|
|
211
|
+
raise HTTPException(status_code=400, detail="Only .db files are accepted")
|
|
212
|
+
|
|
213
|
+
# Create safety backup first
|
|
214
|
+
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
safety_name = f"pre_restore_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
|
216
|
+
shutil.copy2(str(DB_PATH), str(BACKUP_DIR / safety_name))
|
|
217
|
+
|
|
218
|
+
await log_action(
|
|
219
|
+
db=db, action="RESTORE", module="Admin",
|
|
220
|
+
details=f"Database restore initiated from: {file.filename}. Safety backup: {safety_name}",
|
|
221
|
+
user=admin, ip_address=request.client.host if request and request.client else None
|
|
222
|
+
)
|
|
223
|
+
await db.commit()
|
|
224
|
+
|
|
225
|
+
# Write uploaded file to DB path
|
|
226
|
+
content = await file.read()
|
|
227
|
+
with open(str(DB_PATH), 'wb') as f:
|
|
228
|
+
f.write(content)
|
|
229
|
+
|
|
230
|
+
return {"message": "Database restored successfully. Please restart the server.", "safety_backup": safety_name}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@router.post("/wipe-data")
|
|
236
|
+
async def wipe_data(
|
|
237
|
+
request: Request,
|
|
238
|
+
response: Response,
|
|
239
|
+
db: AsyncSession = Depends(get_db),
|
|
240
|
+
admin: User = Depends(require_role("SUPER_ADMIN"))
|
|
241
|
+
):
|
|
242
|
+
"""
|
|
243
|
+
Wipe all business data: medicines, batches, inventory, sales, cash sessions,
|
|
244
|
+
customers, suppliers, purchase orders. Keeps users, roles, branches, and system settings.
|
|
245
|
+
"""
|
|
246
|
+
# Order matters due to foreign key constraints
|
|
247
|
+
tables_to_wipe = [
|
|
248
|
+
"sale_items",
|
|
249
|
+
"sales",
|
|
250
|
+
"held_sales",
|
|
251
|
+
"cash_movements",
|
|
252
|
+
"cash_sessions",
|
|
253
|
+
"inventory_movements",
|
|
254
|
+
"medicine_batches",
|
|
255
|
+
"medicines",
|
|
256
|
+
"medicine_categories",
|
|
257
|
+
"customers",
|
|
258
|
+
"suppliers",
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
for table in tables_to_wipe:
|
|
262
|
+
try:
|
|
263
|
+
await db.execute(text(f"DELETE FROM {table}"))
|
|
264
|
+
except Exception:
|
|
265
|
+
pass # Table might not exist yet
|
|
266
|
+
|
|
267
|
+
await db.commit()
|
|
268
|
+
|
|
269
|
+
await log_action(
|
|
270
|
+
db=db, action="WIPE", module="Admin",
|
|
271
|
+
details="All business data wiped",
|
|
272
|
+
user=admin, ip_address=request.client.host if request.client else None
|
|
273
|
+
)
|
|
274
|
+
await db.commit()
|
|
275
|
+
|
|
276
|
+
return {"message": "All business data has been wiped successfully"}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@router.post("/factory-reset")
|
|
280
|
+
async def factory_reset(
|
|
281
|
+
request: Request,
|
|
282
|
+
response: Response,
|
|
283
|
+
db: AsyncSession = Depends(get_db),
|
|
284
|
+
admin: User = Depends(require_role("SUPER_ADMIN"))
|
|
285
|
+
):
|
|
286
|
+
"""
|
|
287
|
+
Full factory reset: wipe ALL data including users, then recreate default admin.
|
|
288
|
+
"""
|
|
289
|
+
# Wipe everything in the correct order
|
|
290
|
+
tables_to_wipe = [
|
|
291
|
+
"sale_items",
|
|
292
|
+
"sales",
|
|
293
|
+
"held_sales",
|
|
294
|
+
"cash_movements",
|
|
295
|
+
"cash_sessions",
|
|
296
|
+
"inventory_movements",
|
|
297
|
+
"medicine_batches",
|
|
298
|
+
"medicines",
|
|
299
|
+
"medicine_categories",
|
|
300
|
+
"customers",
|
|
301
|
+
"suppliers",
|
|
302
|
+
"users",
|
|
303
|
+
"role_permissions",
|
|
304
|
+
"roles",
|
|
305
|
+
"branches",
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
for table in tables_to_wipe:
|
|
309
|
+
try:
|
|
310
|
+
await db.execute(text(f"DELETE FROM {table}"))
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
await db.commit()
|
|
315
|
+
|
|
316
|
+
# Re-create default admin user
|
|
317
|
+
from app.modules.auth.models import User as UserModel
|
|
318
|
+
default_admin = UserModel(
|
|
319
|
+
email="admin",
|
|
320
|
+
password_hash=hash_password("admin"),
|
|
321
|
+
full_name="Administrator",
|
|
322
|
+
full_name_ar="المدير",
|
|
323
|
+
role="SUPER_ADMIN",
|
|
324
|
+
is_active=True,
|
|
325
|
+
)
|
|
326
|
+
db.add(default_admin)
|
|
327
|
+
|
|
328
|
+
# Re-create default branch
|
|
329
|
+
from app.modules.organizations.models import Branch
|
|
330
|
+
default_branch = Branch(
|
|
331
|
+
name="Main Branch",
|
|
332
|
+
name_ar="الفرع الرئيسي",
|
|
333
|
+
code="HQ",
|
|
334
|
+
is_active=True,
|
|
335
|
+
)
|
|
336
|
+
db.add(default_branch)
|
|
337
|
+
|
|
338
|
+
await db.commit()
|
|
339
|
+
|
|
340
|
+
await db.commit()
|
|
341
|
+
|
|
342
|
+
await log_action(
|
|
343
|
+
db=db, action="RESET", module="Admin",
|
|
344
|
+
details="Full factory reset performed",
|
|
345
|
+
user=default_admin, ip_address=request.client.host if request.client else None
|
|
346
|
+
)
|
|
347
|
+
await db.commit()
|
|
348
|
+
|
|
349
|
+
# Clear auth cookies
|
|
350
|
+
response.delete_cookie("access_token")
|
|
351
|
+
response.delete_cookie("refresh_token")
|
|
352
|
+
|
|
353
|
+
return {"message": "Factory reset complete. Please log in with admin/admin."}
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
2
|
+
from sqlalchemy import String, Boolean, DateTime
|
|
3
|
+
from app.core.database import Base
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
class User(Base):
|
|
7
|
+
__tablename__ = "users"
|
|
8
|
+
|
|
9
|
+
email: Mapped[str] = mapped_column(String, unique=True, index=True)
|
|
10
|
+
password_hash: Mapped[str] = mapped_column(String)
|
|
11
|
+
full_name: Mapped[str] = mapped_column(String)
|
|
12
|
+
full_name_ar: Mapped[str] = mapped_column(String, nullable=True)
|
|
13
|
+
role: Mapped[str] = mapped_column(String)
|
|
14
|
+
phone: Mapped[str] = mapped_column(String, nullable=True)
|
|
15
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
16
|
+
last_login: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, Response, Request
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from app.core.audit import log_action
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from app.core.database import get_db
|
|
6
|
+
from app.core.security import verify_password, create_access_token, create_refresh_token, hash_password
|
|
7
|
+
from app.core.exceptions import AuthError
|
|
8
|
+
from app.modules.auth.models import User
|
|
9
|
+
from app.modules.auth.schemas import LoginRequest, RegisterRequest, UserResponse
|
|
10
|
+
from app.core.deps import get_current_user, require_role
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["Auth"])
|
|
14
|
+
|
|
15
|
+
@router.post("/login", response_model=UserResponse)
|
|
16
|
+
async def login(req: LoginRequest, response: Response, request: Request, db: AsyncSession = Depends(get_db)):
|
|
17
|
+
result = await db.execute(select(User).where(User.email == req.email))
|
|
18
|
+
user = result.scalar_one_or_none()
|
|
19
|
+
if not user or not verify_password(req.password, user.password_hash):
|
|
20
|
+
raise AuthError("Invalid credentials")
|
|
21
|
+
|
|
22
|
+
user.last_login = datetime.utcnow()
|
|
23
|
+
await db.commit()
|
|
24
|
+
|
|
25
|
+
if req.remember_me:
|
|
26
|
+
access_token_expires = timedelta(days=30)
|
|
27
|
+
max_age = 30 * 24 * 60 * 60
|
|
28
|
+
else:
|
|
29
|
+
access_token_expires = None
|
|
30
|
+
max_age = 120 * 60
|
|
31
|
+
|
|
32
|
+
access_token = create_access_token(
|
|
33
|
+
data={"sub": str(user.id), "role": user.role},
|
|
34
|
+
expires_delta=access_token_expires
|
|
35
|
+
)
|
|
36
|
+
refresh_token = create_refresh_token({"sub": str(user.id)})
|
|
37
|
+
|
|
38
|
+
response.set_cookie(key="access_token", value=access_token, httponly=True, samesite="lax", max_age=max_age)
|
|
39
|
+
response.set_cookie(key="refresh_token", value=refresh_token, httponly=True, samesite="lax", max_age=max_age)
|
|
40
|
+
|
|
41
|
+
await log_action(
|
|
42
|
+
db=db, action="LOGIN", module="Auth",
|
|
43
|
+
details=f"User logged in: {user.email}",
|
|
44
|
+
user=user, ip_address=request.client.host if request.client else None
|
|
45
|
+
)
|
|
46
|
+
await db.commit()
|
|
47
|
+
|
|
48
|
+
return user
|
|
49
|
+
|
|
50
|
+
@router.post("/register", response_model=UserResponse)
|
|
51
|
+
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db), admin: User = Depends(require_role("SUPER_ADMIN"))):
|
|
52
|
+
new_user = User(
|
|
53
|
+
email=req.email,
|
|
54
|
+
password_hash=hash_password(req.password),
|
|
55
|
+
full_name=req.full_name,
|
|
56
|
+
full_name_ar=req.full_name_ar,
|
|
57
|
+
role=req.role,
|
|
58
|
+
phone=req.phone
|
|
59
|
+
)
|
|
60
|
+
db.add(new_user)
|
|
61
|
+
await db.commit()
|
|
62
|
+
await db.refresh(new_user)
|
|
63
|
+
return new_user
|
|
64
|
+
|
|
65
|
+
@router.post("/logout")
|
|
66
|
+
async def logout(response: Response):
|
|
67
|
+
response.delete_cookie("access_token")
|
|
68
|
+
response.delete_cookie("refresh_token")
|
|
69
|
+
return {"message": "Logged out"}
|
|
70
|
+
|
|
71
|
+
@router.get("/me", response_model=UserResponse)
|
|
72
|
+
async def get_me(user: User = Depends(get_current_user)):
|
|
73
|
+
return user
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
class LoginRequest(BaseModel):
|
|
7
|
+
email: str
|
|
8
|
+
password: str
|
|
9
|
+
remember_me: Optional[bool] = False
|
|
10
|
+
|
|
11
|
+
class RegisterRequest(BaseModel):
|
|
12
|
+
email: str
|
|
13
|
+
password: str
|
|
14
|
+
full_name: str
|
|
15
|
+
full_name_ar: Optional[str] = None
|
|
16
|
+
role: str
|
|
17
|
+
phone: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
class UserResponse(BaseModel):
|
|
20
|
+
id: uuid.UUID
|
|
21
|
+
email: str
|
|
22
|
+
full_name: str
|
|
23
|
+
full_name_ar: Optional[str] = None
|
|
24
|
+
role: str
|
|
25
|
+
phone: Optional[str] = None
|
|
26
|
+
is_active: bool
|
|
27
|
+
created_at: datetime
|
|
28
|
+
|
|
29
|
+
model_config = {"from_attributes": True}
|
|
File without changes
|