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
Beispiel #2
0
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)
Beispiel #3
0
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
Beispiel #5
0
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
Beispiel #6
0
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)
Beispiel #7
0
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
Beispiel #8
0
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>&nbsp;</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>')
Beispiel #9
0
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),
        }