diff --git a/docs/modules.rst b/docs/modules.rst index ca9278b4..1a60105b 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -43,20 +43,21 @@ music\_publisher.models module :members: :show-inheritance: -music\_publisher.admin module ------------------------------ +music\_publisher.cwr_templates module +------------------------------------- -.. automodule:: music_publisher.admin +.. automodule:: music_publisher.cwr_templates :members: :show-inheritance: -music\_publisher.cwr_templates module -------------------------------------- +music\_publisher.admin module +----------------------------- -.. automodule:: music_publisher.cwr_templates +.. automodule:: music_publisher.admin :members: :show-inheritance: + music\_publisher.data_import module ----------------------------------- diff --git a/music_publisher/__init__.py b/music_publisher/__init__.py index ed30d6e5..71dc8b21 100644 --- a/music_publisher/__init__.py +++ b/music_publisher/__init__.py @@ -1,8 +1,6 @@ """ -This Django app is holds metadata about musical works and recordings, -including songwriters, performing and recording artists, music library and -albums. It allows data exports in JSON and CWR formats. - -Django Admin is the only frontend. +Django-Music-Publisher (DMP) is open source software for managing music +metadata, registration/licencing of musical works and royalty processing. +:mod:`music_publisher` app is the only Django app in this project. """ diff --git a/music_publisher/apps.py b/music_publisher/apps.py index 0c0f354e..a0563a12 100644 --- a/music_publisher/apps.py +++ b/music_publisher/apps.py @@ -1,4 +1,4 @@ -"""Django app definition for `music_publisher`.""" +"""Django app definition for :mod:`music_publisher`.""" from django.apps import AppConfig diff --git a/music_publisher/base.py b/music_publisher/base.py index 403316f2..5032366a 100644 --- a/music_publisher/base.py +++ b/music_publisher/base.py @@ -33,6 +33,9 @@ def __str__(self): class PersonBase(models.Model): """Base class for all classes that contain people with first and last name. + This includes writers and artists. For bands, only the last name field is + used. + Attributes: first_name (django.db.models.CharField): First Name last_name (django.db.models.CharField): Last Name diff --git a/music_publisher/cwr_templates.py b/music_publisher/cwr_templates.py index df93a38c..de326e65 100644 --- a/music_publisher/cwr_templates.py +++ b/music_publisher/cwr_templates.py @@ -2,6 +2,7 @@ Attributes: TEMPLATES_21 (dict): Record templates for CWR 2.1 + TEMPLATES_30 (dict): Record templates for CWR 3.0 """ from django.template import Template @@ -13,6 +14,7 @@ '{{ publisher_name|ljust:45 }}01.10{{ creation_date|date:"Ymd" }}' '{{ creation_date|date:"His" }}{{ creation_date|date:"Ymd" }}' ' \r\n{% endautoescape %}'), + # CWR 2.1 revision 8 "hack" - no sender type field, 11 digit IPI name 'HDR_8': Template( '{% load cwr_filters %}{% autoescape off %}' 'HDR{{ publisher_ipi_name|rjust:11 }}' diff --git a/music_publisher/data_import.py b/music_publisher/data_import.py index f1c0be61..3b581925 100644 --- a/music_publisher/data_import.py +++ b/music_publisher/data_import.py @@ -3,22 +3,27 @@ """ import csv -from collections import defaultdict, OrderedDict -from django.utils.text import slugify -from .models import ( - Work, Artist, ArtistInWork, Writer, WriterInWork, - Library, LibraryRelease, Recording) -from .admin import WriterInWorkFormSet import re -from django.conf import settings +from collections import defaultdict, OrderedDict from decimal import Decimal -from django.forms import inlineformset_factory + +from django.conf import settings from django.contrib.admin.models import LogEntry, ADDITION, CHANGE from django.contrib.admin.options import get_content_type_for_model from django.db import IntegrityError, transaction +from django.forms import inlineformset_factory +from django.utils.text import slugify + +from .admin import WriterInWorkFormSet +from .models import ( + Work, Artist, ArtistInWork, Writer, WriterInWork, + Library, LibraryRelease, Recording) class DataImporter(object): + """ + + """ FLAT_FIELDS = [ 'work_id', 'work_title', 'iswc', 'original_title', 'library', 'cd_identifier'] diff --git a/music_publisher/models.py b/music_publisher/models.py index 453807e0..55d2eb43 100644 --- a/music_publisher/models.py +++ b/music_publisher/models.py @@ -1,6 +1,6 @@ """Concrete models. -They mostly inherit classes from :mod:`.base`. +They mostly inherit from classes in :mod:`.base`. """ @@ -179,8 +179,12 @@ def release_id(self): def get_dict(self, with_tracks=False): """Get the object in an internal dictionary format + Args: + with_tracks (bool): add track data to the output + Returns: dict: internal dict format + """ d = { @@ -215,6 +219,14 @@ def get_queryset(self): return super().get_queryset().filter(cd_identifier__isnull=False) def get_dict(self, qs): + """Get the object in an internal dictionary format + + Args: + qs (django.db.models.query.QuerySet) + + Returns: + dict: internal dict format + """ return { 'releases': [release.get_dict(with_tracks=True) for release in qs] } @@ -279,6 +291,14 @@ def get_queryset(self): return super().get_queryset().filter(cd_identifier__isnull=True) def get_dict(self, qs): + """Get the object in an internal dictionary format + + Args: + qs (django.db.models.query.QuerySet) + + Returns: + dict: internal dict format + """ return { 'releases': [release.get_dict(with_tracks=True) for release in qs] } @@ -300,7 +320,9 @@ class Meta: class OriginalPublishingAgreement(models.Model): - """Original Publishing Agreement for controlled writers.""" + """Original Publishing Agreement for controlled writers. + + Not used in DMP.""" class Meta: managed = False @@ -308,6 +330,10 @@ class Meta: class Writer(WriterBase): """Writers. + + Attributes: + original_publishing_agreement (django.db.models.ForeignKey): \ + Foreign key to :class:`.models.OriginalPublishingAgreement` """ class Meta: @@ -326,7 +352,7 @@ def __str__(self): return name def clean(self, *args, **kwargs): - """Check if writer who is controlled can no longer be.""" + """Check if writer who is controlled still has enough data.""" super().clean(*args, **kwargs) if self.pk is None or self._can_be_controlled: return @@ -422,7 +448,7 @@ def get_dict(self, qs): Return a dictionary with works from the queryset Args: - qs(django.db.models.query import QuerySet): works queryset + qs(django.db.models.query import QuerySet) Returns: dict: dictionary with works @@ -453,6 +479,8 @@ class Work(TitleBase): """Concrete class, with references to foreign objects. Attributes: + _work_id (django.db.models.CharField): permanent work id, either \ + imported or fixed when exports are created iswc (django.db.models.CharField): ISWC original_title (django.db.models.CharField): title of the original \ work, implies modified work @@ -471,7 +499,8 @@ class Work(TitleBase): class Meta: verbose_name = 'Musical Work' ordering = ('-id',) - permissions = (('can_process_royalties', 'Can perform royalty calculations'),) + permissions = ( + ('can_process_royalties', 'Can perform royalty calculations'),) _work_id = models.CharField( 'Work ID', max_length=14, blank=True, null=True, unique=True, @@ -916,11 +945,7 @@ def get_dict(self): class Recording(models.Model): - """Holds data on first recording. - - Note that the CWR 2.x limitation of just one REC record per work has been - removed in the specs, but some societies still complain about it, - so only a single instance is allowed. + """Recording. Attributes: release_date (django.db.models.DateField): Recording Release Date @@ -963,13 +988,15 @@ class Meta: def clean_fields(self, *args, **kwargs): """ + ISRC cleaning, just removing dots and dashes. + + Args: + *args: may be used in upstream + **kwargs: may be used in upstream + + Returns: + return from :meth:`django.db.models.Model.clean_fields` - :param args: - :type args: - :param kwargs: - :type kwargs: - :return: - :rtype: """ if self.isrc: # Removing all characters added for readability @@ -979,9 +1006,10 @@ def clean_fields(self, *args, **kwargs): @property def complete_recording_title(self): """ + Return complete recording title. - :return: - :rtype: + Returns: + str """ if self.recording_title_suffix: return '{} {}'.format( @@ -991,9 +1019,10 @@ def complete_recording_title(self): @property def complete_version_title(self): """ + Return complete version title. - :return: - :rtype: + Returns: + str """ if self.version_title_suffix: return '{} {}'.format( @@ -1002,6 +1031,7 @@ def complete_version_title(self): return self.version_title def __str__(self): + """Return the most precise type of title""" return ( self.complete_version_title if self.version_title else self.complete_recording_title if self.recording_title else @@ -1021,8 +1051,13 @@ def recording_id(self): def get_dict(self, with_releases=False, with_work=True): """Create a data structure that can be serialized as JSON. + Args: + with_releases (bool): add releases data (through tracks) + with_work (bool): add work data + Returns: dict: JSON-serializable data structure + """ j = { 'id': @@ -1055,7 +1090,7 @@ def get_dict(self, with_releases=False, with_work=True): 'cut_number': track.cut_number, }) if with_work: - j['works'] = {'work': self.work.get_dict(with_recordings=False)} + j['works'] = [{'work': self.work.get_dict(with_recordings=False)}] return j @@ -1078,20 +1113,28 @@ class Meta: def get_dict(self): return { 'cut_number': self.cut_number, - 'recording': self.recording.get_dict(with_releases=False, with_work=True) + 'recording': self.recording.get_dict( + with_releases=False, with_work=True) } + class CWRExport(models.Model): """Export in CWR format. Common Works Registration format is a standard format for registration of - musical works world-wide. As of November 2018, version 2.1r7 is used - everywhere, while some societies accept 2.2 as well, it adds no benefits - in this context. Version 3.0 is in draft. + musical works world-wide. Exports are available in CWR 2.1 revision 8 and + CWR 3.0 (experimental). Attributes: - nwr_rev (django.db.models.CharField): Choice field where user can - select which version and type of CWR it is. + nwr_rev (django.db.models.CharField): choice field where user can + select which version and type of CWR it is + cwr (django.db.models.TextField): contents of CWR file + year (django.db.models.CharField): 2-digit year format + num_in_year (django.db.models.PositiveSmallIntegerField): \ + CWR sequential number in a year + works (django.db.models.ManyToManyField): included works + description (django.db.models.CharField): internal note + """ class Meta: @@ -1116,12 +1159,18 @@ class Meta: @property def version(self): + """Return CWR version.""" if self.nwr_rev in ['WRK', 'ISR']: return '30' return '21' @property def filename(self): + """Return CWR file name. + + Returns: + str: CWR file name + """ if self.version == '30': return self.filename30 return self.filename21 @@ -1173,6 +1222,7 @@ def get_record(self, key, record): if self.version == '30': template = TEMPLATES_30.get(key) elif key == 'HDR' and len(settings.PUBLISHER_IPI_NAME.lstrip('0')) > 9: + # CWR 2.1 revision 8 "hack" for 10+ digit IPI name numbers template = TEMPLATES_21.get('HDR_8') else: template = TEMPLATES_21.get(key) @@ -1229,6 +1279,15 @@ def yield_iswc_request_lines(self, works): self.transaction_count += 1 def yield_publisher_lines(self, controlled_relative_share): + """Yield SPU/SPT lines. + + Args: + controlled_relative_share (Decimal): sum of manuscript shares \ + for controlled writers + + Yields: + str: CWR record (row/line) + """ yield self.get_transaction_record( 'SPU', {'share': controlled_relative_share}) if controlled_relative_share: @@ -1237,6 +1296,12 @@ def yield_publisher_lines(self, controlled_relative_share): def yield_registration_lines(self, works): """Yield lines for CWR registrations (WRK in 3.x, NWR and REV in 2.x) + + Args: + works (list): list of work dicts + + Yields: + str: CWR record (row/line) """ for work in works: @@ -1512,9 +1577,10 @@ class Meta: def get_dict(self): """ + Return dictionary with external work IDs. - :return: - :rtype: + Returns: + dict: JSON-serializable data structure """ # if not self.remote_work_id: # return None @@ -1557,7 +1623,10 @@ def __str__(self): class DataImport(models.Model): - """Importing basic work data from a CSV file.""" + """Importing basic work data from a CSV file. + + This class just acts as log, the actual logic is in :mod:`.data_import`. + """ class Meta: verbose_name = 'Data Import' diff --git a/music_publisher/validators.py b/music_publisher/validators.py index f2b19840..4f0c45cb 100644 --- a/music_publisher/validators.py +++ b/music_publisher/validators.py @@ -1,7 +1,7 @@ """CWR-compatibility field-level validation. For formats that allow dashes and dots (ISWC, IPI Base), the actual format is -from CWR 2.x specification for compatibility. +from CWR 2.x specification: ISWC without and IPI Base with dashes. """ @@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils.deconstruct import deconstructible + TITLES_CHARS = re.escape( r"!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`{}~£€") NAMES_CHARS = re.escape(r"!#$%&'()+-./0123456789?@ABCDEFGHIJKLMNOPQRSTUVWXYZ`") @@ -32,6 +33,7 @@ def check_ean_digit(ean): Raises: ValidationError """ + number = ean[:-1] ch = str( (10 - sum( @@ -125,9 +127,6 @@ def __call__(self, value): Args: value (): Input value - Returns: - None: If all is well. - Raises: ValidationError: If the value does not pass the validation. """ @@ -167,7 +166,11 @@ def __call__(self, value): def validate_settings(): - """CWR-compliance validation for settings""" + """CWR-compliance validation for settings. + + This is used to prevent deployment with invalid settings. + """ + if settings.PUBLISHER_NAME: try: CWRFieldValidator('name')(settings.PUBLISHER_NAME)