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.
Files changed (137) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +479 -0
  4. package/backend/Dockerfile +44 -0
  5. package/backend/app/__init__.py +1 -0
  6. package/backend/app/core/__init__.py +1 -0
  7. package/backend/app/core/audit.py +33 -0
  8. package/backend/app/core/config.py +16 -0
  9. package/backend/app/core/database.py +26 -0
  10. package/backend/app/core/deps.py +49 -0
  11. package/backend/app/core/exceptions.py +43 -0
  12. package/backend/app/core/security.py +32 -0
  13. package/backend/app/main.py +54 -0
  14. package/backend/app/modules/__init__.py +0 -0
  15. package/backend/app/modules/admin/__init__.py +1 -0
  16. package/backend/app/modules/admin/models.py +15 -0
  17. package/backend/app/modules/admin/router.py +353 -0
  18. package/backend/app/modules/auth/__init__.py +0 -0
  19. package/backend/app/modules/auth/models.py +16 -0
  20. package/backend/app/modules/auth/router.py +73 -0
  21. package/backend/app/modules/auth/schemas.py +29 -0
  22. package/backend/app/modules/cash_sessions/__init__.py +0 -0
  23. package/backend/app/modules/cash_sessions/models.py +19 -0
  24. package/backend/app/modules/cash_sessions/router.py +85 -0
  25. package/backend/app/modules/cash_sessions/schemas.py +31 -0
  26. package/backend/app/modules/customers/__init__.py +0 -0
  27. package/backend/app/modules/customers/models.py +37 -0
  28. package/backend/app/modules/customers/router.py +296 -0
  29. package/backend/app/modules/customers/schemas.py +72 -0
  30. package/backend/app/modules/inventory/__init__.py +0 -0
  31. package/backend/app/modules/inventory/models.py +16 -0
  32. package/backend/app/modules/inventory/router.py +150 -0
  33. package/backend/app/modules/inventory/schemas.py +24 -0
  34. package/backend/app/modules/medicines/__init__.py +0 -0
  35. package/backend/app/modules/medicines/models.py +54 -0
  36. package/backend/app/modules/medicines/router.py +631 -0
  37. package/backend/app/modules/medicines/schemas.py +153 -0
  38. package/backend/app/modules/medicines/template_generator.py +118 -0
  39. package/backend/app/modules/notifications/__init__.py +1 -0
  40. package/backend/app/modules/notifications/models.py +15 -0
  41. package/backend/app/modules/notifications/router.py +89 -0
  42. package/backend/app/modules/notifications/schemas.py +22 -0
  43. package/backend/app/modules/organizations/__init__.py +0 -0
  44. package/backend/app/modules/organizations/models.py +16 -0
  45. package/backend/app/modules/organizations/router.py +49 -0
  46. package/backend/app/modules/organizations/schemas.py +31 -0
  47. package/backend/app/modules/reports/__init__.py +0 -0
  48. package/backend/app/modules/reports/router.py +409 -0
  49. package/backend/app/modules/roles/__init__.py +1 -0
  50. package/backend/app/modules/roles/models.py +43 -0
  51. package/backend/app/modules/roles/router.py +89 -0
  52. package/backend/app/modules/roles/schemas.py +55 -0
  53. package/backend/app/modules/sales/__init__.py +0 -0
  54. package/backend/app/modules/sales/models.py +44 -0
  55. package/backend/app/modules/sales/router.py +63 -0
  56. package/backend/app/modules/sales/schemas.py +42 -0
  57. package/backend/app/modules/sales/service.py +128 -0
  58. package/backend/app/modules/suppliers/__init__.py +0 -0
  59. package/backend/app/modules/suppliers/models.py +14 -0
  60. package/backend/app/modules/suppliers/router.py +56 -0
  61. package/backend/app/modules/suppliers/schemas.py +27 -0
  62. package/backend/app/modules/users/__init__.py +0 -0
  63. package/backend/app/modules/users/router.py +74 -0
  64. package/backend/app/seed.py +275 -0
  65. package/backend/data/.gitkeep +1 -0
  66. package/backend/requirements.txt +17 -0
  67. package/bin/cli.js +288 -0
  68. package/docker-compose.dev.yml +42 -0
  69. package/docker-compose.yml +59 -0
  70. package/frontend/Dockerfile +56 -0
  71. package/frontend/next.config.ts +19 -0
  72. package/frontend/package.json +60 -0
  73. package/frontend/pnpm-lock.yaml +5678 -0
  74. package/frontend/pnpm-workspace.yaml +6 -0
  75. package/frontend/postcss.config.mjs +6 -0
  76. package/frontend/public/logo.png +0 -0
  77. package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
  78. package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
  79. package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
  80. package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
  81. package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
  82. package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
  83. package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
  84. package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
  85. package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
  86. package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
  87. package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
  88. package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
  89. package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
  90. package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
  91. package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
  92. package/frontend/src/app/[locale]/layout.tsx +30 -0
  93. package/frontend/src/app/[locale]/page.tsx +6 -0
  94. package/frontend/src/app/globals.css +77 -0
  95. package/frontend/src/app/layout.tsx +23 -0
  96. package/frontend/src/app/print.css +18 -0
  97. package/frontend/src/components/pos/CartPanel.tsx +525 -0
  98. package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
  99. package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
  100. package/frontend/src/components/pos/PaymentModal.tsx +266 -0
  101. package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
  102. package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
  103. package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
  104. package/frontend/src/components/ui/badge.tsx +28 -0
  105. package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
  106. package/frontend/src/components/ui/button.tsx +60 -0
  107. package/frontend/src/components/ui/card.tsx +13 -0
  108. package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
  109. package/frontend/src/components/ui/input.tsx +32 -0
  110. package/frontend/src/components/ui/notification-center.tsx +228 -0
  111. package/frontend/src/components/ui/select.tsx +59 -0
  112. package/frontend/src/components/ui/sidebar.tsx +91 -0
  113. package/frontend/src/components/ui/slide-over.tsx +43 -0
  114. package/frontend/src/components/ui/stat-card.tsx +40 -0
  115. package/frontend/src/components/ui/switch.tsx +38 -0
  116. package/frontend/src/components/ui/topbar.tsx +97 -0
  117. package/frontend/src/i18n/request.ts +13 -0
  118. package/frontend/src/i18n/routing.ts +9 -0
  119. package/frontend/src/lib/api.ts +240 -0
  120. package/frontend/src/lib/constants.ts +30 -0
  121. package/frontend/src/lib/utils.ts +32 -0
  122. package/frontend/src/messages/ar.json +106 -0
  123. package/frontend/src/messages/en.json +106 -0
  124. package/frontend/src/middleware.ts +8 -0
  125. package/frontend/src/store/app-store.ts +37 -0
  126. package/frontend/src/store/auth-store.ts +52 -0
  127. package/frontend/src/store/payment-methods-store.ts +144 -0
  128. package/frontend/src/store/pos-store.ts +344 -0
  129. package/frontend/tailwind.config.ts +85 -0
  130. package/frontend/tsconfig.json +27 -0
  131. package/img/backgraund-logo.png +0 -0
  132. package/img/full-logo.png +0 -0
  133. package/img/logo.png +0 -0
  134. package/nginx/nginx.conf +67 -0
  135. package/package.json +88 -0
  136. package/start.bat +90 -0
  137. 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