420 lines
19 KiB
Python
420 lines
19 KiB
Python
"""
|
|
Tests for the QI Projects app.
|
|
|
|
Covers the explicit team-membership model:
|
|
- `team_members` is selected explicitly (managers/champions of linked
|
|
departments), NOT auto-derived from `departments` anymore.
|
|
- The lead must be a role-holder of a selected department or PX Management/Employee.
|
|
- ACL: only admins / project lead / team members can view & manage.
|
|
- Task `assigned_to` is limited to team members.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import patch
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.test import TestCase
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from apps.organizations.models import Department, Hospital, Staff
|
|
from apps.projects.models import QIProject, QIProjectTask
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
def _make_staff(hospital, department=None, first="Staff", email=None):
|
|
return Staff.objects.create(
|
|
hospital=hospital,
|
|
department=department,
|
|
first_name=first,
|
|
last_name="Test",
|
|
status="active",
|
|
staff_type="other",
|
|
job_title="T",
|
|
employee_id=f"EMP-{first}-{Hospital.objects.count()}",
|
|
user=User.objects.create_user(
|
|
username=email or f"{first.lower()}@test.local",
|
|
email=email or f"{first.lower()}@test.local",
|
|
password="pw",
|
|
),
|
|
)
|
|
|
|
|
|
class ExplicitTeamTests(TestCase):
|
|
"""Team membership is now explicit — `departments` changes do not rebuild it."""
|
|
|
|
def setUp(self):
|
|
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
|
self.dept_a = Department.objects.create(hospital=self.hospital, name="Dept A", code="DA", status="active")
|
|
self.dept_b = Department.objects.create(hospital=self.hospital, name="Dept B", code="DB", status="active")
|
|
self.champ_a = _make_staff(self.hospital, self.dept_a, "ChampA")
|
|
self.champ_b = _make_staff(self.hospital, self.dept_b, "ChampB")
|
|
self.dept_a.champion = self.champ_a
|
|
self.dept_a.save()
|
|
self.dept_b.champion = self.champ_b
|
|
self.dept_b.save()
|
|
self.lead = _make_staff(self.hospital, self.dept_a, "Lead")
|
|
|
|
def _new_project(self, **kw):
|
|
defaults = dict(
|
|
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
|
|
)
|
|
defaults.update(kw)
|
|
return QIProject.objects.create(**defaults)
|
|
|
|
def test_team_members_set_explicitly(self):
|
|
p = self._new_project()
|
|
p.departments.set([self.dept_a, self.dept_b])
|
|
p.team_members.set([self.champ_a, self.champ_b, self.lead])
|
|
self.assertEqual(
|
|
set(p.team_members.values_list("pk", flat=True)),
|
|
{self.champ_a.pk, self.champ_b.pk, self.lead.pk},
|
|
)
|
|
|
|
def test_departments_change_does_not_rebuild_team(self):
|
|
"""The m2m_changed auto-derive signal has been removed."""
|
|
p = self._new_project()
|
|
p.team_members.set([self.champ_a])
|
|
# Linking a new department must NOT silently add its champion.
|
|
p.departments.set([self.dept_a, self.dept_b])
|
|
self.assertEqual(set(p.team_members.values_list("pk", flat=True)), {self.champ_a.pk})
|
|
# And removing a department must not strip existing members.
|
|
p.departments.remove(self.dept_b)
|
|
self.assertEqual(set(p.team_members.values_list("pk", flat=True)), {self.champ_a.pk})
|
|
|
|
def test_is_team_member_helper(self):
|
|
p = self._new_project()
|
|
p.team_members.set([self.champ_a])
|
|
self.assertTrue(p.is_team_member(self.champ_a.user))
|
|
self.assertFalse(p.is_team_member(self.champ_b.user))
|
|
# The lead is always considered a team member (checked via project_lead_id)
|
|
self.assertTrue(p.is_team_member(self.lead.user))
|
|
|
|
|
|
class ACLTests(TestCase):
|
|
"""_check_project_permission + _get_can_edit via the views."""
|
|
|
|
def setUp(self):
|
|
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
|
self.hospital2 = Hospital.objects.create(name="H2", code="H2", status="active")
|
|
self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
|
|
self.champ = _make_staff(self.hospital, self.dept, "Champ")
|
|
self.outsider = _make_staff(self.hospital, self.dept, "Outsider")
|
|
self.dept.champion = self.champ
|
|
self.dept.save()
|
|
self.lead = _make_staff(self.hospital, self.dept, "Lead")
|
|
self.project = QIProject.objects.create(
|
|
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
|
|
)
|
|
self.project.departments.set([self.dept])
|
|
self.project.team_members.set([self.champ, self.lead]) # explicit (views force-add the lead)
|
|
|
|
def test_team_member_can_view_detail(self):
|
|
# ACL helper level (view-level rendering is blocked by a pre-existing
|
|
# staticfiles-manifest issue in the test env, unrelated to this feature).
|
|
from apps.projects.ui_views import _check_project_permission, _get_can_edit
|
|
|
|
self.assertTrue(_check_project_permission(self.project, self.champ.user))
|
|
self.assertFalse(_get_can_edit(self.champ.user, self.project)) # not lead/admin
|
|
|
|
def test_non_team_same_hospital_user_is_denied(self):
|
|
from apps.projects.ui_views import _check_project_permission
|
|
|
|
# Outsider is same-hospital but NOT a team member → denied
|
|
self.assertFalse(_check_project_permission(self.project, self.outsider.user))
|
|
|
|
def test_project_lead_can_edit(self):
|
|
from apps.projects.ui_views import _get_can_edit
|
|
|
|
# Lead is not an admin but is the project lead → can edit
|
|
self.assertTrue(_get_can_edit(self.lead.user, self.project))
|
|
|
|
def test_task_assigned_to_is_limited_to_team_members(self):
|
|
from apps.projects.forms import QIProjectTaskForm
|
|
|
|
form = QIProjectTaskForm(project=self.project)
|
|
qs_ids = set(form.fields["assigned_to"].queryset.values_list("pk", flat=True))
|
|
self.assertEqual(qs_ids, {self.champ.pk, self.lead.pk})
|
|
self.assertNotIn(self.outsider.pk, qs_ids)
|
|
|
|
def test_task_toggle_requires_post(self):
|
|
task = QIProjectTask.objects.create(project=self.project, title="t", assigned_to=self.champ, status="pending")
|
|
self.client.force_login(self.champ.user)
|
|
url = reverse("projects:task_toggle_status",
|
|
kwargs={"project_pk": self.project.pk, "task_pk": task.pk})
|
|
# GET must be rejected (405) — CSRF-safe toggle
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 405)
|
|
|
|
def test_champion_of_linked_dept_can_view_even_if_not_team_member(self):
|
|
"""A champion of a linked department can view the project even when not in team_members."""
|
|
from apps.projects.ui_views import _check_project_permission
|
|
|
|
dept_b = Department.objects.create(hospital=self.hospital, name="D2", code="D2", status="active")
|
|
champ_b = _make_staff(self.hospital, dept_b, "ChampB")
|
|
dept_b.champion = champ_b
|
|
dept_b.save()
|
|
self.project.departments.add(dept_b)
|
|
# champ_b is NOT a team member
|
|
self.assertFalse(self.project.is_team_member(champ_b.user))
|
|
# ...but is champion of a linked department → may view
|
|
self.assertTrue(_check_project_permission(self.project, champ_b.user))
|
|
|
|
def test_project_modal_endpoint_allows_champion(self):
|
|
"""The department_record_project AJAX endpoint must not 403 a linked-dept champion."""
|
|
self.client.force_login(self.champ.user)
|
|
url = reverse("organizations:department_record_project", args=[self.project.pk])
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(resp.json()["type"], "project")
|
|
|
|
|
|
class QITaskNotesAttachmentsTests(TestCase):
|
|
"""Notes (core.Note) + AJAX attachments on QI tasks."""
|
|
|
|
def setUp(self):
|
|
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
|
self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
|
|
self.champ = _make_staff(self.hospital, self.dept, "Champ")
|
|
self.lead = _make_staff(self.hospital, self.dept, "Lead")
|
|
self.project = QIProject.objects.create(
|
|
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
|
|
)
|
|
self.project.departments.set([self.dept])
|
|
self.project.team_members.set([self.champ, self.lead])
|
|
self.task = QIProjectTask.objects.create(
|
|
project=self.project, title="T", assigned_to=self.champ, status="pending"
|
|
)
|
|
|
|
def _file(self, name="doc.pdf", content=b"%PDF-1.4"):
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
return SimpleUploadedFile(name, content, content_type="application/pdf")
|
|
|
|
def test_note_appears_on_task(self):
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from apps.core.models import Note
|
|
|
|
ct = ContentType.objects.get_for_model(QIProjectTask)
|
|
Note.objects.create(content_type=ct, object_id=self.task.id, note="hello", created_by=self.lead.user)
|
|
self.assertEqual(self.task.notes.count(), 1)
|
|
self.assertEqual(self.task.notes.first().note, "hello")
|
|
|
|
def test_contributor_can_upload_attachment(self):
|
|
self.client.force_login(self.lead.user)
|
|
url = reverse("projects:task_attachment_upload", args=[self.task.pk])
|
|
resp = self.client.post(url, {"file": self._file(), "description": "evidence"})
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertTrue(resp.json()["success"])
|
|
self.assertEqual(self.task.attachments.count(), 1)
|
|
att = self.task.attachments.first()
|
|
self.assertEqual(att.uploaded_by, self.lead.user)
|
|
self.assertEqual(att.filename, "doc.pdf")
|
|
self.assertGreater(att.file_size, 0)
|
|
|
|
def test_non_contributor_cannot_upload(self):
|
|
# Champion-of-linked-dept who is NOT a team member → can view but not upload.
|
|
dept_b = Department.objects.create(hospital=self.hospital, name="D2", code="D2", status="active")
|
|
champ_b = _make_staff(self.hospital, dept_b, "ChampB")
|
|
dept_b.champion = champ_b
|
|
dept_b.save()
|
|
self.project.departments.add(dept_b)
|
|
self.project.team_members.remove(champ_b)
|
|
self.client.force_login(champ_b.user)
|
|
url = reverse("projects:task_attachment_upload", args=[self.task.pk])
|
|
resp = self.client.post(url, {"file": self._file()})
|
|
self.assertEqual(resp.status_code, 403)
|
|
self.assertEqual(self.task.attachments.count(), 0)
|
|
|
|
def test_oversized_file_rejected(self):
|
|
self.client.force_login(self.lead.user)
|
|
url = reverse("projects:task_attachment_upload", args=[self.task.pk])
|
|
big = self._file(content=b"x" * (10 * 1024 * 1024 + 1))
|
|
resp = self.client.post(url, {"file": big})
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertFalse(resp.json()["success"])
|
|
self.assertEqual(self.task.attachments.count(), 0)
|
|
|
|
def test_contributor_can_delete_attachment(self):
|
|
from apps.projects.models import QIProjectTaskAttachment
|
|
att = QIProjectTaskAttachment.objects.create(task=self.task, file=self._file(), uploaded_by=self.lead.user)
|
|
self.client.force_login(self.champ.user) # champ is a team member → contributor
|
|
url = reverse("projects:task_attachment_delete", args=[self.task.pk, att.pk])
|
|
resp = self.client.post(url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertTrue(resp.json()["success"])
|
|
self.assertEqual(self.task.attachments.count(), 0)
|
|
|
|
|
|
class QITaskNotificationTests(TestCase):
|
|
"""Assignment email + deadline reminders for QI tasks."""
|
|
|
|
def setUp(self):
|
|
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
|
self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
|
|
self.champ = _make_staff(self.hospital, self.dept, "Champ")
|
|
self.lead = _make_staff(self.hospital, self.dept, "Lead")
|
|
self.dept.champion = self.champ
|
|
self.dept.save()
|
|
self.project = QIProject.objects.create(
|
|
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
|
|
)
|
|
self.project.departments.set([self.dept])
|
|
self.project.team_members.set([self.champ, self.lead])
|
|
|
|
def test_assignment_sends_email_to_assignee(self):
|
|
from apps.notifications.models import NotificationLog
|
|
|
|
task = QIProjectTask.objects.create(
|
|
project=self.project, title="My Task", assigned_to=self.champ, status="pending"
|
|
)
|
|
log = NotificationLog.objects.filter(
|
|
recipient=self.champ.user.email, subject__startswith="New QI Task"
|
|
).first()
|
|
self.assertIsNotNone(log, "Assignment should create an email notification log")
|
|
self.assertEqual(log.metadata.get("notification_type"), "qi_task_assigned")
|
|
|
|
def test_reassignment_does_not_notify(self):
|
|
other = _make_staff(self.hospital, self.dept, "Other")
|
|
self.project.team_members.add(other)
|
|
from apps.notifications.models import NotificationLog
|
|
|
|
task = QIProjectTask.objects.create(
|
|
project=self.project, title="T", assigned_to=self.champ, status="pending"
|
|
)
|
|
# Reassign (update, not create) — signal must stay silent
|
|
task.assigned_to = other
|
|
task.save(update_fields=["assigned_to"])
|
|
new_logs = NotificationLog.objects.filter(recipient=other.user.email, subject__startswith="New QI Task")
|
|
self.assertFalse(new_logs.exists(), "Reassignment must not trigger a new-assignment email")
|
|
|
|
def test_first_reminder_fires_and_dedups(self):
|
|
from apps.notifications.models import NotificationLog
|
|
from apps.projects.tasks import send_qi_task_reminders
|
|
|
|
frozen = timezone.make_aware(datetime(2026, 1, 15, 12, 0, 0), timezone.get_current_timezone())
|
|
task = QIProjectTask.objects.create(
|
|
project=self.project, title="Due Today", assigned_to=self.champ, status="pending",
|
|
due_date=datetime(2026, 1, 15).date(), # today → within 24h window
|
|
)
|
|
with patch("apps.projects.tasks.timezone.now", return_value=frozen):
|
|
result = send_qi_task_reminders.apply().get()
|
|
|
|
self.assertEqual(result["first_reminder_count"], 1)
|
|
task.refresh_from_db()
|
|
self.assertIsNotNone(task.reminder_sent_at)
|
|
self.assertTrue(
|
|
NotificationLog.objects.filter(
|
|
recipient=self.champ.user.email, subject__startswith="QI Task Reminder"
|
|
).exists()
|
|
)
|
|
|
|
# Second run must not re-send (deduped via reminder_sent_at)
|
|
with patch("apps.projects.tasks.timezone.now", return_value=frozen):
|
|
result2 = send_qi_task_reminders.apply().get()
|
|
self.assertEqual(result2["first_reminder_count"], 0)
|
|
|
|
def test_second_reminder_fires(self):
|
|
from apps.projects.tasks import send_qi_task_reminders
|
|
|
|
frozen = timezone.make_aware(datetime(2026, 1, 15, 18, 0, 0), timezone.get_current_timezone())
|
|
task = QIProjectTask.objects.create(
|
|
project=self.project, title="Due Today", assigned_to=self.champ, status="pending",
|
|
due_date=datetime(2026, 1, 15).date(),
|
|
reminder_sent_at=frozen - timedelta(hours=2), # first reminder already sent
|
|
)
|
|
with patch("apps.projects.tasks.timezone.now", return_value=frozen):
|
|
result = send_qi_task_reminders.apply().get()
|
|
|
|
self.assertEqual(result["second_reminder_count"], 1)
|
|
task.refresh_from_db()
|
|
self.assertIsNotNone(task.second_reminder_sent_at)
|
|
|
|
|
|
class ProjectCreationNotificationTests(TestCase):
|
|
"""Lead + team members are emailed when a project is created."""
|
|
|
|
def setUp(self):
|
|
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
|
self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
|
|
self.lead = _make_staff(self.hospital, self.dept, "Lead")
|
|
self.member = _make_staff(self.hospital, self.dept, "Member")
|
|
self.project = QIProject.objects.create(
|
|
name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
|
|
)
|
|
|
|
def test_lead_and_team_members_notified(self):
|
|
from apps.notifications.models import NotificationLog
|
|
from apps.projects.ui_views import _notify_project_team
|
|
|
|
self.project.team_members.set([self.lead, self.member])
|
|
_notify_project_team(self.project)
|
|
|
|
for staff in (self.lead, self.member):
|
|
exists = NotificationLog.objects.filter(
|
|
recipient=staff.user.email, subject__startswith="New QI Project"
|
|
).exists()
|
|
self.assertTrue(exists, f"Expected notification for {staff.user.email}")
|
|
|
|
def test_no_recipients_is_noop(self):
|
|
from apps.notifications.models import NotificationLog
|
|
from apps.projects.ui_views import _notify_project_team
|
|
|
|
bare = QIProject.objects.create(
|
|
name="Bare", description="d", hospital=self.hospital, status="pending"
|
|
) # no lead, no team
|
|
_notify_project_team(bare) # must not raise
|
|
self.assertEqual(
|
|
NotificationLog.objects.filter(subject__startswith="New QI Project").count(), 0
|
|
)
|
|
|
|
|
|
class ConvertActionTeamTests(TestCase):
|
|
"""Converting a PX Action to a project must NOT clobber the explicit team.
|
|
|
|
Under the explicit-team model, the converted project's team is just {lead}
|
|
(the form doesn't collect team_members). The old code called
|
|
sync_team_members_from_departments() which overwrote team_members with the
|
|
auto-derived set.
|
|
"""
|
|
|
|
def setUp(self):
|
|
from django.contrib.auth.models import Group
|
|
|
|
self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
|
|
self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
|
|
# A department champion who would have been auto-pulled by the old sync
|
|
self.champ = _make_staff(self.hospital, self.dept, "Champ")
|
|
self.dept.champion = self.champ
|
|
self.dept.save()
|
|
self.lead = _make_staff(self.hospital, self.dept, "Lead")
|
|
|
|
# Hospital Admin user scoped to the hospital (avoids the px-admin tenant-hospital redirect)
|
|
self.admin = User.objects.create_user(
|
|
username="admin@x", email="admin@x", password="p"
|
|
)
|
|
self.admin.hospital = self.hospital
|
|
self.admin.save()
|
|
self.admin.groups.add(Group.objects.get_or_create(name="Hospital Admin")[0])
|
|
|
|
from apps.px_action_center.models import PXAction
|
|
self.action = PXAction.objects.create(
|
|
title="Action", description="d", hospital=self.hospital
|
|
)
|
|
|
|
def test_convert_sets_team_to_lead_only(self):
|
|
self.client.force_login(self.admin)
|
|
url = reverse("projects:convert_action", args=[self.action.pk])
|
|
resp = self.client.post(url, {
|
|
"project_name": "Converted Project",
|
|
"project_lead": str(self.lead.pk),
|
|
})
|
|
self.assertEqual(resp.status_code, 302)
|
|
|
|
project = QIProject.objects.get(name="Converted Project")
|
|
self.assertEqual(project.project_lead_id, self.lead.pk)
|
|
team_ids = set(project.team_members.values_list("id", flat=True))
|
|
self.assertEqual(team_ids, {self.lead.pk})
|
|
# The department champion must NOT have been auto-pulled in
|
|
self.assertNotIn(self.champ.pk, team_ids)
|