Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions SORT/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Customizations for Django admin site"""
# This module provides hooks for customizing the Django admin interface
# The actual customization happens through custom views and templates
1 change: 1 addition & 0 deletions SORT/test/model_factory/user/superuser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@

class SuperUserFactory(UserFactory):
is_superuser = True
is_staff = True
first_name = factory.Sequence(lambda n: f"Superuser{n}")
email = factory.Sequence(lambda n: f"superuser{n}@sort.com")
4 changes: 2 additions & 2 deletions data/survey_config/sort_only_config_ahp.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"A3. Our organisation identifies and celebrates success in the research related activity",
"A4. Our organisation provides research advice sessions where AHPs can explore ideas for project development",
"A5. Our organisation provides help to AHPs to navigate research funding submissions, ethics and governance systems",
"A6. Our organisation has a finance department that can cost research project involvement for external funding applications",
"A6. Our organisation has a finance department that can cost research project involvement for external funding applications",
"A7. Our organisation has an active research-related mentorship programme",
"A8. Our organisation provides mentorship to AHPs to successfully apply for internships and fellowship opportunities",
"A9. Our organisation enables the use of awarded grant funding in the manner intended (for example, protected time and spending decisions)",
Expand Down Expand Up @@ -173,7 +173,7 @@
"sublabels": [
"E1. Our organisation provides training for AHPs to enable them to practise effectively in a digitally enabled environment",
"E2. Our organisation trains AHPs to use and interpret data to make improvements to care (using audit, service evaluation or research)",
"E3. Our organisation has digital AHP leaders in place who can provide advice and guidance in the use of digital technology in service development",
"E3. Our organisation has digital AHP leaders in place who can provide advice and guidance in the use of digital technology in service development",
"E4. Our organisation has digital AHP leaders in place who can provide advice and guidance in the use of digital technology in research",
"E5. Our organisation has the infrastructure to support visualisation of data using business intelligence tools",
"E6. Our organisation has the internal structures that facilitate, support and enable AHP-led digital innovation",
Expand Down
2 changes: 1 addition & 1 deletion data/survey_config/sort_only_config_midwives.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"A3. Our organisation identifies and celebrates success in the midwifery research related activity",
"A4. Our organisation provides research advice sessions where midwives can explore ideas for project development",
"A5. Our organisation provides help to midwives to navigate research funding submissions, ethics and governance systems",
"A6. Our organisation has a finance department that can cost research project involvement for external funding applications",
"A6. Our organisation has a finance department that can cost research project involvement for external funding applications",
"A7. Our organisation has an active research-related mentorship programme",
"A8. Our organisation provides mentorship to midwives to successfully apply for internships and fellowship opportunities",
"A9. Our organisation enables the use of awarded grant funding in the manner intended (for example, protected time and spending decisions)",
Expand Down
62 changes: 56 additions & 6 deletions home/admin.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
from django.contrib import admin

from .models import Organisation, OrganisationMembership, Project, User
from .models import AdminAuditLog, Organisation, OrganisationMembership, Project, User


@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ("email", "first_name", "last_name", "is_staff", "date_joined")
list_display = ("email", "first_name", "last_name", "is_staff", "is_superuser",
"organisation_count", "project_count", "date_joined")
search_fields = ("email", "first_name", "last_name")
list_filter = ("is_staff", "is_superuser", "is_active")
list_filter = ("is_staff", "is_superuser", "is_active", "date_joined")
ordering = ("-date_joined",)
readonly_fields = ("date_joined", "last_login")

def organisation_count(self, obj):
"""Number of organisations user belongs to"""
return obj.organisation_set.count()
organisation_count.short_description = "Orgs"

def project_count(self, obj):
"""Number of projects user has access to"""
return sum(1 for _ in obj.projects_iter())
project_count.short_description = "Projects"

def get_readonly_fields(self, request, obj=None):
if obj: # Editing existing user
return self.readonly_fields + ("email",)
return self.readonly_fields


@admin.register(Organisation)
class OrganisationAdmin(admin.ModelAdmin):
list_display = ("pk", "name", "created_at")
search_fields = ("name", "description",)
ordering = ("name",)
list_display = ("pk", "name", "member_count", "project_count", "survey_count", "created_at")
search_fields = ("name", "description")
ordering = ("-created_at",)
readonly_fields = ("created_at",)

def member_count(self, obj):
return obj.members.count()
member_count.short_description = "Members"

def project_count(self, obj):
return obj.projects.count()
project_count.short_description = "Projects"

def survey_count(self, obj):
from survey.models import Survey
return Survey.objects.filter(project__organisation=obj).count()
survey_count.short_description = "Surveys"


@admin.register(OrganisationMembership)
Expand All @@ -30,3 +61,22 @@ class ProjectAdmin(admin.ModelAdmin):
list_display = ("pk", "name", "created_by", "created_at", "organisation")
search_fields = ("name", "description",)
list_filter = ("organisation",)


@admin.register(AdminAuditLog)
class AdminAuditLogAdmin(admin.ModelAdmin):
list_display = ("timestamp", "performed_by", "action_type", "target_model", "target_representation")
list_filter = ("action_type", "target_model", "timestamp")
search_fields = ("target_representation", "reason", "performed_by__email")
readonly_fields = ("performed_by", "action_type", "timestamp", "target_model",
"target_id", "target_representation", "reason", "metadata")
date_hierarchy = "timestamp"

def has_add_permission(self, request):
return False # Logs created programmatically only

def has_delete_permission(self, request, obj=None):
return False # Immutable

def has_change_permission(self, request, obj=None):
return False # View only
85 changes: 85 additions & 0 deletions home/migrations/0013_adminauditlog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Generated by Django 5.1.15 on 2026-02-12 12:57

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("home", "0012_alter_project_name"),
]

operations = [
migrations.CreateModel(
name="AdminAuditLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"action_type",
models.CharField(
choices=[
("DELETE_USER", "Delete User"),
("DELETE_ORG", "Delete Organisation"),
("DELETE_PROJECT", "Delete Project"),
("DELETE_SURVEY", "Delete Survey"),
("EXPORT_CONSENTED", "Export Consented Data"),
("BULK_DELETE", "Bulk Delete"),
],
max_length=30,
),
),
("timestamp", models.DateTimeField(auto_now_add=True)),
("target_model", models.CharField(max_length=50)),
("target_id", models.IntegerField(null=True)),
("target_representation", models.CharField(max_length=200)),
(
"reason",
models.TextField(
help_text="Explanation for why this action was taken"
),
),
(
"metadata",
models.JSONField(
blank=True,
help_text="Additional action details (e.g., cascade counts, export stats)",
null=True,
),
),
(
"performed_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="admin_actions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-timestamp"],
"indexes": [
models.Index(
fields=["-timestamp"], name="home_admina_timesta_4adb99_idx"
),
models.Index(
fields=["action_type"], name="home_admina_action__f02f66_idx"
),
models.Index(
fields=["target_model", "target_id"],
name="home_admina_target__b98dbf_idx",
),
],
},
),
]
50 changes: 50 additions & 0 deletions home/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,53 @@ def is_active(self) -> bool:
@property
def surveys(self):
return self.survey.all()


class AdminAuditLog(models.Model):
"""
Audit log for superuser administrative actions.
Tracks deletions, exports, and other sensitive operations.
"""

class ActionType(models.TextChoices):
DELETE_USER = "DELETE_USER", "Delete User"
DELETE_ORGANISATION = "DELETE_ORG", "Delete Organisation"
DELETE_PROJECT = "DELETE_PROJECT", "Delete Project"
DELETE_SURVEY = "DELETE_SURVEY", "Delete Survey"
EXPORT_CONSENTED = "EXPORT_CONSENTED", "Export Consented Data"
BULK_DELETE = "BULK_DELETE", "Bulk Delete"

performed_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name="admin_actions"
)
action_type = models.CharField(max_length=30, choices=ActionType.choices)
timestamp = models.DateTimeField(auto_now_add=True)

# Target object details
target_model = models.CharField(max_length=50) # "User", "Organisation", etc.
target_id = models.IntegerField(null=True)
target_representation = models.CharField(max_length=200) # str(obj) before deletion

# Justification
reason = models.TextField(help_text="Explanation for why this action was taken")

# Additional context (JSON)
metadata = models.JSONField(
null=True,
blank=True,
help_text="Additional action details (e.g., cascade counts, export stats)"
)

class Meta:
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["-timestamp"]),
models.Index(fields=["action_type"]),
models.Index(fields=["target_model", "target_id"]),
]

def __str__(self):
return f"{self.performed_by} - {self.action_type} - {self.timestamp}"
2 changes: 2 additions & 0 deletions home/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .audit import audit_service
from .base import BasePermissionService
from .organisation import OrganisationService, organisation_service
from .project import ProjectService, project_service
Expand All @@ -6,6 +7,7 @@


__all__ = [
"audit_service",
"BasePermissionService",
"ProjectService",
"OrganisationService",
Expand Down
60 changes: 60 additions & 0 deletions home/services/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Audit logging service for administrative actions"""

from typing import Dict, Optional
from django.db import models
from ..models import AdminAuditLog, User


class AuditService:
"""Service for creating audit log entries"""

@staticmethod
def log_deletion(
user: User,
target: models.Model,
reason: str,
cascade_info: Optional[Dict[str, int]] = None
) -> AdminAuditLog:
"""Log a deletion action with cascade impact details"""
action_type_map = {
"User": AdminAuditLog.ActionType.DELETE_USER,
"Organisation": AdminAuditLog.ActionType.DELETE_ORGANISATION,
"Project": AdminAuditLog.ActionType.DELETE_PROJECT,
"Survey": AdminAuditLog.ActionType.DELETE_SURVEY,
}

model_name = target.__class__.__name__
action_type = action_type_map.get(model_name, AdminAuditLog.ActionType.BULK_DELETE)

return AdminAuditLog.objects.create(
performed_by=user,
action_type=action_type,
target_model=model_name,
target_id=target.pk,
target_representation=str(target),
reason=reason,
metadata={
"cascade_deletes": cascade_info or {},
"object_pk": target.pk,
}
)

@staticmethod
def log_export(user: User, export_type: str, survey_count: int, response_count: int) -> AdminAuditLog:
"""Log data export action"""
return AdminAuditLog.objects.create(
performed_by=user,
action_type=AdminAuditLog.ActionType.EXPORT_CONSENTED,
target_model="Survey",
target_id=None,
target_representation=f"{survey_count} surveys exported",
reason=f"Exported consented data for research ({export_type})",
metadata={
"export_type": export_type,
"survey_count": survey_count,
"response_count": response_count,
}
)


audit_service = AuditService()
Loading
Loading