Coverage for src / mesh / models / orm / submission_models.py: 90%

225 statements  

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

1from __future__ import annotations 

2 

3import logging 

4import os 

5from enum import Enum, unique 

6from typing import TYPE_CHECKING, Self 

7 

8from django.db import models 

9from django.db.models import FilteredRelation, Manager, Max, Q 

10from django.urls import reverse 

11from django.utils import timezone 

12from django.utils.translation import gettext_lazy as _ 

13 

14from mesh.models.exceptions import SubmissionStateError 

15from mesh.models.orm.base_models import BaseChangeTrackingModel, BaseSubmittableModel 

16from mesh.models.orm.file_models import BaseFileWrapperModel, BaseModelWithFiles 

17from mesh.models.orm.log_models import ModelLog 

18from mesh.models.orm.user_models import User 

19 

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

21if TYPE_CHECKING: 

22 from datetime import datetime 

23 

24 from django.db.models.manager import RelatedManager 

25 

26 from mesh.models.orm.editorial_models import EditorSubmissionRight 

27 from mesh.models.orm.review_models import Review 

28 

29 from .editorial_models import EditorialDecision 

30 

31logger = logging.getLogger(__name__) 

32 

33 

34@unique 

35class SubmissionState(Enum): 

36 """ 

37 Enum of the submission statees. 

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

39 """ 

40 

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

42 # review yet. 

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

44 OPENED = "opened" 

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

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

47 SUBMITTED = "submitted" 

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

49 # (accept/reject/open review round) 

50 ON_REVIEW = "review" 

51 # REVISION REQUESTED Editor requested revisions. 

52 # Waiting for author to submit new version 

53 REVISION_REQUESTED = "rev_requested" 

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

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

56 REVISION_SUBMITTED = "rev_submited" 

57 # ACCEPTED Editor accepted the submission as it is. 

58 # Begin Copyediting process. 

59 ACCEPTED = "accepted" 

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

61 REJECTED = "rejected" 

62 

63 

64SUBMISSION_STATE_CHOICES = [ 

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

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

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

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

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

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

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

72] 

73 

74SUBMISSION_STATE_EDITOR_CHOICES = [ 

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

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

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

78] 

79 

80 

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

82# when using any SubmissionManager methods. 

83class SubmissionQuerySet(models.QuerySet["Submission"]): 

84 def annotate_last_activity(self) -> Self: 

85 """ 

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

87 significant log entry. 

88 """ 

89 return self.annotate( 

90 log_significant=FilteredRelation( 

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

92 ) 

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

94 

95 def prefetch_data(self) -> Self: 

96 """ 

97 Shortcut function to prefetch all related MtoM data. 

98 """ 

99 return self.prefetch_related( 

100 "editors", 

101 "editors__user", 

102 ) 

103 

104 def select_data(self) -> Self: 

105 """ 

106 Shortcut function to select all related FK. 

107 """ 

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

109 

110 

111class PrefetchedSubmissionManager(models.Manager["Submission"]): 

112 def get_queryset(self): 

113 return ( 

114 SubmissionQuerySet(self.model, using=self._db) 

115 .annotate_last_activity() 

116 .prefetch_data() 

117 .select_data() 

118 ) 

119 

120 

121class Submission(BaseChangeTrackingModel): 

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

123 created_by = models.ForeignKey( 

124 User, 

125 verbose_name=_("Created by"), 

126 on_delete=models.PROTECT, 

127 null=False, 

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

129 editable=False, 

130 related_name="+", 

131 ) 

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

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

134 journal_section = models.ForeignKey["JournalSection"]( 

135 "JournalSection", 

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

137 on_delete=models.SET_NULL, 

138 related_name="submissions", 

139 null=True, 

140 ) 

141 state = models.CharField( 

142 verbose_name=_("state"), 

143 max_length=64, 

144 choices=SUBMISSION_STATE_CHOICES, 

145 default=SubmissionState.OPENED.value, 

146 ) 

147 date_first_version = models.DateTimeField( 

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

149 ) 

150 author_agreement = models.BooleanField( 

151 verbose_name=_("Agreement"), 

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

153 ) 

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

155 

156 ojs_id = models.PositiveIntegerField(null=True) 

157 

158 objects = PrefetchedSubmissionManager() 

159 

160 # RelatedObjects 

161 versions: "Manager[SubmissionVersion]" 

162 editors: "Manager[EditorSubmissionRight]" 

163 authors: "Manager[SubmissionAuthor]" 

164 log_messages: "Manager[SubmissionLog]" 

165 

166 # Annotated properties 

167 user_is_editor: bool 

168 "Annotated True when the current user has editing rights for this submission" 

169 authors_string: str 

170 "Annotated" 

171 created_by_censored: str 

172 "Annotated" 

173 authors_censored: list[SubmissionAuthor] 

174 "Annotated" 

175 versions_censored: list[SubmissionVersion] 

176 "Annotated" 

177 log_messages_censored: list[SubmissionLog] 

178 "Annotated" 

179 

180 # class Meta: 

181 # constraints = [ 

182 # models.UniqueConstraint( 

183 # fields=["created_by", "name"], name="unique_submission_name_per_user" 

184 # ) 

185 # ] 

186 

187 def __str__(self) -> str: 

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

189 

190 def get_current_version(self): 

191 """ 

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

193 """ 

194 if not self.versions_censored: 

195 return None 

196 return self.versions_censored[0] 

197 

198 @property 

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

200 """ 

201 Submission date of the submission. 

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

203 if no versions are submitted yet. 

204 """ 

205 return self.date_first_version or self.date_created 

206 

207 @property 

208 def state_order(self) -> int: 

209 """ 

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

211 """ 

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

213 

214 @property 

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

216 return sorted( 

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

218 key=lambda u: u.first_name, 

219 ) 

220 

221 def is_submittable(self) -> bool: 

222 """ 

223 Whether the submission is submittable. 

224 It checks that the required data is correct. 

225 """ 

226 if self.author_agreement is False: 

227 logger.debug(f"Submission {self.pk} not submittable : author_agreement is False") 

228 return False 

229 if self.state not in [ 

230 SubmissionState.OPENED.value, 

231 SubmissionState.REVISION_REQUESTED.value, 

232 ]: 

233 logger.debug(f"Submission {self.pk} not submittable : state is {self.state}") 

234 return False 

235 if len(self.versions.all()) == 0: 

236 logger.debug( 

237 f"Submission {self.pk} not submittable : submission has no versions is None" 

238 ) 

239 return False 

240 if self.versions.all()[0].submitted: 

241 logger.debug( 

242 f"Submission {self.pk} not submittable : current_version is already submitted" 

243 ) 

244 return False 

245 if not hasattr(self.versions.all()[0], "main_file"): 

246 logger.debug( 

247 f"Submission {self.pk} not submittable : current_version does not have a main file" 

248 ) 

249 return False 

250 if self.authors.count() == 0: 

251 logger.debug(f"Submission {self.pk} not submittable : no authors found") 

252 return False 

253 return True 

254 

255 @property 

256 def is_draft(self) -> bool: 

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

258 

259 def submit(self, user, *args, date_submitted=None) -> None: 

260 """ 

261 Submit the submission's current version: 

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

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

264 according to the current state. 

265 - Add an entry to the submission log 

266 

267 Raise an SubmissionStateError is the submission is not submittable. 

268 

269 Params: 

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

271 current user and potential impersonate data. 

272 """ 

273 if not self.is_submittable(): 

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

275 

276 if date_submitted is None: 

277 date_submitted = timezone.now() 

278 

279 code = self.state 

280 

281 self.state = ( 

282 SubmissionState.SUBMITTED.value 

283 if self.is_draft 

284 else SubmissionState.REVISION_SUBMITTED.value 

285 ) 

286 self.save() 

287 

288 version = self.versions.first() 

289 if version is None: 

290 raise ValueError("Cannot submit: submission does not have a current version") 

291 version.submitted = True 

292 

293 version.date_submitted = date_submitted 

294 version._user = user 

295 version.review_open = True 

296 version.save() 

297 

298 self.state = SubmissionState.ON_REVIEW.value 

299 self.save() 

300 

301 SubmissionLog.add_message( 

302 self, 

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

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

305 user=user, 

306 # impersonate_data=impersonate_data, 

307 significant=True, 

308 code=code, 

309 date=date_submitted, 

310 ) 

311 

312 def is_reviewable(self) -> bool: 

313 """ 

314 Returns whether the submission can be sent to review. 

315 """ 

316 current_version = ( 

317 self.versions_censored[0] 

318 if hasattr(self, "versions_censored") and self.versions_censored 

319 else None 

320 ) 

321 return ( 

322 self.state 

323 in [ 

324 SubmissionState.SUBMITTED.value, 

325 SubmissionState.REVISION_SUBMITTED.value, 

326 ] 

327 and current_version is not None 

328 and current_version.submitted 

329 and current_version.review_open is False 

330 and hasattr(current_version, "main_file") 

331 ) 

332 

333 def start_review_process(self, user, date=None) -> None: 

334 """ 

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

336 - Change the submission state to ON_REVIEW 

337 - Open the review on the current version 

338 - Add an entry to the submission log. 

339 

340 Raise an exception is the submission is not reviewable. 

341 

342 Params: 

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

344 current user and potential impersonate data. 

345 """ 

346 if not self.is_reviewable(): 

347 raise SubmissionStateError( 

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

349 ) 

350 version = self.versions.first() 

351 if version is None: 

352 raise ValueError("Cannot submit: submission does not have a current version") 

353 version.review_open = True 

354 version.save() 

355 

356 code = self.state 

357 self.state = SubmissionState.ON_REVIEW.value 

358 self.save() 

359 

360 SubmissionLog.add_message( 

361 self, 

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

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

364 user=user, 

365 # impersonate_data=impersonate_data, 

366 significant=True, 

367 code=code, 

368 date=date, 

369 ) 

370 

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

372 def apply_editorial_decision(self, decision: EditorialDecision, user, date=None) -> None: 

373 """ 

374 Apply an editorial decision: 

375 - Changes the submission state to the selected state. 

376 - Close the review on the current version. 

377 - Add an entry to the submission log 

378 

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

380 

381 Params: 

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

383 current user and potential impersonate data. 

384 """ 

385 if self.is_draft: 

386 raise SubmissionStateError( 

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

388 ) 

389 # Update the submission state with the selected one 

390 code = self.state 

391 self.state = decision.value 

392 self.override_saved_date(date_last_modified=date, last_modified_by_user=user) 

393 self.save() 

394 

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

396 version = self.versions.first() 

397 if version: 

398 version.review_open = False 

399 version.override_saved_date(date_last_modified=date, last_modified_by_user=user) 

400 version.save() 

401 

402 # Add message and log entry 

403 decision_str = decision.get_value_display() 

404 SubmissionLog.add_message( 

405 self, 

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

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

408 user=user, 

409 significant=True, 

410 code=code, 

411 # date=date, 

412 ) 

413 

414 def get_absolute_url(self): 

415 return reverse("mesh:submission_details", kwargs={"submission_pk": self.pk}) 

416 

417 

418class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles): 

419 """ 

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

421 """ 

422 

423 file_fields_required = ["main_file"] 

424 file_fields_deletable = ["additional_files"] 

425 

426 submission = models.ForeignKey( 

427 Submission, 

428 on_delete=models.CASCADE, 

429 null=False, 

430 editable=False, 

431 related_name="versions", 

432 ) 

433 number = models.IntegerField( 

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

435 null=False, 

436 editable=False, 

437 ) 

438 # reviews: "Manager[Review]" 

439 # main_file: "SubmissionMainFile" 

440 # additional_files: "Manager[SubmissionAdditionalFile]" 

441 reviews: Manager[Review] 

442 main_file: SubmissionMainFile 

443 additional_files: Manager[SubmissionAdditionalFile] 

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

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

446 # requires an editorial action. 

447 review_open = models.BooleanField( 

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

449 ) 

450 

451 # RelatedManagers 

452 additional_files: "RelatedManager[SubmissionAdditionalFile]" 

453 main_file: "SubmissionMainFile" 

454 editorial_decision: "EditorialDecision|None" 

455 reviews: "RelatedManager[Review]" 

456 # Annotated properties 

457 created_by_censored: str | User 

458 reviews_censored: "list[Review]" 

459 

460 class Meta: # type: ignore 

461 constraints = [ 

462 models.UniqueConstraint( 

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

464 ), 

465 models.UniqueConstraint( 

466 fields=["submission"], 

467 condition=Q(review_open=True), 

468 name="unique_review_open_submission_version", 

469 ), 

470 ] 

471 ordering = ["-number"] 

472 

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

474 """ 

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

476 """ 

477 if self._state.adding: 

478 current_version = self.submission.versions.first() 

479 if current_version: 

480 self.number = current_version.number + 1 

481 else: 

482 self.number = 1 

483 

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

485 

486 

487class SubmissionMainFile(BaseFileWrapperModel[SubmissionVersion]): 

488 file_extensions = [".pdf"] 

489 

490 attached_to = models.OneToOneField( 

491 SubmissionVersion, 

492 primary_key=True, 

493 on_delete=models.CASCADE, 

494 related_name="main_file", 

495 ) 

496 

497 def get_upload_path(self, filename: str) -> str: 

498 return os.path.join( 

499 "submissions", 

500 str(self.attached_to.submission.pk), 

501 "versions", 

502 str(self.attached_to.number), 

503 filename, 

504 ) 

505 

506 def check_access_right(self, role, right_code: str) -> bool: 

507 """ 

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

509 """ 

510 if right_code == "read": 

511 return role.can_access_version(self.attached_to) 

512 

513 return False 

514 

515 def get_absolute_url(self) -> str: 

516 """ 

517 Returns the URL to the model's file. 

518 """ 

519 if not self.file.url: 

520 return "" 

521 file_identifier = self.get_file_identifier() 

522 version_pk = self.attached_to.pk 

523 submission_pk = self.attached_to.submission.pk 

524 return reverse( 

525 "mesh:serve_submission_version_main_file", 

526 kwargs={ 

527 "file_identifier": file_identifier, 

528 "version_pk": version_pk, 

529 "submission_pk": submission_pk, 

530 }, 

531 ) 

532 

533 

534class SubmissionAdditionalFile(BaseFileWrapperModel[SubmissionVersion]): 

535 file_extensions = [ 

536 ".pdf", 

537 ".docx", 

538 ".odt", 

539 ".py", 

540 ".jpg", 

541 ".png", 

542 ".ipynb", 

543 ".sql", 

544 ".tex", 

545 ] 

546 attached_to = models.ForeignKey( 

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

548 ) 

549 

550 def get_upload_path(self, filename: str) -> str: 

551 return os.path.join( 

552 "submissions", 

553 str(self.attached_to.submission.pk), 

554 "versions", 

555 str(self.attached_to.number), 

556 "additional", 

557 filename, 

558 ) 

559 

560 def check_access_right(self, role, right_code: str) -> bool: 

561 """ 

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

563 """ 

564 if right_code == "read": 

565 return role.can_access_version(self.attached_to) 

566 

567 elif right_code in ["delete"]: 

568 return role.can_edit_version(self.attached_to) 

569 

570 return False 

571 

572 def get_absolute_url(self) -> str: 

573 """ 

574 Returns the URL to the model's file. 

575 """ 

576 if not self.file.url: 

577 return "" 

578 file_identifier = self.get_file_identifier() 

579 version_pk = self.attached_to.pk 

580 submission_pk = self.attached_to.submission.pk 

581 return reverse( 

582 "mesh:serve_submission_version_additional_file", 

583 kwargs={ 

584 "file_identifier": file_identifier, 

585 "version_pk": version_pk, 

586 "submission_pk": submission_pk, 

587 }, 

588 ) 

589 

590 

591class SubmissionLog(ModelLog): 

592 attached_to = models.ForeignKey( 

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

594 ) 

595 

596 

597class SubmissionAuthor(BaseChangeTrackingModel): 

598 """ 

599 Model for a submission's author. 

600 1 submission author is linked to 1 submission only. 

601 """ 

602 

603 submission = models.ForeignKey( 

604 Submission, 

605 on_delete=models.CASCADE, 

606 null=False, 

607 editable=False, 

608 related_name="authors", 

609 ) 

610 first_name = models.CharField( 

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

612 ) 

613 last_name = models.CharField( 

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

615 ) 

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

617 corresponding = models.BooleanField( 

618 verbose_name=_("Corresponding contact"), 

619 blank=False, 

620 null=False, 

621 default=False, 

622 help_text=_( 

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

624 ), 

625 ) 

626 

627 # class Meta: 

628 # constraints = [ 

629 # models.UniqueConstraint( 

630 # fields=["submission", "email"], 

631 # name="unique_author_email_per_submission", 

632 # ) 

633 # ] 

634 

635 class Meta: 

636 ordering = ["last_name"] 

637 

638 def __str__(self) -> str: 

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

640 

641 def full_name(self) -> str: 

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