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

225 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-08-11 09:47 +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 significant=True, 

307 code=code, 

308 date=date_submitted, 

309 ) 

310 

311 def is_reviewable(self) -> bool: 

312 """ 

313 Returns whether the submission can be sent to review. 

314 """ 

315 current_version = ( 

316 self.versions_censored[0] 

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

318 else None 

319 ) 

320 return ( 

321 self.state 

322 in [ 

323 SubmissionState.SUBMITTED.value, 

324 SubmissionState.REVISION_SUBMITTED.value, 

325 ] 

326 and current_version is not None 

327 and current_version.submitted 

328 and current_version.review_open is False 

329 and hasattr(current_version, "main_file") 

330 ) 

331 

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

333 """ 

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

335 - Change the submission state to ON_REVIEW 

336 - Open the review on the current version 

337 - Add an entry to the submission log. 

338 

339 Raise an exception is the submission is not reviewable. 

340 

341 Params: 

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

343 current user and potential impersonate data. 

344 """ 

345 if not self.is_reviewable(): 

346 raise SubmissionStateError( 

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

348 ) 

349 version = self.versions.first() 

350 if version is None: 

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

352 version.review_open = True 

353 version.save() 

354 

355 code = self.state 

356 self.state = SubmissionState.ON_REVIEW.value 

357 self.save() 

358 

359 SubmissionLog.add_message( 

360 self, 

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

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

363 user=user, 

364 significant=True, 

365 code=code, 

366 date=date, 

367 ) 

368 

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

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

371 """ 

372 Apply an editorial decision: 

373 - Changes the submission state to the selected state. 

374 - Close the review on the current version. 

375 - Add an entry to the submission log 

376 

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

378 

379 Params: 

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

381 current user and potential impersonate data. 

382 """ 

383 if self.is_draft: 

384 raise SubmissionStateError( 

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

386 ) 

387 # Update the submission state with the selected one 

388 code = self.state 

389 self.state = decision.value 

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

391 self.save() 

392 

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

394 version = self.versions.first() 

395 if version: 

396 version.review_open = False 

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

398 version.save() 

399 

400 # Add message and log entry 

401 decision_str = decision.get_value_display() 

402 SubmissionLog.add_message( 

403 self, 

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

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

406 user=user, 

407 significant=True, 

408 code=code, 

409 # date=date, 

410 ) 

411 

412 def get_absolute_url(self): 

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

414 

415 

416class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles): 

417 """ 

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

419 """ 

420 

421 file_fields_required = ["main_file"] 

422 file_fields_deletable = ["additional_files"] 

423 

424 submission = models.ForeignKey( 

425 Submission, 

426 on_delete=models.CASCADE, 

427 null=False, 

428 editable=False, 

429 related_name="versions", 

430 ) 

431 number = models.IntegerField( 

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

433 null=False, 

434 editable=False, 

435 ) 

436 # reviews: "Manager[Review]" 

437 # main_file: "SubmissionMainFile" 

438 # additional_files: "Manager[SubmissionAdditionalFile]" 

439 reviews: Manager[Review] 

440 main_file: SubmissionMainFile 

441 additional_files: Manager[SubmissionAdditionalFile] 

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

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

444 # requires an editorial action. 

445 review_open = models.BooleanField( 

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

447 ) 

448 

449 # RelatedManagers 

450 additional_files: RelatedManager[SubmissionAdditionalFile] 

451 main_file: SubmissionMainFile 

452 editorial_decision: EditorialDecision | None 

453 reviews: RelatedManager[Review] 

454 # Annotated properties 

455 created_by_censored: str | User 

456 reviews_censored: list[Review] 

457 

458 class Meta: # type: ignore 

459 constraints = [ 

460 models.UniqueConstraint( 

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

462 ), 

463 models.UniqueConstraint( 

464 fields=["submission"], 

465 condition=Q(review_open=True), 

466 name="unique_review_open_submission_version", 

467 ), 

468 ] 

469 ordering = ["-number"] 

470 

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

472 """ 

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

474 """ 

475 if self._state.adding: 

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

477 if current_version: 

478 self.number = current_version.number + 1 

479 else: 

480 self.number = 1 

481 

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

483 

484 

485class SubmissionMainFile(BaseFileWrapperModel[SubmissionVersion]): 

486 file_extensions = [".pdf"] 

487 

488 attached_to = models.OneToOneField( 

489 SubmissionVersion, 

490 primary_key=True, 

491 on_delete=models.CASCADE, 

492 related_name="main_file", 

493 ) 

494 

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

496 return os.path.join( 

497 "submissions", 

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

499 "versions", 

500 str(self.attached_to.number), 

501 filename, 

502 ) 

503 

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

505 """ 

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

507 """ 

508 if right_code == "read": 

509 return role.can_access_version(self.attached_to) 

510 

511 return False 

512 

513 def get_absolute_url(self) -> str: 

514 """ 

515 Returns the URL to the model's file. 

516 """ 

517 if not self.file.url: 

518 return "" 

519 file_identifier = self.get_file_identifier() 

520 version_pk = self.attached_to.pk 

521 submission_pk = self.attached_to.submission.pk 

522 return reverse( 

523 "mesh:serve_submission_version_main_file", 

524 kwargs={ 

525 "file_identifier": file_identifier, 

526 "version_pk": version_pk, 

527 "submission_pk": submission_pk, 

528 }, 

529 ) 

530 

531 

532class SubmissionAdditionalFile(BaseFileWrapperModel[SubmissionVersion]): 

533 file_extensions = [ 

534 ".pdf", 

535 ".docx", 

536 ".odt", 

537 ".py", 

538 ".jpg", 

539 ".png", 

540 ".ipynb", 

541 ".sql", 

542 ".tex", 

543 ] 

544 attached_to = models.ForeignKey( 

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

546 ) 

547 

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

549 return os.path.join( 

550 "submissions", 

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

552 "versions", 

553 str(self.attached_to.number), 

554 "additional", 

555 filename, 

556 ) 

557 

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

559 """ 

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

561 """ 

562 if right_code == "read": 

563 return role.can_access_version(self.attached_to) 

564 

565 elif right_code in ["delete"]: 

566 return role.can_edit_version(self.attached_to) 

567 

568 return False 

569 

570 def get_absolute_url(self) -> str: 

571 """ 

572 Returns the URL to the model's file. 

573 """ 

574 if not self.file.url: 

575 return "" 

576 file_identifier = self.get_file_identifier() 

577 version_pk = self.attached_to.pk 

578 submission_pk = self.attached_to.submission.pk 

579 return reverse( 

580 "mesh:serve_submission_version_additional_file", 

581 kwargs={ 

582 "file_identifier": file_identifier, 

583 "version_pk": version_pk, 

584 "submission_pk": submission_pk, 

585 }, 

586 ) 

587 

588 

589class SubmissionLog(ModelLog): 

590 attached_to = models.ForeignKey( 

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

592 ) 

593 

594 

595class SubmissionAuthor(BaseChangeTrackingModel): 

596 """ 

597 Model for a submission's author. 

598 1 submission author is linked to 1 submission only. 

599 """ 

600 

601 submission = models.ForeignKey( 

602 Submission, 

603 on_delete=models.CASCADE, 

604 null=False, 

605 editable=False, 

606 related_name="authors", 

607 ) 

608 first_name = models.CharField( 

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

610 ) 

611 last_name = models.CharField( 

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

613 ) 

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

615 corresponding = models.BooleanField( 

616 verbose_name=_("Corresponding contact"), 

617 blank=False, 

618 null=False, 

619 default=False, 

620 help_text=_( 

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

622 ), 

623 ) 

624 

625 # class Meta: 

626 # constraints = [ 

627 # models.UniqueConstraint( 

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

629 # name="unique_author_email_per_submission", 

630 # ) 

631 # ] 

632 

633 class Meta: 

634 ordering = ["last_name"] 

635 

636 def __str__(self) -> str: 

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

638 

639 def full_name(self) -> str: 

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