Coverage for src / mesh / views / components / button.py: 78%

107 statements  

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

1from dataclasses import dataclass, field 

2from typing import TYPE_CHECKING 

3 

4from django import forms 

5from django.urls import reverse 

6from django.utils.translation import gettext_lazy as _ 

7 

8from mesh.models.orm.comment_models import Comment 

9from mesh.models.orm.review_models import RecommendationValue, ReviewState 

10from mesh.models.orm.submission_models import Submission 

11from mesh.models.user.user_interfaces import User 

12 

13if TYPE_CHECKING: 

14 from mesh.models.roles.base_role import Role 

15 

16 

17@dataclass 

18class Button: 

19 """ 

20 Interface for a button component. 

21 """ 

22 

23 # Unique ID for the button - not used for now 

24 id: str 

25 # Title to display as text in the button 

26 title: str 

27 # Optional font-awesome 6 icon code/class to be added next to the title. 

28 icon_class: str = field(default="") 

29 icon_attrs: dict[str, list[str]] = field(default_factory=dict) 

30 # Additional HTML attributes (ex: `{"type": ["submit"]})` 

31 attrs: dict[str, list[str]] = field(default_factory=dict) 

32 # A Django form - If not None, the button will be rendered as a full form 

33 # with this unique button. You must provide the "href" HTML attribute for the form 

34 # to be considered as one. 

35 form: forms.Form | None = field(default=None) 

36 

37 def is_form(self) -> bool: 

38 """ 

39 Whether the button should be displayed as a form. 

40 """ 

41 return bool(self.form) and bool(self.attrs.get("href", False)) 

42 

43 def is_link(self) -> bool: 

44 """ 

45 Whether the button is a link. 

46 """ 

47 return bool(self.attrs.get("href", False) or self.attrs.get("data-modal-href", False)) 

48 

49 def is_modal_link(self): 

50 """ 

51 Whether the button is a link. 

52 """ 

53 return bool(self.attrs.get("data-modal-href", False)) 

54 

55 def add_attr(self, attr: str, values: list[str]): 

56 """ 

57 Add the given value to the given attribute values array. 

58 

59 Ex: 

60 `button.add_attr("class", ["btn", "btn-primary"])` 

61 `button.add_attr("class", ["btn"])` 

62 """ 

63 if attr not in self.attrs: 

64 self.attrs[attr] = [] 

65 

66 for value in values: 

67 if value in self.attrs[attr]: 

68 continue 

69 self.attrs[attr].append(value) 

70 

71 def set_attr(self, attr: str, values: list[str]): 

72 """ 

73 Set the given value for the given attribute. 

74 

75 Ex: 

76 `button.set_attr("class", ["btn", "btn-primary"])` 

77 `button.set_attr("href", ["http://myserver"])` 

78 """ 

79 self.attrs[attr] = values 

80 

81 def remove_attr(self, attr: str): 

82 """ 

83 Remove the given attribute from the attributes array. 

84 """ 

85 if attr in self.attrs: 

86 del self.attrs[attr] 

87 

88 

89@dataclass 

90class SubmissionActionList: 

91 title: str 

92 actions: list[Button] = field(default_factory=list) 

93 

94 

95def build_submission_actions( 

96 submission: Submission, role: "Role", *args, **kwargs 

97) -> list[SubmissionActionList]: 

98 """ 

99 Returns the complete list of available actions to the current user. 

100 """ 

101 article_actions = [] 

102 submission_actions: list[Button] = [] 

103 review_actions = [] 

104 manage_actions: list[Button] = [] 

105 btn_classes = ["as-button"] if kwargs.get("display_as_btn", False) else [] 

106 shortlist = kwargs.get("shortlist", True) 

107 

108 if role.can_access_submission_log(submission) and shortlist: 

109 submission_actions.append( 

110 Button( 

111 id=f"submission-view-{submission.pk}", 

112 title=_("VIEW SUBMISSION"), 

113 icon_class="fa-eye", 

114 attrs={ 

115 "href": [ 

116 reverse("mesh:submission_details", kwargs={"submission_pk": submission.pk}) 

117 ], 

118 "class": btn_classes, 

119 }, 

120 ) 

121 ) 

122 

123 #### [BEGIN] Author actions #### 

124 # Resume submit process 

125 can_submit_submission = role.can_submit_submission(submission) 

126 if can_submit_submission: 

127 submission_actions.append( 

128 Button( 

129 id=f"submission-delete-{submission.pk}", 

130 title=_("DELETE SUBMISSION"), 

131 icon_class="fa-trash", 

132 attrs={ 

133 "href": [ 

134 reverse("mesh:submission_delete", kwargs={"submission_pk": submission.pk}) 

135 ], 

136 "class": btn_classes, 

137 }, 

138 ) 

139 ) 

140 

141 # submission_actions.append( 

142 # Button( 

143 # id=f"submission-submit-{submission.pk}", 

144 # title=_("RESUME SUBMISSION"), 

145 # icon_class="fa-list-check", 

146 # attrs={ 

147 # "href": [reverse("mesh:submission_resume", kwargs={"submission_pk": submission.pk})], 

148 # "class": btn_classes, 

149 # }, 

150 # ) 

151 # ) 

152 

153 # Edit submission metadata & authors 

154 elif role.can_edit_submission(submission): 

155 if not shortlist or can_submit_submission: 

156 article_actions.append( 

157 Button( 

158 id=f"submission-edit-metadata-{submission.pk}", 

159 title=_("EDIT METADATA"), 

160 icon_class="fa-pen-to-square", 

161 attrs={ 

162 "href": [ 

163 reverse( 

164 "mesh:submission_edit_article_metadata", 

165 kwargs={"submission_pk": submission.pk}, 

166 ) 

167 ], 

168 "class": btn_classes, 

169 }, 

170 ) 

171 ) 

172 article_actions.append( 

173 Button( 

174 id=f"submission-edit-authors-{submission.pk}", 

175 title=_("EDIT AUTHOR"), 

176 icon_class="fa-pen-to-square", 

177 attrs={ 

178 "href": [ 

179 reverse( 

180 "mesh:submission_authors", kwargs={"submission_pk": submission.pk} 

181 ) 

182 ], 

183 "class": btn_classes, 

184 }, 

185 ) 

186 ) 

187 

188 # Create a new submission version 

189 if not can_submit_submission and role.can_create_version(submission): 

190 submission_actions.append( 

191 Button( 

192 id=f"submission-create-version-{submission.pk}", 

193 title=_("SUBMIT REVISIONS"), 

194 icon_class="fa-plus", 

195 attrs={ 

196 "href": [ 

197 reverse( 

198 "mesh:submission_version_create", 

199 kwargs={"submission_pk": submission.pk}, 

200 ) 

201 ], 

202 "class": btn_classes, 

203 }, 

204 ) 

205 ) 

206 

207 # Edit the current submission version 

208 current_version = submission.get_current_version() 

209 if not can_submit_submission and current_version and role.can_edit_version(current_version): 

210 submission_actions.append( 

211 Button( 

212 id=f"submission-edit-version-{submission.pk}", 

213 title=_("RESUME REVISIONS"), 

214 icon_class="fa-list-check", 

215 attrs={ 

216 "href": [ 

217 reverse( 

218 "mesh:submission_version_update", 

219 kwargs={"version_pk": current_version.pk}, 

220 ) 

221 ], 

222 "class": btn_classes, 

223 }, 

224 ) 

225 ) 

226 #### [END] Author actions #### 

227 

228 #### [BEGIN] Editor actions #### 

229 if role.can_assign_editor(submission): 

230 """ 

231 <a id="assigned-editors-edit" class="as-button" href="{% url "mesh:submission_editors" pk=submission.pk%}"> 

232 <i class="fa-solid fa-plus"></i> 

233 {% translate "Assign editor" %} 

234 </a> 

235 """ 

236 submission_actions.append( 

237 Button( 

238 id=f"assigned-editors-edit-{submission.pk}", 

239 title=_("ASSIGN EDITOR"), 

240 icon_class="fa-plus", 

241 attrs={ 

242 "class": btn_classes, 

243 "data-modal-href": [ 

244 reverse( 

245 "mesh:async_assign_editor", kwargs={"submission_pk": submission.pk} 

246 ) 

247 ], 

248 "data-reload": ["true"] if kwargs.get("reload_page", False) else ["false"], 

249 "data-reload-id": [kwargs.get("reload_id", "")], 

250 "data-reload-href": ( 

251 [ 

252 reverse( 

253 "mesh:api_fetch_submission", 

254 kwargs={ 

255 "submission_pk": submission.pk, 

256 "row_id": kwargs.get("reload_id", ""), 

257 }, 

258 ) 

259 ] 

260 if kwargs.get("reload_id", "") != "" 

261 else [""] 

262 ), 

263 }, 

264 ) 

265 ) 

266 

267 # Send the submission version to review 

268 if role.can_start_review_process(submission): 

269 from ...views.forms.editorial_forms import StartReviewProcessForm 

270 

271 form = StartReviewProcessForm(initial={"process": True}) 

272 

273 review_actions.append( 

274 Button( 

275 id=f"submission-send-to-review-{submission.pk}", 

276 title=_("SEND TO REVIEW"), 

277 icon_class="fa-file-import", 

278 form=form, 

279 attrs={ 

280 "href": [ 

281 reverse( 

282 "mesh:submission_start_review_process", 

283 kwargs={"pk": submission.pk}, 

284 ) 

285 ], 

286 "class": ["as-link"], 

287 }, 

288 ) 

289 ) 

290 

291 # Make an editorial decision 

292 if role.can_create_editorial_decision(submission): 

293 submission_actions.append( 

294 Button( 

295 id=f"submission-editorial-decision-{submission.pk}", 

296 title=_("EDITORIAL DECISION"), 

297 icon_class="fa-plus", 

298 attrs={ 

299 "href": [ 

300 reverse( 

301 "mesh:editorial_decision_create", 

302 kwargs={"submission_pk": submission.pk}, 

303 ) 

304 ], 

305 "class": btn_classes, 

306 }, 

307 ) 

308 ) 

309 

310 # Request a new review 

311 if current_version and role.can_invite_reviewer(current_version): 

312 review_actions.append( 

313 Button( 

314 id=f"submission-referee-request-{submission.pk}", 

315 title=_("REQUEST REVIEW"), 

316 icon_class="fa-user-group", 

317 attrs={ 

318 "href": [ 

319 reverse( 

320 "mesh:review_create", 

321 kwargs={ 

322 "submission_pk": submission.pk, 

323 "version_pk": submission.get_current_version().pk, 

324 }, 

325 ) 

326 ], 

327 "class": btn_classes, 

328 }, 

329 ) 

330 ) 

331 

332 if current_version.number > 1: 

333 # Auto re-assign reviewers, only available after round 1 

334 existing_reviewers = [review.reviewer for review in current_version.reviews.all()] 

335 

336 previous_round_number = current_version.number - 1 

337 previous_round = submission.versions.get(number=previous_round_number) 

338 reviews_that_can_be_auto_reassigned = previous_round.reviews.filter( 

339 state=ReviewState.SUBMITTED.value 

340 ).exclude( 

341 recommendation__in=[ 

342 RecommendationValue.REJECTED.value, 

343 RecommendationValue.RESUBMIT_SOMEWHERE_ELSE.value, 

344 ] 

345 ) 

346 previous_reviewers = [ 

347 review.reviewer 

348 for review in reviews_that_can_be_auto_reassigned 

349 if review.reviewer not in existing_reviewers 

350 ] 

351 

352 if len(previous_reviewers) > 0: 

353 review_actions.append( 

354 Button( 

355 id=f"submission-referee-auto-request-{submission.pk}", 

356 title=_("ASSIGN REVIEWERS OF THE PREVIOUS ROUND"), 

357 icon_class="fa-user-group", 

358 attrs={ 

359 "href": [ 

360 reverse( 

361 "mesh:review_auto_create", 

362 kwargs={ 

363 "submission_pk": submission.pk, 

364 "version_pk": current_version.pk, 

365 }, 

366 ) 

367 ], 

368 "class": btn_classes, 

369 }, 

370 ) 

371 ) 

372 

373 # if role_handler.check_rights("can_access_submission_log", submission) and not shortlist: 

374 # manage_actions.append( 

375 # Button( 

376 # id=f"submission-log-{submission.pk}", 

377 # title=_("VIEW HISTORY"), 

378 # icon_class="fa-clock-rotate-left", 

379 # attrs={ 

380 # "href": [reverse("mesh:submission_log", kwargs={"pk": submission.pk})], 

381 # "class": btn_classes, 

382 # }, 

383 # ) 

384 # ) 

385 #### [END] Editor actions #### 

386 

387 #### [BEGIN] Reviewer actions #### 

388 # Submit / Edit the user current review 

389 current_review = None 

390 if current_version: 

391 current_review = role.get_current_open_review(current_version) 

392 

393 if current_review is not None and current_review.accepted is None: 

394 # Display 2 buttons: Accept & Decline if the review has not been accepted yet. 

395 review_actions.extend( 

396 [ 

397 Button( 

398 id=f"review-accept-{current_review.pk}", 

399 title=_("Accept review"), 

400 icon_class="fa-check", 

401 attrs={ 

402 "href": [ 

403 reverse( 

404 "mesh:review_accept", 

405 kwargs={ 

406 "submission_pk": submission.pk, 

407 "version_pk": current_version.pk, 

408 "review_pk": current_review.pk, 

409 }, 

410 ) 

411 ], 

412 # "href": [ 

413 # add_query_parameters_to_url( 

414 # reverse( 

415 # "mesh:review_accept", 

416 # kwargs={"pk": current_review.pk}, 

417 # ), 

418 # {"accepted": ["true"]}, 

419 # ) 

420 # ], 

421 "class": ["as-button", "button-success"], 

422 }, 

423 ), 

424 Button( 

425 id=f"review-accept-{current_review.pk}", 

426 title=_("Decline review"), 

427 icon_class="fa-xmark", 

428 attrs={ 

429 "href": [ 

430 reverse( 

431 "mesh:review_decline", 

432 kwargs={ 

433 "submission_pk": submission.pk, 

434 "version_pk": current_version.pk, 

435 "review_pk": current_review.pk, 

436 }, 

437 ) 

438 ], 

439 "class": ["as-button", "button-error"], 

440 }, 

441 ), 

442 ] 

443 ) 

444 elif current_review is not None: 

445 action_name = _("SUBMIT REVIEW") 

446 if current_review.state in [ 

447 ReviewState.SUBMITTED.value, 

448 ReviewState.DECLINED.value, 

449 ]: 

450 action_name = _("EDIT REVIEW") 

451 review_actions.append( 

452 Button( 

453 id=f"review-{current_review.pk}", 

454 title=action_name, 

455 icon_class="fa-pen-to-square", 

456 attrs={ 

457 "href": [ 

458 reverse( 

459 "mesh:review_update", 

460 kwargs={ 

461 "submission_pk": submission.pk, 

462 "version_pk": current_version.pk, 

463 "review_pk": current_review.pk, 

464 }, 

465 ) 

466 ], 

467 "class": btn_classes, 

468 }, 

469 ) 

470 ) 

471 #### [END] Reviewer actions #### 

472 action_lists = [] 

473 

474 if article_actions: 

475 action_lists.append(SubmissionActionList(title=_("Article"), actions=article_actions)) 

476 

477 if submission_actions: 

478 action_lists.append( 

479 SubmissionActionList(title=_("Submission"), actions=submission_actions) 

480 ) 

481 

482 if review_actions: 

483 action_lists.append(SubmissionActionList(title=_("Review"), actions=review_actions)) 

484 

485 if manage_actions: 

486 action_lists.append(SubmissionActionList(title=_("Manage"), actions=manage_actions)) 

487 

488 return action_lists 

489 

490 

491def build_comment_actions(comment: Comment, user: User): 

492 if comment.author == user: 

493 edit_comment_button = Button( 

494 id=f"edit-comment-{comment.pk}", 

495 title="", 

496 icon_class="fa-pen-to-square", 

497 icon_attrs={"style": ["color:black"]}, 

498 attrs={ 

499 "data-comment-id": [comment.pk], 

500 "data-tooltip": ["Edit"], 

501 "type": ["button"], 

502 "class": ["edit-button", "comment-action"], 

503 }, 

504 ) 

505 delete_comment_button = Button( 

506 id=f"edit-comment-{comment.pk}", 

507 title="", 

508 icon_class="fa-trash-can", 

509 icon_attrs={"style": ["color:black"]}, 

510 attrs={ 

511 "data-comment-id": [comment.pk], 

512 "data-tooltip": ["Delete"], 

513 "type": ["button"], 

514 "class": ["delete-button", "comment-action"], 

515 }, 

516 ) 

517 comment_actions = [edit_comment_button, delete_comment_button] 

518 else: 

519 answer_comment_button = Button( 

520 id=f"answer-comment-{comment.pk}", 

521 title="", 

522 icon_class="fa-reply", 

523 icon_attrs={"style": ["color:black"]}, 

524 attrs={ 

525 "data-comment-id": [comment.pk], 

526 "data-author": [f"{comment.author.first_name} {comment.author.last_name}"], 

527 "data-tooltip": ["Answer"], 

528 "type": ["button"], 

529 "class": ["reply-button", "comment-action"], 

530 }, 

531 ) 

532 comment_actions = [answer_comment_button] 

533 return comment_actions