diff --git a/django_api/management/__init__.py b/django_api/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_api/management/commands/__init__.py b/django_api/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_api/management/commands/generate_rql_class.py b/django_api/management/commands/generate_rql_class.py new file mode 100644 index 00000000..49bd9422 --- /dev/null +++ b/django_api/management/commands/generate_rql_class.py @@ -0,0 +1,116 @@ +# +# Copyright © 2021 Ingram Micro Inc. All rights reserved. +# + +import json +import re + +from dj_rql.filter_cls import NestedAutoRQLFilterClass + +from django.core.management import BaseCommand +from django.db.models import ForeignKey, OneToOneField, OneToOneRel +from django.utils.module_loading import import_string + + +TEMPLATE = """from {model_package} import {model_name} + +from dj_rql.filter_cls import RQLFilterClass +{optimizations_import} + +class {model_name}Filters(RQLFilterClass): + MODEL = {model_name} + SELECT = {select_flag} + EXCLUDE_FILTERS = {exclusions} + FILTERS = {filters} +""" + + +class Command(BaseCommand): + help = ( + 'Automatically generates a filter class for a model ' + 'with all relations to the specified depth.' + ) + + def add_arguments(self, parser): + parser.add_argument( + 'model', + nargs=1, + type=str, + help='Importable model location string.', + ) + parser.add_argument( + '-d', + '--depth', + type=int, + default=1, + help='Max depth of traversed model relations.', + ) + parser.add_argument( + '-s', + '--select', + action='store_true', + default=True, + help='Flag to include QuerySet optimizations: true by default.', + ) + parser.add_argument( + '-e', + '--exclude', + type=str, + help='List of coma separated filter names or namespace to be excluded from generation.', + ) + + def handle(self, *args, **options): + model_import = options['model'][0] + model = import_string(model_import) + is_select = options['select'] + exclusions = options['exclude'].split(',') if options['exclude'] else [] + + class Cls(NestedAutoRQLFilterClass): + MODEL = model + DEPTH = options['depth'] + SELECT = is_select + EXCLUDE_FILTERS = exclusions + + def _get_init_filters(self): + self.init_filters = super()._get_init_filters() + + self.DEPTH = 0 + self.SELECT = False + return super()._get_init_filters() + + def _get_field_optimization(self, field): + if not self.SELECT: + return + + if isinstance(field, (ForeignKey, OneToOneField, OneToOneRel)): + return "NSR('{0}')".format(field.name) + + if not self._is_through_field(field): + return "NPR('{0}')".format(field.name) + + filters = Cls(model._default_manager.all()).init_filters + filters_str = json.dumps(filters, sort_keys=False, indent=4).replace( + '"ordering": true', '"ordering": True', + ).replace( + '"ordering": false', '"ordering": False', + ).replace( + '"search": true', '"search": True', + ).replace( + '"search": false', '"search": False', + ).replace( + '"qs": null', '"qs": None', + ) + + filters_str = re.sub(r"\"((NPR|NSR)\('\w+?'\))\"", r'\1', filters_str) + + model_package, model_name = model_import.rsplit('.', 1) + code = TEMPLATE.format( + model_package=model_package, + model_name=model_name, + filters=filters_str, + select_flag='True' if is_select else 'False', + optimizations_import='from dj_rql.qs import NPR, NSR\n' if is_select else '', + exclusions=exclusions, + ) + + return code diff --git a/django_api/migrations/0078_auto_20220323_1720.py b/django_api/migrations/0078_auto_20220323_1720.py new file mode 100644 index 00000000..ece52455 --- /dev/null +++ b/django_api/migrations/0078_auto_20220323_1720.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.6 on 2022-03-23 16:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_api', '0077_auto_20211208_1209'), + ] + + operations = [ + migrations.AlterModelOptions( + name='conditionset', + options={'ordering': ('condition_type__pk', 'subtype__id', 'comment')}, + ), + migrations.AlterField( + model_name='term', + name='comment', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/django_api/models.py b/django_api/models.py index b8863524..4adfb638 100644 --- a/django_api/models.py +++ b/django_api/models.py @@ -1,479 +1,479 @@ """ Django object models for the django_api application of the OACCT project. Ref: database_model_20210421_MB.drawio 21.04.2021 """ from django.db import models from django.contrib.auth.models import User import datetime from django.utils.translation import gettext as _ class Country(models.Model): """ Countries: used as attributes by Publishers and Organizations :param name: full English name :type name: str, optional :param iso_code: ISO 3166-1 Alpha-3 code https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3#Officially_assigned_code_elements :type iso_code: str, optional """ name = models.CharField(verbose_name="Country name", max_length=120, null=True) iso_code = models.CharField(max_length=3, null=True) def __str__(self): return f"{self.name}" class Meta: verbose_name_plural = 'Countries' ordering = ('name',) class Language(models.Model): """ Languages: used as attributes by Journals :param name: full English name :type name: str, optional :param iso_code: ISO 639-2 code https://en.wikipedia.org/wiki/ISO_639-2 :type iso_code: str, optional """ name = models.CharField(verbose_name="Language name", max_length=120, null=True) iso_code = models.CharField(max_length=3, null=True) def __str__(self): return f"{self.name}" class Meta: ordering = ('name',) class Oa(models.Model): """ Open Access status: used as attribute by Journals :param status: short name, ideally one word i.e. Green, Gold, UNKNOWN... :type status: str, optional :param description: description text up to 1000 characters :type status: str, optional :param subscription: does a journal with this status require a subscription? :type subscription: bool :param accepted_manuscript: does a journal with this status generally allow to distribute the accepted manuscript? :type accepted_manuscript: bool :param apc: does a journal with this status require Article Processing Charges (APCs)? :type apc: bool :param final_version: does a journal with this status generally allow to distribute the published version? :type final_version: bool """ status = models.CharField(max_length=1000, null=True) description = models.CharField(max_length=1000, null=True) subscription = models.BooleanField(default=False) accepted_manuscript = models.BooleanField(default=False) apc = models.BooleanField(default=False) final_version = models.BooleanField(default=False) def __str__(self): return f"{self.status}" class Meta: ordering = ('-subscription',) verbose_name = "Open Access status" verbose_name_plural = "Open Access statuses" class Publisher(models.Model): """ Publishers: corporations or societies in charge of Journals :param name: name :type status: str, optional :param city: location of the main office :type city: str, optional :param state: if applicable, state or province :type state: str, optional :param country: home country or countries :type country: many-to-many relationship with the `Country` class :param starting_year: founding year :type starting_year: int, optional :param website: main web site :type website: URL :param oa_policies: web link to general Open Access policy if applicable :type oa_policies: URL """ name = models.CharField(verbose_name="Publisher name", max_length=1000, null=True) city = models.CharField(max_length=100, null=True) state = models.CharField(max_length=3, null=True) country = models.ManyToManyField("Country") starting_year = models.IntegerField(blank=True, null=True) website = models.URLField(max_length=1000) oa_policies = models.URLField(max_length=1000) def __str__(self): return f"{self.name}" class Meta: ordering = ('name',) class Issn(models.Model): """ Issns: a multiple property of Journals :param journal: Journal object to which the ISSN belongs :type journal: class `Journal`, optional :param issn: ISSN code such as 1234-5678 :type issn: str :param issn_type: Print, Electronic or Other :type issn_type: str """ PRINT = '1' ELECTRONIC = '2' OTHER = '3' TYPE_CHOICES = ( (PRINT, 'Print'), (ELECTRONIC, 'Electronic'), (OTHER, 'Other'), ) journal = models.ForeignKey("Journal", null=True, on_delete=models.CASCADE, related_name = "classIssn") #journal.classissn issn = models.CharField(max_length=9, null=False) """ISSN code such as 1234-5678 """ issn_type = models.CharField( choices=TYPE_CHOICES, max_length=10, blank=True ) def __str__(self): return f"{self.issn} ({dict(self.TYPE_CHOICES)[self.issn_type]})" class Meta: ordering = ('issn',) class Journal(models.Model): """ Journals: one of the big entities in the application :param name: journal title :type name: str :param name_short_iso_4: ISO 4 abbreviation of the title :type name_short_iso_4: str :param publisher: zero or more publishers in charge of the Journal :type publisher: many-to-many relationshio with class `Publisher` :param website: home page of the journal :type website: URL :param language: the journal publishes articles in these zero or more languages :type language: many-to-many relationship with class `Journal` :param oa_options: web page with the journal's Open Access conditions :type oa_options: URL, optional :param oa_status: Open Access status :type oa_status: reference to an `Oa` object :param starting_year: founding year :type starting_year: int, optional :param end_year: end year if applicable :type ending_year: int, optional :param doaj_seal: did the journal obtain the DOAJ Seal? https://doaj.org/apply/seal/ :type doaj_seal: bool :param doaj_status: is the journal accepted in the Directory of Open Access Journals? https://doaj.org :type doaj_status: bool :param lockss: is the journal archived by LOCKSS? https://www.lockss.org/about :type lockss: bool :param nlch: please remind me what this is supposed to be :type nlch: bool :param portico: did the journal obtain the DOAJ Seal? https://doaj.org/apply/seal/ :type portico: is the journal archived by Portico? https://www.portico.org/ :param qoam_av_score: Quality Open Access Marker (QOAM) score https://www.qoam.eu/ :type qoam_av_score: decimal number """ name = models.CharField(verbose_name="Journal name", max_length=800, blank=True, null=True) # search journal with name name_short_iso_4 = models.CharField(max_length=300, blank=True, null=True) publisher = models.ManyToManyField(Publisher) website = models.URLField(max_length=300, blank=True, null=True) language = models.ManyToManyField(Language) # 2021-08-11: only one-to-many relationship between Journal and ISSN # issn = models.ForeignKey("Issn", null=True, on_delete=models.CASCADE) oa_options = models.URLField(max_length=1000, blank=True, null=True) oa_status = models.ForeignKey("Oa", related_name ="oa_status", on_delete=models.CASCADE, null=True) starting_year = models.IntegerField(blank=True, null=True) end_year = models.IntegerField(blank=True, null=True) doaj_seal = models.BooleanField(default=False) doaj_status = models.BooleanField(default=False) lockss = models.BooleanField(default=False) nlch = models.BooleanField(default=False) portico = models.BooleanField(default=False) qoam_av_score = models.DecimalField(decimal_places=2, max_digits=5, blank=True, null=True) def __str__(self): return f"{self.name} from {self.website}" class Meta: ordering = ('name',) class Organization(models.Model): """ Organizations: one of the big entities in the application, organizations (research institutions or funders) who employ or fund the authors/researchers :param name: name of the organization :type name: str :param website: web site of the organization :type website: URL, optional :param country: zero or more home countries :type country: many-to-many relationship with class `Country` :param ror: Research Organization Registry (ROR) indentifier https://ror.org/ :type ror: str, optional :param fundref: Crossref Funder Registry identifier https://www.crossref.org/services/funder-registry/ :type fundref: str, optional :param starting_year: founding year :type starting_year: int, optional :param is_funder: if True, the organization is a funding agency, if False a research organization :type is_funder: bool :param ir_name: name of the oeganization's institutional repository for publications, if applicable :type ir_name: str, optional :param ir_url: address of the oeganization's institutional repository for publications, if applicable :type ir_name: URL, optional """ name = models.CharField(verbose_name="Organization name", max_length=600, null=True) website = models.URLField(max_length=600, blank=True, null=True) country = models.ManyToManyField("Country") ror = models.CharField(max_length=255, blank=True, null=True) fundref = models.CharField(max_length=255, blank=True, null=True) starting_year = models.IntegerField(blank=True, null=True) is_funder = models.BooleanField(default=False) ir_name = models.CharField(verbose_name="Institutional repository name", max_length=40, null=True, blank=True) ir_url = models.URLField(verbose_name="Institutional repository URL", max_length=100, null=True, blank=True) def __str__(self): return f"{self.name}" class Meta: ordering = ('name',) class Version(models.Model): """ Possible versions of an article during its life cycle: submitted version, accepted version, published version :param name: name of the version :type name: str """ description = models.CharField(max_length=300, null=False) def __str__(self): return f"{self.description}" class Licence(models.Model): """ Licenses that can or must be applied to an article version :param name_or_abbrev: name or abbreviation for the license: copyright, CC-BY,... :type name_or_abbrev: str :param website: web page that describes the license terms :type website: URL, optional """ name_or_abbrev = models.CharField(max_length=300, null=False) website = models.URLField(max_length=600, null=True, blank=True) class Meta: ordering = ('name_or_abbrev',) def __str__(self): return f"{self.name_or_abbrev}" class Cost_factor_type(models.Model): """ Cost factor types: amount, discount... :param name: name of the type :type name: str """ name = models.CharField(max_length=300, null=False) def __str__(self): return f"{self.name}" class Cost_factor(models.Model): """ Cost factors: financial terms applicable to use an Open Access option :param cost_factor_type: type of the cost factor :type ost_factor_type: reference to a `Cost_factor` object :param amount: actual cost or discount :type amount: int :param symbol: currency code or % :type symbol: str :param comment: extra information in free text :type comment: str, optionaé """ cost_factor_type = models.ForeignKey(Cost_factor_type, on_delete=models.CASCADE, blank=True, null=True) amount = models.IntegerField(null=False) symbol = models.CharField(max_length=10, null=False) comment = models.CharField(max_length=120, default="") class Meta: ordering = ('amount',) def __str__(self): return f"{self.id} - {self.amount} {self.symbol} - {self.comment}" class Term(models.Model): """ Terms: possible options to disseminate an article in Open Access :param version: zero or more versions for which the Term is applicable (currently only 1 is supported by the application) :type version: many-to-many relationship to the `Version` class :param cost_factor: zero or more possible cost factors :type cost_factor: many-to-many relationship to the `Cost_factor` class :param licence: zero or more possible licenses :type licence: many-to-many relationship to the `Licence` class :param embargo_months: duration of a possible embargo in months :type embargo_months: int :param ir_archiving: is archiving in an institutional repository allowed/required or not? :type ir_archiving: bool :param comment: extra information as free text :type comment: str, optional """ version = models.ManyToManyField(Version) cost_factor = models.ManyToManyField(Cost_factor) licence = models.ManyToManyField(Licence) embargo_months = models.IntegerField(blank=True, null=True) ir_archiving = models.BooleanField(default=False) - comment = models.CharField(max_length=600, null=True, blank=True) + comment = models.CharField(max_length=1000, null=True, blank=True) def __str__(self): try: # Maybe these fields should not allow NULL values? if self.embargo_months is None: embargo = 'no_' else: embargo = str(self.embargo_months) if self.comment is None: comment = '' else: comment = str(self.comment) term_data = (str(self.id), ';'.join([str(x) for x in self.version.all()]), ';'.join([str(x) for x in self.licence.all()]), ';'.join([str(x) for x in self.cost_factor.all()]), f'Archiving{str(self.ir_archiving)} {embargo}months', comment,) return ' - '.join(term_data) except RecursionError: # The JSON import in the admin module somehow throws a ValueError during the loading process # probably due to incomplete information in the many2many relationships # Then the error log apparently triggers a cascade of errors until # the RecursionError level is hit. Falling back to a basic __str__ # for the RecursionError seems to bypass the problem. return f"[Term.__str__() error] {self.id} - {self.comment}" class Meta: ordering = ('-ir_archiving', 'embargo_months', 'comment') class ConditionType(models.Model): """ Condition types: issued by a journal, by an organization, or agreement between both? :param condition_issuer: `organization-only`, `agreement` or `journal-only` :type condition_issuer: str """ condition_issuer = models.CharField(max_length=300, null=False) def __str__(self): return f"{self.condition_issuer}" class ConditionSubType(models.Model): """ Condition subtypes: in case we need to distinguish more finely than the 3 main condition types :param label: name of the subtype :type label_issuer: str """ label = models.CharField(max_length=300, null=False) def __str__(self): return f"{self.label}" @classmethod def get_default_pk(cls): """ An automatic subtype is attributed to any newly created `CondtionSet` object """ condition_subtype, created = cls.objects.get_or_create(label='Automatic') return condition_subtype.pk class ConditionSet(models.Model): """ Condition sets: collections of Open Access terms applicable to zero or more Journals and zero or more Organizations for some specific reason (policy document, agreement, contract...). :param condition_type: type for the condition set :type condition_type: reference to a `ConditionType` object :param subtype: subtype for the condition set :type subtype: eference to a `ConditionSubType` object :param organization: zero or more organisations to which the condition set is applicable :type organization: many-to-many relationship with the `Organization` class with `OrganizationCondition` objects as connectors :param journal: zero or more journals to which the condition set is applicable :type journal: many-to-many relationship with the `Journal` class with `JournalCondition` objects as connectors :param term: zero or more terms included in the condition set :type term: many-to-many relationship with the `Term` class :param source: web page with information about the condition set (origin, perimeter, etc.) :type source: URL, optional :param comment: description of the condition set as free text (will be used as a title in the frontend) :type comment: str, optional """ condition_type = models.ForeignKey(ConditionType, on_delete=models.CASCADE, blank=True, null=True) subtype = models.ForeignKey(ConditionSubType, on_delete=models.CASCADE, default=ConditionSubType.get_default_pk, null=True) organization = models.ManyToManyField( Organization, through='OrganizationCondition', through_fields=('condition_set', 'organization') ) journal = models.ManyToManyField( Journal, through='JournalCondition', through_fields=('condition_set', 'journal') ) term = models.ManyToManyField(Term) source = models.URLField(max_length=600, null=True, blank=True) comment = models.CharField(max_length=100, null=True, blank=True) def __str__(self): return f"{self.id} {self.condition_type}|{self.comment}" class Meta: # TODO does this work??? 2ndary sort showing institution first, funder second # ordering = ('condition_type__pk', 'organization__is_funder', 'subtype__id', 'comment') # No it does not, it duplicates most journal policies (one copy for funders, one for institutions) ordering = ('condition_type__pk', 'subtype__id', 'comment') class OrganizationCondition(models.Model): """ Organization-ConditionSet connector, linking `organization` with `condition`. The first (`valid_from`) and last (`valid_until`) known days of validity are recorded. """ organization = models.ForeignKey(Organization, on_delete=models.CASCADE, blank=True, null=True) condition_set = models.ForeignKey(ConditionSet, on_delete=models.CASCADE, blank=True, null=True) valid_from = models.DateField(blank=True, null=True) valid_until = models.DateField(blank=True, null=True) class Meta: verbose_name = "Organization/condition_set relationship" def __str__(self): return f"{self.id} {self.organization.name}/ConditionSet {self.condition_set.id}" class JournalCondition(models.Model): """ Journal-ConditionSet connector, linking `journal` with `condition`. The first (`valid_from`) and last (`valid_until`) known days of validity are recorded. """ journal = models.ForeignKey(Journal, on_delete=models.CASCADE, blank=True, null=True) condition_set = models.ForeignKey(ConditionSet, on_delete=models.CASCADE, blank=True, null=True) valid_from = models.DateField(blank=True, null=True) valid_until = models.DateField(blank=True, null=True) class Meta: verbose_name = "Journal/condition_set relationship" def __str__(self): return f"{self.id} {self.journal.name}/{self.condition_set}"