#!/usr/bin/env python
# camcops_server/cc_modules/cc_patient.py
"""
===============================================================================
Copyright (C) 2012-2018 Rudolf Cardinal (rudolf@pobox.com).
This file is part of CamCOPS.
CamCOPS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CamCOPS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CamCOPS. If not, see <http://www.gnu.org/licenses/>.
===============================================================================
"""
import logging
from typing import (Any, Dict, Generator, List, Optional, Set, TYPE_CHECKING,
Union)
from cardinal_pythonlib.classes import classproperty
from cardinal_pythonlib.datetimefunc import (
coerce_to_pendulum_date,
format_datetime,
get_age,
PotentialDatetimeType,
)
from cardinal_pythonlib.logs import BraceStyleAdapter
import cardinal_pythonlib.rnc_web as ws
import hl7
import pendulum
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Session as SqlASession
from sqlalchemy.orm.relationships import RelationshipProperty
from sqlalchemy.sql.expression import and_, ClauseElement, select
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.selectable import SelectBase
from sqlalchemy.sql import sqltypes
from sqlalchemy.sql.sqltypes import Integer, UnicodeText
from .cc_audit import audit
from .cc_constants import (
DateFormat,
ERA_NOW,
FP_ID_DESC,
FP_ID_SHORT_DESC,
FP_ID_NUM,
TSV_PATIENT_FIELD_PREFIX,
)
from .cc_db import GenericTabletRecordMixin
from .cc_hl7core import make_pid_segment
from .cc_html import answer
from .cc_simpleobjects import BarePatientInfo, HL7PatientIdentifier
from .cc_patientidnum import PatientIdNum
from .cc_policy import TokenizedPolicy
from .cc_recipdef import RecipientDefinition
from .cc_report import Report
from .cc_request import CamcopsRequest
from .cc_simpleobjects import IdNumReference
from .cc_specialnote import SpecialNote
from .cc_sqla_coltypes import (
CamcopsColumn,
PatientNameColType,
SexColType,
)
from .cc_sqlalchemy import Base
from .cc_tsv import TsvPage
from .cc_unittest import DemoDatabaseTestCase
from .cc_version import CAMCOPS_SERVER_VERSION_STRING
from .cc_xml import XML_COMMENT_SPECIAL_NOTES, XmlElement
if TYPE_CHECKING:
from .cc_group import Group
log = BraceStyleAdapter(logging.getLogger(__name__))
# =============================================================================
# Patient class
# =============================================================================
[docs]class Patient(GenericTabletRecordMixin, Base):
"""
Class representing a patient.
"""
__tablename__ = "patient"
id = Column(
"id", Integer,
nullable=False,
comment="Primary key (patient ID) on the source tablet device"
# client PK
)
forename = CamcopsColumn(
"forename", PatientNameColType,
index=True,
identifies_patient=True, cris_include=True,
comment="Forename"
)
surname = CamcopsColumn(
"surname", PatientNameColType,
index=True,
identifies_patient=True, cris_include=True,
comment="Surname"
)
dob = CamcopsColumn(
"dob", sqltypes.Date, # verified: merge_db handles this correctly
index=True,
identifies_patient=True, cris_include=True,
comment="Date of birth"
# ... e.g. "2013-02-04"
)
sex = CamcopsColumn(
"sex", SexColType,
index=True,
cris_include=True,
comment="Sex (M, F, X)"
)
address = CamcopsColumn(
"address", UnicodeText,
identifies_patient=True,
comment="Address"
)
gp = Column(
"gp", UnicodeText,
comment="General practitioner (GP)"
)
other = CamcopsColumn(
"other", UnicodeText,
identifies_patient=True,
comment="Other details"
)
idnums = relationship(
# http://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-custom-foreign
# http://docs.sqlalchemy.org/en/latest/orm/relationship_api.html#sqlalchemy.orm.relationship # noqa
# http://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-primaryjoin # noqa
"PatientIdNum",
primaryjoin=(
"and_("
" remote(PatientIdNum.patient_id) == foreign(Patient.id), "
" remote(PatientIdNum._device_id) == foreign(Patient._device_id), "
" remote(PatientIdNum._era) == foreign(Patient._era), "
" remote(PatientIdNum._current) == True "
")"
),
uselist=True,
viewonly=True,
# Not profiled - any benefit unclear # lazy="joined"
)
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# THE FOLLOWING ARE DEFUNCT, AND THE SERVER WORKS AROUND OLD TABLETS IN
# THE UPLOAD API.
#
# idnum1 = Column("idnum1", BigInteger, comment="ID number 1")
# idnum2 = Column("idnum2", BigInteger, comment="ID number 2")
# idnum3 = Column("idnum3", BigInteger, comment="ID number 3")
# idnum4 = Column("idnum4", BigInteger, comment="ID number 4")
# idnum5 = Column("idnum5", BigInteger, comment="ID number 5")
# idnum6 = Column("idnum6", BigInteger, comment="ID number 6")
# idnum7 = Column("idnum7", BigInteger, comment="ID number 7")
# idnum8 = Column("idnum8", BigInteger, comment="ID number 8")
#
# iddesc1 = Column("iddesc1", IdDescriptorColType, comment="ID description 1") # noqa
# iddesc2 = Column("iddesc2", IdDescriptorColType, comment="ID description 2") # noqa
# iddesc3 = Column("iddesc3", IdDescriptorColType, comment="ID description 3") # noqa
# iddesc4 = Column("iddesc4", IdDescriptorColType, comment="ID description 4") # noqa
# iddesc5 = Column("iddesc5", IdDescriptorColType, comment="ID description 5") # noqa
# iddesc6 = Column("iddesc6", IdDescriptorColType, comment="ID description 6") # noqa
# iddesc7 = Column("iddesc7", IdDescriptorColType, comment="ID description 7") # noqa
# iddesc8 = Column("iddesc8", IdDescriptorColType, comment="ID description 8") # noqa
#
# idshortdesc1 = Column("idshortdesc1", IdDescriptorColType, comment="ID short description 1") # noqa
# idshortdesc2 = Column("idshortdesc2", IdDescriptorColType, comment="ID short description 2") # noqa
# idshortdesc3 = Column("idshortdesc3", IdDescriptorColType, comment="ID short description 3") # noqa
# idshortdesc4 = Column("idshortdesc4", IdDescriptorColType, comment="ID short description 4") # noqa
# idshortdesc5 = Column("idshortdesc5", IdDescriptorColType, comment="ID short description 5") # noqa
# idshortdesc6 = Column("idshortdesc6", IdDescriptorColType, comment="ID short description 6") # noqa
# idshortdesc7 = Column("idshortdesc7", IdDescriptorColType, comment="ID short description 7") # noqa
# idshortdesc8 = Column("idshortdesc8", IdDescriptorColType, comment="ID short description 8") # noqa
#
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Relationships
# noinspection PyMethodParameters
@declared_attr
def special_notes(cls) -> RelationshipProperty:
# The SpecialNote also allows a link to patients, not just tasks,
# like this:
return relationship(
SpecialNote,
primaryjoin=(
"and_("
" remote(SpecialNote.basetable) == literal({repr_patient_tablename}), " # noqa
" remote(SpecialNote.task_id) == foreign(Patient.id), "
" remote(SpecialNote.device_id) == foreign(Patient._device_id), " # noqa
" remote(SpecialNote.era) == foreign(Patient._era) "
")".format(
repr_patient_tablename=repr(cls.__tablename__),
)
),
uselist=True,
order_by="SpecialNote.note_at",
viewonly=True, # for now!
)
@classmethod
def get_patients_by_idnum(cls,
dbsession: SqlASession,
which_idnum: int,
idnum_value: int,
group_id: int = None,
current_only: bool = True) -> List['Patient']:
if not which_idnum or which_idnum < 1:
return []
if idnum_value is None:
return []
q = dbsession.query(cls).join(cls.idnums)
# ... the join pre-restricts to current ID numbers
# http://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#using-custom-operators-in-join-conditions # noqa
q = q.filter(PatientIdNum.which_idnum == which_idnum)
q = q.filter(PatientIdNum.idnum_value == idnum_value)
if group_id is not None:
q = q.filter(Patient._group_id == group_id)
if current_only:
q = q.filter(cls._current == True) # nopep8
patients = q.all() # type: List[Patient]
return patients
@classmethod
def get_patient_by_pk(cls, dbsession: SqlASession,
server_pk: int) -> Optional["Patient"]:
return dbsession.query(cls).filter(cls._pk == server_pk).first()
def get_idnum_objects(self) -> List[PatientIdNum]:
return self.idnums # type: List[PatientIdNum]
def get_idnum_references(self) -> List[IdNumReference]:
idnums = self.idnums # type: List[PatientIdNum]
return [x.get_idnum_reference() for x in idnums if x.is_valid()]
def get_idnum_raw_values_only(self) -> List[int]:
idnums = self.idnums # type: List[PatientIdNum]
return [x.idnum_value for x in idnums if x.is_valid()]
[docs] def get_xml_root(self, req: CamcopsRequest,
skip_fields: List[str] = None) -> XmlElement:
"""Get root of XML tree, as an XmlElementTuple."""
skip_fields = skip_fields or []
# No point in skipping old ID columns (1-8) now; they're gone.
branches = self._get_xml_branches(req, skip_attrs=skip_fields)
# Now add new-style IDs:
pidnum_branches = [] # type: List[XmlElement]
for pidnum in self.idnums: # type: PatientIdNum
pidnum_branches.append(pidnum._get_xml_root(req))
branches.append(XmlElement(
name="idnums",
value=pidnum_branches
))
# Special notes
branches.append(XML_COMMENT_SPECIAL_NOTES)
special_notes = self.special_notes # type: List[SpecialNote]
for sn in special_notes:
branches.append(sn.get_xml_root())
return XmlElement(name=self.__tablename__, value=branches)
def get_tsv_page(self, req: CamcopsRequest) -> TsvPage:
# 1. Our core fields.
page = self._get_core_tsv_page(
req, heading_prefix=TSV_PATIENT_FIELD_PREFIX)
# 2. ID number details
# We can't just iterate through the ID numbers; we have to iterate
# through all possible ID numbers.
for iddef in req.idnum_definitions:
n = iddef.which_idnum
nstr = str(n)
shortdesc = iddef.short_description
longdesc = iddef.description
idnum_value = next(
(idnum.idnum_value for idnum in self.idnums
if idnum.which_idnum == n and idnum.is_valid()),
None)
page.add_or_set_value(
heading=TSV_PATIENT_FIELD_PREFIX + FP_ID_NUM + nstr,
value=idnum_value)
page.add_or_set_value(
heading=TSV_PATIENT_FIELD_PREFIX + FP_ID_DESC + nstr,
value=longdesc)
page.add_or_set_value(
heading=(TSV_PATIENT_FIELD_PREFIX + FP_ID_SHORT_DESC +
nstr),
value=shortdesc)
return page
[docs] def get_bare_ptinfo(self) -> BarePatientInfo:
"""Get basic identifying information, as a BarePatientInfo."""
return BarePatientInfo(
forename=self.forename,
surname=self.surname,
dob=self.dob,
sex=self.sex,
idnum_definitions=self.get_idnum_references()
)
@property
def group(self) -> Optional["Group"]:
return self._group
[docs] def satisfies_upload_id_policy(self) -> bool:
"""Does the patient satisfy the uploading ID policy?"""
group = self._group # type: Optional[Group]
if not group:
return False
return self.satisfies_id_policy(group.tokenized_upload_policy())
[docs] def satisfies_finalize_id_policy(self) -> bool:
"""Does the patient satisfy the finalizing ID policy?"""
group = self._group # type: Optional[Group]
if not group:
return False
return self.satisfies_id_policy(group.tokenized_finalize_policy())
[docs] def satisfies_id_policy(self, policy: TokenizedPolicy) -> bool:
"""Does the patient satisfy a particular ID policy?"""
return policy.satisfies_id_policy(self.get_bare_ptinfo())
[docs] def get_surname(self) -> str:
"""Get surname (in upper case) or ""."""
return self.surname.upper() if self.surname else ""
[docs] def get_forename(self) -> str:
"""Get forename (in upper case) or ""."""
return self.forename.upper() if self.forename else ""
[docs] def get_surname_forename_upper(self) -> str:
"""Get "SURNAME, FORENAME" in HTML-safe format, using "UNKNOWN" for
missing details."""
s = self.surname.upper() if self.surname else "(UNKNOWN)"
f = self.forename.upper() if self.surname else "(UNKNOWN)"
return ws.webify(s + ", " + f)
[docs] def get_dob_html(self, longform: bool) -> str:
"""HTML fragment for date of birth."""
if longform:
return "<br>Date of birth: {}".format(
answer(format_datetime(
self.dob, DateFormat.LONG_DATE, default=None)))
return "DOB: {}.".format(format_datetime(
self.dob, DateFormat.SHORT_DATE))
[docs] def get_age(self, req: CamcopsRequest,
default: str = "") -> Union[int, str]:
"""Age (in whole years) today, or default."""
now = req.now
return self.get_age_at(now, default=default)
[docs] def get_dob(self) -> Optional[pendulum.Date]:
"""Date of birth, as a a timezone-naive date."""
dob = self.dob
if not dob:
return None
return coerce_to_pendulum_date(dob)
def get_dob_str(self) -> Optional[str]:
dob_dt = self.get_dob()
if dob_dt is None:
return None
return format_datetime(dob_dt, DateFormat.SHORT_DATE)
[docs] def get_age_at(self,
when: PotentialDatetimeType,
default: str = "") -> Union[int, str]:
"""
Age (in whole years) at a particular date, or default.
"""
return get_age(self.dob, when, default=default)
[docs] def is_female(self) -> bool:
"""Is sex 'F'?"""
return self.sex == "F"
[docs] def is_male(self) -> bool:
"""Is sex 'M'?"""
return self.sex == "M"
[docs] def get_sex(self) -> str:
"""Return sex or ""."""
return self.sex or ""
[docs] def get_sex_verbose(self, default: str = "sex unknown") -> str:
"""Returns HTML-safe version of sex, or default."""
return default if not self.sex else ws.webify(self.sex)
[docs] def get_address(self) -> Optional[str]:
"""Returns address (NOT necessarily web-safe)."""
address = self.address # type: Optional[str]
return address or ""
[docs] def get_hl7_pid_segment(self,
req: CamcopsRequest,
recipient_def: RecipientDefinition) -> hl7.Segment:
"""Get HL7 patient identifier (PID) segment."""
# Put the primary one first:
patient_id_tuple_list = [
HL7PatientIdentifier(
id=str(self.get_idnum_value(recipient_def.primary_idnum)),
id_type=recipient_def.get_id_type(
req,
recipient_def.primary_idnum),
assigning_authority=recipient_def.get_id_aa(
req,
recipient_def.primary_idnum)
)
]
# Then the rest:
for idobj in self.idnums:
which_idnum = idobj.which_idnum
if which_idnum == recipient_def.primary_idnum:
continue
idnum_value = idobj.idnum_value
if idnum_value is None:
continue
patient_id_tuple_list.append(
HL7PatientIdentifier(
id=str(idnum_value),
id_type=recipient_def.get_id_type(req, which_idnum),
assigning_authority=recipient_def.get_id_aa(
req, which_idnum)
)
)
return make_pid_segment(
forename=self.get_surname(),
surname=self.get_forename(),
dob=self.get_dob(),
sex=self.get_sex(),
address=self.get_address(),
patient_id_list=patient_id_tuple_list,
)
[docs] def get_idnum_object(self, which_idnum: int) -> Optional[PatientIdNum]:
"""
Gets the PatientIdNum object for a specified which_idnum, or None.
"""
idnums = self.idnums # type: List[PatientIdNum]
for x in idnums:
if x.which_idnum == which_idnum:
return x
return None
[docs] def get_idnum_value(self, which_idnum: int) -> Optional[int]:
"""Get value of a specific ID number, if present."""
idobj = self.get_idnum_object(which_idnum)
return idobj.idnum_value if idobj else None
def set_idnum_value(self, req: CamcopsRequest,
which_idnum: int, idnum_value: int) -> None:
dbsession = req.dbsession
ccsession = req.camcops_session
idnums = self.idnums # type: List[PatientIdNum]
for idobj in idnums:
if idobj.which_idnum == which_idnum:
idobj.idnum_value = idnum_value
return
# Otherwise, make a new one:
newid = PatientIdNum()
newid.patient_id = self.id
newid._device_id = self._device_id
newid._era = self._era
newid._current = True
newid._when_added_exact = req.now_iso8601_era_format
newid._when_added_batch_utc = req.now_utc
newid._adding_user_id = ccsession.user_id
newid._camcops_version = CAMCOPS_SERVER_VERSION_STRING
dbsession.add(newid)
self.idnums.append(newid)
[docs] def get_iddesc(self, req: CamcopsRequest,
which_idnum: int) -> Optional[str]:
"""Get value of a specific ID description, if present."""
idobj = self.get_idnum_object(which_idnum)
return idobj.description(req) if idobj else None
[docs] def get_idshortdesc(self, req: CamcopsRequest,
which_idnum: int) -> Optional[str]:
"""Get value of a specific ID short description, if present."""
idobj = self.get_idnum_object(which_idnum)
return idobj.short_description(req) if idobj else None
[docs] def is_preserved(self) -> bool:
"""Is the patient record preserved and erased from the tablet?"""
return self._pk is not None and self._era != ERA_NOW
# -------------------------------------------------------------------------
# Audit
# -------------------------------------------------------------------------
[docs] def audit(self, req: CamcopsRequest,
details: str, from_console: bool = False) -> None:
"""Audits actions to this patient."""
audit(req,
details,
patient_server_pk=self._pk,
table=Patient.__tablename__,
server_pk=self._pk,
from_console=from_console)
# -------------------------------------------------------------------------
# Special notes
# -------------------------------------------------------------------------
[docs] def apply_special_note(
self,
req: CamcopsRequest,
note: str,
audit_msg: str = "Special note applied manually") -> None:
"""
Manually applies a special note to a patient.
WRITES TO DATABASE.
"""
sn = SpecialNote()
sn.basetable = self.__tablename__
sn.task_id = self.id # patient ID, in this case
sn.device_id = self._device_id
sn.era = self._era
sn.note_at = req.now
sn.user_id = req.user_id
sn.note = note
req.dbsession.add(sn)
self.special_notes.append(sn)
self.audit(req, audit_msg)
# HL7 deletion of corresponding tasks is done in camcops.py
# -------------------------------------------------------------------------
# Deletion
# -------------------------------------------------------------------------
def gen_patient_idnums_even_noncurrent(self) -> \
Generator[PatientIdNum, None, None]:
seen = set() # type: Set[PatientIdNum]
for live_pidnum in self.idnums:
for lineage_member in live_pidnum.get_lineage():
if lineage_member in seen:
continue
seen.add(lineage_member)
yield lineage_member
def delete_with_dependants(self, req: "CamcopsRequest") -> None:
if self._pk is None:
return
for pidnum in self.gen_patient_idnums_even_noncurrent():
req.dbsession.delete(pidnum)
super().delete_with_dependants(req)
# -------------------------------------------------------------------------
# Editing
# -------------------------------------------------------------------------
@property
def is_editable(self) -> bool:
if self._era == ERA_NOW:
# Not finalized; no editing on server
return False
return True
def user_may_edit(self, req: "CamcopsRequest") -> bool:
return req.user.may_administer_group(self._group_id)
# =============================================================================
# Reports
# =============================================================================
[docs]class DistinctPatientReport(Report):
"""Report to show distinct patients."""
# noinspection PyMethodParameters
@classproperty
def report_id(cls) -> str:
return "patient_distinct"
# noinspection PyMethodParameters
@classproperty
def title(cls) -> str:
return ("(Server) Patients, distinct by name, sex, DOB, all ID "
"numbers")
# noinspection PyMethodParameters
@classproperty
def superuser_only(cls) -> bool:
return False
# noinspection PyProtectedMember
[docs] def get_query(self, req: CamcopsRequest) -> SelectBase:
select_fields = [
Patient.surname.label("surname"),
Patient.forename.label("forename"),
Patient.dob.label("dob"),
Patient.sex.label("sex"),
]
select_from = Patient.__table__
wheres = [Patient._current == True] # type: List[ClauseElement] # nopep8
if not req.user.superuser:
# Restrict to accessible groups
group_ids = req.user.ids_of_groups_user_may_report_on
wheres.append(Patient._group_id.in_(group_ids))
for iddef in req.idnum_definitions:
n = iddef.which_idnum
desc = iddef.short_description
aliased_table = PatientIdNum.__table__.alias("i{}".format(n))
select_fields.append(aliased_table.c.idnum_value.label(desc))
select_from = select_from.outerjoin(aliased_table, and_(
aliased_table.c.patient_id == Patient.id,
aliased_table.c._device_id == Patient._device_id,
aliased_table.c._era == Patient._era,
# Note: the following are part of the JOIN, not the WHERE:
# (or failure to match a row will wipe out the Patient from the
# OUTER JOIN):
aliased_table.c._current == True,
aliased_table.c.which_idnum == n,
)) # nopep8
order_by = [
Patient.surname,
Patient.forename,
Patient.dob,
Patient.sex,
]
query = (
select(select_fields)
.select_from(select_from)
.where(and_(*wheres))
.order_by(*order_by)
.distinct()
)
return query
# =============================================================================
# Unit tests
# =============================================================================
[docs]class PatientTests(DemoDatabaseTestCase):
def test_patient(self) -> None:
self.announce("test_patient")
from camcops_server.cc_modules.cc_group import Group
req = self.req
q = self.dbsession.query(Patient)
p = q.first() # type: Patient
assert p, "Missing Patient in demo database!"
for pidnum in p.get_idnum_objects():
self.assertIsInstance(pidnum, PatientIdNum)
for idref in p.get_idnum_references():
self.assertIsInstance(idref, IdNumReference)
for idnum in p.get_idnum_raw_values_only():
self.assertIsInstance(idnum, int)
self.assertIsInstance(p.get_xml_root(req), XmlElement)
self.assertIsInstance(p.get_tsv_page(req), TsvPage)
self.assertIsInstance(p.get_bare_ptinfo(), BarePatientInfo)
self.assertIsInstanceOrNone(p.group, Group)
self.assertIsInstance(p.satisfies_upload_id_policy(), bool)
self.assertIsInstance(p.satisfies_finalize_id_policy(), bool)
self.assertIsInstance(p.get_surname(), str)
self.assertIsInstance(p.get_forename(), str)
self.assertIsInstance(p.get_surname_forename_upper(), str)
for longform in [True, False]:
self.assertIsInstance(p.get_dob_html(longform), str)
age_str_int = p.get_age(req)
assert isinstance(age_str_int, str) or isinstance(age_str_int, int)
self.assertIsInstanceOrNone(p.get_dob(), pendulum.Date)
self.assertIsInstanceOrNone(p.get_dob_str(), str)
age_at_str_int = p.get_age_at(req.now)
assert isinstance(age_at_str_int, str) or isinstance(age_at_str_int, int) # noqa
self.assertIsInstance(p.is_female(), bool)
self.assertIsInstance(p.is_male(), bool)
self.assertIsInstance(p.get_sex(), str)
self.assertIsInstance(p.get_sex_verbose(), str)
self.assertIsInstance(p.get_address(), str)
self.assertIsInstance(p.get_hl7_pid_segment(req, self.recipdef),
hl7.Segment)
self.assertIsInstanceOrNone(p.get_idnum_object(which_idnum=1),
PatientIdNum)
self.assertIsInstanceOrNone(p.get_idnum_value(which_idnum=1), int)
self.assertIsInstance(p.get_iddesc(req, which_idnum=1), str)
self.assertIsInstance(p.get_idshortdesc(req, which_idnum=1), str)
self.assertIsInstance(p.is_preserved(), bool)
self.assertIsInstance(p.is_editable, bool)
self.assertIsInstance(p.user_may_edit(req), bool)