from os import SCHED_OTHER from django.conf import settings from django.shortcuts import resolve_url from django.db import models from django.utils.text import slugify from django.utils.timezone import now from django.utils.functional import cached_property from django.db.models import Q, Count, Min, Max import re import os.path from byostorage.user import BYOStorage from byostorage.cached import CachedStorage from library.music_tags import MusicTag from interface.utils import sign_data import logging # from polyphonic.settings import LIBRARY_STORAGE logger = logging.getLogger(__name__) # try: # library_storage = get_storage_class(settings.LIBRARY_STORAGE)() # except (ImportError, AttributeError): # logger.exception("Failed to load library storage") # library_storage = get_storage_class()() # logger.info("Library storage: %s", library_storage.__class__.__name__) # FIXME: move back to settings library_storage = CachedStorage(BYOStorage()) class Orchestration(models.Model): """ Stores a list of instrument codes as a single entry (space delimited). Can be global or ensemble specific """ collection = models.ForeignKey( "Collection", on_delete=models.CASCADE, related_name="custom_orchestrations", null=True, blank=True, ) name = models.CharField(max_length=100) instruments = models.TextField() def as_list(self): tags = [t.strip() for t in self.instruments.split(" ")] return [(t, MusicTag.from_tag(t)) for t in tags if t] def tag_order(self): tags = [t.strip() for t in self.instruments.split(" ") if t] order = {"score": 0} for i, t in enumerate(tags): order.setdefault(t.strip("-0123456789"), i * 2 + 1) return order def sorter(self): tag_order = self.tag_order() def f(x): return (tag_order.get(x[0].strip("-0123456789"), 1000), x[0]) return f def save(self): self.as_list() super(Orchestration, self).save() def __str__(self): return self.name class ProjectItem(models.Model): """ ProjectItem represents a Work attached to a Project e.g. item in set list or programme It also allows works to be shared from one ensemble to another on a per-project basis. """ project = models.ForeignKey( "interface.Project", on_delete=models.CASCADE, related_name="items" ) work = models.ForeignKey( "Work", on_delete=models.CASCADE, related_name="project_items" ) checkout = models.DateTimeField() due = models.DateTimeField(null=True, blank=True) returned = models.DateTimeField(null=True, blank=True) approved_by = models.ForeignKey("auth.User", on_delete=models.CASCADE) order = models.SmallIntegerField(default=0) section = models.CharField(max_length=100, blank=True) class Meta: ordering = ["order", "work"] def __str__(self): return f"<{self.project_id}:{slugify(self.work.name)}>" class Collection(models.Model): """ A logical collection of works, typically owned by an organisation or person (physical or virtual) """ name = models.CharField(max_length=255, help_text="Name of the collection") prefix = models.CharField( max_length=255, default="default", help_text="Folder to store works in" ) administrators = models.ManyToManyField( "auth.User", related_name="collections", help_text="Administrators for this collection", ) location = models.CharField( max_length=100, help_text="Physical location (institution, town...)", blank=True ) storage = models.ForeignKey( "byostorage.UserStorage", on_delete=models.CASCADE, null=True, blank=True, help_text="User storage for documents", ) notes = models.TextField( blank=True, help_text="Publicly visible notes about collection and loans policy (markdown format)", ) settings = models.JSONField( default=dict, blank=True, help_text="Storage specific settings" ) nonce = models.SmallIntegerField( default=1, help_text="Increment this to reset the authentication links" ) def meta(self, name): items = ( WorkMeta.objects.filter(work__collection=self.pk, name=name) .values_list("value", flat=True) .distinct() ) return items @property def tags(self): return self.meta("tag") @property def genres(self): return self.meta("genre") def has_administrator(self, user): if not user.is_authenticated: return False if user.is_superuser: return True return user.pk in self.administrators.values_list("pk", flat=True) def get_absolute_url(self): return resolve_url("collection_work_list", self.pk) def auth(self): return sign_data(f"{self.pk}-{self.nonce}", 12) def __str__(self): return self.name class EnsembleAccess(models.Model): """ Can have different access levels to a collection """ ACCESS_UNLIMITED = 1 ACCESS_APPROVED = 2 ACCESS_TYPES = ( (ACCESS_UNLIMITED, "Unlimited"), (ACCESS_APPROVED, "Approval required"), ) ensemble = models.ForeignKey( "interface.Ensemble", on_delete=models.CASCADE, related_name="allowed_collections", ) collection = models.ForeignKey( Collection, on_delete=models.CASCADE, related_name="allowed_ensembles" ) access_type = models.PositiveSmallIntegerField(choices=ACCESS_TYPES, default=2) class Meta: verbose_name_plural = "Ensemble access" class Work(models.Model): """ A musical work 'owned' by a collection from a licencing perspective. """ LICENCE_PUBLIC = 2 LICENCE_EXPIRED = 4 LICENCE_RECORDING = 5 LICENCE_PERFORMANCE = 6 LICENCE_PERUSAL = 8 LICENCE_NONE = 10 LICENCE_TYPES = ( (LICENCE_PUBLIC, "Public Domain"), (LICENCE_EXPIRED, "Copyright Expired"), (LICENCE_RECORDING, "Recording Licence"), (LICENCE_PERFORMANCE, "Performance Licence"), (LICENCE_PERUSAL, "Perusal Licence"), (LICENCE_NONE, "Internal use only"), ) name = models.CharField(max_length=255, help_text="Original name of the work") edition = models.CharField( max_length=255, blank=True, help_text="Edition details to distinguish multiple versions", ) parent = models.ForeignKey( "Work", null=True, blank=True, on_delete=models.SET_NULL, related_name="related_works", help_text="Arrangement of another work or part of an anthology", ) composer = models.CharField( max_length=255, default="Anon", help_text="Composer or compilation editor. Use Surname, Initial for easy searching", ) orchestration = models.ForeignKey( Orchestration, on_delete=models.SET_DEFAULT, default=1, help_text="Orchestration for the work", ) original_parts = models.JSONField( default=dict, blank=True, help_text="Original printed parts (IMSLP format)" ) # Collection details collection = models.ForeignKey( Collection, on_delete=models.CASCADE, related_name="works" ) code = models.CharField( max_length=100, blank=True, help_text="Collection specific code or number. Will be auto-generated if not supplied", ) licence = models.PositiveSmallIntegerField( choices=LICENCE_TYPES, default=6, help_text="Copyright status" ) max_projects = models.IntegerField( default=1, help_text="How many active projects can this work be attached to" ) # Extra info running_time = models.DurationField( null=True, blank=True, help_text="Running time in mm:ss format" ) notes = models.TextField(blank=True) # Allocation to projects projects = models.ManyToManyField( "interface.Project", through="ProjectItem", related_name="works", help_text="Current usage", ) @property def folder(self): return f"{slugify(self.composer)}/{slugify(self.name)}-{self.pk:04d}" def tagged_sections(self, *tags): qs = self.docs.filter(sections__tag__in=tags) qs = qs.annotate( Count("sections"), end=Min("sections__end"), start=Max("sections__start") ).filter(sections__count=len(tags)) return qs def list_sections(self, *tags): return list(self.tagged_sections(*tags).values_list("upload", "start", "end")) @property def digital_parts(self): sections = [(s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk)] sections.sort(key=self.orchestration.sorter()) # return [ s[1] for s in sections ] sections = list(dict(sections).items()) # primitive unique() return sections def pdfs(self): return self.docs.filter(doctype=Document.DOCTYPE_PDF) @property def physical_parts(self): if not self.original_parts: return [] parts = list(self.original_parts.items()) parts.sort(key=self.orchestration.sorter()) return [(MusicTag.from_tag(x[0]), x[1]) for x in parts] @property def tags(self): return self.meta_info.filter(name="tag").values_list("value", flat=True) @property def meta(self): return self.meta_info.exclude(name="tag") @property def current_loans(self): return self.project_items.filter( checkout__lte=now(), returned=None ).select_related("project") @cached_property def loans(self): try: return self.loan_count except AttributeError: return self.project_items.filter(checkout__lte=now(), returned=None).count() @property def is_available(self): if self.max_projects < 0: return True return self.max_projects > self.loans @property def available(self): if self.max_projects < 0: return "Unlimited" a = self.max_projects - self.loans return "{0} of {1}".format(max(a, 0), self.max_projects) @property def identifier(self): if self.code: return self.code composer = self.composer or "Anon" composer = re.sub("[^\w]", "", composer) words = self.name.split() work = words[0][:3] return f"{composer[:4]}-{work}-{self.pk:05d}".upper() def assigned_instruments(self): return Section.objects.filter(doc__work_id=self.pk).values_list( "tag", flat=True ) def unassigned_instruments(self): assigned = set(self.assigned_instruments()) return [x for x in self.orchestration.as_list() if not x[0] in assigned] def music_tags(self): tags = dict(self.orchestration.as_list()) for section in Section.objects.filter(doc__work_id=self.pk): tags.setdefault(section.tag, section.name) return tags.items() def __str__(self): return f"{self.name} ({self.composer})" class WorkMeta(models.Model): META_CHOICES = ( ("tag", "Tag"), ("arr", "Arranger"), ("lyrics", "Lyracist"), ("genre", "Genre"), ("style", "Style"), ("orchestration", "Orchestration"), ) work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="meta_info") name = models.SlugField(max_length=20, choices=META_CHOICES) value = models.CharField(max_length=255) def doc_upload_filename(doc, filename): collection = doc.work.collection storage = collection.storage if not storage: raise RuntimeError("Collection has no storage attached") return f"{storage}:library/{collection.prefix}/{doc.work.folder}/{filename}" class Document(models.Model): """ Document represents a single file stored in the storage backend. """ DOCTYPE_PDF = 1 DOCTYPE_AUDIO = 2 DOCTYPE_VIDEO = 3 DOCTYPE_MISC = 4 DOCTYPES = ( (DOCTYPE_PDF, "PDF"), (DOCTYPE_AUDIO, "Audio"), (DOCTYPE_VIDEO, "Video"), (DOCTYPE_MISC, "Misc"), ) DOCTYPE_MAP = { ".pdf": DOCTYPE_PDF, ".mp3": DOCTYPE_AUDIO, ".mp4": DOCTYPE_VIDEO, } work = models.ForeignKey("Work", on_delete=models.CASCADE, related_name="docs") doctype = models.PositiveSmallIntegerField(choices=DOCTYPES, default=DOCTYPE_PDF) upload = models.FileField(upload_to=doc_upload_filename, storage=library_storage) created = models.DateTimeField(auto_now_add=True) version = models.CharField(max_length=30, blank=True) def delete(self, *args, **kwargs): self.upload.delete(save=False) return super().delete(*args, **kwargs) def filename(self): return os.path.basename(self.upload.name) def __str__(self): return self.upload.name class Section(models.Model): """ Section is a tagged portion of a Document """ PAGE_AUTO = 0 PAGE_LEFT = 1 PAGE_RIGHT = 2 PAGE_PREFERENCE = ( (PAGE_AUTO, "auto"), (PAGE_LEFT, "left"), (PAGE_RIGHT, "right"), ) doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="sections") tag = models.CharField(max_length=50, blank=True) start = models.SmallIntegerField(null=True, blank=True) end = models.SmallIntegerField(null=True, blank=True) page = models.SmallIntegerField( default=PAGE_AUTO, choices=PAGE_PREFERENCE ) # NOT CURRENTLY USED class Meta: ordering = ["doc", "start", "pk"] @property def music_tag(self): return MusicTag.from_tag(self.tag) @property def name(self): return str(self.music_tag) @property def bulma_class(self): return "success" if self.music_tag.is_general else "info" @property def filename(self): return slugify(f"{self.doc.work.name} - {self.name}").title() + ".pdf" @property def pagerange(self): if self.start: if self.end: return f"{self.start}-{self.end}" return str(self.start) return "all" def __str__(self): return self.name