Coverage for src/mesh/models/submission_models.py: 85%

194 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-04-28 07:45 +0000

1from __future__ import annotations 

2 

3import os 

4import re 

5from enum import Enum, unique 

6from functools import cached_property 

7from typing import TYPE_CHECKING, Self, TypeVar 

8 

9from django.conf import settings 

10from django.db import models 

11from django.db.models import FilteredRelation, Max, Q 

12from django.utils import timezone 

13from django.utils.translation import gettext_lazy as _ 

14 

15from mesh.model.exceptions import SubmissionStateError 

16 

17from .base_models import BaseChangeTrackingModel, BaseSubmittableModel 

18from .file_models import BaseFileWrapperModel, BaseModelWithFiles 

19from .log_models import ModelLog 

20 

21# Used with annotations to enable correct typing without having circular import 

22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true

23 from datetime import datetime 

24 

25 from mesh.model.roles.role_handler import RoleHandler 

26 

27 from .editorial_models import EditorialDecision 

28 from .user_models import User 

29 

30_T = TypeVar("_T", bound=models.Model) 

31 

32 

33@unique 

34class SubmissionState(Enum): 

35 """ 

36 Enum of the submission statees. 

37 Warning: the value of each state is used in CSS. 

38 """ 

39 

40 # OPENED Submission created by the author but not submitted for 

41 # review yet. 

42 # Preferably use the boolean `is_draft` when checking for OPENED state 

43 OPENED = "opened" 

44 # SUBMITTED Submission submitted for review. Waiting for editor to 

45 # take action (accept/reject/open review round) 

46 SUBMITTED = "submitted" 

47 # ON REVIEW Open round OR no waiting for editor to take action 

48 # (accept/reject/open review round) 

49 ON_REVIEW = "review" 

50 # REVISION REQUESTED Editor requested revisions. 

51 # Waiting for author to submit new version 

52 REVISION_REQUESTED = "rev_requested" 

53 # REVISION SUBMITTED Author submitted revision. Waiting for editor to 

54 # take action (accept/reject/open review round) 

55 REVISION_SUBMITTED = "rev_submited" 

56 # ACCEPTED Editor accepted the submission as it is. 

57 # Begin Copyediting process. 

58 ACCEPTED = "accepted" 

59 # REJECTED Editor rejected the submission. The submission is closed. 

60 REJECTED = "rejected" 

61 

62 

63SUBMISSION_STATE_CHOICES = [ 

64 (SubmissionState.OPENED.value, _("Draft")), 

65 (SubmissionState.SUBMITTED.value, _("Submitted")), 

66 (SubmissionState.ON_REVIEW.value, _("Under review")), 

67 (SubmissionState.REVISION_REQUESTED.value, _("Revision requested")), 

68 (SubmissionState.REVISION_SUBMITTED.value, _("Revision submitted")), 

69 (SubmissionState.ACCEPTED.value, _("Accepted")), 

70 (SubmissionState.REJECTED.value, _("Rejected")), 

71] 

72 

73SUBMISSION_STATE_EDITOR_CHOICES = [ 

74 (SubmissionState.REVISION_REQUESTED.value, _("Request revisions")), 

75 (SubmissionState.ACCEPTED.value, _("Accept submission")), 

76 (SubmissionState.REJECTED.value, _("Reject submission")), 

77] 

78 

79 

80# Typing with the generic here is very important to get correct type hints 

81# when using any SubmissionManager methods. 

82class SubmissionQuerySet(models.QuerySet[_T]): 

83 def annotate_last_activity(self) -> Self: 

84 """ 

85 Annotate the `date_last_activity` to the queryset = date of the last 

86 significant log entry. 

87 """ 

88 return self.annotate( 

89 log_significant=FilteredRelation( 

90 "log_messages", condition=Q(log_messages__significant=True) 

91 ) 

92 ).annotate(date_last_activity=Max("log_significant__date_created")) 

93 

94 def prefetch_data(self) -> Self: 

95 """ 

96 Shortcut function to prefetch all related MtoM data. 

97 """ 

98 return self.prefetch_related( 

99 "versions", 

100 "versions__main_file", 

101 "versions__additional_files", 

102 "versions__reviews", 

103 "versions__reviews__additional_files", 

104 "authors", 

105 "editors", 

106 "editors__user", 

107 ) 

108 

109 def select_data(self) -> Self: 

110 """ 

111 Shortcut function to select all related FK. 

112 """ 

113 return self.select_related("created_by", "last_modified_by", "journal_section") 

114 

115 

116class SubmissionManager(models.Manager[_T]): 

117 def get_queryset(self) -> SubmissionQuerySet[_T]: 

118 return ( 

119 SubmissionQuerySet(self.model, using=self._db).annotate_last_activity().select_data() 

120 ) 

121 

122 def get_submissions(self, prefetch=True) -> SubmissionQuerySet[_T]: 

123 """ 

124 Return a SubmissionQuerySet with prefetched related data (default). 

125 """ 

126 if not prefetch: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 return self.get_queryset() 

128 return self.get_queryset().prefetch_data() 

129 

130 

131class Submission(BaseChangeTrackingModel): 

132 # Change the default `created_by` with a protected on_delete behavior. 

133 created_by = models.ForeignKey( 

134 settings.AUTH_USER_MODEL, 

135 verbose_name=_("Created by"), 

136 on_delete=models.PROTECT, 

137 blank=True, 

138 null=False, 

139 help_text=_("Automatically filled on save."), 

140 editable=False, 

141 related_name="+", 

142 ) 

143 name = models.TextField(verbose_name=_("Title")) 

144 abstract = models.TextField(verbose_name=_("Abstract")) 

145 journal_section = models.ForeignKey( 

146 "JournalSection", 

147 verbose_name=_("Section (Optional)"), 

148 on_delete=models.SET_NULL, 

149 related_name="submissions", 

150 null=True, 

151 blank=True, 

152 ) 

153 state = models.CharField( 

154 verbose_name=_("state"), 

155 max_length=64, 

156 choices=SUBMISSION_STATE_CHOICES, 

157 default=SubmissionState.OPENED.value, 

158 ) 

159 date_first_version = models.DateTimeField( 

160 verbose_name=_("Date of the first submitted version"), null=True, editable=False 

161 ) 

162 author_agreement = models.BooleanField( 

163 verbose_name=_("Agreement"), 

164 help_text=_("I hereby declare that I have read blablabla and I consent to the terms"), 

165 ) 

166 notes = models.TextField(verbose_name=_("Notes"), default="") # Post-it notes for the editors 

167 

168 objects: SubmissionManager[Self] = SubmissionManager() 

169 

170 class Meta: 

171 constraints = [ 

172 models.UniqueConstraint( 

173 fields=["created_by", "name"], name="unique_submission_name_per_user" 

174 ) 

175 ] 

176 

177 def __str__(self) -> str: 

178 return f"{self.created_by} - {self.name}" 

179 

180 @cached_property 

181 def all_versions(self) -> list[SubmissionVersion]: 

182 """ 

183 Return all the versions, ordered by descending `number`. 

184 We do the sort manually to prevent an potential additional query ( 

185 the versions should be already prefetched). 

186 """ 

187 versions = self.versions.all() # type:ignore 

188 return sorted(versions, key=lambda version: version.number, reverse=True) 

189 

190 @cached_property 

191 def current_version(self) -> SubmissionVersion | None: 

192 """ 

193 The current (latest) `SubmissionVersion` of the Submission. 

194 """ 

195 all_versions = self.all_versions 

196 if all_versions: 

197 return all_versions[0] 

198 return None 

199 

200 @property 

201 def date_submission(self) -> datetime | None: 

202 """ 

203 Submission date of the submission. 

204 It is the date of the first version completion or the submisison's creation date 

205 if no versions are submitted yet. 

206 """ 

207 return self.date_first_version or self.date_created 

208 

209 @property 

210 def state_order(self) -> int: 

211 """ 

212 Returns the integer mapped to the submission state for ordering purpose. 

213 """ 

214 return [s[0] for s in SUBMISSION_STATE_CHOICES].index(self.state) 

215 

216 @cached_property 

217 def all_assigned_editors(self) -> list[User]: 

218 return sorted( 

219 (e.user for e in self.editors.all()), 

220 key=lambda u: u.first_name, # type:ignore 

221 ) 

222 

223 @cached_property 

224 def all_authors(self) -> list[SubmissionAuthor]: 

225 return sorted(self.authors.all(), key=lambda a: a.first_name) # type:ignore 

226 

227 @property 

228 def is_submittable(self) -> bool: 

229 """ 

230 Whether the submission is submittable. 

231 It checks that the required data is correct. 

232 """ 

233 return ( 

234 self.author_agreement is True 

235 and self.state 

236 in [SubmissionState.OPENED.value, SubmissionState.REVISION_REQUESTED.value] 

237 and self.current_version is not None 

238 and not self.current_version.submitted 

239 and hasattr(self.current_version, "main_file") 

240 and len(self.all_authors) > 0 

241 ) 

242 

243 @property 

244 def is_draft(self) -> bool: 

245 return self.state == SubmissionState.OPENED.value 

246 

247 def submit(self, user) -> None: 

248 """ 

249 Submit the submission's current version: 

250 - Set the submission's current version to `submitted=True` 

251 - Change the submission state to "submitted" or "revisions_submitted" 

252 according to the current state. 

253 - Add an entry to the submission log 

254 

255 Raise an SubmissionStateError is the submission is not submittable. 

256 

257 Params: 

258 - `request` The enclosing HTTP request. It's used to derive the 

259 current user and potential impersonate data. 

260 """ 

261 if not self.is_submittable: 

262 raise SubmissionStateError(_("Trying to submit an non-submittable submission.")) 

263 

264 code = self.state 

265 

266 self.state = ( 

267 SubmissionState.SUBMITTED.value 

268 if self.is_draft 

269 else SubmissionState.REVISION_SUBMITTED.value 

270 ) 

271 self.save() 

272 

273 version: SubmissionVersion = self.current_version # type:ignore 

274 version.submitted = True 

275 version.date_submitted = timezone.now() 

276 version._user = user # type:ignore 

277 

278 # Start Review Process 

279 version = self.current_version 

280 version.review_open = True 

281 

282 version.save() 

283 

284 self.state = SubmissionState.ON_REVIEW.value 

285 self.save() 

286 

287 SubmissionLog.add_message( 

288 self, 

289 content=_("Submission of version") + f" #{version.number}", 

290 content_en=f"Submission of version #{version.number}", 

291 user=user, 

292 # impersonate_data=impersonate_data, 

293 significant=True, 

294 code=code, 

295 ) 

296 

297 @property 

298 def is_reviewable(self) -> bool: 

299 """ 

300 Returns whether the submission can be sent to review. 

301 """ 

302 return ( 

303 self.state 

304 in [ 

305 SubmissionState.SUBMITTED.value, 

306 SubmissionState.REVISION_SUBMITTED.value, 

307 ] 

308 and self.current_version is not None 

309 and self.current_version.submitted 

310 and self.current_version.review_open is False 

311 and hasattr(self.current_version, "main_file") 

312 ) 

313 

314 def start_review_process(self, user) -> None: 

315 """ 

316 Start the review process for the current version of the submission: 

317 - Change the submission state to ON_REVIEW 

318 - Open the review on the current version 

319 - Add an entry to the submission log. 

320 

321 Raise an exception is the submission is not reviewable. 

322 

323 Params: 

324 - `request` The enclosing HTTP request. It's used to derive the 

325 current user and potential impersonate data. 

326 """ 

327 if not self.is_reviewable: 

328 raise SubmissionStateError( 

329 _("Trying to start the review process of an non-reviewable submission.") 

330 ) 

331 version: SubmissionVersion = self.current_version # type:ignore 

332 version.review_open = True 

333 version.save() 

334 

335 code = self.state 

336 self.state = SubmissionState.ON_REVIEW.value 

337 self.save() 

338 

339 SubmissionLog.add_message( 

340 self, 

341 content=f"Submission version #{version.number} sent to review.", 

342 content_en=f"Submission version #{version.number} sent to review.", 

343 user=user, 

344 # impersonate_data=impersonate_data, 

345 significant=True, 

346 code=code, 

347 ) 

348 

349 def apply_editorial_decision(self, decision: EditorialDecision, user) -> None: 

350 """ 

351 Apply an editorial decision: 

352 - Changes the submission state to the selected state. 

353 - Close the review on the current version. 

354 - Add an entry to the submission log 

355 

356 Raise an exception if the submission's status does not allow editorial decision. 

357 

358 Params: 

359 - `request` The enclosing HTTP request. It's used to derive the 

360 current user and potential impersonate data. 

361 """ 

362 if self.is_draft: 

363 raise SubmissionStateError( 

364 _("Trying to apply an editorial decision on a draft submission.") 

365 ) 

366 # Update the submission state with the selected one 

367 code = self.state 

368 self.state = decision.value 

369 self.save() 

370 

371 # Close the review on the current version if any. 

372 version = self.current_version 

373 if version: 373 ↛ 378line 373 didn't jump to line 378 because the condition on line 373 was always true

374 version.review_open = False 

375 version.save() 

376 

377 # Add message and log entry 

378 decision_str = decision.get_value_display() # type:ignore 

379 SubmissionLog.add_message( 

380 self, 

381 content=_("Editorial decision") + f": {decision_str}", 

382 content_en=f"Editorial decision: {decision_str}", 

383 user=user, 

384 significant=True, 

385 code=code, 

386 ) 

387 

388 

389class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles): 

390 """ 

391 Version of a submission. Only contains files (main + additional files). 

392 """ 

393 

394 file_fields_required = ["main_file"] 

395 file_fields_deletable = ["additional_files"] 

396 

397 submission = models.ForeignKey( 

398 Submission, 

399 on_delete=models.CASCADE, 

400 blank=True, 

401 null=False, 

402 editable=False, 

403 related_name="versions", 

404 ) 

405 number = models.IntegerField( 

406 help_text=_("Automatically filled on save"), 

407 blank=True, 

408 null=False, 

409 editable=False, 

410 ) 

411 

412 # Boolean used to track whether the review process is still open for the submission 

413 # version. A new version is not opened for review by default. Changing its value 

414 # requires an editorial action. 

415 review_open = models.BooleanField( 

416 verbose_name=_("Version opened for review"), default=False, editable=False 

417 ) 

418 

419 class Meta: 

420 constraints = [ 

421 models.UniqueConstraint( 

422 fields=["submission", "number"], name="unique_submission_version_number" 

423 ), 

424 models.UniqueConstraint( 

425 fields=["submission"], 

426 condition=Q(review_open=True), 

427 name="unique_review_open_submission_version", 

428 ), 

429 ] 

430 

431 def save(self, *args, **kwargs) -> None: 

432 """ 

433 Fill the version's number for a new instance. 

434 """ 

435 if self._state.adding: 

436 current_version = self.submission.current_version 

437 if current_version: 

438 self.number = current_version.number + 1 

439 else: 

440 self.number = 1 

441 

442 return super().save(*args, **kwargs) 

443 

444 

445class SubmissionMainFile(BaseFileWrapperModel): 

446 file_extensions = [".pdf"] 

447 

448 attached_to: models.OneToOneField[SubmissionVersion] = models.OneToOneField( 

449 SubmissionVersion, 

450 primary_key=True, 

451 on_delete=models.CASCADE, 

452 related_name="main_file", 

453 ) 

454 

455 @staticmethod 

456 def get_upload_path(instance: SubmissionMainFile, filename: str) -> str: 

457 return os.path.join( 

458 "submissions", 

459 str(instance.attached_to.submission.pk), 

460 "versions", 

461 str(instance.attached_to.number), 

462 filename, 

463 ) 

464 

465 @classmethod 

466 def reverse_file_path(cls, file_path: str) -> SubmissionMainFile | None: 

467 """ 

468 Check first with a regex is the file path might match. This is not necessary 

469 but it saves us a DB query in case in doesn't match the path structure. 

470 

471 WARNING: This is not resilient to path change. A file could be moved to another 

472 location, with the path updated in DB and still be an instance of this model. 

473 If this becomes an issue, remove the regex check and just make the DB query. 

474 """ 

475 regex = "/".join( 

476 [ 

477 "submissions", 

478 r"(?P<submission_pk>[0-9]+)", 

479 "versions", 

480 r"(?P<version_number>[0-9]+)", 

481 r"(?P<file_name>[^\/]+)$", 

482 ] 

483 ) 

484 match = re.match(re.compile(regex), file_path) 

485 if not match: 

486 return None 

487 

488 return cls._default_manager.filter(file=file_path).first() 

489 

490 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool: 

491 """ 

492 This model is only editable by user role with edit rights on the submission. 

493 """ 

494 if right_code == "read": 

495 return role_handler.check_global_rights("can_access_version", self.attached_to) 

496 

497 return False 

498 

499 

500class SubmissionAdditionalFile(BaseFileWrapperModel): 

501 file_extensions = [ 

502 ".pdf", 

503 ".docx", 

504 ".odt", 

505 ".py", 

506 ".jpg", 

507 ".png", 

508 ".ipynb", 

509 ".sql", 

510 ".tex", 

511 ] 

512 attached_to: models.ForeignKey[SubmissionVersion] = models.ForeignKey( 

513 SubmissionVersion, on_delete=models.CASCADE, related_name="additional_files" 

514 ) 

515 

516 @staticmethod 

517 def get_upload_path(instance: SubmissionAdditionalFile, filename: str) -> str: 

518 return os.path.join( 

519 "submissions", 

520 str(instance.attached_to.submission.pk), 

521 "versions", 

522 str(instance.attached_to.number), 

523 "additional", 

524 filename, 

525 ) 

526 

527 @classmethod 

528 def reverse_file_path(cls, file_path: str) -> SubmissionAdditionalFile | None: 

529 """ 

530 Check first with a regex is the file path might match. This is not necessary 

531 but it saves us a DB query in case in doesn't match the path structure. 

532 """ 

533 regex = "/".join( 

534 [ 

535 "submissions", 

536 r"(?P<submission_pk>[0-9]+)", 

537 "versions", 

538 r"(?P<version_number>[0-9]+)", 

539 "additional", 

540 r"(?P<file_name>[^\/]+)$", 

541 ] 

542 ) 

543 match = re.match(re.compile(regex), file_path) 

544 if not match: 

545 return None 

546 

547 return cls._default_manager.filter(file=file_path).first() 

548 

549 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool: 

550 """ 

551 This model is only editable by user role with edit rights on the submission. 

552 """ 

553 if right_code == "read": 

554 return role_handler.check_global_rights("can_access_version", self.attached_to) 

555 

556 elif right_code in ["delete"]: 

557 return role_handler.check_rights("can_edit_version", self.attached_to) 

558 

559 return False 

560 

561 

562class SubmissionLog(ModelLog): 

563 attached_to = models.ForeignKey( 

564 Submission, on_delete=models.CASCADE, related_name="log_messages" 

565 ) 

566 

567 

568class SubmissionAuthor(BaseChangeTrackingModel): 

569 """ 

570 Model for a submission's author. 

571 1 submission author is linked to 1 submission only. 

572 """ 

573 

574 submission = models.ForeignKey( 

575 Submission, 

576 on_delete=models.CASCADE, 

577 blank=True, 

578 null=False, 

579 editable=False, 

580 related_name="authors", 

581 ) 

582 first_name = models.CharField( 

583 verbose_name=_("First name"), max_length=150, blank=False, null=False 

584 ) 

585 last_name = models.CharField( 

586 verbose_name=_("Last name"), max_length=150, blank=False, null=False 

587 ) 

588 email = models.EmailField(_("E-mail address")) 

589 corresponding = models.BooleanField( 

590 verbose_name=_("Corresponding contact"), 

591 blank=False, 

592 null=False, 

593 default=False, 

594 help_text=_( 

595 "If checked, e-mails will be sent to advise of any progress in the submission process." 

596 ), 

597 ) 

598 

599 class Meta: 

600 constraints = [ 

601 models.UniqueConstraint( 

602 fields=["submission", "email"], 

603 name="unique_author_email_per_submission", 

604 ) 

605 ] 

606 

607 def __str__(self) -> str: 

608 return f"{self.first_name} {self.last_name} ({self.email})" 

609 

610 def full_name(self) -> str: 

611 return f"{self.first_name} {self.last_name}"