Coverage for src / mesh / models / roles / editor.py: 85%

121 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-08-11 09:47 +0000

1from collections.abc import Collection 

2from itertools import chain 

3from typing import ClassVar 

4 

5from django.db.models import Case, CharField, Exists, OuterRef, Q, QuerySet, Subquery, Value, When 

6from django.utils import timezone 

7from django.utils.translation import gettext as _ 

8from opentelemetry import trace 

9 

10from mesh.app_settings import BlindMode, app_settings 

11from mesh.models.orm.editorial_models import EditorialDecision 

12from mesh.models.orm.journal_models import JournalSection 

13from mesh.models.orm.review_models import Review 

14from mesh.models.orm.submission_models import ( 

15 Submission, 

16 SubmissionLog, 

17 SubmissionState, 

18 SubmissionVersion, 

19) 

20from mesh.models.orm.user_models import User 

21 

22from ..submission_status import SubmissionStatus, SubmissionStatusData 

23from .base_role import Role 

24 

25tracer = trace.get_tracer(__name__) 

26 

27 

28class Editor(Role): 

29 """ 

30 Editor role. 

31 """ 

32 

33 _CODE: ClassVar[str] = "editor" 

34 _NAME: ClassVar[str] = _("Editor") 

35 _ICON_CLASS: ClassVar[str] = "fa-user-tie" 

36 _SUBMISSION_LIST_TITLE: ClassVar[str] = _("My assignments") 

37 

38 @tracer.start_as_current_span("Editor.__init__") 

39 def __init__(self, user: User): 

40 super().__init__(user) 

41 if app_settings.BLIND_MODE == BlindMode.DOUBLE_BLIND: 

42 # hide reviews when current user also has created the submission 

43 self.reviews_queryset = Review.objects.exclude( 

44 version__submission__created_by=self.user 

45 ).all() 

46 else: 

47 self.reviews_queryset = Review.objects.all() 

48 self.versions_queryset = SubmissionVersion.objects.filter(submitted=True) 

49 

50 all_children = self._get_all_journal_sections() 

51 self.submissions_queryset = ( 

52 Submission.objects.all() 

53 .annotate( 

54 user_is_editor=Case( 

55 When( 

56 Q(editors__user=self.user) | Q(journal_section__in=all_children), 

57 then=Value(True), 

58 ), 

59 default=Value(False), 

60 ), 

61 ) 

62 .filter(user_is_editor=True) 

63 .exclude(created_by=self.user) 

64 .exclude(state=SubmissionState.OPENED.value) 

65 ) 

66 self.log_messages_queryset = SubmissionLog.objects.all().select_related("created_by") 

67 

68 def _get_is_active(self): 

69 return ( 

70 JournalSection.objects.filter(editors__user=self.user).exists() 

71 or Submission.objects.filter(editors__user=self.user).exists() 

72 ) 

73 

74 def _get_all_journal_sections(self): 

75 journalsections = [perm.journal_section for perm in self.user.editor_sections.all()] 

76 all_children = set[JournalSection](journalsections) 

77 for section in journalsections: 

78 section_children = section.all_children() 

79 all_children.update(section_children) 

80 return all_children 

81 

82 def get_journal_sections(self) -> list[JournalSection]: 

83 """ 

84 Recursively gets ALL the submission journal_sections the user has access to. 

85 That means all directly assigned journal_sections and all their children. 

86 """ 

87 direct_journal_sections = JournalSection.objects.filter(editors__user=self.user) 

88 if not direct_journal_sections.exists(): 

89 return [] 

90 

91 base_journal_sections = chain.from_iterable( 

92 JournalSection.objects.get_children_recursive(c) for c in direct_journal_sections 

93 ) 

94 

95 return [*direct_journal_sections, *base_journal_sections] 

96 

97 def get_submissions(self): 

98 """ 

99 Editors have access to all submissions and associated data (TBD) 

100 """ 

101 return self._annotate_submission_query(self.submissions_queryset) 

102 

103 def can_submit_submission(self, submission): 

104 return False 

105 

106 def can_access_submission(self, submission) -> bool: 

107 return submission.user_is_editor 

108 

109 def can_access_reviews(self, version) -> bool: 

110 return self.can_access_submission(version.submission) 

111 

112 def can_access_review(self, submission, review) -> bool: 

113 return self.can_access_submission(submission) 

114 

115 def can_access_review_file(self, file) -> bool: 

116 return self.can_access_review(file.attached_to) 

117 

118 def can_access_review_details(self, submission, review) -> bool: 

119 return submission.user_is_editor 

120 

121 def can_invite_reviewer(self, version) -> bool: 

122 """ 

123 A reviewer can be invited only if the version is submitted by the author and 

124 opened for review. 

125 """ 

126 

127 return version.submitted and version.review_open and version.submission.user_is_editor 

128 

129 def can_access_version(self, version: SubmissionVersion) -> bool: 

130 """ 

131 Non-submitted versions are not available to anyone except the author. 

132 """ 

133 return version.submitted and self.can_access_submission(version.submission) 

134 

135 def get_managed_users(self) -> QuerySet[User]: 

136 """ 

137 Restrict to non-editor users. 

138 

139 TODO: Restrict actions to manageable_submissions ? 

140 """ 

141 return ( 

142 User.objects.exclude(pk=self.user.pk) 

143 .exclude(journal_manager=True) 

144 .exclude(editor_sections__isnull=False) 

145 .exclude(editor_submissions__isnull=False) 

146 .exclude(is_superuser=True) 

147 ) 

148 

149 def can_impersonate(self) -> bool: 

150 return True 

151 

152 def can_access_submission_log(self, submission: Submission) -> bool: 

153 return self.can_access_submission(submission) 

154 

155 def can_create_editorial_decision(self, submission: Submission) -> bool: 

156 return submission.user_is_editor 

157 

158 def can_edit_editorial_decision(self, decision: EditorialDecision) -> bool: 

159 return decision.version.submission.user_is_editor 

160 

161 def can_start_review_process(self, submission: Submission) -> bool: 

162 return submission.user_is_editor and submission.is_reviewable() 

163 

164 def can_assign_editor(self, submission: Submission) -> bool: 

165 return submission.user_is_editor 

166 

167 def get_submission_status(self, submission: Submission) -> SubmissionStatusData: 

168 """ 

169 For an editor+ role, the status highly depends on the submission state. 

170 - Submission/Revisions submitted -> The editor must take action 

171 - Revisions required -> Waiting for author to submit 

172 - Submission accepted/declined -> Nothing to do 

173 - Under review -> Tricky. Depends on the existing reviews (or not) state. 

174 """ 

175 current_version = submission.get_current_version() 

176 if not current_version: 

177 return SubmissionStatusData( 

178 submission=submission, 

179 status=SubmissionStatus.ERROR, 

180 description="Submission does not have a current version", 

181 ) 

182 if not submission.user_is_editor or submission.is_draft: 

183 return SubmissionStatusData( 

184 submission=submission, status=SubmissionStatus.ERROR, description="" 

185 ) 

186 

187 if submission.state in [ 

188 SubmissionState.SUBMITTED.value, 

189 SubmissionState.REVISION_SUBMITTED.value, 

190 ]: 

191 return SubmissionStatusData( 

192 submission=submission, 

193 status=SubmissionStatus.TODO, 

194 description=_("Requires an editorial action."), 

195 ) 

196 

197 elif submission.state in [ 

198 SubmissionState.OPENED.value, 

199 SubmissionState.REVISION_REQUESTED.value, 

200 ]: 

201 return SubmissionStatusData( 

202 submission=submission, 

203 status=SubmissionStatus.WAITING, 

204 description=_("Waiting for a new version"), 

205 ) 

206 

207 elif submission.state in [ 

208 SubmissionState.ACCEPTED.value, 

209 SubmissionState.REJECTED.value, 

210 ]: 

211 return SubmissionStatusData( 

212 submission=submission, 

213 status=SubmissionStatus.DONE, 

214 description=_("Submission") + " " + submission.get_state_display(), 

215 ) 

216 

217 # Case under review 

218 reviews = current_version.reviews.all() 

219 if not reviews: 

220 return SubmissionStatusData( 

221 submission=submission, 

222 status=SubmissionStatus.TODO, 

223 description=_("No reviewers for the current round."), 

224 # shortcut_actions=buttons, 

225 ) 

226 

227 # If some review requests are not answered and answer date is overdued 

228 if any(r.is_response_overdue for r in reviews): 

229 return SubmissionStatusData( 

230 submission=submission, 

231 status=SubmissionStatus.TODO, 

232 description=_("At least 1 request answer is overdue."), 

233 # shortcut_actions=buttons, 

234 ) 

235 

236 # If some reports are not submitted and report date is overdued 

237 elif any(r.is_report_overdue for r in reviews): 

238 return SubmissionStatusData( 

239 submission=submission, 

240 status=SubmissionStatus.TODO, 

241 description=_("At least 1 report is overdue."), 

242 # shortcut_actions=buttons, 

243 ) 

244 

245 # All reviews submitted 

246 elif all(r.is_completed for r in reviews): 

247 return SubmissionStatusData( 

248 submission=submission, 

249 status=SubmissionStatus.TODO, 

250 description=_("All reviews have been declined or submitted."), 

251 # shortcut_actions=buttons, 

252 ) 

253 

254 # Pending reviews, nothing to do ? 

255 return SubmissionStatusData( 

256 submission=submission, 

257 status=SubmissionStatus.WAITING, 

258 description=_("Waiting for review(s)."), 

259 # shortcut_actions=buttons, 

260 ) 

261 

262 def can_filter_submissions(self) -> bool: 

263 return True 

264 

265 def can_access_journal_sections(self) -> bool: 

266 return True 

267 

268 def can_edit_review_file_right(self, review: Review, submission=None) -> bool: 

269 # annotation is lost when accessing review.version.submission 

270 # We either need to pass the annotated object as argument 

271 # or re-fetch it... 

272 if submission: 

273 if review.version.submission != submission: 

274 raise ValueError("Provided submission is not related to this review") 

275 else: 

276 submission = self.get_submissions().get(pk=review.version.submission.pk) 

277 return submission.user_is_editor 

278 

279 def annotate_submission_status(self, qs): 

280 now = timezone.now() 

281 

282 current_version = SubmissionVersion.objects.filter( 

283 submission=OuterRef(OuterRef("pk")) 

284 ).values("pk")[:1] 

285 

286 reviews = Review.objects.filter(version=Subquery(current_version)) 

287 

288 qs = qs.annotate( 

289 _has_reviews=Exists(reviews), 

290 _has_response_overdue=Exists( 

291 reviews.filter(accepted__isnull=True, date_response_due__lt=now) 

292 ), 

293 _has_report_overdue=Exists( 

294 reviews.filter(accepted=True, submitted=False, date_review_due__lt=now) 

295 ), 

296 _has_pending_review=Exists(reviews.filter(submitted=False).exclude(accepted=False)), 

297 ) 

298 return qs.annotate( 

299 computed_status=Case( 

300 When( 

301 state__in=[ 

302 SubmissionState.SUBMITTED.value, 

303 SubmissionState.REVISION_SUBMITTED.value, 

304 ], 

305 then=Value(SubmissionStatus.TODO.value), 

306 ), 

307 When( 

308 state__in=[ 

309 SubmissionState.OPENED.value, 

310 SubmissionState.REVISION_REQUESTED.value, 

311 ], 

312 then=Value(SubmissionStatus.WAITING.value), 

313 ), 

314 When( 

315 state__in=[ 

316 SubmissionState.ACCEPTED.value, 

317 SubmissionState.REJECTED.value, 

318 ], 

319 then=Value(SubmissionStatus.DONE.value), 

320 ), 

321 When( 

322 Q(_has_reviews=True) 

323 & Q(_has_response_overdue=False) 

324 & Q(_has_report_overdue=False) 

325 & Q(_has_pending_review=True), 

326 then=Value(SubmissionStatus.WAITING.value), 

327 ), 

328 default=Value(SubmissionStatus.TODO.value), 

329 output_field=CharField(), 

330 ) 

331 ) 

332 

333 

334def get_section_editors(submission: Submission) -> Collection[User]: 

335 """ 

336 Get the editors with access to the given submission from the EditorSectionRights. 

337 """ 

338 # 1 - Get all journal_sections above the given submission 

339 if not submission.journal_section: 

340 return [] 

341 

342 journal_sections = [ 

343 submission.journal_section, 

344 *JournalSection.objects.get_parents_recursive(submission.journal_section), 

345 ] 

346 

347 # 2 - Get all users with rights over one of the above journal_sections 

348 return User.objects.filter(editor_sections__journal_section__in=journal_sections)