Coverage for tasks/photo.py: 63%
84 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/photo.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
11 CamCOPS is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CamCOPS is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26"""
28from typing import List, Optional, Type
30import cardinal_pythonlib.rnc_web as ws
31from sqlalchemy.orm import Mapped, mapped_column
32from sqlalchemy.sql.sqltypes import UnicodeText
34from camcops_server.cc_modules.cc_blob import (
35 blob_relationship,
36 get_blob_img_html,
37)
38from camcops_server.cc_modules.cc_constants import CssClass
39from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
40from camcops_server.cc_modules.cc_db import (
41 ancillary_relationship,
42 GenericTabletRecordMixin,
43 TaskDescendant,
44)
45from camcops_server.cc_modules.cc_html import answer, tr_qa
46from camcops_server.cc_modules.cc_request import CamcopsRequest
47from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
48from camcops_server.cc_modules.cc_sqla_coltypes import mapped_camcops_column
49from camcops_server.cc_modules.cc_sqlalchemy import Base
50from camcops_server.cc_modules.cc_task import (
51 Task,
52 TaskHasClinicianMixin,
53 TaskHasPatientMixin,
54)
57# =============================================================================
58# Photo
59# =============================================================================
62class Photo(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501
63 """
64 Server implementation of the Photo task.
65 """
67 __tablename__ = "photo"
68 shortname = "Photo"
69 info_filename_stem = "clinical"
71 description: Mapped[Optional[str]] = mapped_column(
72 UnicodeText, comment="Description of the photograph"
73 )
74 photo_blobid: Mapped[Optional[int]] = mapped_camcops_column(
75 is_blob_id_field=True,
76 blob_relationship_attr_name="photo",
77 comment="ID of the BLOB (foreign key to blobs.id, given "
78 "matching device and current/frozen record status)",
79 )
80 # IGNORED. REMOVE WHEN ALL PRE-2.0.0 TABLETS GONE:
81 rotation: Mapped[Optional[int]] = mapped_column( # DEFUNCT as of v2.0.0
82 comment="Rotation (clockwise, in degrees) to be applied for viewing",
83 )
85 photo = blob_relationship("Photo", "photo_blobid") # type: ignore[assignment] # noqa: E501
87 @staticmethod
88 def longname(req: "CamcopsRequest") -> str:
89 _ = req.gettext
90 return _("Photograph")
92 def is_complete(self) -> bool:
93 return self.photo_blobid is not None
95 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
96 if not self.is_complete():
97 return CTV_INCOMPLETE
98 if not self.description:
99 return []
100 return [CtvInfo(content=self.description)]
102 def get_task_html(self, req: CamcopsRequest) -> str:
103 # noinspection PyTypeChecker
104 return """
105 <table class="{CssClass.TASKDETAIL}">
106 <tr class="{CssClass.SUBHEADING}"><td>Description</td></tr>
107 <tr><td>{description}</td></tr>
108 <tr class="{CssClass.SUBHEADING}"><td>Photo</td></tr>
109 <tr><td>{photo}</td></tr>
110 </table>
111 """.format(
112 CssClass=CssClass,
113 description=answer(
114 ws.webify(self.description),
115 default="(No description)",
116 default_for_blank_strings=True,
117 ),
118 # ... xhtml2pdf crashes if the contents are empty...
119 photo=get_blob_img_html(self.photo), # type: ignore[arg-type]
120 )
122 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
123 if not self.is_complete():
124 return []
125 return [
126 SnomedExpression(req.snomed(SnomedLookup.PHOTOGRAPH_PROCEDURE)),
127 SnomedExpression(
128 req.snomed(SnomedLookup.PHOTOGRAPH_PHYSICAL_OBJECT)
129 ),
130 ]
133# =============================================================================
134# PhotoSequence
135# =============================================================================
138class PhotoSequenceSinglePhoto(GenericTabletRecordMixin, TaskDescendant, Base):
139 __tablename__ = "photosequence_photos"
141 photosequence_id: Mapped[int] = mapped_column(
142 comment="Tablet FK to photosequence",
143 )
144 seqnum: Mapped[int] = mapped_column(
145 comment="Sequence number of this photo "
146 "(consistently 1-based as of 2018-12-01)",
147 )
148 description: Mapped[Optional[str]] = mapped_column(
149 UnicodeText, comment="Description of the photograph"
150 )
151 photo_blobid: Mapped[Optional[int]] = mapped_camcops_column(
152 is_blob_id_field=True,
153 blob_relationship_attr_name="photo",
154 comment="ID of the BLOB (foreign key to blobs.id, given "
155 "matching device and current/frozen record status)",
156 )
157 # IGNORED. REMOVE WHEN ALL PRE-2.0.0 TABLETS GONE:
158 rotation: Mapped[Optional[int]] = mapped_column( # DEFUNCT as of v2.0.0
159 comment="(DEFUNCT COLUMN) "
160 "Rotation (clockwise, in degrees) to be applied for viewing",
161 )
163 photo = blob_relationship("PhotoSequenceSinglePhoto", "photo_blobid")
165 def get_html_table_rows(self) -> str:
166 # noinspection PyTypeChecker
167 return """
168 <tr class="{CssClass.SUBHEADING}">
169 <td>Photo {num}: <b>{description}</b></td>
170 </tr>
171 <tr><td>{photo}</td></tr>
172 """.format(
173 CssClass=CssClass,
174 num=self.seqnum,
175 description=ws.webify(self.description),
176 photo=get_blob_img_html(self.photo), # type: ignore[arg-type]
177 )
179 # -------------------------------------------------------------------------
180 # TaskDescendant overrides
181 # -------------------------------------------------------------------------
183 @classmethod
184 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
185 return PhotoSequence
187 def task_ancestor(self) -> Optional["PhotoSequence"]:
188 return PhotoSequence.get_linked(self.photosequence_id, self) # type: ignore[return-value] # noqa: E501
191class PhotoSequence(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501
192 """
193 Server implementation of the PhotoSequence task.
194 """
196 __tablename__ = "photosequence"
197 shortname = "PhotoSequence"
198 info_filename_stem = "clinical"
200 sequence_description: Mapped[Optional[str]] = mapped_column(
201 UnicodeText,
202 comment="Description of the sequence of photographs",
203 )
205 photos = ancillary_relationship( # type: ignore[assignment]
206 parent_class_name="PhotoSequence",
207 ancillary_class_name="PhotoSequenceSinglePhoto",
208 ancillary_fk_to_parent_attr_name="photosequence_id",
209 ancillary_order_by_attr_name="seqnum",
210 ) # type: List[PhotoSequenceSinglePhoto]
212 @staticmethod
213 def longname(req: "CamcopsRequest") -> str:
214 _ = req.gettext
215 return _("Photograph sequence")
217 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
218 infolist = [CtvInfo(content=self.sequence_description)]
219 for p in self.photos:
220 infolist.append(CtvInfo(content=p.description))
221 return infolist
223 def get_num_photos(self) -> int:
224 return len(self.photos)
226 def is_complete(self) -> bool:
227 # If you're wondering why this is being called unexpectedly: it may be
228 # because this task is being displayed in the task list, at which point
229 # we colour it by its complete-or-not status.
230 return bool(self.sequence_description) and self.get_num_photos() > 0
232 def get_task_html(self, req: CamcopsRequest) -> str:
233 html = f"""
234 <div class="{CssClass.SUMMARY}">
235 <table class="{CssClass.SUMMARY}">
236 {self.get_is_complete_tr(req)}
237 {tr_qa("Number of photos", self.get_num_photos())}
238 {tr_qa("Description", self.sequence_description)}
239 </table>
240 </div>
241 <table class="{CssClass.TASKDETAIL}">
242 """
243 for p in self.photos:
244 html += p.get_html_table_rows()
245 html += """
246 </table>
247 """
248 return html
250 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
251 if not self.is_complete():
252 return []
253 return [
254 SnomedExpression(req.snomed(SnomedLookup.PHOTOGRAPH_PROCEDURE)),
255 SnomedExpression(
256 req.snomed(SnomedLookup.PHOTOGRAPH_PHYSICAL_OBJECT)
257 ),
258 ]