class _Engagement(Model): """An engagement with a beginning and, perhaps, an end.""" start_date = HistoricDateTimeField(null=True, blank=True) end_date = HistoricDateTimeField(null=True, blank=True) class Meta: """ Meta options for the _Engagement model. See https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. """ abstract = True
class Search(Model): """A search.""" query = models.CharField(verbose_name=_('query'), max_length=100, null=True, blank=True) ordering = models.CharField(max_length=10, choices=ORDERING_OPTIONS) start_year = HistoricDateTimeField(null=True, blank=True) end_year = HistoricDateTimeField(null=True, blank=True) class Meta: """ Meta options for Search. See https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. """ verbose_name_plural = 'Searches' def __str__(self) -> str: """Return a string representation of the search.""" return str(self.query)
class Categorization(Model): """A categorization of an entity.""" entity = models.ForeignKey( to='entities.Entity', related_name='categorizations', on_delete=models.CASCADE, verbose_name=_('entity'), ) category = models.ForeignKey( to=Category, related_name='categorizations', on_delete=models.CASCADE, verbose_name=_('category'), ) date = HistoricDateTimeField(verbose_name=_('date'), null=True, blank=True) end_date = HistoricDateTimeField(verbose_name=_('end date'), null=True, blank=True) class Meta: """ Meta options for the Categorization model. See https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. """ unique_together = ['entity', 'category'] def __str__(self) -> str: """Return the categorization's string representation.""" return str(self.category) @property def weight(self) -> int: """Return the categorization weight.""" return self.category.weight
class TextualSourceMixin(models.Model): """Mixin model for textual sources.""" editors = models.CharField( max_length=100, null=True, blank=True, ) original_edition = models.ForeignKey( to='self', related_name='subsequent_editions', blank=True, null=True, on_delete=models.SET_NULL, ) original_publication_date = HistoricDateTimeField(null=True, blank=True) class Meta: """Meta options.""" # https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. abstract = True @property def file_page_number(self) -> Optional[int]: """Return the page number to which the source file should be opened.""" file = self.file if file: if self.containment and self.containment.page_number: return self.containment.page_number + file.page_offset return file.first_page_number + file.page_offset return None @property def file_url(self) -> Optional[str]: """Return the URL to be used to open the source file.""" file_url = super().file_url if file_url and self.file_page_number: file_url = f'{file_url}#page={self.file_page_number}' return file_url
class Entity( TypedModel, SluggedModel, TaggableModel, ModelWithComputations, ModelWithImages, ModelWithRelatedQuotes, ModelWithRelatedEntities, ): """An entity.""" name = models.CharField(verbose_name=_('name'), max_length=NAME_MAX_LENGTH, unique=True) unabbreviated_name = models.CharField(max_length=NAME_MAX_LENGTH, unique=True, null=True, blank=True) aliases = ArrayField( models.CharField(max_length=NAME_MAX_LENGTH), verbose_name=_('aliases'), null=True, blank=True, ) birth_date = HistoricDateTimeField(null=True, blank=True) death_date = HistoricDateTimeField(null=True, blank=True) description = HTMLField(null=True, blank=True, paragraphed=True) categories = models.ManyToManyField( to='entities.Category', through='entities.Categorization', related_name='entities', blank=True, ) images = models.ManyToManyField( to='images.Image', through='entities.EntityImage', related_name='entities', blank=True, ) affiliated_entities = models.ManyToManyField( to='self', through='entities.Affiliation', blank=True) class Meta: """ Meta options for the Entity model. See https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. """ verbose_name_plural = 'Entities' ordering = ['name'] searchable_fields = ['name', 'aliases', 'description'] serializer = EntitySerializer slug_base_field = 'unabbreviated_name' def __str__(self) -> str: """Return the string representation of the entity.""" return f'{self.name}' def save(self, *args, **kwargs): """Save the entity to the database.""" self.clean() super().save(*args, **kwargs) def clean(self): """Prepare the entity to be saved.""" super().clean() if not self.unabbreviated_name: self.unabbreviated_name = self.name if self.type == 'entities.entity' or not self.type: raise ValidationError('Entity must have a type.') else: # Prevent a RuntimeError when saving a new publication self.recast(self.type) @property def has_quotes(self) -> bool: """Return whether the entity has any attributed quotes.""" return self.quotes.exists() @property def name_html(self) -> str: """Return an HTML string of the entity's name.""" logging.debug(f'Getting name_html for {self}') return format_html( f'<span class="entity-name" data-entity-id="{self.pk}">{self.name}</span>' ) @property def truncated_description(self) -> str: """Return the entity's description, truncated.""" return format_html( truncatechars_html(self.description, TRUNCATED_DESCRIPTION_LENGTH)) def get_categorization(self, date: DateTime) -> Optional['Categorization']: """Return the most applicable categorization based on the date.""" if not self.categories.exists(): return None categorizations = self.categorizations.all() categorizations = (categorizations.exclude( date__gt=date) if date else categorizations) if not len(categorizations): categorizations = self.categorizations.all() return categorizations.order_by('date', 'category__weight').last() def get_categorizations( self, date: Optional[DateTime] = None) -> 'QuerySet[Categorization]': """Return a list of all applicable categorizations.""" categorizations = (self.categorizations.exclude( date__gt=date) if date else self.categorizations.all()) return categorizations.select_related('category') @retrieve_or_compute(attribute_name='categorization_string') def get_categorization_string(self, date: Optional[DateTime] = None) -> str: """Intelligently build a categorization string, like `liberal scholar`.""" categorizations: 'QuerySet[Categorization]' = self.get_categorizations( date) if categorizations: # Build the string categorization_words: List[str] = [] for part_of_speech in ('noun', 'any', 'adj'): pos_categorizations = categorizations.filter( category__part_of_speech=part_of_speech) if pos_categorizations.exists(): categorization_str = str( pos_categorizations.order_by('category__weight', 'date').last()) words = [ word for word in categorization_str.split(' ') if word not in categorization_words ] categorization_words = words + categorization_words # Remove duplicate words categorization_words = list(dict.fromkeys(categorization_words)) return ' '.join(categorization_words) return EMPTY_STRING
class Source(PolymorphicModel, SearchableDatedModel, ModelWithRelatedEntities): """A source of content or information.""" attributee_html = models.CharField(max_length=MAX_ATTRIBUTEE_HTML_LENGTH, null=True, blank=True) attributee_string = models.CharField( max_length=MAX_ATTRIBUTEE_STRING_LENGTH, null=True, blank=True) attributees = models.ManyToManyField( to='entities.Entity', through='SourceAttribution', related_name='attributed_sources', blank=True, # Some sources may not have attributees. verbose_name=_('attributees'), ) citation_html = models.TextField( verbose_name=_('citation HTML'), null=False, # cannot be null in db blank=True, # can be left blank in admin form ) citation_string = models.CharField( max_length=MAX_CITATION_STRING_LENGTH, null=False, # cannot be null in db blank=True, # can be left blank in admin form unique=True, ) containers = models.ManyToManyField( to='self', through='sources.SourceContainment', through_fields=('source', 'container'), related_name='contained_sources', symmetrical=False, blank=True, ) date = HistoricDateTimeField(null=True, blank=True) description = HTMLField(null=True, blank=True, paragraphed=True) file = models.ForeignKey( to=SourceFile, related_name='sources', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='file', ) location = models.ForeignKey(to='places.Place', null=True, blank=True, on_delete=models.SET_NULL) publication_date = HistoricDateTimeField(null=True, blank=True) related = GenericManyToManyField( 'quotes.Quote', 'occurrences.Occurrence', through='sources.Citation', related_name='sources', blank=True, ) title = models.CharField(verbose_name=_('title'), max_length=MAX_TITLE_LENGTH, null=True, blank=True) url = models.URLField( max_length=MAX_URL_LENGTH, null=True, blank=True, help_text='URL where the source can be accessed online', ) class Meta: ordering = ['-date'] objects = PolymorphicSourceManager.from_queryset( PolymorphicSourceQuerySet)() searchable_fields = ['citation_string', 'description'] serializer = SourceSerializer slug_base_field = 'title' def __str__(self): """Return the source's string representation.""" return self.citation_string def clean(self): """Prepare the source to be saved.""" super().clean() self.citation_html = self.calculate_citation_html() self.citation_string = soupify(self.citation_html).get_text() if not self.file: if self.containment and self.containment.container.file: self.file = self.containment.container.file if self.pk: # If this source is not being newly created is_duplicate = (Source.objects.exclude(pk=self.pk).filter( citation_string=self.citation_string).exists()) if is_duplicate: raise ValidationError( f'Unable to save this source because it duplicates an existing source ' f'or has an identical string: {self.citation_string}') for container in self.containers.all(): if self in container.containers.all(): raise ValidationError( f'This source cannot be contained by {container}, ' f'because that source is already contained by this source.' ) @property def ctype(self): return self.polymorphic_ctype @property def escaped_citation_html(self) -> SafeString: return format_html(self.citation_html) @property def calculate_attributee_html(self) -> Optional[str]: """Return an HTML string representing the source's attributees.""" # Check for pk to avoid RecursionErrors with not-yet-saved objects has_attributees = self.attributees.exists() if self.pk else False if self.attributee_string: attributee_html = self.attributee_string if has_attributees: for entity in self.attributees.all().iterator(): if entity.name in attributee_html: attributee_html = attributee_html.replace( entity.name, entity.name_html) else: logging.info( f'Returning preset creator string: {attributee_html}') return format_html(attributee_html) elif not has_attributees: return None attributees = self.ordered_attributees n_attributions = len(attributees) first_attributee = attributees[0] html = first_attributee.name_html if n_attributions == 2: html = f'{html} and {attributees[1].name_html}' elif n_attributions == 3: html = f'{html}, {attributees[1].name_html}, and {attributees[2].name_html}' elif n_attributions > 3: html = f'{html} et al.' return html def calculate_citation_html(self) -> str: """Return the HTML representation of the source, including its containers.""" # TODO: html methods should be split into different classes and/or mixins. html = self.__html__() container_strings = self.get_container_strings() if container_strings: containers = ', and '.join(container_strings) html = f'{html}, {containers}' elif getattr(self, 'page_number', None): page_number_html = _get_page_number_html(self, self.file, self.page_number, self.end_page_number) html = f'{html}, {page_number_html}' if not self.file: if self.link and self.link not in html: html = f'{html}, retrieved from {self.link}' if getattr(self, 'information_url', None) and self.information_url: html = ( f'{html}, information available at ' f'{compose_link(self.information_url, href=self.information_url, target="_blank")}' ) the_code_below_is_good = False if the_code_below_is_good: # TODO: Remove search icon; insert link intelligently if self.file: html += ( f'<a href="{self.file.url}" class="mx-1 display-source"' f' data-toggle="modal" data-target="#modal">' f'<i class="fas fa-search"></i>' f'</a>') elif self.url: link = self.url if self.page_number and 'www.sacred-texts.com' in link: link = f'{link}#page_{self.page_number}' html += (f'<a href="{link}" class="mx-1" target="_blank">' f'<i class="fas fa-search"></i>' f'</a>') return format_html(fix_comma_positions(html)) @property def containment(self) -> Optional['SourceContainment']: """Return the source's primary containment.""" try: return self.source_containments.first() except (ObjectDoesNotExist, AttributeError): return None @property def escaped_citation_html(self) -> SafeString: return format_html(self.citation_html) def get_container_strings(self) -> Optional[List[str]]: """Return a list of strings representing the source's containers.""" containments = self.source_containments.order_by('position')[:2] container_strings = [] same_creator = True for containment in containments: container_html = f'{containment.container.html}' # Determine whether the container has the same attributee if containment.container.attributee_html != self.attributee_html: same_creator = False # Remove redundant creator string if necessary creator_string_is_duplicated = (same_creator and self.attributee_html and self.attributee_html in container_html) if creator_string_is_duplicated: container_html = container_html[len(f'{self.attributee_html}, ' ):] # Include the page number if containment.page_number: page_number_html = _get_page_number_html( containment.source, containment.source.file, containment.page_number, containment.end_page_number, ) container_html = f'{container_html}, {page_number_html}' container_html = (f'{containment.phrase} in {container_html}' if containment.phrase else f'in {container_html}') container_strings.append(container_html) return container_strings def get_date(self) -> Optional[HistoricDateTime]: """Get the source's date.""" # TODO: prefetch container? if self.date: return self.date elif self.containment and self.containment.container.date: return self.containment.container.date return None @property # type: ignore @retrieve_or_compute(attribute_name='href') def href(self) -> Optional[str]: """ Return the href to use when providing a link to the source. If the source has a file, the URL of the file is returned; otherwise, the source's `url` field value is returned. """ if self.file: url = self.file.url page_number = self.file.default_page_number if getattr(self, 'page_number', None): page_number = self.page_number + self.file.page_offset if page_number: url = _set_page_number(url, page_number) else: url = self.url return url @property def link(self) -> Optional[SafeString]: """Return an HTML link element containing the source URL, if one exists.""" if self.url: return format_html( f'<a target="_blank" href="{self.url}">{self.url}</a>') return None @property def linked_title(self) -> Optional[SafeString]: """Return the source's title as a link.""" if not self.title: return None html = (compose_link( self.title, href=self.href, klass='source-title display-source', target=NEW_TAB, ) if self.href else self.title) return format_html(html) @property def ordered_attributees(self) -> List['Entity']: """Return an ordered list of the source's attributees.""" try: attributions = self.attributions.select_related('attributee') return [attribution.attributee for attribution in attributions] except (AttributeError, ObjectDoesNotExist): return [] @property # type: ignore @retrieve_or_compute(attribute_name='containers') def serialized_containments(self) -> List[Dict]: """Return the source's containers, serialized.""" return [ containment.container.serialize() for containment in self.source_containments.all().select_related('container') ] def __html__(self) -> str: """ Return the source's HTML representation, not including its containers. Must be defined by models inheriting from Source. """ raise NotImplementedError @staticmethod def components_to_html(components: Sequence[Optional[str]]): """Combine a list of HTML components into an HTML string.""" return components_to_html(components, delimiter=COMPONENT_DELIMITER)
class DatedModel(Model): """A model with a date (e.g., a quote or occurrence).""" date_is_circa = models.BooleanField( verbose_name=_('date is circa'), blank=True, default=False, help_text='whether the date is estimated/imprecise', ) date = HistoricDateTimeField(verbose_name=_('date'), null=True) class Meta: """ Meta options for DatedModel. See https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. """ abstract = True def date_string(self) -> str: """Return the string representation of the model instance's date.""" date_html = self.date_html return soupify(date_html).get_text() if date_html else '' date_string.admin_order_field = 'date' # type: ignore date_string = property(date_string) # type: ignore @property # type: ignore @retrieve_or_compute(caster=format_html) def date_html(self) -> SafeString: """Return the HTML representation of the model instance's date.""" date, date_html = self.get_date(), '' if date: date_html = f'{date.html}' date_html_requires_circa_prefix = (date_html and self.date_is_circa and CIRCA_PREFIX not in date_html) if date_html_requires_circa_prefix: date_html = f'{CIRCA_PREFIX}{date_html}' end_date = getattr(self, 'end_date', None) if end_date: date_html = f'{date_html} – {end_date.html}' use_ce = (self.date.year < 1000 and not self.date.is_bce and not date_html.endswith(' CE')) if use_ce: date_html = f'{date_html} CE' return format_html(date_html) @property def year_html(self) -> Optional[SafeString]: """Return the HTML representation of the model instance's date's year.""" if not self.date: return None year_html = self.date.year_string if self.date_is_circa and not self.date.month_is_known: year_html = f'{CIRCA_PREFIX}{year_html}' year_html = year_html.replace(f'{CIRCA_PREFIX}{CIRCA_PREFIX}', CIRCA_PREFIX) return format_html(year_html) def get_date(self) -> Optional[HistoricDateTime]: """ Determine and return the model instance's date. Override this to retrieve date values through other means, e.g., by inspecting related objects. """ return self.date or None
class Quote( SearchableDatedModel, ModelWithSources, ModelWithRelatedQuotes, ModelWithRelatedEntities, ModelWithImages, ): """A quote.""" text = HTMLField(verbose_name='text', paragraphed=True) bite = HTMLField(verbose_name='bite', null=True, blank=True) pretext = HTMLField( verbose_name='pretext', null=True, blank=True, paragraphed=False, help_text='Content to be displayed before the quote', ) context = HTMLField( verbose_name='context', null=True, blank=True, paragraphed=True, help_text='Content to be displayed after the quote', ) date = HistoricDateTimeField(null=True) attributees = models.ManyToManyField( to='entities.Entity', through='quotes.QuoteAttribution', related_name='quotes', blank=True, ) related = GenericManyToManyField( 'occurrences.Occurrence', 'entities.Entity', 'quotes.Quote', through='quotes.QuoteRelation', related_name='related_quotes', blank=True, ) images = models.ManyToManyField('images.Image', through='quotes.QuoteImage', related_name='quotes', blank=True) class Meta: """ Meta options for Quote. See https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. """ unique_together = ['date', 'bite'] ordering = ['date'] objects: QuoteManager = QuoteManager() # type: ignore placeholder_regex = quote_placeholder_regex searchable_fields = [ 'text', 'context', 'attributees__name', 'date__year', 'sources__citation_string', 'tags__topic__key', 'tags__topic__aliases', ] serializer = QuoteSerializer def __str__(self) -> str: """Return the quote's string representation, for debugging and internal use.""" # Avoid recursion errors by checking for pk attributee_string = self.attributee_string or '<Unknown>' date_string = self.date.string if self.date else EMPTY_STRING if date_string: string = f'{attributee_string}, {date_string}: {self.bite.text}' else: string = f'{attributee_string}: {self.bite.text}' return string def save(self, *args, **kwargs): """Save the quote to the database.""" self.clean() super().save(*args, **kwargs) if not self.images.exists(): image = None try: attributee: 'Entity' = self.attributees.first() if self.date: image = attributee.images.get_closest_to_datetime( self.date) else: image = attributee.images.first() except (ObjectDoesNotExist, AttributeError): pass if image is None and self.related_occurrences.exists(): image = self.related_occurrences.first().primary_image if image: QuoteImage.objects.create(quote=self, image=image) @retrieve_or_compute(attribute_name='attributee_html', caster=format_html) def attributee_html(self) -> SafeString: """Return the HTML representing the quote's attributees.""" logging.debug('Computing attributee HTML...') attributees = self.ordered_attributees attributee_html = '' if attributees: n_attributions = len(attributees) primary_attributee = attributees[0] primary_attributee_html = primary_attributee.get_detail_link( primary_attributee.name) if n_attributions == 1: attributee_html = f'{primary_attributee_html}' else: secondary_attributee_html = ( f'{attributees[1].get_detail_link(attributees[1].name)}') if n_attributions == 2: attributee_html = ( f'{primary_attributee_html} and {secondary_attributee_html}' ) elif n_attributions == 3: attributee_html = ( f'{primary_attributee_html}, {secondary_attributee_html}, and ' f'{attributees[2].get_detail_link(attributees[2].name)}' ) else: attributee_html = f'{primary_attributee_html} et al.' return format_html(attributee_html) # TODO: Order by `attributee_string` instead of `attributee` attributee_html.admin_order_field = 'attributee' attributee_html: SafeString = property(attributee_html) # type: ignore def get_slug(self) -> str: """Generate a slug for the quote.""" return slugify(self.pk) @property def has_multiple_attributees(self) -> bool: """ Return True if the quote has multiple attributees, else False. This method minimizes db query complexity. """ attributee_html: str = self.attributee_html # type: ignore signals = (' and ', ', ', ' et al.') for signal in signals: if signal in attributee_html: return True return False @property def attributee_string(self) -> Optional[str]: """See the `attributee_html` property.""" if self.attributee_html: return soupify(self.attributee_html).get_text() # type: ignore return None @property def html(self) -> SafeString: """Return the quote's HTML representation.""" blockquote = ( f'<blockquote class="blockquote">' f'{self.text.html}' f'<footer class="blockquote-footer" style="position: relative;">' f'{self.citation_html or self.attributee_string}' f'</footer>' f'</blockquote>') components = [ f'<p class="quote-context">{self.pretext.html}</p>' if self.pretext else EMPTY_STRING, blockquote, f'<div class="quote-context">{self.context.html}</div>' if self.context else EMPTY_STRING, ] html = '\n'.join([component for component in components if component]) return format_html(html) @property def ordered_attributees(self) -> List['Entity']: """ Return an ordered list of the quote's attributees. WARNING: This queries the database. """ try: attributions = self.attributions.select_related( 'attributee').iterator() return [attribution.attributee for attribution in attributions] except (AttributeError, ObjectDoesNotExist) as error: logging.error(f'{type(error)}: {error}') return [] @property def related_occurrences(self) -> 'QuerySet': """Return a queryset of the quote's related occurrences.""" # TODO: refactor from apps.occurrences.models import Occurrence occurrence_ids = self.relations.filter( models.Q(content_type_id=get_ct_id( ContentTypes.occurrence))).values_list('id', flat=True) return Occurrence.objects.filter(id__in=occurrence_ids) def clean(self): """Prepare the quote to be saved to the database.""" super().clean() no_text = not self.text min_text_length = 15 if no_text or len( f'{self.text}') < min_text_length: # e.g., <p> </p> raise ValidationError('The quote must have text.') if not self.bite: text = self.text.text if len(text) > BITE_MAX_LENGTH: raise ValidationError('Add a quote bite.') self.bite = text # type: ignore # TODO: remove type ignore @classmethod def get_object_html(cls, match: Match, use_preretrieved_html: bool = False) -> str: """Return the obj's HTML based on a placeholder in the admin.""" if use_preretrieved_html: # Return the pre-retrieved HTML (already included in placeholder) preretrieved_html = match.group(PlaceholderGroups.HTML) if preretrieved_html: return preretrieved_html.strip() quote = cls.objects.get(pk=match.group(PlaceholderGroups.PK)) if isinstance(quote, dict): body = quote['text'] footer = quote.get('citation_html') or quote.get( 'attributee_string') else: body = quote.text.html footer = quote.citation_html or quote.attributee_string return ( f'<blockquote class="blockquote">' f'{body}' f'<footer class="blockquote-footer" style="position: relative;">' f'{footer}' f'</footer>' f'</blockquote>')
class Occurrence( SearchableDatedModel, ModelWithRelatedQuotes, ModelWithSources, ModelWithImages, ): """Something that happened.""" date = HistoricDateTimeField(verbose_name=_('date'), null=True, blank=True) end_date = HistoricDateTimeField(verbose_name=_('end date'), null=True, blank=True) summary = HTMLField(verbose_name=_('summary'), paragraphed=False) description = HTMLField(verbose_name=_('description'), paragraphed=True) postscript = HTMLField( verbose_name=_('postscript'), null=True, blank=True, paragraphed=True, help_text='Content to be displayed below all related data', ) version = IntegerVersionField() locations = models.ManyToManyField( to='places.Place', through='occurrences.OccurrenceLocation', related_name='occurrences', blank=True, verbose_name=_('locations'), ) images = models.ManyToManyField( to='images.Image', through='occurrences.OccurrenceImage', related_name='occurrences', blank=True, verbose_name=_('images'), ) image_relations: 'Manager' involved_entities = models.ManyToManyField( to='entities.Entity', through='occurrences.OccurrenceEntityInvolvement', related_name='involved_occurrences', blank=True, verbose_name=_('involved entities'), ) chains = models.ManyToManyField( to='occurrences.OccurrenceChain', through='occurrences.OccurrenceChainInclusion', related_name='occurrences', verbose_name=_('chains'), ) class Meta: """ Meta options for the Category model. See https://docs.djangoproject.com/en/3.1/ref/models/options/#model-meta-options. """ unique_together = ['summary', 'date'] ordering = ['date'] objects: OccurrenceManager = OccurrenceManager() # type: ignore searchable_fields = [ 'summary', 'description', 'date__year', 'involved_entities__name', 'involved_entities__aliases', 'tags__topic__key', 'tags__topic__aliases', ] serializer = OccurrenceSerializer slug_base_field = 'summary' def __str__(self) -> str: """Return the string representation of the occurrence.""" return self.summary.text def save(self, *args, **kwargs): """Save the occurrence to the database.""" self.clean() super().save(*args, **kwargs) if not self.images.exists(): image = None if self.involved_entities.exists(): for entity in self.involved_entities.all(): if entity.images.exists(): if self.date: image = entity.images.get_closest_to_datetime( self.date) else: image = entity.image if image: OccurrenceImage.objects.create(occurrence=self, image=image) def clean(self): """Prepare the occurrence to be saved.""" super().clean() if not self.date: raise ValidationError('Occurrence needs a date.') @property def truncated_description(self) -> Optional[SafeString]: """Return the occurrence's description, truncated.""" if not self.description: return None description = soupify(self.description.html) if description.find('img'): description.find('img').decompose() return format_html( truncatechars_html(description.prettify(), TRUNCATED_DESCRIPTION_LENGTH)) @property def ordered_images(self): """Careful! These are occurrence-images, not images.""" return self.image_relations.all().select_related('image') def get_context(self): """Return context for rendering the occurrence's detail template.""" quotes = [ quote_relation.quote for quote_relation in self.quote_relations.all().select_related('quote').iterator() ] return { 'occurrence': self, 'quotes': sorted(quotes, key=quote_sorter_key), }