Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
ClubPython
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
ClubPython
Commits
4456643a
Commit
4456643a
authored
Apr 07, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 51 files via Son of Anton
parent
449c59b9
Changes
51
Hide whitespace changes
Inline
Side-by-side
Showing
51 changed files
with
2421 additions
and
71 deletions
+2421
-71
admin.py
backend/apps/archive/admin.py
+21
-1
models.py
backend/apps/archive/models.py
+34
-1
serializers.py
backend/apps/archive/serializers.py
+13
-1
tests.py
backend/apps/archive/tests.py
+27
-1
urls.py
backend/apps/archive/urls.py
+10
-1
views.py
backend/apps/archive/views.py
+16
-1
admin.py
backend/apps/audit/admin.py
+25
-1
middleware.py
backend/apps/audit/middleware.py
+29
-14
models.py
backend/apps/audit/models.py
+39
-1
serializers.py
backend/apps/audit/serializers.py
+13
-1
tests.py
backend/apps/audit/tests.py
+29
-1
urls.py
backend/apps/audit/urls.py
+10
-1
views.py
backend/apps/audit/views.py
+18
-1
backends.py
backend/apps/authentication/backends.py
+35
-1
serializers.py
backend/apps/authentication/serializers.py
+123
-3
tests.py
backend/apps/authentication/tests.py
+100
-1
urls.py
backend/apps/authentication/urls.py
+12
-1
views.py
backend/apps/authentication/views.py
+142
-1
admin.py
backend/apps/branches/admin.py
+11
-1
seed_branches.py
backend/apps/branches/management/commands/seed_branches.py
+10
-0
models.py
backend/apps/branches/models.py
+32
-1
seed.py
backend/apps/branches/seed.py
+37
-1
serializers.py
backend/apps/branches/serializers.py
+12
-1
tests.py
backend/apps/branches/tests.py
+37
-1
urls.py
backend/apps/branches/urls.py
+10
-1
views.py
backend/apps/branches/views.py
+16
-1
admin.py
backend/apps/roles/admin.py
+38
-1
seed_roles.py
backend/apps/roles/management/commands/seed_roles.py
+12
-0
models.py
backend/apps/roles/models.py
+113
-1
seed.py
backend/apps/roles/seed.py
+307
-1
serializers.py
backend/apps/roles/serializers.py
+66
-1
tests.py
backend/apps/roles/tests.py
+55
-1
urls.py
backend/apps/roles/urls.py
+12
-1
views.py
backend/apps/roles/views.py
+69
-1
admin.py
backend/apps/settings_app/admin.py
+12
-1
seed_settings.py
...nd/apps/settings_app/management/commands/seed_settings.py
+10
-0
models.py
backend/apps/settings_app/models.py
+62
-1
seed.py
backend/apps/settings_app/seed.py
+173
-1
serializers.py
backend/apps/settings_app/serializers.py
+18
-1
tests.py
backend/apps/settings_app/tests.py
+29
-1
urls.py
backend/apps/settings_app/urls.py
+10
-1
views.py
backend/apps/settings_app/views.py
+27
-1
admin.py
backend/apps/users/admin.py
+42
-5
apps.py
backend/apps/users/apps.py
+2
-2
models.py
backend/apps/users/models.py
+98
-4
permissions.py
backend/apps/users/permissions.py
+103
-1
serializers.py
backend/apps/users/serializers.py
+97
-1
signals.py
backend/apps/users/signals.py
+23
-1
tests.py
backend/apps/users/tests.py
+93
-1
urls.py
backend/apps/users/urls.py
+10
-1
views.py
backend/apps/users/views.py
+79
-1
No files found.
backend/apps/archive/admin.py
View file @
4456643a
# Admin for archive — implemented in Phase 2
\ No newline at end of file
from
django.contrib
import
admin
from
simple_history.admin
import
SimpleHistoryAdmin
from
.models
import
MembershipNumberChain
@
admin
.
register
(
MembershipNumberChain
)
class
MembershipNumberChainAdmin
(
SimpleHistoryAdmin
):
list_display
=
[
'old_membership_number'
,
'new_membership_number'
,
'member_name'
,
'transfer_type'
,
'transfer_date'
,
]
list_filter
=
[
'transfer_type'
,
'transfer_date'
]
search_fields
=
[
'old_membership_number'
,
'new_membership_number'
,
'member_name'
]
readonly_fields
=
[
'old_membership_number'
,
'new_membership_number'
,
'member_name'
,
'transfer_type'
,
'transfer_id'
,
'transfer_date'
,
'notes'
,
'created_at'
,
]
date_hierarchy
=
'transfer_date'
def
has_delete_permission
(
self
,
request
,
obj
=
None
):
return
False
\ No newline at end of file
backend/apps/archive/models.py
View file @
4456643a
# Models for archive — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Archive Models
================================
Membership number chain tracking for transfers, separations, etc.
"""
from
django.db
import
models
from
simple_history.models
import
HistoricalRecords
import
auditlog
class
MembershipNumberChain
(
models
.
Model
):
"""Tracks the chain of ownership for membership numbers across transfers."""
old_membership_number
=
models
.
CharField
(
max_length
=
20
,
db_index
=
True
,
verbose_name
=
"رقم العضوية القديم"
)
new_membership_number
=
models
.
CharField
(
max_length
=
20
,
db_index
=
True
,
verbose_name
=
"رقم العضوية الجديد"
)
member_name
=
models
.
CharField
(
max_length
=
200
,
verbose_name
=
"اسم العضو"
)
transfer_type
=
models
.
CharField
(
max_length
=
30
,
verbose_name
=
"نوع التحويل"
)
transfer_id
=
models
.
BigIntegerField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"معرف التحويل"
)
transfer_date
=
models
.
DateField
(
verbose_name
=
"تاريخ التحويل"
)
notes
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"ملاحظات"
)
created_at
=
models
.
DateTimeField
(
auto_now_add
=
True
,
verbose_name
=
"تاريخ الإنشاء"
)
history
=
HistoricalRecords
()
class
Meta
:
ordering
=
[
'-transfer_date'
]
verbose_name
=
"سلسلة أرقام العضوية"
verbose_name_plural
=
"سلاسل أرقام العضوية"
def
__str__
(
self
):
return
f
"{self.old_membership_number} → {self.new_membership_number} ({self.transfer_type})"
auditlog
.
register
(
MembershipNumberChain
)
\ No newline at end of file
backend/apps/archive/serializers.py
View file @
4456643a
# Serializers for archive — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
serializers
from
.models
import
MembershipNumberChain
class
MembershipNumberChainSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
MembershipNumberChain
fields
=
[
'id'
,
'old_membership_number'
,
'new_membership_number'
,
'member_name'
,
'transfer_type'
,
'transfer_id'
,
'transfer_date'
,
'notes'
,
'created_at'
,
]
read_only_fields
=
fields
\ No newline at end of file
backend/apps/archive/tests.py
View file @
4456643a
# Tests for archive — implemented in Phase 2
\ No newline at end of file
from
django.test
import
TestCase
from
datetime
import
date
from
.models
import
MembershipNumberChain
class
ArchiveTests
(
TestCase
):
def
test_create_number_chain
(
self
):
chain
=
MembershipNumberChain
.
objects
.
create
(
old_membership_number
=
'1001/2'
,
new_membership_number
=
'1050/2'
,
member_name
=
'أحمد محمد'
,
transfer_type
=
'SEPARATION'
,
transfer_date
=
date
.
today
(),
)
self
.
assertEqual
(
str
(
chain
),
'1001/2 → 1050/2 (SEPARATION)'
)
def
test_chain_ordering
(
self
):
MembershipNumberChain
.
objects
.
create
(
old_membership_number
=
'1001/2'
,
new_membership_number
=
'1050/2'
,
member_name
=
'أ'
,
transfer_type
=
'SEPARATION'
,
transfer_date
=
date
(
2024
,
1
,
1
),
)
MembershipNumberChain
.
objects
.
create
(
old_membership_number
=
'1002/2'
,
new_membership_number
=
'1051/2'
,
member_name
=
'ب'
,
transfer_type
=
'DIVORCE'
,
transfer_date
=
date
(
2024
,
6
,
1
),
)
chains
=
MembershipNumberChain
.
objects
.
all
()
self
.
assertEqual
(
chains
[
0
]
.
transfer_date
,
date
(
2024
,
6
,
1
))
\ No newline at end of file
backend/apps/archive/urls.py
View file @
4456643a
urlpatterns
=
[]
\ No newline at end of file
from
django.urls
import
path
,
include
from
rest_framework.routers
import
DefaultRouter
from
.
import
views
router
=
DefaultRouter
()
router
.
register
(
r''
,
views
.
MembershipNumberChainViewSet
,
basename
=
'numberchain'
)
urlpatterns
=
[
path
(
''
,
include
(
router
.
urls
)),
]
\ No newline at end of file
backend/apps/archive/views.py
View file @
4456643a
# Views for archive — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
viewsets
,
permissions
from
django_filters.rest_framework
import
DjangoFilterBackend
from
rest_framework.filters
import
SearchFilter
,
OrderingFilter
from
.models
import
MembershipNumberChain
from
.serializers
import
MembershipNumberChainSerializer
class
MembershipNumberChainViewSet
(
viewsets
.
ReadOnlyModelViewSet
):
queryset
=
MembershipNumberChain
.
objects
.
all
()
serializer_class
=
MembershipNumberChainSerializer
permission_classes
=
[
permissions
.
IsAuthenticated
]
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
,
OrderingFilter
]
filterset_fields
=
[
'transfer_type'
]
search_fields
=
[
'old_membership_number'
,
'new_membership_number'
,
'member_name'
]
ordering_fields
=
[
'transfer_date'
,
'created_at'
]
ordering
=
[
'-transfer_date'
]
\ No newline at end of file
backend/apps/audit/admin.py
View file @
4456643a
# Admin for audit — implemented in Phase 2
\ No newline at end of file
from
django.contrib
import
admin
from
.models
import
AuditLog
@
admin
.
register
(
AuditLog
)
class
AuditLogAdmin
(
admin
.
ModelAdmin
):
list_display
=
[
'timestamp'
,
'table_name'
,
'action'
,
'record_id'
,
'actor_username'
,
'ip_address'
]
list_filter
=
[
'action'
,
'table_name'
,
'timestamp'
]
search_fields
=
[
'actor_username'
,
'table_name'
,
'notes'
]
readonly_fields
=
[
'table_name'
,
'record_id'
,
'action'
,
'before_data'
,
'after_data'
,
'actor_id'
,
'actor_username'
,
'actor_role'
,
'ip_address'
,
'session_key'
,
'user_agent'
,
'timestamp'
,
'notes'
,
]
date_hierarchy
=
'timestamp'
ordering
=
[
'-timestamp'
]
def
has_add_permission
(
self
,
request
):
return
False
def
has_change_permission
(
self
,
request
,
obj
=
None
):
return
False
def
has_delete_permission
(
self
,
request
,
obj
=
None
):
return
False
\ No newline at end of file
backend/apps/audit/middleware.py
View file @
4456643a
"""
THE CLUB ERP — Audit
IP
Middleware
================================
===
Captures IP a
ddress and user-agent for audit logging
.
THE CLUB ERP — Audit Middleware
================================
Captures IP a
nd user-agent for every request and makes it available
.
"""
import
threading
_request_local
=
threading
.
local
()
def
get_current_request
():
return
getattr
(
_request_local
,
'request'
,
None
)
def
get_client_ip
(
request
=
None
):
request
=
request
or
get_current_request
()
if
not
request
:
return
None
xff
=
request
.
META
.
get
(
'HTTP_X_FORWARDED_FOR'
)
return
xff
.
split
(
','
)[
0
]
.
strip
()
if
xff
else
request
.
META
.
get
(
'REMOTE_ADDR'
)
def
get_user_agent
(
request
=
None
):
request
=
request
or
get_current_request
()
if
not
request
:
return
''
return
request
.
META
.
get
(
'HTTP_USER_AGENT'
,
''
)
class
AuditIPMiddleware
:
"""
Middleware that attaches IP and user-agent to the request
for audit logging."""
"""
Stores the current request in thread-local
for audit logging."""
def
__init__
(
self
,
get_response
):
self
.
get_response
=
get_response
def
__call__
(
self
,
request
):
# Extract IP address
x_forwarded_for
=
request
.
META
.
get
(
'HTTP_X_FORWARDED_FOR'
)
if
x_forwarded_for
:
request
.
audit_ip
=
x_forwarded_for
.
split
(
','
)[
0
]
.
strip
()
else
:
request
.
audit_ip
=
request
.
META
.
get
(
'REMOTE_ADDR'
,
'0.0.0.0'
)
# Extract user-agent
request
.
audit_user_agent
=
request
.
META
.
get
(
'HTTP_USER_AGENT'
,
''
)
_request_local
.
request
=
request
response
=
self
.
get_response
(
request
)
if
hasattr
(
_request_local
,
'request'
):
del
_request_local
.
request
return
response
\ No newline at end of file
backend/apps/audit/models.py
View file @
4456643a
# Models for audit — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Audit Trail Model
==================================
IMMUTABLE. No updates. No deletes. INSERT ONLY.
"""
from
django.db
import
models
class
AuditLog
(
models
.
Model
):
"""Immutable audit trail. Every action in the system is logged here."""
table_name
=
models
.
CharField
(
max_length
=
100
,
db_index
=
True
,
verbose_name
=
"الجدول"
)
record_id
=
models
.
BigIntegerField
(
db_index
=
True
,
default
=
0
,
verbose_name
=
"رقم السجل"
)
action
=
models
.
CharField
(
max_length
=
20
,
verbose_name
=
"الإجراء"
)
before_data
=
models
.
JSONField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"البيانات السابقة"
)
after_data
=
models
.
JSONField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"البيانات الجديدة"
)
actor_id
=
models
.
BigIntegerField
(
null
=
True
,
blank
=
True
,
db_index
=
True
,
verbose_name
=
"معرف الفاعل"
)
actor_username
=
models
.
CharField
(
max_length
=
50
,
blank
=
True
,
default
=
""
,
verbose_name
=
"اسم المستخدم"
)
actor_role
=
models
.
CharField
(
max_length
=
50
,
blank
=
True
,
default
=
""
,
verbose_name
=
"دور الفاعل"
)
ip_address
=
models
.
GenericIPAddressField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"عنوان IP"
)
session_key
=
models
.
CharField
(
max_length
=
64
,
blank
=
True
,
default
=
""
,
verbose_name
=
"مفتاح الجلسة"
)
user_agent
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"المتصفح"
)
timestamp
=
models
.
DateTimeField
(
auto_now_add
=
True
,
db_index
=
True
,
verbose_name
=
"التوقيت"
)
notes
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"ملاحظات"
)
class
Meta
:
ordering
=
[
'-timestamp'
]
verbose_name
=
"سجل المراجعة"
verbose_name_plural
=
"سجلات المراجعة"
indexes
=
[
models
.
Index
(
fields
=
[
'table_name'
,
'record_id'
]),
models
.
Index
(
fields
=
[
'actor_id'
,
'timestamp'
]),
models
.
Index
(
fields
=
[
'timestamp'
]),
]
def
__str__
(
self
):
return
f
"[{self.timestamp:
%
Y-
%
m-
%
d
%
H:
%
M}] {self.action} on {self.table_name}#{self.record_id} by {self.actor_username}"
# NO history = HistoricalRecords() — this IS the history
# NO auditlog.register() — this IS the audit log
\ No newline at end of file
backend/apps/audit/serializers.py
View file @
4456643a
# Serializers for audit — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
serializers
from
.models
import
AuditLog
class
AuditLogSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
AuditLog
fields
=
[
'id'
,
'table_name'
,
'record_id'
,
'action'
,
'before_data'
,
'after_data'
,
'actor_id'
,
'actor_username'
,
'actor_role'
,
'ip_address'
,
'session_key'
,
'user_agent'
,
'timestamp'
,
'notes'
,
]
read_only_fields
=
fields
\ No newline at end of file
backend/apps/audit/tests.py
View file @
4456643a
# Tests for audit — implemented in Phase 2
\ No newline at end of file
from
django.test
import
TestCase
from
.models
import
AuditLog
class
AuditLogTests
(
TestCase
):
def
test_create_audit_log
(
self
):
log
=
AuditLog
.
objects
.
create
(
table_name
=
'test_table'
,
record_id
=
1
,
action
=
'CREATE'
,
actor_username
=
'test'
,
notes
=
'test entry'
,
)
self
.
assertIsNotNone
(
log
.
timestamp
)
self
.
assertEqual
(
log
.
action
,
'CREATE'
)
def
test_audit_log_immutability_in_admin
(
self
):
from
django.contrib.admin.sites
import
AdminSite
from
.admin
import
AuditLogAdmin
admin_instance
=
AuditLogAdmin
(
AuditLog
,
AdminSite
())
self
.
assertFalse
(
admin_instance
.
has_add_permission
(
None
))
self
.
assertFalse
(
admin_instance
.
has_change_permission
(
None
))
self
.
assertFalse
(
admin_instance
.
has_delete_permission
(
None
))
def
test_ordering
(
self
):
AuditLog
.
objects
.
create
(
table_name
=
'a'
,
record_id
=
1
,
action
=
'CREATE'
)
AuditLog
.
objects
.
create
(
table_name
=
'b'
,
record_id
=
2
,
action
=
'UPDATE'
)
logs
=
AuditLog
.
objects
.
all
()
self
.
assertGreaterEqual
(
logs
[
0
]
.
timestamp
,
logs
[
1
]
.
timestamp
)
\ No newline at end of file
backend/apps/audit/urls.py
View file @
4456643a
urlpatterns
=
[]
\ No newline at end of file
from
django.urls
import
path
,
include
from
rest_framework.routers
import
DefaultRouter
from
.
import
views
router
=
DefaultRouter
()
router
.
register
(
r''
,
views
.
AuditLogViewSet
,
basename
=
'auditlog'
)
urlpatterns
=
[
path
(
''
,
include
(
router
.
urls
)),
]
\ No newline at end of file
backend/apps/audit/views.py
View file @
4456643a
# Views for audit — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
viewsets
,
permissions
from
django_filters.rest_framework
import
DjangoFilterBackend
from
rest_framework.filters
import
SearchFilter
,
OrderingFilter
from
.models
import
AuditLog
from
.serializers
import
AuditLogSerializer
from
apps.users.permissions
import
HasClubPermission
class
AuditLogViewSet
(
viewsets
.
ReadOnlyModelViewSet
):
queryset
=
AuditLog
.
objects
.
all
()
serializer_class
=
AuditLogSerializer
permission_classes
=
[
permissions
.
IsAuthenticated
,
HasClubPermission
]
required_permission
=
'report.view_audit'
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
,
OrderingFilter
]
filterset_fields
=
[
'table_name'
,
'action'
,
'actor_username'
,
'actor_id'
]
search_fields
=
[
'actor_username'
,
'table_name'
,
'notes'
]
ordering_fields
=
[
'timestamp'
,
'table_name'
,
'action'
]
ordering
=
[
'-timestamp'
]
\ No newline at end of file
backend/apps/authentication/backends.py
View file @
4456643a
# Custom authentication backends — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Custom Authentication Backend
"""
from
django.contrib.auth
import
get_user_model
from
django.contrib.auth.backends
import
ModelBackend
User
=
get_user_model
()
class
ClubAuthBackend
(
ModelBackend
):
"""
Custom backend that enforces lockout and active checks.
"""
def
authenticate
(
self
,
request
,
username
=
None
,
password
=
None
,
**
kwargs
):
try
:
user
=
User
.
objects
.
get
(
username
=
username
)
except
User
.
DoesNotExist
:
return
None
if
not
user
.
is_active
:
return
None
if
user
.
is_locked
:
return
None
if
user
.
check_password
(
password
):
return
user
return
None
def
get_user
(
self
,
user_id
):
try
:
return
User
.
objects
.
get
(
pk
=
user_id
)
except
User
.
DoesNotExist
:
return
None
\ No newline at end of file
backend/apps/authentication/serializers.py
View file @
4456643a
# Serializers for authentication — implemented in Phase 2
"""
THE CLUB ERP — Authentication Serializers
"""
from
django.contrib.auth
import
authenticate
from
django.utils
import
timezone
from
rest_framework
import
serializers
from
rest_framework_simplejwt.serializers
import
TokenObtainPairSerializer
from
rest_framework_simplejwt.tokens
import
RefreshToken
from
apps.users.models
import
Employee
from
apps.users.permissions
import
user_permissions_list
class
CustomTokenObtainPairSerializer
(
TokenObtainPairSerializer
):
"""Placeholder — full implementation in Phase 2."""
pass
\ No newline at end of file
"""Custom JWT login with lockout enforcement and extra claims."""
def
validate
(
self
,
attrs
):
username
=
attrs
.
get
(
'username'
,
''
)
password
=
attrs
.
get
(
'password'
,
''
)
try
:
employee
=
Employee
.
objects
.
get
(
username
=
username
)
except
Employee
.
DoesNotExist
:
raise
serializers
.
ValidationError
({
'detail'
:
'اسم المستخدم أو كلمة المرور غير صحيحة'
})
if
not
employee
.
is_active
:
raise
serializers
.
ValidationError
({
'detail'
:
'هذا الحساب غير نشط. تواصل مع المدير'
})
if
employee
.
is_locked
:
remaining
=
(
employee
.
locked_until
-
timezone
.
now
())
.
seconds
//
60
raise
serializers
.
ValidationError
(
{
'detail'
:
f
'الحساب مقفل. حاول بعد {remaining + 1} دقيقة'
}
)
user
=
authenticate
(
request
=
self
.
context
.
get
(
'request'
),
username
=
username
,
password
=
password
)
if
user
is
None
:
employee
.
increment_failed_login
(
max_attempts
=
5
,
lockout_minutes
=
15
)
from
apps.audit.models
import
AuditLog
AuditLog
.
objects
.
create
(
table_name
=
'authentication'
,
record_id
=
employee
.
pk
,
action
=
'FAILED'
,
actor_id
=
employee
.
pk
,
actor_username
=
username
,
ip_address
=
self
.
_get_ip
(),
user_agent
=
self
.
_get_user_agent
(),
notes
=
'محاولة دخول فاشلة'
,
)
raise
serializers
.
ValidationError
({
'detail'
:
'اسم المستخدم أو كلمة المرور غير صحيحة'
})
employee
.
reset_failed_logins
()
data
=
super
()
.
validate
(
attrs
)
data
[
'user'
]
=
{
'id'
:
user
.
pk
,
'username'
:
user
.
username
,
'full_name_ar'
:
user
.
full_name_ar
,
'branch_id'
:
user
.
branch_id
,
'force_password_change'
:
user
.
force_password_change
,
}
return
data
@
classmethod
def
get_token
(
cls
,
user
):
token
=
super
()
.
get_token
(
user
)
token
[
'username'
]
=
user
.
username
token
[
'full_name_ar'
]
=
user
.
full_name_ar
token
[
'branch_id'
]
=
user
.
branch_id
return
token
def
_get_ip
(
self
):
request
=
self
.
context
.
get
(
'request'
)
if
request
:
xff
=
request
.
META
.
get
(
'HTTP_X_FORWARDED_FOR'
)
return
xff
.
split
(
','
)[
0
]
.
strip
()
if
xff
else
request
.
META
.
get
(
'REMOTE_ADDR'
)
return
None
def
_get_user_agent
(
self
):
request
=
self
.
context
.
get
(
'request'
)
return
request
.
META
.
get
(
'HTTP_USER_AGENT'
,
''
)
if
request
else
''
class
ChangePasswordSerializer
(
serializers
.
Serializer
):
old_password
=
serializers
.
CharField
(
required
=
True
)
new_password
=
serializers
.
CharField
(
required
=
True
)
def
validate_old_password
(
self
,
value
):
user
=
self
.
context
[
'request'
]
.
user
if
not
user
.
check_password
(
value
):
raise
serializers
.
ValidationError
(
'كلمة المرور الحالية غير صحيحة'
)
return
value
def
validate_new_password
(
self
,
value
):
from
django.contrib.auth.password_validation
import
validate_password
validate_password
(
value
,
self
.
context
[
'request'
]
.
user
)
return
value
def
validate
(
self
,
attrs
):
if
attrs
[
'old_password'
]
==
attrs
[
'new_password'
]:
raise
serializers
.
ValidationError
({
'new_password'
:
'كلمة المرور الجديدة يجب أن تختلف عن الحالية'
})
return
attrs
def
save
(
self
):
user
=
self
.
context
[
'request'
]
.
user
user
.
set_password
(
self
.
validated_data
[
'new_password'
])
user
.
force_password_change
=
False
user
.
password_changed_at
=
timezone
.
now
()
user
.
save
(
update_fields
=
[
'password'
,
'force_password_change'
,
'password_changed_at'
])
return
user
class
EmployeeProfileSerializer
(
serializers
.
ModelSerializer
):
branch_name
=
serializers
.
CharField
(
source
=
'branch.name_ar'
,
read_only
=
True
,
default
=
''
)
permissions
=
serializers
.
SerializerMethodField
()
class
Meta
:
model
=
Employee
fields
=
[
'id'
,
'username'
,
'full_name_ar'
,
'full_name_en'
,
'email'
,
'branch'
,
'branch_name'
,
'is_active'
,
'is_superuser'
,
'force_password_change'
,
'last_login'
,
'date_joined'
,
'permissions'
,
]
def
get_permissions
(
self
,
obj
):
return
user_permissions_list
(
obj
)
\ No newline at end of file
backend/apps/authentication/tests.py
View file @
4456643a
# Tests for authentication — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Authentication Tests
"""
from
django.test
import
TestCase
from
rest_framework.test
import
APIClient
from
rest_framework
import
status
as
http_status
from
apps.users.models
import
Employee
class
AuthenticationTests
(
TestCase
):
def
setUp
(
self
):
self
.
client
=
APIClient
()
self
.
user
=
Employee
.
objects
.
create_user
(
username
=
'authtest'
,
password
=
'AuthPass123!'
,
full_name_ar
=
'اختبار المصادقة'
,
)
self
.
user
.
force_password_change
=
False
self
.
user
.
save
()
def
test_login_success
(
self
):
res
=
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'authtest'
,
'password'
:
'AuthPass123!'
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
self
.
assertIn
(
'access'
,
res
.
data
)
self
.
assertIn
(
'refresh'
,
res
.
data
)
self
.
assertIn
(
'user'
,
res
.
data
)
def
test_login_wrong_password
(
self
):
res
=
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'authtest'
,
'password'
:
'WrongPass!'
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_400_BAD_REQUEST
)
def
test_login_nonexistent_user
(
self
):
res
=
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'noone'
,
'password'
:
'Whatever1!'
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_400_BAD_REQUEST
)
def
test_lockout_after_5_failures
(
self
):
for
_
in
range
(
5
):
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'authtest'
,
'password'
:
'Wrong!'
})
res
=
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'authtest'
,
'password'
:
'AuthPass123!'
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_400_BAD_REQUEST
)
self
.
assertIn
(
'مقفل'
,
str
(
res
.
data
))
def
test_inactive_user_cannot_login
(
self
):
self
.
user
.
is_active
=
False
self
.
user
.
save
()
res
=
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'authtest'
,
'password'
:
'AuthPass123!'
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_400_BAD_REQUEST
)
def
test_me_endpoint
(
self
):
self
.
client
.
force_authenticate
(
user
=
self
.
user
)
res
=
self
.
client
.
get
(
'/api/v1/auth/me/'
)
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
self
.
assertEqual
(
res
.
data
[
'username'
],
'authtest'
)
def
test_change_password
(
self
):
self
.
client
.
force_authenticate
(
user
=
self
.
user
)
res
=
self
.
client
.
post
(
'/api/v1/auth/change-password/'
,
{
'old_password'
:
'AuthPass123!'
,
'new_password'
:
'NewSecure456!'
,
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
self
.
user
.
refresh_from_db
()
self
.
assertTrue
(
self
.
user
.
check_password
(
'NewSecure456!'
))
self
.
assertFalse
(
self
.
user
.
force_password_change
)
def
test_sessions_list
(
self
):
self
.
client
.
force_authenticate
(
user
=
self
.
user
)
res
=
self
.
client
.
get
(
'/api/v1/auth/sessions/'
)
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
def
test_token_refresh
(
self
):
login
=
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'authtest'
,
'password'
:
'AuthPass123!'
})
refresh
=
login
.
data
[
'refresh'
]
res
=
self
.
client
.
post
(
'/api/v1/auth/refresh/'
,
{
'refresh'
:
refresh
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
self
.
assertIn
(
'access'
,
res
.
data
)
def
test_logout
(
self
):
login
=
self
.
client
.
post
(
'/api/v1/auth/login/'
,
{
'username'
:
'authtest'
,
'password'
:
'AuthPass123!'
})
self
.
client
.
force_authenticate
(
user
=
self
.
user
)
res
=
self
.
client
.
post
(
'/api/v1/auth/logout/'
,
{
'refresh'
:
login
.
data
[
'refresh'
],
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
\ No newline at end of file
backend/apps/authentication/urls.py
View file @
4456643a
urlpatterns
=
[]
\ No newline at end of file
from
django.urls
import
path
from
.
import
views
urlpatterns
=
[
path
(
'login/'
,
views
.
CustomTokenObtainPairView
.
as_view
(),
name
=
'token_obtain'
),
path
(
'refresh/'
,
views
.
CustomTokenRefreshView
.
as_view
(),
name
=
'token_refresh'
),
path
(
'logout/'
,
views
.
LogoutView
.
as_view
(),
name
=
'logout'
),
path
(
'change-password/'
,
views
.
ChangePasswordView
.
as_view
(),
name
=
'change_password'
),
path
(
'me/'
,
views
.
MeView
.
as_view
(),
name
=
'me'
),
path
(
'sessions/'
,
views
.
SessionListView
.
as_view
(),
name
=
'session_list'
),
path
(
'sessions/<int:pk>/'
,
views
.
SessionDestroyView
.
as_view
(),
name
=
'session_destroy'
),
]
\ No newline at end of file
backend/apps/authentication/views.py
View file @
4456643a
# Views for authentication — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Authentication Views
"""
import
uuid
from
django.utils
import
timezone
from
rest_framework
import
status
,
permissions
from
rest_framework.response
import
Response
from
rest_framework.views
import
APIView
from
rest_framework_simplejwt.views
import
TokenObtainPairView
,
TokenRefreshView
from
rest_framework_simplejwt.tokens
import
RefreshToken
from
rest_framework_simplejwt.exceptions
import
TokenError
from
apps.users.models
import
Employee
,
ActiveSession
from
apps.users.serializers
import
ActiveSessionSerializer
from
apps.audit.models
import
AuditLog
from
.serializers
import
(
CustomTokenObtainPairSerializer
,
ChangePasswordSerializer
,
EmployeeProfileSerializer
,
)
def
_get_client_ip
(
request
):
xff
=
request
.
META
.
get
(
'HTTP_X_FORWARDED_FOR'
)
return
xff
.
split
(
','
)[
0
]
.
strip
()
if
xff
else
request
.
META
.
get
(
'REMOTE_ADDR'
)
class
CustomTokenObtainPairView
(
TokenObtainPairView
):
serializer_class
=
CustomTokenObtainPairSerializer
def
post
(
self
,
request
,
*
args
,
**
kwargs
):
response
=
super
()
.
post
(
request
,
*
args
,
**
kwargs
)
if
response
.
status_code
==
200
:
user_data
=
response
.
data
.
get
(
'user'
,
{})
user_id
=
user_data
.
get
(
'id'
)
if
user_id
:
try
:
employee
=
Employee
.
objects
.
get
(
pk
=
user_id
)
ip
=
_get_client_ip
(
request
)
ua
=
request
.
META
.
get
(
'HTTP_USER_AGENT'
,
''
)
employee
.
last_login_ip
=
ip
employee
.
save
(
update_fields
=
[
'last_login_ip'
])
session_key
=
str
(
uuid
.
uuid4
())
ActiveSession
.
objects
.
create
(
employee
=
employee
,
session_key
=
session_key
,
ip_address
=
ip
or
'0.0.0.0'
,
user_agent
=
ua
,
)
response
.
data
[
'session_key'
]
=
session_key
AuditLog
.
objects
.
create
(
table_name
=
'authentication'
,
record_id
=
employee
.
pk
,
action
=
'LOGIN'
,
actor_id
=
employee
.
pk
,
actor_username
=
employee
.
username
,
ip_address
=
ip
,
user_agent
=
ua
,
notes
=
'تسجيل دخول ناجح'
,
)
except
Employee
.
DoesNotExist
:
pass
return
response
class
CustomTokenRefreshView
(
TokenRefreshView
):
pass
class
LogoutView
(
APIView
):
permission_classes
=
[
permissions
.
IsAuthenticated
]
def
post
(
self
,
request
):
refresh_token
=
request
.
data
.
get
(
'refresh'
)
session_key
=
request
.
data
.
get
(
'session_key'
)
if
refresh_token
:
try
:
token
=
RefreshToken
(
refresh_token
)
token
.
blacklist
()
except
TokenError
:
pass
if
session_key
:
ActiveSession
.
objects
.
filter
(
employee
=
request
.
user
,
session_key
=
session_key
)
.
update
(
is_active
=
False
)
AuditLog
.
objects
.
create
(
table_name
=
'authentication'
,
record_id
=
request
.
user
.
pk
,
action
=
'LOGOUT'
,
actor_id
=
request
.
user
.
pk
,
actor_username
=
request
.
user
.
username
,
ip_address
=
_get_client_ip
(
request
),
notes
=
'تسجيل خروج'
,
)
return
Response
({
'detail'
:
'تم تسجيل الخروج'
},
status
=
status
.
HTTP_200_OK
)
class
ChangePasswordView
(
APIView
):
permission_classes
=
[
permissions
.
IsAuthenticated
]
def
post
(
self
,
request
):
serializer
=
ChangePasswordSerializer
(
data
=
request
.
data
,
context
=
{
'request'
:
request
})
serializer
.
is_valid
(
raise_exception
=
True
)
serializer
.
save
()
return
Response
({
'detail'
:
'تم تغيير كلمة المرور بنجاح'
})
class
MeView
(
APIView
):
permission_classes
=
[
permissions
.
IsAuthenticated
]
def
get
(
self
,
request
):
serializer
=
EmployeeProfileSerializer
(
request
.
user
)
return
Response
(
serializer
.
data
)
class
SessionListView
(
APIView
):
permission_classes
=
[
permissions
.
IsAuthenticated
]
def
get
(
self
,
request
):
sessions
=
ActiveSession
.
objects
.
filter
(
employee
=
request
.
user
,
is_active
=
True
)
serializer
=
ActiveSessionSerializer
(
sessions
,
many
=
True
)
return
Response
(
serializer
.
data
)
class
SessionDestroyView
(
APIView
):
permission_classes
=
[
permissions
.
IsAuthenticated
]
def
delete
(
self
,
request
,
pk
):
try
:
session
=
ActiveSession
.
objects
.
get
(
pk
=
pk
,
employee
=
request
.
user
,
is_active
=
True
)
session
.
is_active
=
False
session
.
save
(
update_fields
=
[
'is_active'
])
return
Response
(
status
=
status
.
HTTP_204_NO_CONTENT
)
except
ActiveSession
.
DoesNotExist
:
return
Response
({
'detail'
:
'الجلسة غير موجودة'
},
status
=
status
.
HTTP_404_NOT_FOUND
)
\ No newline at end of file
backend/apps/branches/admin.py
View file @
4456643a
# Admin for branches — implemented in Phase 2
\ No newline at end of file
from
django.contrib
import
admin
from
simple_history.admin
import
SimpleHistoryAdmin
from
.models
import
Branch
@
admin
.
register
(
Branch
)
class
BranchAdmin
(
SimpleHistoryAdmin
):
list_display
=
[
'code'
,
'name_ar'
,
'name_en'
,
'is_active'
,
'manager_name'
,
'phone'
]
list_filter
=
[
'is_active'
]
search_fields
=
[
'code'
,
'name_ar'
,
'name_en'
]
readonly_fields
=
[
'code'
,
'created_at'
,
'updated_at'
]
\ No newline at end of file
backend/apps/branches/management/commands/seed_branches.py
0 → 100644
View file @
4456643a
from
django.core.management.base
import
BaseCommand
from
apps.branches.seed
import
run_seed
class
Command
(
BaseCommand
):
help
=
'Seeds the predefined branches for THE CLUB ERP'
def
handle
(
self
,
*
args
,
**
options
):
count
=
run_seed
()
self
.
stdout
.
write
(
self
.
style
.
SUCCESS
(
f
'تم إنشاء {count} فرع جديد'
))
\ No newline at end of file
backend/apps/branches/models.py
View file @
4456643a
# Models for branches — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Branch Models
"""
from
django.db
import
models
from
simple_history.models
import
HistoricalRecords
import
auditlog
class
Branch
(
models
.
Model
):
"""Club branch/location."""
code
=
models
.
CharField
(
max_length
=
20
,
unique
=
True
,
verbose_name
=
"الكود"
)
name_ar
=
models
.
CharField
(
max_length
=
200
,
verbose_name
=
"الاسم بالعربي"
)
name_en
=
models
.
CharField
(
max_length
=
200
,
blank
=
True
,
default
=
""
,
verbose_name
=
"الاسم بالانجليزي"
)
address
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"العنوان"
)
phone
=
models
.
CharField
(
max_length
=
20
,
blank
=
True
,
default
=
""
,
verbose_name
=
"الهاتف"
)
manager_name
=
models
.
CharField
(
max_length
=
200
,
blank
=
True
,
default
=
""
,
verbose_name
=
"اسم المدير"
)
is_active
=
models
.
BooleanField
(
default
=
True
,
verbose_name
=
"نشط"
)
created_at
=
models
.
DateTimeField
(
auto_now_add
=
True
,
verbose_name
=
"تاريخ الإنشاء"
)
updated_at
=
models
.
DateTimeField
(
auto_now
=
True
,
verbose_name
=
"تاريخ التعديل"
)
history
=
HistoricalRecords
()
class
Meta
:
verbose_name
=
"فرع"
verbose_name_plural
=
"الفروع"
ordering
=
[
'name_ar'
]
def
__str__
(
self
):
return
self
.
name_ar
auditlog
.
register
(
Branch
)
\ No newline at end of file
backend/apps/branches/seed.py
View file @
4456643a
# Management command: seed_branches — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Seed Branches
"""
from
apps.branches.models
import
Branch
BRANCHES
=
[
{
'code'
:
'sheraton'
,
'name_ar'
:
'فرع شيراتون / مصر الجديدة'
,
'name_en'
:
'Sheraton / Masr El-Gedida'
,
'address'
:
'شيراتون، مصر الجديدة، القاهرة'
,
},
{
'code'
:
'sadis'
,
'name_ar'
:
'فرع السادس من أكتوبر'
,
'name_en'
:
'6th of October'
,
'address'
:
'السادس من أكتوبر، الجيزة'
,
},
{
'code'
:
'new_capital'
,
'name_ar'
:
'فرع العاصمة الإدارية الجديدة'
,
'name_en'
:
'New Administrative Capital'
,
'address'
:
'العاصمة الإدارية الجديدة'
,
},
]
def
run_seed
():
created
=
0
for
data
in
BRANCHES
:
_
,
was_created
=
Branch
.
objects
.
get_or_create
(
code
=
data
[
'code'
],
defaults
=
data
,
)
if
was_created
:
created
+=
1
return
created
\ No newline at end of file
backend/apps/branches/serializers.py
View file @
4456643a
# Serializers for branches — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
serializers
from
.models
import
Branch
class
BranchSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
Branch
fields
=
[
'id'
,
'code'
,
'name_ar'
,
'name_en'
,
'address'
,
'phone'
,
'manager_name'
,
'is_active'
,
'created_at'
,
'updated_at'
,
]
read_only_fields
=
[
'id'
,
'created_at'
,
'updated_at'
]
\ No newline at end of file
backend/apps/branches/tests.py
View file @
4456643a
# Tests for branches — implemented in Phase 2
\ No newline at end of file
from
django.test
import
TestCase
from
rest_framework.test
import
APIClient
from
rest_framework
import
status
as
http_status
from
apps.users.models
import
Employee
from
.models
import
Branch
from
.seed
import
run_seed
class
BranchTests
(
TestCase
):
def
setUp
(
self
):
self
.
admin
=
Employee
.
objects
.
create_superuser
(
username
=
'admin'
,
password
=
'Admin123!'
,
full_name_ar
=
'مدير'
)
self
.
client
=
APIClient
()
self
.
client
.
force_authenticate
(
user
=
self
.
admin
)
def
test_seed_branches
(
self
):
count
=
run_seed
()
self
.
assertEqual
(
count
,
3
)
self
.
assertTrue
(
Branch
.
objects
.
filter
(
code
=
'sheraton'
)
.
exists
())
def
test_seed_idempotent
(
self
):
run_seed
()
run_seed
()
self
.
assertEqual
(
Branch
.
objects
.
count
(),
3
)
def
test_list_branches
(
self
):
run_seed
()
res
=
self
.
client
.
get
(
'/api/v1/branches/'
)
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
def
test_create_branch
(
self
):
res
=
self
.
client
.
post
(
'/api/v1/branches/'
,
{
'code'
:
'test_branch'
,
'name_ar'
:
'فرع اختبار'
,
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_201_CREATED
)
\ No newline at end of file
backend/apps/branches/urls.py
View file @
4456643a
urlpatterns
=
[]
\ No newline at end of file
from
django.urls
import
path
,
include
from
rest_framework.routers
import
DefaultRouter
from
.
import
views
router
=
DefaultRouter
()
router
.
register
(
r''
,
views
.
BranchViewSet
,
basename
=
'branch'
)
urlpatterns
=
[
path
(
''
,
include
(
router
.
urls
)),
]
\ No newline at end of file
backend/apps/branches/views.py
View file @
4456643a
# Views for branches — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
viewsets
,
permissions
from
django_filters.rest_framework
import
DjangoFilterBackend
from
rest_framework.filters
import
SearchFilter
,
OrderingFilter
from
.models
import
Branch
from
.serializers
import
BranchSerializer
class
BranchViewSet
(
viewsets
.
ModelViewSet
):
queryset
=
Branch
.
objects
.
all
()
serializer_class
=
BranchSerializer
permission_classes
=
[
permissions
.
IsAuthenticated
]
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
,
OrderingFilter
]
filterset_fields
=
[
'is_active'
]
search_fields
=
[
'code'
,
'name_ar'
,
'name_en'
]
ordering_fields
=
[
'name_ar'
,
'code'
,
'created_at'
]
ordering
=
[
'name_ar'
]
\ No newline at end of file
backend/apps/roles/admin.py
View file @
4456643a
# Admin for roles — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Roles Admin
"""
from
django.contrib
import
admin
from
simple_history.admin
import
SimpleHistoryAdmin
from
.models
import
Permission
,
Role
,
RolePermission
,
EmployeeRole
@
admin
.
register
(
Permission
)
class
PermissionAdmin
(
admin
.
ModelAdmin
):
list_display
=
[
'code'
,
'name_ar'
,
'category'
,
'module'
]
list_filter
=
[
'category'
,
'module'
]
search_fields
=
[
'code'
,
'name_ar'
,
'name_en'
]
ordering
=
[
'category'
,
'code'
]
class
RolePermissionInline
(
admin
.
TabularInline
):
model
=
RolePermission
extra
=
1
autocomplete_fields
=
[
'permission'
]
@
admin
.
register
(
Role
)
class
RoleAdmin
(
SimpleHistoryAdmin
):
list_display
=
[
'code'
,
'name_ar'
,
'is_system'
,
'parent_role'
]
list_filter
=
[
'is_system'
]
search_fields
=
[
'code'
,
'name_ar'
]
inlines
=
[
RolePermissionInline
]
readonly_fields
=
[
'created_at'
,
'updated_at'
]
@
admin
.
register
(
EmployeeRole
)
class
EmployeeRoleAdmin
(
SimpleHistoryAdmin
):
list_display
=
[
'employee'
,
'role'
,
'branch'
,
'is_active'
,
'temporary_until'
,
'granted_at'
]
list_filter
=
[
'role'
,
'branch'
,
'is_active'
]
search_fields
=
[
'employee__username'
,
'employee__full_name_ar'
]
raw_id_fields
=
[
'employee'
,
'granted_by'
]
readonly_fields
=
[
'granted_at'
]
\ No newline at end of file
backend/apps/roles/management/commands/seed_roles.py
0 → 100644
View file @
4456643a
from
django.core.management.base
import
BaseCommand
from
apps.roles.seed
import
run_seed
class
Command
(
BaseCommand
):
help
=
'Seeds all permissions and roles for THE CLUB ERP'
def
handle
(
self
,
*
args
,
**
options
):
perm_count
,
role_count
=
run_seed
()
self
.
stdout
.
write
(
self
.
style
.
SUCCESS
(
f
'تم إنشاء {perm_count} صلاحية جديدة و {role_count} دور جديد'
))
\ No newline at end of file
backend/apps/roles/models.py
View file @
4456643a
# Models for roles — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Role & Permission Models
"""
from
django.db
import
models
from
django.conf
import
settings
from
simple_history.models
import
HistoricalRecords
import
auditlog
class
Permission
(
models
.
Model
):
"""Granular permission for the custom RBAC system."""
code
=
models
.
CharField
(
max_length
=
100
,
unique
=
True
,
verbose_name
=
"الكود"
)
name_ar
=
models
.
CharField
(
max_length
=
200
,
verbose_name
=
"الاسم بالعربي"
)
name_en
=
models
.
CharField
(
max_length
=
200
,
blank
=
True
,
default
=
""
,
verbose_name
=
"الاسم بالانجليزي"
)
category
=
models
.
CharField
(
max_length
=
50
,
verbose_name
=
"الفئة"
)
module
=
models
.
CharField
(
max_length
=
50
,
verbose_name
=
"الموديول"
)
description
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"الوصف"
)
class
Meta
:
ordering
=
[
'category'
,
'code'
]
verbose_name
=
"صلاحية"
verbose_name_plural
=
"الصلاحيات"
def
__str__
(
self
):
return
f
"{self.code} — {self.name_ar}"
class
Role
(
models
.
Model
):
"""Role grouping permissions together."""
code
=
models
.
CharField
(
max_length
=
50
,
unique
=
True
,
verbose_name
=
"الكود"
)
name_ar
=
models
.
CharField
(
max_length
=
200
,
verbose_name
=
"الاسم بالعربي"
)
name_en
=
models
.
CharField
(
max_length
=
200
,
blank
=
True
,
default
=
""
,
verbose_name
=
"الاسم بالانجليزي"
)
is_system
=
models
.
BooleanField
(
default
=
False
,
verbose_name
=
"دور نظام"
)
parent_role
=
models
.
ForeignKey
(
'self'
,
null
=
True
,
blank
=
True
,
on_delete
=
models
.
SET_NULL
,
related_name
=
'child_roles'
,
verbose_name
=
"الدور الأب"
,
)
permissions
=
models
.
ManyToManyField
(
Permission
,
through
=
'RolePermission'
,
blank
=
True
,
related_name
=
'roles'
,
verbose_name
=
"الصلاحيات"
,
)
description
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"الوصف"
)
created_at
=
models
.
DateTimeField
(
auto_now_add
=
True
,
verbose_name
=
"تاريخ الإنشاء"
)
updated_at
=
models
.
DateTimeField
(
auto_now
=
True
,
verbose_name
=
"تاريخ التعديل"
)
history
=
HistoricalRecords
()
class
Meta
:
verbose_name
=
"دور"
verbose_name_plural
=
"الأدوار"
ordering
=
[
'code'
]
def
__str__
(
self
):
return
self
.
name_ar
def
get_all_permissions
(
self
):
"""Get permissions including inherited from parent."""
perms
=
set
(
self
.
permissions
.
values_list
(
'code'
,
flat
=
True
))
if
self
.
parent_role
:
perms
|=
set
(
self
.
parent_role
.
get_all_permissions
())
return
perms
class
RolePermission
(
models
.
Model
):
"""Through table for Role ↔ Permission M2M."""
role
=
models
.
ForeignKey
(
Role
,
on_delete
=
models
.
CASCADE
,
verbose_name
=
"الدور"
)
permission
=
models
.
ForeignKey
(
Permission
,
on_delete
=
models
.
CASCADE
,
verbose_name
=
"الصلاحية"
)
class
Meta
:
unique_together
=
[(
'role'
,
'permission'
)]
verbose_name
=
"صلاحية الدور"
verbose_name_plural
=
"صلاحيات الأدوار"
def
__str__
(
self
):
return
f
"{self.role.code} → {self.permission.code}"
class
EmployeeRole
(
models
.
Model
):
"""Assignment of roles to employees, optionally scoped to branches."""
employee
=
models
.
ForeignKey
(
settings
.
AUTH_USER_MODEL
,
on_delete
=
models
.
CASCADE
,
related_name
=
'employee_roles'
,
verbose_name
=
"الموظف"
,
)
role
=
models
.
ForeignKey
(
Role
,
on_delete
=
models
.
CASCADE
,
verbose_name
=
"الدور"
)
branch
=
models
.
ForeignKey
(
'branches.Branch'
,
null
=
True
,
blank
=
True
,
on_delete
=
models
.
SET_NULL
,
verbose_name
=
"الفرع"
,
)
granted_by
=
models
.
ForeignKey
(
settings
.
AUTH_USER_MODEL
,
null
=
True
,
on_delete
=
models
.
SET_NULL
,
related_name
=
'roles_granted'
,
verbose_name
=
"منح بواسطة"
,
)
granted_at
=
models
.
DateTimeField
(
auto_now_add
=
True
,
verbose_name
=
"تاريخ المنح"
)
temporary_until
=
models
.
DateTimeField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"مؤقت حتى"
)
is_active
=
models
.
BooleanField
(
default
=
True
,
verbose_name
=
"نشط"
)
history
=
HistoricalRecords
()
class
Meta
:
unique_together
=
[(
'employee'
,
'role'
,
'branch'
)]
verbose_name
=
"صلاحية موظف"
verbose_name_plural
=
"صلاحيات الموظفين"
ordering
=
[
'employee'
,
'role'
]
def
__str__
(
self
):
branch_txt
=
f
" ({self.branch.name_ar})"
if
self
.
branch
else
""
return
f
"{self.employee} → {self.role}{branch_txt}"
auditlog
.
register
(
Permission
)
auditlog
.
register
(
Role
)
auditlog
.
register
(
RolePermission
)
auditlog
.
register
(
EmployeeRole
)
\ No newline at end of file
backend/apps/roles/seed.py
View file @
4456643a
# Management command: seed_roles — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Seed Roles & Permissions
=========================================
All 60+ permissions and 10 predefined roles from the master document.
"""
from
apps.roles.models
import
Permission
,
Role
,
RolePermission
PERMISSIONS
=
[
# Members
(
'member.create'
,
'إنشاء عضو'
,
'members'
,
'members'
),
(
'member.view'
,
'عرض عضو'
,
'members'
,
'members'
),
(
'member.edit'
,
'تعديل عضو'
,
'members'
,
'members'
),
(
'member.archive'
,
'أرشفة عضو'
,
'members'
,
'members'
),
(
'member.search'
,
'بحث أعضاء'
,
'members'
,
'members'
),
(
'member.view_financial'
,
'عرض المالية'
,
'members'
,
'members'
),
(
'member.change_status'
,
'تغيير حالة العضو'
,
'members'
,
'members'
),
# Spouses
(
'spouse.add'
,
'إضافة زوج/زوجة'
,
'spouses'
,
'members'
),
(
'spouse.edit'
,
'تعديل زوج/زوجة'
,
'spouses'
,
'members'
),
(
'spouse.remove'
,
'إزالة زوج/زوجة'
,
'spouses'
,
'members'
),
(
'spouse.view'
,
'عرض زوج/زوجة'
,
'spouses'
,
'members'
),
# Children
(
'child.add'
,
'إضافة ابن/ابنة'
,
'children'
,
'members'
),
(
'child.edit'
,
'تعديل ابن/ابنة'
,
'children'
,
'members'
),
(
'child.remove'
,
'إزالة ابن/ابنة'
,
'children'
,
'members'
),
(
'child.view'
,
'عرض ابن/ابنة'
,
'children'
,
'members'
),
(
'child.freeze'
,
'تجميد ابن'
,
'children'
,
'members'
),
(
'child.separate'
,
'فصل ابن'
,
'children'
,
'members'
),
# Temporary
(
'temp.add'
,
'إضافة عضو مؤقت'
,
'temporary'
,
'members'
),
(
'temp.edit'
,
'تعديل عضو مؤقت'
,
'temporary'
,
'members'
),
(
'temp.remove'
,
'إزالة عضو مؤقت'
,
'temporary'
,
'members'
),
(
'temp.view'
,
'عرض عضو مؤقت'
,
'temporary'
,
'members'
),
# Payments
(
'payment.process_cash'
,
'معالجة دفع نقدي'
,
'payments'
,
'financial'
),
(
'payment.process_check'
,
'معالجة دفع بشيك'
,
'payments'
,
'financial'
),
(
'payment.process_visa'
,
'معالجة دفع بفيزا'
,
'payments'
,
'financial'
),
(
'payment.view'
,
'عرض المدفوعات'
,
'payments'
,
'financial'
),
(
'payment.void_receipt'
,
'إلغاء إيصال'
,
'payments'
,
'financial'
),
(
'payment.refund'
,
'استرداد مبلغ'
,
'payments'
,
'financial'
),
# Installments
(
'installment.create_plan'
,
'إنشاء خطة تقسيط'
,
'installments'
,
'financial'
),
(
'installment.record_payment'
,
'تسجيل دفعة قسط'
,
'installments'
,
'financial'
),
(
'installment.view'
,
'عرض الأقساط'
,
'installments'
,
'financial'
),
(
'installment.modify_plan'
,
'تعديل خطة التقسيط'
,
'installments'
,
'financial'
),
# Subscriptions
(
'subscription.collect'
,
'تحصيل اشتراك'
,
'subscriptions'
,
'financial'
),
(
'subscription.exempt'
,
'إعفاء من اشتراك'
,
'subscriptions'
,
'financial'
),
(
'subscription.view'
,
'عرض الاشتراكات'
,
'subscriptions'
,
'financial'
),
(
'subscription.generate_batch'
,
'توليد اشتراكات جماعية'
,
'subscriptions'
,
'financial'
),
# Fines
(
'fine.impose'
,
'فرض غرامة'
,
'fines'
,
'financial'
),
(
'fine.collect'
,
'تحصيل غرامة'
,
'fines'
,
'financial'
),
(
'fine.view'
,
'عرض الغرامات'
,
'fines'
,
'financial'
),
(
'fine.waive'
,
'إعفاء من غرامة'
,
'fines'
,
'financial'
),
# Transfers
(
'transfer.initiate'
,
'بدء تحويل'
,
'transfers'
,
'operations'
),
(
'transfer.approve'
,
'الموافقة على تحويل'
,
'transfers'
,
'operations'
),
(
'transfer.view'
,
'عرض التحويلات'
,
'transfers'
,
'operations'
),
# Separations
(
'separation.initiate'
,
'بدء فصل'
,
'separations'
,
'operations'
),
(
'separation.approve'
,
'الموافقة على فصل'
,
'separations'
,
'operations'
),
(
'separation.view'
,
'عرض الفصل'
,
'separations'
,
'operations'
),
# Waivers
(
'waiver.initiate'
,
'بدء تنازل'
,
'waivers'
,
'operations'
),
(
'waiver.approve'
,
'الموافقة على تنازل'
,
'waivers'
,
'operations'
),
(
'waiver.view'
,
'عرض التنازلات'
,
'waivers'
,
'operations'
),
# Interviews
(
'interview.schedule'
,
'جدولة مقابلة'
,
'interviews'
,
'operations'
),
(
'interview.conduct'
,
'إجراء مقابلة'
,
'interviews'
,
'operations'
),
(
'interview.decide'
,
'قرار المقابلة'
,
'interviews'
,
'operations'
),
(
'interview.view'
,
'عرض المقابلات'
,
'interviews'
,
'operations'
),
# Carnets
(
'carnet.print'
,
'طباعة كارنيه'
,
'carnets'
,
'operations'
),
(
'carnet.replace'
,
'بدل فاقد كارنيه'
,
'carnets'
,
'operations'
),
(
'carnet.view_log'
,
'عرض سجل الكارنيه'
,
'carnets'
,
'operations'
),
# Documents
(
'document.upload'
,
'رفع مستندات'
,
'documents'
,
'operations'
),
(
'document.view'
,
'عرض مستندات'
,
'documents'
,
'operations'
),
(
'document.delete'
,
'حذف مستندات'
,
'documents'
,
'operations'
),
# Reports
(
'report.view_membership'
,
'عرض تقارير العضوية'
,
'reports'
,
'reports'
),
(
'report.view_financial'
,
'عرض التقارير المالية'
,
'reports'
,
'reports'
),
(
'report.view_operations'
,
'عرض تقارير العمليات'
,
'reports'
,
'reports'
),
(
'report.view_audit'
,
'عرض تقارير المراجعة'
,
'reports'
,
'reports'
),
(
'report.export'
,
'تصدير التقارير'
,
'reports'
,
'reports'
),
(
'report.custom_build'
,
'بناء تقرير مخصص'
,
'reports'
,
'reports'
),
# Rules
(
'rules.view'
,
'عرض القواعد'
,
'rules'
,
'admin'
),
(
'rules.edit'
,
'تعديل القواعد'
,
'rules'
,
'admin'
),
(
'rules.create'
,
'إنشاء قاعدة'
,
'rules'
,
'admin'
),
(
'rules.deactivate'
,
'تعطيل قاعدة'
,
'rules'
,
'admin'
),
# Forms
(
'forms.view'
,
'عرض الاستمارات'
,
'forms'
,
'admin'
),
(
'forms.edit_schema'
,
'تعديل مخطط الاستمارة'
,
'forms'
,
'admin'
),
(
'forms.create_schema'
,
'إنشاء مخطط استمارة'
,
'forms'
,
'admin'
),
# Pricing
(
'pricing.view'
,
'عرض الأسعار'
,
'pricing'
,
'admin'
),
(
'pricing.edit'
,
'تعديل الأسعار'
,
'pricing'
,
'admin'
),
(
'pricing.create'
,
'إنشاء سعر'
,
'pricing'
,
'admin'
),
# Users
(
'user.create'
,
'إنشاء موظف'
,
'users'
,
'admin'
),
(
'user.edit'
,
'تعديل موظف'
,
'users'
,
'admin'
),
(
'user.deactivate'
,
'تعطيل موظف'
,
'users'
,
'admin'
),
(
'user.assign_role'
,
'تعيين دور'
,
'users'
,
'admin'
),
(
'user.view'
,
'عرض الموظفين'
,
'users'
,
'admin'
),
# Settings
(
'settings.view'
,
'عرض الإعدادات'
,
'settings'
,
'admin'
),
(
'settings.edit'
,
'تعديل الإعدادات'
,
'settings'
,
'admin'
),
(
'settings.backup'
,
'نسخ احتياطي'
,
'settings'
,
'admin'
),
# SMS
(
'sms.send_single'
,
'إرسال رسالة'
,
'sms'
,
'communication'
),
(
'sms.send_bulk'
,
'إرسال رسائل جماعية'
,
'sms'
,
'communication'
),
(
'sms.view_log'
,
'عرض سجل الرسائل'
,
'sms'
,
'communication'
),
(
'sms.edit_templates'
,
'تعديل قوالب الرسائل'
,
'sms'
,
'communication'
),
]
ROLES
=
{
'super_admin'
:
{
'name_ar'
:
'المدير العام للنظام'
,
'name_en'
:
'Super Admin'
,
'is_system'
:
True
,
'description'
:
'كل الصلاحيات بدون استثناء'
,
'permissions'
:
'__all__'
,
},
'board_member'
:
{
'name_ar'
:
'عضو مجلس أمناء'
,
'name_en'
:
'Board Member'
,
'is_system'
:
True
,
'description'
:
'الموافقة والرفض والعقوبات والتقارير'
,
'permissions'
:
[
'member.view'
,
'member.search'
,
'member.view_financial'
,
'member.change_status'
,
'spouse.view'
,
'child.view'
,
'temp.view'
,
'payment.view'
,
'installment.view'
,
'subscription.view'
,
'fine.impose'
,
'fine.view'
,
'fine.waive'
,
'transfer.approve'
,
'separation.approve'
,
'waiver.approve'
,
'interview.conduct'
,
'interview.decide'
,
'interview.view'
,
'carnet.view_log'
,
'document.view'
,
'report.view_membership'
,
'report.view_financial'
,
'report.view_operations'
,
'report.view_audit'
,
'report.export'
,
],
},
'membership_director'
:
{
'name_ar'
:
'مدير العضويات'
,
'name_en'
:
'Membership Director'
,
'is_system'
:
True
,
'description'
:
'إدارة كاملة للعضويات والأسر'
,
'permissions'
:
[
'member.create'
,
'member.view'
,
'member.edit'
,
'member.archive'
,
'member.search'
,
'member.view_financial'
,
'member.change_status'
,
'spouse.add'
,
'spouse.edit'
,
'spouse.remove'
,
'spouse.view'
,
'child.add'
,
'child.edit'
,
'child.remove'
,
'child.view'
,
'child.freeze'
,
'child.separate'
,
'temp.add'
,
'temp.edit'
,
'temp.remove'
,
'temp.view'
,
'transfer.initiate'
,
'transfer.view'
,
'separation.initiate'
,
'separation.view'
,
'waiver.initiate'
,
'waiver.view'
,
'interview.schedule'
,
'interview.view'
,
'carnet.print'
,
'carnet.replace'
,
'carnet.view_log'
,
'document.upload'
,
'document.view'
,
'report.view_membership'
,
'report.view_operations'
,
'report.export'
,
'sms.send_single'
,
],
},
'membership_officer'
:
{
'name_ar'
:
'موظف العضويات'
,
'name_en'
:
'Membership Officer'
,
'is_system'
:
True
,
'description'
:
'إدخال بيانات الأعضاء والمستندات'
,
'permissions'
:
[
'member.create'
,
'member.view'
,
'member.edit'
,
'member.search'
,
'spouse.add'
,
'spouse.edit'
,
'spouse.view'
,
'child.add'
,
'child.edit'
,
'child.view'
,
'temp.add'
,
'temp.edit'
,
'temp.view'
,
'interview.schedule'
,
'interview.view'
,
'document.upload'
,
'document.view'
,
],
},
'treasury_manager'
:
{
'name_ar'
:
'مدير الخزينة'
,
'name_en'
:
'Treasury Manager'
,
'is_system'
:
True
,
'description'
:
'جميع العمليات المالية'
,
'permissions'
:
[
'member.view'
,
'member.search'
,
'member.view_financial'
,
'payment.process_cash'
,
'payment.process_check'
,
'payment.process_visa'
,
'payment.view'
,
'payment.void_receipt'
,
'payment.refund'
,
'installment.create_plan'
,
'installment.record_payment'
,
'installment.view'
,
'installment.modify_plan'
,
'subscription.collect'
,
'subscription.view'
,
'subscription.generate_batch'
,
'fine.collect'
,
'fine.view'
,
'report.view_financial'
,
'report.export'
,
],
},
'treasury_officer'
:
{
'name_ar'
:
'أمين الخزينة'
,
'name_en'
:
'Treasury Officer'
,
'is_system'
:
True
,
'description'
:
'تحصيل وإصدار إيصالات'
,
'permissions'
:
[
'member.view'
,
'member.search'
,
'member.view_financial'
,
'payment.process_cash'
,
'payment.process_check'
,
'payment.process_visa'
,
'payment.view'
,
'installment.record_payment'
,
'installment.view'
,
'subscription.collect'
,
'subscription.view'
,
'fine.collect'
,
'fine.view'
,
],
},
'sales_agent'
:
{
'name_ar'
:
'موظف المبيعات'
,
'name_en'
:
'Sales Agent'
,
'is_system'
:
True
,
'description'
:
'استقبال وتقديم استمارات'
,
'permissions'
:
[
'member.view'
,
'member.search'
,
'forms.view'
,
],
},
'security_officer'
:
{
'name_ar'
:
'ضابط الأمن'
,
'name_en'
:
'Security Officer'
,
'is_system'
:
True
,
'description'
:
'التحقق من الهوية عند البوابات'
,
'permissions'
:
[
'member.view'
,
'member.search'
,
'carnet.view_log'
,
],
},
'report_viewer'
:
{
'name_ar'
:
'مراجع التقارير'
,
'name_en'
:
'Report Viewer'
,
'is_system'
:
True
,
'description'
:
'عرض جميع التقارير فقط'
,
'permissions'
:
[
'report.view_membership'
,
'report.view_financial'
,
'report.view_operations'
,
'report.view_audit'
,
'report.export'
,
'member.view'
,
'member.search'
,
'member.view_financial'
,
],
},
'auditor'
:
{
'name_ar'
:
'المراجع'
,
'name_en'
:
'Auditor'
,
'is_system'
:
True
,
'description'
:
'وصول للقراءة فقط لجميع البيانات'
,
'permissions'
:
[
'member.view'
,
'member.search'
,
'member.view_financial'
,
'spouse.view'
,
'child.view'
,
'temp.view'
,
'payment.view'
,
'installment.view'
,
'subscription.view'
,
'fine.view'
,
'transfer.view'
,
'separation.view'
,
'waiver.view'
,
'interview.view'
,
'carnet.view_log'
,
'document.view'
,
'report.view_membership'
,
'report.view_financial'
,
'report.view_operations'
,
'report.view_audit'
,
'report.export'
,
'rules.view'
,
'forms.view'
,
'pricing.view'
,
'settings.view'
,
'sms.view_log'
,
],
},
}
def
seed_all_permissions
():
"""Create all permissions."""
created
=
0
for
code
,
name_ar
,
category
,
module
in
PERMISSIONS
:
_
,
was_created
=
Permission
.
objects
.
get_or_create
(
code
=
code
,
defaults
=
{
'name_ar'
:
name_ar
,
'category'
:
category
,
'module'
:
module
,
},
)
if
was_created
:
created
+=
1
return
created
def
seed_all_roles
():
"""Create all roles and assign permissions."""
all_perms
=
{
p
.
code
:
p
for
p
in
Permission
.
objects
.
all
()}
created
=
0
for
code
,
config
in
ROLES
.
items
():
role
,
was_created
=
Role
.
objects
.
get_or_create
(
code
=
code
,
defaults
=
{
'name_ar'
:
config
[
'name_ar'
],
'name_en'
:
config
.
get
(
'name_en'
,
''
),
'is_system'
:
config
.
get
(
'is_system'
,
False
),
'description'
:
config
.
get
(
'description'
,
''
),
},
)
if
was_created
:
created
+=
1
perm_codes
=
config
[
'permissions'
]
if
perm_codes
==
'__all__'
:
role
.
permissions
.
set
(
Permission
.
objects
.
all
())
else
:
perm_objects
=
[
all_perms
[
c
]
for
c
in
perm_codes
if
c
in
all_perms
]
role
.
permissions
.
set
(
perm_objects
)
return
created
def
run_seed
():
perm_count
=
seed_all_permissions
()
role_count
=
seed_all_roles
()
return
perm_count
,
role_count
\ No newline at end of file
backend/apps/roles/serializers.py
View file @
4456643a
# Serializers for roles — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Roles Serializers
"""
from
rest_framework
import
serializers
from
.models
import
Permission
,
Role
,
EmployeeRole
class
PermissionSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
Permission
fields
=
[
'id'
,
'code'
,
'name_ar'
,
'name_en'
,
'category'
,
'module'
,
'description'
]
class
RoleSerializer
(
serializers
.
ModelSerializer
):
permissions
=
PermissionSerializer
(
many
=
True
,
read_only
=
True
)
permission_ids
=
serializers
.
PrimaryKeyRelatedField
(
queryset
=
Permission
.
objects
.
all
(),
many
=
True
,
write_only
=
True
,
required
=
False
,
source
=
'permissions'
,
)
class
Meta
:
model
=
Role
fields
=
[
'id'
,
'code'
,
'name_ar'
,
'name_en'
,
'is_system'
,
'parent_role'
,
'permissions'
,
'permission_ids'
,
'description'
,
'created_at'
,
'updated_at'
,
]
read_only_fields
=
[
'id'
,
'is_system'
,
'created_at'
,
'updated_at'
]
def
create
(
self
,
validated_data
):
perms
=
validated_data
.
pop
(
'permissions'
,
[])
role
=
Role
.
objects
.
create
(
**
validated_data
)
role
.
permissions
.
set
(
perms
)
return
role
def
update
(
self
,
instance
,
validated_data
):
perms
=
validated_data
.
pop
(
'permissions'
,
None
)
for
attr
,
value
in
validated_data
.
items
():
setattr
(
instance
,
attr
,
value
)
instance
.
save
()
if
perms
is
not
None
:
instance
.
permissions
.
set
(
perms
)
return
instance
class
RoleListSerializer
(
serializers
.
ModelSerializer
):
permission_count
=
serializers
.
IntegerField
(
source
=
'permissions.count'
,
read_only
=
True
)
class
Meta
:
model
=
Role
fields
=
[
'id'
,
'code'
,
'name_ar'
,
'is_system'
,
'parent_role'
,
'permission_count'
]
class
EmployeeRoleSerializer
(
serializers
.
ModelSerializer
):
role_name
=
serializers
.
CharField
(
source
=
'role.name_ar'
,
read_only
=
True
)
role_code
=
serializers
.
CharField
(
source
=
'role.code'
,
read_only
=
True
)
branch_name
=
serializers
.
CharField
(
source
=
'branch.name_ar'
,
read_only
=
True
,
default
=
''
)
class
Meta
:
model
=
EmployeeRole
fields
=
[
'id'
,
'employee'
,
'role'
,
'role_name'
,
'role_code'
,
'branch'
,
'branch_name'
,
'granted_by'
,
'granted_at'
,
'temporary_until'
,
'is_active'
,
]
read_only_fields
=
[
'id'
,
'granted_at'
]
\ No newline at end of file
backend/apps/roles/tests.py
View file @
4456643a
# Tests for roles — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Roles Tests
"""
from
django.test
import
TestCase
from
rest_framework.test
import
APIClient
from
apps.users.models
import
Employee
from
.models
import
Permission
,
Role
,
EmployeeRole
from
.seed
import
run_seed
class
RoleSeedTests
(
TestCase
):
def
test_seed_creates_permissions_and_roles
(
self
):
perm_count
,
role_count
=
run_seed
()
self
.
assertGreater
(
perm_count
,
60
)
self
.
assertEqual
(
role_count
,
10
)
self
.
assertTrue
(
Permission
.
objects
.
filter
(
code
=
'member.create'
)
.
exists
())
self
.
assertTrue
(
Role
.
objects
.
filter
(
code
=
'super_admin'
)
.
exists
())
def
test_seed_idempotent
(
self
):
run_seed
()
p1
=
Permission
.
objects
.
count
()
r1
=
Role
.
objects
.
count
()
run_seed
()
self
.
assertEqual
(
Permission
.
objects
.
count
(),
p1
)
self
.
assertEqual
(
Role
.
objects
.
count
(),
r1
)
def
test_super_admin_has_all_permissions
(
self
):
run_seed
()
role
=
Role
.
objects
.
get
(
code
=
'super_admin'
)
self
.
assertEqual
(
role
.
permissions
.
count
(),
Permission
.
objects
.
count
())
class
PermissionCheckTests
(
TestCase
):
def
setUp
(
self
):
run_seed
()
self
.
employee
=
Employee
.
objects
.
create_user
(
username
=
'permtest'
,
password
=
'Test123!'
,
full_name_ar
=
'اختبار'
)
role
=
Role
.
objects
.
get
(
code
=
'membership_officer'
)
EmployeeRole
.
objects
.
create
(
employee
=
self
.
employee
,
role
=
role
)
def
test_employee_has_assigned_permission
(
self
):
from
apps.users.permissions
import
user_has_permission
self
.
assertTrue
(
user_has_permission
(
self
.
employee
,
'member.create'
))
def
test_employee_lacks_unassigned_permission
(
self
):
from
apps.users.permissions
import
user_has_permission
self
.
assertFalse
(
user_has_permission
(
self
.
employee
,
'payment.void_receipt'
))
def
test_superuser_has_all_permissions
(
self
):
admin
=
Employee
.
objects
.
create_superuser
(
username
=
'supertest'
,
password
=
'Admin123!'
,
full_name_ar
=
'مدير'
)
from
apps.users.permissions
import
user_has_permission
self
.
assertTrue
(
user_has_permission
(
admin
,
'payment.void_receipt'
))
\ No newline at end of file
backend/apps/roles/urls.py
View file @
4456643a
urlpatterns
=
[]
\ No newline at end of file
from
django.urls
import
path
,
include
from
rest_framework.routers
import
DefaultRouter
from
.
import
views
router
=
DefaultRouter
()
router
.
register
(
r'permissions'
,
views
.
PermissionViewSet
,
basename
=
'permission'
)
router
.
register
(
r'assignments'
,
views
.
EmployeeRoleViewSet
,
basename
=
'employeerole'
)
router
.
register
(
r''
,
views
.
RoleViewSet
,
basename
=
'role'
)
urlpatterns
=
[
path
(
''
,
include
(
router
.
urls
)),
]
\ No newline at end of file
backend/apps/roles/views.py
View file @
4456643a
# Views for roles — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Roles Views
"""
from
rest_framework
import
viewsets
,
permissions
,
status
from
rest_framework.decorators
import
action
from
rest_framework.response
import
Response
from
django_filters.rest_framework
import
DjangoFilterBackend
from
rest_framework.filters
import
SearchFilter
,
OrderingFilter
from
.models
import
Permission
,
Role
,
EmployeeRole
from
.serializers
import
(
PermissionSerializer
,
RoleSerializer
,
RoleListSerializer
,
EmployeeRoleSerializer
,
)
from
apps.users.permissions
import
HasClubPermission
class
PermissionViewSet
(
viewsets
.
ReadOnlyModelViewSet
):
queryset
=
Permission
.
objects
.
all
()
serializer_class
=
PermissionSerializer
permission_classes
=
[
permissions
.
IsAuthenticated
]
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
]
filterset_fields
=
[
'category'
,
'module'
]
search_fields
=
[
'code'
,
'name_ar'
]
pagination_class
=
None
class
RoleViewSet
(
viewsets
.
ModelViewSet
):
queryset
=
Role
.
objects
.
prefetch_related
(
'permissions'
)
.
all
()
permission_classes
=
[
permissions
.
IsAuthenticated
,
HasClubPermission
]
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
]
filterset_fields
=
[
'is_system'
]
search_fields
=
[
'code'
,
'name_ar'
]
ordering
=
[
'code'
]
required_permission
=
{
'list'
:
'rules.view'
,
'retrieve'
:
'rules.view'
,
'create'
:
'rules.create'
,
'update'
:
'rules.edit'
,
'partial_update'
:
'rules.edit'
,
'destroy'
:
'rules.deactivate'
,
}
def
get_serializer_class
(
self
):
if
self
.
action
==
'list'
:
return
RoleListSerializer
return
RoleSerializer
def
destroy
(
self
,
request
,
*
args
,
**
kwargs
):
role
=
self
.
get_object
()
if
role
.
is_system
:
return
Response
(
{
'detail'
:
'لا يمكن حذف أدوار النظام'
},
status
=
status
.
HTTP_400_BAD_REQUEST
,
)
return
super
()
.
destroy
(
request
,
*
args
,
**
kwargs
)
class
EmployeeRoleViewSet
(
viewsets
.
ModelViewSet
):
queryset
=
EmployeeRole
.
objects
.
select_related
(
'employee'
,
'role'
,
'branch'
)
.
all
()
serializer_class
=
EmployeeRoleSerializer
permission_classes
=
[
permissions
.
IsAuthenticated
,
HasClubPermission
]
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
]
filterset_fields
=
[
'employee'
,
'role'
,
'branch'
,
'is_active'
]
search_fields
=
[
'employee__username'
,
'employee__full_name_ar'
]
required_permission
=
'user.assign_role'
def
perform_create
(
self
,
serializer
):
serializer
.
save
(
granted_by
=
self
.
request
.
user
)
\ No newline at end of file
backend/apps/settings_app/admin.py
View file @
4456643a
# Admin for settings_app — implemented in Phase 2
\ No newline at end of file
from
django.contrib
import
admin
from
simple_history.admin
import
SimpleHistoryAdmin
from
.models
import
SystemSetting
@
admin
.
register
(
SystemSetting
)
class
SystemSettingAdmin
(
SimpleHistoryAdmin
):
list_display
=
[
'key'
,
'name_ar'
,
'value'
,
'category'
,
'value_type'
,
'is_editable'
,
'updated_at'
]
list_filter
=
[
'category'
,
'is_editable'
,
'value_type'
]
search_fields
=
[
'key'
,
'name_ar'
,
'name_en'
]
readonly_fields
=
[
'updated_at'
]
list_editable
=
[
'value'
]
\ No newline at end of file
backend/apps/settings_app/management/commands/seed_settings.py
0 → 100644
View file @
4456643a
from
django.core.management.base
import
BaseCommand
from
apps.settings_app.seed
import
run_seed
class
Command
(
BaseCommand
):
help
=
'Seeds all system settings for THE CLUB ERP'
def
handle
(
self
,
*
args
,
**
options
):
count
=
run_seed
()
self
.
stdout
.
write
(
self
.
style
.
SUCCESS
(
f
'تم إنشاء {count} إعداد جديد'
))
\ No newline at end of file
backend/apps/settings_app/models.py
View file @
4456643a
# Models for settings_app — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — System Settings Model
"""
from
django.db
import
models
from
django.conf
import
settings
from
simple_history.models
import
HistoricalRecords
import
auditlog
class
SystemSetting
(
models
.
Model
):
"""Key-value system settings, editable through admin."""
VALUE_TYPES
=
[
(
'string'
,
'نص'
),
(
'integer'
,
'عدد صحيح'
),
(
'decimal'
,
'عدد عشري'
),
(
'boolean'
,
'منطقي'
),
(
'json'
,
'JSON'
),
(
'date'
,
'تاريخ'
),
]
CATEGORIES
=
[
(
'general'
,
'عام'
),
(
'financial'
,
'مالي'
),
(
'membership'
,
'عضوية'
),
(
'sms'
,
'رسائل'
),
(
'display'
,
'عرض'
),
]
key
=
models
.
CharField
(
max_length
=
100
,
unique
=
True
,
verbose_name
=
"المفتاح"
)
value
=
models
.
JSONField
(
verbose_name
=
"القيمة"
)
value_type
=
models
.
CharField
(
max_length
=
20
,
choices
=
VALUE_TYPES
,
default
=
'string'
,
verbose_name
=
"نوع القيمة"
)
category
=
models
.
CharField
(
max_length
=
50
,
choices
=
CATEGORIES
,
default
=
'general'
,
verbose_name
=
"الفئة"
)
name_ar
=
models
.
CharField
(
max_length
=
200
,
verbose_name
=
"الاسم بالعربي"
)
name_en
=
models
.
CharField
(
max_length
=
200
,
blank
=
True
,
default
=
""
,
verbose_name
=
"الاسم بالانجليزي"
)
description
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"الوصف"
)
is_editable
=
models
.
BooleanField
(
default
=
True
,
verbose_name
=
"قابل للتعديل"
)
updated_at
=
models
.
DateTimeField
(
auto_now
=
True
,
verbose_name
=
"تاريخ التعديل"
)
updated_by
=
models
.
ForeignKey
(
settings
.
AUTH_USER_MODEL
,
null
=
True
,
blank
=
True
,
on_delete
=
models
.
SET_NULL
,
verbose_name
=
"عدّل بواسطة"
,
)
history
=
HistoricalRecords
()
class
Meta
:
verbose_name
=
"إعداد النظام"
verbose_name_plural
=
"إعدادات النظام"
ordering
=
[
'category'
,
'key'
]
def
__str__
(
self
):
return
f
"{self.key}: {self.value}"
@
classmethod
def
get_value
(
cls
,
key
,
default
=
None
):
try
:
return
cls
.
objects
.
get
(
key
=
key
)
.
value
except
cls
.
DoesNotExist
:
return
default
auditlog
.
register
(
SystemSetting
)
\ No newline at end of file
backend/apps/settings_app/seed.py
View file @
4456643a
# Management command: seed_settings — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Seed System Settings
"""
from
apps.settings_app.models
import
SystemSetting
SETTINGS
=
[
{
'key'
:
'FINANCIAL_YEAR_START_MONTH'
,
'value'
:
7
,
'value_type'
:
'integer'
,
'category'
:
'financial'
,
'name_ar'
:
'شهر بداية السنة المالية'
,
'name_en'
:
'Financial Year Start Month'
,
'description'
:
'يوليو = 7'
,
},
{
'key'
:
'CURRENT_FINANCIAL_YEAR'
,
'value'
:
'2024/2025'
,
'value_type'
:
'string'
,
'category'
:
'financial'
,
'name_ar'
:
'السنة المالية الحالية'
,
'name_en'
:
'Current Financial Year'
,
},
{
'key'
:
'MEMBERSHIP_NUMBER_PREFIX'
,
'value'
:
''
,
'value_type'
:
'string'
,
'category'
:
'membership'
,
'name_ar'
:
'بادئة رقم العضوية'
,
},
{
'key'
:
'MEMBERSHIP_NUMBER_START'
,
'value'
:
1001
,
'value_type'
:
'integer'
,
'category'
:
'membership'
,
'name_ar'
:
'بداية أرقام العضوية'
,
'description'
:
'يبدأ من 1001/2 لفرع شيراتون'
,
},
{
'key'
:
'MEMBERSHIP_NUMBER_SUFFIX'
,
'value'
:
'/2'
,
'value_type'
:
'string'
,
'category'
:
'membership'
,
'name_ar'
:
'لاحقة رقم العضوية'
,
},
{
'key'
:
'DEFAULT_BRANCH_CODE'
,
'value'
:
'sheraton'
,
'value_type'
:
'string'
,
'category'
:
'general'
,
'name_ar'
:
'الفرع الافتراضي'
,
},
{
'key'
:
'SYSTEM_LANGUAGE'
,
'value'
:
'ar'
,
'value_type'
:
'string'
,
'category'
:
'display'
,
'name_ar'
:
'لغة النظام'
,
},
{
'key'
:
'TIMEZONE'
,
'value'
:
'Africa/Cairo'
,
'value_type'
:
'string'
,
'category'
:
'general'
,
'name_ar'
:
'المنطقة الزمنية'
,
'is_editable'
:
False
,
},
{
'key'
:
'DATE_FORMAT'
,
'value'
:
'DD/MM/YYYY'
,
'value_type'
:
'string'
,
'category'
:
'display'
,
'name_ar'
:
'صيغة التاريخ'
,
},
{
'key'
:
'CURRENCY'
,
'value'
:
'EGP'
,
'value_type'
:
'string'
,
'category'
:
'financial'
,
'name_ar'
:
'العملة الافتراضية'
,
},
{
'key'
:
'SESSION_TIMEOUT_MINUTES'
,
'value'
:
30
,
'value_type'
:
'integer'
,
'category'
:
'general'
,
'name_ar'
:
'مهلة انتهاء الجلسة (دقائق)'
,
},
{
'key'
:
'MAX_FAILED_LOGIN_ATTEMPTS'
,
'value'
:
5
,
'value_type'
:
'integer'
,
'category'
:
'general'
,
'name_ar'
:
'أقصى محاولات دخول فاشلة'
,
},
{
'key'
:
'LOCKOUT_DURATION_MINUTES'
,
'value'
:
15
,
'value_type'
:
'integer'
,
'category'
:
'general'
,
'name_ar'
:
'مدة قفل الحساب (دقائق)'
,
},
{
'key'
:
'PASSWORD_MIN_LENGTH'
,
'value'
:
8
,
'value_type'
:
'integer'
,
'category'
:
'general'
,
'name_ar'
:
'الحد الأدنى لطول كلمة المرور'
,
},
{
'key'
:
'PASSWORD_EXPIRY_DAYS'
,
'value'
:
90
,
'value_type'
:
'integer'
,
'category'
:
'general'
,
'name_ar'
:
'صلاحية كلمة المرور (أيام)'
,
},
{
'key'
:
'MAX_FILE_UPLOAD_MB'
,
'value'
:
10
,
'value_type'
:
'integer'
,
'category'
:
'general'
,
'name_ar'
:
'أقصى حجم ملف مرفوع (ميجا)'
,
},
{
'key'
:
'CLUB_NAME_AR'
,
'value'
:
'نادي شيراتون — THE CLUB'
,
'value_type'
:
'string'
,
'category'
:
'display'
,
'name_ar'
:
'اسم النادي بالعربية'
,
},
{
'key'
:
'CLUB_NAME_EN'
,
'value'
:
'THE CLUB — Sheraton Sports City'
,
'value_type'
:
'string'
,
'category'
:
'display'
,
'name_ar'
:
'اسم النادي بالإنجليزية'
,
},
{
'key'
:
'SMS_GATEWAY_ENABLED'
,
'value'
:
False
,
'value_type'
:
'boolean'
,
'category'
:
'sms'
,
'name_ar'
:
'تفعيل بوابة الرسائل'
,
},
{
'key'
:
'NEW_NUMBERING_SYSTEM_START_DATE'
,
'value'
:
'2026-07-01'
,
'value_type'
:
'date'
,
'category'
:
'membership'
,
'name_ar'
:
'تاريخ بدء نظام الترقيم الجديد'
,
'description'
:
'نظام الترقيم الجديد يبدأ 1/7/2026 بتعليمات مجلس الأمناء'
,
},
]
def
run_seed
():
created
=
0
for
setting
in
SETTINGS
:
_
,
was_created
=
SystemSetting
.
objects
.
get_or_create
(
key
=
setting
[
'key'
],
defaults
=
{
'value'
:
setting
[
'value'
],
'value_type'
:
setting
.
get
(
'value_type'
,
'string'
),
'category'
:
setting
.
get
(
'category'
,
'general'
),
'name_ar'
:
setting
[
'name_ar'
],
'name_en'
:
setting
.
get
(
'name_en'
,
''
),
'description'
:
setting
.
get
(
'description'
,
''
),
'is_editable'
:
setting
.
get
(
'is_editable'
,
True
),
},
)
if
was_created
:
created
+=
1
return
created
\ No newline at end of file
backend/apps/settings_app/serializers.py
View file @
4456643a
# Serializers for settings_app — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
serializers
from
.models
import
SystemSetting
class
SystemSettingSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
SystemSetting
fields
=
[
'id'
,
'key'
,
'value'
,
'value_type'
,
'category'
,
'name_ar'
,
'name_en'
,
'description'
,
'is_editable'
,
'updated_at'
,
]
read_only_fields
=
[
'id'
,
'key'
,
'value_type'
,
'category'
,
'name_ar'
,
'name_en'
,
'updated_at'
]
def
validate
(
self
,
attrs
):
instance
=
self
.
instance
if
instance
and
not
instance
.
is_editable
:
raise
serializers
.
ValidationError
(
'هذا الإعداد غير قابل للتعديل'
)
return
attrs
\ No newline at end of file
backend/apps/settings_app/tests.py
View file @
4456643a
# Tests for settings_app — implemented in Phase 2
\ No newline at end of file
from
django.test
import
TestCase
from
.models
import
SystemSetting
from
.seed
import
run_seed
class
SystemSettingTests
(
TestCase
):
def
test_seed_creates_settings
(
self
):
count
=
run_seed
()
self
.
assertGreater
(
count
,
15
)
def
test_seed_idempotent
(
self
):
run_seed
()
c1
=
SystemSetting
.
objects
.
count
()
run_seed
()
self
.
assertEqual
(
SystemSetting
.
objects
.
count
(),
c1
)
def
test_get_value
(
self
):
run_seed
()
val
=
SystemSetting
.
get_value
(
'CURRENCY'
)
self
.
assertEqual
(
val
,
'EGP'
)
def
test_get_value_default
(
self
):
val
=
SystemSetting
.
get_value
(
'NONEXISTENT'
,
default
=
'NOPE'
)
self
.
assertEqual
(
val
,
'NOPE'
)
def
test_non_editable_setting
(
self
):
run_seed
()
setting
=
SystemSetting
.
objects
.
get
(
key
=
'TIMEZONE'
)
self
.
assertFalse
(
setting
.
is_editable
)
\ No newline at end of file
backend/apps/settings_app/urls.py
View file @
4456643a
urlpatterns
=
[]
\ No newline at end of file
from
django.urls
import
path
,
include
from
rest_framework.routers
import
DefaultRouter
from
.
import
views
router
=
DefaultRouter
()
router
.
register
(
r''
,
views
.
SystemSettingViewSet
,
basename
=
'systemsetting'
)
urlpatterns
=
[
path
(
''
,
include
(
router
.
urls
)),
]
\ No newline at end of file
backend/apps/settings_app/views.py
View file @
4456643a
# Views for settings_app — implemented in Phase 2
\ No newline at end of file
from
rest_framework
import
viewsets
,
permissions
from
django_filters.rest_framework
import
DjangoFilterBackend
from
rest_framework.filters
import
SearchFilter
from
.models
import
SystemSetting
from
.serializers
import
SystemSettingSerializer
from
apps.users.permissions
import
HasClubPermission
class
SystemSettingViewSet
(
viewsets
.
ModelViewSet
):
queryset
=
SystemSetting
.
objects
.
all
()
serializer_class
=
SystemSettingSerializer
permission_classes
=
[
permissions
.
IsAuthenticated
,
HasClubPermission
]
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
]
filterset_fields
=
[
'category'
,
'is_editable'
,
'value_type'
]
search_fields
=
[
'key'
,
'name_ar'
]
ordering
=
[
'category'
,
'key'
]
required_permission
=
{
'list'
:
'settings.view'
,
'retrieve'
:
'settings.view'
,
'create'
:
'settings.edit'
,
'update'
:
'settings.edit'
,
'partial_update'
:
'settings.edit'
,
'destroy'
:
'settings.edit'
,
}
def
perform_update
(
self
,
serializer
):
serializer
.
save
(
updated_by
=
self
.
request
.
user
)
\ No newline at end of file
backend/apps/users/admin.py
View file @
4456643a
# Admin for users — implemented in Phase 2
"""
THE CLUB ERP — Users Admin
"""
from
django.contrib
import
admin
from
django.contrib.auth.admin
import
UserAdmin
from
.models
import
Employee
from
django.contrib.auth.admin
import
UserAdmin
as
BaseUserAdmin
from
simple_history.admin
import
SimpleHistoryAdmin
from
.models
import
Employee
,
ActiveSession
@
admin
.
register
(
Employee
)
class
EmployeeAdmin
(
UserAdmin
):
list_display
=
[
'username'
,
'full_name_ar'
,
'is_active'
]
\ No newline at end of file
class
EmployeeAdmin
(
BaseUserAdmin
,
SimpleHistoryAdmin
):
list_display
=
[
'username'
,
'full_name_ar'
,
'branch'
,
'is_active'
,
'last_login'
]
list_filter
=
[
'is_active'
,
'branch'
,
'date_joined'
,
'is_staff'
]
search_fields
=
[
'username'
,
'full_name_ar'
,
'full_name_en'
,
'email'
]
readonly_fields
=
[
'last_login'
,
'last_login_ip'
,
'failed_login_count'
,
'password_changed_at'
,
'created_at'
,
'updated_at'
,
'date_joined'
,
]
ordering
=
[
'full_name_ar'
]
fieldsets
=
(
(
None
,
{
'fields'
:
(
'username'
,
'password'
)}),
(
'البيانات الشخصية'
,
{
'fields'
:
(
'full_name_ar'
,
'full_name_en'
,
'email'
)}),
(
'الفرع والصلاحيات'
,
{
'fields'
:
(
'branch'
,
'is_active'
,
'is_staff'
,
'is_superuser'
)}),
(
'الأمان'
,
{
'fields'
:
(
'force_password_change'
,
'last_login'
,
'last_login_ip'
,
'failed_login_count'
,
'locked_until'
,
'password_changed_at'
,
)}),
(
'التواريخ'
,
{
'fields'
:
(
'date_joined'
,
'created_at'
,
'updated_at'
)}),
)
add_fieldsets
=
(
(
None
,
{
'classes'
:
(
'wide'
,),
'fields'
:
(
'username'
,
'full_name_ar'
,
'password1'
,
'password2'
,
'branch'
,
'is_active'
),
}),
)
@
admin
.
register
(
ActiveSession
)
class
ActiveSessionAdmin
(
admin
.
ModelAdmin
):
list_display
=
[
'employee'
,
'ip_address'
,
'started_at'
,
'last_activity'
,
'is_active'
]
list_filter
=
[
'is_active'
,
'started_at'
]
search_fields
=
[
'employee__username'
,
'employee__full_name_ar'
,
'ip_address'
]
readonly_fields
=
[
'employee'
,
'session_key'
,
'ip_address'
,
'user_agent'
,
'device_info'
,
'started_at'
,
'last_activity'
]
raw_id_fields
=
[
'employee'
]
\ No newline at end of file
backend/apps/users/apps.py
View file @
4456643a
...
...
@@ -4,7 +4,7 @@ from django.apps import AppConfig
class
UsersConfig
(
AppConfig
):
default_auto_field
=
'django.db.models.BigAutoField'
name
=
'apps.users'
verbose_name
=
'
الموظفو
ن'
verbose_name
=
'
إدارة الموظفي
ن'
def
ready
(
self
):
import
apps.users.signals
# noqa
\ No newline at end of file
import
apps.users.signals
# noqa: F401
\ No newline at end of file
backend/apps/users/models.py
View file @
4456643a
# Models for users — implemented in Phase 2
"""
THE CLUB ERP — Users & Employee Models
========================================
Phase 2: Foundation — Custom User Model + Session Tracking.
"""
from
django.contrib.auth.models
import
AbstractUser
from
django.core.validators
import
RegexValidator
from
django.db
import
models
from
django.utils
import
timezone
from
simple_history.models
import
HistoricalRecords
import
auditlog
alphanumeric_underscore
=
RegexValidator
(
r'^[a-zA-Z0-9_]+$'
,
'يجب أن يحتوي على أحرف إنجليزية وأرقام و _ فقط'
)
class
Employee
(
AbstractUser
):
"""Placeholder custom user model — full implementation in Phase 2."""
full_name_ar
=
models
.
CharField
(
max_length
=
200
,
verbose_name
=
"الاسم بالعربي"
,
default
=
""
)
"""Custom user model for club employees."""
username
=
models
.
CharField
(
max_length
=
50
,
unique
=
True
,
validators
=
[
alphanumeric_underscore
],
verbose_name
=
"اسم المستخدم"
,
)
full_name_ar
=
models
.
CharField
(
max_length
=
200
,
verbose_name
=
"الاسم بالعربي"
)
full_name_en
=
models
.
CharField
(
max_length
=
200
,
blank
=
True
,
default
=
""
,
verbose_name
=
"الاسم بالانجليزي"
)
email
=
models
.
EmailField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"البريد الإلكتروني"
)
branch
=
models
.
ForeignKey
(
'branches.Branch'
,
on_delete
=
models
.
SET_NULL
,
null
=
True
,
blank
=
True
,
related_name
=
'employees'
,
verbose_name
=
"الفرع"
,
)
is_staff
=
models
.
BooleanField
(
default
=
True
,
verbose_name
=
"وصول لوحة التحكم"
)
force_password_change
=
models
.
BooleanField
(
default
=
True
,
verbose_name
=
"تغيير كلمة المرور إجباري"
)
last_login_ip
=
models
.
GenericIPAddressField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"آخر IP دخول"
)
failed_login_count
=
models
.
PositiveIntegerField
(
default
=
0
,
verbose_name
=
"محاولات دخول فاشلة"
)
locked_until
=
models
.
DateTimeField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"مقفل حتى"
)
password_changed_at
=
models
.
DateTimeField
(
null
=
True
,
blank
=
True
,
verbose_name
=
"تاريخ تغيير كلمة المرور"
)
created_at
=
models
.
DateTimeField
(
auto_now_add
=
True
,
verbose_name
=
"تاريخ الإنشاء"
)
updated_at
=
models
.
DateTimeField
(
auto_now
=
True
,
verbose_name
=
"تاريخ التعديل"
)
history
=
HistoricalRecords
()
USERNAME_FIELD
=
'username'
REQUIRED_FIELDS
=
[
'full_name_ar'
]
class
Meta
:
verbose_name
=
"موظف"
verbose_name_plural
=
"الموظفون"
ordering
=
[
'full_name_ar'
]
def
__str__
(
self
):
return
self
.
full_name_ar
or
self
.
username
@
property
def
is_locked
(
self
):
if
self
.
locked_until
and
self
.
locked_until
>
timezone
.
now
():
return
True
return
False
def
lock_account
(
self
,
minutes
=
15
):
self
.
locked_until
=
timezone
.
now
()
+
timezone
.
timedelta
(
minutes
=
minutes
)
self
.
save
(
update_fields
=
[
'locked_until'
])
def
reset_failed_logins
(
self
):
if
self
.
failed_login_count
>
0
or
self
.
locked_until
:
self
.
failed_login_count
=
0
self
.
locked_until
=
None
self
.
save
(
update_fields
=
[
'failed_login_count'
,
'locked_until'
])
def
increment_failed_login
(
self
,
max_attempts
=
5
,
lockout_minutes
=
15
):
self
.
failed_login_count
+=
1
if
self
.
failed_login_count
>=
max_attempts
:
self
.
lock_account
(
lockout_minutes
)
self
.
save
(
update_fields
=
[
'failed_login_count'
,
'locked_until'
])
class
ActiveSession
(
models
.
Model
):
"""Tracks active employee sessions."""
employee
=
models
.
ForeignKey
(
Employee
,
on_delete
=
models
.
CASCADE
,
related_name
=
'sessions'
,
verbose_name
=
"الموظف"
,
)
session_key
=
models
.
CharField
(
max_length
=
64
,
unique
=
True
,
verbose_name
=
"مفتاح الجلسة"
)
ip_address
=
models
.
GenericIPAddressField
(
verbose_name
=
"عنوان IP"
)
user_agent
=
models
.
TextField
(
blank
=
True
,
default
=
""
,
verbose_name
=
"المتصفح"
)
device_info
=
models
.
CharField
(
max_length
=
255
,
blank
=
True
,
default
=
""
,
verbose_name
=
"معلومات الجهاز"
)
started_at
=
models
.
DateTimeField
(
auto_now_add
=
True
,
verbose_name
=
"بداية الجلسة"
)
last_activity
=
models
.
DateTimeField
(
auto_now
=
True
,
verbose_name
=
"آخر نشاط"
)
is_active
=
models
.
BooleanField
(
default
=
True
,
verbose_name
=
"نشطة"
)
history
=
HistoricalRecords
()
class
Meta
:
ordering
=
[
'-started_at'
]
verbose_name
=
"جلسة نشطة"
verbose_name_plural
=
"الجلسات النشطة"
def
__str__
(
self
):
return
self
.
full_name_ar
or
self
.
username
\ No newline at end of file
return
f
"{self.employee} — {self.ip_address} — {self.started_at:
%
Y-
%
m-
%
d
%
H:
%
M}"
auditlog
.
register
(
Employee
)
auditlog
.
register
(
ActiveSession
)
\ No newline at end of file
backend/apps/users/permissions.py
View file @
4456643a
# Permissions for users — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Custom Permission Classes
==========================================
Checks permissions against our Role/Permission engine, NOT Django's built-in.
"""
from
rest_framework.permissions
import
BasePermission
from
django.utils
import
timezone
def
user_has_permission
(
user
,
permission_code
):
"""Check if a user has a specific permission through their roles."""
if
not
user
or
not
user
.
is_authenticated
:
return
False
if
user
.
is_superuser
:
return
True
from
apps.roles.models
import
EmployeeRole
now
=
timezone
.
now
()
return
EmployeeRole
.
objects
.
filter
(
employee
=
user
,
is_active
=
True
,
role__permissions__code
=
permission_code
,
)
.
filter
(
models_Q_temp_valid
(
now
)
)
.
exists
()
def
models_Q_temp_valid
(
now
):
"""Return Q object for valid temporary permissions."""
from
django.db.models
import
Q
return
Q
(
temporary_until__isnull
=
True
)
|
Q
(
temporary_until__gt
=
now
)
def
user_permissions_list
(
user
):
"""Get flat list of all permission codes for a user."""
if
not
user
or
not
user
.
is_authenticated
:
return
[]
if
user
.
is_superuser
:
from
apps.roles.models
import
Permission
return
list
(
Permission
.
objects
.
values_list
(
'code'
,
flat
=
True
))
from
apps.roles.models
import
EmployeeRole
from
django.db.models
import
Q
now
=
timezone
.
now
()
return
list
(
EmployeeRole
.
objects
.
filter
(
employee
=
user
,
is_active
=
True
,
)
.
filter
(
Q
(
temporary_until__isnull
=
True
)
|
Q
(
temporary_until__gt
=
now
)
)
.
values_list
(
'role__permissions__code'
,
flat
=
True
)
.
distinct
()
)
class
HasClubPermission
(
BasePermission
):
"""
DRF Permission class that checks our custom permission system.
Usage: Set `required_permission` on the view or viewset.
"""
def
has_permission
(
self
,
request
,
view
):
if
not
request
.
user
or
not
request
.
user
.
is_authenticated
:
return
False
required
=
getattr
(
view
,
'required_permission'
,
None
)
if
not
required
:
return
True
if
isinstance
(
required
,
dict
):
action
=
getattr
(
view
,
'action'
,
request
.
method
.
lower
())
perm
=
required
.
get
(
action
)
if
not
perm
:
return
True
return
user_has_permission
(
request
.
user
,
perm
)
return
user_has_permission
(
request
.
user
,
required
)
def
require_permission
(
permission_code
):
"""Factory that creates a permission class for a specific code."""
class
DynamicPermission
(
BasePermission
):
message
=
f
'صلاحية مطلوبة: {permission_code}'
def
has_permission
(
self
,
request
,
view
):
return
user_has_permission
(
request
.
user
,
permission_code
)
DynamicPermission
.
__name__
=
f
'Require_{permission_code.replace(".", "_")}'
return
DynamicPermission
class
IsSuperAdmin
(
BasePermission
):
"""Only super admins."""
message
=
'هذا الإجراء متاح للمدير العام فقط'
def
has_permission
(
self
,
request
,
view
):
if
not
request
.
user
or
not
request
.
user
.
is_authenticated
:
return
False
if
request
.
user
.
is_superuser
:
return
True
from
apps.roles.models
import
EmployeeRole
return
EmployeeRole
.
objects
.
filter
(
employee
=
request
.
user
,
role__code
=
'super_admin'
,
is_active
=
True
,
)
.
exists
()
\ No newline at end of file
backend/apps/users/serializers.py
View file @
4456643a
# Serializers for users — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Users Serializers
"""
from
django.contrib.auth.password_validation
import
validate_password
from
rest_framework
import
serializers
from
.models
import
Employee
,
ActiveSession
class
EmployeeListSerializer
(
serializers
.
ModelSerializer
):
branch_name
=
serializers
.
CharField
(
source
=
'branch.name_ar'
,
read_only
=
True
,
default
=
''
)
class
Meta
:
model
=
Employee
fields
=
[
'id'
,
'username'
,
'full_name_ar'
,
'full_name_en'
,
'email'
,
'branch'
,
'branch_name'
,
'is_active'
,
'is_staff'
,
'last_login'
,
'date_joined'
,
'force_password_change'
,
]
read_only_fields
=
[
'id'
,
'last_login'
,
'date_joined'
]
class
EmployeeDetailSerializer
(
serializers
.
ModelSerializer
):
branch_name
=
serializers
.
CharField
(
source
=
'branch.name_ar'
,
read_only
=
True
,
default
=
''
)
roles
=
serializers
.
SerializerMethodField
()
class
Meta
:
model
=
Employee
fields
=
[
'id'
,
'username'
,
'full_name_ar'
,
'full_name_en'
,
'email'
,
'branch'
,
'branch_name'
,
'is_active'
,
'is_staff'
,
'force_password_change'
,
'last_login'
,
'last_login_ip'
,
'failed_login_count'
,
'locked_until'
,
'password_changed_at'
,
'date_joined'
,
'created_at'
,
'updated_at'
,
'roles'
,
]
read_only_fields
=
[
'id'
,
'last_login'
,
'last_login_ip'
,
'failed_login_count'
,
'locked_until'
,
'password_changed_at'
,
'date_joined'
,
'created_at'
,
'updated_at'
,
]
def
get_roles
(
self
,
obj
):
from
apps.roles.serializers
import
EmployeeRoleSerializer
return
EmployeeRoleSerializer
(
obj
.
employee_roles
.
filter
(
is_active
=
True
),
many
=
True
)
.
data
class
EmployeeCreateSerializer
(
serializers
.
ModelSerializer
):
password
=
serializers
.
CharField
(
write_only
=
True
,
required
=
True
,
validators
=
[
validate_password
])
class
Meta
:
model
=
Employee
fields
=
[
'id'
,
'username'
,
'full_name_ar'
,
'full_name_en'
,
'email'
,
'branch'
,
'is_active'
,
'password'
,
]
read_only_fields
=
[
'id'
]
def
create
(
self
,
validated_data
):
password
=
validated_data
.
pop
(
'password'
)
employee
=
Employee
(
**
validated_data
)
employee
.
set_password
(
password
)
employee
.
force_password_change
=
True
employee
.
save
()
return
employee
class
EmployeeUpdateSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
Employee
fields
=
[
'full_name_ar'
,
'full_name_en'
,
'email'
,
'branch'
,
'is_active'
,
]
class
ResetPasswordSerializer
(
serializers
.
Serializer
):
new_password
=
serializers
.
CharField
(
required
=
True
,
validators
=
[
validate_password
])
def
save
(
self
,
employee
):
employee
.
set_password
(
self
.
validated_data
[
'new_password'
])
employee
.
force_password_change
=
True
employee
.
failed_login_count
=
0
employee
.
locked_until
=
None
employee
.
save
()
return
employee
class
ActiveSessionSerializer
(
serializers
.
ModelSerializer
):
employee_name
=
serializers
.
CharField
(
source
=
'employee.full_name_ar'
,
read_only
=
True
)
class
Meta
:
model
=
ActiveSession
fields
=
[
'id'
,
'employee'
,
'employee_name'
,
'ip_address'
,
'user_agent'
,
'device_info'
,
'started_at'
,
'last_activity'
,
'is_active'
,
]
read_only_fields
=
fields
\ No newline at end of file
backend/apps/users/signals.py
View file @
4456643a
# Signals for users — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Users Signals
"""
from
django.db.models.signals
import
post_save
from
django.dispatch
import
receiver
from
django.utils
import
timezone
from
.models
import
Employee
@
receiver
(
post_save
,
sender
=
Employee
)
def
employee_post_save
(
sender
,
instance
,
created
,
**
kwargs
):
"""Log employee creation to audit on first save."""
if
created
:
from
apps.audit.models
import
AuditLog
AuditLog
.
objects
.
create
(
table_name
=
'users_employee'
,
record_id
=
instance
.
pk
,
action
=
'CREATE'
,
after_data
=
{
'username'
:
instance
.
username
,
'full_name_ar'
:
instance
.
full_name_ar
},
actor_id
=
None
,
actor_username
=
'system'
,
notes
=
'إنشاء حساب موظف جديد'
,
)
\ No newline at end of file
backend/apps/users/tests.py
View file @
4456643a
# Tests for users — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Users Tests
"""
from
django.test
import
TestCase
from
django.utils
import
timezone
from
rest_framework.test
import
APIClient
from
rest_framework
import
status
as
http_status
from
.models
import
Employee
,
ActiveSession
class
EmployeeModelTests
(
TestCase
):
def
setUp
(
self
):
self
.
employee
=
Employee
.
objects
.
create_user
(
username
=
'testuser'
,
password
=
'TestPass123!'
,
full_name_ar
=
'مستخدم اختبار'
,
)
def
test_employee_creation
(
self
):
self
.
assertEqual
(
self
.
employee
.
full_name_ar
,
'مستخدم اختبار'
)
self
.
assertTrue
(
self
.
employee
.
is_active
)
self
.
assertTrue
(
self
.
employee
.
force_password_change
)
def
test_employee_str
(
self
):
self
.
assertEqual
(
str
(
self
.
employee
),
'مستخدم اختبار'
)
def
test_lock_account
(
self
):
self
.
employee
.
lock_account
(
minutes
=
15
)
self
.
assertTrue
(
self
.
employee
.
is_locked
)
def
test_reset_failed_logins
(
self
):
self
.
employee
.
failed_login_count
=
5
self
.
employee
.
lock_account
(
15
)
self
.
employee
.
reset_failed_logins
()
self
.
assertEqual
(
self
.
employee
.
failed_login_count
,
0
)
self
.
assertIsNone
(
self
.
employee
.
locked_until
)
self
.
assertFalse
(
self
.
employee
.
is_locked
)
def
test_increment_failed_login_locks_at_5
(
self
):
for
_
in
range
(
5
):
self
.
employee
.
increment_failed_login
(
max_attempts
=
5
,
lockout_minutes
=
15
)
self
.
employee
.
refresh_from_db
()
self
.
assertTrue
(
self
.
employee
.
is_locked
)
self
.
assertEqual
(
self
.
employee
.
failed_login_count
,
5
)
def
test_deactivation
(
self
):
self
.
employee
.
is_active
=
False
self
.
employee
.
save
()
self
.
assertFalse
(
self
.
employee
.
is_active
)
def
test_reactivation
(
self
):
self
.
employee
.
is_active
=
False
self
.
employee
.
save
()
self
.
employee
.
is_active
=
True
self
.
employee
.
save
()
self
.
assertTrue
(
self
.
employee
.
is_active
)
class
EmployeeAPITests
(
TestCase
):
def
setUp
(
self
):
self
.
admin
=
Employee
.
objects
.
create_superuser
(
username
=
'admin'
,
password
=
'AdminPass123!'
,
full_name_ar
=
'مدير النظام'
,
)
self
.
client
=
APIClient
()
self
.
client
.
force_authenticate
(
user
=
self
.
admin
)
def
test_list_employees
(
self
):
res
=
self
.
client
.
get
(
'/api/v1/users/'
)
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_200_OK
)
def
test_create_employee
(
self
):
res
=
self
.
client
.
post
(
'/api/v1/users/'
,
{
'username'
:
'newuser'
,
'full_name_ar'
:
'موظف جديد'
,
'password'
:
'NewPass123!'
,
})
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_201_CREATED
)
self
.
assertTrue
(
Employee
.
objects
.
filter
(
username
=
'newuser'
)
.
exists
())
def
test_deactivate_employee
(
self
):
emp
=
Employee
.
objects
.
create_user
(
username
=
'todeactivate'
,
password
=
'Pass123!'
,
full_name_ar
=
'للإلغاء'
,
)
res
=
self
.
client
.
delete
(
f
'/api/v1/users/{emp.pk}/'
)
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_204_NO_CONTENT
)
emp
.
refresh_from_db
()
self
.
assertFalse
(
emp
.
is_active
)
def
test_cannot_deactivate_self
(
self
):
res
=
self
.
client
.
delete
(
f
'/api/v1/users/{self.admin.pk}/'
)
self
.
assertEqual
(
res
.
status_code
,
http_status
.
HTTP_400_BAD_REQUEST
)
\ No newline at end of file
backend/apps/users/urls.py
View file @
4456643a
urlpatterns
=
[]
\ No newline at end of file
from
django.urls
import
path
,
include
from
rest_framework.routers
import
DefaultRouter
from
.
import
views
router
=
DefaultRouter
()
router
.
register
(
r''
,
views
.
EmployeeViewSet
,
basename
=
'employee'
)
urlpatterns
=
[
path
(
''
,
include
(
router
.
urls
)),
]
\ No newline at end of file
backend/apps/users/views.py
View file @
4456643a
# Views for users — implemented in Phase 2
\ No newline at end of file
"""
THE CLUB ERP — Users Views
"""
from
rest_framework
import
viewsets
,
permissions
,
status
from
rest_framework.decorators
import
action
from
rest_framework.response
import
Response
from
django_filters.rest_framework
import
DjangoFilterBackend
from
rest_framework.filters
import
SearchFilter
,
OrderingFilter
from
.models
import
Employee
,
ActiveSession
from
.serializers
import
(
EmployeeListSerializer
,
EmployeeDetailSerializer
,
EmployeeCreateSerializer
,
EmployeeUpdateSerializer
,
ResetPasswordSerializer
,
ActiveSessionSerializer
,
)
from
.permissions
import
IsSuperAdmin
,
HasClubPermission
class
EmployeeViewSet
(
viewsets
.
ModelViewSet
):
queryset
=
Employee
.
objects
.
select_related
(
'branch'
)
.
all
()
permission_classes
=
[
permissions
.
IsAuthenticated
,
HasClubPermission
]
filter_backends
=
[
DjangoFilterBackend
,
SearchFilter
,
OrderingFilter
]
filterset_fields
=
[
'is_active'
,
'branch'
,
'is_staff'
]
search_fields
=
[
'username'
,
'full_name_ar'
,
'full_name_en'
,
'email'
]
ordering_fields
=
[
'full_name_ar'
,
'date_joined'
,
'last_login'
,
'username'
]
ordering
=
[
'full_name_ar'
]
required_permission
=
{
'list'
:
'user.view'
,
'retrieve'
:
'user.view'
,
'create'
:
'user.create'
,
'update'
:
'user.edit'
,
'partial_update'
:
'user.edit'
,
'destroy'
:
'user.deactivate'
,
}
def
get_serializer_class
(
self
):
if
self
.
action
==
'create'
:
return
EmployeeCreateSerializer
if
self
.
action
in
(
'update'
,
'partial_update'
):
return
EmployeeUpdateSerializer
if
self
.
action
==
'retrieve'
:
return
EmployeeDetailSerializer
return
EmployeeListSerializer
def
destroy
(
self
,
request
,
*
args
,
**
kwargs
):
"""Soft-deactivate instead of hard delete."""
employee
=
self
.
get_object
()
if
employee
==
request
.
user
:
return
Response
(
{
'detail'
:
'لا يمكنك إلغاء تنشيط حسابك'
},
status
=
status
.
HTTP_400_BAD_REQUEST
,
)
employee
.
is_active
=
False
employee
.
save
(
update_fields
=
[
'is_active'
])
ActiveSession
.
objects
.
filter
(
employee
=
employee
,
is_active
=
True
)
.
update
(
is_active
=
False
)
return
Response
(
status
=
status
.
HTTP_204_NO_CONTENT
)
@
action
(
detail
=
True
,
methods
=
[
'post'
],
permission_classes
=
[
IsSuperAdmin
])
def
reset_password
(
self
,
request
,
pk
=
None
):
employee
=
self
.
get_object
()
serializer
=
ResetPasswordSerializer
(
data
=
request
.
data
)
serializer
.
is_valid
(
raise_exception
=
True
)
serializer
.
save
(
employee
=
employee
)
return
Response
({
'detail'
:
'تم إعادة تعيين كلمة المرور'
})
@
action
(
detail
=
True
,
methods
=
[
'post'
],
permission_classes
=
[
IsSuperAdmin
])
def
reactivate
(
self
,
request
,
pk
=
None
):
employee
=
self
.
get_object
()
employee
.
is_active
=
True
employee
.
failed_login_count
=
0
employee
.
locked_until
=
None
employee
.
save
(
update_fields
=
[
'is_active'
,
'failed_login_count'
,
'locked_until'
])
return
Response
({
'detail'
:
'تم إعادة تنشيط الحساب'
})
@
action
(
detail
=
True
,
methods
=
[
'post'
],
permission_classes
=
[
IsSuperAdmin
])
def
force_logout
(
self
,
request
,
pk
=
None
):
employee
=
self
.
get_object
()
ActiveSession
.
objects
.
filter
(
employee
=
employee
,
is_active
=
True
)
.
update
(
is_active
=
False
)
return
Response
({
'detail'
:
'تم إنهاء جميع الجلسات'
})
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment