Commit 4456643a authored by Administrator's avatar Administrator

Update 51 files via Son of Anton

parent 449c59b9
# Admin for archive — implemented in Phase 2 from django.contrib import admin
\ No newline at end of file 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
# 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
# Serializers for archive — implemented in Phase 2 from rest_framework import serializers
\ No newline at end of file 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
# Tests for archive — implemented in Phase 2 from django.test import TestCase
\ No newline at end of file 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
urlpatterns = [] from django.urls import path, include
\ No newline at end of file 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
# Views for archive — implemented in Phase 2 from rest_framework import viewsets, permissions
\ No newline at end of file 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
# Admin for audit — implemented in Phase 2 from django.contrib import admin
\ No newline at end of file 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
""" """
THE CLUB ERP — Audit IP Middleware THE CLUB ERP — Audit Middleware
=================================== ================================
Captures IP address and user-agent for audit logging. Captures IP and 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: 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): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
# Extract IP address _request_local.request = request
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', '')
response = self.get_response(request) response = self.get_response(request)
if hasattr(_request_local, 'request'):
del _request_local.request
return response return response
\ No newline at end of file
# 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
# Serializers for audit — implemented in Phase 2 from rest_framework import serializers
\ No newline at end of file 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
# Tests for audit — implemented in Phase 2 from django.test import TestCase
\ No newline at end of file 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
urlpatterns = [] from django.urls import path, include
\ No newline at end of file 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
# Views for audit — implemented in Phase 2 from rest_framework import viewsets, permissions
\ No newline at end of file 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
# 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
# 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.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): class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
"""Placeholder — full implementation in Phase 2.""" """Custom JWT login with lockout enforcement and extra claims."""
pass
\ No newline at end of file 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
# 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
urlpatterns = [] from django.urls import path
\ No newline at end of file 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
# 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
# Admin for branches — implemented in Phase 2 from django.contrib import admin
\ No newline at end of file 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
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
# 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
# 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
# Serializers for branches — implemented in Phase 2 from rest_framework import serializers
\ No newline at end of file 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
# Tests for branches — implemented in Phase 2 from django.test import TestCase
\ No newline at end of file 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
urlpatterns = [] from django.urls import path, include
\ No newline at end of file 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
# Views for branches — implemented in Phase 2 from rest_framework import viewsets, permissions
\ No newline at end of file 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
# 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
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
# 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
# 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
# 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
# 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
urlpatterns = [] from django.urls import path, include
\ No newline at end of file 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
# 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
# Admin for settings_app — implemented in Phase 2 from django.contrib import admin
\ No newline at end of file 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
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
# 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
# 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
# Serializers for settings_app — implemented in Phase 2 from rest_framework import serializers
\ No newline at end of file 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
# Tests for settings_app — implemented in Phase 2 from django.test import TestCase
\ No newline at end of file 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
urlpatterns = [] from django.urls import path, include
\ No newline at end of file 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
# Views for settings_app — implemented in Phase 2 from rest_framework import viewsets, permissions
\ No newline at end of file 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
# Admin for users — implemented in Phase 2 """
THE CLUB ERP — Users Admin
"""
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import Employee from simple_history.admin import SimpleHistoryAdmin
from .models import Employee, ActiveSession
@admin.register(Employee) @admin.register(Employee)
class EmployeeAdmin(UserAdmin): class EmployeeAdmin(BaseUserAdmin, SimpleHistoryAdmin):
list_display = ['username', 'full_name_ar', 'is_active'] list_display = ['username', 'full_name_ar', 'branch', 'is_active', 'last_login']
\ No newline at end of file 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
...@@ -4,7 +4,7 @@ from django.apps import AppConfig ...@@ -4,7 +4,7 @@ from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.users' name = 'apps.users'
verbose_name = 'الموظفون' verbose_name = 'إدارة الموظفين'
def ready(self): def ready(self):
import apps.users.signals # noqa import apps.users.signals # noqa: F401
\ No newline at end of file \ No newline at end of file
# 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.contrib.auth.models import AbstractUser
from django.core.validators import RegexValidator
from django.db import models 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): class Employee(AbstractUser):
"""Placeholder custom user model — full implementation in Phase 2.""" """Custom user model for club employees."""
full_name_ar = models.CharField(max_length=200, verbose_name="الاسم بالعربي", default="")
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: class Meta:
verbose_name = "موظف" verbose_name = "موظف"
verbose_name_plural = "الموظفون" 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): def __str__(self):
return self.full_name_ar or self.username return f"{self.employee} — {self.ip_address} — {self.started_at:%Y-%m-%d %H:%M}"
\ No newline at end of file
auditlog.register(Employee)
auditlog.register(ActiveSession)
\ No newline at end of file
# 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
# 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
# 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
# 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
urlpatterns = [] from django.urls import path, include
\ No newline at end of file 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
# 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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment