first commit
This commit is contained in:
commit
1992c3359d
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
35
.idea/hospital_management_system_v4.iml
generated
Normal file
35
.idea/hospital_management_system_v4.iml
generated
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="FacetManager">
|
||||||
|
<facet type="django" name="Django">
|
||||||
|
<configuration>
|
||||||
|
<option name="rootFolder" value="$MODULE_DIR$" />
|
||||||
|
<option name="settingsModule" value="hospital_management/settings.py" />
|
||||||
|
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
|
||||||
|
<option name="environment" value="<map/>" />
|
||||||
|
<option name="doNotUseTestRunner" value="false" />
|
||||||
|
<option name="trackFilePattern" value="migrations" />
|
||||||
|
</configuration>
|
||||||
|
</facet>
|
||||||
|
</component>
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="uv (hospital_management_system_v4)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
<component name="TemplatesService">
|
||||||
|
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||||
|
<option name="TEMPLATE_FOLDERS">
|
||||||
|
<list>
|
||||||
|
<option value="$MODULE_DIR$/templates/emr/templates" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
46
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
46
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<Languages>
|
||||||
|
<language minSize="102" name="Python" />
|
||||||
|
</Languages>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="myValues">
|
||||||
|
<value>
|
||||||
|
<list size="9">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="nobr" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="noembed" />
|
||||||
|
<item index="2" class="java.lang.String" itemvalue="comment" />
|
||||||
|
<item index="3" class="java.lang.String" itemvalue="noscript" />
|
||||||
|
<item index="4" class="java.lang.String" itemvalue="embed" />
|
||||||
|
<item index="5" class="java.lang.String" itemvalue="script" />
|
||||||
|
<item index="6" class="java.lang.String" itemvalue="app-emergency-department-patient-flow" />
|
||||||
|
<item index="7" class="java.lang.String" itemvalue="app-alarm-list" />
|
||||||
|
<item index="8" class="java.lang.String" itemvalue="app-root" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
<option name="myCustomValuesEnabled" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="JSHint" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyPackageRequirementsInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||||
|
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="N806" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyShadowingBuiltinsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredNames">
|
||||||
|
<list>
|
||||||
|
<option value="id" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
17
.idea/misc.xml
generated
Normal file
17
.idea/misc.xml
generated
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.12 (hospital_management_system_v4)" />
|
||||||
|
</component>
|
||||||
|
<component name="KubernetesApiProvider">{}</component>
|
||||||
|
<component name="MaterialThemeProjectNewConfig">
|
||||||
|
<option name="metadata">
|
||||||
|
<MTProjectMetadataState>
|
||||||
|
<option name="migrated" value="true" />
|
||||||
|
<option name="pristineConfig" value="false" />
|
||||||
|
<option name="userId" value="-4d33890d:18fe9fd09b1:-7ffe" />
|
||||||
|
</MTProjectMetadataState>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="uv (hospital_management_system_v4)" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/hospital_management_system_v4.iml" filepath="$PROJECT_DIR$/.idea/hospital_management_system_v4.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
BIN
accounts/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/admin.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/admin.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/apps.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/apps.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/forms.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/forms.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/forms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/models.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/models.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/urls.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/urls.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/views.cpython-311.pyc
Normal file
BIN
accounts/__pycache__/views.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/views.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
230
accounts/admin.py
Normal file
230
accounts/admin.py
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
Admin configuration for accounts app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from .models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(BaseUserAdmin):
|
||||||
|
"""
|
||||||
|
Admin configuration for User model.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'username', 'email', 'get_full_name', 'role', 'tenant',
|
||||||
|
'is_active', 'is_verified', 'is_approved', 'last_login'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'role', 'tenant', 'is_active', 'is_verified', 'is_approved',
|
||||||
|
'two_factor_enabled', 'is_staff', 'is_superuser'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'username', 'email', 'first_name', 'last_name',
|
||||||
|
'employee_id', 'license_number', 'npi_number'
|
||||||
|
]
|
||||||
|
ordering = ['last_name', 'first_name']
|
||||||
|
|
||||||
|
fieldsets = BaseUserAdmin.fieldsets + (
|
||||||
|
('Tenant Information', {
|
||||||
|
'fields': ('tenant', 'user_id')
|
||||||
|
}),
|
||||||
|
('Personal Information', {
|
||||||
|
'fields': (
|
||||||
|
'middle_name', 'preferred_name', 'phone_number', 'mobile_number',
|
||||||
|
'profile_picture', 'bio'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Professional Information', {
|
||||||
|
'fields': (
|
||||||
|
'employee_id', 'department', 'job_title', 'role',
|
||||||
|
'license_number', 'license_state', 'license_expiry',
|
||||||
|
'dea_number', 'npi_number'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Security Settings', {
|
||||||
|
'fields': (
|
||||||
|
'force_password_change', 'password_expires_at',
|
||||||
|
'failed_login_attempts', 'locked_until', 'two_factor_enabled',
|
||||||
|
'max_concurrent_sessions', 'session_timeout_minutes'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Preferences', {
|
||||||
|
'fields': ( 'language', 'theme')
|
||||||
|
}),
|
||||||
|
('Status', {
|
||||||
|
'fields': (
|
||||||
|
'is_verified', 'is_approved', 'approval_date', 'approved_by'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at', 'updated_at', 'last_password_change'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = [
|
||||||
|
'user_id', 'created_at', 'updated_at', 'last_password_change'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related('tenant', 'approved_by')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TwoFactorDevice)
|
||||||
|
class TwoFactorDeviceAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin configuration for TwoFactorDevice model.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'user', 'name', 'device_type', 'is_active', 'is_verified',
|
||||||
|
'last_used_at', 'usage_count'
|
||||||
|
]
|
||||||
|
list_filter = ['device_type', 'is_active', 'is_verified']
|
||||||
|
search_fields = ['user__username', 'user__email', 'name']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Device Information', {
|
||||||
|
'fields': ('user', 'device_id', 'name', 'device_type')
|
||||||
|
}),
|
||||||
|
('Configuration', {
|
||||||
|
'fields': ('secret_key', 'phone_number', 'email_address')
|
||||||
|
}),
|
||||||
|
('Status', {
|
||||||
|
'fields': ('is_active', 'is_verified', 'verified_at')
|
||||||
|
}),
|
||||||
|
('Usage Statistics', {
|
||||||
|
'fields': ('last_used_at', 'usage_count')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ['device_id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SocialAccount)
|
||||||
|
class SocialAccountAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin configuration for SocialAccount model.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'user', 'provider', 'provider_email', 'display_name',
|
||||||
|
'is_active', 'last_login_at'
|
||||||
|
]
|
||||||
|
list_filter = ['provider', 'is_active']
|
||||||
|
search_fields = [
|
||||||
|
'user__username', 'user__email', 'provider_email',
|
||||||
|
'display_name', 'provider_id'
|
||||||
|
]
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('User Information', {
|
||||||
|
'fields': ('user',)
|
||||||
|
}),
|
||||||
|
('Provider Information', {
|
||||||
|
'fields': (
|
||||||
|
'provider', 'provider_id', 'provider_email',
|
||||||
|
'display_name', 'profile_url', 'avatar_url'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Tokens', {
|
||||||
|
'fields': ('access_token', 'refresh_token', 'token_expires_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Status', {
|
||||||
|
'fields': ('is_active',)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at', 'updated_at', 'last_login_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserSession)
|
||||||
|
class UserSessionAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin configuration for UserSession model.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'user', 'ip_address', 'device_type', 'browser',
|
||||||
|
'is_active', 'login_method', 'created_at', 'expires_at'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'device_type', 'is_active', 'login_method',
|
||||||
|
'country', 'created_at'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'user__username', 'user__email', 'ip_address',
|
||||||
|
'user_agent', 'browser', 'operating_system'
|
||||||
|
]
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('User Information', {
|
||||||
|
'fields': ('user', 'session_key', 'session_id')
|
||||||
|
}),
|
||||||
|
('Device Information', {
|
||||||
|
'fields': (
|
||||||
|
'ip_address', 'user_agent', 'device_type',
|
||||||
|
'browser', 'operating_system'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Location Information', {
|
||||||
|
'fields': ('country', 'region', 'city')
|
||||||
|
}),
|
||||||
|
('Session Status', {
|
||||||
|
'fields': ('is_active', 'login_method')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': (
|
||||||
|
'created_at', 'last_activity_at',
|
||||||
|
'expires_at', 'ended_at'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = [
|
||||||
|
'session_id', 'created_at', 'last_activity_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related('user')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PasswordHistory)
|
||||||
|
class PasswordHistoryAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin configuration for PasswordHistory model.
|
||||||
|
"""
|
||||||
|
list_display = ['user', 'created_at']
|
||||||
|
list_filter = ['created_at']
|
||||||
|
search_fields = ['user__username', 'user__email']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('User Information', {
|
||||||
|
'fields': ('user',)
|
||||||
|
}),
|
||||||
|
('Password Information', {
|
||||||
|
'fields': ('password_hash',)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ['created_at']
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).select_related('user')
|
||||||
|
|
||||||
2
accounts/api/__init__.py
Normal file
2
accounts/api/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Accounts API package
|
||||||
|
|
||||||
BIN
accounts/api/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
accounts/api/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/api/__pycache__/serializers.cpython-311.pyc
Normal file
BIN
accounts/api/__pycache__/serializers.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/api/__pycache__/urls.cpython-311.pyc
Normal file
BIN
accounts/api/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
accounts/api/__pycache__/views.cpython-311.pyc
Normal file
BIN
accounts/api/__pycache__/views.cpython-311.pyc
Normal file
Binary file not shown.
217
accounts/api/serializers.py
Normal file
217
accounts/api/serializers.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
"""
|
||||||
|
Accounts API serializers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from ..models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
User serializer.
|
||||||
|
"""
|
||||||
|
tenant_name = serializers.CharField(source='tenant.name', read_only=True)
|
||||||
|
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||||
|
display_name = serializers.CharField(source='get_display_name', read_only=True)
|
||||||
|
is_account_locked = serializers.ReadOnlyField()
|
||||||
|
is_password_expired = serializers.ReadOnlyField()
|
||||||
|
is_license_expired = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'id', 'user_id', 'username', 'email', 'first_name', 'last_name',
|
||||||
|
'middle_name', 'preferred_name', 'full_name', 'display_name',
|
||||||
|
'phone_number', 'mobile_number', 'tenant', 'tenant_name',
|
||||||
|
'employee_id', 'department', 'job_title', 'role',
|
||||||
|
'license_number', 'license_state', 'license_expiry',
|
||||||
|
'dea_number', 'npi_number', 'timezone', 'language', 'theme',
|
||||||
|
'profile_picture', 'bio', 'is_active', 'is_verified', 'is_approved',
|
||||||
|
'approval_date', 'two_factor_enabled', 'max_concurrent_sessions',
|
||||||
|
'session_timeout_minutes', 'is_account_locked', 'is_password_expired',
|
||||||
|
'is_license_expired', 'last_login', 'date_joined', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'user_id', 'last_login', 'date_joined', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'password': {'write_only': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
password = validated_data.pop('password', None)
|
||||||
|
user = User.objects.create_user(**validated_data)
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
password = validated_data.pop('password', None)
|
||||||
|
user = super().update(instance, validated_data)
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
User profile serializer for self-service updates.
|
||||||
|
"""
|
||||||
|
tenant_name = serializers.CharField(source='tenant.name', read_only=True)
|
||||||
|
full_name = serializers.CharField(source='get_full_name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'id', 'username', 'email', 'first_name', 'last_name',
|
||||||
|
'middle_name', 'preferred_name', 'full_name',
|
||||||
|
'phone_number', 'mobile_number', 'tenant_name',
|
||||||
|
'timezone', 'language', 'theme', 'profile_picture', 'bio'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'username', 'tenant_name']
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDeviceSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Two-factor device serializer.
|
||||||
|
"""
|
||||||
|
user_name = serializers.CharField(source='user.get_display_name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TwoFactorDevice
|
||||||
|
fields = [
|
||||||
|
'device_id', 'user', 'user_name', 'name', 'device_type',
|
||||||
|
'phone_number', 'email_address', 'is_active', 'is_verified',
|
||||||
|
'verified_at', 'last_used_at', 'usage_count',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['device_id', 'created_at', 'updated_at']
|
||||||
|
extra_kwargs = {
|
||||||
|
'secret_key': {'write_only': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Social account serializer.
|
||||||
|
"""
|
||||||
|
user_name = serializers.CharField(source='user.get_display_name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SocialAccount
|
||||||
|
fields = [
|
||||||
|
'id', 'user', 'user_name', 'provider', 'provider_id',
|
||||||
|
'provider_email', 'display_name', 'profile_url', 'avatar_url',
|
||||||
|
'is_active', 'created_at', 'updated_at', 'last_login_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['created_at', 'updated_at']
|
||||||
|
extra_kwargs = {
|
||||||
|
'access_token': {'write_only': True},
|
||||||
|
'refresh_token': {'write_only': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserSessionSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
User session serializer.
|
||||||
|
"""
|
||||||
|
user_name = serializers.CharField(source='user.get_display_name', read_only=True)
|
||||||
|
is_expired = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserSession
|
||||||
|
fields = [
|
||||||
|
'session_id', 'user', 'user_name', 'session_key',
|
||||||
|
'ip_address', 'user_agent', 'device_type', 'browser',
|
||||||
|
'operating_system', 'country', 'region', 'city',
|
||||||
|
'is_active', 'login_method', 'is_expired',
|
||||||
|
'created_at', 'last_activity_at', 'expires_at', 'ended_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['session_id', 'created_at', 'last_activity_at']
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordHistorySerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Password history serializer.
|
||||||
|
"""
|
||||||
|
user_name = serializers.CharField(source='user.get_display_name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PasswordHistory
|
||||||
|
fields = ['id', 'user', 'user_name', 'created_at']
|
||||||
|
read_only_fields = ['created_at']
|
||||||
|
extra_kwargs = {
|
||||||
|
'password_hash': {'write_only': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Login serializer.
|
||||||
|
"""
|
||||||
|
username = serializers.CharField()
|
||||||
|
password = serializers.CharField(write_only=True)
|
||||||
|
remember_me = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
username = attrs.get('username')
|
||||||
|
password = attrs.get('password')
|
||||||
|
|
||||||
|
if username and password:
|
||||||
|
user = authenticate(username=username, password=password)
|
||||||
|
if not user:
|
||||||
|
raise serializers.ValidationError('Invalid credentials')
|
||||||
|
if not user.is_active:
|
||||||
|
raise serializers.ValidationError('User account is disabled')
|
||||||
|
if user.is_account_locked:
|
||||||
|
raise serializers.ValidationError('User account is locked')
|
||||||
|
|
||||||
|
attrs['user'] = user
|
||||||
|
else:
|
||||||
|
raise serializers.ValidationError('Must include username and password')
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Password change serializer.
|
||||||
|
"""
|
||||||
|
old_password = serializers.CharField(write_only=True)
|
||||||
|
new_password = serializers.CharField(write_only=True)
|
||||||
|
confirm_password = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
def validate_old_password(self, value):
|
||||||
|
user = self.context['request'].user
|
||||||
|
if not user.check_password(value):
|
||||||
|
raise serializers.ValidationError('Old password is incorrect')
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs['new_password'] != attrs['confirm_password']:
|
||||||
|
raise serializers.ValidationError('New passwords do not match')
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
user = self.context['request'].user
|
||||||
|
user.set_password(self.validated_data['new_password'])
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Password reset serializer.
|
||||||
|
"""
|
||||||
|
email = serializers.EmailField()
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=value, is_active=True)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise serializers.ValidationError('No active user found with this email')
|
||||||
|
return value
|
||||||
|
|
||||||
19
accounts/api/urls.py
Normal file
19
accounts/api/urls.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Accounts API URLs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'users', views.UserViewSet, basename='user')
|
||||||
|
router.register(r'two-factor-devices', views.TwoFactorDeviceViewSet, basename='twofactordevice')
|
||||||
|
router.register(r'social-accounts', views.SocialAccountViewSet, basename='socialaccount')
|
||||||
|
router.register(r'sessions', views.UserSessionViewSet, basename='usersession')
|
||||||
|
router.register(r'auth', views.AuthViewSet, basename='auth')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
445
accounts/api/views.py
Normal file
445
accounts/api/views.py
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
"""
|
||||||
|
Accounts API views.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import viewsets, filters, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth import login, logout
|
||||||
|
from ..models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||||
|
from .serializers import (
|
||||||
|
UserSerializer, UserProfileSerializer, TwoFactorDeviceSerializer,
|
||||||
|
SocialAccountSerializer, UserSessionSerializer, PasswordHistorySerializer,
|
||||||
|
LoginSerializer, PasswordChangeSerializer, PasswordResetSerializer
|
||||||
|
)
|
||||||
|
from core.utils import AuditLogger
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
User API viewset.
|
||||||
|
"""
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = [
|
||||||
|
'role', 'department', 'is_active', 'is_verified', 'is_approved',
|
||||||
|
'two_factor_enabled'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'username', 'email', 'first_name', 'last_name',
|
||||||
|
'employee_id', 'license_number', 'npi_number'
|
||||||
|
]
|
||||||
|
ordering_fields = ['username', 'email', 'last_name', 'created_at', 'last_login']
|
||||||
|
ordering = ['last_name', 'first_name']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if hasattr(self.request, 'tenant') and self.request.tenant:
|
||||||
|
return User.objects.filter(tenant=self.request.tenant)
|
||||||
|
return User.objects.none()
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
user = serializer.save(tenant=getattr(self.request, 'tenant', None))
|
||||||
|
|
||||||
|
# Log user creation
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(self.request, 'tenant', None),
|
||||||
|
event_type='CREATE',
|
||||||
|
event_category='DATA_MODIFICATION',
|
||||||
|
action='Create User',
|
||||||
|
description=f'Created user: {user.username}',
|
||||||
|
user=self.request.user,
|
||||||
|
content_object=user,
|
||||||
|
request=self.request
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
user = serializer.save()
|
||||||
|
|
||||||
|
# Log user update
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(self.request, 'tenant', None),
|
||||||
|
event_type='UPDATE',
|
||||||
|
event_category='DATA_MODIFICATION',
|
||||||
|
action='Update User',
|
||||||
|
description=f'Updated user: {user.username}',
|
||||||
|
user=self.request.user,
|
||||||
|
content_object=user,
|
||||||
|
request=self.request
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def me(self, request):
|
||||||
|
"""
|
||||||
|
Get current user profile.
|
||||||
|
"""
|
||||||
|
serializer = UserProfileSerializer(request.user)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['put', 'patch'])
|
||||||
|
def update_profile(self, request):
|
||||||
|
"""
|
||||||
|
Update current user profile.
|
||||||
|
"""
|
||||||
|
serializer = UserProfileSerializer(
|
||||||
|
request.user,
|
||||||
|
data=request.data,
|
||||||
|
partial=request.method == 'PATCH'
|
||||||
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# Log profile update
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='UPDATE',
|
||||||
|
event_category='DATA_MODIFICATION',
|
||||||
|
action='Update Profile',
|
||||||
|
description=f'User updated their profile: {request.user.username}',
|
||||||
|
user=request.user,
|
||||||
|
content_object=request.user,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def change_password(self, request):
|
||||||
|
"""
|
||||||
|
Change user password.
|
||||||
|
"""
|
||||||
|
serializer = PasswordChangeSerializer(
|
||||||
|
data=request.data,
|
||||||
|
context={'request': request}
|
||||||
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# Log password change
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='UPDATE',
|
||||||
|
event_category='SECURITY',
|
||||||
|
action='Change Password',
|
||||||
|
description=f'User changed their password: {request.user.username}',
|
||||||
|
user=request.user,
|
||||||
|
content_object=request.user,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'message': 'Password changed successfully'})
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def lock_account(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Lock user account.
|
||||||
|
"""
|
||||||
|
user = self.get_object()
|
||||||
|
duration = request.data.get('duration_minutes', 15)
|
||||||
|
user.lock_account(duration)
|
||||||
|
|
||||||
|
# Log account lock
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='UPDATE',
|
||||||
|
event_category='SECURITY',
|
||||||
|
action='Lock User Account',
|
||||||
|
description=f'Locked user account: {user.username}',
|
||||||
|
user=request.user,
|
||||||
|
content_object=user,
|
||||||
|
additional_data={'duration_minutes': duration},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'message': 'Account locked successfully'})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def unlock_account(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Unlock user account.
|
||||||
|
"""
|
||||||
|
user = self.get_object()
|
||||||
|
user.unlock_account()
|
||||||
|
|
||||||
|
# Log account unlock
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='UPDATE',
|
||||||
|
event_category='SECURITY',
|
||||||
|
action='Unlock User Account',
|
||||||
|
description=f'Unlocked user account: {user.username}',
|
||||||
|
user=request.user,
|
||||||
|
content_object=user,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'message': 'Account unlocked successfully'})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def statistics(self, request):
|
||||||
|
"""
|
||||||
|
Get user statistics.
|
||||||
|
"""
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_users': queryset.count(),
|
||||||
|
'active_users': queryset.filter(is_active=True).count(),
|
||||||
|
'pending_approval': queryset.filter(is_approved=False).count(),
|
||||||
|
'locked_accounts': queryset.filter(locked_until__gt=timezone.now()).count(),
|
||||||
|
'two_factor_enabled': queryset.filter(two_factor_enabled=True).count(),
|
||||||
|
'users_by_role': list(
|
||||||
|
queryset.values('role').annotate(count=Count('id')).order_by('-count')
|
||||||
|
),
|
||||||
|
'users_by_department': list(
|
||||||
|
queryset.exclude(department__isnull=True).exclude(department='')
|
||||||
|
.values('department').annotate(count=Count('id')).order_by('-count')[:10]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(stats)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDeviceViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
Two-factor device API viewset.
|
||||||
|
"""
|
||||||
|
serializer_class = TwoFactorDeviceSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['device_type', 'is_active', 'is_verified']
|
||||||
|
search_fields = ['name']
|
||||||
|
ordering_fields = ['name', 'created_at', 'last_used_at']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Users can only see their own devices
|
||||||
|
return TwoFactorDevice.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
device = serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
# Log device creation
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(self.request, 'tenant', None),
|
||||||
|
event_type='CREATE',
|
||||||
|
event_category='SECURITY',
|
||||||
|
action='Create Two-Factor Device',
|
||||||
|
description=f'Created two-factor device: {device.name}',
|
||||||
|
user=self.request.user,
|
||||||
|
content_object=device,
|
||||||
|
request=self.request
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
device_name = instance.name
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
# Log device deletion
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(self.request, 'tenant', None),
|
||||||
|
event_type='DELETE',
|
||||||
|
event_category='SECURITY',
|
||||||
|
action='Delete Two-Factor Device',
|
||||||
|
description=f'Deleted two-factor device: {device_name}',
|
||||||
|
user=self.request.user,
|
||||||
|
additional_data={'device_name': device_name},
|
||||||
|
request=self.request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
Social account API viewset.
|
||||||
|
"""
|
||||||
|
serializer_class = SocialAccountSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['provider', 'is_active']
|
||||||
|
search_fields = ['provider_email', 'display_name']
|
||||||
|
ordering_fields = ['provider', 'created_at', 'last_login_at']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Users can only see their own social accounts
|
||||||
|
return SocialAccount.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSessionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
User session API viewset.
|
||||||
|
"""
|
||||||
|
serializer_class = UserSessionSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['device_type', 'is_active', 'login_method', 'country']
|
||||||
|
search_fields = ['ip_address', 'browser', 'operating_system']
|
||||||
|
ordering_fields = ['created_at', 'last_activity_at', 'expires_at']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if self.request.user.is_staff:
|
||||||
|
# Staff can see all sessions in their tenant
|
||||||
|
if hasattr(self.request, 'tenant') and self.request.tenant:
|
||||||
|
return UserSession.objects.filter(user__tenant=self.request.tenant)
|
||||||
|
else:
|
||||||
|
# Regular users can only see their own sessions
|
||||||
|
return UserSession.objects.filter(user=self.request.user)
|
||||||
|
return UserSession.objects.none()
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def end_session(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
End a user session.
|
||||||
|
"""
|
||||||
|
session = self.get_object()
|
||||||
|
|
||||||
|
# Check if user can end this session
|
||||||
|
if not request.user.is_staff and session.user != request.user:
|
||||||
|
return Response(
|
||||||
|
{'error': 'You can only end your own sessions'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
session.end_session()
|
||||||
|
|
||||||
|
# Log session termination
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='UPDATE',
|
||||||
|
event_category='AUTHENTICATION',
|
||||||
|
action='End User Session',
|
||||||
|
description=f'Ended session for user: {session.user.username}',
|
||||||
|
user=request.user,
|
||||||
|
content_object=session,
|
||||||
|
additional_data={
|
||||||
|
'target_user': session.user.username,
|
||||||
|
'session_ip': session.ip_address,
|
||||||
|
},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'message': 'Session ended successfully'})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def active(self, request):
|
||||||
|
"""
|
||||||
|
Get active sessions.
|
||||||
|
"""
|
||||||
|
queryset = self.get_queryset().filter(is_active=True)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthViewSet(viewsets.ViewSet):
|
||||||
|
"""
|
||||||
|
Authentication API viewset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], permission_classes=[AllowAny])
|
||||||
|
def login(self, request):
|
||||||
|
"""
|
||||||
|
User login.
|
||||||
|
"""
|
||||||
|
serializer = LoginSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
user = serializer.validated_data['user']
|
||||||
|
login(request, user)
|
||||||
|
|
||||||
|
# Log successful login
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='LOGIN',
|
||||||
|
event_category='AUTHENTICATION',
|
||||||
|
action='User Login',
|
||||||
|
description=f'User logged in: {user.username}',
|
||||||
|
user=user,
|
||||||
|
content_object=user,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset failed login attempts
|
||||||
|
user.reset_failed_login()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Login successful',
|
||||||
|
'user': UserSerializer(user).data
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Log failed login attempt
|
||||||
|
username = request.data.get('username')
|
||||||
|
if username:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(username=username)
|
||||||
|
user.increment_failed_login()
|
||||||
|
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='LOGIN',
|
||||||
|
event_category='SECURITY',
|
||||||
|
action='Failed Login Attempt',
|
||||||
|
description=f'Failed login attempt for user: {username}',
|
||||||
|
user=None,
|
||||||
|
is_successful=False,
|
||||||
|
additional_data={'username': username},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def logout(self, request):
|
||||||
|
"""
|
||||||
|
User logout.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
logout(request)
|
||||||
|
|
||||||
|
# Log logout
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='LOGOUT',
|
||||||
|
event_category='AUTHENTICATION',
|
||||||
|
action='User Logout',
|
||||||
|
description=f'User logged out: {user.username}',
|
||||||
|
user=user,
|
||||||
|
content_object=user,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'message': 'Logout successful'})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], permission_classes=[AllowAny])
|
||||||
|
def password_reset(self, request):
|
||||||
|
"""
|
||||||
|
Password reset request.
|
||||||
|
"""
|
||||||
|
serializer = PasswordResetSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
email = serializer.validated_data['email']
|
||||||
|
|
||||||
|
# Log password reset request
|
||||||
|
AuditLogger.log_event(
|
||||||
|
tenant=getattr(request, 'tenant', None),
|
||||||
|
event_type='UPDATE',
|
||||||
|
event_category='SECURITY',
|
||||||
|
action='Password Reset Request',
|
||||||
|
description=f'Password reset requested for email: {email}',
|
||||||
|
user=None,
|
||||||
|
additional_data={'email': email},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
# In a real implementation, send password reset email here
|
||||||
|
return Response({'message': 'Password reset email sent'})
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
6
accounts/apps.py
Normal file
6
accounts/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'accounts'
|
||||||
225
accounts/forms.py
Normal file
225
accounts/forms.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
Forms for Accounts app CRUD operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from .models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||||
|
|
||||||
|
|
||||||
|
class UserForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for updating user information.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'first_name', 'last_name', 'email', 'phone_number', 'mobile_number',
|
||||||
|
'employee_id', 'role', 'department', 'bio', 'user_timezone', 'language',
|
||||||
|
'theme', 'is_active', 'is_approved'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
'phone_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'mobile_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'role': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'department': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'bio': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||||
|
'user_timezone': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'language': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'theme': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'is_approved': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateForm(UserCreationForm):
|
||||||
|
"""
|
||||||
|
Form for creating new users.
|
||||||
|
"""
|
||||||
|
first_name = forms.CharField(max_length=150, required=True)
|
||||||
|
last_name = forms.CharField(max_length=150, required=True)
|
||||||
|
email = forms.EmailField(required=True)
|
||||||
|
employee_id = forms.CharField(max_length=50, required=False)
|
||||||
|
role = forms.ChoiceField(choices=User._meta.get_field('role').choices, required=True)
|
||||||
|
department = forms.CharField(max_length=100, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'username', 'first_name', 'last_name', 'email', 'employee_id',
|
||||||
|
'role', 'department', 'password1', 'password2'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'username': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
'employee_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'role': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'department': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['password1'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
self.fields['password2'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDeviceForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for two-factor device management.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = TwoFactorDevice
|
||||||
|
fields = ['user', 'name', 'device_type', 'phone_number', 'email_address']
|
||||||
|
widgets = {
|
||||||
|
'user': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'device_type': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'phone_number': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email_address': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user and hasattr(user, 'tenant'):
|
||||||
|
self.fields['user'].queryset = User.objects.filter(
|
||||||
|
tenant=user.tenant,
|
||||||
|
is_active=True
|
||||||
|
).order_by('last_name', 'first_name')
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
device_type = cleaned_data.get('device_type')
|
||||||
|
phone_number = cleaned_data.get('phone_number')
|
||||||
|
email_address = cleaned_data.get('email_address')
|
||||||
|
|
||||||
|
if device_type == 'SMS' and not phone_number:
|
||||||
|
raise ValidationError('Phone number is required for SMS devices.')
|
||||||
|
|
||||||
|
if device_type == 'EMAIL' and not email_address:
|
||||||
|
raise ValidationError('Email address is required for email devices.')
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for social account management.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = SocialAccount
|
||||||
|
fields = ['user', 'provider', 'provider_id', 'display_name', 'profile_url']
|
||||||
|
widgets = {
|
||||||
|
'user': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'provider': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'provider_id': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'display_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'profile_url': forms.URLInput(attrs={'class': 'form-control'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user and hasattr(user, 'tenant'):
|
||||||
|
self.fields['user'].queryset = User.objects.filter(
|
||||||
|
tenant=user.tenant,
|
||||||
|
is_active=True
|
||||||
|
).order_by('last_name', 'first_name')
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsSearchForm(forms.Form):
|
||||||
|
"""
|
||||||
|
Form for searching accounts data.
|
||||||
|
"""
|
||||||
|
search = forms.CharField(
|
||||||
|
max_length=255,
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Search users, sessions, devices...'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
role = forms.ChoiceField(
|
||||||
|
choices=[('', 'All Roles')] + list(User._meta.get_field('role').choices),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
|
)
|
||||||
|
status = forms.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
('', 'All Status'),
|
||||||
|
('active', 'Active'),
|
||||||
|
('inactive', 'Inactive'),
|
||||||
|
('pending', 'Pending Approval')
|
||||||
|
],
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-select'})
|
||||||
|
)
|
||||||
|
date_from = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
date_to = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.DateInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'type': 'date'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeForm(forms.Form):
|
||||||
|
"""
|
||||||
|
Form for changing user passwords.
|
||||||
|
"""
|
||||||
|
old_password = forms.CharField(
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
|
||||||
|
label='Current Password'
|
||||||
|
)
|
||||||
|
new_password1 = forms.CharField(
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
|
||||||
|
label='New Password'
|
||||||
|
)
|
||||||
|
new_password2 = forms.CharField(
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
|
||||||
|
label='Confirm New Password'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, user, *args, **kwargs):
|
||||||
|
self.user = user
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_old_password(self):
|
||||||
|
old_password = self.cleaned_data.get('old_password')
|
||||||
|
if not self.user.check_password(old_password):
|
||||||
|
raise ValidationError('Your old password was entered incorrectly.')
|
||||||
|
return old_password
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
password1 = cleaned_data.get('new_password1')
|
||||||
|
password2 = cleaned_data.get('new_password2')
|
||||||
|
|
||||||
|
if password1 and password2:
|
||||||
|
if password1 != password2:
|
||||||
|
raise ValidationError('The two password fields didn\'t match.')
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
password = self.cleaned_data['new_password1']
|
||||||
|
self.user.set_password(password)
|
||||||
|
self.user.save()
|
||||||
|
return self.user
|
||||||
|
|
||||||
745
accounts/migrations/0001_initial.py
Normal file
745
accounts/migrations/0001_initial.py
Normal file
@ -0,0 +1,745 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-04 04:41
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PasswordHistory",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"password_hash",
|
||||||
|
models.CharField(help_text="Hashed password", max_length=128),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Password History",
|
||||||
|
"verbose_name_plural": "Password History",
|
||||||
|
"db_table": "accounts_password_history",
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SocialAccount",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("GOOGLE", "Google"),
|
||||||
|
("MICROSOFT", "Microsoft"),
|
||||||
|
("APPLE", "Apple"),
|
||||||
|
("FACEBOOK", "Facebook"),
|
||||||
|
("LINKEDIN", "LinkedIn"),
|
||||||
|
("GITHUB", "GitHub"),
|
||||||
|
("OKTA", "Okta"),
|
||||||
|
("SAML", "SAML"),
|
||||||
|
("LDAP", "LDAP"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider_id",
|
||||||
|
models.CharField(help_text="Provider user ID", max_length=200),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider_email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Email from provider",
|
||||||
|
max_length=254,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Display name from provider",
|
||||||
|
max_length=200,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"profile_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True, help_text="Profile URL from provider", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"avatar_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True, help_text="Avatar URL from provider", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"access_token",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Access token from provider", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"refresh_token",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Refresh token from provider", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"token_expires_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, help_text="Token expiration date", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True, help_text="Social account is active"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"last_login_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Last login using this social account",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Social Account",
|
||||||
|
"verbose_name_plural": "Social Accounts",
|
||||||
|
"db_table": "accounts_social_account",
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TwoFactorDevice",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"device_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="Unique device identifier",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(help_text="Device name", max_length=100)),
|
||||||
|
(
|
||||||
|
"device_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("TOTP", "Time-based OTP (Authenticator App)"),
|
||||||
|
("SMS", "SMS"),
|
||||||
|
("EMAIL", "Email"),
|
||||||
|
("HARDWARE", "Hardware Token"),
|
||||||
|
("BACKUP", "Backup Codes"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"secret_key",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Secret key for TOTP devices",
|
||||||
|
max_length=200,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"phone_number",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Phone number for SMS devices",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email_address",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Email address for email devices",
|
||||||
|
max_length=254,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(default=True, help_text="Device is active"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_verified",
|
||||||
|
models.BooleanField(default=False, help_text="Device is verified"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"verified_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, help_text="Device verification date", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_used_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, help_text="Last time device was used", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"usage_count",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=0, help_text="Number of times device was used"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Two Factor Device",
|
||||||
|
"verbose_name_plural": "Two Factor Devices",
|
||||||
|
"db_table": "accounts_two_factor_device",
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserSession",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_key",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Django session key", max_length=40, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="Unique session identifier",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ip_address", models.GenericIPAddressField(help_text="IP address")),
|
||||||
|
("user_agent", models.TextField(help_text="User agent string")),
|
||||||
|
(
|
||||||
|
"device_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("DESKTOP", "Desktop"),
|
||||||
|
("MOBILE", "Mobile"),
|
||||||
|
("TABLET", "Tablet"),
|
||||||
|
("UNKNOWN", "Unknown"),
|
||||||
|
],
|
||||||
|
default="UNKNOWN",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"browser",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Browser name and version",
|
||||||
|
max_length=100,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"operating_system",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Operating system",
|
||||||
|
max_length=100,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"country",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Country", max_length=100, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"region",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Region/State", max_length=100, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"city",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="City", max_length=100, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(default=True, help_text="Session is active"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"login_method",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PASSWORD", "Password"),
|
||||||
|
("TWO_FACTOR", "Two Factor"),
|
||||||
|
("SOCIAL", "Social Login"),
|
||||||
|
("SSO", "Single Sign-On"),
|
||||||
|
("API_KEY", "API Key"),
|
||||||
|
],
|
||||||
|
default="PASSWORD",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("last_activity_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"expires_at",
|
||||||
|
models.DateTimeField(help_text="Session expiration time"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ended_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, help_text="Session end time", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "User Session",
|
||||||
|
"verbose_name_plural": "User Sessions",
|
||||||
|
"db_table": "accounts_user_session",
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="User",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
|
(
|
||||||
|
"last_login",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="last login"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_superuser",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
|
verbose_name="superuser status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"username",
|
||||||
|
models.CharField(
|
||||||
|
error_messages={
|
||||||
|
"unique": "A user with that username already exists."
|
||||||
|
},
|
||||||
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
|
max_length=150,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||||
|
],
|
||||||
|
verbose_name="username",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="last name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True, max_length=254, verbose_name="email address"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_staff",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates whether the user can log into this admin site.",
|
||||||
|
verbose_name="staff status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||||
|
verbose_name="active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"date_joined",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now, verbose_name="date joined"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="Unique user identifier",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"middle_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Middle name", max_length=150, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"preferred_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Preferred name",
|
||||||
|
max_length=150,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"phone_number",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Primary phone number",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator(
|
||||||
|
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.',
|
||||||
|
regex="^\\+?1?\\d{9,15}$",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mobile_number",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Mobile phone number",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator(
|
||||||
|
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.',
|
||||||
|
regex="^\\+?1?\\d{9,15}$",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"employee_id",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Employee ID", max_length=50, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"department",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Department", max_length=100, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"job_title",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Job title", max_length=100, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"role",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("SUPER_ADMIN", "Super Administrator"),
|
||||||
|
("ADMIN", "Administrator"),
|
||||||
|
("PHYSICIAN", "Physician"),
|
||||||
|
("NURSE", "Nurse"),
|
||||||
|
("NURSE_PRACTITIONER", "Nurse Practitioner"),
|
||||||
|
("PHYSICIAN_ASSISTANT", "Physician Assistant"),
|
||||||
|
("PHARMACIST", "Pharmacist"),
|
||||||
|
("PHARMACY_TECH", "Pharmacy Technician"),
|
||||||
|
("LAB_TECH", "Laboratory Technician"),
|
||||||
|
("RADIOLOGIST", "Radiologist"),
|
||||||
|
("RAD_TECH", "Radiology Technician"),
|
||||||
|
("THERAPIST", "Therapist"),
|
||||||
|
("SOCIAL_WORKER", "Social Worker"),
|
||||||
|
("CASE_MANAGER", "Case Manager"),
|
||||||
|
("BILLING_SPECIALIST", "Billing Specialist"),
|
||||||
|
("REGISTRATION", "Registration Staff"),
|
||||||
|
("SCHEDULER", "Scheduler"),
|
||||||
|
("MEDICAL_ASSISTANT", "Medical Assistant"),
|
||||||
|
("CLERICAL", "Clerical Staff"),
|
||||||
|
("IT_SUPPORT", "IT Support"),
|
||||||
|
("QUALITY_ASSURANCE", "Quality Assurance"),
|
||||||
|
("COMPLIANCE", "Compliance Officer"),
|
||||||
|
("SECURITY", "Security"),
|
||||||
|
("MAINTENANCE", "Maintenance"),
|
||||||
|
("VOLUNTEER", "Volunteer"),
|
||||||
|
("STUDENT", "Student"),
|
||||||
|
("RESEARCHER", "Researcher"),
|
||||||
|
("CONSULTANT", "Consultant"),
|
||||||
|
("VENDOR", "Vendor"),
|
||||||
|
("GUEST", "Guest"),
|
||||||
|
],
|
||||||
|
default="CLERICAL",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"license_number",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Professional license number",
|
||||||
|
max_length=100,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"license_state",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="License issuing state",
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"license_expiry",
|
||||||
|
models.DateField(
|
||||||
|
blank=True, help_text="License expiry date", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dea_number",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="DEA number for prescribing",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"npi_number",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="National Provider Identifier",
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"force_password_change",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="User must change password on next login",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"password_expires_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, help_text="Password expiration date", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"failed_login_attempts",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=0, help_text="Number of failed login attempts"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"locked_until",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Account locked until this time",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"two_factor_enabled",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False, help_text="Two-factor authentication enabled"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"max_concurrent_sessions",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=3, help_text="Maximum concurrent sessions allowed"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_timeout_minutes",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=30, help_text="Session timeout in minutes"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_timezone",
|
||||||
|
models.CharField(
|
||||||
|
default="UTC", help_text="User timezone", max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language",
|
||||||
|
models.CharField(
|
||||||
|
default="en", help_text="Preferred language", max_length=10
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"theme",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("LIGHT", "Light"),
|
||||||
|
("DARK", "Dark"),
|
||||||
|
("AUTO", "Auto"),
|
||||||
|
],
|
||||||
|
default="LIGHT",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"profile_picture",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Profile picture",
|
||||||
|
null=True,
|
||||||
|
upload_to="profile_pictures/",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bio",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Professional bio", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_verified",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False, help_text="User account is verified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_approved",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False, help_text="User account is approved"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"approval_date",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, help_text="Account approval date", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"last_password_change",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
help_text="Last password change date",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"approved_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="User who approved this account",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="approved_users",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "User",
|
||||||
|
"verbose_name_plural": "Users",
|
||||||
|
"db_table": "accounts_user",
|
||||||
|
"ordering": ["last_name", "first_name"],
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
123
accounts/migrations/0002_initial.py
Normal file
123
accounts/migrations/0002_initial.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-04 04:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0001_initial"),
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("core", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="tenant",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="Organization tenant",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="users",
|
||||||
|
to="core.tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="user_permissions",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="passwordhistory",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="password_history",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="socialaccount",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="social_accounts",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="twofactordevice",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="two_factor_devices",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="usersession",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_sessions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "role"], name="accounts_us_tenant__731b87_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["employee_id"], name="accounts_us_employe_0cbd94_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["license_number"], name="accounts_us_license_02eb85_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["npi_number"], name="accounts_us_npi_num_800ef1_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="socialaccount",
|
||||||
|
unique_together={("provider", "provider_id")},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="usersession",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["user", "is_active"], name="accounts_us_user_id_f3bc3f_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="usersession",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["session_key"], name="accounts_us_session_5ce38e_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="usersession",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["ip_address"], name="accounts_us_ip_addr_f7885b_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
BIN
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/migrations/__pycache__/0002_initial.cpython-312.pyc
Normal file
BIN
accounts/migrations/__pycache__/0002_initial.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
accounts/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
709
accounts/models.py
Normal file
709
accounts/models.py
Normal file
@ -0,0 +1,709 @@
|
|||||||
|
"""
|
||||||
|
Accounts app models for hospital management system.
|
||||||
|
Provides user management, authentication, and authorization functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
"""
|
||||||
|
Extended user model for hospital management system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Basic Information
|
||||||
|
user_id = models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
help_text='Unique user identifier'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tenant relationship
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
'core.Tenant',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='users',
|
||||||
|
help_text='Organization tenant'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Personal Information
|
||||||
|
middle_name = models.CharField(
|
||||||
|
max_length=150,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Middle name'
|
||||||
|
)
|
||||||
|
preferred_name = models.CharField(
|
||||||
|
max_length=150,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Preferred name'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Contact Information
|
||||||
|
phone_number = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[RegexValidator(
|
||||||
|
regex=r'^\+?1?\d{9,15}$',
|
||||||
|
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
|
||||||
|
)],
|
||||||
|
help_text='Primary phone number'
|
||||||
|
)
|
||||||
|
mobile_number = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[RegexValidator(
|
||||||
|
regex=r'^\+?1?\d{9,15}$',
|
||||||
|
message='Phone number must be entered in the format: "+999999999". Up to 15 digits allowed.'
|
||||||
|
)],
|
||||||
|
help_text='Mobile phone number'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Professional Information
|
||||||
|
employee_id = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Employee ID'
|
||||||
|
)
|
||||||
|
department = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Department'
|
||||||
|
)
|
||||||
|
job_title = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Job title'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Role and Permissions
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('SUPER_ADMIN', 'Super Administrator'),
|
||||||
|
('ADMIN', 'Administrator'),
|
||||||
|
('PHYSICIAN', 'Physician'),
|
||||||
|
('NURSE', 'Nurse'),
|
||||||
|
('NURSE_PRACTITIONER', 'Nurse Practitioner'),
|
||||||
|
('PHYSICIAN_ASSISTANT', 'Physician Assistant'),
|
||||||
|
('PHARMACIST', 'Pharmacist'),
|
||||||
|
('PHARMACY_TECH', 'Pharmacy Technician'),
|
||||||
|
('LAB_TECH', 'Laboratory Technician'),
|
||||||
|
('RADIOLOGIST', 'Radiologist'),
|
||||||
|
('RAD_TECH', 'Radiology Technician'),
|
||||||
|
('THERAPIST', 'Therapist'),
|
||||||
|
('SOCIAL_WORKER', 'Social Worker'),
|
||||||
|
('CASE_MANAGER', 'Case Manager'),
|
||||||
|
('BILLING_SPECIALIST', 'Billing Specialist'),
|
||||||
|
('REGISTRATION', 'Registration Staff'),
|
||||||
|
('SCHEDULER', 'Scheduler'),
|
||||||
|
('MEDICAL_ASSISTANT', 'Medical Assistant'),
|
||||||
|
('CLERICAL', 'Clerical Staff'),
|
||||||
|
('IT_SUPPORT', 'IT Support'),
|
||||||
|
('QUALITY_ASSURANCE', 'Quality Assurance'),
|
||||||
|
('COMPLIANCE', 'Compliance Officer'),
|
||||||
|
('SECURITY', 'Security'),
|
||||||
|
('MAINTENANCE', 'Maintenance'),
|
||||||
|
('VOLUNTEER', 'Volunteer'),
|
||||||
|
('STUDENT', 'Student'),
|
||||||
|
('RESEARCHER', 'Researcher'),
|
||||||
|
('CONSULTANT', 'Consultant'),
|
||||||
|
('VENDOR', 'Vendor'),
|
||||||
|
('GUEST', 'Guest'),
|
||||||
|
],
|
||||||
|
default='CLERICAL'
|
||||||
|
)
|
||||||
|
|
||||||
|
# License and Certification
|
||||||
|
license_number = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Professional license number'
|
||||||
|
)
|
||||||
|
license_state = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='License issuing state'
|
||||||
|
)
|
||||||
|
license_expiry = models.DateField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='License expiry date'
|
||||||
|
)
|
||||||
|
dea_number = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='DEA number for prescribing'
|
||||||
|
)
|
||||||
|
npi_number = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='National Provider Identifier'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Security Settings
|
||||||
|
force_password_change = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='User must change password on next login'
|
||||||
|
)
|
||||||
|
password_expires_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Password expiration date'
|
||||||
|
)
|
||||||
|
failed_login_attempts = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='Number of failed login attempts'
|
||||||
|
)
|
||||||
|
locked_until = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Account locked until this time'
|
||||||
|
)
|
||||||
|
two_factor_enabled = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='Two-factor authentication enabled'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session Management
|
||||||
|
max_concurrent_sessions = models.PositiveIntegerField(
|
||||||
|
default=3,
|
||||||
|
help_text='Maximum concurrent sessions allowed'
|
||||||
|
)
|
||||||
|
session_timeout_minutes = models.PositiveIntegerField(
|
||||||
|
default=30,
|
||||||
|
help_text='Session timeout in minutes'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preferences
|
||||||
|
user_timezone = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
default='UTC',
|
||||||
|
help_text='User timezone'
|
||||||
|
)
|
||||||
|
language = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
default='en',
|
||||||
|
help_text='Preferred language'
|
||||||
|
)
|
||||||
|
theme = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('LIGHT', 'Light'),
|
||||||
|
('DARK', 'Dark'),
|
||||||
|
('AUTO', 'Auto'),
|
||||||
|
],
|
||||||
|
default='LIGHT'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Profile Information
|
||||||
|
profile_picture = models.ImageField(
|
||||||
|
upload_to='profile_pictures/',
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Profile picture'
|
||||||
|
)
|
||||||
|
bio = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Professional bio'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_verified = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='User account is verified'
|
||||||
|
)
|
||||||
|
is_approved = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='User account is approved'
|
||||||
|
)
|
||||||
|
approval_date = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Account approval date'
|
||||||
|
)
|
||||||
|
approved_by = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='approved_users',
|
||||||
|
help_text='User who approved this account'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
last_password_change = models.DateTimeField(
|
||||||
|
default=timezone.now,
|
||||||
|
help_text='Last password change date'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'accounts_user'
|
||||||
|
verbose_name = 'User'
|
||||||
|
verbose_name_plural = 'Users'
|
||||||
|
ordering = ['last_name', 'first_name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'role']),
|
||||||
|
models.Index(fields=['employee_id']),
|
||||||
|
models.Index(fields=['license_number']),
|
||||||
|
models.Index(fields=['npi_number']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.get_full_name()} ({self.username})"
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
"""
|
||||||
|
Return the full name for the user.
|
||||||
|
"""
|
||||||
|
if self.preferred_name:
|
||||||
|
return f"{self.preferred_name} {self.last_name}"
|
||||||
|
return super().get_full_name()
|
||||||
|
|
||||||
|
def get_display_name(self):
|
||||||
|
"""
|
||||||
|
Return the display name for the user.
|
||||||
|
"""
|
||||||
|
full_name = self.get_full_name()
|
||||||
|
if full_name.strip():
|
||||||
|
return full_name
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_account_locked(self):
|
||||||
|
"""
|
||||||
|
Check if account is currently locked.
|
||||||
|
"""
|
||||||
|
if self.locked_until:
|
||||||
|
return timezone.now() < self.locked_until
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_password_expired(self):
|
||||||
|
"""
|
||||||
|
Check if password has expired.
|
||||||
|
"""
|
||||||
|
if self.password_expires_at:
|
||||||
|
return timezone.now() > self.password_expires_at
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_license_expired(self):
|
||||||
|
"""
|
||||||
|
Check if professional license has expired.
|
||||||
|
"""
|
||||||
|
if self.license_expiry:
|
||||||
|
return timezone.now().date() > self.license_expiry
|
||||||
|
return False
|
||||||
|
|
||||||
|
def lock_account(self, duration_minutes=15):
|
||||||
|
"""
|
||||||
|
Lock the user account for specified duration.
|
||||||
|
"""
|
||||||
|
self.locked_until = timezone.now() + timezone.timedelta(minutes=duration_minutes)
|
||||||
|
self.save(update_fields=['locked_until'])
|
||||||
|
|
||||||
|
def unlock_account(self):
|
||||||
|
"""
|
||||||
|
Unlock the user account.
|
||||||
|
"""
|
||||||
|
self.locked_until = None
|
||||||
|
self.failed_login_attempts = 0
|
||||||
|
self.save(update_fields=['locked_until', 'failed_login_attempts'])
|
||||||
|
|
||||||
|
def increment_failed_login(self):
|
||||||
|
"""
|
||||||
|
Increment failed login attempts and lock if threshold reached.
|
||||||
|
"""
|
||||||
|
self.failed_login_attempts += 1
|
||||||
|
|
||||||
|
# Lock account after 5 failed attempts
|
||||||
|
max_attempts = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('MAX_LOGIN_ATTEMPTS', 5)
|
||||||
|
if self.failed_login_attempts >= max_attempts:
|
||||||
|
lockout_duration = getattr(settings, 'HOSPITAL_SETTINGS', {}).get('LOCKOUT_DURATION_MINUTES', 15)
|
||||||
|
self.lock_account(lockout_duration)
|
||||||
|
|
||||||
|
self.save(update_fields=['failed_login_attempts'])
|
||||||
|
|
||||||
|
def reset_failed_login(self):
|
||||||
|
"""
|
||||||
|
Reset failed login attempts.
|
||||||
|
"""
|
||||||
|
self.failed_login_attempts = 0
|
||||||
|
self.save(update_fields=['failed_login_attempts'])
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDevice(models.Model):
|
||||||
|
"""
|
||||||
|
Two-factor authentication devices for users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# User relationship
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='two_factor_devices'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device Information
|
||||||
|
device_id = models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
help_text='Unique device identifier'
|
||||||
|
)
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text='Device name'
|
||||||
|
)
|
||||||
|
device_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('TOTP', 'Time-based OTP (Authenticator App)'),
|
||||||
|
('SMS', 'SMS'),
|
||||||
|
('EMAIL', 'Email'),
|
||||||
|
('HARDWARE', 'Hardware Token'),
|
||||||
|
('BACKUP', 'Backup Codes'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device Configuration
|
||||||
|
secret_key = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Secret key for TOTP devices'
|
||||||
|
)
|
||||||
|
phone_number = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Phone number for SMS devices'
|
||||||
|
)
|
||||||
|
email_address = models.EmailField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Email address for email devices'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Device is active'
|
||||||
|
)
|
||||||
|
is_verified = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='Device is verified'
|
||||||
|
)
|
||||||
|
verified_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Device verification date'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage Statistics
|
||||||
|
last_used_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Last time device was used'
|
||||||
|
)
|
||||||
|
usage_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='Number of times device was used'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'accounts_two_factor_device'
|
||||||
|
verbose_name = 'Two Factor Device'
|
||||||
|
verbose_name_plural = 'Two Factor Devices'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.name} ({self.device_type})"
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccount(models.Model):
|
||||||
|
"""
|
||||||
|
Social authentication accounts linked to users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# User relationship
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='social_accounts'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provider Information
|
||||||
|
provider = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('GOOGLE', 'Google'),
|
||||||
|
('MICROSOFT', 'Microsoft'),
|
||||||
|
('APPLE', 'Apple'),
|
||||||
|
('FACEBOOK', 'Facebook'),
|
||||||
|
('LINKEDIN', 'LinkedIn'),
|
||||||
|
('GITHUB', 'GitHub'),
|
||||||
|
('OKTA', 'Okta'),
|
||||||
|
('SAML', 'SAML'),
|
||||||
|
('LDAP', 'LDAP'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
provider_id = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
help_text='Provider user ID'
|
||||||
|
)
|
||||||
|
provider_email = models.EmailField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Email from provider'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Account Information
|
||||||
|
display_name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Display name from provider'
|
||||||
|
)
|
||||||
|
profile_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Profile URL from provider'
|
||||||
|
)
|
||||||
|
avatar_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Avatar URL from provider'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tokens
|
||||||
|
access_token = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Access token from provider'
|
||||||
|
)
|
||||||
|
refresh_token = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Refresh token from provider'
|
||||||
|
)
|
||||||
|
token_expires_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Token expiration date'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Social account is active'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
last_login_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Last login using this social account'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'accounts_social_account'
|
||||||
|
verbose_name = 'Social Account'
|
||||||
|
verbose_name_plural = 'Social Accounts'
|
||||||
|
unique_together = ['provider', 'provider_id']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.provider}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserSession(models.Model):
|
||||||
|
"""
|
||||||
|
User session tracking for security and audit purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# User relationship
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='user_sessions'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session Information
|
||||||
|
session_key = models.CharField(
|
||||||
|
max_length=40,
|
||||||
|
unique=True,
|
||||||
|
help_text='Django session key'
|
||||||
|
)
|
||||||
|
session_id = models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
help_text='Unique session identifier'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device Information
|
||||||
|
ip_address = models.GenericIPAddressField(
|
||||||
|
help_text='IP address'
|
||||||
|
)
|
||||||
|
user_agent = models.TextField(
|
||||||
|
help_text='User agent string'
|
||||||
|
)
|
||||||
|
device_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('DESKTOP', 'Desktop'),
|
||||||
|
('MOBILE', 'Mobile'),
|
||||||
|
('TABLET', 'Tablet'),
|
||||||
|
('UNKNOWN', 'Unknown'),
|
||||||
|
],
|
||||||
|
default='UNKNOWN'
|
||||||
|
)
|
||||||
|
browser = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Browser name and version'
|
||||||
|
)
|
||||||
|
operating_system = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Operating system'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Location Information
|
||||||
|
country = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Country'
|
||||||
|
)
|
||||||
|
region = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Region/State'
|
||||||
|
)
|
||||||
|
city = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='City'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session Status
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Session is active'
|
||||||
|
)
|
||||||
|
login_method = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('PASSWORD', 'Password'),
|
||||||
|
('TWO_FACTOR', 'Two Factor'),
|
||||||
|
('SOCIAL', 'Social Login'),
|
||||||
|
('SSO', 'Single Sign-On'),
|
||||||
|
('API_KEY', 'API Key'),
|
||||||
|
],
|
||||||
|
default='PASSWORD'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
last_activity_at = models.DateTimeField(auto_now=True)
|
||||||
|
expires_at = models.DateTimeField(
|
||||||
|
help_text='Session expiration time'
|
||||||
|
)
|
||||||
|
ended_at = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='Session end time'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'accounts_user_session'
|
||||||
|
verbose_name = 'User Session'
|
||||||
|
verbose_name_plural = 'User Sessions'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'is_active']),
|
||||||
|
models.Index(fields=['session_key']),
|
||||||
|
models.Index(fields=['ip_address']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.ip_address} - {self.created_at}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
"""
|
||||||
|
Check if session has expired.
|
||||||
|
"""
|
||||||
|
return timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
def end_session(self):
|
||||||
|
"""
|
||||||
|
End the session.
|
||||||
|
"""
|
||||||
|
self.is_active = False
|
||||||
|
self.ended_at = timezone.now()
|
||||||
|
self.save(update_fields=['is_active', 'ended_at'])
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordHistory(models.Model):
|
||||||
|
"""
|
||||||
|
Password history for users to prevent password reuse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# User relationship
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='password_history'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Password Information
|
||||||
|
password_hash = models.CharField(
|
||||||
|
max_length=128,
|
||||||
|
help_text='Hashed password'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'accounts_password_history'
|
||||||
|
verbose_name = 'Password History'
|
||||||
|
verbose_name_plural = 'Password History'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.created_at}"
|
||||||
|
|
||||||
3
accounts/tests.py
Normal file
3
accounts/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
34
accounts/urls.py
Normal file
34
accounts/urls.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for accounts app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
from allauth.account.views import SignupView, LoginView, LogoutView
|
||||||
|
|
||||||
|
app_name = 'accounts'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# path('accounts/', include('allauth.urls')),
|
||||||
|
# Main views
|
||||||
|
# path('login/', views.AccountLoginView.as_view(), name='login'),
|
||||||
|
# path('logout/', LogoutView.as_view(), name='logout'),
|
||||||
|
# path('signup/', SignupView.as_view(), name='account_signup'),
|
||||||
|
|
||||||
|
path('users/', views.UserListView.as_view(), name='user_list'),
|
||||||
|
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user_detail'),
|
||||||
|
path('profile/', views.UserProfileView.as_view(), name='user_profile'),
|
||||||
|
path('sessions/', views.SessionManagementView.as_view(), name='session_management'),
|
||||||
|
|
||||||
|
|
||||||
|
# HTMX views
|
||||||
|
path('htmx/user-search/', views.user_search, name='user_search'),
|
||||||
|
path('htmx/user-stats/', views.user_stats, name='user_stats'),
|
||||||
|
path('htmx/session-list/', views.session_list, name='session_list'),
|
||||||
|
path('htmx/end-session/<uuid:session_id>/', views.end_session, name='end_session'),
|
||||||
|
path('htmx/profile-update/', views.user_profile_update, name='user_profile_update'),
|
||||||
|
path('htmx/two-factor-setup/', views.two_factor_setup, name='two_factor_setup'),
|
||||||
|
path('htmx/remove-two-factor/<uuid:device_id>/', views.remove_two_factor_device, name='remove_two_factor_device'),
|
||||||
|
path('htmx/user-activity/<int:user_id>/', views.user_activity_log, name='user_activity_log'),
|
||||||
|
]
|
||||||
|
|
||||||
2421
accounts/views.py
Normal file
2421
accounts/views.py
Normal file
File diff suppressed because it is too large
Load Diff
457
accounts_data.py
Normal file
457
accounts_data.py
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
# Set up Django environment
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from django.utils import timezone as django_timezone
|
||||||
|
from accounts.models import User, TwoFactorDevice, SocialAccount, UserSession, PasswordHistory
|
||||||
|
from core.models import Tenant
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# Saudi-specific data constants
|
||||||
|
SAUDI_FIRST_NAMES_MALE = [
|
||||||
|
'Mohammed', 'Abdullah', 'Ahmed', 'Omar', 'Ali', 'Hassan', 'Khalid', 'Faisal',
|
||||||
|
'Saad', 'Fahd', 'Bandar', 'Turki', 'Nasser', 'Saud', 'Abdulrahman',
|
||||||
|
'Abdulaziz', 'Salman', 'Waleed', 'Majid', 'Rayan', 'Yazeed', 'Mansour',
|
||||||
|
'Osama', 'Tariq', 'Adel', 'Nawaf', 'Sultan', 'Mishaal', 'Badr', 'Ziad'
|
||||||
|
]
|
||||||
|
|
||||||
|
SAUDI_FIRST_NAMES_FEMALE = [
|
||||||
|
'Fatima', 'Aisha', 'Maryam', 'Khadija', 'Sarah', 'Noura', 'Hala', 'Reem',
|
||||||
|
'Lina', 'Dana', 'Rana', 'Nada', 'Layla', 'Amira', 'Zahra', 'Yasmin',
|
||||||
|
'Dina', 'Noor', 'Rahma', 'Salma', 'Lama', 'Ghada', 'Rania', 'Maha',
|
||||||
|
'Wedad', 'Najla', 'Shahd', 'Jood', 'Rand', 'Malak'
|
||||||
|
]
|
||||||
|
|
||||||
|
SAUDI_FAMILY_NAMES = [
|
||||||
|
'Al-Rashid', 'Al-Harbi', 'Al-Qahtani', 'Al-Dosari', 'Al-Otaibi', 'Al-Mutairi',
|
||||||
|
'Al-Shammari', 'Al-Zahrani', 'Al-Ghamdi', 'Al-Maliki', 'Al-Subai', 'Al-Jubayr',
|
||||||
|
'Al-Faisal', 'Al-Saud', 'Al-Thani', 'Al-Maktoum', 'Al-Sabah', 'Al-Khalifa',
|
||||||
|
'Bin-Laden', 'Al-Rajhi', 'Al-Sudairy', 'Al-Shaalan', 'Al-Kabeer', 'Al-Ajmi',
|
||||||
|
'Al-Anzi', 'Al-Dawsari', 'Al-Shamrani', 'Al-Balawi', 'Al-Juhani', 'Al-Sulami'
|
||||||
|
]
|
||||||
|
|
||||||
|
SAUDI_MIDDLE_NAMES = [
|
||||||
|
'bin Ahmed', 'bin Mohammed', 'bin Abdullah', 'bin Omar', 'bin Ali', 'bin Hassan',
|
||||||
|
'bin Khalid', 'bin Faisal', 'bin Saad', 'bin Fahd', 'bin Abdulaziz', 'bin Salman'
|
||||||
|
]
|
||||||
|
|
||||||
|
SAUDI_CITIES = [
|
||||||
|
'Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'Khobar', 'Dhahran',
|
||||||
|
'Taif', 'Tabuk', 'Buraidah', 'Khamis Mushait', 'Hofuf', 'Mubarraz',
|
||||||
|
'Jubail', 'Yanbu', 'Abha', 'Najran', 'Jazan', 'Hail', 'Arar'
|
||||||
|
]
|
||||||
|
|
||||||
|
SAUDI_PROVINCES = [
|
||||||
|
'Riyadh Province', 'Makkah Province', 'Eastern Province', 'Asir Province',
|
||||||
|
'Jazan Province', 'Medina Province', 'Qassim Province', 'Tabuk Province',
|
||||||
|
'Hail Province', 'Northern Borders Province', 'Najran Province', 'Al Bahah Province'
|
||||||
|
]
|
||||||
|
|
||||||
|
SAUDI_JOB_TITLES = {
|
||||||
|
'PHYSICIAN': ['Consultant Physician', 'Senior Physician', 'Staff Physician', 'Resident Physician',
|
||||||
|
'Chief Medical Officer'],
|
||||||
|
'NURSE': ['Head Nurse', 'Senior Nurse', 'Staff Nurse', 'Charge Nurse', 'Clinical Nurse Specialist'],
|
||||||
|
'PHARMACIST': ['Clinical Pharmacist', 'Staff Pharmacist', 'Pharmacy Manager', 'Pharmaceutical Consultant'],
|
||||||
|
'ADMIN': ['Medical Director', 'Hospital Administrator', 'Department Manager', 'Operations Manager'],
|
||||||
|
'LAB_TECH': ['Senior Lab Technician', 'Medical Laboratory Scientist', 'Lab Supervisor'],
|
||||||
|
'RAD_TECH': ['Senior Radiologic Technologist', 'CT Technologist', 'MRI Technologist'],
|
||||||
|
'RADIOLOGIST': ['Consultant Radiologist', 'Senior Radiologist', 'Interventional Radiologist']
|
||||||
|
}
|
||||||
|
|
||||||
|
SAUDI_DEPARTMENTS = [
|
||||||
|
'Internal Medicine', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology',
|
||||||
|
'Pediatrics', 'Emergency Medicine', 'Radiology', 'Laboratory Medicine',
|
||||||
|
'Pharmacy', 'Surgery', 'Obstetrics and Gynecology', 'Dermatology',
|
||||||
|
'Ophthalmology', 'ENT', 'Anesthesiology', 'Pathology', 'Psychiatry'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Saudi Medical License Formats
|
||||||
|
SAUDI_LICENSE_PREFIXES = ['MOH', 'SCFHS', 'SMLE', 'SFH']
|
||||||
|
|
||||||
|
|
||||||
|
def generate_saudi_phone():
|
||||||
|
"""Generate Saudi phone number"""
|
||||||
|
area_codes = ['11', '12', '13', '14', '16', '17'] # Major Saudi area codes
|
||||||
|
return f"+966-{random.choice(area_codes)}-{random.randint(100, 999)}-{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_saudi_mobile():
|
||||||
|
"""Generate Saudi mobile number"""
|
||||||
|
mobile_prefixes = ['50', '53', '54', '55', '56', '57', '58', '59'] # Saudi mobile prefixes
|
||||||
|
return f"+966-{random.choice(mobile_prefixes)}-{random.randint(100, 999)}-{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_saudi_license():
|
||||||
|
"""Generate Saudi medical license number"""
|
||||||
|
prefix = random.choice(SAUDI_LICENSE_PREFIXES)
|
||||||
|
return f"{prefix}-{random.randint(100000, 999999)}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_saudi_employee_id(tenant_name, role):
|
||||||
|
"""Generate Saudi employee ID"""
|
||||||
|
tenant_code = ''.join([c for c in tenant_name.upper() if c.isalpha()])[:3]
|
||||||
|
role_code = role[:3].upper()
|
||||||
|
return f"{tenant_code}-{role_code}-{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_saudi_users(tenants, users_per_tenant=50):
|
||||||
|
"""Create Saudi healthcare users"""
|
||||||
|
users = []
|
||||||
|
|
||||||
|
role_distribution = {
|
||||||
|
'PHYSICIAN': 0.15,
|
||||||
|
'NURSE': 0.25,
|
||||||
|
'PHARMACIST': 0.08,
|
||||||
|
'LAB_TECH': 0.10,
|
||||||
|
'RAD_TECH': 0.08,
|
||||||
|
'RADIOLOGIST': 0.05,
|
||||||
|
'ADMIN': 0.07,
|
||||||
|
'MEDICAL_ASSISTANT': 0.12,
|
||||||
|
'CLERICAL': 0.10
|
||||||
|
}
|
||||||
|
|
||||||
|
for tenant in tenants:
|
||||||
|
tenant_users = []
|
||||||
|
|
||||||
|
for role, percentage in role_distribution.items():
|
||||||
|
user_count = max(1, int(users_per_tenant * percentage))
|
||||||
|
|
||||||
|
for i in range(user_count):
|
||||||
|
# Determine gender for Arabic naming
|
||||||
|
is_male = random.choice([True, False])
|
||||||
|
first_name = random.choice(SAUDI_FIRST_NAMES_MALE if is_male else SAUDI_FIRST_NAMES_FEMALE)
|
||||||
|
last_name = random.choice(SAUDI_FAMILY_NAMES)
|
||||||
|
middle_name = random.choice(SAUDI_MIDDLE_NAMES) if random.choice([True, False]) else None
|
||||||
|
|
||||||
|
# Generate username
|
||||||
|
username = f"{first_name.lower()}.{last_name.lower().replace('-', '').replace('al', '')}"
|
||||||
|
counter = 1
|
||||||
|
original_username = username
|
||||||
|
while User.objects.filter(username=username).exists():
|
||||||
|
username = f"{original_username}{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Generate email
|
||||||
|
email = f"{username}@{tenant.name.lower().replace(' ', '').replace('-', '')}.sa"
|
||||||
|
|
||||||
|
# Professional information
|
||||||
|
department = random.choice(SAUDI_DEPARTMENTS)
|
||||||
|
job_title = random.choice(SAUDI_JOB_TITLES.get(role, [f"{role.replace('_', ' ').title()}"]))
|
||||||
|
|
||||||
|
# License information for medical professionals
|
||||||
|
license_number = None
|
||||||
|
license_state = None
|
||||||
|
license_expiry = None
|
||||||
|
npi_number = None
|
||||||
|
|
||||||
|
if role in ['PHYSICIAN', 'NURSE', 'PHARMACIST', 'RADIOLOGIST']:
|
||||||
|
license_number = generate_saudi_license()
|
||||||
|
license_state = random.choice(SAUDI_PROVINCES)
|
||||||
|
license_expiry = django_timezone.now().date() + timedelta(days=random.randint(365, 1095))
|
||||||
|
if role == 'PHYSICIAN':
|
||||||
|
npi_number = f"SA{random.randint(1000000, 9999999)}"
|
||||||
|
|
||||||
|
user = User.objects.create(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
middle_name=middle_name,
|
||||||
|
preferred_name=first_name if random.choice([True, False]) else None,
|
||||||
|
tenant=tenant,
|
||||||
|
|
||||||
|
# Contact information
|
||||||
|
phone_number=generate_saudi_phone(),
|
||||||
|
mobile_number=generate_saudi_mobile(),
|
||||||
|
|
||||||
|
# Professional information
|
||||||
|
employee_id=generate_saudi_employee_id(tenant.name, role),
|
||||||
|
department=department,
|
||||||
|
job_title=job_title,
|
||||||
|
role=role,
|
||||||
|
|
||||||
|
# License information
|
||||||
|
license_number=license_number,
|
||||||
|
license_state=license_state,
|
||||||
|
license_expiry=license_expiry,
|
||||||
|
npi_number=npi_number,
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
force_password_change=random.choice([True, False]),
|
||||||
|
password_expires_at=django_timezone.now() + timedelta(days=random.randint(90, 365)),
|
||||||
|
failed_login_attempts=random.randint(0, 2),
|
||||||
|
two_factor_enabled=random.choice([True, False]) if role in ['PHYSICIAN', 'ADMIN',
|
||||||
|
'PHARMACIST'] else False,
|
||||||
|
|
||||||
|
# Session settings
|
||||||
|
max_concurrent_sessions=random.choice([1, 2, 3, 5]),
|
||||||
|
session_timeout_minutes=random.choice([30, 60, 120, 240]),
|
||||||
|
|
||||||
|
# Preferences
|
||||||
|
user_timezone='Asia/Riyadh',
|
||||||
|
language=random.choice(['ar', 'en', 'ar_SA']),
|
||||||
|
theme=random.choice(['LIGHT', 'DARK', 'AUTO']),
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_verified=True,
|
||||||
|
is_approved=True,
|
||||||
|
approval_date=django_timezone.now() - timedelta(days=random.randint(1, 180)),
|
||||||
|
is_active=True,
|
||||||
|
is_staff=role in ['ADMIN', 'SUPER_ADMIN'],
|
||||||
|
is_superuser=role == 'SUPER_ADMIN',
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at=django_timezone.now() - timedelta(days=random.randint(1, 365)),
|
||||||
|
updated_at=django_timezone.now() - timedelta(days=random.randint(0, 30)),
|
||||||
|
last_password_change=django_timezone.now() - timedelta(days=random.randint(1, 90)),
|
||||||
|
date_joined=django_timezone.now() - timedelta(days=random.randint(1, 365))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set password
|
||||||
|
user.set_password('Hospital@123') # Default password
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
users.append(user)
|
||||||
|
tenant_users.append(user)
|
||||||
|
|
||||||
|
# Set approval relationships
|
||||||
|
admin_users = [u for u in tenant_users if u.role in ['ADMIN', 'SUPER_ADMIN']]
|
||||||
|
if admin_users:
|
||||||
|
approver = random.choice(admin_users)
|
||||||
|
for user in tenant_users:
|
||||||
|
if user != approver and user.role != 'SUPER_ADMIN':
|
||||||
|
user.approved_by = approver
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
print(f"Created {len(tenant_users)} users for {tenant.name}")
|
||||||
|
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def create_saudi_two_factor_devices(users):
|
||||||
|
"""Create two-factor authentication devices for Saudi users"""
|
||||||
|
devices = []
|
||||||
|
|
||||||
|
device_types = ['TOTP', 'SMS', 'EMAIL']
|
||||||
|
device_names = {
|
||||||
|
'TOTP': ['Google Authenticator', 'Microsoft Authenticator', 'Authy', 'LastPass Authenticator'],
|
||||||
|
'SMS': ['Primary Mobile', 'Work Mobile', 'Emergency Contact'],
|
||||||
|
'EMAIL': ['Work Email', 'Personal Email', 'Backup Email']
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if user.two_factor_enabled:
|
||||||
|
# Create 1-3 devices per user
|
||||||
|
device_count = random.randint(1, 3)
|
||||||
|
|
||||||
|
for _ in range(device_count):
|
||||||
|
device_type = random.choice(device_types)
|
||||||
|
|
||||||
|
device_data = {
|
||||||
|
'user': user,
|
||||||
|
'device_id': uuid.uuid4(),
|
||||||
|
'name': random.choice(device_names[device_type]),
|
||||||
|
'device_type': device_type,
|
||||||
|
'is_active': True,
|
||||||
|
'is_verified': True,
|
||||||
|
'verified_at': django_timezone.now() - timedelta(days=random.randint(1, 30)),
|
||||||
|
'last_used_at': django_timezone.now() - timedelta(hours=random.randint(1, 168)),
|
||||||
|
'usage_count': random.randint(5, 100),
|
||||||
|
'created_at': django_timezone.now() - timedelta(days=random.randint(1, 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
if device_type == 'TOTP':
|
||||||
|
device_data['secret_key'] = secrets.token_urlsafe(32)
|
||||||
|
elif device_type == 'SMS':
|
||||||
|
device_data['phone_number'] = user.mobile_number
|
||||||
|
elif device_type == 'EMAIL':
|
||||||
|
device_data['email_address'] = user.email
|
||||||
|
|
||||||
|
device = TwoFactorDevice.objects.create(**device_data)
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
print(f"Created {len(devices)} two-factor devices")
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def create_saudi_social_accounts(users):
|
||||||
|
"""Create social authentication accounts for Saudi users"""
|
||||||
|
social_accounts = []
|
||||||
|
|
||||||
|
# Common providers in Saudi Arabia
|
||||||
|
providers = ['GOOGLE', 'MICROSOFT', 'APPLE', 'LINKEDIN']
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# 30% chance of having social accounts
|
||||||
|
if random.choice([True, False, False, False]):
|
||||||
|
provider = random.choice(providers)
|
||||||
|
|
||||||
|
social_account = SocialAccount.objects.create(
|
||||||
|
user=user,
|
||||||
|
provider=provider,
|
||||||
|
provider_id=f"{provider.lower()}_{random.randint(100000000, 999999999)}",
|
||||||
|
provider_email=user.email,
|
||||||
|
display_name=user.get_full_name(),
|
||||||
|
profile_url=f"https://{provider.lower()}.com/profile/{user.username}",
|
||||||
|
avatar_url=f"https://{provider.lower()}.com/avatar/{user.username}.jpg",
|
||||||
|
access_token=secrets.token_urlsafe(64),
|
||||||
|
refresh_token=secrets.token_urlsafe(64),
|
||||||
|
token_expires_at=django_timezone.now() + timedelta(hours=1),
|
||||||
|
is_active=True,
|
||||||
|
created_at=django_timezone.now() - timedelta(days=random.randint(1, 180)),
|
||||||
|
last_login_at=django_timezone.now() - timedelta(hours=random.randint(1, 48))
|
||||||
|
)
|
||||||
|
social_accounts.append(social_account)
|
||||||
|
|
||||||
|
print(f"Created {len(social_accounts)} social accounts")
|
||||||
|
return social_accounts
|
||||||
|
|
||||||
|
|
||||||
|
def create_saudi_user_sessions(users):
|
||||||
|
"""Create user sessions for Saudi healthcare users"""
|
||||||
|
sessions = []
|
||||||
|
|
||||||
|
saudi_ips = [
|
||||||
|
'37.99.', '37.200.', '31.9.', '31.173.', '188.161.',
|
||||||
|
'185.84.', '188.245.', '217.9.', '82.205.', '5.63.'
|
||||||
|
]
|
||||||
|
|
||||||
|
browsers = [
|
||||||
|
'Chrome 120.0.0.0', 'Safari 17.1.2', 'Firefox 121.0.0', 'Edge 120.0.0.0',
|
||||||
|
'Chrome Mobile 120.0.0.0', 'Safari Mobile 17.1.2'
|
||||||
|
]
|
||||||
|
|
||||||
|
operating_systems = [
|
||||||
|
'Windows 11', 'Windows 10', 'macOS 14.0', 'iOS 17.1.2',
|
||||||
|
'Android 14', 'Ubuntu 22.04'
|
||||||
|
]
|
||||||
|
|
||||||
|
device_types = ['DESKTOP', 'MOBILE', 'TABLET']
|
||||||
|
login_methods = ['PASSWORD', 'TWO_FACTOR', 'SOCIAL', 'SSO']
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# Create 1-5 sessions per user
|
||||||
|
session_count = random.randint(1, 5)
|
||||||
|
|
||||||
|
for i in range(session_count):
|
||||||
|
ip_prefix = random.choice(saudi_ips)
|
||||||
|
ip_address = f"{ip_prefix}{random.randint(1, 255)}.{random.randint(1, 255)}"
|
||||||
|
|
||||||
|
session_start = django_timezone.now() - timedelta(hours=random.randint(1, 720))
|
||||||
|
is_active = i == 0 and random.choice([True, True, False]) # Most recent session likely active
|
||||||
|
|
||||||
|
session = UserSession.objects.create(
|
||||||
|
user=user,
|
||||||
|
session_key=f"session_{secrets.token_urlsafe(20)}",
|
||||||
|
session_id=uuid.uuid4(),
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=f"Mozilla/5.0 (compatible; HospitalSystem/1.0; {random.choice(browsers)})",
|
||||||
|
device_type=random.choice(device_types),
|
||||||
|
browser=random.choice(browsers),
|
||||||
|
operating_system=random.choice(operating_systems),
|
||||||
|
country='Saudi Arabia',
|
||||||
|
region=random.choice(SAUDI_PROVINCES),
|
||||||
|
city=random.choice(SAUDI_CITIES),
|
||||||
|
is_active=is_active,
|
||||||
|
login_method=random.choice(login_methods),
|
||||||
|
created_at=session_start,
|
||||||
|
last_activity_at=session_start + timedelta(minutes=random.randint(1, 480)),
|
||||||
|
expires_at=session_start + timedelta(hours=user.session_timeout_minutes // 60),
|
||||||
|
ended_at=None if is_active else session_start + timedelta(hours=random.randint(1, 8))
|
||||||
|
)
|
||||||
|
sessions.append(session)
|
||||||
|
|
||||||
|
print(f"Created {len(sessions)} user sessions")
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def create_saudi_password_history(users):
|
||||||
|
"""Create password history for Saudi users"""
|
||||||
|
password_history = []
|
||||||
|
|
||||||
|
passwords = ['Hospital@123', 'Medical@456', 'Health@789', 'Saudi@2024', 'Secure@Pass']
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# Create 1-5 password history entries per user
|
||||||
|
history_count = random.randint(1, 5)
|
||||||
|
|
||||||
|
for i in range(history_count):
|
||||||
|
password = random.choice(passwords)
|
||||||
|
|
||||||
|
history_entry = PasswordHistory.objects.create(
|
||||||
|
user=user,
|
||||||
|
password_hash=make_password(password),
|
||||||
|
created_at=django_timezone.now() - timedelta(days=random.randint(30 * i, 30 * (i + 1)))
|
||||||
|
)
|
||||||
|
password_history.append(history_entry)
|
||||||
|
|
||||||
|
print(f"Created {len(password_history)} password history entries")
|
||||||
|
return password_history
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function to generate all Saudi accounts data"""
|
||||||
|
print("Starting Saudi Healthcare Accounts Data Generation...")
|
||||||
|
|
||||||
|
# Get existing tenants
|
||||||
|
tenants = list(Tenant.objects.all())
|
||||||
|
if not tenants:
|
||||||
|
print("❌ No tenants found. Please run the core data generator first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create users
|
||||||
|
print("\n1. Creating Saudi Healthcare Users...")
|
||||||
|
users = create_saudi_users(tenants, 40) # 40 users per tenant
|
||||||
|
|
||||||
|
# Create two-factor devices
|
||||||
|
print("\n2. Creating Two-Factor Authentication Devices...")
|
||||||
|
devices = create_saudi_two_factor_devices(users)
|
||||||
|
|
||||||
|
# Create social accounts
|
||||||
|
print("\n3. Creating Social Authentication Accounts...")
|
||||||
|
social_accounts = create_saudi_social_accounts(users)
|
||||||
|
|
||||||
|
# Create user sessions
|
||||||
|
print("\n4. Creating User Sessions...")
|
||||||
|
sessions = create_saudi_user_sessions(users)
|
||||||
|
|
||||||
|
# Create password history
|
||||||
|
print("\n5. Creating Password History...")
|
||||||
|
password_history = create_saudi_password_history(users)
|
||||||
|
|
||||||
|
print(f"\n✅ Saudi Healthcare Accounts Data Generation Complete!")
|
||||||
|
print(f"📊 Summary:")
|
||||||
|
print(f" - Users: {len(users)}")
|
||||||
|
print(f" - Two-Factor Devices: {len(devices)}")
|
||||||
|
print(f" - Social Accounts: {len(social_accounts)}")
|
||||||
|
print(f" - User Sessions: {len(sessions)}")
|
||||||
|
print(f" - Password History Entries: {len(password_history)}")
|
||||||
|
|
||||||
|
# Role distribution summary
|
||||||
|
role_counts = {}
|
||||||
|
for user in users:
|
||||||
|
role_counts[user.role] = role_counts.get(user.role, 0) + 1
|
||||||
|
|
||||||
|
print(f"\n👥 User Role Distribution:")
|
||||||
|
for role, count in sorted(role_counts.items()):
|
||||||
|
print(f" - {role.replace('_', ' ').title()}: {count}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'users': users,
|
||||||
|
'devices': devices,
|
||||||
|
'social_accounts': social_accounts,
|
||||||
|
'sessions': sessions,
|
||||||
|
'password_history': password_history
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
analytics/__init__.py
Normal file
0
analytics/__init__.py
Normal file
BIN
analytics/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
analytics/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
analytics/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/admin.cpython-311.pyc
Normal file
BIN
analytics/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/admin.cpython-312.pyc
Normal file
BIN
analytics/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/apps.cpython-311.pyc
Normal file
BIN
analytics/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/apps.cpython-312.pyc
Normal file
BIN
analytics/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/forms.cpython-311.pyc
Normal file
BIN
analytics/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/forms.cpython-312.pyc
Normal file
BIN
analytics/__pycache__/forms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/models.cpython-311.pyc
Normal file
BIN
analytics/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/models.cpython-312.pyc
Normal file
BIN
analytics/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/urls.cpython-311.pyc
Normal file
BIN
analytics/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/urls.cpython-312.pyc
Normal file
BIN
analytics/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/views.cpython-311.pyc
Normal file
BIN
analytics/__pycache__/views.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analytics/__pycache__/views.cpython-312.pyc
Normal file
BIN
analytics/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
665
analytics/admin.py
Normal file
665
analytics/admin.py
Normal file
@ -0,0 +1,665 @@
|
|||||||
|
"""
|
||||||
|
Analytics app admin configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .models import (
|
||||||
|
Dashboard, DashboardWidget, DataSource, Report,
|
||||||
|
ReportExecution, MetricDefinition, MetricValue
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetInline(admin.TabularInline):
|
||||||
|
"""
|
||||||
|
Inline admin for dashboard widgets.
|
||||||
|
"""
|
||||||
|
model = DashboardWidget
|
||||||
|
extra = 0
|
||||||
|
fields = [
|
||||||
|
'name', 'widget_type', 'chart_type', 'data_source',
|
||||||
|
'position_x', 'position_y', 'width', 'height', 'is_active'
|
||||||
|
]
|
||||||
|
readonly_fields = ['widget_id']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Dashboard)
|
||||||
|
class DashboardAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for dashboards.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'name', 'dashboard_type',
|
||||||
|
'is_public', 'is_default', 'is_active',
|
||||||
|
'refresh_interval_display', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'tenant', 'dashboard_type', 'is_public', 'is_default', 'is_active'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'name', 'description'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'dashboard_id',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
fieldsets = [
|
||||||
|
('Dashboard Information', {
|
||||||
|
'fields': [
|
||||||
|
'dashboard_id', 'tenant', 'name', 'description'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Dashboard Type', {
|
||||||
|
'fields': [
|
||||||
|
'dashboard_type'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Layout Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'layout_config', 'refresh_interval'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Access Control', {
|
||||||
|
'fields': [
|
||||||
|
'is_public', 'allowed_users', 'allowed_roles'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Dashboard Status', {
|
||||||
|
'fields': [
|
||||||
|
'is_active', 'is_default'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
# ('Summary Information', {
|
||||||
|
# 'fields': [
|
||||||
|
# 'widget_count'
|
||||||
|
# ],
|
||||||
|
# 'classes': ['collapse']
|
||||||
|
# }),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': [
|
||||||
|
'created_at', 'updated_at', 'created_by'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
inlines = [DashboardWidgetInline]
|
||||||
|
filter_horizontal = ['allowed_users']
|
||||||
|
|
||||||
|
# def widget_count_display(self, obj):
|
||||||
|
# """Display widget count."""
|
||||||
|
# return obj.value_count
|
||||||
|
# widget_count_display.short_description = 'Widgets'
|
||||||
|
|
||||||
|
def refresh_interval_display(self, obj):
|
||||||
|
"""Display refresh interval."""
|
||||||
|
if obj.refresh_interval >= 3600:
|
||||||
|
hours = obj.refresh_interval // 3600
|
||||||
|
return f"{hours}h"
|
||||||
|
elif obj.refresh_interval >= 60:
|
||||||
|
minutes = obj.refresh_interval // 60
|
||||||
|
return f"{minutes}m"
|
||||||
|
return f"{obj.refresh_interval}s"
|
||||||
|
refresh_interval_display.short_description = 'Refresh'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter by user's tenant."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if hasattr(request.user, 'tenant'):
|
||||||
|
qs = qs.filter(tenant=request.user.tenant)
|
||||||
|
return qs.prefetch_related('widgets')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DashboardWidget)
|
||||||
|
class DashboardWidgetAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for dashboard widgets.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'name', 'dashboard_name', 'widget_type', 'chart_type',
|
||||||
|
'data_source', 'position_display', 'size_display',
|
||||||
|
'auto_refresh', 'is_active'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'dashboard__tenant', 'widget_type', 'chart_type',
|
||||||
|
'auto_refresh', 'is_active'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'name', 'description', 'dashboard__name'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'widget_id', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
fieldsets = [
|
||||||
|
('Widget Information', {
|
||||||
|
'fields': [
|
||||||
|
'widget_id', 'dashboard', 'name', 'description'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Widget Type', {
|
||||||
|
'fields': [
|
||||||
|
'widget_type', 'chart_type'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Data Source', {
|
||||||
|
'fields': [
|
||||||
|
'data_source', 'query_config'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Layout', {
|
||||||
|
'fields': [
|
||||||
|
'position_x', 'position_y', 'width', 'height'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Display Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'display_config', 'color_scheme'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Refresh Settings', {
|
||||||
|
'fields': [
|
||||||
|
'auto_refresh', 'refresh_interval'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Widget Status', {
|
||||||
|
'fields': [
|
||||||
|
'is_active'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': [
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
def dashboard_name(self, obj):
|
||||||
|
"""Display dashboard name."""
|
||||||
|
return obj.dashboard.name
|
||||||
|
dashboard_name.short_description = 'Dashboard'
|
||||||
|
|
||||||
|
def position_display(self, obj):
|
||||||
|
"""Display position."""
|
||||||
|
return f"({obj.position_x}, {obj.position_y})"
|
||||||
|
position_display.short_description = 'Position'
|
||||||
|
|
||||||
|
def size_display(self, obj):
|
||||||
|
"""Display size."""
|
||||||
|
return f"{obj.width}×{obj.height}"
|
||||||
|
size_display.short_description = 'Size'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter by user's tenant."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if hasattr(request.user, 'tenant'):
|
||||||
|
qs = qs.filter(dashboard__tenant=request.user.tenant)
|
||||||
|
return qs.select_related('dashboard', 'data_source')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DataSource)
|
||||||
|
class DataSourceAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for data sources.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'name', 'source_type', 'connection_type',
|
||||||
|
'health_status_display', 'last_health_check',
|
||||||
|
'cache_duration_display', 'is_active'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'tenant', 'source_type', 'connection_type',
|
||||||
|
'is_healthy', 'is_active'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'name', 'description'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'source_id', 'is_healthy', 'last_health_check',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
fieldsets = [
|
||||||
|
('Data Source Information', {
|
||||||
|
'fields': [
|
||||||
|
'source_id', 'tenant', 'name', 'description'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Source Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'source_type', 'connection_type'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Connection Details', {
|
||||||
|
'fields': [
|
||||||
|
'connection_config', 'authentication_config'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Query Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'query_template', 'parameters'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Data Processing', {
|
||||||
|
'fields': [
|
||||||
|
'data_transformation', 'cache_duration'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Health Monitoring', {
|
||||||
|
'fields': [
|
||||||
|
'is_healthy', 'last_health_check', 'health_check_interval'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Source Status', {
|
||||||
|
'fields': [
|
||||||
|
'is_active'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': [
|
||||||
|
'created_at', 'updated_at', 'created_by'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
def health_status_display(self, obj):
|
||||||
|
"""Display health status with color coding."""
|
||||||
|
if obj.is_healthy:
|
||||||
|
return format_html('<span style="color: green;">✓ Healthy</span>')
|
||||||
|
return format_html('<span style="color: red;">✗ Unhealthy</span>')
|
||||||
|
health_status_display.short_description = 'Health'
|
||||||
|
|
||||||
|
def cache_duration_display(self, obj):
|
||||||
|
"""Display cache duration."""
|
||||||
|
if obj.cache_duration >= 3600:
|
||||||
|
hours = obj.cache_duration // 3600
|
||||||
|
return f"{hours}h"
|
||||||
|
elif obj.cache_duration >= 60:
|
||||||
|
minutes = obj.cache_duration // 60
|
||||||
|
return f"{minutes}m"
|
||||||
|
return f"{obj.cache_duration}s"
|
||||||
|
cache_duration_display.short_description = 'Cache'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter by user's tenant."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if hasattr(request.user, 'tenant'):
|
||||||
|
qs = qs.filter(tenant=request.user.tenant)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExecutionInline(admin.TabularInline):
|
||||||
|
"""
|
||||||
|
Inline admin for report executions.
|
||||||
|
"""
|
||||||
|
model = ReportExecution
|
||||||
|
extra = 0
|
||||||
|
fields = [
|
||||||
|
'execution_type', 'started_at', 'status',
|
||||||
|
'duration_seconds', 'record_count'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'execution_id', 'started_at', 'completed_at',
|
||||||
|
'duration_seconds', 'record_count'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Report)
|
||||||
|
class ReportAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for reports.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'name', 'report_type', 'output_format',
|
||||||
|
'schedule_type', 'next_execution',
|
||||||
|
'execution_count_display', 'is_active'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'tenant', 'report_type', 'output_format',
|
||||||
|
'schedule_type', 'is_active'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'name', 'description'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'report_id',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
fieldsets = [
|
||||||
|
('Report Information', {
|
||||||
|
'fields': [
|
||||||
|
'report_id', 'tenant', 'name', 'description'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Report Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'report_type', 'data_source', 'query_config'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Output Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'output_format', 'template_config'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Scheduling', {
|
||||||
|
'fields': [
|
||||||
|
'schedule_type', 'schedule_config', 'next_execution'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Distribution', {
|
||||||
|
'fields': [
|
||||||
|
'recipients', 'distribution_config'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Report Status', {
|
||||||
|
'fields': [
|
||||||
|
'is_active'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Summary Information', {
|
||||||
|
'fields': [
|
||||||
|
'execution_count'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': [
|
||||||
|
'created_at', 'updated_at', 'created_by'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
inlines = [ReportExecutionInline]
|
||||||
|
|
||||||
|
def execution_count_display(self, obj):
|
||||||
|
"""Display execution count."""
|
||||||
|
return obj.execution_count
|
||||||
|
execution_count_display.short_description = 'Executions'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter by user's tenant."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if hasattr(request.user, 'tenant'):
|
||||||
|
qs = qs.filter(tenant=request.user.tenant)
|
||||||
|
return qs.select_related('data_source')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReportExecution)
|
||||||
|
class ReportExecutionAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for report executions.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'report_name', 'execution_type', 'started_at',
|
||||||
|
'status', 'duration_display', 'record_count',
|
||||||
|
'output_size_display'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'report__tenant', 'execution_type', 'status', 'started_at'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'report__name', 'executed_by__username'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'execution_id', 'started_at', 'completed_at',
|
||||||
|
'duration_seconds', 'output_size_bytes',
|
||||||
|
'record_count'
|
||||||
|
]
|
||||||
|
fieldsets = [
|
||||||
|
('Execution Information', {
|
||||||
|
'fields': [
|
||||||
|
'execution_id', 'report', 'execution_type'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Timing', {
|
||||||
|
'fields': [
|
||||||
|
'started_at', 'completed_at', 'duration_seconds'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Status and Results', {
|
||||||
|
'fields': [
|
||||||
|
'status', 'error_message'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Output', {
|
||||||
|
'fields': [
|
||||||
|
'output_file_path', 'output_size_bytes', 'record_count'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Parameters', {
|
||||||
|
'fields': [
|
||||||
|
'execution_parameters'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': [
|
||||||
|
'executed_by'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
date_hierarchy = 'started_at'
|
||||||
|
|
||||||
|
def report_name(self, obj):
|
||||||
|
"""Display report name."""
|
||||||
|
return obj.report.name
|
||||||
|
report_name.short_description = 'Report'
|
||||||
|
|
||||||
|
def duration_display(self, obj):
|
||||||
|
"""Display duration."""
|
||||||
|
if obj.duration_seconds:
|
||||||
|
if obj.duration_seconds >= 3600:
|
||||||
|
hours = obj.duration_seconds // 3600
|
||||||
|
minutes = (obj.duration_seconds % 3600) // 60
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
elif obj.duration_seconds >= 60:
|
||||||
|
minutes = obj.duration_seconds // 60
|
||||||
|
seconds = obj.duration_seconds % 60
|
||||||
|
return f"{minutes}m {seconds}s"
|
||||||
|
return f"{obj.duration_seconds}s"
|
||||||
|
return "-"
|
||||||
|
duration_display.short_description = 'Duration'
|
||||||
|
|
||||||
|
def output_size_display(self, obj):
|
||||||
|
"""Display output size."""
|
||||||
|
if obj.output_size_bytes:
|
||||||
|
if obj.output_size_bytes >= 1024 * 1024:
|
||||||
|
mb = obj.output_size_bytes / (1024 * 1024)
|
||||||
|
return f"{mb:.1f} MB"
|
||||||
|
elif obj.output_size_bytes >= 1024:
|
||||||
|
kb = obj.output_size_bytes / 1024
|
||||||
|
return f"{kb:.1f} KB"
|
||||||
|
return f"{obj.output_size_bytes} B"
|
||||||
|
return "-"
|
||||||
|
output_size_display.short_description = 'Size'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter by user's tenant."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if hasattr(request.user, 'tenant'):
|
||||||
|
qs = qs.filter(report__tenant=request.user.tenant)
|
||||||
|
return qs.select_related('report', 'executed_by')
|
||||||
|
|
||||||
|
|
||||||
|
class MetricValueInline(admin.TabularInline):
|
||||||
|
"""
|
||||||
|
Inline admin for metric values.
|
||||||
|
"""
|
||||||
|
model = MetricValue
|
||||||
|
extra = 0
|
||||||
|
fields = [
|
||||||
|
'value', 'period_start', 'period_end',
|
||||||
|
'data_quality_score', 'confidence_level'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'value_id', 'calculation_timestamp',
|
||||||
|
'calculation_duration_ms'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MetricDefinition)
|
||||||
|
class MetricDefinitionAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for metric definitions.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'name', 'metric_type', 'aggregation_period',
|
||||||
|
'target_value', 'unit_of_measure',
|
||||||
|
'value_count_display', 'is_active'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'tenant', 'metric_type', 'aggregation_period', 'is_active'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'name', 'description'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'metric_id',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
fieldsets = [
|
||||||
|
('Metric Information', {
|
||||||
|
'fields': [
|
||||||
|
'metric_id', 'tenant', 'name', 'description'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Metric Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'metric_type', 'data_source', 'calculation_config'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Aggregation', {
|
||||||
|
'fields': [
|
||||||
|
'aggregation_period', 'aggregation_config'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Thresholds and Targets', {
|
||||||
|
'fields': [
|
||||||
|
'target_value', 'warning_threshold', 'critical_threshold'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Display Configuration', {
|
||||||
|
'fields': [
|
||||||
|
'unit_of_measure', 'decimal_places', 'display_format'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Metric Status', {
|
||||||
|
'fields': [
|
||||||
|
'is_active'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Summary Information', {
|
||||||
|
'fields': [
|
||||||
|
'value_count'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': [
|
||||||
|
'created_at', 'updated_at', 'created_by'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
inlines = [MetricValueInline]
|
||||||
|
|
||||||
|
def value_count_display(self, obj):
|
||||||
|
"""Display value count."""
|
||||||
|
return obj.value_count
|
||||||
|
value_count_display.short_description = 'Values'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter by user's tenant."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if hasattr(request.user, 'tenant'):
|
||||||
|
qs = qs.filter(tenant=request.user.tenant)
|
||||||
|
return qs.select_related('data_source')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MetricValue)
|
||||||
|
class MetricValueAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for metric values.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'metric_name', 'value', 'period_start',
|
||||||
|
'period_end', 'threshold_status_display',
|
||||||
|
'data_quality_score', 'calculation_timestamp'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'metric_definition__tenant', 'metric_definition__metric_type',
|
||||||
|
'period_start', 'calculation_timestamp'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'metric_definition__name'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'value_id', 'is_above_target', 'threshold_status',
|
||||||
|
'calculation_timestamp', 'calculation_duration_ms'
|
||||||
|
]
|
||||||
|
fieldsets = [
|
||||||
|
('Metric Value Information', {
|
||||||
|
'fields': [
|
||||||
|
'value_id', 'metric_definition'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Value Details', {
|
||||||
|
'fields': [
|
||||||
|
'value', 'period_start', 'period_end'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Context', {
|
||||||
|
'fields': [
|
||||||
|
'dimensions', 'metadata'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Quality Indicators', {
|
||||||
|
'fields': [
|
||||||
|
'data_quality_score', 'confidence_level'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Calculation Details', {
|
||||||
|
'fields': [
|
||||||
|
'calculation_timestamp', 'calculation_duration_ms'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
('Analysis', {
|
||||||
|
'fields': [
|
||||||
|
'is_above_target', 'threshold_status'
|
||||||
|
],
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
date_hierarchy = 'period_start'
|
||||||
|
|
||||||
|
def metric_name(self, obj):
|
||||||
|
"""Display metric name."""
|
||||||
|
return obj.metric_definition.name
|
||||||
|
metric_name.short_description = 'Metric'
|
||||||
|
|
||||||
|
def threshold_status_display(self, obj):
|
||||||
|
"""Display threshold status with color coding."""
|
||||||
|
status = obj.threshold_status
|
||||||
|
if status == 'CRITICAL':
|
||||||
|
return format_html('<span style="color: red;">🔴 Critical</span>')
|
||||||
|
elif status == 'WARNING':
|
||||||
|
return format_html('<span style="color: orange;">🟡 Warning</span>')
|
||||||
|
return format_html('<span style="color: green;">🟢 Normal</span>')
|
||||||
|
threshold_status_display.short_description = 'Status'
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Filter by user's tenant."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if hasattr(request.user, 'tenant'):
|
||||||
|
qs = qs.filter(metric_definition__tenant=request.user.tenant)
|
||||||
|
return qs.select_related('metric_definition')
|
||||||
|
|
||||||
|
|
||||||
|
# Customize admin site
|
||||||
|
admin.site.site_header = "Hospital Management System - Analytics"
|
||||||
|
admin.site.site_title = "Analytics Admin"
|
||||||
|
admin.site.index_title = "Analytics Administration"
|
||||||
|
|
||||||
0
analytics/api/__init__.py
Normal file
0
analytics/api/__init__.py
Normal file
BIN
analytics/api/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
analytics/api/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/api/__pycache__/serializers.cpython-312.pyc
Normal file
BIN
analytics/api/__pycache__/serializers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/api/__pycache__/urls.cpython-312.pyc
Normal file
BIN
analytics/api/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/api/__pycache__/views.cpython-312.pyc
Normal file
BIN
analytics/api/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
235
analytics/api/serializers.py
Normal file
235
analytics/api/serializers.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from ..models import (
|
||||||
|
Dashboard, DashboardWidget, DataSource, Report,
|
||||||
|
ReportExecution, MetricDefinition, MetricValue
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSerializer(serializers.ModelSerializer):
|
||||||
|
"""Base serializer with common functionality"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = ['id']
|
||||||
|
read_only_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for DataSource model"""
|
||||||
|
source_type_display = serializers.CharField(source='get_source_type_display', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataSource
|
||||||
|
fields = [
|
||||||
|
'source_id', 'name', 'description', 'source_type', 'source_type_display',
|
||||||
|
'connection_string', 'query', 'refresh_interval', 'is_active',
|
||||||
|
'last_updated', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['source_id', 'last_updated', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitionSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for MetricDefinition model"""
|
||||||
|
metric_type_display = serializers.CharField(source='get_metric_type_display', read_only=True)
|
||||||
|
data_source_name = serializers.CharField(source='data_source.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MetricDefinition
|
||||||
|
fields = [
|
||||||
|
'metric_id', 'name', 'description', 'metric_type', 'metric_type_display',
|
||||||
|
'data_source', 'data_source_name', 'calculation_formula',
|
||||||
|
'target_value', 'threshold_warning', 'threshold_critical',
|
||||||
|
'unit', 'is_active', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['metric_id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MetricValueSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for MetricValue model"""
|
||||||
|
metric_name = serializers.CharField(source='metric.name', read_only=True)
|
||||||
|
metric_unit = serializers.CharField(source='metric.unit', read_only=True)
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MetricValue
|
||||||
|
fields = [
|
||||||
|
'value_id', 'metric', 'metric_name', 'metric_unit', 'value',
|
||||||
|
'timestamp', 'status', 'status_display', 'notes',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['value_id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for DashboardWidget model"""
|
||||||
|
widget_type_display = serializers.CharField(source='get_widget_type_display', read_only=True)
|
||||||
|
data_source_name = serializers.CharField(source='data_source.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DashboardWidget
|
||||||
|
fields = [
|
||||||
|
'widget_id', 'title', 'description', 'widget_type', 'widget_type_display',
|
||||||
|
'data_source', 'data_source_name', 'configuration', 'position_x',
|
||||||
|
'position_y', 'width', 'height', 'refresh_interval', 'is_visible',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['widget_id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for Dashboard model"""
|
||||||
|
created_by_name = serializers.CharField(source='created_by.get_full_name', read_only=True)
|
||||||
|
widgets = DashboardWidgetSerializer(many=True, read_only=True)
|
||||||
|
widget_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Dashboard
|
||||||
|
fields = [
|
||||||
|
'dashboard_id', 'name', 'description', 'layout', 'is_public',
|
||||||
|
'is_default', 'created_by', 'created_by_name', 'widgets',
|
||||||
|
'widget_count', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['dashboard_id', 'widget_count', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class ReportSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for Report model"""
|
||||||
|
report_type_display = serializers.CharField(source='get_report_type_display', read_only=True)
|
||||||
|
created_by_name = serializers.CharField(source='created_by.get_full_name', read_only=True)
|
||||||
|
data_source_name = serializers.CharField(source='data_source.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Report
|
||||||
|
fields = [
|
||||||
|
'report_id', 'name', 'description', 'report_type', 'report_type_display',
|
||||||
|
'data_source', 'data_source_name', 'query', 'parameters',
|
||||||
|
'schedule', 'is_active', 'created_by', 'created_by_name',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['report_id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExecutionSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for ReportExecution model"""
|
||||||
|
report_name = serializers.CharField(source='report.name', read_only=True)
|
||||||
|
executed_by_name = serializers.CharField(source='executed_by.get_full_name', read_only=True)
|
||||||
|
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
duration_seconds = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReportExecution
|
||||||
|
fields = [
|
||||||
|
'execution_id', 'report', 'report_name', 'executed_by', 'executed_by_name',
|
||||||
|
'start_time', 'end_time', 'duration_seconds', 'status', 'status_display',
|
||||||
|
'parameters', 'result_count', 'file_path', 'error_message',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'execution_id', 'duration_seconds', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsStatsSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for analytics statistics"""
|
||||||
|
total_dashboards = serializers.IntegerField()
|
||||||
|
total_reports = serializers.IntegerField()
|
||||||
|
total_metrics = serializers.IntegerField()
|
||||||
|
active_data_sources = serializers.IntegerField()
|
||||||
|
reports_executed_today = serializers.IntegerField()
|
||||||
|
failed_executions = serializers.IntegerField()
|
||||||
|
avg_execution_time = serializers.FloatField()
|
||||||
|
popular_reports = serializers.ListField()
|
||||||
|
metric_status_breakdown = serializers.DictField()
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardCreateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for creating dashboards"""
|
||||||
|
name = serializers.CharField()
|
||||||
|
description = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
layout = serializers.JSONField(required=False)
|
||||||
|
is_public = serializers.BooleanField(default=False)
|
||||||
|
widgets = serializers.ListField(
|
||||||
|
child=serializers.DictField(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_widgets(self, value):
|
||||||
|
"""Validate dashboard widgets"""
|
||||||
|
for widget in value:
|
||||||
|
required_fields = ['title', 'widget_type', 'data_source_id']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in widget:
|
||||||
|
raise serializers.ValidationError(f"Widget missing required field: {field}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExecuteSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for executing reports"""
|
||||||
|
report_id = serializers.IntegerField()
|
||||||
|
parameters = serializers.JSONField(required=False)
|
||||||
|
format = serializers.ChoiceField(
|
||||||
|
choices=['JSON', 'CSV', 'PDF', 'EXCEL'],
|
||||||
|
default='JSON'
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_report_id(self, value):
|
||||||
|
"""Validate that the report exists and is active"""
|
||||||
|
try:
|
||||||
|
report = Report.objects.get(id=value)
|
||||||
|
if not report.is_active:
|
||||||
|
raise serializers.ValidationError("Report is not active.")
|
||||||
|
return value
|
||||||
|
except Report.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Invalid report ID.")
|
||||||
|
|
||||||
|
|
||||||
|
class MetricCalculateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for calculating metrics"""
|
||||||
|
metric_id = serializers.IntegerField()
|
||||||
|
start_date = serializers.DateTimeField(required=False)
|
||||||
|
end_date = serializers.DateTimeField(required=False)
|
||||||
|
|
||||||
|
def validate_metric_id(self, value):
|
||||||
|
"""Validate that the metric exists and is active"""
|
||||||
|
try:
|
||||||
|
metric = MetricDefinition.objects.get(id=value)
|
||||||
|
if not metric.is_active:
|
||||||
|
raise serializers.ValidationError("Metric is not active.")
|
||||||
|
return value
|
||||||
|
except MetricDefinition.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Invalid metric ID.")
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""Validate date range"""
|
||||||
|
if data.get('start_date') and data.get('end_date'):
|
||||||
|
if data['end_date'] <= data['start_date']:
|
||||||
|
raise serializers.ValidationError("End date must be after start date.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceTestSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for testing data sources"""
|
||||||
|
source_id = serializers.IntegerField()
|
||||||
|
test_query = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
def validate_source_id(self, value):
|
||||||
|
"""Validate that the data source exists"""
|
||||||
|
try:
|
||||||
|
DataSource.objects.get(id=value)
|
||||||
|
return value
|
||||||
|
except DataSource.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Invalid data source ID.")
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetDataSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for widget data"""
|
||||||
|
widget_id = serializers.IntegerField()
|
||||||
|
refresh = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
|
def validate_widget_id(self, value):
|
||||||
|
"""Validate that the widget exists"""
|
||||||
|
try:
|
||||||
|
DashboardWidget.objects.get(id=value)
|
||||||
|
return value
|
||||||
|
except DashboardWidget.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Invalid widget ID.")
|
||||||
|
|
||||||
11
analytics/api/urls.py
Normal file
11
analytics/api/urls.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'dashboards', views.DashboardViewSet)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
525
analytics/api/views.py
Normal file
525
analytics/api/views.py
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
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 import filters
|
||||||
|
from django.db.models import Q, Count, Avg, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Dashboard, DashboardWidget, DataSource, Report,
|
||||||
|
ReportExecution, MetricDefinition, MetricValue
|
||||||
|
)
|
||||||
|
from .serializers import (
|
||||||
|
DashboardSerializer, DashboardWidgetSerializer, DataSourceSerializer,
|
||||||
|
ReportSerializer, ReportExecutionSerializer, MetricDefinitionSerializer,
|
||||||
|
MetricValueSerializer, AnalyticsStatsSerializer, DashboardCreateSerializer,
|
||||||
|
ReportExecuteSerializer, MetricCalculateSerializer, DataSourceTestSerializer,
|
||||||
|
WidgetDataSerializer
|
||||||
|
)
|
||||||
|
from core.utils import AuditLogger
|
||||||
|
|
||||||
|
|
||||||
|
class BaseViewSet(viewsets.ModelViewSet):
|
||||||
|
"""Base ViewSet with common functionality"""
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Filter by tenant if user has one
|
||||||
|
if hasattr(self.request.user, 'tenant') and self.request.user.tenant:
|
||||||
|
return self.queryset.filter(tenant=self.request.user.tenant)
|
||||||
|
return self.queryset
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
if hasattr(self.request.user, 'tenant'):
|
||||||
|
serializer.save(tenant=self.request.user.tenant)
|
||||||
|
else:
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceViewSet(BaseViewSet):
|
||||||
|
"""ViewSet for DataSource model"""
|
||||||
|
queryset = DataSource.objects.all()
|
||||||
|
serializer_class = DataSourceSerializer
|
||||||
|
filterset_fields = ['source_type', 'is_active']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['name', 'last_updated']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def active(self, request):
|
||||||
|
"""Get active data sources"""
|
||||||
|
queryset = self.get_queryset().filter(is_active=True)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def test_connection(self, request, pk=None):
|
||||||
|
"""Test data source connection"""
|
||||||
|
data_source = self.get_object()
|
||||||
|
serializer = DataSourceTestSerializer(data={'source_id': pk})
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
try:
|
||||||
|
# Mock connection test - in real implementation, would test actual connection
|
||||||
|
test_result = {
|
||||||
|
'success': True,
|
||||||
|
'message': 'Connection successful',
|
||||||
|
'response_time': 0.15,
|
||||||
|
'records_available': 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update last_updated timestamp
|
||||||
|
data_source.last_updated = timezone.now()
|
||||||
|
data_source.save()
|
||||||
|
|
||||||
|
# Log the action
|
||||||
|
AuditLogger.log_action(
|
||||||
|
user=request.user,
|
||||||
|
action='DATA_SOURCE_TESTED',
|
||||||
|
model='DataSource',
|
||||||
|
object_id=str(data_source.source_id),
|
||||||
|
details={
|
||||||
|
'source_name': data_source.name,
|
||||||
|
'test_result': 'SUCCESS'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(test_result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Connection failed: {str(e)}',
|
||||||
|
'error': str(e)
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitionViewSet(BaseViewSet):
|
||||||
|
"""ViewSet for MetricDefinition model"""
|
||||||
|
queryset = MetricDefinition.objects.all()
|
||||||
|
serializer_class = MetricDefinitionSerializer
|
||||||
|
filterset_fields = ['metric_type', 'data_source', 'is_active']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['name', 'metric_type']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def active(self, request):
|
||||||
|
"""Get active metrics"""
|
||||||
|
queryset = self.get_queryset().filter(is_active=True)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def calculate(self, request, pk=None):
|
||||||
|
"""Calculate metric value"""
|
||||||
|
metric = self.get_object()
|
||||||
|
serializer = MetricCalculateSerializer(data=request.data)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
try:
|
||||||
|
# Mock calculation - in real implementation, would execute actual calculation
|
||||||
|
calculated_value = 85.5 # Mock value
|
||||||
|
|
||||||
|
# Determine status based on thresholds
|
||||||
|
status_value = 'NORMAL'
|
||||||
|
if metric.threshold_critical and calculated_value >= metric.threshold_critical:
|
||||||
|
status_value = 'CRITICAL'
|
||||||
|
elif metric.threshold_warning and calculated_value >= metric.threshold_warning:
|
||||||
|
status_value = 'WARNING'
|
||||||
|
|
||||||
|
# Create metric value record
|
||||||
|
metric_value = MetricValue.objects.create(
|
||||||
|
metric=metric,
|
||||||
|
value=calculated_value,
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
status=status_value,
|
||||||
|
notes=f"Calculated via API by {request.user.get_full_name()}",
|
||||||
|
tenant=getattr(request.user, 'tenant', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log the action
|
||||||
|
AuditLogger.log_action(
|
||||||
|
user=request.user,
|
||||||
|
action='METRIC_CALCULATED',
|
||||||
|
model='MetricValue',
|
||||||
|
object_id=str(metric_value.value_id),
|
||||||
|
details={
|
||||||
|
'metric_name': metric.name,
|
||||||
|
'calculated_value': calculated_value,
|
||||||
|
'status': status_value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Metric calculated successfully',
|
||||||
|
'metric_value': MetricValueSerializer(metric_value).data
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'error': f'Calculation failed: {str(e)}'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricValueViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""ViewSet for MetricValue model (read-only)"""
|
||||||
|
queryset = MetricValue.objects.all()
|
||||||
|
serializer_class = MetricValueSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['metric', 'status']
|
||||||
|
search_fields = ['metric__name', 'notes']
|
||||||
|
ordering_fields = ['timestamp', 'value']
|
||||||
|
ordering = ['-timestamp']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if hasattr(self.request.user, 'tenant') and self.request.user.tenant:
|
||||||
|
return self.queryset.filter(tenant=self.request.user.tenant)
|
||||||
|
return self.queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def latest(self, request):
|
||||||
|
"""Get latest metric values"""
|
||||||
|
queryset = self.get_queryset().order_by('metric', '-timestamp').distinct('metric')
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardViewSet(BaseViewSet):
|
||||||
|
"""ViewSet for Dashboard model"""
|
||||||
|
queryset = Dashboard.objects.all()
|
||||||
|
serializer_class = DashboardSerializer
|
||||||
|
filterset_fields = ['is_public', 'is_default', 'created_by']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['name', 'created_at']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def create_dashboard(self, request):
|
||||||
|
"""Create a dashboard with widgets"""
|
||||||
|
serializer = DashboardCreateSerializer(data=request.data)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
# Create dashboard
|
||||||
|
dashboard = Dashboard.objects.create(
|
||||||
|
name=serializer.validated_data['name'],
|
||||||
|
description=serializer.validated_data.get('description', ''),
|
||||||
|
layout=serializer.validated_data.get('layout', {}),
|
||||||
|
is_public=serializer.validated_data.get('is_public', False),
|
||||||
|
created_by=request.user,
|
||||||
|
tenant=getattr(request.user, 'tenant', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create widgets if provided
|
||||||
|
widgets_data = serializer.validated_data.get('widgets', [])
|
||||||
|
for widget_data in widgets_data:
|
||||||
|
data_source = DataSource.objects.get(id=widget_data['data_source_id'])
|
||||||
|
|
||||||
|
DashboardWidget.objects.create(
|
||||||
|
dashboard=dashboard,
|
||||||
|
title=widget_data['title'],
|
||||||
|
description=widget_data.get('description', ''),
|
||||||
|
widget_type=widget_data['widget_type'],
|
||||||
|
data_source=data_source,
|
||||||
|
configuration=widget_data.get('configuration', {}),
|
||||||
|
position_x=widget_data.get('position_x', 0),
|
||||||
|
position_y=widget_data.get('position_y', 0),
|
||||||
|
width=widget_data.get('width', 4),
|
||||||
|
height=widget_data.get('height', 3),
|
||||||
|
tenant=getattr(request.user, 'tenant', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log the action
|
||||||
|
AuditLogger.log_action(
|
||||||
|
user=request.user,
|
||||||
|
action='DASHBOARD_CREATED',
|
||||||
|
model='Dashboard',
|
||||||
|
object_id=str(dashboard.dashboard_id),
|
||||||
|
details={
|
||||||
|
'dashboard_name': dashboard.name,
|
||||||
|
'widgets_count': len(widgets_data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Dashboard created successfully',
|
||||||
|
'dashboard': DashboardSerializer(dashboard).data
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def public(self, request):
|
||||||
|
"""Get public dashboards"""
|
||||||
|
queryset = self.get_queryset().filter(is_public=True)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def my_dashboards(self, request):
|
||||||
|
"""Get user's dashboards"""
|
||||||
|
queryset = self.get_queryset().filter(created_by=request.user)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetViewSet(BaseViewSet):
|
||||||
|
"""ViewSet for DashboardWidget model"""
|
||||||
|
queryset = DashboardWidget.objects.all()
|
||||||
|
serializer_class = DashboardWidgetSerializer
|
||||||
|
filterset_fields = ['dashboard', 'widget_type', 'data_source', 'is_visible']
|
||||||
|
search_fields = ['title', 'description']
|
||||||
|
ordering_fields = ['title', 'position_x', 'position_y']
|
||||||
|
ordering = ['position_y', 'position_x']
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def data(self, request, pk=None):
|
||||||
|
"""Get widget data"""
|
||||||
|
widget = self.get_object()
|
||||||
|
serializer = WidgetDataSerializer(data={'widget_id': pk})
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
try:
|
||||||
|
# Mock data generation - in real implementation, would query actual data source
|
||||||
|
mock_data = {
|
||||||
|
'CHART': {
|
||||||
|
'labels': ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
|
||||||
|
'datasets': [{
|
||||||
|
'label': 'Sample Data',
|
||||||
|
'data': [65, 59, 80, 81, 56]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
'TABLE': {
|
||||||
|
'headers': ['Name', 'Value', 'Status'],
|
||||||
|
'rows': [
|
||||||
|
['Metric 1', '85.5', 'Normal'],
|
||||||
|
['Metric 2', '92.1', 'Warning'],
|
||||||
|
['Metric 3', '78.3', 'Normal']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'KPI': {
|
||||||
|
'value': 85.5,
|
||||||
|
'target': 90.0,
|
||||||
|
'trend': 'up',
|
||||||
|
'change': '+2.3%'
|
||||||
|
},
|
||||||
|
'GAUGE': {
|
||||||
|
'value': 75,
|
||||||
|
'min': 0,
|
||||||
|
'max': 100,
|
||||||
|
'thresholds': [50, 80]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget_data = mock_data.get(widget.widget_type, {})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'widget_id': widget.widget_id,
|
||||||
|
'title': widget.title,
|
||||||
|
'widget_type': widget.widget_type,
|
||||||
|
'data': widget_data,
|
||||||
|
'last_updated': timezone.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'error': f'Failed to load widget data: {str(e)}'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportViewSet(BaseViewSet):
|
||||||
|
"""ViewSet for Report model"""
|
||||||
|
queryset = Report.objects.all()
|
||||||
|
serializer_class = ReportSerializer
|
||||||
|
filterset_fields = ['report_type', 'data_source', 'is_active', 'created_by']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['name', 'created_at']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def active(self, request):
|
||||||
|
"""Get active reports"""
|
||||||
|
queryset = self.get_queryset().filter(is_active=True)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def execute(self, request, pk=None):
|
||||||
|
"""Execute a report"""
|
||||||
|
report = self.get_object()
|
||||||
|
serializer = ReportExecuteSerializer(data=request.data)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
try:
|
||||||
|
# Create execution record
|
||||||
|
execution = ReportExecution.objects.create(
|
||||||
|
report=report,
|
||||||
|
executed_by=request.user,
|
||||||
|
start_time=timezone.now(),
|
||||||
|
status='RUNNING',
|
||||||
|
parameters=serializer.validated_data.get('parameters', {}),
|
||||||
|
tenant=getattr(request.user, 'tenant', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock execution - in real implementation, would execute actual report
|
||||||
|
import time
|
||||||
|
time.sleep(0.1) # Simulate processing time
|
||||||
|
|
||||||
|
# Update execution with results
|
||||||
|
execution.end_time = timezone.now()
|
||||||
|
execution.status = 'COMPLETED'
|
||||||
|
execution.result_count = 150 # Mock result count
|
||||||
|
execution.file_path = f'/reports/{execution.execution_id}.json'
|
||||||
|
execution.save()
|
||||||
|
|
||||||
|
# Log the action
|
||||||
|
AuditLogger.log_action(
|
||||||
|
user=request.user,
|
||||||
|
action='REPORT_EXECUTED',
|
||||||
|
model='ReportExecution',
|
||||||
|
object_id=str(execution.execution_id),
|
||||||
|
details={
|
||||||
|
'report_name': report.name,
|
||||||
|
'result_count': execution.result_count
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Report executed successfully',
|
||||||
|
'execution': ReportExecutionSerializer(execution).data
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Update execution with error
|
||||||
|
if 'execution' in locals():
|
||||||
|
execution.end_time = timezone.now()
|
||||||
|
execution.status = 'FAILED'
|
||||||
|
execution.error_message = str(e)
|
||||||
|
execution.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'error': f'Report execution failed: {str(e)}'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExecutionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""ViewSet for ReportExecution model (read-only)"""
|
||||||
|
queryset = ReportExecution.objects.all()
|
||||||
|
serializer_class = ReportExecutionSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['report', 'executed_by', 'status']
|
||||||
|
search_fields = ['report__name', 'executed_by__first_name', 'executed_by__last_name']
|
||||||
|
ordering_fields = ['start_time', 'end_time']
|
||||||
|
ordering = ['-start_time']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if hasattr(self.request.user, 'tenant') and self.request.user.tenant:
|
||||||
|
return self.queryset.filter(tenant=self.request.user.tenant)
|
||||||
|
return self.queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def recent(self, request):
|
||||||
|
"""Get recent executions"""
|
||||||
|
queryset = self.get_queryset().order_by('-start_time')[:20]
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsStatsViewSet(viewsets.ViewSet):
|
||||||
|
"""ViewSet for analytics statistics"""
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def dashboard(self, request):
|
||||||
|
"""Get analytics dashboard statistics"""
|
||||||
|
tenant_filter = {}
|
||||||
|
if hasattr(request.user, 'tenant') and request.user.tenant:
|
||||||
|
tenant_filter['tenant'] = request.user.tenant
|
||||||
|
|
||||||
|
today = timezone.now().date()
|
||||||
|
|
||||||
|
# Dashboard statistics
|
||||||
|
dashboards = Dashboard.objects.filter(**tenant_filter)
|
||||||
|
total_dashboards = dashboards.count()
|
||||||
|
|
||||||
|
# Report statistics
|
||||||
|
reports = Report.objects.filter(**tenant_filter)
|
||||||
|
total_reports = reports.count()
|
||||||
|
|
||||||
|
# Metric statistics
|
||||||
|
metrics = MetricDefinition.objects.filter(**tenant_filter)
|
||||||
|
total_metrics = metrics.count()
|
||||||
|
|
||||||
|
# Data source statistics
|
||||||
|
data_sources = DataSource.objects.filter(**tenant_filter)
|
||||||
|
active_data_sources = data_sources.filter(is_active=True).count()
|
||||||
|
|
||||||
|
# Execution statistics
|
||||||
|
executions = ReportExecution.objects.filter(**tenant_filter)
|
||||||
|
reports_executed_today = executions.filter(start_time__date=today).count()
|
||||||
|
failed_executions = executions.filter(
|
||||||
|
start_time__date=today,
|
||||||
|
status='FAILED'
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Average execution time
|
||||||
|
completed_executions = executions.filter(
|
||||||
|
status='COMPLETED',
|
||||||
|
start_time__date=today
|
||||||
|
)
|
||||||
|
avg_execution_time = 0
|
||||||
|
if completed_executions.exists():
|
||||||
|
total_time = sum([
|
||||||
|
(exec.end_time - exec.start_time).total_seconds()
|
||||||
|
for exec in completed_executions
|
||||||
|
if exec.end_time
|
||||||
|
])
|
||||||
|
avg_execution_time = total_time / completed_executions.count()
|
||||||
|
|
||||||
|
# Popular reports (top 5)
|
||||||
|
popular_reports = list(
|
||||||
|
executions.filter(start_time__gte=today - timedelta(days=30))
|
||||||
|
.values('report__name')
|
||||||
|
.annotate(execution_count=Count('id'))
|
||||||
|
.order_by('-execution_count')[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metric status breakdown
|
||||||
|
metric_values = MetricValue.objects.filter(**tenant_filter)
|
||||||
|
latest_values = metric_values.order_by('metric', '-timestamp').distinct('metric')
|
||||||
|
metric_status_breakdown = latest_values.values('status').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).order_by('-count')
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_dashboards': total_dashboards,
|
||||||
|
'total_reports': total_reports,
|
||||||
|
'total_metrics': total_metrics,
|
||||||
|
'active_data_sources': active_data_sources,
|
||||||
|
'reports_executed_today': reports_executed_today,
|
||||||
|
'failed_executions': failed_executions,
|
||||||
|
'avg_execution_time': round(avg_execution_time, 2),
|
||||||
|
'popular_reports': popular_reports,
|
||||||
|
'metric_status_breakdown': {
|
||||||
|
item['status']: item['count']
|
||||||
|
for item in metric_status_breakdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = AnalyticsStatsSerializer(stats)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
6
analytics/apps.py
Normal file
6
analytics/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'analytics'
|
||||||
398
analytics/forms.py
Normal file
398
analytics/forms.py
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
"""
|
||||||
|
Analytics app forms for CRUD operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Dashboard, DashboardWidget, DataSource, Report,
|
||||||
|
MetricDefinition
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for creating and updating dashboards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Dashboard
|
||||||
|
fields = [
|
||||||
|
'name', 'description', 'dashboard_type', 'is_public',
|
||||||
|
'layout_config', 'refresh_interval', 'is_active'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Enter dashboard name'
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter dashboard description'
|
||||||
|
}),
|
||||||
|
'dashboard_type': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'is_public': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'layout_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 5,
|
||||||
|
'placeholder': 'Enter JSON layout configuration'
|
||||||
|
}),
|
||||||
|
'refresh_interval': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': 30,
|
||||||
|
'max': 3600,
|
||||||
|
'step': 30
|
||||||
|
}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Unique name for this dashboard',
|
||||||
|
'dashboard_type': 'Dashboard type for organization',
|
||||||
|
'is_public': 'Whether this dashboard is publicly accessible',
|
||||||
|
'layout_config': 'JSON configuration for dashboard layout',
|
||||||
|
'refresh_interval': 'Auto-refresh interval in seconds (30-3600)',
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
name = self.cleaned_data['name']
|
||||||
|
if len(name) < 3:
|
||||||
|
raise ValidationError('Dashboard name must be at least 3 characters long.')
|
||||||
|
return name
|
||||||
|
|
||||||
|
def clean_refresh_interval(self):
|
||||||
|
interval = self.cleaned_data['refresh_interval']
|
||||||
|
if interval and (interval < 30 or interval > 3600):
|
||||||
|
raise ValidationError('Refresh interval must be between 30 and 3600 seconds.')
|
||||||
|
return interval
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidgetForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for creating and updating dashboard widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DashboardWidget
|
||||||
|
fields = [
|
||||||
|
'dashboard', 'name', 'widget_type', 'position_x', 'position_y',
|
||||||
|
'width', 'height', 'display_config', 'query_config', 'is_active'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'dashboard': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Enter widget name'
|
||||||
|
}),
|
||||||
|
'widget_type': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'position_x': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': 0,
|
||||||
|
'max': 12
|
||||||
|
}),
|
||||||
|
'position_y': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': 0
|
||||||
|
}),
|
||||||
|
'width': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': 1,
|
||||||
|
'max': 12
|
||||||
|
}),
|
||||||
|
'height': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': 1,
|
||||||
|
'max': 20
|
||||||
|
}),
|
||||||
|
'display_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': 'Enter JSON display configuration'
|
||||||
|
}),
|
||||||
|
'query_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter query configuration'
|
||||||
|
}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'widget_type': 'Type of widget (chart, table, KPI, etc.)',
|
||||||
|
'position_x': 'Horizontal position (0-12 grid system)',
|
||||||
|
'position_y': 'Vertical position',
|
||||||
|
'width': 'Widget width (1-12 grid columns)',
|
||||||
|
'height': 'Widget height in grid rows',
|
||||||
|
'display_config': 'JSON configuration for widget display',
|
||||||
|
'query_config': 'Query configuration for widget content',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
self.fields['dashboard'].queryset = Dashboard.objects.filter(
|
||||||
|
tenant=user.tenant
|
||||||
|
).order_by('name')
|
||||||
|
|
||||||
|
def clean_position_x(self):
|
||||||
|
x = self.cleaned_data['position_x']
|
||||||
|
if x < 0 or x > 12:
|
||||||
|
raise ValidationError('Position X must be between 0 and 12.')
|
||||||
|
return x
|
||||||
|
|
||||||
|
def clean_width(self):
|
||||||
|
width = self.cleaned_data['width']
|
||||||
|
if width < 1 or width > 12:
|
||||||
|
raise ValidationError('Width must be between 1 and 12.')
|
||||||
|
return width
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for creating and updating data sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DataSource
|
||||||
|
fields = [
|
||||||
|
'name', 'description', 'source_type', 'connection_config',
|
||||||
|
'authentication_config', 'query_template', 'cache_duration',
|
||||||
|
'is_active'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Enter data source name'
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter data source description'
|
||||||
|
}),
|
||||||
|
'source_type': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'connection_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': 'Enter JSON connection configuration'
|
||||||
|
}),
|
||||||
|
'authentication_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter JSON authentication configuration'
|
||||||
|
}),
|
||||||
|
'query_template': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': 'Enter query template'
|
||||||
|
}),
|
||||||
|
'cache_duration': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': 0,
|
||||||
|
'max': 86400,
|
||||||
|
'value': 300
|
||||||
|
}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Unique name for this data source',
|
||||||
|
'source_type': 'Type of data source (database, API, file, etc.)',
|
||||||
|
'connection_config': 'JSON configuration for connection settings',
|
||||||
|
'authentication_config': 'JSON configuration for authentication',
|
||||||
|
'cache_duration': 'Cache duration in seconds (0-86400)',
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
name = self.cleaned_data['name']
|
||||||
|
if len(name) < 3:
|
||||||
|
raise ValidationError('Data source name must be at least 3 characters long.')
|
||||||
|
return name
|
||||||
|
|
||||||
|
def clean_cache_duration(self):
|
||||||
|
cache_duration = self.cleaned_data['cache_duration']
|
||||||
|
if cache_duration < 0 or cache_duration > 86400:
|
||||||
|
raise ValidationError('Cache duration must be between 0 and 86400 seconds.')
|
||||||
|
return cache_duration
|
||||||
|
|
||||||
|
|
||||||
|
class ReportForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for creating and updating reports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Report
|
||||||
|
fields = [
|
||||||
|
'name', 'description', 'report_type', 'data_source',
|
||||||
|
'query_config', 'output_format', 'template_config',
|
||||||
|
'schedule_config', 'is_active'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Enter report name'
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter report description'
|
||||||
|
}),
|
||||||
|
'report_type': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'data_source': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'query_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 8,
|
||||||
|
'placeholder': 'Enter query configuration'
|
||||||
|
}),
|
||||||
|
'template_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': 'Enter JSON template configuration'
|
||||||
|
}),
|
||||||
|
'output_format': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'schedule_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter JSON schedule configuration'
|
||||||
|
}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Unique name for this report',
|
||||||
|
'report_type': 'Report type for organization',
|
||||||
|
'query_config': 'Query configuration for report data',
|
||||||
|
'template_config': 'Template configuration for report formatting',
|
||||||
|
'output_format': 'Default output format for report',
|
||||||
|
'schedule_config': 'JSON configuration for automated scheduling',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
self.fields['data_source'].queryset = DataSource.objects.filter(
|
||||||
|
tenant=user.tenant,
|
||||||
|
is_active=True
|
||||||
|
).order_by('source_name')
|
||||||
|
|
||||||
|
def clean_report_name(self):
|
||||||
|
name = self.cleaned_data['report_name']
|
||||||
|
if len(name) < 3:
|
||||||
|
raise ValidationError('Report name must be at least 3 characters long.')
|
||||||
|
return name
|
||||||
|
|
||||||
|
def clean_query_definition(self):
|
||||||
|
query = self.cleaned_data['query_definition']
|
||||||
|
if len(query.strip()) < 10:
|
||||||
|
raise ValidationError('Query definition must be at least 10 characters long.')
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitionForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for creating and updating metric definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MetricDefinition
|
||||||
|
fields = [
|
||||||
|
'name', 'description', 'metric_type', 'data_source',
|
||||||
|
'calculation_config', 'aggregation_period', 'unit_of_measure',
|
||||||
|
'target_value', 'warning_threshold', 'critical_threshold',
|
||||||
|
'aggregation_config', 'is_active'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Enter metric name'
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter metric description'
|
||||||
|
}),
|
||||||
|
'metric_type': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'data_source': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'calculation_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 6,
|
||||||
|
'placeholder': 'Enter calculation configuration'
|
||||||
|
}),
|
||||||
|
'aggregation_period': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'unit_of_measure': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'e.g., %, count, minutes, dollars'
|
||||||
|
}),
|
||||||
|
'target_value': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': 'any',
|
||||||
|
'placeholder': 'Target value for this metric'
|
||||||
|
}),
|
||||||
|
'warning_threshold': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': 'any',
|
||||||
|
'placeholder': 'Warning threshold value'
|
||||||
|
}),
|
||||||
|
'critical_threshold': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': 'any',
|
||||||
|
'placeholder': 'Critical threshold value'
|
||||||
|
}),
|
||||||
|
'aggregation_config': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter aggregation configuration'
|
||||||
|
}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'name': 'Unique name for this metric',
|
||||||
|
'metric_type': 'Metric type for organization',
|
||||||
|
'calculation_config': 'Configuration for metric calculation',
|
||||||
|
'aggregation_period': 'Period for metric aggregation',
|
||||||
|
'unit_of_measure': 'Unit of measurement for metric values',
|
||||||
|
'target_value': 'Target or goal value for this metric',
|
||||||
|
'warning_threshold': 'Value that triggers warning alerts',
|
||||||
|
'critical_threshold': 'Value that triggers critical alerts',
|
||||||
|
'aggregation_config': 'Configuration for metric aggregation',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
self.fields['data_source'].queryset = DataSource.objects.filter(
|
||||||
|
tenant=user.tenant,
|
||||||
|
is_active=True
|
||||||
|
).order_by('source_name')
|
||||||
|
|
||||||
|
def clean_metric_name(self):
|
||||||
|
name = self.cleaned_data['metric_name']
|
||||||
|
if len(name) < 3:
|
||||||
|
raise ValidationError('Metric name must be at least 3 characters long.')
|
||||||
|
return name
|
||||||
|
|
||||||
|
def clean_query_definition(self):
|
||||||
|
query = self.cleaned_data['query_definition']
|
||||||
|
if len(query.strip()) < 5:
|
||||||
|
raise ValidationError('Query definition must be at least 5 characters long.')
|
||||||
|
return query
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
target = cleaned_data.get('target_value')
|
||||||
|
warning = cleaned_data.get('threshold_warning')
|
||||||
|
critical = cleaned_data.get('threshold_critical')
|
||||||
|
|
||||||
|
# Validate threshold relationships
|
||||||
|
if target and warning and critical:
|
||||||
|
if warning == critical:
|
||||||
|
raise ValidationError('Warning and critical thresholds must be different.')
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
603
analytics/migrations/0001_initial.py
Normal file
603
analytics/migrations/0001_initial.py
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-04 04:41
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DashboardWidget",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"widget_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"widget_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("CHART", "Chart Widget"),
|
||||||
|
("TABLE", "Table Widget"),
|
||||||
|
("METRIC", "Metric Widget"),
|
||||||
|
("GAUGE", "Gauge Widget"),
|
||||||
|
("MAP", "Map Widget"),
|
||||||
|
("TEXT", "Text Widget"),
|
||||||
|
("IMAGE", "Image Widget"),
|
||||||
|
("IFRAME", "IFrame Widget"),
|
||||||
|
("CUSTOM", "Custom Widget"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"chart_type",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("LINE", "Line Chart"),
|
||||||
|
("BAR", "Bar Chart"),
|
||||||
|
("PIE", "Pie Chart"),
|
||||||
|
("DOUGHNUT", "Doughnut Chart"),
|
||||||
|
("AREA", "Area Chart"),
|
||||||
|
("SCATTER", "Scatter Plot"),
|
||||||
|
("HISTOGRAM", "Histogram"),
|
||||||
|
("HEATMAP", "Heat Map"),
|
||||||
|
("TREEMAP", "Tree Map"),
|
||||||
|
("FUNNEL", "Funnel Chart"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"query_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Query configuration for data source"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("position_x", models.PositiveIntegerField(default=0)),
|
||||||
|
("position_y", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"width",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=4,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"height",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=4,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Widget display configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("color_scheme", models.CharField(default="default", max_length=50)),
|
||||||
|
("auto_refresh", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"refresh_interval",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=300, help_text="Refresh interval in seconds"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "analytics_dashboard_widget",
|
||||||
|
"ordering": ["position_y", "position_x"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DataSource",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"source_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"source_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("DATABASE", "Database Query"),
|
||||||
|
("API", "API Endpoint"),
|
||||||
|
("FILE", "File Upload"),
|
||||||
|
("STREAM", "Real-time Stream"),
|
||||||
|
("WEBHOOK", "Webhook"),
|
||||||
|
("CUSTOM", "Custom Source"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"connection_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("POSTGRESQL", "PostgreSQL"),
|
||||||
|
("MYSQL", "MySQL"),
|
||||||
|
("SQLITE", "SQLite"),
|
||||||
|
("MONGODB", "MongoDB"),
|
||||||
|
("REDIS", "Redis"),
|
||||||
|
("REST_API", "REST API"),
|
||||||
|
("GRAPHQL", "GraphQL"),
|
||||||
|
("WEBSOCKET", "WebSocket"),
|
||||||
|
("CSV", "CSV File"),
|
||||||
|
("JSON", "JSON File"),
|
||||||
|
("XML", "XML File"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"connection_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Connection configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"authentication_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Authentication configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"query_template",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="SQL query or API endpoint template"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"parameters",
|
||||||
|
models.JSONField(default=dict, help_text="Query parameters"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"data_transformation",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Data transformation rules"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cache_duration",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=300, help_text="Cache duration in seconds"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_healthy", models.BooleanField(default=True)),
|
||||||
|
("last_health_check", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"health_check_interval",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=300, help_text="Health check interval in seconds"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "analytics_data_source",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MetricDefinition",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"metric_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"metric_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("COUNT", "Count"),
|
||||||
|
("SUM", "Sum"),
|
||||||
|
("AVERAGE", "Average"),
|
||||||
|
("PERCENTAGE", "Percentage"),
|
||||||
|
("RATIO", "Ratio"),
|
||||||
|
("RATE", "Rate"),
|
||||||
|
("DURATION", "Duration"),
|
||||||
|
("CUSTOM", "Custom Calculation"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"calculation_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Metric calculation configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"aggregation_period",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("REAL_TIME", "Real-time"),
|
||||||
|
("HOURLY", "Hourly"),
|
||||||
|
("DAILY", "Daily"),
|
||||||
|
("WEEKLY", "Weekly"),
|
||||||
|
("MONTHLY", "Monthly"),
|
||||||
|
("QUARTERLY", "Quarterly"),
|
||||||
|
("YEARLY", "Yearly"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"aggregation_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Aggregation configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target_value",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=4, max_digits=15, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"warning_threshold",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=4, max_digits=15, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"critical_threshold",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=4, max_digits=15, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("unit_of_measure", models.CharField(blank=True, max_length=50)),
|
||||||
|
(
|
||||||
|
"decimal_places",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=2,
|
||||||
|
validators=[django.core.validators.MaxValueValidator(10)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("display_format", models.CharField(default="number", max_length=50)),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "analytics_metric_definition",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MetricValue",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"value_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("value", models.DecimalField(decimal_places=4, max_digits=15)),
|
||||||
|
("period_start", models.DateTimeField()),
|
||||||
|
("period_end", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"dimensions",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="Metric dimensions (e.g., department, provider)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"metadata",
|
||||||
|
models.JSONField(default=dict, help_text="Additional metadata"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"data_quality_score",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
max_digits=5,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(0),
|
||||||
|
django.core.validators.MaxValueValidator(100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"confidence_level",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
max_digits=5,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(0),
|
||||||
|
django.core.validators.MaxValueValidator(100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("calculation_timestamp", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"calculation_duration_ms",
|
||||||
|
models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "analytics_metric_value",
|
||||||
|
"ordering": ["-period_start"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Report",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"report_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"report_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("OPERATIONAL", "Operational Report"),
|
||||||
|
("FINANCIAL", "Financial Report"),
|
||||||
|
("CLINICAL", "Clinical Report"),
|
||||||
|
("QUALITY", "Quality Report"),
|
||||||
|
("COMPLIANCE", "Compliance Report"),
|
||||||
|
("PERFORMANCE", "Performance Report"),
|
||||||
|
("CUSTOM", "Custom Report"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"query_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Query configuration for report"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"output_format",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PDF", "PDF Document"),
|
||||||
|
("EXCEL", "Excel Spreadsheet"),
|
||||||
|
("CSV", "CSV File"),
|
||||||
|
("JSON", "JSON Data"),
|
||||||
|
("HTML", "HTML Page"),
|
||||||
|
("EMAIL", "Email Report"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"template_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Report template configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"schedule_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("MANUAL", "Manual Execution"),
|
||||||
|
("DAILY", "Daily"),
|
||||||
|
("WEEKLY", "Weekly"),
|
||||||
|
("MONTHLY", "Monthly"),
|
||||||
|
("QUARTERLY", "Quarterly"),
|
||||||
|
("YEARLY", "Yearly"),
|
||||||
|
("CUSTOM", "Custom Schedule"),
|
||||||
|
],
|
||||||
|
default="MANUAL",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"schedule_config",
|
||||||
|
models.JSONField(default=dict, help_text="Schedule configuration"),
|
||||||
|
),
|
||||||
|
("next_execution", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"recipients",
|
||||||
|
models.JSONField(default=list, help_text="Report recipients"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"distribution_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Distribution configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "analytics_report",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReportExecution",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"execution_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"execution_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("MANUAL", "Manual"),
|
||||||
|
("SCHEDULED", "Scheduled"),
|
||||||
|
("API", "API Triggered"),
|
||||||
|
],
|
||||||
|
default="MANUAL",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("started_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("completed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"duration_seconds",
|
||||||
|
models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PENDING", "Pending"),
|
||||||
|
("RUNNING", "Running"),
|
||||||
|
("COMPLETED", "Completed"),
|
||||||
|
("FAILED", "Failed"),
|
||||||
|
("CANCELLED", "Cancelled"),
|
||||||
|
],
|
||||||
|
default="PENDING",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("error_message", models.TextField(blank=True)),
|
||||||
|
("output_file_path", models.CharField(blank=True, max_length=500)),
|
||||||
|
(
|
||||||
|
"output_size_bytes",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("record_count", models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"execution_parameters",
|
||||||
|
models.JSONField(default=dict, help_text="Execution parameters"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "analytics_report_execution",
|
||||||
|
"ordering": ["-started_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Dashboard",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"dashboard_id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"dashboard_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("EXECUTIVE", "Executive Dashboard"),
|
||||||
|
("CLINICAL", "Clinical Dashboard"),
|
||||||
|
("OPERATIONAL", "Operational Dashboard"),
|
||||||
|
("FINANCIAL", "Financial Dashboard"),
|
||||||
|
("QUALITY", "Quality Dashboard"),
|
||||||
|
("PATIENT", "Patient Dashboard"),
|
||||||
|
("PROVIDER", "Provider Dashboard"),
|
||||||
|
("DEPARTMENT", "Department Dashboard"),
|
||||||
|
("CUSTOM", "Custom Dashboard"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"layout_config",
|
||||||
|
models.JSONField(
|
||||||
|
default=dict, help_text="Dashboard layout configuration"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"refresh_interval",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=300, help_text="Refresh interval in seconds"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_public", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"allowed_roles",
|
||||||
|
models.JSONField(
|
||||||
|
default=list, help_text="List of allowed user roles"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("is_default", models.BooleanField(default=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"allowed_users",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="accessible_dashboards",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "analytics_dashboard",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
309
analytics/migrations/0002_initial.py
Normal file
309
analytics/migrations/0002_initial.py
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-04 04:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("analytics", "0001_initial"),
|
||||||
|
("core", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="dashboard",
|
||||||
|
name="tenant",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="dashboards",
|
||||||
|
to="core.tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="dashboardwidget",
|
||||||
|
name="dashboard",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="widgets",
|
||||||
|
to="analytics.dashboard",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datasource",
|
||||||
|
name="created_by",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datasource",
|
||||||
|
name="tenant",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="data_sources",
|
||||||
|
to="core.tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="dashboardwidget",
|
||||||
|
name="data_source",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="widgets",
|
||||||
|
to="analytics.datasource",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="metricdefinition",
|
||||||
|
name="created_by",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="metricdefinition",
|
||||||
|
name="data_source",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="metrics",
|
||||||
|
to="analytics.datasource",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="metricdefinition",
|
||||||
|
name="tenant",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="metric_definitions",
|
||||||
|
to="core.tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="metricvalue",
|
||||||
|
name="metric_definition",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="values",
|
||||||
|
to="analytics.metricdefinition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="report",
|
||||||
|
name="created_by",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="report",
|
||||||
|
name="data_source",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="reports",
|
||||||
|
to="analytics.datasource",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="report",
|
||||||
|
name="tenant",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="reports",
|
||||||
|
to="core.tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="reportexecution",
|
||||||
|
name="executed_by",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="reportexecution",
|
||||||
|
name="report",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="executions",
|
||||||
|
to="analytics.report",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="dashboard",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "dashboard_type"],
|
||||||
|
name="analytics_d_tenant__6c4962_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="dashboard",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "is_active"], name="analytics_d_tenant__c68e4a_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="dashboard",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "is_default"], name="analytics_d_tenant__f167b1_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="dashboard",
|
||||||
|
unique_together={("tenant", "name")},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="datasource",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "source_type"], name="analytics_d_tenant__1f790a_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="datasource",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "is_active"], name="analytics_d_tenant__a566a2_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="datasource",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "is_healthy"], name="analytics_d_tenant__442319_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="datasource",
|
||||||
|
unique_together={("tenant", "name")},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="dashboardwidget",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["dashboard", "is_active"], name="analytics_d_dashboa_6a4da0_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="dashboardwidget",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["dashboard", "position_x", "position_y"],
|
||||||
|
name="analytics_d_dashboa_4ce236_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="metricdefinition",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "metric_type"], name="analytics_m_tenant__74f857_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="metricdefinition",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "aggregation_period"],
|
||||||
|
name="analytics_m_tenant__95594d_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="metricdefinition",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "is_active"], name="analytics_m_tenant__fed8ae_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="metricdefinition",
|
||||||
|
unique_together={("tenant", "name")},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="metricvalue",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["metric_definition", "period_start"],
|
||||||
|
name="analytics_m_metric__20f4a3_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="metricvalue",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["metric_definition", "period_end"],
|
||||||
|
name="analytics_m_metric__eca5ed_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="metricvalue",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["period_start", "period_end"],
|
||||||
|
name="analytics_m_period__286467_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="metricvalue",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["calculation_timestamp"], name="analytics_m_calcula_c2ca26_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="metricvalue",
|
||||||
|
unique_together={("metric_definition", "period_start", "period_end")},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="report",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "report_type"], name="analytics_r_tenant__9818e0_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="report",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "schedule_type"],
|
||||||
|
name="analytics_r_tenant__6d4012_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="report",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "next_execution"],
|
||||||
|
name="analytics_r_tenant__832dfb_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="report",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["tenant", "is_active"], name="analytics_r_tenant__88f6f3_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="report",
|
||||||
|
unique_together={("tenant", "name")},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="reportexecution",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["report", "status"], name="analytics_r_report__db5768_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="reportexecution",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["report", "started_at"], name="analytics_r_report__be32b5_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="reportexecution",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["status", "started_at"], name="analytics_r_status_294e23_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-04 17:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("analytics", "0002_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datasource",
|
||||||
|
name="last_test_duration_seconds",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datasource",
|
||||||
|
name="last_test_end_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datasource",
|
||||||
|
name="last_test_error_message",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datasource",
|
||||||
|
name="last_test_start_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datasource",
|
||||||
|
name="last_test_status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PENDING", "Pending"),
|
||||||
|
("RUNNING", "Running"),
|
||||||
|
("SUCCESS", "Success"),
|
||||||
|
("FAILURE", "Failure"),
|
||||||
|
],
|
||||||
|
default="PENDING",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
analytics/migrations/__init__.py
Normal file
0
analytics/migrations/__init__.py
Normal file
BIN
analytics/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
analytics/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analytics/migrations/__pycache__/0002_initial.cpython-312.pyc
Normal file
BIN
analytics/migrations/__pycache__/0002_initial.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
analytics/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
analytics/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
496
analytics/models.py
Normal file
496
analytics/models.py
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
"""
|
||||||
|
Analytics app models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from django.utils import timezone
|
||||||
|
from core.models import Tenant
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Dashboard(models.Model):
|
||||||
|
"""
|
||||||
|
Dashboard model for organizing widgets and analytics views.
|
||||||
|
"""
|
||||||
|
DASHBOARD_TYPES = [
|
||||||
|
('EXECUTIVE', 'Executive Dashboard'),
|
||||||
|
('CLINICAL', 'Clinical Dashboard'),
|
||||||
|
('OPERATIONAL', 'Operational Dashboard'),
|
||||||
|
('FINANCIAL', 'Financial Dashboard'),
|
||||||
|
('QUALITY', 'Quality Dashboard'),
|
||||||
|
('PATIENT', 'Patient Dashboard'),
|
||||||
|
('PROVIDER', 'Provider Dashboard'),
|
||||||
|
('DEPARTMENT', 'Department Dashboard'),
|
||||||
|
('CUSTOM', 'Custom Dashboard'),
|
||||||
|
]
|
||||||
|
|
||||||
|
dashboard_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='dashboards')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
dashboard_type = models.CharField(max_length=20, choices=DASHBOARD_TYPES)
|
||||||
|
|
||||||
|
# Layout and configuration
|
||||||
|
layout_config = models.JSONField(default=dict, help_text="Dashboard layout configuration")
|
||||||
|
refresh_interval = models.PositiveIntegerField(default=300, help_text="Refresh interval in seconds")
|
||||||
|
|
||||||
|
# Access control
|
||||||
|
is_public = models.BooleanField(default=False)
|
||||||
|
allowed_users = models.ManyToManyField(User, blank=True, related_name='accessible_dashboards')
|
||||||
|
allowed_roles = models.JSONField(default=list, help_text="List of allowed user roles")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
is_default = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'analytics_dashboard'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'dashboard_type']),
|
||||||
|
models.Index(fields=['tenant', 'is_active']),
|
||||||
|
models.Index(fields=['tenant', 'is_default']),
|
||||||
|
]
|
||||||
|
unique_together = [['tenant', 'name']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_dashboard_type_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardWidget(models.Model):
|
||||||
|
"""
|
||||||
|
Dashboard widget model for individual analytics components.
|
||||||
|
"""
|
||||||
|
WIDGET_TYPES = [
|
||||||
|
('CHART', 'Chart Widget'),
|
||||||
|
('TABLE', 'Table Widget'),
|
||||||
|
('METRIC', 'Metric Widget'),
|
||||||
|
('GAUGE', 'Gauge Widget'),
|
||||||
|
('MAP', 'Map Widget'),
|
||||||
|
('TEXT', 'Text Widget'),
|
||||||
|
('IMAGE', 'Image Widget'),
|
||||||
|
('IFRAME', 'IFrame Widget'),
|
||||||
|
('CUSTOM', 'Custom Widget'),
|
||||||
|
]
|
||||||
|
|
||||||
|
CHART_TYPES = [
|
||||||
|
('LINE', 'Line Chart'),
|
||||||
|
('BAR', 'Bar Chart'),
|
||||||
|
('PIE', 'Pie Chart'),
|
||||||
|
('DOUGHNUT', 'Doughnut Chart'),
|
||||||
|
('AREA', 'Area Chart'),
|
||||||
|
('SCATTER', 'Scatter Plot'),
|
||||||
|
('HISTOGRAM', 'Histogram'),
|
||||||
|
('HEATMAP', 'Heat Map'),
|
||||||
|
('TREEMAP', 'Tree Map'),
|
||||||
|
('FUNNEL', 'Funnel Chart'),
|
||||||
|
]
|
||||||
|
|
||||||
|
widget_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
dashboard = models.ForeignKey(Dashboard, on_delete=models.CASCADE, related_name='widgets')
|
||||||
|
|
||||||
|
# Widget configuration
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
widget_type = models.CharField(max_length=20, choices=WIDGET_TYPES)
|
||||||
|
chart_type = models.CharField(max_length=20, choices=CHART_TYPES, blank=True)
|
||||||
|
|
||||||
|
# Data source
|
||||||
|
data_source = models.ForeignKey('DataSource', on_delete=models.CASCADE, related_name='widgets')
|
||||||
|
query_config = models.JSONField(default=dict, help_text="Query configuration for data source")
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
position_x = models.PositiveIntegerField(default=0)
|
||||||
|
position_y = models.PositiveIntegerField(default=0)
|
||||||
|
width = models.PositiveIntegerField(default=4, validators=[MinValueValidator(1), MaxValueValidator(12)])
|
||||||
|
height = models.PositiveIntegerField(default=4, validators=[MinValueValidator(1), MaxValueValidator(12)])
|
||||||
|
|
||||||
|
# Display configuration
|
||||||
|
display_config = models.JSONField(default=dict, help_text="Widget display configuration")
|
||||||
|
color_scheme = models.CharField(max_length=50, default='default')
|
||||||
|
|
||||||
|
# Refresh settings
|
||||||
|
auto_refresh = models.BooleanField(default=True)
|
||||||
|
refresh_interval = models.PositiveIntegerField(default=300, help_text="Refresh interval in seconds")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'analytics_dashboard_widget'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['dashboard', 'is_active']),
|
||||||
|
models.Index(fields=['dashboard', 'position_x', 'position_y']),
|
||||||
|
]
|
||||||
|
ordering = ['position_y', 'position_x']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_widget_type_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class DataSource(models.Model):
|
||||||
|
"""
|
||||||
|
Data source model for analytics data connections.
|
||||||
|
"""
|
||||||
|
SOURCE_TYPES = [
|
||||||
|
('DATABASE', 'Database Query'),
|
||||||
|
('API', 'API Endpoint'),
|
||||||
|
('FILE', 'File Upload'),
|
||||||
|
('STREAM', 'Real-time Stream'),
|
||||||
|
('WEBHOOK', 'Webhook'),
|
||||||
|
('CUSTOM', 'Custom Source'),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONNECTION_TYPES = [
|
||||||
|
('POSTGRESQL', 'PostgreSQL'),
|
||||||
|
('MYSQL', 'MySQL'),
|
||||||
|
('SQLITE', 'SQLite'),
|
||||||
|
('MONGODB', 'MongoDB'),
|
||||||
|
('REDIS', 'Redis'),
|
||||||
|
('REST_API', 'REST API'),
|
||||||
|
('GRAPHQL', 'GraphQL'),
|
||||||
|
('WEBSOCKET', 'WebSocket'),
|
||||||
|
('CSV', 'CSV File'),
|
||||||
|
('JSON', 'JSON File'),
|
||||||
|
('XML', 'XML File'),
|
||||||
|
]
|
||||||
|
TEST_STATUS_CHOICES = [
|
||||||
|
('PENDING', 'Pending'),
|
||||||
|
('RUNNING', 'Running'),
|
||||||
|
('SUCCESS', 'Success'),
|
||||||
|
('FAILURE', 'Failure'),
|
||||||
|
]
|
||||||
|
|
||||||
|
source_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='data_sources')
|
||||||
|
|
||||||
|
# Source configuration
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
source_type = models.CharField(max_length=20, choices=SOURCE_TYPES)
|
||||||
|
connection_type = models.CharField(max_length=20, choices=CONNECTION_TYPES)
|
||||||
|
|
||||||
|
# Connection details
|
||||||
|
connection_config = models.JSONField(default=dict, help_text="Connection configuration")
|
||||||
|
authentication_config = models.JSONField(default=dict, help_text="Authentication configuration")
|
||||||
|
|
||||||
|
# Query/endpoint details
|
||||||
|
query_template = models.TextField(blank=True, help_text="SQL query or API endpoint template")
|
||||||
|
parameters = models.JSONField(default=dict, help_text="Query parameters")
|
||||||
|
|
||||||
|
# Data processing
|
||||||
|
data_transformation = models.JSONField(default=dict, help_text="Data transformation rules")
|
||||||
|
cache_duration = models.PositiveIntegerField(default=300, help_text="Cache duration in seconds")
|
||||||
|
|
||||||
|
# Health monitoring
|
||||||
|
is_healthy = models.BooleanField(default=True)
|
||||||
|
last_health_check = models.DateTimeField(null=True, blank=True)
|
||||||
|
health_check_interval = models.PositiveIntegerField(default=300, help_text="Health check interval in seconds")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
last_test_status = models.CharField(max_length=20, choices=TEST_STATUS_CHOICES, default='PENDING')
|
||||||
|
last_test_start_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_test_end_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_test_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
last_test_error_message = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'analytics_data_source'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'source_type']),
|
||||||
|
models.Index(fields=['tenant', 'is_active']),
|
||||||
|
models.Index(fields=['tenant', 'is_healthy']),
|
||||||
|
]
|
||||||
|
unique_together = [['tenant', 'name']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_source_type_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class Report(models.Model):
|
||||||
|
"""
|
||||||
|
Report model for scheduled and ad-hoc reporting.
|
||||||
|
"""
|
||||||
|
REPORT_TYPES = [
|
||||||
|
('OPERATIONAL', 'Operational Report'),
|
||||||
|
('FINANCIAL', 'Financial Report'),
|
||||||
|
('CLINICAL', 'Clinical Report'),
|
||||||
|
('QUALITY', 'Quality Report'),
|
||||||
|
('COMPLIANCE', 'Compliance Report'),
|
||||||
|
('PERFORMANCE', 'Performance Report'),
|
||||||
|
('CUSTOM', 'Custom Report'),
|
||||||
|
]
|
||||||
|
|
||||||
|
OUTPUT_FORMATS = [
|
||||||
|
('PDF', 'PDF Document'),
|
||||||
|
('EXCEL', 'Excel Spreadsheet'),
|
||||||
|
('CSV', 'CSV File'),
|
||||||
|
('JSON', 'JSON Data'),
|
||||||
|
('HTML', 'HTML Page'),
|
||||||
|
('EMAIL', 'Email Report'),
|
||||||
|
]
|
||||||
|
|
||||||
|
SCHEDULE_TYPES = [
|
||||||
|
('MANUAL', 'Manual Execution'),
|
||||||
|
('DAILY', 'Daily'),
|
||||||
|
('WEEKLY', 'Weekly'),
|
||||||
|
('MONTHLY', 'Monthly'),
|
||||||
|
('QUARTERLY', 'Quarterly'),
|
||||||
|
('YEARLY', 'Yearly'),
|
||||||
|
('CUSTOM', 'Custom Schedule'),
|
||||||
|
]
|
||||||
|
|
||||||
|
report_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='reports')
|
||||||
|
|
||||||
|
# Report configuration
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
report_type = models.CharField(max_length=20, choices=REPORT_TYPES)
|
||||||
|
|
||||||
|
# Data source
|
||||||
|
data_source = models.ForeignKey(DataSource, on_delete=models.CASCADE, related_name='reports')
|
||||||
|
query_config = models.JSONField(default=dict, help_text="Query configuration for report")
|
||||||
|
|
||||||
|
# Output configuration
|
||||||
|
output_format = models.CharField(max_length=20, choices=OUTPUT_FORMATS)
|
||||||
|
template_config = models.JSONField(default=dict, help_text="Report template configuration")
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
schedule_type = models.CharField(max_length=20, choices=SCHEDULE_TYPES, default='MANUAL')
|
||||||
|
schedule_config = models.JSONField(default=dict, help_text="Schedule configuration")
|
||||||
|
next_execution = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Distribution
|
||||||
|
recipients = models.JSONField(default=list, help_text="Report recipients")
|
||||||
|
distribution_config = models.JSONField(default=dict, help_text="Distribution configuration")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'analytics_report'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'report_type']),
|
||||||
|
models.Index(fields=['tenant', 'schedule_type']),
|
||||||
|
models.Index(fields=['tenant', 'next_execution']),
|
||||||
|
models.Index(fields=['tenant', 'is_active']),
|
||||||
|
]
|
||||||
|
unique_together = [['tenant', 'name']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_report_type_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExecution(models.Model):
|
||||||
|
"""
|
||||||
|
Report execution model for tracking report runs.
|
||||||
|
"""
|
||||||
|
EXECUTION_STATUS = [
|
||||||
|
('PENDING', 'Pending'),
|
||||||
|
('RUNNING', 'Running'),
|
||||||
|
('COMPLETED', 'Completed'),
|
||||||
|
('FAILED', 'Failed'),
|
||||||
|
('CANCELLED', 'Cancelled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
execution_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
report = models.ForeignKey(Report, on_delete=models.CASCADE, related_name='executions')
|
||||||
|
|
||||||
|
# Execution details
|
||||||
|
execution_type = models.CharField(max_length=20, choices=[
|
||||||
|
('MANUAL', 'Manual'),
|
||||||
|
('SCHEDULED', 'Scheduled'),
|
||||||
|
('API', 'API Triggered'),
|
||||||
|
], default='MANUAL')
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Status and results
|
||||||
|
status = models.CharField(max_length=20, choices=EXECUTION_STATUS, default='PENDING')
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Output
|
||||||
|
output_file_path = models.CharField(max_length=500, blank=True)
|
||||||
|
output_size_bytes = models.PositiveBigIntegerField(null=True, blank=True)
|
||||||
|
record_count = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Parameters
|
||||||
|
execution_parameters = models.JSONField(default=dict, help_text="Execution parameters")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
executed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'analytics_report_execution'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['report', 'status']),
|
||||||
|
models.Index(fields=['report', 'started_at']),
|
||||||
|
models.Index(fields=['status', 'started_at']),
|
||||||
|
]
|
||||||
|
ordering = ['-started_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.report.name} - {self.started_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_completed(self):
|
||||||
|
"""Check if execution is completed."""
|
||||||
|
return self.status in ['COMPLETED', 'FAILED', 'CANCELLED']
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinition(models.Model):
|
||||||
|
"""
|
||||||
|
Metric definition model for KPI and performance metrics.
|
||||||
|
"""
|
||||||
|
METRIC_TYPES = [
|
||||||
|
('COUNT', 'Count'),
|
||||||
|
('SUM', 'Sum'),
|
||||||
|
('AVERAGE', 'Average'),
|
||||||
|
('PERCENTAGE', 'Percentage'),
|
||||||
|
('RATIO', 'Ratio'),
|
||||||
|
('RATE', 'Rate'),
|
||||||
|
('DURATION', 'Duration'),
|
||||||
|
('CUSTOM', 'Custom Calculation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
AGGREGATION_PERIODS = [
|
||||||
|
('REAL_TIME', 'Real-time'),
|
||||||
|
('HOURLY', 'Hourly'),
|
||||||
|
('DAILY', 'Daily'),
|
||||||
|
('WEEKLY', 'Weekly'),
|
||||||
|
('MONTHLY', 'Monthly'),
|
||||||
|
('QUARTERLY', 'Quarterly'),
|
||||||
|
('YEARLY', 'Yearly'),
|
||||||
|
]
|
||||||
|
|
||||||
|
metric_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='metric_definitions')
|
||||||
|
|
||||||
|
# Metric configuration
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
metric_type = models.CharField(max_length=20, choices=METRIC_TYPES)
|
||||||
|
|
||||||
|
# Data source
|
||||||
|
data_source = models.ForeignKey(DataSource, on_delete=models.CASCADE, related_name='metrics')
|
||||||
|
calculation_config = models.JSONField(default=dict, help_text="Metric calculation configuration")
|
||||||
|
|
||||||
|
# Aggregation
|
||||||
|
aggregation_period = models.CharField(max_length=20, choices=AGGREGATION_PERIODS)
|
||||||
|
aggregation_config = models.JSONField(default=dict, help_text="Aggregation configuration")
|
||||||
|
|
||||||
|
# Thresholds and targets
|
||||||
|
target_value = models.DecimalField(max_digits=15, decimal_places=4, null=True, blank=True)
|
||||||
|
warning_threshold = models.DecimalField(max_digits=15, decimal_places=4, null=True, blank=True)
|
||||||
|
critical_threshold = models.DecimalField(max_digits=15, decimal_places=4, null=True, blank=True)
|
||||||
|
|
||||||
|
# Display configuration
|
||||||
|
unit_of_measure = models.CharField(max_length=50, blank=True)
|
||||||
|
decimal_places = models.PositiveIntegerField(default=2, validators=[MaxValueValidator(10)])
|
||||||
|
display_format = models.CharField(max_length=50, default='number')
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'analytics_metric_definition'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'metric_type']),
|
||||||
|
models.Index(fields=['tenant', 'aggregation_period']),
|
||||||
|
models.Index(fields=['tenant', 'is_active']),
|
||||||
|
]
|
||||||
|
unique_together = [['tenant', 'name']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_metric_type_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class MetricValue(models.Model):
|
||||||
|
"""
|
||||||
|
Metric value model for storing calculated metric values.
|
||||||
|
"""
|
||||||
|
value_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
metric_definition = models.ForeignKey(MetricDefinition, on_delete=models.CASCADE, related_name='values')
|
||||||
|
|
||||||
|
# Value details
|
||||||
|
value = models.DecimalField(max_digits=15, decimal_places=4)
|
||||||
|
period_start = models.DateTimeField()
|
||||||
|
period_end = models.DateTimeField()
|
||||||
|
|
||||||
|
# Context
|
||||||
|
dimensions = models.JSONField(default=dict, help_text="Metric dimensions (e.g., department, provider)")
|
||||||
|
metadata = models.JSONField(default=dict, help_text="Additional metadata")
|
||||||
|
|
||||||
|
# Quality indicators
|
||||||
|
data_quality_score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
|
||||||
|
validators=[MinValueValidator(0), MaxValueValidator(100)])
|
||||||
|
confidence_level = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
|
||||||
|
validators=[MinValueValidator(0), MaxValueValidator(100)])
|
||||||
|
|
||||||
|
# Calculation details
|
||||||
|
calculation_timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
calculation_duration_ms = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'analytics_metric_value'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['metric_definition', 'period_start']),
|
||||||
|
models.Index(fields=['metric_definition', 'period_end']),
|
||||||
|
models.Index(fields=['period_start', 'period_end']),
|
||||||
|
models.Index(fields=['calculation_timestamp']),
|
||||||
|
]
|
||||||
|
unique_together = [['metric_definition', 'period_start', 'period_end']]
|
||||||
|
ordering = ['-period_start']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.metric_definition.name}: {self.value} ({self.period_start.date()})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_above_target(self):
|
||||||
|
"""Check if value is above target."""
|
||||||
|
if self.metric_definition.target_value:
|
||||||
|
return self.value >= self.metric_definition.target_value
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def threshold_status(self):
|
||||||
|
"""Get threshold status."""
|
||||||
|
if self.metric_definition.critical_threshold and self.value >= self.metric_definition.critical_threshold:
|
||||||
|
return 'CRITICAL'
|
||||||
|
elif self.metric_definition.warning_threshold and self.value >= self.metric_definition.warning_threshold:
|
||||||
|
return 'WARNING'
|
||||||
|
return 'NORMAL'
|
||||||
|
|
||||||
3
analytics/tests.py
Normal file
3
analytics/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
93
analytics/urls.py
Normal file
93
analytics/urls.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Analytics app URLs with comprehensive CRUD operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'analytics'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# ============================================================================
|
||||||
|
# DASHBOARD AND OVERVIEW
|
||||||
|
# ============================================================================
|
||||||
|
path('', views.AnalyticsDashboardView.as_view(), name='dashboard'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DASHBOARD URLS (FULL CRUD - Master Data)
|
||||||
|
# ============================================================================
|
||||||
|
path('dashboards/', views.DashboardListView.as_view(), name='dashboard_list'),
|
||||||
|
path('dashboards/create/', views.DashboardCreateView.as_view(), name='dashboard_create'),
|
||||||
|
path('dashboards/<uuid:pk>/', views.DashboardDetailView.as_view(), name='dashboard_detail'),
|
||||||
|
path('dashboards/<uuid:pk>/update/', views.DashboardUpdateView.as_view(), name='dashboard_update'),
|
||||||
|
path('dashboards/<uuid:pk>/delete/', views.DashboardDeleteView.as_view(), name='dashboard_delete'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DASHBOARD WIDGET URLS (FULL CRUD - Operational Data)
|
||||||
|
# ============================================================================
|
||||||
|
path('widgets/', views.DashboardWidgetListView.as_view(), name='dashboard_widget_list'),
|
||||||
|
path('widgets/create/', views.DashboardWidgetCreateView.as_view(), name='dashboard_widget_create'),
|
||||||
|
path('widgets/<uuid:pk>/', views.DashboardWidgetDetailView.as_view(), name='dashboard_widget_detail'),
|
||||||
|
path('widgets/<uuid:pk>/update/', views.DashboardWidgetUpdateView.as_view(), name='dashboard_widget_update'),
|
||||||
|
path('widgets/<uuid:pk>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboard_widget_delete'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DATA SOURCE URLS (FULL CRUD - Master Data)
|
||||||
|
# ============================================================================
|
||||||
|
path('data-sources/', views.DataSourceListView.as_view(), name='data_source_list'),
|
||||||
|
path('data-sources/create/', views.DataSourceCreateView.as_view(), name='data_source_create'),
|
||||||
|
path('data-sources/<uuid:pk>/', views.DataSourceDetailView.as_view(), name='data_source_detail'),
|
||||||
|
path('data-sources/<uuid:pk>/update/', views.DataSourceUpdateView.as_view(), name='data_source_update'),
|
||||||
|
path('data-sources/<uuid:pk>/delete/', views.DataSourceDeleteView.as_view(), name='data_source_delete'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# REPORT URLS (FULL CRUD - Operational Data)
|
||||||
|
# ============================================================================
|
||||||
|
path('reports/', views.ReportListView.as_view(), name='report_list'),
|
||||||
|
path('reports/create/', views.ReportCreateView.as_view(), name='report_create'),
|
||||||
|
path('reports/<uuid:pk>/', views.ReportDetailView.as_view(), name='report_detail'),
|
||||||
|
path('reports/<int:pk>/update/', views.ReportUpdateView.as_view(), name='report_update'),
|
||||||
|
path('reports/<int:pk>/delete/', views.ReportDeleteView.as_view(), name='report_delete'),
|
||||||
|
path('ajax/report-list/', views.report_list, name='report_list_data'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# REPORT EXECUTION URLS (READ-ONLY - System Generated)
|
||||||
|
# ============================================================================
|
||||||
|
path('executions/', views.ReportExecutionListView.as_view(), name='report_execution_list'),
|
||||||
|
path('executions/<uuid:pk>/', views.ReportExecutionDetailView.as_view(), name='report_execution_detail'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# METRIC DEFINITION URLS (FULL CRUD - Master Data)
|
||||||
|
# ============================================================================
|
||||||
|
path('metrics/', views.MetricDefinitionListView.as_view(), name='metric_definition_list'),
|
||||||
|
path('metrics/create/', views.MetricDefinitionCreateView.as_view(), name='metric_definition_create'),
|
||||||
|
path('metrics/<int:pk>/', views.MetricDefinitionDetailView.as_view(), name='metric_definition_detail'),
|
||||||
|
path('metrics/<int:pk>/update/', views.MetricDefinitionUpdateView.as_view(), name='metric_definition_update'),
|
||||||
|
path('metrics/<int:pk>/delete/', views.MetricDefinitionDeleteView.as_view(), name='metric_definition_delete'),
|
||||||
|
path('ajax/metric-stats/', views.metric_stats, name='metric_stats'),
|
||||||
|
path('ajax/metric-list/', views.metric_list, name='metric_list'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# METRIC VALUE URLS (READ-ONLY - System Generated)
|
||||||
|
# ============================================================================
|
||||||
|
path('metric-values/', views.MetricValueListView.as_view(), name='metric_value_list'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HTMX ENDPOINTS FOR REAL-TIME UPDATES
|
||||||
|
# ============================================================================
|
||||||
|
path('htmx/stats/', views.analytics_stats, name='analytics_stats'),
|
||||||
|
path('htmx/dashboard-search/', views.dashboard_search, name='dashboard_search'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ACTION URLS FOR WORKFLOW OPERATIONS
|
||||||
|
# ============================================================================
|
||||||
|
path('data-sources/<int:data_source_id>/test/', views.test_data_source, name='test_data_source'),
|
||||||
|
path('reports/<int:report_id>/execute/', views.execute_report, name='execute_report'),
|
||||||
|
path('metrics/<int:metric_id>/calculate/', views.calculate_metric, name='calculate_metric'),
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# API ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
path('api/', include('analytics.api.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
3160
analytics/views.py
Normal file
3160
analytics/views.py
Normal file
File diff suppressed because it is too large
Load Diff
270
analytics_data.py
Normal file
270
analytics_data.py
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hospital_management.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from analytics.models import (
|
||||||
|
Dashboard, DashboardWidget, DataSource,
|
||||||
|
Report, ReportExecution, MetricDefinition, MetricValue
|
||||||
|
)
|
||||||
|
from core.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
SAUDI_HOSPITAL_NAMES = [
|
||||||
|
"King Faisal Specialist Hospital", "King Saud Medical City", "King Abdulaziz University Hospital",
|
||||||
|
"Riyadh Military Hospital", "Dammam Central Hospital", "King Khalid Hospital", "Maternity and Children Hospital"
|
||||||
|
]
|
||||||
|
|
||||||
|
SAUDI_DEPARTMENTS = [
|
||||||
|
'Internal Medicine', 'Emergency', 'Cardiology', 'Surgery', 'Radiology',
|
||||||
|
'Pharmacy', 'Neurology', 'Obstetrics', 'ICU', 'Pediatrics'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_dashboards(tenants, users):
|
||||||
|
dashboards = []
|
||||||
|
types = [dt[0] for dt in Dashboard.DASHBOARD_TYPES]
|
||||||
|
for tenant in tenants:
|
||||||
|
for _ in range(random.randint(2, 5)):
|
||||||
|
creator = random.choice(users)
|
||||||
|
base_name = f"{random.choice(SAUDI_HOSPITAL_NAMES)} Dashboard"
|
||||||
|
name = base_name
|
||||||
|
counter = 1
|
||||||
|
while Dashboard.objects.filter(tenant=tenant, name=name).exists():
|
||||||
|
name = f"{base_name} #{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
dashboard = Dashboard.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
|
name=name,
|
||||||
|
description="Analytics for healthcare operations",
|
||||||
|
dashboard_type=random.choice(types),
|
||||||
|
layout_config={"layout": "grid"},
|
||||||
|
refresh_interval=300,
|
||||||
|
is_public=random.choice([True, False]),
|
||||||
|
is_active=True,
|
||||||
|
is_default=random.choice([True, False]),
|
||||||
|
created_by=creator
|
||||||
|
)
|
||||||
|
dashboard.allowed_users.add(creator)
|
||||||
|
dashboard.allowed_roles = [creator.role]
|
||||||
|
dashboard.save()
|
||||||
|
dashboards.append(dashboard)
|
||||||
|
print(f"✅ Created {len(dashboards)} dashboards")
|
||||||
|
return dashboards
|
||||||
|
|
||||||
|
|
||||||
|
def generate_data_sources(tenants, users):
|
||||||
|
data_sources = []
|
||||||
|
for tenant in tenants:
|
||||||
|
for _ in range(random.randint(2, 4)):
|
||||||
|
creator = random.choice(users)
|
||||||
|
base_name = f"{random.choice(SAUDI_DEPARTMENTS)} Source"
|
||||||
|
name = base_name
|
||||||
|
counter = 1
|
||||||
|
while DataSource.objects.filter(tenant=tenant, name=name).exists():
|
||||||
|
name = f"{base_name} #{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
ds = DataSource.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
|
name=name,
|
||||||
|
description=f"Source for {name}",
|
||||||
|
source_type="DATABASE",
|
||||||
|
connection_type="POSTGRESQL",
|
||||||
|
connection_config={"host": "db.hospital.local", "port": 5432},
|
||||||
|
authentication_config={"user": "readonly", "password": "secure"},
|
||||||
|
query_template="SELECT * FROM department_data WHERE department = %s",
|
||||||
|
parameters={"department": random.choice(SAUDI_DEPARTMENTS)},
|
||||||
|
data_transformation={"columns": ["metric1", "metric2"]},
|
||||||
|
cache_duration=300,
|
||||||
|
is_active=True,
|
||||||
|
is_healthy=True,
|
||||||
|
health_check_interval=300,
|
||||||
|
last_health_check=timezone.now(),
|
||||||
|
last_test_status='SUCCESS',
|
||||||
|
created_by=creator
|
||||||
|
)
|
||||||
|
data_sources.append(ds)
|
||||||
|
print(f"✅ Created {len(data_sources)} data sources")
|
||||||
|
return data_sources
|
||||||
|
|
||||||
|
|
||||||
|
def generate_widgets(dashboards, data_sources):
|
||||||
|
widgets = []
|
||||||
|
for dashboard in dashboards:
|
||||||
|
for _ in range(random.randint(2, 6)):
|
||||||
|
ds = random.choice(data_sources)
|
||||||
|
widget = DashboardWidget.objects.create(
|
||||||
|
dashboard=dashboard,
|
||||||
|
name=f"{random.choice(['Admissions', 'Lab Turnaround', 'Bed Occupancy'])} Widget",
|
||||||
|
widget_type="CHART",
|
||||||
|
chart_type=random.choice(['LINE', 'BAR', 'PIE']),
|
||||||
|
data_source=ds,
|
||||||
|
query_config={"table": "metrics"},
|
||||||
|
position_x=random.randint(0, 3),
|
||||||
|
position_y=random.randint(0, 3),
|
||||||
|
width=random.randint(3, 6),
|
||||||
|
height=random.randint(2, 4),
|
||||||
|
display_config={"color": "blue"},
|
||||||
|
auto_refresh=True,
|
||||||
|
refresh_interval=300,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
widgets.append(widget)
|
||||||
|
print(f"✅ Created {len(widgets)} widgets")
|
||||||
|
return widgets
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reports(tenants, data_sources, users):
|
||||||
|
reports = []
|
||||||
|
for tenant in tenants:
|
||||||
|
for _ in range(random.randint(2, 5)):
|
||||||
|
creator = random.choice(users)
|
||||||
|
ds = random.choice(data_sources)
|
||||||
|
|
||||||
|
base_name = f"{random.choice(['Inpatient Report', 'ER Load', 'Daily Discharges'])}"
|
||||||
|
name = base_name
|
||||||
|
counter = 1
|
||||||
|
while Report.objects.filter(tenant=tenant, name=name).exists():
|
||||||
|
name = f"{base_name} #{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
report = Report.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
|
name=name,
|
||||||
|
description=f"Auto-generated report for {name}",
|
||||||
|
report_type="CLINICAL",
|
||||||
|
data_source=ds,
|
||||||
|
query_config={"sql": "SELECT * FROM report_data"},
|
||||||
|
output_format=random.choice(["PDF", "EXCEL", "CSV"]),
|
||||||
|
template_config={"header": "Hospital Report"},
|
||||||
|
schedule_type=random.choice(["DAILY", "WEEKLY"]),
|
||||||
|
schedule_config={"time": "06:00"},
|
||||||
|
next_execution=timezone.now() + timedelta(days=1),
|
||||||
|
recipients=[creator.email],
|
||||||
|
distribution_config={"method": "EMAIL"},
|
||||||
|
is_active=True,
|
||||||
|
created_by=creator
|
||||||
|
)
|
||||||
|
reports.append(report)
|
||||||
|
print(f"✅ Created {len(reports)} reports")
|
||||||
|
return reports
|
||||||
|
|
||||||
|
|
||||||
|
def generate_report_executions(reports, users):
|
||||||
|
executions = []
|
||||||
|
for report in reports:
|
||||||
|
for _ in range(random.randint(1, 3)):
|
||||||
|
status = random.choice(['COMPLETED', 'FAILED'])
|
||||||
|
start_time = timezone.now() - timedelta(days=random.randint(1, 10))
|
||||||
|
end_time = start_time + timedelta(minutes=random.randint(1, 30))
|
||||||
|
duration = int((end_time - start_time).total_seconds())
|
||||||
|
|
||||||
|
execution = ReportExecution.objects.create(
|
||||||
|
report=report,
|
||||||
|
execution_type="SCHEDULED",
|
||||||
|
started_at=start_time,
|
||||||
|
completed_at=end_time,
|
||||||
|
duration_seconds=duration,
|
||||||
|
status=status,
|
||||||
|
error_message="" if status == 'COMPLETED' else "Timeout",
|
||||||
|
output_file_path=f"/reports/{uuid.uuid4()}.pdf",
|
||||||
|
output_size_bytes=random.randint(50000, 500000),
|
||||||
|
record_count=random.randint(100, 1000),
|
||||||
|
execution_parameters={"param1": "value"},
|
||||||
|
executed_by=random.choice(users)
|
||||||
|
)
|
||||||
|
executions.append(execution)
|
||||||
|
print(f"✅ Created {len(executions)} report executions")
|
||||||
|
return executions
|
||||||
|
|
||||||
|
|
||||||
|
def generate_metrics(tenants, data_sources, users):
|
||||||
|
metrics = []
|
||||||
|
for tenant in tenants:
|
||||||
|
for _ in range(random.randint(3, 7)):
|
||||||
|
creator = random.choice(users)
|
||||||
|
ds = random.choice(data_sources)
|
||||||
|
|
||||||
|
base_name = random.choice(['ER Wait Time', 'Readmission Rate', 'Avg Length of Stay'])
|
||||||
|
name = base_name
|
||||||
|
counter = 1
|
||||||
|
while MetricDefinition.objects.filter(tenant=tenant, name=name).exists():
|
||||||
|
name = f"{base_name} #{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
metric = MetricDefinition.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
|
name=name,
|
||||||
|
description=f"Metric tracking {name.lower()}",
|
||||||
|
metric_type="AVERAGE",
|
||||||
|
data_source=ds,
|
||||||
|
calculation_config={"sql": "SELECT AVG(value) FROM metric_table"},
|
||||||
|
aggregation_period="DAILY",
|
||||||
|
aggregation_config={"group_by": "date"},
|
||||||
|
target_value=5.0,
|
||||||
|
warning_threshold=7.0,
|
||||||
|
critical_threshold=10.0,
|
||||||
|
unit_of_measure="days",
|
||||||
|
decimal_places=2,
|
||||||
|
display_format="number",
|
||||||
|
is_active=True,
|
||||||
|
created_by=creator
|
||||||
|
)
|
||||||
|
metrics.append(metric)
|
||||||
|
print(f"✅ Created {len(metrics)} metric definitions")
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def generate_metric_values(metrics):
|
||||||
|
values = []
|
||||||
|
for metric in metrics:
|
||||||
|
for i in range(7): # one week of data
|
||||||
|
start = timezone.now() - timedelta(days=i)
|
||||||
|
value = MetricValue.objects.create(
|
||||||
|
metric_definition=metric,
|
||||||
|
value=round(random.uniform(3.0, 10.0), 2),
|
||||||
|
period_start=start.replace(hour=0, minute=0, second=0),
|
||||||
|
period_end=start.replace(hour=23, minute=59, second=59),
|
||||||
|
dimensions={"department": random.choice(SAUDI_DEPARTMENTS)},
|
||||||
|
metadata={"source": "daily_batch"},
|
||||||
|
data_quality_score=round(random.uniform(85, 100), 2),
|
||||||
|
confidence_level=round(random.uniform(90, 100), 2),
|
||||||
|
calculation_duration_ms=random.randint(50, 500)
|
||||||
|
)
|
||||||
|
values.append(value)
|
||||||
|
print(f"✅ Created {len(values)} metric values")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tenants = list(Tenant.objects.all())
|
||||||
|
users = list(User.objects.filter(is_active=True))
|
||||||
|
|
||||||
|
if not tenants or not users:
|
||||||
|
print("❌ Tenants or Users not available.")
|
||||||
|
return
|
||||||
|
|
||||||
|
dashboards = generate_dashboards(tenants, users)
|
||||||
|
data_sources = generate_data_sources(tenants, users)
|
||||||
|
widgets = generate_widgets(dashboards, data_sources)
|
||||||
|
reports = generate_reports(tenants, data_sources, users)
|
||||||
|
executions = generate_report_executions(reports, users)
|
||||||
|
metrics = generate_metrics(tenants, data_sources, users)
|
||||||
|
metric_values = generate_metric_values(metrics)
|
||||||
|
|
||||||
|
print("\n🎉 Saudi Analytics Data Generation Complete!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
appointments/__init__.py
Normal file
0
appointments/__init__.py
Normal file
BIN
appointments/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
appointments/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
appointments/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/admin.cpython-311.pyc
Normal file
BIN
appointments/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/admin.cpython-312.pyc
Normal file
BIN
appointments/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/apps.cpython-311.pyc
Normal file
BIN
appointments/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/apps.cpython-312.pyc
Normal file
BIN
appointments/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/forms.cpython-311.pyc
Normal file
BIN
appointments/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/forms.cpython-312.pyc
Normal file
BIN
appointments/__pycache__/forms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/models.cpython-311.pyc
Normal file
BIN
appointments/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/models.cpython-312.pyc
Normal file
BIN
appointments/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/urls.cpython-311.pyc
Normal file
BIN
appointments/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/urls.cpython-312.pyc
Normal file
BIN
appointments/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/views.cpython-311.pyc
Normal file
BIN
appointments/__pycache__/views.cpython-311.pyc
Normal file
Binary file not shown.
BIN
appointments/__pycache__/views.cpython-312.pyc
Normal file
BIN
appointments/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user