diff --git a/scipost_django/common/faker.py b/scipost_django/common/faker.py index 64200d06a8daca0d2611c36ae2b86a102a7187d9..72ea6f630ea7f0807ce9becfbbd0e1eec20d22c7 100644 --- a/scipost_django/common/faker.py +++ b/scipost_django/common/faker.py @@ -3,6 +3,8 @@ __license__ = "AGPL v3" import random from datetime import datetime, timedelta +from django.db.models import QuerySet +from django.db.models.base import ModelBase from django.utils.timezone import make_aware import factory @@ -31,6 +33,25 @@ class LazyRandEnum(factory.LazyAttribute): return [random.choice(self.enum)[0] for _ in range(self.repeat)] +class LazyRandInstance(factory.LazyAttribute): + """ + Define a lazy attribute that takes a random instance from a Django model. + The first argument can be either the model class or a query set. + The second argument is the number of instances to return. + """ + + def __init__(self, model_qs: ModelBase | QuerySet, repeat=1, *args, **kwargs): + self.qs = model_qs if isinstance(model_qs, QuerySet) else model_qs.objects.all() + self.repeat = repeat + super().__init__(function=self._random_instance, *args, **kwargs) + + def _random_instance(self, _): + if self.repeat == 1: + return self.qs.order_by("?").first() + else: + return self.qs.order_by("?")[: self.repeat] + + class LazyObjectCount(factory.LazyAttribute): """ Define a lazy attribute that returns the total number of objects of a model. @@ -97,8 +118,18 @@ class TZAwareDateAccessor: setattr(self, attr, aware_wrapper(func=getattr(parent_obj, attr))) +def _get_random_instance(model_qs: ModelBase | QuerySet, repeat=1): + qs = model_qs if isinstance(model_qs, QuerySet) else model_qs.objects.all() + if repeat == 1: + return qs.order_by("?").first() + else: + return qs.order_by("?")[:repeat] + + fake = Faker() fake.add_provider(DurationProvider) aware_date_accessor = TZAwareDateAccessor(fake) fake.aware = aware_date_accessor + +fake.random_instance = _get_random_instance diff --git a/scipost_django/journals/factories.py b/scipost_django/journals/factories.py index 9ddc58483d115e99e3f556a46c4247b5d994659e..8952c1fddb800a470441e685c4a8cb6027028fc9 100644 --- a/scipost_django/journals/factories.py +++ b/scipost_django/journals/factories.py @@ -117,6 +117,10 @@ class JournalFactory(factory.django.DjangoModelFactory): ) self.specialties.add(*specialties) + @factory.post_generation + def journal_alternatives(self, create, extracted, **kwargs): + self.alternative_journals.add(*fake.random_instance(Journal, 3)) + class VolumeFactory(factory.django.DjangoModelFactory): in_journal = factory.SubFactory(JournalFactory) diff --git a/scipost_django/preprints/factories.py b/scipost_django/preprints/factories.py index bc137b07464a71d238e5ed326f980dc7cbac3460..0f186a5972fa709a9aaf8d31b9c309c2dff16455 100644 --- a/scipost_django/preprints/factories.py +++ b/scipost_django/preprints/factories.py @@ -10,8 +10,11 @@ from .models import Preprint class PreprintFactory(factory.django.DjangoModelFactory): + identifier_w_vn_nr = factory.Faker("numerify", text="####.####") + class Meta: model = Preprint + django_get_or_create = ("identifier_w_vn_nr",) class Params: arXiv = factory.Trait( diff --git a/scipost_django/submissions/factories/assignment.py b/scipost_django/submissions/factories/assignment.py index 019cde55a6262a17be9880396a4c9e739670cbd7..1f79664afa1553243182821fd71bf10b185d580b 100644 --- a/scipost_django/submissions/factories/assignment.py +++ b/scipost_django/submissions/factories/assignment.py @@ -3,10 +3,11 @@ __license__ = "AGPL v3" import factory +import factory.random from common.faker import LazyRandEnum, fake -from ..models import EditorialAssignment +from ..models.assignment import * class EditorialAssignmentFactory(factory.django.DjangoModelFactory): @@ -33,3 +34,45 @@ class EditorialAssignmentFactory(factory.django.DjangoModelFactory): start_date=self.date_invited, end_date="+10d" ) ) + + +class ConditionalAssignmentOfferFactory(factory.django.DjangoModelFactory): + class Meta: + model = ConditionalAssignmentOffer + django_get_or_create = ("submission", "offered_by") + + submission = factory.SubFactory("submissions.factories.SubmissionFactory") + offered_by = factory.SubFactory("scipost.factories.ContributorFactory") + offered_on = factory.LazyAttribute( + lambda self: fake.aware.date_between( + start_date=self.submission.submission_date, end_date="+60d" + ) + ) + offered_until = factory.LazyAttribute( + lambda self: fake.aware.date_between( + start_date=self.offered_on, end_date="+30d" + ) + ) + + condition_type = LazyRandEnum(ConditionalAssignmentOffer.CONDITION_CHOICES) + + # Add parameter to accept the offer, in which case an acceptance date is set + @factory.post_generation + def accept(self, create, extracted, **kwargs): + if extracted: + self.status = ConditionalAssignmentOffer.STATUS_ACCEPTED + self.accepted_on = fake.aware.date_between( + start_date=self.offered_on, end_date="+10d" + ) + + +class JournalTransferOfferFactory(ConditionalAssignmentOfferFactory): + + condition_type = "JournalTransfer" + condition_details = factory.LazyAttribute( + lambda self: { + "alternative_journal_id": factory.random.random.choice( + self.submission.submitted_to.alternative_journals.all() + ).id + } + ) diff --git a/scipost_django/submissions/factories/submission.py b/scipost_django/submissions/factories/submission.py index 7807bb865bd92f0e4d73c3a4cbc02de0e4468cd5..0cbee29d007aa1dd11b8a05fb450ddb9a7099e2e 100644 --- a/scipost_django/submissions/factories/submission.py +++ b/scipost_django/submissions/factories/submission.py @@ -60,6 +60,7 @@ class SubmissionFactory(factory.django.DjangoModelFactory): class Meta: model = Submission + django_get_or_create = ("preprint",) @factory.post_generation def add_specialties(self, create, extracted, **kwargs): diff --git a/scipost_django/submissions/tests/test_factories.py b/scipost_django/submissions/tests/test_factories.py index eb41fe88e1a44a737b153a1abb9ac962f1fb98c2..b30e0f2ab26a2c6ed2214947031efd6889c0a448 100644 --- a/scipost_django/submissions/tests/test_factories.py +++ b/scipost_django/submissions/tests/test_factories.py @@ -111,3 +111,16 @@ class TestiThenticateReportFactory(TestCase): def test_can_create_ithenticate_reports(self): ithenticate_report = iThenticateReportFactory() self.assertIsNotNone(ithenticate_report) + + +# Conditional Assignment Offer +class TestConditionalAssignmentOfferFactory(TestCase): + def test_can_create_offers(self): + offer = ConditionalAssignmentOfferFactory() + self.assertIsNotNone(offer) + + +class TestJournalTransferOfferFactory(TestCase): + def test_can_create_offers(self): + offer = JournalTransferOfferFactory() + self.assertIsNotNone(offer) diff --git a/scipost_django/submissions/tests/test_models.py b/scipost_django/submissions/tests/test_models.py index a6729a13ce7b83d17a6b0c3b5bc9921139a2c02d..fcd293e8812c138b2a7b06746d37512961375f44 100644 --- a/scipost_django/submissions/tests/test_models.py +++ b/scipost_django/submissions/tests/test_models.py @@ -1,14 +1,138 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" - +from common.faker import fake from django.test import TestCase -# from .factories import ResubmittedSubmissionFactory -# -# -# class NewSubmissionStatusTest(TestCase): -# '''Do tests to check the submission status cycle.''' -# def test_resubmitted_submission(self): -# '''New resubmission.''' -# submission = ResubmittedSubmissionFactory() +from journals.models.journal import Journal +from submissions.factories.assignment import ( + ConditionalAssignmentOfferFactory, + JournalTransferOfferFactory, +) +from submissions.factories.submission import SubmissionFactory +from submissions.models.assignment import ConditionalAssignmentOffer + + +class TestJournalTransferOfferAcceptance(TestCase): + def test_accepting_offer_changes_journal(self) -> None: + offer = JournalTransferOfferFactory( + offered_until=fake.aware.date_time_this_month( + after_now=True, before_now=False + ) + ) + offer.accept(offer.submission.submitted_by) + + # Check the conditions have been applied + alternative_journal = Journal.objects.get( + id=offer.condition_details["alternative_journal_id"] + ) + self.assertEqual(offer.submission.submitted_to, alternative_journal) + + +class TestConditionalAssignmentOfferAcceptance(TestCase): + def setUp(self) -> None: + self.submission = SubmissionFactory() + self.offer = ConditionalAssignmentOfferFactory( + submission=self.submission, + offered_until=fake.aware.date_time_this_year( + before_now=False, after_now=True + ), + condition_details={ + "alternative_journal_id": self.submission.submitted_to.id + }, # Hack temporary fix + ) + + def test_can_accept_offer(self) -> None: + self.offer.accept(self.offer.submission.submitted_by) + + self.assertEqual(self.offer.status, ConditionalAssignmentOffer.STATUS_ACCEPTED) + self.assertIsNotNone(self.offer.accepted_on) + self.assertIsNotNone(self.offer.accepted_by) + + def test_finalizing_offer(self): + other_offers = ConditionalAssignmentOfferFactory.create_batch( + 5, submission=self.submission + ) + + self.offer.accept(self.offer.submission.submitted_by) + self.offer.finalize() + + # Creates an editorial assignment for the self.offering fellow + self.assertIsNotNone( + self.offer.submission.editorial_assignments.filter(to=self.offer.offered_by) + ) + self.assertEqual(self.offer.submission.editor_in_charge, self.offer.offered_by) + self.assertEqual(self.offer.status, ConditionalAssignmentOffer.STATUS_FULFILLED) + + # Implicitly declines all other offers + for other_offer in other_offers: + other_offer.refresh_from_db() + self.assertEqual( + other_offer.status, + ConditionalAssignmentOffer.STATUS_DECLINED, + ) + + +class TestConditionalAssignmentOfferAcceptanceFailure(TestCase): + def setUp(self) -> None: + self.submission = SubmissionFactory() + + def test_cannot_accept_expired_offer(self): + with self.assertRaises(ValueError): + offer = ConditionalAssignmentOfferFactory( + offered_until=fake.aware.date_time_this_year( + before_now=True, after_now=False + ), + condition_details={ + "alternative_journal_id": self.submission.submitted_to.id + }, # Hack temporary fix + ) + offer.accept(offer.submission.submitted_by) + + def test_cannot_accept_already_accepted_offer(self): + offer = ConditionalAssignmentOfferFactory( + offered_until=fake.aware.date_time_this_year( + before_now=False, after_now=True + ), + condition_details={ + "alternative_journal_id": self.submission.submitted_to.id + }, # Hack temporary fix + ) + offer.accept(offer.submission.submitted_by) + with self.assertRaises(ValueError): + offer.accept(offer.submission.submitted_by) + + def test_cannot_accept_declined_offer(self): + offer = ConditionalAssignmentOfferFactory( + offered_until=fake.aware.date_time_this_year( + before_now=False, after_now=True + ), + status=ConditionalAssignmentOffer.STATUS_DECLINED, + condition_details={ + "alternative_journal_id": self.submission.submitted_to.id + }, # Hack temporary fix + ) + with self.assertRaises(ValueError): + offer.accept(offer.submission.submitted_by) + + def test_cannot_accept_later_identical_offer(self): + offer = ConditionalAssignmentOfferFactory( + offered_until=fake.aware.date_time_this_year( + before_now=False, after_now=True + ), + condition_details={ + "alternative_journal_id": self.submission.submitted_to.id + }, # Hack temporary fix + ) + later_offer = ConditionalAssignmentOfferFactory( + submission=offer.submission, + offered_on=fake.aware.date_time_between( + start_date=offer.offered_on, end_date="+30d" + ), + offered_until=offer.offered_until, + condition_type=offer.condition_type, + condition_details=offer.condition_details, + ) + + with self.assertRaises(ValueError): + later_offer.accept(self.submission.submitted_by)