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,153 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
class CategoryCreate(BaseModel):
|
|
8
|
+
name_en: str
|
|
9
|
+
name_ar: Optional[str] = None
|
|
10
|
+
parent_id: Optional[uuid.UUID] = None
|
|
11
|
+
category_level: int = 1
|
|
12
|
+
icon: Optional[str] = None
|
|
13
|
+
sort_order: int = 0
|
|
14
|
+
|
|
15
|
+
class CategoryResponse(CategoryCreate):
|
|
16
|
+
id: uuid.UUID
|
|
17
|
+
category_level: int
|
|
18
|
+
icon: Optional[str] = None
|
|
19
|
+
sort_order: int
|
|
20
|
+
model_config = {"from_attributes": True}
|
|
21
|
+
|
|
22
|
+
class CategoryUpdate(BaseModel):
|
|
23
|
+
name_en: Optional[str] = None
|
|
24
|
+
name_ar: Optional[str] = None
|
|
25
|
+
parent_id: Optional[uuid.UUID] = None
|
|
26
|
+
category_level: Optional[int] = None
|
|
27
|
+
icon: Optional[str] = None
|
|
28
|
+
sort_order: Optional[int] = None
|
|
29
|
+
|
|
30
|
+
class CategoryTreeResponse(CategoryResponse):
|
|
31
|
+
children: List['CategoryTreeResponse'] = []
|
|
32
|
+
model_config = {"from_attributes": True}
|
|
33
|
+
|
|
34
|
+
class MedicineCreate(BaseModel):
|
|
35
|
+
sku: str
|
|
36
|
+
name_en: str
|
|
37
|
+
name_ar: Optional[str] = None
|
|
38
|
+
base_unit: str
|
|
39
|
+
selling_price: Decimal
|
|
40
|
+
barcode: Optional[str] = None
|
|
41
|
+
category_id: Optional[uuid.UUID] = None
|
|
42
|
+
generic_name: Optional[str] = None
|
|
43
|
+
brand_name: Optional[str] = None
|
|
44
|
+
strength: Optional[str] = None
|
|
45
|
+
dosage_form: Optional[str] = None
|
|
46
|
+
manufacturer: Optional[str] = None
|
|
47
|
+
description: Optional[str] = None
|
|
48
|
+
units_per_pack: int = 1
|
|
49
|
+
allow_loose_sale: bool = False
|
|
50
|
+
requires_prescription: bool = False
|
|
51
|
+
is_controlled: bool = False
|
|
52
|
+
is_cold_chain: bool = False
|
|
53
|
+
reorder_level: int = 0
|
|
54
|
+
max_stock: int = 0
|
|
55
|
+
image_url: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
class MedicineUpdate(BaseModel):
|
|
58
|
+
sku: Optional[str] = None
|
|
59
|
+
name_en: Optional[str] = None
|
|
60
|
+
name_ar: Optional[str] = None
|
|
61
|
+
base_unit: Optional[str] = None
|
|
62
|
+
selling_price: Optional[Decimal] = None
|
|
63
|
+
barcode: Optional[str] = None
|
|
64
|
+
category_id: Optional[uuid.UUID] = None
|
|
65
|
+
generic_name: Optional[str] = None
|
|
66
|
+
brand_name: Optional[str] = None
|
|
67
|
+
strength: Optional[str] = None
|
|
68
|
+
dosage_form: Optional[str] = None
|
|
69
|
+
manufacturer: Optional[str] = None
|
|
70
|
+
description: Optional[str] = None
|
|
71
|
+
units_per_pack: Optional[int] = None
|
|
72
|
+
allow_loose_sale: Optional[bool] = None
|
|
73
|
+
requires_prescription: Optional[bool] = None
|
|
74
|
+
is_controlled: Optional[bool] = None
|
|
75
|
+
is_cold_chain: Optional[bool] = None
|
|
76
|
+
reorder_level: Optional[int] = None
|
|
77
|
+
max_stock: Optional[int] = None
|
|
78
|
+
image_url: Optional[str] = None
|
|
79
|
+
is_active: Optional[bool] = None
|
|
80
|
+
|
|
81
|
+
class MedicineResponse(MedicineCreate):
|
|
82
|
+
id: uuid.UUID
|
|
83
|
+
selling_price: Decimal
|
|
84
|
+
is_active: bool
|
|
85
|
+
model_config = {"from_attributes": True}
|
|
86
|
+
|
|
87
|
+
class InitialBatch(BaseModel):
|
|
88
|
+
batch_number: Optional[str] = None
|
|
89
|
+
quantity: Decimal
|
|
90
|
+
purchase_price: Decimal
|
|
91
|
+
production_date: Optional[datetime] = None
|
|
92
|
+
expiry_date: datetime
|
|
93
|
+
supplier_id: Optional[uuid.UUID] = None
|
|
94
|
+
|
|
95
|
+
class MedicineCreateWithBatch(MedicineCreate):
|
|
96
|
+
initial_batch: Optional[InitialBatch] = None
|
|
97
|
+
|
|
98
|
+
class BatchCreate(BaseModel):
|
|
99
|
+
batch_number: str
|
|
100
|
+
expiry_date: datetime
|
|
101
|
+
purchase_price: Decimal
|
|
102
|
+
quantity_received: Decimal
|
|
103
|
+
supplier_id: Optional[uuid.UUID] = None
|
|
104
|
+
production_date: Optional[datetime] = None
|
|
105
|
+
notes: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
class BatchUpdate(BaseModel):
|
|
108
|
+
expiry_date: Optional[datetime] = None
|
|
109
|
+
purchase_price: Optional[Decimal] = None
|
|
110
|
+
production_date: Optional[datetime] = None
|
|
111
|
+
notes: Optional[str] = None
|
|
112
|
+
status: Optional[str] = None # ACTIVE, QUARANTINED, RECALLED, DAMAGED
|
|
113
|
+
|
|
114
|
+
class BatchResponse(BaseModel):
|
|
115
|
+
id: uuid.UUID
|
|
116
|
+
medicine_id: uuid.UUID
|
|
117
|
+
batch_number: str
|
|
118
|
+
expiry_date: datetime
|
|
119
|
+
purchase_price: Decimal
|
|
120
|
+
quantity_received: Decimal
|
|
121
|
+
quantity_remaining: Decimal
|
|
122
|
+
received_date: datetime
|
|
123
|
+
is_active: bool
|
|
124
|
+
production_date: Optional[datetime] = None
|
|
125
|
+
supplier_id: Optional[uuid.UUID] = None
|
|
126
|
+
status: Optional[str] = "ACTIVE"
|
|
127
|
+
notes: Optional[str] = None
|
|
128
|
+
model_config = {"from_attributes": True}
|
|
129
|
+
|
|
130
|
+
class BatchConflictResponse(BaseModel):
|
|
131
|
+
"""Returned when a batch number already exists with a different expiry date"""
|
|
132
|
+
conflict: bool = True
|
|
133
|
+
message: str
|
|
134
|
+
existing_batch_number: str
|
|
135
|
+
existing_expiry_date: datetime
|
|
136
|
+
entered_expiry_date: datetime
|
|
137
|
+
|
|
138
|
+
class MedicineDetailResponse(MedicineResponse):
|
|
139
|
+
batches: List[BatchResponse] = []
|
|
140
|
+
category_name: Optional[str] = None
|
|
141
|
+
|
|
142
|
+
class BulkImportRowError(BaseModel):
|
|
143
|
+
row: int
|
|
144
|
+
field: Optional[str] = None
|
|
145
|
+
message: str
|
|
146
|
+
|
|
147
|
+
class BulkImportResponse(BaseModel):
|
|
148
|
+
total_rows: int
|
|
149
|
+
successful: int
|
|
150
|
+
failed: int
|
|
151
|
+
errors: List[BulkImportRowError] = []
|
|
152
|
+
created_medicines: List[MedicineResponse] = []
|
|
153
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import csv
|
|
3
|
+
from openpyxl import Workbook
|
|
4
|
+
from openpyxl.styles import PatternFill, Font, Alignment
|
|
5
|
+
from openpyxl.worksheet.datavalidation import DataValidation
|
|
6
|
+
from openpyxl.utils import get_column_letter
|
|
7
|
+
|
|
8
|
+
COLUMNS = [
|
|
9
|
+
{"header": "name_en *", "field": "name_en", "desc": "English name of the medicine (Required)"},
|
|
10
|
+
{"header": "name_ar", "field": "name_ar", "desc": "Arabic name"},
|
|
11
|
+
{"header": "sku", "field": "sku", "desc": "Stock Keeping Unit (Auto-generated if empty)"},
|
|
12
|
+
{"header": "barcode", "field": "barcode", "desc": "Barcode for scanning"},
|
|
13
|
+
{"header": "selling_price *", "field": "selling_price", "desc": "Selling price in base currency (Required)"},
|
|
14
|
+
{"header": "base_unit *", "field": "base_unit", "desc": "Base unit (Required)"},
|
|
15
|
+
{"header": "category", "field": "category", "desc": "Category name (Matched with existing)"},
|
|
16
|
+
{"header": "generic_name", "field": "generic_name", "desc": "Generic name / Scientific name"},
|
|
17
|
+
{"header": "brand_name", "field": "brand_name", "desc": "Brand name"},
|
|
18
|
+
{"header": "strength", "field": "strength", "desc": "Strength (e.g., 500mg)"},
|
|
19
|
+
{"header": "dosage_form", "field": "dosage_form", "desc": "Form of the medicine"},
|
|
20
|
+
{"header": "manufacturer", "field": "manufacturer", "desc": "Manufacturer name"},
|
|
21
|
+
{"header": "description", "field": "description", "desc": "Additional details"},
|
|
22
|
+
{"header": "units_per_pack", "field": "units_per_pack", "desc": "Number of units per pack (Default: 1)"},
|
|
23
|
+
{"header": "max_stock", "field": "max_stock", "desc": "Maximum stock allowed (Default: 0)"},
|
|
24
|
+
{"header": "initial_stock", "field": "initial_stock", "desc": "Initial stock quantity"},
|
|
25
|
+
{"header": "purchase_price", "field": "purchase_price", "desc": "Purchase price"},
|
|
26
|
+
{"header": "batch_number", "field": "batch_number", "desc": "Batch/Lot number"},
|
|
27
|
+
{"header": "expiry_date", "field": "expiry_date", "desc": "Expiry date YYYY-MM-DD"},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
EXAMPLE_ROW = [
|
|
31
|
+
"Amoxicillin 500mg",
|
|
32
|
+
"أموكسيسيلين 500 ملغ",
|
|
33
|
+
"AMX-500",
|
|
34
|
+
"1234567890123",
|
|
35
|
+
"15.50",
|
|
36
|
+
"Box",
|
|
37
|
+
"Antibiotics",
|
|
38
|
+
"Amoxicillin",
|
|
39
|
+
"Amoxil",
|
|
40
|
+
"500mg",
|
|
41
|
+
"capsule",
|
|
42
|
+
"GSK",
|
|
43
|
+
"Broad-spectrum antibiotic",
|
|
44
|
+
"20",
|
|
45
|
+
"100",
|
|
46
|
+
"100",
|
|
47
|
+
"10.50",
|
|
48
|
+
"B-AMX-500",
|
|
49
|
+
"2027-12-31"
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
BASE_UNITS = ["Pack", "Tablet", "Bottle", "Box", "Tube", "Strip", "Vial", "Ampoule", "Piece"]
|
|
53
|
+
DOSAGE_FORMS = ["tablet", "capsule", "syrup", "suspension", "injection", "infusion", "cream", "ointment", "gel", "drops", "eye_drops", "ear_drops", "nasal_spray", "inhaler", "suppository", "powder"]
|
|
54
|
+
|
|
55
|
+
def generate_csv_template() -> io.BytesIO:
|
|
56
|
+
output = io.StringIO()
|
|
57
|
+
writer = csv.writer(output)
|
|
58
|
+
|
|
59
|
+
headers = [col["header"] for col in COLUMNS]
|
|
60
|
+
descriptions = [col["desc"] for col in COLUMNS]
|
|
61
|
+
|
|
62
|
+
writer.writerow(headers)
|
|
63
|
+
writer.writerow(EXAMPLE_ROW)
|
|
64
|
+
writer.writerow(descriptions)
|
|
65
|
+
|
|
66
|
+
bio = io.BytesIO(output.getvalue().encode('utf-8'))
|
|
67
|
+
return bio
|
|
68
|
+
|
|
69
|
+
def generate_xlsx_template() -> io.BytesIO:
|
|
70
|
+
wb = Workbook()
|
|
71
|
+
ws = wb.active
|
|
72
|
+
ws.title = "Medicines Import"
|
|
73
|
+
|
|
74
|
+
headers = [col["header"] for col in COLUMNS]
|
|
75
|
+
descriptions = [col["desc"] for col in COLUMNS]
|
|
76
|
+
|
|
77
|
+
ws.append(headers)
|
|
78
|
+
ws.append(EXAMPLE_ROW)
|
|
79
|
+
ws.append(descriptions)
|
|
80
|
+
|
|
81
|
+
# Styles
|
|
82
|
+
header_fill = PatternFill(start_color="000080", end_color="000080", fill_type="solid")
|
|
83
|
+
header_font = Font(color="FFFFFF", bold=True)
|
|
84
|
+
example_fill = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
|
|
85
|
+
desc_fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
|
86
|
+
|
|
87
|
+
for col_idx, cell in enumerate(ws[1], 1):
|
|
88
|
+
cell.fill = header_fill
|
|
89
|
+
cell.font = header_font
|
|
90
|
+
cell.alignment = Alignment(horizontal="center")
|
|
91
|
+
|
|
92
|
+
for cell in ws[2]:
|
|
93
|
+
cell.fill = example_fill
|
|
94
|
+
|
|
95
|
+
for cell in ws[3]:
|
|
96
|
+
cell.fill = desc_fill
|
|
97
|
+
|
|
98
|
+
# Auto-fit columns
|
|
99
|
+
for col_idx, col in enumerate(COLUMNS, 1):
|
|
100
|
+
col_letter = get_column_letter(col_idx)
|
|
101
|
+
max_length = max(len(str(col["header"])), len(str(col["desc"])), len(str(EXAMPLE_ROW[col_idx-1])))
|
|
102
|
+
ws.column_dimensions[col_letter].width = max_length + 2
|
|
103
|
+
|
|
104
|
+
# Data Validation
|
|
105
|
+
# base_unit is column 6 (F)
|
|
106
|
+
dv_base_unit = DataValidation(type="list", formula1=f'"{",".join(BASE_UNITS)}"', allow_blank=False)
|
|
107
|
+
ws.add_data_validation(dv_base_unit)
|
|
108
|
+
dv_base_unit.add(f"F4:F1000")
|
|
109
|
+
|
|
110
|
+
# dosage_form is column 11 (K)
|
|
111
|
+
dv_dosage_form = DataValidation(type="list", formula1=f'"{",".join(DOSAGE_FORMS)}"', allow_blank=True)
|
|
112
|
+
ws.add_data_validation(dv_dosage_form)
|
|
113
|
+
dv_dosage_form.add(f"K4:K1000")
|
|
114
|
+
|
|
115
|
+
bio = io.BytesIO()
|
|
116
|
+
wb.save(bio)
|
|
117
|
+
bio.seek(0)
|
|
118
|
+
return bio
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Notifications module
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
2
|
+
from sqlalchemy import String, Boolean, ForeignKey, Text
|
|
3
|
+
from app.core.database import Base
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class Notification(Base):
|
|
7
|
+
__tablename__ = "notifications"
|
|
8
|
+
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=True)
|
|
9
|
+
type: Mapped[str] = mapped_column(String) # info, warning, danger, success
|
|
10
|
+
title: Mapped[str] = mapped_column(String)
|
|
11
|
+
title_ar: Mapped[str] = mapped_column(String, nullable=True)
|
|
12
|
+
message: Mapped[str] = mapped_column(Text)
|
|
13
|
+
message_ar: Mapped[str] = mapped_column(Text, nullable=True)
|
|
14
|
+
link: Mapped[str] = mapped_column(String, nullable=True)
|
|
15
|
+
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlalchemy import select, update, delete, func, or_
|
|
4
|
+
from typing import List
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from app.core.database import get_db
|
|
8
|
+
from app.core.deps import get_current_user
|
|
9
|
+
from app.modules.auth.models import User
|
|
10
|
+
from app.modules.notifications.models import Notification
|
|
11
|
+
from app.modules.notifications.schemas import NotificationResponse, UnreadCountResponse
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["Notifications"])
|
|
14
|
+
|
|
15
|
+
@router.get("/", response_model=List[NotificationResponse])
|
|
16
|
+
async def get_notifications(
|
|
17
|
+
db: AsyncSession = Depends(get_db),
|
|
18
|
+
current_user: User = Depends(get_current_user)
|
|
19
|
+
):
|
|
20
|
+
stmt = (
|
|
21
|
+
select(Notification)
|
|
22
|
+
.where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
|
|
23
|
+
.order_by(Notification.created_at.desc())
|
|
24
|
+
.limit(50)
|
|
25
|
+
)
|
|
26
|
+
result = await db.execute(stmt)
|
|
27
|
+
return result.scalars().all()
|
|
28
|
+
|
|
29
|
+
@router.get("/unread-count", response_model=UnreadCountResponse)
|
|
30
|
+
async def get_unread_count(
|
|
31
|
+
db: AsyncSession = Depends(get_db),
|
|
32
|
+
current_user: User = Depends(get_current_user)
|
|
33
|
+
):
|
|
34
|
+
stmt = (
|
|
35
|
+
select(func.count(Notification.id))
|
|
36
|
+
.where(
|
|
37
|
+
or_(Notification.user_id == current_user.id, Notification.user_id == None),
|
|
38
|
+
Notification.is_read == False
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
result = await db.execute(stmt)
|
|
42
|
+
count = result.scalar() or 0
|
|
43
|
+
return {"unread_count": count}
|
|
44
|
+
|
|
45
|
+
@router.patch("/{id}/read")
|
|
46
|
+
async def mark_as_read(
|
|
47
|
+
id: UUID,
|
|
48
|
+
db: AsyncSession = Depends(get_db),
|
|
49
|
+
current_user: User = Depends(get_current_user)
|
|
50
|
+
):
|
|
51
|
+
stmt = (
|
|
52
|
+
update(Notification)
|
|
53
|
+
.where(Notification.id == id)
|
|
54
|
+
.where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
|
|
55
|
+
.values(is_read=True)
|
|
56
|
+
)
|
|
57
|
+
await db.execute(stmt)
|
|
58
|
+
await db.commit()
|
|
59
|
+
return {"status": "success"}
|
|
60
|
+
|
|
61
|
+
@router.post("/read-all")
|
|
62
|
+
async def mark_all_as_read(
|
|
63
|
+
db: AsyncSession = Depends(get_db),
|
|
64
|
+
current_user: User = Depends(get_current_user)
|
|
65
|
+
):
|
|
66
|
+
stmt = (
|
|
67
|
+
update(Notification)
|
|
68
|
+
.where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
|
|
69
|
+
.where(Notification.is_read == False)
|
|
70
|
+
.values(is_read=True)
|
|
71
|
+
)
|
|
72
|
+
await db.execute(stmt)
|
|
73
|
+
await db.commit()
|
|
74
|
+
return {"status": "success"}
|
|
75
|
+
|
|
76
|
+
@router.delete("/{id}")
|
|
77
|
+
async def delete_notification(
|
|
78
|
+
id: UUID,
|
|
79
|
+
db: AsyncSession = Depends(get_db),
|
|
80
|
+
current_user: User = Depends(get_current_user)
|
|
81
|
+
):
|
|
82
|
+
stmt = (
|
|
83
|
+
delete(Notification)
|
|
84
|
+
.where(Notification.id == id)
|
|
85
|
+
.where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
|
|
86
|
+
)
|
|
87
|
+
await db.execute(stmt)
|
|
88
|
+
await db.commit()
|
|
89
|
+
return {"status": "success"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
class NotificationResponse(BaseModel):
|
|
7
|
+
id: UUID
|
|
8
|
+
user_id: Optional[UUID] = None
|
|
9
|
+
type: str
|
|
10
|
+
title: str
|
|
11
|
+
title_ar: Optional[str] = None
|
|
12
|
+
message: str
|
|
13
|
+
message_ar: Optional[str] = None
|
|
14
|
+
link: Optional[str] = None
|
|
15
|
+
is_read: bool
|
|
16
|
+
created_at: datetime
|
|
17
|
+
updated_at: datetime
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(from_attributes=True)
|
|
20
|
+
|
|
21
|
+
class UnreadCountResponse(BaseModel):
|
|
22
|
+
unread_count: int
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
2
|
+
from sqlalchemy import String, Boolean
|
|
3
|
+
from app.core.database import Base
|
|
4
|
+
|
|
5
|
+
class Branch(Base):
|
|
6
|
+
__tablename__ = "branches"
|
|
7
|
+
name: Mapped[str] = mapped_column(String)
|
|
8
|
+
name_ar: Mapped[str] = mapped_column(String, nullable=True)
|
|
9
|
+
code: Mapped[str] = mapped_column(String, unique=True)
|
|
10
|
+
address: Mapped[str] = mapped_column(String, nullable=True)
|
|
11
|
+
phone: Mapped[str] = mapped_column(String, nullable=True)
|
|
12
|
+
email: Mapped[str] = mapped_column(String, nullable=True)
|
|
13
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
14
|
+
currency: Mapped[str] = mapped_column(String, default="SAR")
|
|
15
|
+
timezone: Mapped[str] = mapped_column(String, default="Asia/Riyadh")
|
|
16
|
+
locale: Mapped[str] = mapped_column(String, default="ar_SA")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from typing import List
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from app.core.database import get_db
|
|
8
|
+
from app.core.deps import get_current_user
|
|
9
|
+
from app.modules.auth.models import User
|
|
10
|
+
from app.modules.organizations.models import Branch
|
|
11
|
+
from app.modules.organizations.schemas import BranchCreate, BranchUpdate, BranchResponse
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
@router.post("/", response_model=BranchResponse)
|
|
16
|
+
async def create_branch(branch: BranchCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
17
|
+
db_branch = Branch(**branch.model_dump())
|
|
18
|
+
db.add(db_branch)
|
|
19
|
+
await db.commit()
|
|
20
|
+
await db.refresh(db_branch)
|
|
21
|
+
return db_branch
|
|
22
|
+
|
|
23
|
+
@router.get("/", response_model=List[BranchResponse])
|
|
24
|
+
async def list_branches(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
25
|
+
result = await db.execute(select(Branch))
|
|
26
|
+
return result.scalars().all()
|
|
27
|
+
|
|
28
|
+
@router.get("/{id}", response_model=BranchResponse)
|
|
29
|
+
async def get_branch(id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
30
|
+
result = await db.execute(select(Branch).where(Branch.id == id))
|
|
31
|
+
branch = result.scalar_one_or_none()
|
|
32
|
+
if not branch:
|
|
33
|
+
raise HTTPException(status_code=404, detail="Branch not found")
|
|
34
|
+
return branch
|
|
35
|
+
|
|
36
|
+
@router.put("/{id}", response_model=BranchResponse)
|
|
37
|
+
async def update_branch(id: uuid.UUID, branch: BranchUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
38
|
+
result = await db.execute(select(Branch).where(Branch.id == id))
|
|
39
|
+
db_branch = result.scalar_one_or_none()
|
|
40
|
+
if not db_branch:
|
|
41
|
+
raise HTTPException(status_code=404, detail="Branch not found")
|
|
42
|
+
|
|
43
|
+
update_data = branch.model_dump(exclude_unset=True)
|
|
44
|
+
for key, value in update_data.items():
|
|
45
|
+
setattr(db_branch, key, value)
|
|
46
|
+
|
|
47
|
+
await db.commit()
|
|
48
|
+
await db.refresh(db_branch)
|
|
49
|
+
return db_branch
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pydantic import BaseModel, EmailStr
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
class BranchCreate(BaseModel):
|
|
6
|
+
name: str
|
|
7
|
+
code: str
|
|
8
|
+
name_ar: Optional[str] = None
|
|
9
|
+
address: Optional[str] = None
|
|
10
|
+
phone: Optional[str] = None
|
|
11
|
+
email: Optional[EmailStr] = None
|
|
12
|
+
currency: str = "SAR"
|
|
13
|
+
timezone: str = "Asia/Riyadh"
|
|
14
|
+
locale: str = "ar_SA"
|
|
15
|
+
|
|
16
|
+
class BranchUpdate(BaseModel):
|
|
17
|
+
name: Optional[str] = None
|
|
18
|
+
code: Optional[str] = None
|
|
19
|
+
name_ar: Optional[str] = None
|
|
20
|
+
address: Optional[str] = None
|
|
21
|
+
phone: Optional[str] = None
|
|
22
|
+
email: Optional[EmailStr] = None
|
|
23
|
+
currency: Optional[str] = None
|
|
24
|
+
timezone: Optional[str] = None
|
|
25
|
+
locale: Optional[str] = None
|
|
26
|
+
is_active: Optional[bool] = None
|
|
27
|
+
|
|
28
|
+
class BranchResponse(BranchCreate):
|
|
29
|
+
id: uuid.UUID
|
|
30
|
+
is_active: bool
|
|
31
|
+
model_config = {"from_attributes": True}
|
|
File without changes
|