Hide navigation sidebar
Hide table of contents sidebar
Skip to content
Back to top
Toggle table of contents sidebar

# Source code for betty.privatizer

    """
    Provide an API to determine if information should be kept private.
    """

    from __future__ import annotations

    import logging
    from contextlib import suppress
    from datetime import datetime
    from typing import Iterator, TypeAlias, Any, TYPE_CHECKING

    from betty.locale.date import DateRange, Date
    from betty.model import Entity
    from betty.model.ancestry import (
        Person,
        Event,
        HasFileReferences,
        HasCitations,
        HasNotes,
        Source,
        Presence,
        Privacy,
        HasPrivacy,
        Place,
    )
    from betty.model.presence_role import Subject
    from betty.model.event_type import EndOfLifeEventType

    if TYPE_CHECKING:
        from betty.locale.localizer import Localizer

    Expirable: TypeAlias = Person | Event | Date | None

    class Privatizer:
        """
        Privatize resources.
        """

        def __init__(
            self,
            lifetime_threshold: int,
            *,
            localizer: Localizer,
        ):
            self._lifetime_threshold = lifetime_threshold
            self._localizer = localizer
            self._seen: list[HasPrivacy] = []

        def privatize(self, subject: HasPrivacy) -> None:
            """
            Privatize a resource.
            """
            if subject.privacy is Privacy.PUBLIC:
                return

            if isinstance(subject, Person):
                self._determine_person_privacy(subject)

            if isinstance(subject, Place):
                self._determine_place_privacy(subject)

            if subject.privacy is not Privacy.PRIVATE:
                return

            if subject in self._seen:
                return
            self._seen.append(subject)

            if isinstance(subject, Person):
                self._privatize_person(subject)

            if isinstance(subject, Presence):
                self._privatize_presence(subject)

            if isinstance(subject, Event):
                self._privatize_event(subject)

            if isinstance(subject, Place):
                self._privatize_place(subject)

            if isinstance(subject, Source):
                self._privatize_source(subject)

            if isinstance(subject, HasCitations):
                self._privatize_has_citations(subject)

            if isinstance(subject, HasFileReferences):
                self._privatize_has_file_references(subject)

            if isinstance(subject, HasNotes):
                self._privatize_has_notes(subject)

        def _privatize_person(self, person: Person) -> None:
            if not person.private:
                return

            for person_name in person.names:
                self._mark_private(person_name, person)
                self.privatize(person_name)
            for presence in person.presences:
                self._mark_private(presence, person)
                self.privatize(presence)

        def _privatize_presence(self, presence: Presence) -> None:
            if not presence.private:
                return

            if presence.event is not None and isinstance(presence.role, Subject):
                self._mark_private(presence.event, presence)
                self.privatize(presence.event)
            if presence.person is not None:
                self._mark_private(presence.person, presence)
                self.privatize(presence.person)

        def _privatize_event(self, event: Event) -> None:
            if not event.private:
                return

            for presence in event.presences:
                self._mark_private(presence, event)
                self.privatize(presence)
            if event.place:
                self.privatize(event.place)

        def _privatize_place(self, place: Place) -> None:
            if not place.private:
                return

            for enclosure in place.encloses:
                if not enclosure.encloses:
                    continue
                self._mark_private(enclosure.encloses, place)
                self.privatize(enclosure.encloses)
            for enclosure in place.enclosed_by:
                if not enclosure.enclosed_by:
                    continue
                self.privatize(enclosure.enclosed_by)

        def _privatize_has_citations(
            self, has_citations: HasCitations & HasPrivacy
        ) -> None:
            if not has_citations.private:
                return

            for citation in has_citations.citations:
                self._mark_private(citation, has_citations)
                self.privatize(citation)

        def _privatize_source(self, source: Source) -> None:
            if not source.private:
                return

            for contained_source in source.contains:
                self._mark_private(contained_source, source)
                self.privatize(contained_source)
            for citation in source.citations:
                self._mark_private(citation, source)
                self.privatize(citation)

        def _privatize_has_file_references(
            self, has_file_references: HasFileReferences & HasPrivacy
        ) -> None:
            if not has_file_references.private:
                return

            for file_reference in has_file_references.file_references:
                if file_reference.file is not None:
                    self._mark_private(file_reference.file, has_file_references)
                    self.privatize(file_reference.file)

        def _privatize_has_notes(self, has_notes: HasNotes & HasPrivacy) -> None:
            if not has_notes.private:
                return

            for note in has_notes.notes:
                self._mark_private(note, has_notes)
                self.privatize(note)

        def _ancestors_by_generation(
            self, person: Person, generations_ago: int = 1
        ) -> Iterator[tuple[Person, int]]:
            for parent in person.parents:
                yield parent, generations_ago
                yield from self._ancestors_by_generation(parent, generations_ago + 1)

        def _determine_person_privacy(self, person: Person) -> None:
            # Do not change existing explicit privacy declarations.
            if person.privacy is not Privacy.UNDETERMINED:
                return

            # A dead person is not private, regardless of when they died.
            for presence in person.presences:
                if presence.event and isinstance(
                    presence.event.event_type, EndOfLifeEventType
                ):
                    if presence.event.date is None:
                        person.public = True
                        return
                    if self.has_expired(presence.event, 0):
                        person.public = True
                        return

            if self.has_expired(person, 1):
                person.public = True
                return

            for ancestor, generations_ago in self._ancestors_by_generation(person):
                if self.has_expired(ancestor, generations_ago + 1):
                    person.public = True
                    return

            # If any descendant has any expired event, the person is considered not private.
            for descendant in person.descendants:
                if self.has_expired(descendant, 1):
                    person.public = True
                    return

            person.private = True
            logging.getLogger(__name__).debug(
                self._localizer._(
                    "Privatized person {privatized_person_id} ({privatized_person}) because they are likely still alive."
                ).format(
                    privatized_person_id=person.id,
                    privatized_person=person.label.localize(self._localizer),
                )
            )

        def _determine_place_privacy(self, place: Place) -> None:
            # Do not change existing explicit privacy declarations.
            if place.privacy is not Privacy.UNDETERMINED:
                return

            # If there are non-private events, we will not privatize the place.
            for event in place.events:
                if not event.private:
                    return

            # If there are non-private enclosed places, we will not privatize the place.
            for enclosure in place.encloses:
                if not enclosure.encloses:
                    continue
                if not enclosure.encloses.private:
                    return

            place.private = True
            logging.getLogger(__name__).debug(
                self._localizer._(
                    "Privatized place {privatized_place_id} ({privatized_place}) because it is not associated with any public information."
                ).format(
                    privatized_place_id=place.id,
                    privatized_place=place.label.localize(self._localizer),
                )
            )

        def has_expired(
            self,
            subject: Expirable,
            generations_ago: int = 0,
        ) -> bool:
            """
            Check if a subject of the given generation has expired.
            """
            if isinstance(subject, Person):
                return self._person_has_expired(subject, generations_ago)

            if isinstance(subject, Event):
                return self._event_has_expired(subject, generations_ago)

            if isinstance(subject, Date):
                return self._date_has_expired(subject, generations_ago)

            return False

        def _person_has_expired(self, person: Person, generations_ago: int) -> bool:
            for presence in person.presences:
                if presence.event is not None and self._event_has_expired(
                    presence.event, generations_ago
                ):
                    return True
            return False

        def _event_has_expired(self, event: Event, generations_ago: int) -> bool:
            date = event.date

            if isinstance(date, DateRange):
                # We can only determine event expiration with certainty if we have an end date to work with. Someone born in
                # 2000 can have a valid birth event with a start date of 1800, which does nothing to help us determine
                # expiration.
                date = date.end

            return self.has_expired(date, generations_ago)

        def _date_has_expired(
            self,
            date: Date,
            generations_ago: int,
        ) -> bool:
            if not date.comparable:
                return False

            return date <= Date(
                datetime.now().year - self._lifetime_threshold * generations_ago,
                datetime.now().month,
                datetime.now().day,
            )

        def _mark_private(self, target: HasPrivacy, reason: Any) -> None:
            # Do not change existing explicit privacy declarations.
            if target.own_privacy is not Privacy.UNDETERMINED:
                return

            target.private = True
            with suppress(ValueError):
                self._seen.remove(target)

            if isinstance(target, Entity) and isinstance(reason, Entity):
                logging.getLogger(__name__).debug(
                    self._localizer._(
                        "Privatized {privatized_entity_type} {privatized_entity_id} ({privatized_entity}) because of {reason_entity_type} {reason_entity_id} ({reason_entity})."
                    ).format(
                        privatized_entity_type=target.plugin_label().localize(
                            self._localizer
                        ),
                        privatized_entity_id=target.id,
                        privatized_entity=target.label.localize(self._localizer),
                        reason_entity_type=reason.plugin_label().localize(self._localizer),
                        reason_entity_id=reason.id,
                        reason_entity=reason.label.localize(self._localizer),
                    )
                )