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

223 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-22 07:20 +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 mesh.models.orm.editorial_models import EditorSubmissionRight 

25 from mesh.models.orm.review_models import Review 

26 

27 from .editorial_models import EditorialDecision 

28 

29logger = logging.getLogger(__name__) 

30 

31 

32@unique 

33class SubmissionState(Enum): 

34 """ 

35 Enum of the submission statees. 

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

37 """ 

38 

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

40 # review yet. 

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

42 OPENED = "opened" 

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

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

45 SUBMITTED = "submitted" 

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

47 # (accept/reject/open review round) 

48 ON_REVIEW = "review" 

49 # REVISION REQUESTED Editor requested revisions. 

50 # Waiting for author to submit new version 

51 REVISION_REQUESTED = "rev_requested" 

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

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

54 REVISION_SUBMITTED = "rev_submited" 

55 # ACCEPTED Editor accepted the submission as it is. 

56 # Begin Copyediting process. 

57 ACCEPTED = "accepted" 

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

59 REJECTED = "rejected" 

60 

61 

62SUBMISSION_STATE_CHOICES = [ 

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

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

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

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

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

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

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

70] 

71 

72SUBMISSION_STATE_EDITOR_CHOICES = [ 

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

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

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

76] 

77 

78 

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

80# when using any SubmissionManager methods. 

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

82 def annotate_last_activity(self) -> Self: 

83 """ 

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

85 significant log entry. 

86 """ 

87 return self.annotate( 

88 log_significant=FilteredRelation( 

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

90 ) 

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

92 

93 def prefetch_data(self) -> Self: 

94 """ 

95 Shortcut function to prefetch all related MtoM data. 

96 """ 

97 return self.prefetch_related( 

98 "editors", 

99 "editors__user", 

100 ) 

101 

102 def select_data(self) -> Self: 

103 """ 

104 Shortcut function to select all related FK. 

105 """ 

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

107 

108 

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

110 def get_queryset(self): 

111 return ( 

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

113 .annotate_last_activity() 

114 .prefetch_data() 

115 .select_data() 

116 ) 

117 

118 

119class Submission(BaseChangeTrackingModel): 

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

121 created_by = models.ForeignKey( 

122 User, 

123 verbose_name=_("Created by"), 

124 on_delete=models.PROTECT, 

125 null=False, 

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

127 editable=False, 

128 related_name="+", 

129 ) 

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

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

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

133 "JournalSection", 

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

135 on_delete=models.SET_NULL, 

136 related_name="submissions", 

137 null=True, 

138 ) 

139 state = models.CharField( 

140 verbose_name=_("state"), 

141 max_length=64, 

142 choices=SUBMISSION_STATE_CHOICES, 

143 default=SubmissionState.OPENED.value, 

144 ) 

145 date_first_version = models.DateTimeField( 

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

147 ) 

148 author_agreement = models.BooleanField( 

149 verbose_name=_("Agreement"), 

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

151 ) 

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

153 

154 ojs_id = models.PositiveIntegerField(null=True) 

155 

156 objects = PrefetchedSubmissionManager() 

157 

158 # RelatedObjects 

159 versions: Manager[SubmissionVersion] 

160 editors: Manager[EditorSubmissionRight] 

161 authors: Manager[SubmissionAuthor] 

162 log_messages: Manager[SubmissionLog] 

163 

164 # Annotated properties 

165 user_is_editor: bool 

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

167 authors_string: str 

168 "Annotated" 

169 created_by_censored: str 

170 "Annotated" 

171 authors_censored: list[SubmissionAuthor] 

172 "Annotated" 

173 versions_censored: list[SubmissionVersion] 

174 "Annotated" 

175 log_messages_censored: list[SubmissionLog] 

176 "Annotated" 

177 

178 # class Meta: 

179 # constraints = [ 

180 # models.UniqueConstraint( 

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

182 # ) 

183 # ] 

184 

185 def __str__(self) -> str: 

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

187 

188 def get_current_version(self): 

189 """ 

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

191 """ 

192 if not self.versions_censored: 

193 return None 

194 return self.versions_censored[0] 

195 

196 @property 

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

198 """ 

199 Submission date of the submission. 

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

201 if no versions are submitted yet. 

202 """ 

203 return self.date_first_version or self.date_created 

204 

205 @property 

206 def state_order(self) -> int: 

207 """ 

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

209 """ 

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

211 

212 @property 

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

214 return sorted( 

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

216 key=lambda u: u.first_name, 

217 ) 

218 

219 def is_submittable(self) -> bool: 

220 """ 

221 Whether the submission is submittable. 

222 It checks that the required data is correct. 

223 """ 

224 if self.author_agreement is False: 

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

226 return False 

227 if self.state not in [ 

228 SubmissionState.OPENED.value, 

229 SubmissionState.REVISION_REQUESTED.value, 

230 ]: 

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

232 return False 

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

234 logger.debug( 

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

236 ) 

237 return False 

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

239 logger.debug( 

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

241 ) 

242 return False 

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

244 logger.debug( 

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

246 ) 

247 return False 

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

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

250 return False 

251 return True 

252 

253 @property 

254 def is_draft(self) -> bool: 

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

256 

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

258 """ 

259 Submit the submission's current version: 

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

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

262 according to the current state. 

263 - Add an entry to the submission log 

264 

265 Raise an SubmissionStateError is the submission is not submittable. 

266 

267 Params: 

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

269 current user and potential impersonate data. 

270 """ 

271 if not self.is_submittable(): 

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

273 

274 if date_submitted is None: 

275 date_submitted = timezone.now() 

276 

277 code = self.state 

278 

279 self.state = ( 

280 SubmissionState.SUBMITTED.value 

281 if self.is_draft 

282 else SubmissionState.REVISION_SUBMITTED.value 

283 ) 

284 self.save() 

285 

286 version = self.versions.first() 

287 if version is None: 

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

289 version.submitted = True 

290 

291 version.date_submitted = date_submitted 

292 version._user = user 

293 version.review_open = True 

294 version.save() 

295 

296 self.state = SubmissionState.ON_REVIEW.value 

297 self.save() 

298 

299 SubmissionLog.add_message( 

300 self, 

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

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

303 user=user, 

304 significant=True, 

305 code=code, 

306 date=date_submitted, 

307 html_class="success-log", 

308 round_number=version.number, 

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 html_class="info-log", 

410 # date=date, 

411 ) 

412 

413 def get_absolute_url(self): 

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

415 

416 

417class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles): 

418 """ 

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

420 """ 

421 

422 file_fields_required = ["main_file"] 

423 file_fields_deletable = ["additional_files"] 

424 

425 submission = models.ForeignKey( 

426 Submission, 

427 on_delete=models.CASCADE, 

428 null=False, 

429 editable=False, 

430 related_name="versions", 

431 ) 

432 number = models.IntegerField( 

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

434 null=False, 

435 editable=False, 

436 ) 

437 # reviews: "Manager[Review]" 

438 # main_file: "SubmissionMainFile" 

439 # additional_files: "Manager[SubmissionAdditionalFile]" 

440 reviews: Manager[Review] 

441 main_file: SubmissionMainFile 

442 additional_files: Manager[SubmissionAdditionalFile] 

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

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

445 # requires an editorial action. 

446 review_open = models.BooleanField( 

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

448 ) 

449 

450 # RelatedManagers 

451 editorial_decision: EditorialDecision | None 

452 # Annotated properties 

453 created_by_censored: str | User 

454 reviews_censored: list[Review] 

455 

456 class Meta: # type: ignore 

457 constraints = [ 

458 models.UniqueConstraint( 

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

460 ), 

461 models.UniqueConstraint( 

462 fields=["submission"], 

463 condition=Q(review_open=True), 

464 name="unique_review_open_submission_version", 

465 ), 

466 ] 

467 ordering = ["-number"] 

468 

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

470 """ 

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

472 """ 

473 if self._state.adding: 

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

475 if current_version: 

476 self.number = current_version.number + 1 

477 else: 

478 self.number = 1 

479 

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

481 

482 

483class SubmissionMainFile(BaseFileWrapperModel[SubmissionVersion]): 

484 file_extensions = [".pdf"] 

485 

486 attached_to = models.OneToOneField( 

487 SubmissionVersion, 

488 primary_key=True, 

489 on_delete=models.CASCADE, 

490 related_name="main_file", 

491 ) 

492 

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

494 return os.path.join( 

495 "submissions", 

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

497 "versions", 

498 str(self.attached_to.number), 

499 filename, 

500 ) 

501 

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

503 """ 

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

505 """ 

506 if right_code == "read": 

507 return role.can_access_version(self.attached_to) 

508 

509 return False 

510 

511 def get_absolute_url(self) -> str: 

512 """ 

513 Returns the URL to the model's file. 

514 """ 

515 if not self.file.url: 

516 return "" 

517 file_identifier = self.get_file_identifier() 

518 version_pk = self.attached_to.pk 

519 submission_pk = self.attached_to.submission.pk 

520 return reverse( 

521 "mesh:serve_submission_version_main_file", 

522 kwargs={ 

523 "file_identifier": file_identifier, 

524 "version_pk": version_pk, 

525 "submission_pk": submission_pk, 

526 }, 

527 ) 

528 

529 

530class SubmissionAdditionalFile(BaseFileWrapperModel[SubmissionVersion]): 

531 file_extensions = [ 

532 ".pdf", 

533 ".docx", 

534 ".odt", 

535 ".py", 

536 ".jpg", 

537 ".png", 

538 ".ipynb", 

539 ".sql", 

540 ".tex", 

541 ] 

542 attached_to = models.ForeignKey( 

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

544 ) 

545 

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

547 return os.path.join( 

548 "submissions", 

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

550 "versions", 

551 str(self.attached_to.number), 

552 "additional", 

553 filename, 

554 ) 

555 

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

557 """ 

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

559 """ 

560 if right_code == "read": 

561 return role.can_access_version(self.attached_to) 

562 

563 elif right_code in ["delete"]: 

564 return role.can_edit_version(self.attached_to) 

565 

566 return False 

567 

568 def get_absolute_url(self) -> str: 

569 """ 

570 Returns the URL to the model's file. 

571 """ 

572 if not self.file.url: 

573 return "" 

574 file_identifier = self.get_file_identifier() 

575 version_pk = self.attached_to.pk 

576 submission_pk = self.attached_to.submission.pk 

577 return reverse( 

578 "mesh:serve_submission_version_additional_file", 

579 kwargs={ 

580 "file_identifier": file_identifier, 

581 "version_pk": version_pk, 

582 "submission_pk": submission_pk, 

583 }, 

584 ) 

585 

586 

587class SubmissionLog(ModelLog): 

588 attached_to = models.ForeignKey( 

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

590 ) 

591 round_number = models.TextField(blank=True, null=True) 

592 

593 

594class SubmissionAuthor(BaseChangeTrackingModel): 

595 """ 

596 Model for a submission's author. 

597 1 submission author is linked to 1 submission only. 

598 """ 

599 

600 submission = models.ForeignKey( 

601 Submission, 

602 on_delete=models.CASCADE, 

603 null=False, 

604 editable=False, 

605 related_name="authors", 

606 ) 

607 first_name = models.CharField( 

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

609 ) 

610 last_name = models.CharField( 

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

612 ) 

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

614 corresponding = models.BooleanField( 

615 verbose_name=_("Corresponding contact"), 

616 blank=False, 

617 null=False, 

618 default=False, 

619 help_text=_( 

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

621 ), 

622 ) 

623 

624 # class Meta: 

625 # constraints = [ 

626 # models.UniqueConstraint( 

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

628 # name="unique_author_email_per_submission", 

629 # ) 

630 # ] 

631 

632 class Meta: 

633 ordering = ["last_name"] 

634 

635 def __str__(self) -> str: 

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

637 

638 def full_name(self) -> str: 

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