390 lines
12 KiB
Python
390 lines
12 KiB
Python
"""
|
|
References models - Reference Section for document management
|
|
|
|
This module implements a file server system for managing reference documents
|
|
with folder categorization, version control, and role-based access control.
|
|
"""
|
|
import os
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
|
|
from apps.core.models import TenantModel, TimeStampedModel, UUIDModel, SoftDeleteModel
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
def document_upload_path(instance, filename):
|
|
"""
|
|
Generate upload path for reference documents.
|
|
|
|
Format: references/<hospital_id>/YYYY/MM/DD/<uuid>_<filename>
|
|
"""
|
|
hospital_id = instance.hospital.id if instance.hospital else 'default'
|
|
from django.utils import timezone
|
|
date_str = timezone.now().strftime('%Y/%m/%d')
|
|
# Get file extension
|
|
ext = os.path.splitext(filename)[1]
|
|
return f'references/{hospital_id}/{date_str}/{instance.id}{ext}'
|
|
|
|
|
|
class ReferenceFolder(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel):
|
|
"""
|
|
Reference Folder model for organizing documents into hierarchical folders.
|
|
|
|
Features:
|
|
- Hospital-specific folders (via TenantModel)
|
|
- Nested folder hierarchy via self-referential parent field
|
|
- Bilingual support (English/Arabic)
|
|
- Role-based access control
|
|
- Soft delete support
|
|
"""
|
|
# Bilingual folder name
|
|
name = models.CharField(max_length=200, db_index=True)
|
|
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
|
|
|
# Description
|
|
description = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
|
|
|
|
# Hierarchy - self-referential for nested folders
|
|
parent = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='subfolders',
|
|
help_text="Parent folder for nested structure"
|
|
)
|
|
|
|
# UI customization
|
|
icon = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')"
|
|
)
|
|
color = models.CharField(
|
|
max_length=7,
|
|
blank=True,
|
|
help_text="Hex color code (e.g., '#007bff')"
|
|
)
|
|
|
|
# Ordering
|
|
order = models.IntegerField(
|
|
default=0,
|
|
help_text="Display order within parent folder"
|
|
)
|
|
|
|
# Role-based access control
|
|
access_roles = models.ManyToManyField(
|
|
'auth.Group',
|
|
blank=True,
|
|
related_name='accessible_folders',
|
|
help_text="Roles that can access this folder (empty = all roles)"
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True, db_index=True)
|
|
|
|
class Meta:
|
|
ordering = ['parent__order', 'order', 'name']
|
|
verbose_name = 'Reference Folder'
|
|
verbose_name_plural = 'Reference Folders'
|
|
indexes = [
|
|
models.Index(fields=['hospital', 'parent', 'order']),
|
|
models.Index(fields=['hospital', 'is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
hospital_name = self.hospital.name if self.hospital else 'No Hospital'
|
|
return f"{hospital_name} - {self.name}"
|
|
|
|
def get_full_path(self):
|
|
"""Get full folder path as a list of folder names"""
|
|
path = [self.name]
|
|
parent = self.parent
|
|
while parent:
|
|
path.insert(0, parent.name)
|
|
parent = parent.parent
|
|
return ' / '.join(path)
|
|
|
|
def has_access(self, user):
|
|
"""
|
|
Check if user has access to this folder.
|
|
|
|
Returns True if:
|
|
- Folder has no access_roles (public to all)
|
|
- User belongs to any of the access_roles
|
|
"""
|
|
if not self.access_roles.exists():
|
|
return True
|
|
return user.groups.filter(id__in=self.access_roles.all()).exists()
|
|
|
|
def get_subfolders(self):
|
|
"""Get all immediate subfolders"""
|
|
return self.subfolders.filter(is_active=True, is_deleted=False)
|
|
|
|
def get_documents(self):
|
|
"""Get all documents in this folder"""
|
|
return self.documents.filter(is_published=True, is_deleted=False)
|
|
|
|
def get_document_count(self):
|
|
"""Get count of documents in this folder (including subfolders)"""
|
|
count = self.get_documents().count()
|
|
for subfolder in self.get_subfolders():
|
|
count += subfolder.get_document_count()
|
|
return count
|
|
|
|
|
|
class ReferenceDocument(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel):
|
|
"""
|
|
Reference Document model for storing and managing documents.
|
|
|
|
Features:
|
|
- Multi-version support
|
|
- Multiple file types (PDF, Word, Excel, etc.)
|
|
- Role-based access control
|
|
- Download tracking
|
|
- Bilingual support
|
|
- Soft delete support
|
|
"""
|
|
# Folder association
|
|
folder = models.ForeignKey(
|
|
ReferenceFolder,
|
|
on_delete=models.CASCADE,
|
|
related_name='documents',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Folder containing this document"
|
|
)
|
|
|
|
# Bilingual title
|
|
title = models.CharField(max_length=500, db_index=True)
|
|
title_ar = models.CharField(max_length=500, blank=True, verbose_name="Title (Arabic)")
|
|
|
|
# File information
|
|
file = models.FileField(upload_to=document_upload_path, max_length=500)
|
|
filename = models.CharField(max_length=500, help_text="Original filename")
|
|
file_type = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
help_text="File extension/type (e.g., pdf, docx, xlsx)"
|
|
)
|
|
file_size = models.IntegerField(help_text="File size in bytes")
|
|
|
|
# Description
|
|
description = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
|
|
|
|
# Versioning
|
|
version = models.CharField(
|
|
max_length=20,
|
|
default='1.0',
|
|
help_text="Document version (e.g., 1.0, 1.1, 2.0)"
|
|
)
|
|
is_latest_version = models.BooleanField(
|
|
default=True,
|
|
db_index=True,
|
|
help_text="Is this the latest version?"
|
|
)
|
|
parent_document = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='versions',
|
|
help_text="Previous version of this document"
|
|
)
|
|
|
|
# Upload tracking
|
|
uploaded_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name='uploaded_documents'
|
|
)
|
|
|
|
# Usage tracking
|
|
download_count = models.IntegerField(default=0, help_text="Number of downloads")
|
|
last_accessed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Visibility
|
|
is_published = models.BooleanField(
|
|
default=True,
|
|
db_index=True,
|
|
help_text="Is this document visible to users?"
|
|
)
|
|
|
|
# Role-based access control
|
|
access_roles = models.ManyToManyField(
|
|
'auth.Group',
|
|
blank=True,
|
|
related_name='accessible_documents',
|
|
help_text="Roles that can access this document (empty = all roles)"
|
|
)
|
|
|
|
# Tags for search
|
|
tags = models.CharField(
|
|
max_length=500,
|
|
blank=True,
|
|
help_text="Comma-separated tags for search (e.g., policy, procedure, handbook)"
|
|
)
|
|
|
|
# Additional metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['title', '-created_at']
|
|
verbose_name = 'Reference Document'
|
|
verbose_name_plural = 'Reference Documents'
|
|
indexes = [
|
|
models.Index(fields=['hospital', 'folder', 'is_latest_version']),
|
|
models.Index(fields=['hospital', 'is_published']),
|
|
models.Index(fields=['folder', 'title']),
|
|
]
|
|
|
|
def __str__(self):
|
|
title = self.title or self.filename
|
|
version = f" v{self.version}" if self.version else ""
|
|
return f"{title}{version}"
|
|
|
|
def clean(self):
|
|
"""Validate file type"""
|
|
if self.file:
|
|
ext = os.path.splitext(self.file.name)[1].lower().lstrip('.')
|
|
# Allowed file types - can be extended as needed
|
|
allowed_types = [
|
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
|
'txt', 'rtf', 'csv', 'jpg', 'jpeg', 'png', 'gif'
|
|
]
|
|
if ext not in allowed_types:
|
|
raise ValidationError({
|
|
'file': f'File type "{ext}" is not allowed. Allowed types: {", ".join(allowed_types)}'
|
|
})
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Auto-populate file_type and file_size"""
|
|
if self.file:
|
|
# Extract file extension
|
|
ext = os.path.splitext(self.file.name)[1].lower().lstrip('.')
|
|
self.file_type = ext
|
|
|
|
# Get file size if not already set
|
|
if not self.file_size and hasattr(self.file, 'size'):
|
|
self.file_size = self.file.size
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
def has_access(self, user):
|
|
"""
|
|
Check if user has access to this document.
|
|
|
|
Returns True if:
|
|
- Document has no access_roles (public to all)
|
|
- User belongs to any of the access_roles
|
|
"""
|
|
if not self.access_roles.exists():
|
|
return True
|
|
return user.groups.filter(id__in=self.access_roles.all()).exists()
|
|
|
|
def increment_download_count(self):
|
|
"""Increment download count and update last_accessed_at"""
|
|
self.download_count += 1
|
|
from django.utils import timezone
|
|
self.last_accessed_at = timezone.now()
|
|
self.save(update_fields=['download_count', 'last_accessed_at'])
|
|
|
|
def get_tags_list(self):
|
|
"""Get tags as a list"""
|
|
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
|
|
|
def get_file_icon(self):
|
|
"""Get icon class based on file type"""
|
|
icon_map = {
|
|
'pdf': 'fa-file-pdf text-danger',
|
|
'doc': 'fa-file-word text-primary',
|
|
'docx': 'fa-file-word text-primary',
|
|
'xls': 'fa-file-excel text-success',
|
|
'xlsx': 'fa-file-excel text-success',
|
|
'ppt': 'fa-file-powerpoint text-warning',
|
|
'pptx': 'fa-file-powerpoint text-warning',
|
|
'jpg': 'fa-file-image text-info',
|
|
'jpeg': 'fa-file-image text-info',
|
|
'png': 'fa-file-image text-info',
|
|
'gif': 'fa-file-image text-info',
|
|
'txt': 'fa-file-lines text-secondary',
|
|
}
|
|
return icon_map.get(self.file_type.lower(), 'fa-file text-secondary')
|
|
|
|
def get_file_size_display(self):
|
|
"""Get human-readable file size"""
|
|
size = self.file_size
|
|
for unit in ['B', 'KB', 'MB', 'GB']:
|
|
if size < 1024:
|
|
return f"{size:.2f} {unit}"
|
|
size /= 1024
|
|
return f"{size:.2f} TB"
|
|
|
|
def get_version_history(self):
|
|
"""Get all versions of this document"""
|
|
versions = list(self.versions.all())
|
|
versions.append(self)
|
|
return sorted(versions, key=lambda x: x.version, reverse=True)
|
|
|
|
|
|
class ReferenceDocumentAccess(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Reference Document Access model for tracking document access.
|
|
|
|
Features:
|
|
- Audit trail for document downloads/views
|
|
- Track user actions on documents
|
|
- IP address logging
|
|
"""
|
|
ACTION_CHOICES = [
|
|
('view', 'Viewed'),
|
|
('download', 'Downloaded'),
|
|
('preview', 'Previewed'),
|
|
]
|
|
|
|
# Document reference
|
|
document = models.ForeignKey(
|
|
ReferenceDocument,
|
|
on_delete=models.CASCADE,
|
|
related_name='access_logs'
|
|
)
|
|
|
|
# User who accessed the document
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name='document_accesses'
|
|
)
|
|
|
|
# Action type
|
|
action = models.CharField(
|
|
max_length=20,
|
|
choices=ACTION_CHOICES,
|
|
db_index=True
|
|
)
|
|
|
|
# Context information
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
verbose_name = 'Document Access Log'
|
|
verbose_name_plural = 'Document Access Logs'
|
|
indexes = [
|
|
models.Index(fields=['document', '-created_at']),
|
|
models.Index(fields=['user', '-created_at']),
|
|
models.Index(fields=['action', '-created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
user_str = self.user.email if self.user else 'Anonymous'
|
|
return f"{user_str} - {self.get_action_display()} - {self.document}"
|