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

187 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-10 09:11 +0000

1from __future__ import annotations 

2 

3import os 

4import re 

5from enum import Enum, unique 

6from typing import TYPE_CHECKING, Self 

7 

8from django.conf import settings 

9from django.db import models 

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

11from django.utils import timezone 

12from django.utils.translation import gettext_lazy as _ 

13 

14from mesh.model.exceptions import SubmissionStateError 

15 

16from .base_models import BaseChangeTrackingModel, BaseSubmittableModel 

17from .file_models import BaseFileWrapperModel, BaseModelWithFiles 

18from .log_models import ModelLog 

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.model.roles.role_handler import RoleHandler 

25 from mesh.models.editorial_models import EditorSubmissionRight 

26 

27 from .editorial_models import EditorialDecision 

28 from .user_models import User 

29 

30 

31@unique 

32class SubmissionState(Enum): 

33 """ 

34 Enum of the submission statees. 

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

36 """ 

37 

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

39 # review yet. 

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

41 OPENED = "opened" 

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

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

44 SUBMITTED = "submitted" 

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

46 # (accept/reject/open review round) 

47 ON_REVIEW = "review" 

48 # REVISION REQUESTED Editor requested revisions. 

49 # Waiting for author to submit new version 

50 REVISION_REQUESTED = "rev_requested" 

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

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

53 REVISION_SUBMITTED = "rev_submited" 

54 # ACCEPTED Editor accepted the submission as it is. 

55 # Begin Copyediting process. 

56 ACCEPTED = "accepted" 

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

58 REJECTED = "rejected" 

59 

60 

61SUBMISSION_STATE_CHOICES = [ 

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

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

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

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

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

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

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

69] 

70 

71SUBMISSION_STATE_EDITOR_CHOICES = [ 

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

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

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

75] 

76 

77 

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

79# when using any SubmissionManager methods. 

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

81 def annotate_last_activity(self) -> Self: 

82 """ 

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

84 significant log entry. 

85 """ 

86 return self.annotate( 

87 log_significant=FilteredRelation( 

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

89 ) 

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

91 

92 def prefetch_data(self) -> Self: 

93 """ 

94 Shortcut function to prefetch all related MtoM data. 

95 """ 

96 return self.prefetch_related( 

97 "versions", 

98 "versions__main_file", 

99 "versions__additional_files", 

100 "versions__reviews", 

101 "versions__reviews__additional_files", 

102 "authors", 

103 "editors", 

104 "editors__user", 

105 ) 

106 

107 def select_data(self) -> Self: 

108 """ 

109 Shortcut function to select all related FK. 

110 """ 

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

112 

113 

114class SubmissionManager(models.Manager["Submission"]): 

115 def get_queryset(self): 

116 return ( 

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

118 ) 

119 

120 def get_submissions(self, prefetch=True): 

121 """ 

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

123 """ 

124 if not prefetch: 

125 return self.get_queryset() 

126 return self.get_queryset().prefetch_data() 

127 

128 

129class Submission(BaseChangeTrackingModel): 

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

131 created_by = models.ForeignKey( 

132 settings.AUTH_USER_MODEL, 

133 verbose_name=_("Created by"), 

134 on_delete=models.PROTECT, 

135 null=False, 

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

137 editable=False, 

138 related_name="+", 

139 ) 

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

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

142 journal_section = models.ForeignKey( 

143 "JournalSection", 

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

145 on_delete=models.SET_NULL, 

146 related_name="submissions", 

147 null=True, 

148 ) 

149 state = models.CharField( 

150 verbose_name=_("state"), 

151 max_length=64, 

152 choices=SUBMISSION_STATE_CHOICES, 

153 default=SubmissionState.OPENED.value, 

154 ) 

155 date_first_version = models.DateTimeField( 

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

157 ) 

158 author_agreement = models.BooleanField( 

159 verbose_name=_("Agreement"), 

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

161 ) 

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

163 

164 objects: SubmissionManager = SubmissionManager() # type: ignore 

165 versions: "Manager[SubmissionVersion]" 

166 editors: "Manager[EditorSubmissionRight]" 

167 authors: "Manager[SubmissionAuthor]" 

168 # class Meta: 

169 # constraints = [ 

170 # models.UniqueConstraint( 

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

172 # ) 

173 # ] 

174 

175 def __str__(self) -> str: 

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

177 

178 @property 

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

180 """ 

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

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

183 the versions should be already prefetched). 

184 """ 

185 versions = self.versions.all() 

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

187 

188 @property 

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

190 """ 

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

192 """ 

193 all_versions = self.all_versions 

194 if all_versions: 

195 return all_versions[0] 

196 return None 

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 @property 

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

223 return sorted(self.authors.all(), key=lambda a: a.first_name) 

224 

225 @property 

226 def is_submittable(self) -> bool: 

227 """ 

228 Whether the submission is submittable. 

229 It checks that the required data is correct. 

230 """ 

231 return ( 

232 self.author_agreement is True 

233 and self.state 

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

235 and self.current_version is not None 

236 and not self.current_version.submitted 

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

238 and len(self.all_authors) > 0 

239 ) 

240 

241 @property 

242 def is_draft(self) -> bool: 

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

244 

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

246 """ 

247 Submit the submission's current version: 

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

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

250 according to the current state. 

251 - Add an entry to the submission log 

252 

253 Raise an SubmissionStateError is the submission is not submittable. 

254 

255 Params: 

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

257 current user and potential impersonate data. 

258 """ 

259 if not self.is_submittable: 

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

261 

262 code = self.state 

263 

264 self.state = ( 

265 SubmissionState.SUBMITTED.value 

266 if self.is_draft 

267 else SubmissionState.REVISION_SUBMITTED.value 

268 ) 

269 self.save() 

270 

271 version = self.current_version 

272 if version is None: 

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

274 version.submitted = True 

275 version.date_submitted = timezone.now() 

276 version._user = user 

277 version.review_open = True 

278 version.save() 

279 

280 self.state = SubmissionState.ON_REVIEW.value 

281 self.save() 

282 

283 SubmissionLog.add_message( 

284 self, 

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

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

287 user=user, 

288 # impersonate_data=impersonate_data, 

289 significant=True, 

290 code=code, 

291 ) 

292 

293 @property 

294 def is_reviewable(self) -> bool: 

295 """ 

296 Returns whether the submission can be sent to review. 

297 """ 

298 return ( 

299 self.state 

300 in [ 

301 SubmissionState.SUBMITTED.value, 

302 SubmissionState.REVISION_SUBMITTED.value, 

303 ] 

304 and self.current_version is not None 

305 and self.current_version.submitted 

306 and self.current_version.review_open is False 

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

308 ) 

309 

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

311 """ 

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

313 - Change the submission state to ON_REVIEW 

314 - Open the review on the current version 

315 - Add an entry to the submission log. 

316 

317 Raise an exception is the submission is not reviewable. 

318 

319 Params: 

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

321 current user and potential impersonate data. 

322 """ 

323 if not self.is_reviewable: 

324 raise SubmissionStateError( 

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

326 ) 

327 version = self.current_version 

328 if version is None: 

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

330 version.review_open = True 

331 version.save() 

332 

333 code = self.state 

334 self.state = SubmissionState.ON_REVIEW.value 

335 self.save() 

336 

337 SubmissionLog.add_message( 

338 self, 

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

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

341 user=user, 

342 # impersonate_data=impersonate_data, 

343 significant=True, 

344 code=code, 

345 ) 

346 

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

348 """ 

349 Apply an editorial decision: 

350 - Changes the submission state to the selected state. 

351 - Close the review on the current version. 

352 - Add an entry to the submission log 

353 

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

355 

356 Params: 

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

358 current user and potential impersonate data. 

359 """ 

360 if self.is_draft: 

361 raise SubmissionStateError( 

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

363 ) 

364 # Update the submission state with the selected one 

365 code = self.state 

366 self.state = decision.value 

367 self.save() 

368 

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

370 version = self.current_version 

371 if version: 

372 version.review_open = False 

373 version.save() 

374 

375 # Add message and log entry 

376 decision_str = decision.get_value_display() 

377 SubmissionLog.add_message( 

378 self, 

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

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

381 user=user, 

382 significant=True, 

383 code=code, 

384 ) 

385 

386 

387class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles): 

388 """ 

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

390 """ 

391 

392 file_fields_required = ["main_file"] 

393 file_fields_deletable = ["additional_files"] 

394 

395 submission = models.ForeignKey( 

396 Submission, 

397 on_delete=models.CASCADE, 

398 null=False, 

399 editable=False, 

400 related_name="versions", 

401 ) 

402 number = models.IntegerField( 

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

404 null=False, 

405 editable=False, 

406 ) 

407 

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

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

410 # requires an editorial action. 

411 review_open = models.BooleanField( 

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

413 ) 

414 

415 class Meta: # type: ignore 

416 constraints = [ 

417 models.UniqueConstraint( 

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

419 ), 

420 models.UniqueConstraint( 

421 fields=["submission"], 

422 condition=Q(review_open=True), 

423 name="unique_review_open_submission_version", 

424 ), 

425 ] 

426 

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

428 """ 

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

430 """ 

431 if self._state.adding: 

432 current_version = self.submission.current_version 

433 if current_version: 

434 self.number = current_version.number + 1 

435 else: 

436 self.number = 1 

437 

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

439 

440 

441class SubmissionMainFile(BaseFileWrapperModel[SubmissionVersion]): 

442 file_extensions = [".pdf"] 

443 

444 attached_to = models.OneToOneField( 

445 SubmissionVersion, 

446 primary_key=True, 

447 on_delete=models.CASCADE, 

448 related_name="main_file", 

449 ) 

450 

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

452 return os.path.join( 

453 "submissions", 

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

455 "versions", 

456 str(self.attached_to.number), 

457 filename, 

458 ) 

459 

460 @classmethod 

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

462 """ 

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

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

465 

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

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

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

469 """ 

470 regex = "/".join( 

471 [ 

472 "submissions", 

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

474 "versions", 

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

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

477 ] 

478 ) 

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

480 if not match: 

481 return None 

482 

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

484 

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

486 """ 

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

488 """ 

489 if right_code == "read": 

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

491 

492 return False 

493 

494 

495class SubmissionAdditionalFile(BaseFileWrapperModel[SubmissionVersion]): 

496 file_extensions = [ 

497 ".pdf", 

498 ".docx", 

499 ".odt", 

500 ".py", 

501 ".jpg", 

502 ".png", 

503 ".ipynb", 

504 ".sql", 

505 ".tex", 

506 ] 

507 attached_to = models.ForeignKey( 

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

509 ) 

510 

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

512 return os.path.join( 

513 "submissions", 

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

515 "versions", 

516 str(self.attached_to.number), 

517 "additional", 

518 filename, 

519 ) 

520 

521 @classmethod 

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

523 """ 

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

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

526 """ 

527 regex = "/".join( 

528 [ 

529 "submissions", 

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

531 "versions", 

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

533 "additional", 

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

535 ] 

536 ) 

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

538 if not match: 

539 return None 

540 

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

542 

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

544 """ 

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

546 """ 

547 if right_code == "read": 

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

549 

550 elif right_code in ["delete"]: 

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

552 

553 return False 

554 

555 

556class SubmissionLog(ModelLog): 

557 attached_to = models.ForeignKey( 

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

559 ) 

560 

561 

562class SubmissionAuthor(BaseChangeTrackingModel): 

563 """ 

564 Model for a submission's author. 

565 1 submission author is linked to 1 submission only. 

566 """ 

567 

568 submission = models.ForeignKey( 

569 Submission, 

570 on_delete=models.CASCADE, 

571 null=False, 

572 editable=False, 

573 related_name="authors", 

574 ) 

575 first_name = models.CharField( 

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

577 ) 

578 last_name = models.CharField( 

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

580 ) 

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

582 corresponding = models.BooleanField( 

583 verbose_name=_("Corresponding contact"), 

584 blank=False, 

585 null=False, 

586 default=False, 

587 help_text=_( 

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

589 ), 

590 ) 

591 

592 # class Meta: 

593 # constraints = [ 

594 # models.UniqueConstraint( 

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

596 # name="unique_author_email_per_submission", 

597 # ) 

598 # ] 

599 

600 def __str__(self) -> str: 

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

602 

603 def full_name(self) -> str: 

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