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

114 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-05-04 12:41 +0000

1from collections.abc import Collection 

2from itertools import chain 

3from typing import ClassVar 

4 

5from django.db.models import Case, Q, QuerySet, Value, When 

6from django.utils.translation import gettext as _ 

7from opentelemetry import trace 

8 

9from mesh.app_settings import BlindMode, app_settings 

10from mesh.models.orm.editorial_models import EditorialDecision 

11from mesh.models.orm.journal_models import JournalSection 

12from mesh.models.orm.review_models import Review 

13from mesh.models.orm.submission_models import ( 

14 Submission, 

15 SubmissionLog, 

16 SubmissionState, 

17 SubmissionVersion, 

18) 

19from mesh.models.orm.user_models import User 

20 

21from ..submission_status import SubmissionStatus, SubmissionStatusData 

22from .base_role import Role 

23 

24tracer = trace.get_tracer(__name__) 

25 

26 

27class Editor(Role): 

28 """ 

29 Editor role. 

30 """ 

31 

32 _CODE: ClassVar[str] = "editor" 

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

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

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

36 

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

38 def __init__(self, user: User): 

39 super().__init__(user) 

40 if app_settings.BLIND_MODE == BlindMode.DOUBLE_BLIND: 

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

42 self.reviews_queryset = Review.objects.exclude( 

43 version__submission__created_by=self.user 

44 ).all() 

45 else: 

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

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

48 

49 all_children = self._get_all_journal_sections() 

50 self.submissions_queryset = ( 

51 Submission.objects.all() 

52 .annotate( 

53 user_is_editor=Case( 

54 When( 

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

56 then=Value(True), 

57 ), 

58 default=Value(False), 

59 ), 

60 ) 

61 .filter(user_is_editor=True) 

62 .exclude(created_by=self.user) 

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

64 ) 

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

66 

67 def _get_is_active(self): 

68 return ( 

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

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

71 ) 

72 

73 def _get_all_journal_sections(self): 

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

75 all_children = set[JournalSection](journalsections) 

76 for section in journalsections: 

77 section_children = section.all_children() 

78 all_children.update(section_children) 

79 return all_children 

80 

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

82 """ 

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

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

85 """ 

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

87 if not direct_journal_sections.exists(): 

88 return [] 

89 

90 base_journal_sections = chain.from_iterable( 

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

92 ) 

93 

94 return [*direct_journal_sections, *base_journal_sections] 

95 

96 def get_submissions(self): 

97 """ 

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

99 """ 

100 return self._annotate_submission_query(self.submissions_queryset) 

101 

102 def can_submit_submission(self, submission): 

103 return False 

104 

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

106 return submission.user_is_editor 

107 

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

109 return self.can_access_submission(version.submission) 

110 

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

112 return self.can_access_submission(submission) 

113 

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

115 return self.can_access_review(file.attached_to) 

116 

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

118 return submission.user_is_editor 

119 

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

121 """ 

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

123 opened for review. 

124 """ 

125 

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

127 

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

129 """ 

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

131 """ 

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

133 

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

135 """ 

136 Restrict to non-editor users. 

137 

138 TODO: Restrict actions to manageable_submissions ? 

139 """ 

140 return ( 

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

142 .exclude(journal_manager=True) 

143 .exclude(editor_sections__isnull=False) 

144 .exclude(editor_submissions__isnull=False) 

145 .exclude(is_superuser=True) 

146 ) 

147 

148 def can_impersonate(self) -> bool: 

149 return True 

150 

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

152 return self.can_access_submission(submission) 

153 

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

155 return submission.user_is_editor 

156 

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

158 return decision.version.submission.user_is_editor 

159 

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

161 return submission.user_is_editor and submission.is_reviewable() 

162 

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

164 return submission.user_is_editor 

165 

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

167 """ 

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

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

170 - Revisions required -> Waiting for author to submit 

171 - Submission accepted/declined -> Nothing to do 

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

173 """ 

174 current_version = submission.get_current_version() 

175 if not current_version: 

176 return SubmissionStatusData( 

177 submission=submission, 

178 status=SubmissionStatus.ERROR, 

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

180 ) 

181 if not submission.user_is_editor or submission.is_draft: 

182 return SubmissionStatusData( 

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

184 ) 

185 

186 if submission.state in [ 

187 SubmissionState.SUBMITTED.value, 

188 SubmissionState.REVISION_SUBMITTED.value, 

189 ]: 

190 return SubmissionStatusData( 

191 submission=submission, 

192 status=SubmissionStatus.TODO, 

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

194 ) 

195 

196 elif submission.state in [ 

197 SubmissionState.OPENED.value, 

198 SubmissionState.REVISION_REQUESTED.value, 

199 ]: 

200 return SubmissionStatusData( 

201 submission=submission, 

202 status=SubmissionStatus.WAITING, 

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

204 ) 

205 

206 elif submission.state in [ 

207 SubmissionState.ACCEPTED.value, 

208 SubmissionState.REJECTED.value, 

209 ]: 

210 return SubmissionStatusData( 

211 submission=submission, 

212 status=SubmissionStatus.DONE, 

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

214 ) 

215 

216 # Case under review 

217 reviews = current_version.reviews.all() 

218 if not reviews: 

219 return SubmissionStatusData( 

220 submission=submission, 

221 status=SubmissionStatus.TODO, 

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

223 # shortcut_actions=buttons, 

224 ) 

225 

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

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

228 return SubmissionStatusData( 

229 submission=submission, 

230 status=SubmissionStatus.TODO, 

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

232 # shortcut_actions=buttons, 

233 ) 

234 

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

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

237 return SubmissionStatusData( 

238 submission=submission, 

239 status=SubmissionStatus.TODO, 

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

241 # shortcut_actions=buttons, 

242 ) 

243 

244 # All reviews submitted 

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

246 return SubmissionStatusData( 

247 submission=submission, 

248 status=SubmissionStatus.TODO, 

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

250 # shortcut_actions=buttons, 

251 ) 

252 

253 # Pending reviews, nothing to do ? 

254 return SubmissionStatusData( 

255 submission=submission, 

256 status=SubmissionStatus.WAITING, 

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

258 # shortcut_actions=buttons, 

259 ) 

260 

261 def can_filter_submissions(self) -> bool: 

262 return True 

263 

264 def can_access_journal_sections(self) -> bool: 

265 return True 

266 

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

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

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

270 # or re-fetch it... 

271 if submission: 

272 if review.version.submission != submission: 

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

274 else: 

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

276 return submission.user_is_editor 

277 

278 

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

280 """ 

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

282 """ 

283 # 1 - Get all journal_sections above the given submission 

284 if not submission.journal_section: 

285 return [] 

286 

287 journal_sections = [ 

288 submission.journal_section, 

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

290 ] 

291 

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

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