diff --git a/journals/migrations/0072_auto_20190922_1444.py b/journals/migrations/0072_auto_20190922_1444.py new file mode 100644 index 0000000000000000000000000000000000000000..f1dc6557c4e159abd31892c1e30fd0688e06fac1 --- /dev/null +++ b/journals/migrations/0072_auto_20190922_1444.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.8 on 2019-09-22 12:44 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('journals', '0071_remove_publication_domain'), + ] + + operations = [ + migrations.AlterField( + model_name='publication', + name='doi_label', + field=models.CharField(db_index=True, max_length=200, unique=True, validators=[django.core.validators.RegexValidator('[a-zA-Z]+(.\\w+(.[0-9]+(.[0-9]{3,})?)?)?', 'Only valid DOI expressions are allowed: `[a-zA-Z]+(.\\w+(.[0-9]+(.[0-9]{3,})?)?)?`')]), + ), + ] diff --git a/journals/models.py b/journals/models.py index f881bfd1d3bbdebea17ed2235aa386d010fb1b87..5dfd5d6b69c3f6bb2ad0234397fb362d5d963cdb 100644 --- a/journals/models.py +++ b/journals/models.py @@ -1,760 +1,2 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" - - -import datetime -from decimal import Decimal - -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.fields import JSONField -from django.core.exceptions import ValidationError -from django.db import models -from django.db.models import Avg, Min, Sum, F -from django.shortcuts import get_object_or_404 -from django.utils import timezone -from django.urls import reverse - -from .behaviors import ( - doi_journal_validator, doi_volume_validator, doi_issue_validator, doi_publication_validator) -from .constants import ( - STATUS_DRAFT, STATUS_PUBLICLY_OPEN, STATUS_PUBLISHED, ISSUE_STATUSES, - PUBLICATION_PUBLISHED, CCBY4, CC_LICENSES, CC_LICENSES_URI, PUBLICATION_STATUSES, - JOURNAL_STRUCTURE, ISSUES_AND_VOLUMES, ISSUES_ONLY) -from .helpers import paper_nr_string -from .managers import IssueQuerySet, PublicationQuerySet, JournalQuerySet - -from scipost.constants import SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS, SCIPOST_APPROACHES -from scipost.fields import ChoiceArrayField - -from proceedings.models import Proceedings - - -################ -# Journals etc # -################ - - -class PublicationAuthorsTable(models.Model): - """ - PublicationAuthorsTable represents an author of a Publication. - - Fields: - * publication - * profile - * affiliations: for this author/Publication (supersede profile.affiliations) - * order: the ordinal position of this author in this Publication's list of authors. - """ - - publication = models.ForeignKey('journals.Publication', on_delete=models.CASCADE, - related_name='authors') - profile = models.ForeignKey('profiles.Profile', on_delete=models.PROTECT, - blank=True, null=True) - affiliations = models.ManyToManyField('organizations.Organization', blank=True) - order = models.PositiveSmallIntegerField() - - class Meta: - ordering = ('order',) - - def __str__(self): - return str(self.profile) - - def save(self, *args, **kwargs): - """Auto increment order number if not explicitly set.""" - if not self.order: - self.order = self.publication.authors.count() + 1 - return super().save(*args, **kwargs) - - @property - def is_registered(self): - """Check if author is registered at SciPost.""" - return self.profile.contributor is not None - - @property - def first_name(self): - """Return first name of author.""" - return self.profile.first_name - - @property - def last_name(self): - """Return last name of author.""" - return self.profile.last_name - - -class Journal(models.Model): - """Journal is a container of Publications with a unique issn and doi_label. - - Publications may be categorized into issues or issues and volumes. - """ - - name = models.CharField(max_length=256, unique=True) - name_abbrev = models.CharField(max_length=128, default='SciPost [abbrev]', - help_text='Abbreviated name (for use in citations)') - doi_label = models.CharField(max_length=200, unique=True, db_index=True, - validators=[doi_journal_validator]) - issn = models.CharField(max_length=16, default='2542-4653', blank=True) - active = models.BooleanField(default=True) - structure = models.CharField(max_length=2, - choices=JOURNAL_STRUCTURE, default=ISSUES_AND_VOLUMES) - refereeing_period = models.DurationField(default=datetime.timedelta(days=28)) - - objects = JournalQuerySet.as_manager() - - def __str__(self): - return self.name - - def get_absolute_url(self): - """Return Journal's homepage url.""" - return reverse('scipost:landing_page', args=(self.doi_label,)) - - @property - def doi_string(self): - """Return DOI including the SciPost registrant prefix.""" - return '10.21468/' + self.doi_label - - @property - def has_issues(self): - return self.structure in (ISSUES_AND_VOLUMES, ISSUES_ONLY) - - @property - def has_volumes(self): - return self.structure in (ISSUES_AND_VOLUMES) - - def get_issues(self): - if self.structure == ISSUES_AND_VOLUMES: - return Issue.objects.filter(in_volume__in_journal=self).published() - elif self.structure == ISSUES_ONLY: - return self.issues.open_or_published() - return Issue.objects.none() - - def get_latest_issue(self): - """Get latest existing Issue in database irrespective of its status.""" - if self.structure == ISSUES_ONLY: - return self.issues.order_by('-until_date').first() - if self.structure == ISSUES_AND_VOLUMES: - return Issue.objects.filter(in_volume__in_journal=self).order_by('-until_date').first() - return None - - def get_latest_volume(self): - """Get latest existing Volume in database irrespective of its status.""" - if self.structure == ISSUES_AND_VOLUMES: - return self.volumes.order_by('-until_date').first() - return None - - def get_publications(self): - if self.structure == ISSUES_AND_VOLUMES: - return Publication.objects.filter(in_issue__in_volume__in_journal=self) - elif self.structure == ISSUES_ONLY: - return Publication.objects.filter(in_issue__in_journal=self) - return self.publications.all() - - def nr_publications(self, tier=None): - publications = Publication.objects.filter(in_issue__in_volume__in_journal=self) - if tier: - publications = publications.filter( - accepted_submission__eicrecommendations__recommendation=tier) - return publications.count() - - def avg_processing_duration(self): - duration = Publication.objects.filter( - in_issue__in_volume__in_journal=self).aggregate( - avg=Avg(F('publication_date') - F('submission_date')))['avg'] - if duration: - return duration.total_seconds() / 86400 - return 0 - - def citation_rate(self, tier=None): - """Return the citation rate in units of nr citations per article per year.""" - publications = Publication.objects.filter(in_issue__in_volume__in_journal=self) - if tier: - publications = publications.filter( - accepted_submission__eicrecommendations__recommendation=tier) - ncites = 0 - deltat = 1 # to avoid division by zero - for pub in publications: - if pub.citedby and pub.latest_citedby_update: - ncites += len(pub.citedby) - deltat += (pub.latest_citedby_update.date() - pub.publication_date).days - return (ncites * 365.25/deltat) - - def citedby_impact_factor(self, year): - """Compute the impact factor for a given year YYYY, from Crossref cited-by data. - - This is defined as the total number of citations in year YYYY - for all papers published in years YYYY-1 and YYYY-2, divided - by the number of papers published in year YYYY. - """ - publications = self.get_publications().filter( - models.Q(publication_date__year=int(year)-1) | - models.Q(publication_date__year=int(year)-2)) - nrpub = publications.count() - if nrpub == 0: - return 0 - ncites = 0 - for pub in publications: - if pub.citedby and pub.latest_citedby_update: - for citation in pub.citedby: - if citation['year'] == year: - ncites += 1 - return ncites / nrpub - - -class Volume(models.Model): - """ - A Volume belongs to a specific Journal, and is a container for - either (multiple) Issue(s) or Publication(s). - """ - in_journal = models.ForeignKey( - 'journals.Journal', limit_choices_to={'structure': ISSUES_AND_VOLUMES}, - on_delete=models.CASCADE) - number = models.PositiveSmallIntegerField() - start_date = models.DateField(default=timezone.now) - until_date = models.DateField(default=timezone.now) - doi_label = models.CharField(max_length=200, unique=True, db_index=True, - validators=[doi_volume_validator]) - - class Meta: - default_related_name = 'volumes' - ordering = ('-until_date',) - unique_together = ('number', 'in_journal') - - def __str__(self): - return str(self.in_journal) + ' Vol. ' + str(self.number) - - def clean(self): - """Check if the Volume is assigned to a valid Journal.""" - if not self.in_journal.has_volumes: - raise ValidationError({ - 'in_journal': ValidationError('This journal does not allow for the use of Volumes', - code='invalid'), - }) - - @property - def doi_string(self): - return '10.21468/' + self.doi_label - - def is_current(self): - today = timezone.now().date() - return self.start_date <= today and self.until_date >= today - - def nr_publications(self, tier=None): - publications = Publication.objects.filter(in_issue__in_volume=self) - if tier: - publications = publications.filter( - accepted_submission__eicrecommendations__recommendation=tier) - return publications.count() - - def avg_processing_duration(self): - duration = Publication.objects.filter( - in_issue__in_volume=self).aggregate( - avg=Avg(F('publication_date') - F('submission_date')))['avg'] - if duration: - return duration.total_seconds() / 86400 - return 0 - - def citation_rate(self, tier=None): - """Returns the citation rate in units of nr citations per article per year.""" - publications = Publication.objects.filter(in_issue__in_volume=self) - if tier: - publications = publications.filter( - accepted_submission__eicrecommendations__recommendation=tier) - ncites = 0 - deltat = 1 # to avoid division by zero - for pub in publications: - if pub.citedby and pub.latest_citedby_update: - ncites += len(pub.citedby) - deltat += (pub.latest_citedby_update.date() - pub.publication_date).days - return (ncites * 365.25 / deltat) - - -class Issue(models.Model): - """ - An Issue is related to a specific Journal, either indirectly via a Volume - container, or directly. It is a container for multiple Publications. - """ - in_journal = models.ForeignKey( - 'journals.Journal', on_delete=models.CASCADE, null=True, blank=True, - limit_choices_to={'structure': ISSUES_ONLY}, - help_text='Assign either a Volume or Journal to the Issue') - in_volume = models.ForeignKey( - 'journals.Volume', on_delete=models.CASCADE, null=True, blank=True, - help_text='Assign either a Volume or Journal to the Issue') - number = models.PositiveIntegerField() - slug = models.SlugField() - start_date = models.DateField(default=timezone.now) - until_date = models.DateField(default=timezone.now) - status = models.CharField(max_length=20, choices=ISSUE_STATUSES, default=STATUS_PUBLISHED) - doi_label = models.CharField(max_length=200, unique=True, db_index=True, - validators=[doi_issue_validator]) - - # absolute path on filesystem: (JOURNALS_DIR)/journal/vol/issue/ - path = models.CharField(max_length=200) - - objects = IssueQuerySet.as_manager() - - class Meta: - default_related_name = 'issues' - ordering = ('-until_date',) - unique_together = ('number', 'in_volume') - - def __str__(self): - text = self.issue_string - if hasattr(self, 'proceedings'): - return text - text += ' (%s)' % self.period_as_string - if self.status == STATUS_DRAFT: - text += ' (In draft)' - return text - - def clean(self): - """Check if either a Journal or Volume is assigned to the Issue.""" - if not (self.in_journal or self.in_volume): - raise ValidationError({ - 'in_journal': ValidationError('Either assign a Journal or Volume to this Issue', - code='required'), - 'in_volume': ValidationError('Either assign a Journal or Volume to this Issue', - code='required'), - }) - if self.in_journal and not self.in_journal.has_issues: - raise ValidationError({ - 'in_journal': ValidationError('This journal does not allow for the use of Issues', - code='invalid'), - }) - - def get_absolute_url(self): - return reverse('scipost:issue_detail', args=[self.doi_label]) - - @property - def doi_string(self): - return '10.21468/' + self.doi_label - - @property - def issue_string(self): - if self.in_volume: - return '%s issue %s' % (self.in_volume, self.number) - elif self.status == STATUS_PUBLICLY_OPEN: - try: - return '%s (open): %s (%s)' % (self.in_journal, - self.proceedings.event_name, self.number) - except Proceedings.DoesNotExist: - pass - return '%s (open): %s' % (self.in_journal, self.number) - return '%s issue %s' % (self.in_journal, self.number) - - @property - def short_str(self): - if self.in_volume: - return 'Vol. %s issue %s' % (self.in_volume.number, self.number) - return 'Issue %s' % self.doi_label.rpartition('.')[2] - - @property - def period_as_string(self): - if self.start_date.month == self.until_date.month: - return '%s %s' % (self.until_date.strftime('%B'), self.until_date.strftime('%Y')) - return '%s - %s' % (self.start_date.strftime('%B'), self.until_date.strftime('%B %Y')) - - def get_journal(self): - if self.in_journal: - return self.in_journal - return self.in_volume.in_journal - - def is_current(self): - today = timezone.now().date() - return self.start_date <= today and self.until_date >= today - - def nr_publications(self, tier=None): - publications = Publication.objects.filter(in_issue=self) - if tier: - publications = publications.filter( - accepted_submission__eicrecommendations__recommendation=tier) - return publications.count() - - def avg_processing_duration(self): - duration = Publication.objects.filter( - in_issue=self).aggregate( - avg=Avg(F('publication_date') - F('submission_date')))['avg'] - if duration: - return duration.total_seconds() / 86400 - return 0 - - def citation_rate(self, tier=None): - """Return the citation rate in units of nr citations per article per year.""" - publications = Publication.objects.filter(in_issue=self) - if tier: - publications = publications.filter( - accepted_submission__eicrecommendations__recommendation=tier) - ncites = 0 - deltat = 1 # to avoid division by zero - for pub in publications: - if pub.citedby and pub.latest_citedby_update: - ncites += len(pub.citedby) - deltat += (pub.latest_citedby_update.date() - pub.publication_date).days - return (ncites * 365.25 / deltat) - - -class Publication(models.Model): - """A Publication is an object directly related to an accepted Submission. - - It contains metadata, the actual publication file, author data, etc. etc. - It may be directly related to a Journal or to an Issue. - """ - - # Publication data - accepted_submission = models.OneToOneField('submissions.Submission', on_delete=models.CASCADE, - related_name='publication') - in_issue = models.ForeignKey( - 'journals.Issue', on_delete=models.CASCADE, null=True, blank=True, - help_text='Assign either an Issue or Journal to the Publication') - in_journal = models.ForeignKey( - 'journals.Journal', on_delete=models.CASCADE, null=True, blank=True, - help_text='Assign either an Issue or Journal to the Publication') - paper_nr = models.PositiveSmallIntegerField() - status = models.CharField(max_length=8, - choices=PUBLICATION_STATUSES, default=STATUS_DRAFT) - - # Core fields - title = models.CharField(max_length=300) - author_list = models.CharField(max_length=10000, verbose_name="author list") - abstract = models.TextField() - abstract_jats = models.TextField(blank=True, default='', - help_text='JATS version of abstract for Crossref deposit') - pdf_file = models.FileField(upload_to='UPLOADS/PUBLICATIONS/%Y/%m/', max_length=200) - discipline = models.CharField(max_length=20, choices=SCIPOST_DISCIPLINES, default='physics') - subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS, - verbose_name='Primary subject area', default='Phys:QP') - secondary_areas = ChoiceArrayField( - models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS), blank=True, null=True) - approaches = ChoiceArrayField( - models.CharField(max_length=24, choices=SCIPOST_APPROACHES), - blank=True, null=True, verbose_name='approach(es) [optional]') - - # Authors - authors_claims = models.ManyToManyField('scipost.Contributor', blank=True, - related_name='claimed_publications') - authors_false_claims = models.ManyToManyField('scipost.Contributor', blank=True, - related_name='false_claimed_publications') - - cc_license = models.CharField(max_length=32, choices=CC_LICENSES, default=CCBY4) - - # Funders - grants = models.ManyToManyField('funders.Grant', blank=True) - funders_generic = models.ManyToManyField('funders.Funder', blank=True) # not linked to a grant - pubfractions_confirmed_by_authors = models.BooleanField(default=False) - - # Metadata - metadata = JSONField(default=dict, blank=True, null=True) - metadata_xml = models.TextField(blank=True) # for Crossref deposit - metadata_DOAJ = JSONField(default=dict, blank=True, null=True) - doi_label = models.CharField(max_length=200, unique=True, db_index=True, - validators=[doi_publication_validator]) - BiBTeX_entry = models.TextField(blank=True) - doideposit_needs_updating = models.BooleanField(default=False) - citedby = JSONField(default=dict, blank=True, null=True) - number_of_citations = models.PositiveIntegerField(default=0) - - # Topics for semantic linking - topics = models.ManyToManyField('ontology.Topic', blank=True) - - # Date fields - submission_date = models.DateField(verbose_name='submission date') - acceptance_date = models.DateField(verbose_name='acceptance date') - publication_date = models.DateField(verbose_name='publication date') - latest_citedby_update = models.DateTimeField(null=True, blank=True) - latest_metadata_update = models.DateTimeField(blank=True, null=True) - latest_activity = models.DateTimeField(auto_now=True) # Needs `auto_now` as its not explicity updated anywhere? - - objects = PublicationQuerySet.as_manager() - - class Meta: - default_related_name = 'publications' - ordering = ('-publication_date', '-paper_nr') - - def __str__(self): - return '{cite}, {title} by {authors}, {date}'.format( - cite=self.citation, - title=self.title[:30], - authors=self.author_list[:30], - date=self.publication_date.strftime('%Y-%m-%d')) - - def clean(self): - """Check if either a valid Journal or Issue is assigned to the Publication.""" - if not (self.in_journal or self.in_issue): - raise ValidationError({ - 'in_journal': ValidationError( - 'Either assign a Journal or Issue to this Publication', code='required'), - 'in_issue': ValidationError( - 'Either assign a Journal or Issue to this Publication', code='required'), - }) - if self.in_journal and self.in_issue: - # Assigning both a Journal and an Issue will screw up the database - raise ValidationError({ - 'in_journal': ValidationError( - 'Either assign only a Journal or Issue to this Publication', code='invalid'), - 'in_issue': ValidationError( - 'Either assign only a Journal or Issue to this Publication', code='invalid'), - }) - if self.in_issue and not self.get_journal().has_issues: - # Assigning both a Journal and an Issue will screw up the database - raise ValidationError({ - 'in_issue': ValidationError( - 'This journal does not allow the use of Issues', - code='invalid'), - }) - if self.in_journal and self.get_journal().has_issues: - # Assigning both a Journal and an Issue will screw up the database - raise ValidationError({ - 'in_journal': ValidationError( - 'This journal does not allow the use of individual Publications', - code='invalid'), - }) - - def get_absolute_url(self): - return reverse('scipost:publication_detail', args=(self.doi_label,)) - - def get_cc_license_URI(self): - for (key, val) in CC_LICENSES_URI: - if key == self.cc_license: - return val - raise KeyError - - def get_all_affiliations(self): - """ - Returns all author affiliations. - """ - from organizations.models import Organization - return Organization.objects.filter( - publicationauthorstable__publication=self - ).annotate(order=Min('publicationauthorstable__order')).order_by('order') - - def get_all_funders(self): - from funders.models import Funder - return Funder.objects.filter( - models.Q(grants__publications=self) | models.Q(publications=self)).distinct() - - def get_organizations(self): - """ - Returns a queryset of all Organizations which are associated to this Publication, - through being in author affiliations, funders or generic funders. - """ - from organizations.models import Organization - return Organization.objects.filter( - models.Q(publicationauthorstable__publication=self) | - models.Q(funder__grants__publications=self) | - models.Q(funder__publications=self)).distinct() - - @property - def doi_string(self): - return '10.21468/' + self.doi_label - - @property - def is_draft(self): - return self.status == STATUS_DRAFT - - @property - def is_published(self): - if self.status != PUBLICATION_PUBLISHED: - return False - - if self.in_issue: - return self.in_issue.status == STATUS_PUBLISHED - elif self.in_journal: - return self.in_journal.active - return False - - @property - def has_xml_metadata(self): - return self.metadata_xml != '' - - @property - def has_bibtex_entry(self): - return self.BiBTeX_entry != '' - - @property - def has_citation_list(self): - return 'citation_list' in self.metadata and len(self.metadata['citation_list']) > 0 - - @property - def has_funding_statement(self): - return 'funding_statement' in self.metadata and self.metadata['funding_statement'] - - @property - def pubfractions_sum_to_1(self): - """ Checks that the support fractions sum up to one. """ - return self.pubfractions.aggregate(Sum('fraction'))['fraction__sum'] == 1 - - @property - def citation(self): - """Return Publication name in the preferred citation format.""" - if self.in_issue and self.in_issue.in_volume: - return '{journal} {volume}, {paper_nr} ({year})'.format( - journal=self.in_issue.in_volume.in_journal.name_abbrev, - volume=self.in_issue.in_volume.number, - paper_nr=self.get_paper_nr(), - year=self.publication_date.strftime('%Y')) - elif self.in_issue and self.in_issue.in_journal: - return '{journal} {issue}, {paper_nr} ({year})'.format( - journal=self.in_issue.in_journal.name_abbrev, - issue=self.in_issue.number, - paper_nr=self.get_paper_nr(), - year=self.publication_date.strftime('%Y')) - elif self.in_journal: - return '{journal} {paper_nr} ({year})'.format( - journal=self.in_journal.name_abbrev, - paper_nr=self.paper_nr, - year=self.publication_date.strftime('%Y')) - return '{paper_nr} ({year})'.format( - paper_nr=self.paper_nr, - year=self.publication_date.strftime('%Y')) - - def get_cc_license_URI(self): - for (key, val) in CC_LICENSES_URI: - if key == self.cc_license: - return val - raise KeyError - - def get_all_funders(self): - from funders.models import Funder - return Funder.objects.filter( - models.Q(grants__publications=self) | models.Q(publications=self)).distinct() - - def get_journal(self): - if self.in_journal: - return self.in_journal - elif self.in_issue.in_journal: - return self.in_issue.in_journal - return self.in_issue.in_volume.in_journal - - def journal_issn(self): - return self.get_journal().issn - - def get_paper_nr(self): - if self.in_journal: - return self.paper_nr - return paper_nr_string(self.paper_nr) - - def citation_rate(self): - """Returns the citation rate in units of nr citations per article per year.""" - if self.citedby and self.latest_citedby_update: - ncites = len(self.citedby) - deltat = (self.latest_citedby_update.date() - self.publication_date).days - return (ncites * 365.25 / deltat) - else: - return 0 - - def get_similar_publications(self): - """Return 4 Publications with same subject area.""" - return Publication.objects.published().filter( - subject_area=self.subject_area).exclude(id=self.id)[:4] - - def get_issue_related_publications(self): - """Return 4 Publications within same Issue.""" - return Publication.objects.published().filter( - in_issue=self.in_issue).exclude(id=self.id)[:4] - - -class Reference(models.Model): - """A Refence is a reference used in a specific Publication.""" - reference_number = models.IntegerField() - publication = models.ForeignKey('journals.Publication', on_delete=models.CASCADE) - - authors = models.CharField(max_length=1028) - citation = models.CharField(max_length=1028, blank=True) - identifier = models.CharField(blank=True, max_length=128) - link = models.URLField(blank=True) - - class Meta: - unique_together = ('reference_number', 'publication') - ordering = ['reference_number'] - default_related_name = 'references' - - def __str__(self): - return '[{}] {}, {}'.format(self.reference_number, self.authors[:30], self.citation[:30]) - - -class OrgPubFraction(models.Model): - """ - Associates a fraction of the funding credit for a given publication to an Organization, - to help answer the question: who funded this research? - - Fractions for a given publication should sum up to one. - - This data is used to compile publicly-displayed information on Organizations - as well as to set suggested contributions from Partners. - - To be set (ideally) during production phase, based on information provided by the authors. - """ - organization = models.ForeignKey('organizations.Organization', on_delete=models.CASCADE, - related_name='pubfractions', blank=True, null=True) - publication = models.ForeignKey('journals.Publication', on_delete=models.CASCADE, - related_name='pubfractions') - fraction = models.DecimalField(max_digits=4, decimal_places=3, default=Decimal('0.000')) - - class Meta: - unique_together = (('organization', 'publication'),) - - -class Deposit(models.Model): - """ - Each time a Crossref deposit is made for a Publication, - a Deposit object instance is created containing the Publication's - current version of the metadata_xml field. - All deposit history is thus contained here. - """ - publication = models.ForeignKey(Publication, on_delete=models.CASCADE) - timestamp = models.CharField(max_length=40) - doi_batch_id = models.CharField(max_length=40) - metadata_xml = models.TextField(blank=True) - metadata_xml_file = models.FileField(blank=True, null=True, max_length=512) - deposition_date = models.DateTimeField(blank=True, null=True) - response_text = models.TextField(blank=True) - deposit_successful = models.NullBooleanField(default=None) - - class Meta: - ordering = ['-timestamp'] - - def __str__(self): - _str = '' - if self.deposition_date: - _str += '%s for ' % self.deposition_date.strftime('%Y-%m-%D') - return _str + self.publication.doi_label - - -class DOAJDeposit(models.Model): - """ - For the Directory of Open Access Journals. - """ - publication = models.ForeignKey(Publication, on_delete=models.CASCADE) - timestamp = models.CharField(max_length=40) - metadata_DOAJ = JSONField() - metadata_DOAJ_file = models.FileField(blank=True, null=True, max_length=512) - deposition_date = models.DateTimeField(blank=True, null=True) - response_text = models.TextField(blank=True, null=True) - deposit_successful = models.NullBooleanField(default=None) - - class Meta: - verbose_name = 'DOAJ deposit' - - def __str__(self): - return ('DOAJ deposit for ' + self.publication.doi_label) - - -class GenericDOIDeposit(models.Model): - """ - Instances of this class represent Crossref deposits for non-publication - objects such as Reports, Comments etc. - """ - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey() - timestamp = models.CharField(max_length=40, default='') - doi_batch_id = models.CharField(max_length=40, default='') - metadata_xml = models.TextField(blank=True, null=True) - deposition_date = models.DateTimeField(blank=True, null=True) - response = models.TextField(blank=True, null=True) - deposit_successful = models.NullBooleanField(default=None) - - class Meta: - ordering = ['-timestamp'] - - def __str__(self): - return 'GenericDOIDeposit for %s %s' % (self.content_type, str(self.content_object)) diff --git a/journals/models/__init__.py b/journals/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..72e45349d61fe2bf68326259565c1bc287d93d58 --- /dev/null +++ b/journals/models/__init__.py @@ -0,0 +1,9 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from .journal import Journal +from .volume import Volume +from .issue import Issue +from .publication import PublicationAuthorsTable, Publication, Reference, OrgPubFraction +from .deposits import Deposit, DOAJDeposit, GenericDOIDeposit diff --git a/journals/models/deposits.py b/journals/models/deposits.py new file mode 100644 index 0000000000000000000000000000000000000000..914a0f5bb2ce108c667b02454a5c702c585e2036 --- /dev/null +++ b/journals/models/deposits.py @@ -0,0 +1,77 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField +from django.db import models + +from journals.models import Publication + + +class Deposit(models.Model): + """ + Each time a Crossref deposit is made for a Publication, + a Deposit object instance is created containing the Publication's + current version of the metadata_xml field. + All deposit history is thus contained here. + """ + publication = models.ForeignKey(Publication, on_delete=models.CASCADE) + timestamp = models.CharField(max_length=40) + doi_batch_id = models.CharField(max_length=40) + metadata_xml = models.TextField(blank=True) + metadata_xml_file = models.FileField(blank=True, null=True, max_length=512) + deposition_date = models.DateTimeField(blank=True, null=True) + response_text = models.TextField(blank=True) + deposit_successful = models.NullBooleanField(default=None) + + class Meta: + ordering = ['-timestamp'] + + def __str__(self): + _str = '' + if self.deposition_date: + _str += '%s for ' % self.deposition_date.strftime('%Y-%m-%D') + return _str + self.publication.doi_label + + +class DOAJDeposit(models.Model): + """ + For the Directory of Open Access Journals. + """ + publication = models.ForeignKey(Publication, on_delete=models.CASCADE) + timestamp = models.CharField(max_length=40) + metadata_DOAJ = JSONField() + metadata_DOAJ_file = models.FileField(blank=True, null=True, max_length=512) + deposition_date = models.DateTimeField(blank=True, null=True) + response_text = models.TextField(blank=True, null=True) + deposit_successful = models.NullBooleanField(default=None) + + class Meta: + verbose_name = 'DOAJ deposit' + + def __str__(self): + return ('DOAJ deposit for ' + self.publication.doi_label) + + +class GenericDOIDeposit(models.Model): + """ + Instances of this class represent Crossref deposits for non-publication + objects such as Reports, Comments etc. + """ + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey() + timestamp = models.CharField(max_length=40, default='') + doi_batch_id = models.CharField(max_length=40, default='') + metadata_xml = models.TextField(blank=True, null=True) + deposition_date = models.DateTimeField(blank=True, null=True) + response = models.TextField(blank=True, null=True) + deposit_successful = models.NullBooleanField(default=None) + + class Meta: + ordering = ['-timestamp'] + + def __str__(self): + return 'GenericDOIDeposit for %s %s' % (self.content_type, str(self.content_object)) diff --git a/journals/models/issue.py b/journals/models/issue.py new file mode 100644 index 0000000000000000000000000000000000000000..7e8eda8f6553a4b3c5511b2d61b2ea4717c97fc0 --- /dev/null +++ b/journals/models/issue.py @@ -0,0 +1,145 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Avg, F +from django.utils import timezone +from django.urls import reverse + +from proceedings.models import Proceedings + +from ..behaviors import doi_issue_validator +from ..constants import ISSUES_ONLY, ISSUE_STATUSES, STATUS_DRAFT, STATUS_PUBLISHED,\ + STATUS_PUBLICLY_OPEN +from ..managers import IssueQuerySet + + + +class Issue(models.Model): + """ + An Issue is related to a specific Journal, either indirectly via a Volume + container, or directly. It is a container for multiple Publications. + """ + in_journal = models.ForeignKey( + 'journals.Journal', on_delete=models.CASCADE, null=True, blank=True, + limit_choices_to={'structure': ISSUES_ONLY}, + help_text='Assign either a Volume or Journal to the Issue') + in_volume = models.ForeignKey( + 'journals.Volume', on_delete=models.CASCADE, null=True, blank=True, + help_text='Assign either a Volume or Journal to the Issue') + number = models.PositiveIntegerField() + slug = models.SlugField() + start_date = models.DateField(default=timezone.now) + until_date = models.DateField(default=timezone.now) + status = models.CharField(max_length=20, choices=ISSUE_STATUSES, default=STATUS_PUBLISHED) + doi_label = models.CharField(max_length=200, unique=True, db_index=True, + validators=[doi_issue_validator]) + + # absolute path on filesystem: (JOURNALS_DIR)/journal/vol/issue/ + path = models.CharField(max_length=200) + + objects = IssueQuerySet.as_manager() + + class Meta: + default_related_name = 'issues' + ordering = ('-until_date',) + unique_together = ('number', 'in_volume') + + def __str__(self): + text = self.issue_string + if hasattr(self, 'proceedings'): + return text + text += ' (%s)' % self.period_as_string + if self.status == STATUS_DRAFT: + text += ' (In draft)' + return text + + def clean(self): + """Check if either a Journal or Volume is assigned to the Issue.""" + if not (self.in_journal or self.in_volume): + raise ValidationError({ + 'in_journal': ValidationError('Either assign a Journal or Volume to this Issue', + code='required'), + 'in_volume': ValidationError('Either assign a Journal or Volume to this Issue', + code='required'), + }) + if self.in_journal and not self.in_journal.has_issues: + raise ValidationError({ + 'in_journal': ValidationError('This journal does not allow for the use of Issues', + code='invalid'), + }) + + def get_absolute_url(self): + return reverse('scipost:issue_detail', args=[self.doi_label]) + + @property + def doi_string(self): + return '10.21468/' + self.doi_label + + @property + def issue_string(self): + if self.in_volume: + return '%s issue %s' % (self.in_volume, self.number) + elif self.status == STATUS_PUBLICLY_OPEN: + try: + return '%s (open): %s (%s)' % (self.in_journal, + self.proceedings.event_name, self.number) + except Proceedings.DoesNotExist: + pass + return '%s (open): %s' % (self.in_journal, self.number) + return '%s issue %s' % (self.in_journal, self.number) + + @property + def short_str(self): + if self.in_volume: + return 'Vol. %s issue %s' % (self.in_volume.number, self.number) + return 'Issue %s' % self.doi_label.rpartition('.')[2] + + @property + def period_as_string(self): + if self.start_date.month == self.until_date.month: + return '%s %s' % (self.until_date.strftime('%B'), self.until_date.strftime('%Y')) + return '%s - %s' % (self.start_date.strftime('%B'), self.until_date.strftime('%B %Y')) + + def get_journal(self): + if self.in_journal: + return self.in_journal + return self.in_volume.in_journal + + def is_current(self): + today = timezone.now().date() + return self.start_date <= today and self.until_date >= today + + def nr_publications(self, tier=None): + from journals.models import Publication + publications = Publication.objects.filter(in_issue=self) + if tier: + publications = publications.filter( + accepted_submission__eicrecommendations__recommendation=tier) + return publications.count() + + def avg_processing_duration(self): + from journals.models import Publication + duration = Publication.objects.filter( + in_issue=self).aggregate( + avg=Avg(F('publication_date') - F('submission_date')))['avg'] + if duration: + return duration.total_seconds() / 86400 + return 0 + + def citation_rate(self, tier=None): + """Return the citation rate in units of nr citations per article per year.""" + from journals.models import Publication + publications = Publication.objects.filter(in_issue=self) + if tier: + publications = publications.filter( + accepted_submission__eicrecommendations__recommendation=tier) + ncites = 0 + deltat = 1 # to avoid division by zero + for pub in publications: + if pub.citedby and pub.latest_citedby_update: + ncites += len(pub.citedby) + deltat += (pub.latest_citedby_update.date() - pub.publication_date).days + return (ncites * 365.25 / deltat) diff --git a/journals/models/journal.py b/journals/models/journal.py new file mode 100644 index 0000000000000000000000000000000000000000..b297bac92f8c15a8a353c46e4522f9e3122e27d5 --- /dev/null +++ b/journals/models/journal.py @@ -0,0 +1,137 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime + +from django.db import models +from django.db.models import Avg, F +from django.urls import reverse + +from ..behaviors import doi_journal_validator +from ..constants import JOURNAL_STRUCTURE, ISSUES_AND_VOLUMES, ISSUES_ONLY +from ..managers import JournalQuerySet + + +class Journal(models.Model): + """Journal is a container of Publications with a unique issn and doi_label. + + Publications may be categorized into issues or issues and volumes. + """ + + name = models.CharField(max_length=256, unique=True) + name_abbrev = models.CharField(max_length=128, default='SciPost [abbrev]', + help_text='Abbreviated name (for use in citations)') + doi_label = models.CharField(max_length=200, unique=True, db_index=True, + validators=[doi_journal_validator]) + issn = models.CharField(max_length=16, default='2542-4653', blank=True) + active = models.BooleanField(default=True) + structure = models.CharField(max_length=2, + choices=JOURNAL_STRUCTURE, default=ISSUES_AND_VOLUMES) + refereeing_period = models.DurationField(default=datetime.timedelta(days=28)) + + objects = JournalQuerySet.as_manager() + + def __str__(self): + return self.name + + def get_absolute_url(self): + """Return Journal's homepage url.""" + return reverse('scipost:landing_page', args=(self.doi_label,)) + + @property + def doi_string(self): + """Return DOI including the SciPost registrant prefix.""" + return '10.21468/' + self.doi_label + + @property + def has_issues(self): + return self.structure in (ISSUES_AND_VOLUMES, ISSUES_ONLY) + + @property + def has_volumes(self): + return self.structure in (ISSUES_AND_VOLUMES) + + def get_issues(self): + from journals.models import Issue + if self.structure == ISSUES_AND_VOLUMES: + return Issue.objects.filter(in_volume__in_journal=self).published() + elif self.structure == ISSUES_ONLY: + return self.issues.open_or_published() + return Issue.objects.none() + + def get_latest_issue(self): + """Get latest existing Issue in database irrespective of its status.""" + from journals.models import Issue + if self.structure == ISSUES_ONLY: + return self.issues.order_by('-until_date').first() + if self.structure == ISSUES_AND_VOLUMES: + return Issue.objects.filter(in_volume__in_journal=self).order_by('-until_date').first() + return None + + def get_latest_volume(self): + """Get latest existing Volume in database irrespective of its status.""" + if self.structure == ISSUES_AND_VOLUMES: + return self.volumes.order_by('-until_date').first() + return None + + def get_publications(self): + from journals.models import Publication + if self.structure == ISSUES_AND_VOLUMES: + return Publication.objects.filter(in_issue__in_volume__in_journal=self) + elif self.structure == ISSUES_ONLY: + return Publication.objects.filter(in_issue__in_journal=self) + return self.publications.all() + + def nr_publications(self, tier=None): + from journals.models import Publication + publications = Publication.objects.filter(in_issue__in_volume__in_journal=self) + if tier: + publications = publications.filter( + accepted_submission__eicrecommendations__recommendation=tier) + return publications.count() + + def avg_processing_duration(self): + from journals.models import Publication + duration = Publication.objects.filter( + in_issue__in_volume__in_journal=self).aggregate( + avg=Avg(F('publication_date') - F('submission_date')))['avg'] + if duration: + return duration.total_seconds() / 86400 + return 0 + + def citation_rate(self, tier=None): + """Return the citation rate in units of nr citations per article per year.""" + from journals.models import Publication + publications = Publication.objects.filter(in_issue__in_volume__in_journal=self) + if tier: + publications = publications.filter( + accepted_submission__eicrecommendations__recommendation=tier) + ncites = 0 + deltat = 1 # to avoid division by zero + for pub in publications: + if pub.citedby and pub.latest_citedby_update: + ncites += len(pub.citedby) + deltat += (pub.latest_citedby_update.date() - pub.publication_date).days + return (ncites * 365.25/deltat) + + def citedby_impact_factor(self, year): + """Compute the impact factor for a given year YYYY, from Crossref cited-by data. + + This is defined as the total number of citations in year YYYY + for all papers published in years YYYY-1 and YYYY-2, divided + by the number of papers published in year YYYY. + """ + publications = self.get_publications().filter( + models.Q(publication_date__year=int(year)-1) | + models.Q(publication_date__year=int(year)-2)) + nrpub = publications.count() + if nrpub == 0: + return 0 + ncites = 0 + for pub in publications: + if pub.citedby and pub.latest_citedby_update: + for citation in pub.citedby: + if citation['year'] == year: + ncites += 1 + return ncites / nrpub diff --git a/journals/models/publication.py b/journals/models/publication.py new file mode 100644 index 0000000000000000000000000000000000000000..0e6439a99d7c7b8e585e0652379c74ae02f31a44 --- /dev/null +++ b/journals/models/publication.py @@ -0,0 +1,368 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from decimal import Decimal + +from django.contrib.postgres.fields import JSONField +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Min, Sum +from django.urls import reverse + +from ..behaviors import doi_publication_validator +from ..constants import (STATUS_DRAFT, STATUS_PUBLISHED, + PUBLICATION_PUBLISHED, CCBY4, CC_LICENSES, CC_LICENSES_URI, PUBLICATION_STATUSES) +from ..helpers import paper_nr_string +from ..managers import PublicationQuerySet + +from scipost.constants import SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS, SCIPOST_APPROACHES +from scipost.fields import ChoiceArrayField + + +class PublicationAuthorsTable(models.Model): + """ + PublicationAuthorsTable represents an author of a Publication. + + Fields: + * publication + * profile + * affiliations: for this author/Publication (supersede profile.affiliations) + * order: the ordinal position of this author in this Publication's list of authors. + """ + + publication = models.ForeignKey('journals.Publication', on_delete=models.CASCADE, + related_name='authors') + profile = models.ForeignKey('profiles.Profile', on_delete=models.PROTECT, + blank=True, null=True) + affiliations = models.ManyToManyField('organizations.Organization', blank=True) + order = models.PositiveSmallIntegerField() + + class Meta: + ordering = ('order',) + + def __str__(self): + return str(self.profile) + + def save(self, *args, **kwargs): + """Auto increment order number if not explicitly set.""" + if not self.order: + self.order = self.publication.authors.count() + 1 + return super().save(*args, **kwargs) + + @property + def is_registered(self): + """Check if author is registered at SciPost.""" + return self.profile.contributor is not None + + @property + def first_name(self): + """Return first name of author.""" + return self.profile.first_name + + @property + def last_name(self): + """Return last name of author.""" + return self.profile.last_name + + + +class Publication(models.Model): + """A Publication is an object directly related to an accepted Submission. + + It contains metadata, the actual publication file, author data, etc. etc. + It may be directly related to a Journal or to an Issue. + """ + + # Publication data + accepted_submission = models.OneToOneField('submissions.Submission', on_delete=models.CASCADE, + related_name='publication') + in_issue = models.ForeignKey( + 'journals.Issue', on_delete=models.CASCADE, null=True, blank=True, + help_text='Assign either an Issue or Journal to the Publication') + in_journal = models.ForeignKey( + 'journals.Journal', on_delete=models.CASCADE, null=True, blank=True, + help_text='Assign either an Issue or Journal to the Publication') + paper_nr = models.PositiveSmallIntegerField() + status = models.CharField(max_length=8, + choices=PUBLICATION_STATUSES, default=STATUS_DRAFT) + + # Core fields + title = models.CharField(max_length=300) + author_list = models.CharField(max_length=10000, verbose_name="author list") + abstract = models.TextField() + abstract_jats = models.TextField(blank=True, default='', + help_text='JATS version of abstract for Crossref deposit') + pdf_file = models.FileField(upload_to='UPLOADS/PUBLICATIONS/%Y/%m/', max_length=200) + discipline = models.CharField(max_length=20, choices=SCIPOST_DISCIPLINES, default='physics') + subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS, + verbose_name='Primary subject area', default='Phys:QP') + secondary_areas = ChoiceArrayField( + models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS), blank=True, null=True) + approaches = ChoiceArrayField( + models.CharField(max_length=24, choices=SCIPOST_APPROACHES), + blank=True, null=True, verbose_name='approach(es) [optional]') + + # Authors + authors_claims = models.ManyToManyField('scipost.Contributor', blank=True, + related_name='claimed_publications') + authors_false_claims = models.ManyToManyField('scipost.Contributor', blank=True, + related_name='false_claimed_publications') + + cc_license = models.CharField(max_length=32, choices=CC_LICENSES, default=CCBY4) + + # Funders + grants = models.ManyToManyField('funders.Grant', blank=True) + funders_generic = models.ManyToManyField('funders.Funder', blank=True) # not linked to a grant + pubfractions_confirmed_by_authors = models.BooleanField(default=False) + + # Metadata + metadata = JSONField(default=dict, blank=True, null=True) + metadata_xml = models.TextField(blank=True) # for Crossref deposit + metadata_DOAJ = JSONField(default=dict, blank=True, null=True) + doi_label = models.CharField(max_length=200, unique=True, db_index=True, + validators=[doi_publication_validator]) + BiBTeX_entry = models.TextField(blank=True) + doideposit_needs_updating = models.BooleanField(default=False) + citedby = JSONField(default=dict, blank=True, null=True) + number_of_citations = models.PositiveIntegerField(default=0) + + # Topics for semantic linking + topics = models.ManyToManyField('ontology.Topic', blank=True) + + # Date fields + submission_date = models.DateField(verbose_name='submission date') + acceptance_date = models.DateField(verbose_name='acceptance date') + publication_date = models.DateField(verbose_name='publication date') + latest_citedby_update = models.DateTimeField(null=True, blank=True) + latest_metadata_update = models.DateTimeField(blank=True, null=True) + latest_activity = models.DateTimeField(auto_now=True) # Needs `auto_now` as its not explicity updated anywhere? + + objects = PublicationQuerySet.as_manager() + + class Meta: + default_related_name = 'publications' + ordering = ('-publication_date', '-paper_nr') + + def __str__(self): + return '{cite}, {title} by {authors}, {date}'.format( + cite=self.citation, + title=self.title[:30], + authors=self.author_list[:30], + date=self.publication_date.strftime('%Y-%m-%d')) + + def clean(self): + """Check if either a valid Journal or Issue is assigned to the Publication.""" + if not (self.in_journal or self.in_issue): + raise ValidationError({ + 'in_journal': ValidationError( + 'Either assign a Journal or Issue to this Publication', code='required'), + 'in_issue': ValidationError( + 'Either assign a Journal or Issue to this Publication', code='required'), + }) + if self.in_journal and self.in_issue: + # Assigning both a Journal and an Issue will screw up the database + raise ValidationError({ + 'in_journal': ValidationError( + 'Either assign only a Journal or Issue to this Publication', code='invalid'), + 'in_issue': ValidationError( + 'Either assign only a Journal or Issue to this Publication', code='invalid'), + }) + if self.in_issue and not self.get_journal().has_issues: + # Assigning both a Journal and an Issue will screw up the database + raise ValidationError({ + 'in_issue': ValidationError( + 'This journal does not allow the use of Issues', + code='invalid'), + }) + if self.in_journal and self.get_journal().has_issues: + # Assigning both a Journal and an Issue will screw up the database + raise ValidationError({ + 'in_journal': ValidationError( + 'This journal does not allow the use of individual Publications', + code='invalid'), + }) + + def get_absolute_url(self): + return reverse('scipost:publication_detail', args=(self.doi_label,)) + + def get_cc_license_URI(self): + for (key, val) in CC_LICENSES_URI: + if key == self.cc_license: + return val + raise KeyError + + def get_all_affiliations(self): + """ + Returns all author affiliations. + """ + from organizations.models import Organization + return Organization.objects.filter( + publicationauthorstable__publication=self + ).annotate(order=Min('publicationauthorstable__order')).order_by('order') + + def get_all_funders(self): + from funders.models import Funder + return Funder.objects.filter( + models.Q(grants__publications=self) | models.Q(publications=self)).distinct() + + def get_organizations(self): + """ + Returns a queryset of all Organizations which are associated to this Publication, + through being in author affiliations, funders or generic funders. + """ + from organizations.models import Organization + return Organization.objects.filter( + models.Q(publicationauthorstable__publication=self) | + models.Q(funder__grants__publications=self) | + models.Q(funder__publications=self)).distinct() + + @property + def doi_string(self): + return '10.21468/' + self.doi_label + + @property + def is_draft(self): + return self.status == STATUS_DRAFT + + @property + def is_published(self): + if self.status != PUBLICATION_PUBLISHED: + return False + + if self.in_issue: + return self.in_issue.status == STATUS_PUBLISHED + elif self.in_journal: + return self.in_journal.active + return False + + @property + def has_xml_metadata(self): + return self.metadata_xml != '' + + @property + def has_bibtex_entry(self): + return self.BiBTeX_entry != '' + + @property + def has_citation_list(self): + return 'citation_list' in self.metadata and len(self.metadata['citation_list']) > 0 + + @property + def has_funding_statement(self): + return 'funding_statement' in self.metadata and self.metadata['funding_statement'] + + @property + def pubfractions_sum_to_1(self): + """ Checks that the support fractions sum up to one. """ + return self.pubfractions.aggregate(Sum('fraction'))['fraction__sum'] == 1 + + @property + def citation(self): + """Return Publication name in the preferred citation format.""" + if self.in_issue and self.in_issue.in_volume: + return '{journal} {volume}, {paper_nr} ({year})'.format( + journal=self.in_issue.in_volume.in_journal.name_abbrev, + volume=self.in_issue.in_volume.number, + paper_nr=self.get_paper_nr(), + year=self.publication_date.strftime('%Y')) + elif self.in_issue and self.in_issue.in_journal: + return '{journal} {issue}, {paper_nr} ({year})'.format( + journal=self.in_issue.in_journal.name_abbrev, + issue=self.in_issue.number, + paper_nr=self.get_paper_nr(), + year=self.publication_date.strftime('%Y')) + elif self.in_journal: + return '{journal} {paper_nr} ({year})'.format( + journal=self.in_journal.name_abbrev, + paper_nr=self.paper_nr, + year=self.publication_date.strftime('%Y')) + return '{paper_nr} ({year})'.format( + paper_nr=self.paper_nr, + year=self.publication_date.strftime('%Y')) + + def get_cc_license_URI(self): + for (key, val) in CC_LICENSES_URI: + if key == self.cc_license: + return val + raise KeyError + + def get_all_funders(self): + from funders.models import Funder + return Funder.objects.filter( + models.Q(grants__publications=self) | models.Q(publications=self)).distinct() + + def get_journal(self): + if self.in_journal: + return self.in_journal + elif self.in_issue.in_journal: + return self.in_issue.in_journal + return self.in_issue.in_volume.in_journal + + def journal_issn(self): + return self.get_journal().issn + + def get_paper_nr(self): + if self.in_journal: + return self.paper_nr + return paper_nr_string(self.paper_nr) + + def citation_rate(self): + """Returns the citation rate in units of nr citations per article per year.""" + if self.citedby and self.latest_citedby_update: + ncites = len(self.citedby) + deltat = (self.latest_citedby_update.date() - self.publication_date).days + return (ncites * 365.25 / deltat) + else: + return 0 + + def get_similar_publications(self): + """Return 4 Publications with same subject area.""" + return Publication.objects.published().filter( + subject_area=self.subject_area).exclude(id=self.id)[:4] + + def get_issue_related_publications(self): + """Return 4 Publications within same Issue.""" + return Publication.objects.published().filter( + in_issue=self.in_issue).exclude(id=self.id)[:4] + + +class Reference(models.Model): + """A Refence is a reference used in a specific Publication.""" + reference_number = models.IntegerField() + publication = models.ForeignKey('journals.Publication', on_delete=models.CASCADE) + + authors = models.CharField(max_length=1028) + citation = models.CharField(max_length=1028, blank=True) + identifier = models.CharField(blank=True, max_length=128) + link = models.URLField(blank=True) + + class Meta: + unique_together = ('reference_number', 'publication') + ordering = ['reference_number'] + default_related_name = 'references' + + def __str__(self): + return '[{}] {}, {}'.format(self.reference_number, self.authors[:30], self.citation[:30]) + + +class OrgPubFraction(models.Model): + """ + Associates a fraction of the funding credit for a given publication to an Organization, + to help answer the question: who funded this research? + + Fractions for a given publication should sum up to one. + + This data is used to compile publicly-displayed information on Organizations + as well as to set suggested contributions from Partners. + + To be set (ideally) during production phase, based on information provided by the authors. + """ + organization = models.ForeignKey('organizations.Organization', on_delete=models.CASCADE, + related_name='pubfractions', blank=True, null=True) + publication = models.ForeignKey('journals.Publication', on_delete=models.CASCADE, + related_name='pubfractions') + fraction = models.DecimalField(max_digits=4, decimal_places=3, default=Decimal('0.000')) + + class Meta: + unique_together = (('organization', 'publication'),) diff --git a/journals/models/volume.py b/journals/models/volume.py new file mode 100644 index 0000000000000000000000000000000000000000..df1500e1d0b0f0675fb8390324bfdc7b5f2242ad --- /dev/null +++ b/journals/models/volume.py @@ -0,0 +1,82 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Avg, F +from django.utils import timezone + +from ..behaviors import doi_volume_validator +from ..constants import ISSUES_AND_VOLUMES + + +class Volume(models.Model): + """ + A Volume belongs to a specific Journal, and is a container for + either (multiple) Issue(s) or Publication(s). + """ + in_journal = models.ForeignKey( + 'journals.Journal', limit_choices_to={'structure': ISSUES_AND_VOLUMES}, + on_delete=models.CASCADE) + number = models.PositiveSmallIntegerField() + start_date = models.DateField(default=timezone.now) + until_date = models.DateField(default=timezone.now) + doi_label = models.CharField(max_length=200, unique=True, db_index=True, + validators=[doi_volume_validator]) + + class Meta: + default_related_name = 'volumes' + ordering = ('-until_date',) + unique_together = ('number', 'in_journal') + + def __str__(self): + return str(self.in_journal) + ' Vol. ' + str(self.number) + + def clean(self): + """Check if the Volume is assigned to a valid Journal.""" + if not self.in_journal.has_volumes: + raise ValidationError({ + 'in_journal': ValidationError('This journal does not allow for the use of Volumes', + code='invalid'), + }) + + @property + def doi_string(self): + return '10.21468/' + self.doi_label + + def is_current(self): + today = timezone.now().date() + return self.start_date <= today and self.until_date >= today + + def nr_publications(self, tier=None): + from journals.models import Publication + publications = Publication.objects.filter(in_issue__in_volume=self) + if tier: + publications = publications.filter( + accepted_submission__eicrecommendations__recommendation=tier) + return publications.count() + + def avg_processing_duration(self): + from journals.models import Publication + duration = Publication.objects.filter( + in_issue__in_volume=self).aggregate( + avg=Avg(F('publication_date') - F('submission_date')))['avg'] + if duration: + return duration.total_seconds() / 86400 + return 0 + + def citation_rate(self, tier=None): + """Returns the citation rate in units of nr citations per article per year.""" + from journals.models import Publication + publications = Publication.objects.filter(in_issue__in_volume=self) + if tier: + publications = publications.filter( + accepted_submission__eicrecommendations__recommendation=tier) + ncites = 0 + deltat = 1 # to avoid division by zero + for pub in publications: + if pub.citedby and pub.latest_citedby_update: + ncites += len(pub.citedby) + deltat += (pub.latest_citedby_update.date() - pub.publication_date).days + return (ncites * 365.25 / deltat)