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

123 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-22 07:20 +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 can_create_discussion(self, submission: Submission) -> bool: 

168 return self.can_access_submission(submission=submission) 

169 

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

171 """ 

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

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

174 - Revisions required -> Waiting for author to submit 

175 - Submission accepted/declined -> Nothing to do 

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

177 """ 

178 current_version = submission.get_current_version() 

179 if not current_version: 

180 return SubmissionStatusData( 

181 submission=submission, 

182 status=SubmissionStatus.ERROR, 

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

184 ) 

185 if not submission.user_is_editor or submission.is_draft: 

186 return SubmissionStatusData( 

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

188 ) 

189 

190 if submission.state in [ 

191 SubmissionState.SUBMITTED.value, 

192 SubmissionState.REVISION_SUBMITTED.value, 

193 ]: 

194 return SubmissionStatusData( 

195 submission=submission, 

196 status=SubmissionStatus.TODO, 

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

198 ) 

199 

200 elif submission.state in [ 

201 SubmissionState.OPENED.value, 

202 SubmissionState.REVISION_REQUESTED.value, 

203 ]: 

204 return SubmissionStatusData( 

205 submission=submission, 

206 status=SubmissionStatus.WAITING, 

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

208 ) 

209 

210 elif submission.state in [ 

211 SubmissionState.ACCEPTED.value, 

212 SubmissionState.REJECTED.value, 

213 ]: 

214 return SubmissionStatusData( 

215 submission=submission, 

216 status=SubmissionStatus.DONE, 

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

218 ) 

219 

220 # Case under review 

221 reviews = current_version.reviews.all() 

222 if not reviews: 

223 return SubmissionStatusData( 

224 submission=submission, 

225 status=SubmissionStatus.TODO, 

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

227 # shortcut_actions=buttons, 

228 ) 

229 

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

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

232 return SubmissionStatusData( 

233 submission=submission, 

234 status=SubmissionStatus.TODO, 

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

236 # shortcut_actions=buttons, 

237 ) 

238 

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

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

241 return SubmissionStatusData( 

242 submission=submission, 

243 status=SubmissionStatus.TODO, 

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

245 # shortcut_actions=buttons, 

246 ) 

247 

248 # All reviews submitted 

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

250 return SubmissionStatusData( 

251 submission=submission, 

252 status=SubmissionStatus.TODO, 

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

254 # shortcut_actions=buttons, 

255 ) 

256 

257 # Pending reviews, nothing to do ? 

258 return SubmissionStatusData( 

259 submission=submission, 

260 status=SubmissionStatus.WAITING, 

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

262 # shortcut_actions=buttons, 

263 ) 

264 

265 def can_filter_submissions(self) -> bool: 

266 return True 

267 

268 def can_access_journal_sections(self) -> bool: 

269 return True 

270 

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

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

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

274 # or re-fetch it... 

275 if submission: 

276 if review.version.submission != submission: 

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

278 else: 

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

280 return submission.user_is_editor 

281 

282 def annotate_submission_status(self, qs): 

283 now = timezone.now() 

284 

285 current_version = SubmissionVersion.objects.filter( 

286 submission=OuterRef(OuterRef("pk")) 

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

288 

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

290 

291 qs = qs.annotate( 

292 _has_reviews=Exists(reviews), 

293 _has_response_overdue=Exists( 

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

295 ), 

296 _has_report_overdue=Exists( 

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

298 ), 

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

300 ) 

301 return qs.annotate( 

302 computed_status=Case( 

303 When( 

304 state__in=[ 

305 SubmissionState.SUBMITTED.value, 

306 SubmissionState.REVISION_SUBMITTED.value, 

307 ], 

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

309 ), 

310 When( 

311 state__in=[ 

312 SubmissionState.OPENED.value, 

313 SubmissionState.REVISION_REQUESTED.value, 

314 ], 

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

316 ), 

317 When( 

318 state__in=[ 

319 SubmissionState.ACCEPTED.value, 

320 SubmissionState.REJECTED.value, 

321 ], 

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

323 ), 

324 When( 

325 Q(_has_reviews=True) 

326 & Q(_has_response_overdue=False) 

327 & Q(_has_report_overdue=False) 

328 & Q(_has_pending_review=True), 

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

330 ), 

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

332 output_field=CharField(), 

333 ) 

334 ) 

335 

336 

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

338 """ 

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

340 """ 

341 # 1 - Get all journal_sections above the given submission 

342 if not submission.journal_section: 

343 return [] 

344 

345 journal_sections = [ 

346 submission.journal_section, 

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

348 ] 

349 

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

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