diff --git a/catalog/migrations/0003_people_itempeoplerelation.py b/catalog/migrations/0003_people_itempeoplerelation.py new file mode 100644 index 00000000..bfc0cfef --- /dev/null +++ b/catalog/migrations/0003_people_itempeoplerelation.py @@ -0,0 +1,116 @@ +# Generated by Django 5.2.6 on 2025-09-07 23:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("catalog", "0002_externalresource_catalog_extres_lookup_ids_gin"), + ] + + operations = [ + migrations.CreateModel( + name="People", + fields=[ + ( + "item_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="catalog.item", + ), + ), + ( + "people_type", + models.CharField( + choices=[ + ("person", "Person"), + ("organization", "Organization"), + ], + default="person", + max_length=20, + verbose_name="type", + ), + ), + ], + options={ + "abstract": False, + "base_manager_name": "objects", + }, + bases=("catalog.item",), + ), + migrations.CreateModel( + name="ItemPeopleRelation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("author", "Author"), + ("translator", "Translator"), + ("performer", "Performer"), + ("actor", "Actor"), + ("director", "Director"), + ("composer", "Composer"), + ("artist", "Artist"), + ("voice_actor", "Voice Actor"), + ("host", "Host"), + ("publisher", "Publisher"), + ("distributor", "Distributor"), + ("production_company", "Production Company"), + ("record_label", "Record Label"), + ("developer", "Developer"), + ("studio", "Studio"), + ], + max_length=50, + verbose_name="role", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, null=True, verbose_name="metadata" + ), + ), + ("created_time", models.DateTimeField(auto_now_add=True)), + ("edited_time", models.DateTimeField(auto_now=True)), + ( + "item", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="people_relations", + to="catalog.item", + ), + ), + ( + "people", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="item_relations", + to="catalog.people", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["item", "role"], name="catalog_ite_item_id_df3230_idx" + ) + ], + "unique_together": {("item", "people", "role")}, + }, + ), + ] diff --git a/catalog/models/__init__.py b/catalog/models/__init__.py index cf47a40a..9ee0b419 100644 --- a/catalog/models/__init__.py +++ b/catalog/models/__init__.py @@ -22,6 +22,14 @@ ) from .movie import Movie, MovieInSchema, MovieSchema from .music import Album, AlbumInSchema, AlbumSchema +from .people import ( + ItemPeopleRelation, + People, + PeopleInSchema, + PeopleRole, + PeopleSchema, + PeopleType, +) from .performance import ( Performance, PerformanceProduction, @@ -137,4 +145,10 @@ def init_catalog_audit_log(): "TVShowInSchema", "TVShowSchema", "init_catalog_audit_log", + "People", + "PeopleInSchema", + "PeopleSchema", + "PeopleType", + "PeopleRole", + "ItemPeopleRelation", ] diff --git a/catalog/models/book.py b/catalog/models/book.py index ea2a6d5a..ce591469 100644 --- a/catalog/models/book.py +++ b/catalog/models/book.py @@ -45,6 +45,7 @@ ItemType, PrimaryLookupIdDescriptor, ) +from .people import PeopleRole from .utils import * @@ -126,6 +127,14 @@ class BookFormat(models.TextChoices): type = ItemType.Edition url_path = "book" + available_roles = [ + PeopleRole.AUTHOR, + PeopleRole.TRANSLATOR, + PeopleRole.PUBLISHER, + PeopleRole.PUBLISHING_HOUSE, + PeopleRole.IMPRINT, + ] + isbn = PrimaryLookupIdDescriptor(IdType.ISBN) asin = PrimaryLookupIdDescriptor(IdType.ASIN) cubn = PrimaryLookupIdDescriptor(IdType.CUBN) @@ -440,6 +449,11 @@ class Work(Item): category = ItemCategory.Book type = ItemType.Work url_path = "book/work" + + available_roles = [ + PeopleRole.AUTHOR, + PeopleRole.TRANSLATOR, + ] douban_work = PrimaryLookupIdDescriptor(IdType.DoubanBook_Work) goodreads_work = PrimaryLookupIdDescriptor(IdType.Goodreads_Work) # TODO change related_name to '+' in future release diff --git a/catalog/models/common.py b/catalog/models/common.py index 7045adec..6af33394 100644 --- a/catalog/models/common.py +++ b/catalog/models/common.py @@ -120,7 +120,7 @@ class ItemType(models.TextChoices): Collection = "collection", _("Collection") # Person = "person", _("Person") # Organization = "organization", _("Organization") - # People = "people", _("Person / Organization") + People = "people", _("Person / Organization") class ItemCategory(models.TextChoices): @@ -133,7 +133,7 @@ class ItemCategory(models.TextChoices): Performance = "performance", _("Performance") # FanFic = "fanfic", _("FanFic") # Exhibition = "exhibition", _("Exhibition") - # People = "people", _("Person / Organization") + People = "people", _("Person / Organization") Collection = "collection", _("Collection") diff --git a/catalog/models/game.py b/catalog/models/game.py index 48fba3cd..1a3ae494 100644 --- a/catalog/models/game.py +++ b/catalog/models/game.py @@ -16,6 +16,7 @@ ItemType, PrimaryLookupIdDescriptor, ) +from .people import PeopleRole class GameReleaseType(models.TextChoices): @@ -50,6 +51,14 @@ class Game(Item): category = ItemCategory.Game type = ItemType.Game url_path = "game" + + available_roles = [ + PeopleRole.DESIGNER, + PeopleRole.ARTIST, + PeopleRole.DEVELOPER, + PeopleRole.PUBLISHER, + PeopleRole.PRODUCER, + ] igdb = PrimaryLookupIdDescriptor(IdType.IGDB) steam = PrimaryLookupIdDescriptor(IdType.Steam) douban_game = PrimaryLookupIdDescriptor(IdType.DoubanGame) diff --git a/catalog/models/item.py b/catalog/models/item.py index 585bb10e..7d6d6599 100644 --- a/catalog/models/item.py +++ b/catalog/models/item.py @@ -43,6 +43,7 @@ from users.models import User from ..common import ResourceContent + from .people import ItemPeopleRelation, PeopleRole class PrimaryLookupIdDescriptor(object): # TODO make it mixin of Field @@ -143,6 +144,7 @@ class Item(PolymorphicModel): merged_from_items: QuerySet["Item"] merged_to_item_id: int mark: "Mark" + people_relations: QuerySet["ItemPeopleRelation"] schema = ItemSchema category: ItemCategory # subclass must specify this type: ItemType # subclass must specify this @@ -323,6 +325,24 @@ def log_action(self, changes: dict[str, Any]): self, action=LogEntry.Action.UPDATE, changes=changes ) + def merge_people_relations(self, to_item: Self) -> bool: + """Merge people relations from this item to the target item""" + updated = False + for relation in self.people_relations.all(): + existing_relation = to_item.people_relations.filter( + people=relation.people, role=relation.role + ).first() + if existing_relation: + if relation.metadata and not existing_relation.metadata: + existing_relation.metadata = relation.metadata + existing_relation.save() + relation.delete() + else: + relation.item = to_item + relation.save() + updated = True + return updated + def merge_to(self, to_item: Self | None): if to_item is None: if self.merged_to_item is not None: @@ -358,6 +378,7 @@ def merge_to(self, to_item: Self | None): to_item.cover = self.cover updated = True updated |= to_item.normalize_metadata() + updated |= self.merge_people_relations(to_item) to_item.log_action({"!merged_from": [str(self.merged_to_item), str(to_item)]}) if updated: to_item.save() @@ -674,6 +695,13 @@ def process_fetched_item( def is_editable(self): return not self.is_deleted and self.merged_to_item is None + def get_people_by_role(self, role: "PeopleRole"): + from .people import People + + return People.objects.filter( + item_relations__item=self, item_relations__role=role + ) + @property def mark_count(self): from journal.models import Mark diff --git a/catalog/models/movie.py b/catalog/models/movie.py index 60efd7d9..723bc346 100644 --- a/catalog/models/movie.py +++ b/catalog/models/movie.py @@ -17,6 +17,7 @@ ItemType, PrimaryLookupIdDescriptor, ) +from .people import PeopleRole class MovieInSchema(ItemInSchema): @@ -42,6 +43,15 @@ class Movie(Item): category = ItemCategory.Movie type = ItemType.Movie url_path = "movie" + + available_roles = [ + PeopleRole.DIRECTOR, + PeopleRole.PLAYWRIGHT, + PeopleRole.ACTOR, + PeopleRole.PRODUCER, + PeopleRole.PRODUCTION_COMPANY, + PeopleRole.DISTRIBUTOR, + ] imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_movie = PrimaryLookupIdDescriptor(IdType.TMDB_Movie) douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie) diff --git a/catalog/models/music.py b/catalog/models/music.py index 6aac873f..f4aaeacb 100644 --- a/catalog/models/music.py +++ b/catalog/models/music.py @@ -18,6 +18,7 @@ ItemType, PrimaryLookupIdDescriptor, ) +from .people import PeopleRole class AlbumInSchema(ItemInSchema): @@ -39,6 +40,14 @@ class Album(Item): url_path = "album" category = ItemCategory.Music type = ItemType.Album + + available_roles = [ + PeopleRole.ARTIST, + PeopleRole.PERFORMER, + PeopleRole.COMPOSER, + PeopleRole.PRODUCER, + PeopleRole.RECORD_LABEL, + ] barcode = PrimaryLookupIdDescriptor(IdType.GTIN) douban_music = PrimaryLookupIdDescriptor(IdType.DoubanMusic) spotify_album = PrimaryLookupIdDescriptor(IdType.Spotify_Album) diff --git a/catalog/models/people.py b/catalog/models/people.py new file mode 100644 index 00000000..83c65318 --- /dev/null +++ b/catalog/models/people.py @@ -0,0 +1,236 @@ +import json +from functools import cached_property +from typing import Any + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from ninja import Field, Schema + +from common.models import get_current_locales, jsondata, uniq + +from .common import LOCALIZED_LABEL_SCHEMA, LocalizedLabelSchema +from .item import Item, ItemCategory, ItemType + + +class PeopleType(models.TextChoices): + PERSON = "person", _("Person") + ORGANIZATION = "organization", _("Organization") + + +class PeopleRole(models.TextChoices): + # Person roles + AUTHOR = "author", _("Author") + TRANSLATOR = "translator", _("Translator") + PERFORMER = "performer", _("Performer") + ACTOR = "actor", _("Actor") + DIRECTOR = "director", _("Director") + COMPOSER = "composer", _("Composer") + ARTIST = "artist", _("Artist") + VOICE_ACTOR = "voice_actor", _("Voice Actor") + HOST = "host", _("Host") + PLAYWRIGHT = "playwright", _("Playwright") + DESIGNER = "designer", _("Designer") + CHOREOGRAPHER = "choreographer", _("Choreographer") + ORIGINAL_CREATOR = "original_creator", _("Original Creator") + PRODUCER = "producer", _("Producer") + + # Organization roles + PUBLISHER = "publisher", _("Publisher") + DISTRIBUTOR = "distributor", _("Distributor") + PRODUCTION_COMPANY = "production_company", _("Production Company") + RECORD_LABEL = "record_label", _("Record Label") + DEVELOPER = "developer", _("Developer") + STUDIO = "studio", _("Studio") + PUBLISHING_HOUSE = "publishing_house", _("Publishing House") + IMPRINT = "imprint", _("Imprint") + TROUPE = "troupe", _("Troupe") + CREW = "crew", _("Crew") + + +class PeopleInSchema(Schema): + name: str = Field(alias="display_name") + description: str = Field(default="", alias="display_description") + people_type: str + localized_name: list[LocalizedLabelSchema] = [] + localized_description: list[LocalizedLabelSchema] = [] + cover_image_url: str | None + + +class PeopleSchema(Schema): + id: str = Field(alias="absolute_url") + uuid: str + url: str + api_url: str + people_type: str + display_name: str + name: str = Field(alias="display_name") + description: str = Field(default="", alias="display_description") + localized_name: list[LocalizedLabelSchema] = [] + localized_description: list[LocalizedLabelSchema] = [] + cover_image_url: str | None + + +class People(Item): + """ + Model for people and organizations that can be linked to items with roles. + Now inherits from Item to share common functionality. + """ + + schema = PeopleSchema + category = ItemCategory.People + url_path = "people" + type = ItemType.People + + # People can have any role + available_roles = list(PeopleRole) + item_relations: models.QuerySet["ItemPeopleRelation"] + people_type = models.CharField( + _("type"), max_length=20, choices=PeopleType.choices, default=PeopleType.PERSON + ) + localized_name = jsondata.JSONField( + verbose_name=_("name"), + null=False, + blank=True, + default=list, + schema=LOCALIZED_LABEL_SCHEMA, + ) + + # localized_description is inherited from Item + + # # ManyToMany relationship with Items through ItemPeopleRelation + # related_items = models.ManyToManyField( + # "catalog.Item", + # through="ItemPeopleRelation", + # related_name="related_people", + # blank=True, + # help_text=_("Items this person/organization is associated with"), + # ) + + METADATA_COPY_LIST = [ + "localized_name", + "localized_description", + ] + METADATA_MERGE_LIST = [ + "localized_name", + "localized_description", + ] + + @property + def is_person(self) -> bool: + return self.people_type == PeopleType.PERSON + + @property + def is_organization(self) -> bool: + return self.people_type == PeopleType.ORGANIZATION + + def get_localized_name(self) -> str | None: + if self.localized_name: + locales = get_current_locales() + for loc in locales: + v = next( + filter(lambda t: t["lang"] == loc, self.localized_name), {} + ).get("text") + if v: + return v + + @cached_property + def display_name(self) -> str: + # return name in current locale if possible, otherwise any name + return self.get_localized_name() or ( + self.localized_name[0]["text"] if self.localized_name else "" + ) + + @cached_property + def additional_names(self) -> list[str]: + name = self.display_name + return uniq([t["text"] for t in self.localized_name if t["text"] != name]) + + def is_deletable(self): + return ( + not self.is_deleted + and not self.merged_to_item_id + and not self.merged_from_items.exists() + and not self.item_relations.exists() # has linked items + ) + + def merge_relations(self, to_item): + for link in self.item_relations.all(): + existing_link = to_item.item_relations.filter( + item=link.item, people=to_item, role=link.role + ).first() + if existing_link: + if link.character and not existing_link.character: + existing_link.character = link.character + existing_link.save() + link.delete() + else: + link.people = to_item + link.save() + + def merge_to(self, to_item): + super().merge_to(to_item) + if not to_item: + return + self.merge_relations(to_item) + + def to_schema_org(self): + data: dict[str, Any] = { + "@context": "https://schema.org", + "@type": "Person" if self.is_person else "Organization", + "name": self.display_name, + "url": self.absolute_url, + } + + if self.display_description: + data["description"] = self.display_description + + if self.has_cover(): + data["image"] = self.cover_image_url + + return data + + def to_schema_org_json(self): + data = self.to_schema_org() + return json.dumps(data, ensure_ascii=False, indent=2) + + @property + def ap_object_ref(self) -> dict[str, Any]: + o = { + "type": "Person" if self.is_person else "Organization", + "href": self.absolute_url, + "name": self.display_name, + } + if self.has_cover(): + o["image"] = self.cover_image_url or "" + return o + + +class ItemPeopleRelation(models.Model): + """Through model linking Items to People with roles""" + + item = models.ForeignKey( + Item, on_delete=models.CASCADE, related_name="people_relations" + ) + people = models.ForeignKey( + People, on_delete=models.CASCADE, related_name="item_relations" + ) + role = models.CharField(_("role"), max_length=50, choices=PeopleRole.choices) + character = jsondata.CharField( + _("character"), + max_length=1000, + null=True, + blank=True, + help_text=_("Character name for actor roles"), + ) + metadata = models.JSONField(_("metadata"), blank=True, null=True, default=dict) + created_time = models.DateTimeField(auto_now_add=True) + edited_time = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [["item", "people", "role"]] + indexes = [ + models.Index(fields=["item", "role"]), + ] + + def __str__(self): + return f"{self.pk}|{self.people_id}|{self.item_id}|{self.role}" # type: ignore diff --git a/catalog/models/performance.py b/catalog/models/performance.py index 831e02db..e968a969 100644 --- a/catalog/models/performance.py +++ b/catalog/models/performance.py @@ -20,6 +20,7 @@ ItemSchema, ItemType, ) +from .people import PeopleRole class CrewMemberSchema(Schema): @@ -111,6 +112,19 @@ class Performance(Item): child_class = "PerformanceProduction" category = ItemCategory.Performance url_path = "performance" + + available_roles = [ + PeopleRole.DIRECTOR, + PeopleRole.PLAYWRIGHT, + PeopleRole.ORIGINAL_CREATOR, + PeopleRole.COMPOSER, + PeopleRole.CHOREOGRAPHER, + PeopleRole.ACTOR, + PeopleRole.PERFORMER, + PeopleRole.TROUPE, + PeopleRole.CREW, + PeopleRole.PRODUCER, + ] orig_title = jsondata.CharField( verbose_name=_("original name"), blank=True, max_length=500 ) @@ -310,6 +324,19 @@ class PerformanceProduction(Item): category = ItemCategory.Performance type = ItemType.PerformanceProduction url_path = "performance/production" + + available_roles = [ + PeopleRole.DIRECTOR, + PeopleRole.PLAYWRIGHT, + PeopleRole.ORIGINAL_CREATOR, + PeopleRole.COMPOSER, + PeopleRole.CHOREOGRAPHER, + PeopleRole.ACTOR, + PeopleRole.PERFORMER, + PeopleRole.TROUPE, + PeopleRole.CREW, + PeopleRole.PRODUCER, + ] show = models.ForeignKey( Performance, null=True, on_delete=models.SET_NULL, related_name="productions" ) diff --git a/catalog/models/podcast.py b/catalog/models/podcast.py index ab2cdf62..deec0d92 100644 --- a/catalog/models/podcast.py +++ b/catalog/models/podcast.py @@ -18,6 +18,7 @@ ItemInSchema, ItemType, ) +from .people import PeopleRole class PodcastInSchema(ItemInSchema): @@ -53,6 +54,11 @@ class Podcast(Item): type = ItemType.Podcast child_class = "PodcastEpisode" url_path = "podcast" + + available_roles = [ + PeopleRole.HOST, + PeopleRole.PRODUCER, + ] # apple_podcast = PrimaryLookupIdDescriptor(IdType.ApplePodcast) # ximalaya = LookupIdDescriptor(IdType.Ximalaya) # xiaoyuzhou = LookupIdDescriptor(IdType.Xiaoyuzhou) @@ -155,6 +161,11 @@ class PodcastEpisode(Item): category = ItemCategory.Podcast type = ItemType.PodcastEpisode url_path = "podcast/episode" + + available_roles = [ + PeopleRole.HOST, + PeopleRole.PRODUCER, + ] # uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) program = models.ForeignKey(Podcast, models.CASCADE, related_name="episodes") guid = models.CharField(null=True, max_length=1000) diff --git a/catalog/models/tv.py b/catalog/models/tv.py index c23e2d63..dd0052d9 100644 --- a/catalog/models/tv.py +++ b/catalog/models/tv.py @@ -51,6 +51,7 @@ ItemType, PrimaryLookupIdDescriptor, ) +from .people import PeopleRole class TVShowInSchema(ItemInSchema): @@ -105,6 +106,15 @@ class TVShow(Item): category = ItemCategory.TV type = ItemType.TVShow url_path = "tv" + + available_roles = [ + PeopleRole.DIRECTOR, + PeopleRole.PLAYWRIGHT, + PeopleRole.ACTOR, + PeopleRole.PRODUCER, + PeopleRole.PRODUCTION_COMPANY, + PeopleRole.DISTRIBUTOR, + ] imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_tv = PrimaryLookupIdDescriptor(IdType.TMDB_TV) imdb = PrimaryLookupIdDescriptor(IdType.IMDB) @@ -329,6 +339,15 @@ class TVSeason(Item): category = ItemCategory.TV url_path = "tv/season" child_class = "TVEpisode" + + available_roles = [ + PeopleRole.DIRECTOR, + PeopleRole.PLAYWRIGHT, + PeopleRole.ACTOR, + PeopleRole.PRODUCER, + PeopleRole.PRODUCTION_COMPANY, + PeopleRole.DISTRIBUTOR, + ] douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie) imdb = PrimaryLookupIdDescriptor(IdType.IMDB) tmdb_tvseason = PrimaryLookupIdDescriptor(IdType.TMDB_TVSeason) @@ -606,6 +625,13 @@ class TVEpisode(Item): category = ItemCategory.TV type = ItemType.TVEpisode url_path = "tv/episode" + + available_roles = [ + PeopleRole.DIRECTOR, + PeopleRole.PLAYWRIGHT, + PeopleRole.ACTOR, + PeopleRole.PRODUCER, + ] season = models.ForeignKey( TVSeason, null=True, on_delete=models.SET_NULL, related_name="episodes" ) diff --git a/tests/catalog/test_people.py b/tests/catalog/test_people.py new file mode 100644 index 00000000..f81146a9 --- /dev/null +++ b/tests/catalog/test_people.py @@ -0,0 +1,311 @@ +import pytest + +from catalog.models import ( + Edition, + ItemPeopleRelation, + People, + PeopleRole, + PeopleType, +) + +_DAN_SIMMONS_METADATA = {"localized_name": [{"lang": "en", "text": "Dan Simmons"}]} +_BANTAM_BOOKS_METADATA = {"localized_name": [{"lang": "en", "text": "Bantam Books"}]} +_HAYAO_MIYAZAKI_METADATA = { + "localized_name": [ + {"lang": "ja", "text": "宮崎駿"}, + {"lang": "en", "text": "Hayao Miyazaki"}, + ] +} + + +@pytest.mark.django_db(databases="__all__") +class TestPeople: + def test_create_person(self): + person = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, + people_type=PeopleType.PERSON, + brief="American science fiction author", + ) + assert person.is_person + assert not person.is_organization + assert person.display_name == "Dan Simmons" + assert person.uuid + assert person.url == f"/people/{person.uuid}" + + def test_create_organization(self): + org = People.objects.create( + metadata=_BANTAM_BOOKS_METADATA, + people_type=PeopleType.ORGANIZATION, + brief="Publishing company", + ) + assert not org.is_person + assert org.is_organization + assert org.display_name == "Bantam Books" + + def test_localized_names(self): + person = People.objects.create( + people_type=PeopleType.PERSON, + metadata=_HAYAO_MIYAZAKI_METADATA, + ) + assert person.display_name == "Hayao Miyazaki" + assert "宮崎駿" in person.additional_names + + def test_people_merge(self): + person1 = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, + people_type=PeopleType.PERSON, + brief="Author of Hyperion", + ) + person2 = People.objects.create( + title="Daniel Simmons", + people_type=PeopleType.PERSON, + brief="Science fiction writer", + ) + + person1.merge_to(person2) + assert person1.merged_to_item == person2 + assert person1.final_item == person2 + + def test_people_merge_resolve(self): + person1 = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, people_type=PeopleType.PERSON + ) + person2 = People.objects.create( + title="Daniel Simmons", people_type=PeopleType.PERSON + ) + person3 = People.objects.create( + title="D. Simmons", people_type=PeopleType.PERSON + ) + + person1.merge_to(person2) + person2.merge_to(person3) + resolved = People.get_by_url(person1.url, True) + assert resolved == person3 + + def test_people_merge_with_links(self): + book = Edition.objects.create(title="Hyperion") + person1 = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, people_type=PeopleType.PERSON + ) + person2 = People.objects.create( + title="Daniel Simmons", people_type=PeopleType.PERSON + ) + + # Create link for person1 + link1 = ItemPeopleRelation.objects.create( + item=book, people=person1, role=PeopleRole.AUTHOR + ) + + # Merge person1 to person2 + person1.merge_to(person2) + + # Link should now point to person2 + link1.refresh_from_db() + assert link1.people == person2 + + # Should have only one link for this item-role combination + links = ItemPeopleRelation.objects.filter(item=book, role=PeopleRole.AUTHOR) + assert links.count() == 1 + + def test_people_merge_duplicate_links(self): + book = Edition.objects.create(title="Hyperion") + person1 = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, people_type=PeopleType.PERSON + ) + person2 = People.objects.create( + title="Daniel Simmons", people_type=PeopleType.PERSON + ) + + # Create same role links for both people + ItemPeopleRelation.objects.create( + item=book, people=person1, role=PeopleRole.AUTHOR + ) + ItemPeopleRelation.objects.create( + item=book, people=person2, role=PeopleRole.AUTHOR + ) + + # Merge person1 to person2 + person1.merge_to(person2) + + # Should have only one link remaining (duplicate removed) + relations = ItemPeopleRelation.objects.filter(item=book, role=PeopleRole.AUTHOR) + assert relations.count() == 1 + r = relations.first() + assert r is not None + assert r.people == person2 + + def test_people_soft_delete(self): + person = People.objects.create(people_type=PeopleType.PERSON) + + assert person.is_deletable() + person.delete(soft=True) + assert person.is_deleted + + def test_people_cannot_delete_with_links(self): + book = Edition.objects.create(title="Hyperion") + person = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, people_type=PeopleType.PERSON + ) + + ItemPeopleRelation.objects.create( + item=book, people=person, role=PeopleRole.AUTHOR + ) + + assert not person.is_deletable() + + def test_schema_org_person(self): + person = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, + people_type=PeopleType.PERSON, + brief="Science fiction author", + ) + + schema = person.to_schema_org() + assert schema["@type"] == "Person" + assert schema["name"] == "Dan Simmons" + assert schema["description"] == "Science fiction author" + + def test_schema_org_organization(self): + org = People.objects.create( + metadata=_BANTAM_BOOKS_METADATA, + people_type=PeopleType.ORGANIZATION, + brief="Publishing company", + ) + + schema = org.to_schema_org() + assert schema["@type"] == "Organization" + assert schema["name"] == "Bantam Books" + + def test_item_get_people_by_role(self): + """Test that Item.get_people_by_role returns People queryset instead of relations""" + book = Edition.objects.create(title="Hyperion") + + # Create author + author = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, + people_type=PeopleType.PERSON, + brief="Science fiction author", + ) + + # Create publisher + publisher = People.objects.create( + metadata=_BANTAM_BOOKS_METADATA, + people_type=PeopleType.ORGANIZATION, + brief="Publishing company", + ) + + # Create relations + ItemPeopleRelation.objects.create( + item=book, people=author, role=PeopleRole.AUTHOR + ) + ItemPeopleRelation.objects.create( + item=book, people=publisher, role=PeopleRole.PUBLISHER + ) + + # Test that get_people_by_role returns People queryset + authors = book.get_people_by_role(PeopleRole.AUTHOR) + publishers = book.get_people_by_role(PeopleRole.PUBLISHER) + + # Should return People objects, not ItemPeopleRelation objects + assert authors.count() == 1 + assert isinstance(authors.first(), People) + assert authors.first() == author + + assert publishers.count() == 1 + assert isinstance(publishers.first(), People) + assert publishers.first() == publisher + + # Test with non-existent role + directors = book.get_people_by_role(PeopleRole.DIRECTOR) + assert directors.count() == 0 + + def test_item_merge_with_people_relations(self): + """Test that people relations are merged when items are merged""" + # Create two books + book1 = Edition.objects.create(title="Hyperion First Edition") + book2 = Edition.objects.create(title="Hyperion Second Edition") + + # Create people + author = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, + people_type=PeopleType.PERSON, + brief="Science fiction author", + ) + publisher = People.objects.create( + metadata=_BANTAM_BOOKS_METADATA, + people_type=PeopleType.ORGANIZATION, + brief="Publishing company", + ) + + # Create relations for book1 + ItemPeopleRelation.objects.create( + item=book1, people=author, role=PeopleRole.AUTHOR + ) + ItemPeopleRelation.objects.create( + item=book1, people=publisher, role=PeopleRole.PUBLISHER + ) + + # Verify initial state + assert book1.people_relations.count() == 2 + assert book2.people_relations.count() == 0 + + # Merge book1 to book2 + book1.merge_to(book2) + + # Verify relations were transferred + assert book1.people_relations.count() == 0 # Relations moved from book1 + assert book2.people_relations.count() == 2 # Relations moved to book2 + + # Verify the actual relations + book2_authors = book2.get_people_by_role(PeopleRole.AUTHOR) + book2_publishers = book2.get_people_by_role(PeopleRole.PUBLISHER) + + assert book2_authors.count() == 1 + assert book2_authors.first() == author + assert book2_publishers.count() == 1 + assert book2_publishers.first() == publisher + + def test_item_merge_with_duplicate_people_relations(self): + """Test merging items when both have relations to the same people with same roles""" + # Create two books + book1 = Edition.objects.create(title="Hyperion First Edition") + book2 = Edition.objects.create(title="Hyperion Second Edition") + + # Create author + author = People.objects.create( + metadata=_DAN_SIMMONS_METADATA, + people_type=PeopleType.PERSON, + brief="Science fiction author", + ) + + # Create same author relation for both books + ItemPeopleRelation.objects.create( + item=book1, + people=author, + role=PeopleRole.ACTOR, + character="Kassad", # book1 has character info + ) + ItemPeopleRelation.objects.create( + item=book2, + people=author, + role=PeopleRole.ACTOR, + # book2 has no character info + ) + + # Verify initial state + assert book1.people_relations.count() == 1 + assert book2.people_relations.count() == 1 + + # Merge book1 to book2 + book1.merge_to(book2) + + # Verify only one relation remains (duplicate removed) + assert book1.people_relations.count() == 0 + assert book2.people_relations.count() == 1 + + # Verify character info was preserved from book1 + remaining_relation = book2.people_relations.first() + assert remaining_relation is not None + assert remaining_relation.people == author + assert remaining_relation.role == PeopleRole.ACTOR + assert remaining_relation.character == "Kassad"