From 1851c009f12fc43cd21e28f1afd7368c916a7fca Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 11 Jan 2026 22:09:07 +0100 Subject: [PATCH 01/77] add character order editing --- apps/gcd/admin.py | 3 +- .../migrations/0067_add_character_order.py | 54 +++++++ apps/gcd/models/__init__.py | 1 + apps/gcd/models/story.py | 39 +++++ apps/oi/forms/story.py | 67 ++++++++- .../oi/migrations/0062_add_character_order.py | 50 +++++++ apps/oi/models.py | 101 ++++++++++--- apps/oi/urls.py | 9 ++ apps/oi/views.py | 141 +++++++++++++++++- templates/oi/edit/reorder_characters.html | 79 ++++++++++ 10 files changed, 516 insertions(+), 28 deletions(-) create mode 100644 apps/gcd/migrations/0067_add_character_order.py create mode 100644 apps/oi/migrations/0062_add_character_order.py create mode 100644 templates/oi/edit/reorder_characters.html diff --git a/apps/gcd/admin.py b/apps/gcd/admin.py index a7177b520..cffe4035a 100644 --- a/apps/gcd/admin.py +++ b/apps/gcd/admin.py @@ -4,7 +4,7 @@ NonComicWorkType, RelationType, School, SeriesBondType, SourceType, FeatureType, FeatureRelationType, StoryType, Degree, CodeNumberType, CharacterRelationType, - StoryArcRelationType, + CharacterOrderType, StoryArcRelationType, GroupRelationType, Multiverse, CharacterRole, ExternalSite) @@ -29,6 +29,7 @@ class ImpGrantAdmin(admin.ModelAdmin): admin.site.register(SeriesBondType, SeriesBondTypeAdmin) admin.site.register(NameType) admin.site.register(SourceType) +admin.site.register(CharacterOrderType) admin.site.register(CreditType) admin.site.register(CodeNumberType) admin.site.register(FeatureType) diff --git a/apps/gcd/migrations/0067_add_character_order.py b/apps/gcd/migrations/0067_add_character_order.py new file mode 100644 index 000000000..03933f82f --- /dev/null +++ b/apps/gcd/migrations/0067_add_character_order.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.9 on 2026-01-10 12:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gcd', '0066_indicia_printer_not_printed'), + ] + + operations = [ + migrations.CreateModel( + name='CharacterOrderType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=255, unique=True)), + ], + options={ + 'db_table': 'gcd_character_order_type', + }, + ), + migrations.CreateModel( + name='CharacterOrder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True, db_index=True)), + ('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='character_orders', to='gcd.story')), + ('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='gcd.characterordertype')), + ], + options={ + 'db_table': 'gcd_character_order', + }, + ), + migrations.CreateModel( + name='CharacterThroughOrder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_code', models.IntegerField(db_index=True, default=0)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='gcd.characterorder')), + ('story_character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='gcd.storycharacter')), + ], + options={ + 'db_table': 'gcd_character_through_order', + }, + ), + migrations.AddField( + model_name='characterorder', + name='characters', + field=models.ManyToManyField(through='gcd.CharacterThroughOrder', to='gcd.storycharacter'), + ), + ] diff --git a/apps/gcd/models/__init__.py b/apps/gcd/models/__init__.py index 6340f3a9b..f1434a58e 100644 --- a/apps/gcd/models/__init__.py +++ b/apps/gcd/models/__init__.py @@ -14,6 +14,7 @@ from .story import StoryType, Story, CreditType, StoryCredit, BiblioEntry,\ StoryCharacter, CharacterRole, StoryGroup, StoryArc, \ StoryArcRelationType, StoryArcRelation, \ + CharacterOrderType, CharacterOrder, \ STORY_TYPES, OLD_TYPES, NON_OPTIONAL_TYPES, CREDIT_TYPES, \ DEPRECATED_TYPES, CORE_TYPES, AD_TYPES from .feature import (Feature, FeatureLogo, FeatureType, FeatureRelation, diff --git a/apps/gcd/models/story.py b/apps/gcd/models/story.py index c4203d038..abff20aee 100644 --- a/apps/gcd/models/story.py +++ b/apps/gcd/models/story.py @@ -555,6 +555,45 @@ def __str__(self): return "%s: %s" % (self.story, self.character) +class CharacterOrderType(models.Model): + class Meta: + app_label = 'gcd' + db_table = 'gcd_character_order_type' + + name = models.CharField(max_length=255, db_index=True, unique=True) + + def __str__(self): + return self.name + + +class CharacterOrder(GcdLink): + class Meta: + app_label = 'gcd' + db_table = 'gcd_character_order' + + characters = models.ManyToManyField(StoryCharacter, + through='CharacterThroughOrder') + story = models.ForeignKey('Story', on_delete=models.CASCADE, + related_name='character_orders') + type = models.ForeignKey(CharacterOrderType, + on_delete=models.CASCADE) + + def __str__(self): + return "%s: (order: %d)" % (self.story, self.type) + + +class CharacterThroughOrder(models.Model): + class Meta: + app_label = 'gcd' + db_table = 'gcd_character_through_order' + + order = models.ForeignKey(CharacterOrder, + on_delete=models.CASCADE) + story_character = models.ForeignKey(StoryCharacter, + on_delete=models.CASCADE) + order_code = models.IntegerField(default=0, db_index=True) + + class StoryGroup(GcdData): class Meta: app_label = 'gcd' diff --git a/apps/oi/forms/story.py b/apps/oi/forms/story.py index e0cd0677e..496c5bda2 100644 --- a/apps/oi/forms/story.py +++ b/apps/oi/forms/story.py @@ -589,7 +589,7 @@ def __init__(self, *args, **kwargs): can_delete=True, extra=1) -# check with crispy 2.0, why here and in custom_layout ? +# TODO check with crispy 2.0, why here and in custom_layout ? class BaseField(Field): def render(self, form, context, renderer=None, template_pack=None): @@ -647,6 +647,37 @@ class Meta: } def __init__(self, *args, **kwargs): + """ + Initialize the StoryRevisionForm with custom layout and field + organization. + + This form constructor creates a complex layout with multiple sections + for story revision, including sequence details, creator credits, and + character information. The layout can be organized either as a single + form or with tabs based on user preferences. + + Args: + *args: Variable length argument list passed to parent form. + **kwargs: Arbitrary keyword arguments. Must include: + user: The user object with indexer preferences (use_tabs flag). + + The form includes: + - sequence details section with genre information + - creator credits section with formset support + - characters section with universe assignment and formsets + - dynamic HTML elements for genre display + - dynamic HTML elements for character universe management + - conditional buttons for character appearance order + - tab-based or linear layout based on user preferences + - custom field templates and Bootstrap horizontal form styling + + Layout Organization: + - If use_tabs is False: All fields are displayed linearly + - If use_tabs is True: Fields are organized into three tabs: + 1. Sequence Details: Main story information and remaining fields + 2. Creator Credits: Creator formset and related fields + 3. Characters: Character formsets, groups, and universe management + """ user = kwargs.pop('user') super(StoryRevisionForm, self).__init__(*args, **kwargs) self.helper = FormHelper() @@ -669,9 +700,6 @@ def __init__(self, *args, **kwargs): '')) credits_start = fields.index('creator_help') - # field_list = [BaseField(Field(field, - # template='oi/bits/uni_field.html')) - # for field in fields[:credits_start-7]] field_list.extend([BaseField(Field(field, template='oi/bits/uni_field.html')) for field in fields[genres:credits_start-7]]) @@ -713,11 +741,40 @@ def __init__(self, *args, **kwargs): field_list.append(Field(fields[characters_start], template='oi/bits/uni_field.html')) field_list.append(Formset('groups_formset')) + has_appearance_order = False + has_importance_order = False + if 'instance' in kwargs and kwargs['instance'] and \ + kwargs['instance'].id: + if kwargs['instance'].character_orders.filter(type__id=1): + has_appearance_order = True + if kwargs['instance'].character_orders.filter(type__id=2): + has_importance_order = True + character_order_html = '' + if has_appearance_order: + character_order_html += '' + else: + character_order_html += '' + if has_importance_order: + character_order_html += '' + else: + character_order_html += '' + field_list.append(HTML(character_order_html + '')) characters_end = len(field_list) field_list.extend([BaseField(Field(field, template='oi/bits/uni_field.html')) for field in fields[characters_start+1:]]) - # characters_end += 1 if not user.indexer.use_tabs: self.helper.layout = Layout(*(f for f in field_list)) else: diff --git a/apps/oi/migrations/0062_add_character_order.py b/apps/oi/migrations/0062_add_character_order.py new file mode 100644 index 000000000..fb0a3d673 --- /dev/null +++ b/apps/oi/migrations/0062_add_character_order.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.9 on 2026-01-10 12:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gcd', '0067_add_character_order'), + ('oi', '0061_indicia_printer_not_printed'), + ] + + operations = [ + migrations.CreateModel( + name='CharacterOrderRevision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('committed', models.BooleanField(db_index=True, default=None, null=True)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('modified', models.DateTimeField(auto_now=True, db_index=True)), + ('changeset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='oi.changeset')), + ('character_order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='gcd.characterorder')), + ('previous_revision', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='next_revision', to='oi.characterorderrevision')), + ('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='character_orders', to='oi.storyrevision')), + ('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='gcd.characterordertype')), + ], + options={ + 'db_table': 'oi_character_order_revision', + }, + ), + migrations.CreateModel( + name='CharacterThroughOrderRevision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_code', models.IntegerField(db_index=True, default=0)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oi.characterorderrevision')), + ('story_character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oi.storycharacterrevision')), + ], + options={ + 'db_table': 'oi_character_through_order', + }, + ), + migrations.AddField( + model_name='characterorderrevision', + name='characters', + field=models.ManyToManyField(through='oi.CharacterThroughOrderRevision', to='oi.storycharacterrevision'), + ), + ] diff --git a/apps/oi/models.py b/apps/oi/models.py index 3e1187c0a..fce39a919 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -38,6 +38,7 @@ SeriesBond, Cover, Image, Issue, IssueCredit, PublisherCodeNumber, CodeNumberType, Story, StoryCredit, StoryCharacter, CharacterRole, StoryGroup, StoryArc, StoryArcRelation, Universe, Multiverse, + CharacterOrderType, CharacterOrder, BiblioEntry, Reprint, SeriesPublicationType, SeriesBondType, StoryType, CreditType, FeatureType, Feature, FeatureLogo, FeatureRelation, Character, CharacterRelation, @@ -5447,6 +5448,65 @@ def _imps_for(self, field_name): return 0 +class CharacterOrderRevision(Revision): + class Meta: + app_label = 'oi' + db_table = 'oi_character_order_revision' + + character_order = models.ForeignKey(CharacterOrder, null=True, + on_delete=models.CASCADE, + related_name='revisions') + characters = models.ManyToManyField( + StoryCharacterRevision, through='CharacterThroughOrderRevision') + story = models.ForeignKey('StoryRevision', on_delete=models.CASCADE, + related_name='character_orders') + type = models.ForeignKey(CharacterOrderType, + on_delete=models.CASCADE) + + @property + def ordered_characters(self): + return self.characters.order_by( + 'characterthroughorderrevision__order_code') + + def story_characters(self): + # get existing characters in their order + order = 0 + character_order_list = [] + for character in self.ordered_characters: + character_order_list.append((character, order)) + order += 1 + character_order_list.append((None, order)) + order += 1 + # get all characters appearing in the story + # user order by id, which could reflect creation order + story_characters = self.story.appearing_characters.order_by('id') + # process characters to have civilians after their aliases + character_list = _order_civilian_after_alias(story_characters) + for character in character_list: + if not self.characters.filter(id=character[0].id).exists(): + character_order_list.append((character[0], order)) + order += 1 + # we do not add civilians if their alias is present, so + # we ignore character[1] here + return character_order_list + + def __str__(self): + return "%s: (order: %s)" % (self.story, self.type) + + +class CharacterThroughOrderRevision(models.Model): + class Meta: + app_label = 'oi' + db_table = 'oi_character_through_order' + ordering = ['order_code'] + + order = models.ForeignKey(CharacterOrderRevision, + on_delete=models.CASCADE) + story_character = models.ForeignKey(StoryCharacterRevision, + on_delete=models.CASCADE) + order_code = models.IntegerField(default=0, db_index=True) + + class StoryGroupRevision(Revision): class Meta: db_table = 'oi_story_group_revision' @@ -5674,6 +5734,28 @@ def __str__(self): ) +def _order_civilian_after_alias(story_characters): + character_list = [] + for character in story_characters: + alias_identity = set( + character.character.character.from_related_character + .filter(relation_type__id=2) + .values_list('from_character', flat=True))\ + .intersection(story_characters.filter( + universe=character.universe).values_list( + 'character__character', flat=True)) + if alias_identity: + continue + civilian_identity = _get_civilian_identity(character, + story_characters) + if civilian_identity: + civilian_identity = story_characters.filter( + universe=character.universe, + character__character__id__in=civilian_identity) + character_list.append([character, civilian_identity]) + return character_list + + class StoryRevision(Revision): class Meta: db_table = 'oi_story_revision' @@ -6094,24 +6176,7 @@ def extra_forms(self, request): queryset=self.story_credit_revisions.filter(deleted=False)) story_characters = self.story_character_revisions.filter(deleted=False) - character_list = [] - for character in story_characters: - alias_identity = set( - character.character.character.from_related_character - .filter(relation_type__id=2) - .values_list('from_character', flat=True))\ - .intersection(story_characters.filter( - universe=character.universe).values_list( - 'character__character', flat=True)) - if alias_identity: - continue - civilian_identity = _get_civilian_identity(character, - story_characters) - if civilian_identity: - civilian_identity = story_characters.filter( - universe=character.universe, - character__character__id__in=civilian_identity) - character_list.append([character, civilian_identity]) + character_list = _order_civilian_after_alias(story_characters) order = 0 # Create a dict to store order by character id order_map = {} diff --git a/apps/oi/urls.py b/apps/oi/urls.py index 2b61eee30..28b622efc 100644 --- a/apps/oi/urls.py +++ b/apps/oi/urls.py @@ -208,6 +208,15 @@ def to_url(self, value): oi_views.compare_stories_copy, name='compare_revisions_copy'), path('story/revision//select_compare/', oi_views.story_select_compare, name='story_select_compare'), + path('story/revision//create_character_order_revision/type//', + oi_views.create_character_order_revision, + name='create_character_order_revision'), + path('story/revision//edit_character_order_revision/type//', + oi_views.edit_character_order_revision, + name='edit_character_order_revision'), + path('character_order/revision//reorder/', + oi_views.reorder_characters, + name='reorder_characters'), # Story Arc URLs path('story_arc/add/', diff --git a/apps/oi/views.py b/apps/oi/views.py index 8444057d9..4350af90c 100644 --- a/apps/oi/views.py +++ b/apps/oi/views.py @@ -35,6 +35,7 @@ CreatorMembership, CreatorArtInfluence, CreatorDegree, CreatorNonComicWork, CreatorRelation, CreatorSchool, CreatorNameDetail, Story, StoryType, StoryArc, StoryArcRelation, STORY_TYPES, BiblioEntry, + CharacterOrderType, Feature, FeatureLogo, FeatureRelation, Printer, IndiciaPrinter, CreatorSignature, Character, CharacterRelation, Group, @@ -60,7 +61,8 @@ Changeset, BrandGroupRevision, BrandRevision, BrandUseRevision, CoverRevision, ImageRevision, IndiciaPublisherRevision, IssueRevision, PublisherRevision, ReprintRevision, SeriesBondRevision, SeriesRevision, - StoryRevision, BiblioEntryRevision, OngoingReservation, RevisionLock, + StoryRevision, BiblioEntryRevision, CharacterOrderRevision, + OngoingReservation, RevisionLock, _get_revision_lock, _free_revision_lock, CTYPES, get_issue_field_list, set_series_first_last, AwardRevision, ReceivedAwardRevision, IssueCreditRevision, @@ -139,6 +141,7 @@ 'issue': IssueRevision, 'story': StoryRevision, 'biblio_entry': BiblioEntryRevision, + 'character_order': CharacterOrderRevision, 'story_arc': StoryArcRevision, 'story_arc_relation': StoryArcRelationRevision, 'feature': FeatureRevision, @@ -742,6 +745,22 @@ def _save(request, form, revision, changeset=None, model_name=None): return HttpResponseRedirect(urlresolvers.reverse( 'edit_revision', kwargs={'model_name': model_name, 'id': revision.id})) + if 'create_appearance_order' in request.POST and model_name == 'story': + return HttpResponseRedirect(urlresolvers.reverse( + 'create_character_order_revision', + kwargs={'story_revision_id': revision.id, 'type_id': 1})) + if 'edit_appearance_order' in request.POST and model_name == 'story': + return HttpResponseRedirect(urlresolvers.reverse( + 'edit_character_order_revision', + kwargs={'story_revision_id': revision.id, 'type_id': 1})) + if 'create_importance_order' in request.POST and model_name == 'story': + return HttpResponseRedirect(urlresolvers.reverse( + 'create_character_order_revision', + kwargs={'story_revision_id': revision.id, 'type_id': 2})) + if 'edit_importance_order' in request.POST and model_name == 'story': + return HttpResponseRedirect(urlresolvers.reverse( + 'edit_character_order_revision', + kwargs={'story_revision_id': revision.id, 'type_id': 2})) if 'save_and_set_universe' in request.POST: if revision.universe.count() == 1: characters = revision.story_character_revisions.filter( @@ -1582,7 +1601,11 @@ def process_revision(request, id, model_name): if 'save' in request.POST or 'save_return' in request.POST \ or 'save_migrate' in request.POST \ or 'save_migrate_feature' in request.POST \ - or 'save_and_set_universe' in request.POST: + or 'save_and_set_universe' in request.POST \ + or 'edit_appearance_order' in request.POST \ + or 'create_appearance_order' in request.POST \ + or 'edit_importance_order' in request.POST \ + or 'create_importance_order' in request.POST: revision = get_object_or_404(REVISION_CLASSES[model_name], id=id) form = get_revision_form(revision, user=request.user)(request.POST, @@ -3422,6 +3445,14 @@ def add_story(request, issue_revision_id, changeset_id): urlresolvers.reverse('edit_revision', kwargs={'model_name': 'biblio_entry', 'id': biblio_revision.id})) + if 'create_appearance_order' in request.POST: + return HttpResponseRedirect(urlresolvers.reverse( + 'create_character_order_revision', + kwargs={'story_revision_id': revision.id, 'type_id': 1})) + if 'create_importance_order' in request.POST: + return HttpResponseRedirect(urlresolvers.reverse( + 'create_character_order_revision', + kwargs={'story_revision_id': revision.id, 'type_id': 2})) return HttpResponseRedirect(urlresolvers.reverse('edit', kwargs={'id': changeset.id})) @@ -3791,6 +3822,48 @@ def compare_stories_copy(request, story_revision_id, story_id=None, kwargs={'model_name': 'story', 'id': revision.id})) + +@permission_required('indexer.can_reserve') +def edit_character_order_revision(request, story_revision_id, type_id): + story_revision = get_object_or_404(StoryRevision, id=story_revision_id) + changeset = get_object_or_404(Changeset, id=story_revision.changeset_id) + if request.user != changeset.indexer: + return render_error( + request, 'Only the reservation holder may edit character orders.') + type = get_object_or_404(CharacterOrderType, id=type_id) + if not story_revision.character_orders.filter(type=type).exists(): + return render_error( + request, + 'A character order of type "%s" does not exist for story "%s".' + % (type.name, story_revision)) + return HttpResponseRedirect(urlresolvers.reverse( + 'reorder_characters', + kwargs={'character_order_id': story_revision.character_orders.get( + type=type).id})) + + +@permission_required('indexer.can_reserve') +def create_character_order_revision(request, story_revision_id, type_id): + story_revision = get_object_or_404(StoryRevision, id=story_revision_id) + changeset = get_object_or_404(Changeset, id=story_revision.changeset_id) + if request.user != changeset.indexer: + return render_error( + request, 'Only the reservation holder may create character orders.') + type = get_object_or_404(CharacterOrderType, id=type_id) + if story_revision.character_orders.filter(type=type).exists(): + return render_error( + request, + 'A character order of type "%s" already exists for story "%s".' + % (type.name, story_revision)) + order_revision = CharacterOrderRevision( + story=story_revision, + type=type, + changeset=changeset) + order_revision.save() + return HttpResponseRedirect(urlresolvers.reverse( + 'reorder_characters', + kwargs={'character_order_id': order_revision.id})) + ############################################################################## # Series Bond Editing ############################################################################## @@ -5380,7 +5453,7 @@ def reorder_stories(request, issue_id, changeset_id): 'Only the reservation holder may reorder stories.') # At this time, only existing issues can have their stories reordered. - # This is analagous to issues needing to exist before stories can be added. + # This is analogous to issues needing to exist before stories can be added. issue_revision = changeset.issuerevisions.get(issue=issue_id) if request.method != 'POST': return oi_render(request, 'oi/edit/reorder_stories.html', @@ -5405,6 +5478,66 @@ def reorder_stories(request, issue_id, changeset_id): return vte.response +@permission_required('indexer.can_reserve') +def reorder_characters(request, character_order_id): + character_order_revision = get_object_or_404(CharacterOrderRevision, + id=character_order_id) + changeset = character_order_revision.changeset + if request.user != changeset.indexer: + return render_error( + request, + 'Only the reservation holder may reorder characters.') + + if request.method != 'POST': + return oi_render(request, 'oi/edit/reorder_characters.html', + {'character_order': character_order_revision}) + + if 'cancel' in request.POST: + return HttpResponseRedirect(urlresolvers.reverse( + 'edit_revision', kwargs={'id': character_order_revision.story.id, + 'model_name': 'story'})) + + try: + order_code_boundary = request.POST['order_code_boundary'] + request_post = request.POST.copy() + for key in request.POST: + if key.startswith('order_code_') and key != 'order_code_boundary': + value = request.POST[key] + if value and float(value) >= float(order_code_boundary): + request_post.pop(key) + character_id = int(key.split('_')[-1]) + revision_characters = character_order_revision.characters + if revision_characters.filter(id=character_id).exists(): + revision_characters.remove(character_id) + request.POST = request_post + characters = _process_reorder_form(request, character_order_revision, + 'order_code', + 'character', StoryCharacterRevision) + order = 0 + for character in characters: + revision_characters = character_order_revision.characters + if not revision_characters.filter(id=character.id).exists(): + revision_characters.add(character, + through_defaults={'order_code': order}) + else: + through_instance = revision_characters.through.objects.get( + order=character_order_revision, + story_character=character + ) + through_instance.order_code = order + through_instance.save() + order += 1 + if 'commit_and_changeset' in request.POST: + return HttpResponseRedirect(urlresolvers.reverse( + 'edit', kwargs={'id': changeset.id})) + return HttpResponseRedirect(urlresolvers.reverse( + 'edit_revision', kwargs={'id': character_order_revision.story.id, + 'model_name': 'story'})) + + except ViewTerminationError as vte: + return vte.response + + def _reorder_children(request, parent, children, sort_field, child_set, commit, unique=True, skip=None, extras=None): """ @@ -5431,7 +5564,7 @@ def _reorder_children(request, parent, children, sort_field, child_set, # There's a "unique together" constraint on series_id and sort_code in # the issue table, which is great for avoiding nonsensical sort_code # situations that we had in the old system, but annoying when updating - # the codes. Get around it by shifing the numbers down to starting + # the codes. Get around it by shifting the numbers down to starting # at one if they'll fit, or up above the current range if not. Given # that the numbers were all set to consecutive ranges when we migrated # to this system, and this code always produces consecutive ranges, the diff --git a/templates/oi/edit/reorder_characters.html b/templates/oi/edit/reorder_characters.html new file mode 100644 index 000000000..e4e08564b --- /dev/null +++ b/templates/oi/edit/reorder_characters.html @@ -0,0 +1,79 @@ +{% extends "oi/base_view.html" %} + +{% load static %} +{% load credits %} +{% load display %} + +{% block title %} +{{ Story }} Character Order +{% endblock %} + +{% block view_body %} +

{{ story }} Character Order

+ +

Reordering Characters

+

+You can reorder the characters in a story by clicking and moving one sequence (or more) to the correct position. +

+

+You can also reorder the characters in a story by changing one or more order codes. +Since the numbers in this form do not need to be whole numbers, you can move one +and shift others up or down with a minimal number of changes. For instance: +

+
    +
  • You may use negative numbers +
  • You may use decimal numbers using a decimal point (i.e. 2.5, -10.125, etc.) +
  • You may *NOT* use commas (our apologies to folks who would prefer to write + the above as 2,5; -10,125; etc.) +
+

+The actual order codes that get saved to the database will be whole numbers +calculated by the system, so do not worry about what exact numbers are used in this +form. They only need to be in the correct order. +

+ +
+{% csrf_token %} + + + + + + + + +{% for character in character_order.story_characters %} + + {% if not character.0 %} + + + {% else %} + + + {% endif %} + +{% endfor %} + +
Order Code Character
+ --- characters below will be ordered alphabetically --- + {{ character.0.character }}
+ + + +
+ + + + +{% endblock %} \ No newline at end of file From 04df8f58ee6f4496573fb13cf689679b96eb9d9c Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Fri, 16 Jan 2026 23:21:47 +0100 Subject: [PATCH 02/77] for python 3.14 --- apps/oi/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/oi/models.py b/apps/oi/models.py index fce39a919..5313e9b2f 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -1251,8 +1251,8 @@ class Meta: # Child classes must set these properly. Unlike source, they cannot be # instance properties because they are needed during revision construction. - # H.TODO source_name = NotImplemented - source_class = NotImplemented + # H.TODO source_name = NotImplementedError + source_class = NotImplementedError # H.TODO no separate _get_source @property From 395e037d5328b9f15f33448972a892236cd71989 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 17 Jan 2026 17:08:48 +0100 Subject: [PATCH 03/77] limit edit/delete of character in character order --- apps/oi/forms/story.py | 10 ++++++++++ apps/select/views.py | 9 ++++++++- templates/oi/edit/reorder_characters.html | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/oi/forms/story.py b/apps/oi/forms/story.py index 496c5bda2..f60a3d349 100644 --- a/apps/oi/forms/story.py +++ b/apps/oi/forms/story.py @@ -458,6 +458,16 @@ def __init__(self, *args, **kwargs): instance.is_flashback or instance.is_origin or \ instance.is_death or instance.notes: self.fields['additional_information'].initial = True + if instance.characterorderrevision_set.exists(): + self.no_delete = True + self.fields['character'].help_text = \ + 'Characters that are part of a character order cannot be ' \ + 'removed.' + if self.instance and self.instance.character: + self.fields['character'].widget.forward = [ + forward.Const(self.instance.character.id, + 'current_character_id') + ] character = forms.ModelChoiceField( queryset=CharacterNameDetail.objects.all(), diff --git a/apps/select/views.py b/apps/select/views.py index b74dd570c..2d981e640 100644 --- a/apps/select/views.py +++ b/apps/select/views.py @@ -704,6 +704,7 @@ def get_queryset(self): language = self.forwarded.get('language_code', None) group_name = self.forwarded.get('group_name', None) + current_character_id = self.forwarded.get('current_character_id', None) if language and language not in ['zxx', 'und']: qs = qs.filter(character__language__code__in=[language, 'zxx']) @@ -711,6 +712,12 @@ def get_queryset(self): if group_name: qs = qs.filter( character__memberships__group__group_names=group_name).distinct() + + if current_character_id: + character = Character.objects.get( + character_names__id=current_character_id) + qs = qs.filter(character__id=character.id) + qs = _filter_and_sort(qs, self.q, parent_disambiguation='character', chrono_sort='character__year_first_published') @@ -1039,7 +1046,7 @@ class SequenceFilter(CommonFilter): class Meta: model = Issue - fields = ['country', 'language', 'publisher'] + fields = ['country', 'language', 'publisher', 'story_type'] class KeywordUsedFilter(FilterSet): diff --git a/templates/oi/edit/reorder_characters.html b/templates/oi/edit/reorder_characters.html index e4e08564b..d03a8b776 100644 --- a/templates/oi/edit/reorder_characters.html +++ b/templates/oi/edit/reorder_characters.html @@ -9,7 +9,7 @@ {% endblock %} {% block view_body %} -

{{ story }} Character Order

+

{{ story }} Character {{ character_order.type|title }}

Reordering Characters

From 507396743c0fcf219fd7d28508137209f4711f49 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 17 Jan 2026 17:11:08 +0100 Subject: [PATCH 04/77] comment old brand_id on issue, cleanup --- apps/gcd/models/issue.py | 4 ++-- apps/oi/models.py | 6 +++--- apps/oi/templates/forms/widgets/select_brand.html | 1 - templates/oi/bits/revision_form_utils.html | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) delete mode 100644 apps/oi/templates/forms/widgets/select_brand.html diff --git a/apps/gcd/models/issue.py b/apps/gcd/models/issue.py index 4035d6a1f..a5990f345 100644 --- a/apps/gcd/models/issue.py +++ b/apps/gcd/models/issue.py @@ -176,8 +176,8 @@ class Meta: on_delete=models.CASCADE, null=True) indicia_pub_not_printed = models.BooleanField(default=False) brand_emblem = models.ManyToManyField(Brand) - brand = models.ForeignKey(Brand, on_delete=models.CASCADE, null=True, - related_name='issues_deprecated') + # brand = models.ForeignKey(Brand, on_delete=models.CASCADE, null=True, + # related_name='issues_deprecated') no_brand = models.BooleanField(default=False, db_index=True) indicia_printer = models.ManyToManyField(IndiciaPrinter) indicia_printer_not_printed = models.BooleanField(default=False) diff --git a/apps/oi/models.py b/apps/oi/models.py index 5313e9b2f..d82fd204d 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -4052,9 +4052,9 @@ class Meta: indicia_pub_not_printed = models.BooleanField(default=False) brand_emblem = models.ManyToManyField(Brand, blank=True, related_name='issue_revisions') - brand = models.ForeignKey( - Brand, on_delete=models.CASCADE, null=True, default=None, blank=True, - related_name='issue_revisions_deprecated') + # brand = models.ForeignKey( + # Brand, on_delete=models.CASCADE, null=True, default=None, blank=True, + # related_name='issue_revisions_deprecated') # TODO when removing brand_emblem, remove msdropdown from revision_form_utils.html # and remove apps/oi/templates/forms/widgets/select_brand.html no_brand = models.BooleanField(default=False) diff --git a/apps/oi/templates/forms/widgets/select_brand.html b/apps/oi/templates/forms/widgets/select_brand.html deleted file mode 100644 index fdc28cf60..000000000 --- a/apps/oi/templates/forms/widgets/select_brand.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/oi/bits/revision_form_utils.html b/templates/oi/bits/revision_form_utils.html index 5d3603c81..b97b666c9 100644 --- a/templates/oi/bits/revision_form_utils.html +++ b/templates/oi/bits/revision_form_utils.html @@ -11,7 +11,6 @@ {% endif %} {% if revision.source_name == 'issue' or object_name in 'Variant Issues' or 'Variant Issue' in object_name %} - {% endif %} {% if revision.source_name == 'story' or object_name == 'Story' %} From 90896f352ffe4c6096eef4ef10dbfdc84847eb55 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 24 Jan 2026 16:09:08 +0100 Subject: [PATCH 05/77] commit_to_display for character orders, display of character orders --- apps/gcd/models/story.py | 97 ++++++++++++++++- apps/oi/forms/story.py | 6 +- .../oi/migrations/0062_add_character_order.py | 5 +- apps/oi/models.py | 100 ++++++++++++++++-- apps/oi/views.py | 28 ++--- templates/gcd/bits/tw_story_characters.html | 31 ++++++ templates/gcd/details/tw_single_story.html | 40 ++----- 7 files changed, 246 insertions(+), 61 deletions(-) create mode 100644 templates/gcd/bits/tw_story_characters.html diff --git a/apps/gcd/models/story.py b/apps/gcd/models/story.py index abff20aee..54fab8050 100644 --- a/apps/gcd/models/story.py +++ b/apps/gcd/models/story.py @@ -209,6 +209,10 @@ def _process_single_character(character, appearing_characters, def process_appearing_characters(story): + """ + Return a properly formatted list of characters appearing in the story. + Ordering is by groups first, then by character sort name. + """ all_appearing_characters = story.active_characters in_group = all_appearing_characters.exclude(group_name=None) groups = story.active_groups @@ -248,8 +252,94 @@ def process_appearing_characters(story): return (group_list, character_list) +def process_ordered_appearing_characters(character_order): + """ + Return a properly formatted list of characters appearing in the story. + The order ist defined by the given CharacterOrder, followed by any other + appearing characters not included in the order, ordered by sort name. + """ + story = character_order.story + all_appearing_characters = story.active_characters + in_group = all_appearing_characters.exclude(group_name=None) + if hasattr(character_order, 'character_revisions'): + field = 'character_revisions' + else: + field = 'characters' + through_model = character_order._meta.get_field(field).remote_field.through + in_character_order = all_appearing_characters.filter( + **{f'{through_model.__name__.lower()}__order': character_order} + ).distinct() + groups = story.active_groups + + reference_universe_id = _get_reference_universe(story) + + group_list = [] + processed_appearances_ids = [] + for group in groups: + group_universe = None + if reference_universe_id and group.universe: + if group.universe_id != reference_universe_id: + group_universe = group.universe + character_list = [] + ordered_character_list = [] + for member in in_group.filter(group_name=group.group_name_id, + group_universe=group.universe_id): + if member in in_character_order: + ordered_character_list.append(( + getattr(character_order, + f'{through_model.__name__.lower()}_set').get( + order=character_order, + story_character=member).order_code, member)) + else: + character_list.append(_process_single_character( + member, all_appearing_characters, reference_universe_id)) + processed_appearances_ids.append(member.id) + ordered_character_list.sort(key=lambda x: x[0]) + cnt = 0 + for _, member in ordered_character_list: + character_list.insert(cnt, _process_single_character( + member, all_appearing_characters, reference_universe_id)) + cnt += 1 + group_list.append((group, group_universe, character_list)) + appearing_characters = all_appearing_characters.exclude( + id__in=processed_appearances_ids) + + character_list = [] + ordered_character_list = [] + for character in appearing_characters: + alias_identity = set( + character.character.character.from_related_character + .filter(relation_type__id=2).values_list('from_character', + flat=True))\ + .intersection(all_appearing_characters.filter( + universe=character.universe).values_list( + 'character__character', flat=True)) + if alias_identity: + continue + if character in in_character_order: + ordered_character_list.append(( + getattr(character_order, + f'{through_model.__name__.lower()}_set').get( + order=character_order, + story_character=character).order_code, character)) + else: + character_list.append(_process_single_character( + character, all_appearing_characters, reference_universe_id)) + ordered_character_list.sort(key=lambda x: x[0]) + cnt = 0 + for _, character in ordered_character_list: + character_list.insert(cnt, _process_single_character( + character, all_appearing_characters, reference_universe_id)) + cnt += 1 + return (group_list, character_list) + + def show_characters(story, url=True, css_style=True, compare=False, bare_value=False): + ''' + Return a properly formatted list of characters appearing in the story. + Old version kept for reference, not used anymore. + ''' first = True characters = '' disambiguation = '' @@ -578,8 +668,11 @@ class Meta: type = models.ForeignKey(CharacterOrderType, on_delete=models.CASCADE) + def process_ordered_appearing_characters(self): + return process_ordered_appearing_characters(self) + def __str__(self): - return "%s: (order: %d)" % (self.story, self.type) + return "%s: (order: %s)" % (self.story, self.type) class CharacterThroughOrder(models.Model): @@ -588,7 +681,7 @@ class Meta: db_table = 'gcd_character_through_order' order = models.ForeignKey(CharacterOrder, - on_delete=models.CASCADE) + on_delete=models.CASCADE) story_character = models.ForeignKey(StoryCharacter, on_delete=models.CASCADE) order_code = models.IntegerField(default=0, db_index=True) diff --git a/apps/oi/forms/story.py b/apps/oi/forms/story.py index f60a3d349..83c944b2b 100644 --- a/apps/oi/forms/story.py +++ b/apps/oi/forms/story.py @@ -461,7 +461,7 @@ def __init__(self, *args, **kwargs): if instance.characterorderrevision_set.exists(): self.no_delete = True self.fields['character'].help_text = \ - 'Characters that are part of a character order cannot be ' \ + 'Characters that are part of a character order cannot be '\ 'removed.' if self.instance and self.instance.character: self.fields['character'].widget.forward = [ @@ -755,9 +755,9 @@ def __init__(self, *args, **kwargs): has_importance_order = False if 'instance' in kwargs and kwargs['instance'] and \ kwargs['instance'].id: - if kwargs['instance'].character_orders.filter(type__id=1): + if kwargs['instance'].character_order_revisions.filter(type__id=1): has_appearance_order = True - if kwargs['instance'].character_orders.filter(type__id=2): + if kwargs['instance'].character_order_revisions.filter(type__id=2): has_importance_order = True character_order_html = '' if has_appearance_order: diff --git a/apps/oi/migrations/0062_add_character_order.py b/apps/oi/migrations/0062_add_character_order.py index fb0a3d673..78f44bcab 100644 --- a/apps/oi/migrations/0062_add_character_order.py +++ b/apps/oi/migrations/0062_add_character_order.py @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('changeset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='oi.changeset')), ('character_order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='gcd.characterorder')), ('previous_revision', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='next_revision', to='oi.characterorderrevision')), - ('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='character_orders', to='oi.storyrevision')), + ('story_revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='character_order_revisions', to='oi.storyrevision')), ('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='gcd.characterordertype')), ], options={ @@ -40,11 +40,12 @@ class Migration(migrations.Migration): ], options={ 'db_table': 'oi_character_through_order', + 'ordering': ['order_code'], }, ), migrations.AddField( model_name='characterorderrevision', - name='characters', + name='character_revisions', field=models.ManyToManyField(through='oi.CharacterThroughOrderRevision', to='oi.storycharacterrevision'), ), ] diff --git a/apps/oi/models.py b/apps/oi/models.py index d82fd204d..61f04907e 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -343,7 +343,8 @@ def _revision_sets(self): self.coverrevisions.all(), self.reprintrevisions.all(), self.publishercodenumberrevisions.all(), - self.externallinkrevisions.all()) + self.externallinkrevisions.all(), + self.characterorderrevisions.all(),) if self.change_type in [CTYPES['issue_add'], CTYPES['issue_bulk']]: if self.issuerevisions.all().count() == 1 and \ @@ -357,7 +358,8 @@ def _revision_sets(self): self.coverrevisions.all(), self.reprintrevisions.all(), self.publishercodenumberrevisions.all(), - self.externallinkrevisions.all()) + self.externallinkrevisions.all(), + self.characterorderrevisions.all(),) elif self.issuerevisions.all().count() == 1: return (self.issuerevisions.all(), self.issuecreditrevisions.all(), @@ -2111,7 +2113,6 @@ def commit_to_display(self, clear_reservation=True): for multi in self._get_multi_value_fields(): old_rp = relpath.RelPath(type(self), multi) new_rp = relpath.RelPath(type(self.source), multi) - new_rp.set_value(self.source, old_rp.get_value(self)) self._post_save_object(changes) @@ -4425,6 +4426,17 @@ def _do_create_dependent_revisions(self, delete=False): if delete: credit_revision.deleted = story_revision.deleted credit_revision.save() + for character_order in story.character_orders.all(): + order_lock = _get_revision_lock(character_order, + changeset=self.changeset) + if order_lock is None: + raise IntegrityError("needed Order lock not possible") + order_revision = CharacterOrderRevision.clone( + character_order, self.changeset, + story_revision=story_revision) + if delete: + order_revision.deleted = story_revision.deleted + order_revision.save() for character in story.active_characters: character_lock = _get_revision_lock(character, changeset=self.changeset) @@ -4435,6 +4447,14 @@ def _do_create_dependent_revisions(self, delete=False): if delete: character_revision.deleted = story_revision.deleted character_revision.save() + for order in character.characterorder_set.all(): + order_code = character\ + .characterthroughorder_set.get(order=order).order_code + order_revision = order.revisions.get( + changeset=self.changeset) + order_revision.character_revisions.add( + character_revision, + through_defaults={'order_code': order_code}) for group in story.active_groups: group_lock = _get_revision_lock(group, changeset=self.changeset) @@ -5456,16 +5476,58 @@ class Meta: character_order = models.ForeignKey(CharacterOrder, null=True, on_delete=models.CASCADE, related_name='revisions') - characters = models.ManyToManyField( + character_revisions = models.ManyToManyField( StoryCharacterRevision, through='CharacterThroughOrderRevision') - story = models.ForeignKey('StoryRevision', on_delete=models.CASCADE, - related_name='character_orders') + story_revision = models.ForeignKey( + 'StoryRevision', on_delete=models.CASCADE, + related_name='character_order_revisions') type = models.ForeignKey(CharacterOrderType, on_delete=models.CASCADE) + source_name = 'character_order' + source_class = CharacterOrder + + @property + def source(self): + return self.character_order + + @source.setter + def source(self, value): + self.character_order = value + + def _pre_save_object(self, changes): + self.character_order.story = self.story_revision.story + + def _pre_initial_save(self, fork=False, fork_source=None, + exclude=frozenset(), **kwargs): + self.story_revision = kwargs['story_revision'] + + def _post_save_object(self, changes): + characters = self.character_order.characters.all() + character_revisions = self.character_revisions.all() + for character in characters: + if not character_revisions.filter( + character__id=character.id, + universe=character.universe).count(): + self.character_order.characters.remove(character) + else: + character.order_code = character_revisions.get( + character__id=character.id, + universe=character.universe).order_code + character.save() + for character_revision in character_revisions: + if not characters.filter( + id=character_revision.character.id, + universe=character_revision.universe).exists(): + order_code = character_revision\ + .characterthroughorderrevision_set.get(order=self).order_code + self.character_order.characters.add( + character_revision.story_character, + through_defaults={'order_code': order_code}) + @property def ordered_characters(self): - return self.characters.order_by( + return self.character_revisions.order_by( 'characterthroughorderrevision__order_code') def story_characters(self): @@ -5479,19 +5541,33 @@ def story_characters(self): order += 1 # get all characters appearing in the story # user order by id, which could reflect creation order - story_characters = self.story.appearing_characters.order_by('id') + story_characters = self.story_revision.appearing_characters\ + .order_by('id') # process characters to have civilians after their aliases character_list = _order_civilian_after_alias(story_characters) for character in character_list: - if not self.characters.filter(id=character[0].id).exists(): + character_id = character[0].id + # add characters to the list if not already present in the order + if not self.character_revisions.filter(id=character_id).exists(): character_order_list.append((character[0], order)) order += 1 # we do not add civilians if their alias is present, so # we ignore character[1] here return character_order_list + def _get_blank_values(self): + return { + 'story_revision': None, + 'type': None, + } + + def process_ordered_appearing_characters(self): + from apps.gcd.models.story import process_ordered_appearing_characters + self.story = self.story_revision + return process_ordered_appearing_characters(self) + def __str__(self): - return "%s: (order: %s)" % (self.story, self.type) + return "%s: (order: %s)" % (self.story_revision, self.type) class CharacterThroughOrderRevision(models.Model): @@ -7039,6 +7115,10 @@ def active_characters(self): def active_groups(self): return self.revision.story_group_revisions.exclude(deleted=True) + @property + def character_orders(self): + return self.revision.character_order_revisions.exclude(deleted=True) + def has_credits(self): """ Simplifies UI checks for conditionals. Credit fields. diff --git a/apps/oi/views.py b/apps/oi/views.py index 4350af90c..84c98261b 100644 --- a/apps/oi/views.py +++ b/apps/oi/views.py @@ -562,7 +562,8 @@ def submit(request, id): if comment_text == '' and changeset.approver is None and \ changeset.comments.count() == 1: changeset.calculate_imps() - if changeset.imps == 0: + if changeset.imps == 0 and not \ + changeset.characterorderrevisions.exists(): return oi_render( request, 'indexer/error.html', {'error_text': mark_safe('A submission needs to consists of at ' @@ -3831,15 +3832,15 @@ def edit_character_order_revision(request, story_revision_id, type_id): return render_error( request, 'Only the reservation holder may edit character orders.') type = get_object_or_404(CharacterOrderType, id=type_id) - if not story_revision.character_orders.filter(type=type).exists(): + if not story_revision.character_order_revisions.filter(type=type).exists(): return render_error( request, 'A character order of type "%s" does not exist for story "%s".' % (type.name, story_revision)) return HttpResponseRedirect(urlresolvers.reverse( 'reorder_characters', - kwargs={'character_order_id': story_revision.character_orders.get( - type=type).id})) + kwargs={'character_order_id': + story_revision.character_order_revisions.get(type=type).id})) @permission_required('indexer.can_reserve') @@ -3850,13 +3851,13 @@ def create_character_order_revision(request, story_revision_id, type_id): return render_error( request, 'Only the reservation holder may create character orders.') type = get_object_or_404(CharacterOrderType, id=type_id) - if story_revision.character_orders.filter(type=type).exists(): + if story_revision.character_order_revisions.filter(type=type).exists(): return render_error( request, 'A character order of type "%s" already exists for story "%s".' % (type.name, story_revision)) order_revision = CharacterOrderRevision( - story=story_revision, + story_revision=story_revision, type=type, changeset=changeset) order_revision.save() @@ -5494,8 +5495,9 @@ def reorder_characters(request, character_order_id): if 'cancel' in request.POST: return HttpResponseRedirect(urlresolvers.reverse( - 'edit_revision', kwargs={'id': character_order_revision.story.id, - 'model_name': 'story'})) + 'edit_revision', + kwargs={'id': character_order_revision.story_revision.id, + 'model_name': 'story'})) try: order_code_boundary = request.POST['order_code_boundary'] @@ -5506,7 +5508,8 @@ def reorder_characters(request, character_order_id): if value and float(value) >= float(order_code_boundary): request_post.pop(key) character_id = int(key.split('_')[-1]) - revision_characters = character_order_revision.characters + revision_characters = character_order_revision\ + .character_revisions if revision_characters.filter(id=character_id).exists(): revision_characters.remove(character_id) request.POST = request_post @@ -5515,7 +5518,7 @@ def reorder_characters(request, character_order_id): 'character', StoryCharacterRevision) order = 0 for character in characters: - revision_characters = character_order_revision.characters + revision_characters = character_order_revision.character_revisions if not revision_characters.filter(id=character.id).exists(): revision_characters.add(character, through_defaults={'order_code': order}) @@ -5531,8 +5534,9 @@ def reorder_characters(request, character_order_id): return HttpResponseRedirect(urlresolvers.reverse( 'edit', kwargs={'id': changeset.id})) return HttpResponseRedirect(urlresolvers.reverse( - 'edit_revision', kwargs={'id': character_order_revision.story.id, - 'model_name': 'story'})) + 'edit_revision', + kwargs={'id': character_order_revision.story_revision.id, + 'model_name': 'story'})) except ViewTerminationError as vte: return vte.response diff --git a/templates/gcd/bits/tw_story_characters.html b/templates/gcd/bits/tw_story_characters.html new file mode 100644 index 000000000..3970a78bd --- /dev/null +++ b/templates/gcd/bits/tw_story_characters.html @@ -0,0 +1,31 @@ + {% with group_list=characters.0 character_list=characters.1 %} + {% if group_list %} + {% for group in group_list %} + {{ group.0.group_name.name }} + {% if group.1 %} + ({{ group.1 }}) + {% endif %} + {% if group.0.notes %} + ({{ group.0.notes }}) + {% endif %} + {% if group.2 %} + {% with character_list=group.2 %} +

    + {% include 'gcd/bits/tw_character_list.html' %} +
+ {% endwith %} + {% else %} +
+ {% endif %} + {% endfor %} + {% endif %} + {% if character_list %} + {% if character_list|length > 1 %} +
    + {% else %} +
      + {% endif %} + {% include 'gcd/bits/tw_character_list.html' %} +
    + {% endif %} + {% endwith %} diff --git a/templates/gcd/details/tw_single_story.html b/templates/gcd/details/tw_single_story.html index e4f7acf9a..43a647d68 100644 --- a/templates/gcd/details/tw_single_story.html +++ b/templates/gcd/details/tw_single_story.html @@ -110,39 +110,15 @@

    Characters: {% with characters=story.process_appearing_characters %} - {% with group_list=characters.0 character_list=characters.1 %} - {% if group_list %} - {% for group in group_list %} - {{ group.0.group_name.name }} - {% if group.1 %} - ({{ group.1 }}) - {% endif %} - {% if group.0.notes %} - ({{ group.0.notes }}) - {% endif %} - {% if group.2 %} - {% with character_list=group.2 %} -
      - {% include 'gcd/bits/tw_character_list.html' %} -
    - {% endwith %} - {% else %} -
    - {% endif %} - {% endfor %} - {% endif %} - {% if character_list %} - {% if character_list|length > 1 %} -
      - {% else %} -
        - {% endif %} - {% include 'gcd/bits/tw_character_list.html' %} -
      - {% endif %} - {% endwith %} + {% include 'gcd/bits/tw_story_characters.html' %} {% endwith %} - {{ story.characters }} + {% for character_order in story.character_orders.all %} +
      Character {{ character_order.type|title }}:
      + {% with characters=character_order.process_ordered_appearing_characters %} + {% include 'gcd/bits/tw_story_characters.html' %} + {% endwith %} + {% endfor %} + {{ story.characters }} {% endif %}
      {{ story|show_credit_description_list:"synopsis"|linebreaksbr }} From e3d24c2d848b7f55bd364239baefe293176605ac Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 7 Feb 2026 18:27:27 +0100 Subject: [PATCH 06/77] show more info about character appearance --- apps/oi/models.py | 4 ++++ templates/oi/edit/reorder_characters.html | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/oi/models.py b/apps/oi/models.py index fdaab4add..eaf386e71 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -5435,6 +5435,10 @@ def copied_translation(cls, character, story_revision): return new_character return None + def show_character_notes(self): + from apps.gcd.models.story import character_notes + return character_notes(self) + def __str__(self): if hasattr(self, 'character'): return "%s: %s" % (self.story_revision, self.character) diff --git a/templates/oi/edit/reorder_characters.html b/templates/oi/edit/reorder_characters.html index d03a8b776..ae963d01f 100644 --- a/templates/oi/edit/reorder_characters.html +++ b/templates/oi/edit/reorder_characters.html @@ -59,7 +59,14 @@

      Reordering Characters

      name="order_code_{{ character.0.id }}" value="{{ character.1 }}"> - {{ character.0.character }} + + {{ character.0.character.character.object_markdown_name }} - + {{ character.0.character.name }} + {{ character.0.show_character_notes }} + {% if character.0.universe %} + - {{ character.0.universe }} + {% endif %} + {% endif %} {% endfor %} From fd932699af3858436175875f05a65342754dad7e Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 8 Feb 2026 05:21:44 +0100 Subject: [PATCH 07/77] format --- templates/oi/edit/reorder_characters.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/oi/edit/reorder_characters.html b/templates/oi/edit/reorder_characters.html index ae963d01f..6da41c0ef 100644 --- a/templates/oi/edit/reorder_characters.html +++ b/templates/oi/edit/reorder_characters.html @@ -64,7 +64,7 @@

      Reordering Characters

      {{ character.0.character.name }} {{ character.0.show_character_notes }} {% if character.0.universe %} - - {{ character.0.universe }} + ({{ character.0.universe }}) {% endif %} {% endif %} From 3a97e03ed3abeb2ab5cd7d0090383998d7865e12 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 8 Feb 2026 18:31:59 +0100 Subject: [PATCH 08/77] auto-commit if only character order changes --- apps/oi/views.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/oi/views.py b/apps/oi/views.py index e0d28621f..49b6dc29b 100644 --- a/apps/oi/views.py +++ b/apps/oi/views.py @@ -605,6 +605,20 @@ def submit(request, id): if comment_text: send_comment_observer(request, changeset, comment_text) + # If there are only CharacterOrderRevisions, and no actual changes, + # we can skip the reviewing and commit. + if changeset.imps == 0 and changeset.characterorderrevisions.exists(): + is_changed = False + for c in changeset.revisions: + c.compare_changes() + if c.is_changed and type(c) is not CharacterOrderRevision: + is_changed = True + break + if not is_changed: + changeset.approver = User.objects.get(username='anon') + changeset.state = states.REVIEWING + changeset.approve() + return HttpResponseRedirect(urlresolvers.reverse('editing')) From 762e90f502038ca859094e5faebd30bd1def6ebb Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 3 May 2026 16:29:34 +0200 Subject: [PATCH 09/77] change donation --- templates/gcd/donate/donate.html | 60 +++++++++++--------------------- 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/templates/gcd/donate/donate.html b/templates/gcd/donate/donate.html index 1dcd7c7a1..5cf76bead 100644 --- a/templates/gcd/donate/donate.html +++ b/templates/gcd/donate/donate.html @@ -1,5 +1,11 @@ {% extends "gcd/tw_base_view.html" %} {% load static %} +{% block head %} +{{ block.super }} + +{% endblock %} {% block title %} GCD :: Donations {% endblock %} @@ -7,58 +13,34 @@

      Donations

      -The GCD is available for public use within our licensing guidelines. However, operating the website is not free for the Grand Comicbook Database Foundation and your donations are needed to meet our ongoing costs. Please note that neither the GCD nor the Foundation have any paid employees -- 100% of your gift goes directly to making the GCD appear in your browser window and to furthering research in and education on comics and their history. +The GCD is available for public use within our licensing guidelines. However, operating the website is not free for the Grand Comicbook Database Foundation and your donations are needed to meet our ongoing costs. Please note that neither the GCD nor the Foundation have any paid employees -- 100% of your donation goes directly to making the GCD appear in your browser window and to furthering research in and education on comics and their history.

      -Donations are accepted from most countries via PayPal. In order to receive official acknowledgment of your gift, you must include a (non-email) mailing address with your contribution. +Donations are accepted from most countries via PayPal. In order to receive official acknowledgment of your donation, you must include a (non-email) mailing address with your contribution.

      The Grand Comicbook Database Foundation, Inc. is a 501(c)3, non-profit organization, incorporated in the state of Arkansas, USA. Your donations may be tax deductible. Please contact your tax advisor for further information. A donation to the GCD does not constitute a purchase of goods, services or membership. A donation does not entitle the donor to any rights or privileges.

      -Additionally, Google ads enable the GCD to take advantage of the high usage of our site. Your continued use of the GCD will earn us a few cents, especially if you visit the advertisers' sites. -

      -

      The Grand Comicbook Database Foundation Board and the membership of the GCD thank you for your support in whatever form it takes and we hope that we continue to meet your research needs.

      - - - - - - -
      Make a One-time DonationMake a Recurring DonationCancel a Recurring Donation
      -
      - - - - -
      +
      +
      -
      -

      - - - - - -

      -

      - - -

      -
      -
      - - - + +
      +
      From 595ede22565f2d0cd58434ecb055508d57355f0d Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 3 May 2026 16:39:22 +0200 Subject: [PATCH 10/77] center --- templates/gcd/donate/donate.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/gcd/donate/donate.html b/templates/gcd/donate/donate.html index 5cf76bead..f841c3b7a 100644 --- a/templates/gcd/donate/donate.html +++ b/templates/gcd/donate/donate.html @@ -27,7 +27,7 @@

      Donations

      -
      -
      +
      -
      +
      +
      From b840bf6c3962f7b9697248dfd7c689e16792ba88 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Tue, 2 Jun 2026 02:44:00 +0200 Subject: [PATCH 15/77] add auto-approve comment for characterorder-only changes --- apps/oi/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/oi/views.py b/apps/oi/views.py index 561222613..5dd572f16 100644 --- a/apps/oi/views.py +++ b/apps/oi/views.py @@ -617,7 +617,8 @@ def submit(request, id): if not is_changed: changeset.approver = User.objects.get(username='anon') changeset.state = states.REVIEWING - changeset.approve() + changeset.approve('Auto-approved since there are only character ' + 'order changes.') return HttpResponseRedirect(urlresolvers.reverse('editing')) From df57ce4ed11c0a02810aabbc0b76eb22ed5cee78 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Tue, 2 Jun 2026 06:22:59 +0200 Subject: [PATCH 16/77] move JS, column/row order flipped for characters --- static/css/output.css | 34 ++++++++------ templates/gcd/bits/tw_story_characters.html | 4 +- templates/gcd/details/tw_issue.html | 47 +++++++++++++++++++ templates/gcd/details/tw_single_story.html | 51 +-------------------- 4 files changed, 70 insertions(+), 66 deletions(-) diff --git a/static/css/output.css b/static/css/output.css index 23ee3ee9d..25660ee92 100644 --- a/static/css/output.css +++ b/static/css/output.css @@ -1729,6 +1729,10 @@ a:hover { cursor: move; } +.cursor-pointer { + cursor: pointer; +} + .list-inside { list-style-position: inside; } @@ -1824,6 +1828,10 @@ a:hover { gap: 0.25rem; } +.gap-2 { + gap: 0.5rem; +} + .gap-4 { gap: 1rem; } @@ -1981,11 +1989,6 @@ a:hover { border-color: rgb(107 114 128 / var(--tw-border-opacity)); } -.border-orange-400 { - --tw-border-opacity: 1; - border-color: rgb(251 146 60 / var(--tw-border-opacity)); -} - .\!bg-white { --tw-bg-opacity: 1 !important; background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important; @@ -2081,11 +2084,6 @@ a:hover { background-color: rgb(169 48 42 / var(--tw-bg-opacity)); } -.bg-orange-400 { - --tw-bg-opacity: 1; - background-color: rgb(251 146 60 / var(--tw-bg-opacity)); -} - .bg-preview { --tw-bg-opacity: 1; background-color: rgb(255 233 68 / var(--tw-bg-opacity)); @@ -2349,6 +2347,10 @@ a:hover { font-weight: 600; } +.capitalize { + text-transform: capitalize; +} + .italic { font-style: italic; } @@ -2885,6 +2887,10 @@ select { columns: 3; } + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sm\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -3055,6 +3061,10 @@ select { columns: 3; } + .lg\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .lg\:grid-cols-8 { grid-template-columns: repeat(8, minmax(0, 1fr)); } @@ -3282,10 +3292,6 @@ select { padding-right: 0.75rem; } -.\[\&_td\]\:text-center td { - text-align: center; -} - .\[\&_td\]\:align-top td { vertical-align: top; } diff --git a/templates/gcd/bits/tw_story_characters.html b/templates/gcd/bits/tw_story_characters.html index 3970a78bd..749d6f5a0 100644 --- a/templates/gcd/bits/tw_story_characters.html +++ b/templates/gcd/bits/tw_story_characters.html @@ -10,7 +10,7 @@ {% endif %} {% if group.2 %} {% with character_list=group.2 %} -
        +
          {% include 'gcd/bits/tw_character_list.html' %}
        {% endwith %} @@ -21,7 +21,7 @@ {% endif %} {% if character_list %} {% if character_list|length > 1 %} -
          +
            {% else %}
              {% endif %} diff --git a/templates/gcd/details/tw_issue.html b/templates/gcd/details/tw_issue.html index 767c2da5c..b58395b46 100644 --- a/templates/gcd/details/tw_issue.html +++ b/templates/gcd/details/tw_issue.html @@ -476,4 +476,51 @@

              Indexer Notes

              }) + {% endblock %} diff --git a/templates/gcd/details/tw_single_story.html b/templates/gcd/details/tw_single_story.html index d0bddabd2..899a58fbf 100644 --- a/templates/gcd/details/tw_single_story.html +++ b/templates/gcd/details/tw_single_story.html @@ -111,7 +111,7 @@

              {% if has_orders %} -
              +
              Characters:
              Ordered by: @@ -194,52 +194,3 @@

              Indexer Notes

              {% endif %} {% endif %}
              - - From b2e667a95c0502eb50488d02dcb124ab485b4279 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Tue, 2 Jun 2026 06:27:27 +0200 Subject: [PATCH 17/77] spacing --- static/css/output.css | 4 ---- templates/gcd/details/tw_single_story.html | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/static/css/output.css b/static/css/output.css index 25660ee92..99f1a3c9a 100644 --- a/static/css/output.css +++ b/static/css/output.css @@ -1828,10 +1828,6 @@ a:hover { gap: 0.25rem; } -.gap-2 { - gap: 0.5rem; -} - .gap-4 { gap: 1rem; } diff --git a/templates/gcd/details/tw_single_story.html b/templates/gcd/details/tw_single_story.html index 899a58fbf..566421803 100644 --- a/templates/gcd/details/tw_single_story.html +++ b/templates/gcd/details/tw_single_story.html @@ -111,7 +111,7 @@

              {% if has_orders %} -
              +
              Characters:
              Ordered by: From 20036bd85884b71c168e6388328b25e3b6e30973 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Tue, 2 Jun 2026 06:34:00 +0200 Subject: [PATCH 18/77] spacing --- templates/gcd/bits/tw_story_characters.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/gcd/bits/tw_story_characters.html b/templates/gcd/bits/tw_story_characters.html index 749d6f5a0..6c811b741 100644 --- a/templates/gcd/bits/tw_story_characters.html +++ b/templates/gcd/bits/tw_story_characters.html @@ -10,7 +10,7 @@ {% endif %} {% if group.2 %} {% with character_list=group.2 %} -
                +
                  {% include 'gcd/bits/tw_character_list.html' %}
                {% endwith %} @@ -21,7 +21,7 @@ {% endif %} {% if character_list %} {% if character_list|length > 1 %} -
                  +
                    {% else %}
                      {% endif %} From 0c21ea5c7ede8994afeb57df643003df9f8ab0fb Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Tue, 2 Jun 2026 06:47:31 +0200 Subject: [PATCH 19/77] space --- templates/gcd/bits/tw_story_characters.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/gcd/bits/tw_story_characters.html b/templates/gcd/bits/tw_story_characters.html index 6c811b741..869cb7207 100644 --- a/templates/gcd/bits/tw_story_characters.html +++ b/templates/gcd/bits/tw_story_characters.html @@ -10,7 +10,7 @@ {% endif %} {% if group.2 %} {% with character_list=group.2 %} -
                        +
                          {% include 'gcd/bits/tw_character_list.html' %}
                        {% endwith %} @@ -21,7 +21,7 @@ {% endif %} {% if character_list %} {% if character_list|length > 1 %} -
                          +
                            {% else %}
                              {% endif %} From 22202aaa4e51b682ae20610065e10f7b0249e01a Mon Sep 17 00:00:00 2001 From: "J. Hunter Johnson" Date: Wed, 3 Jun 2026 12:01:36 -0400 Subject: [PATCH 20/77] Pin setuptools<81 to resolve pkg_resources ModuleNotFoundError --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 35655d4de..17354b0d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -65,3 +65,4 @@ redis<5.1 rq<2.7 django-rq<3.3 git+https://github.com/GrandComicsDatabase/haystack-rqueue.git +setuptools<81 \ No newline at end of file From 3b10181f4718c7296bcaf3be001c618d0df112fc Mon Sep 17 00:00:00 2001 From: "J. Hunter Johnson" Date: Wed, 3 Jun 2026 12:44:27 -0400 Subject: [PATCH 21/77] Consolidate setuptools version pin to line 43 --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 17354b0d1..60afd81c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ simplejson<3.21 tablib<3.10 unidecode # is used by python-graph-core, which has a successor python-graph, which just was released, check -setuptools +setuptools<81 # REST-API djangorestframework @@ -65,4 +65,3 @@ redis<5.1 rq<2.7 django-rq<3.3 git+https://github.com/GrandComicsDatabase/haystack-rqueue.git -setuptools<81 \ No newline at end of file From f6fa68e6e12fab1859cc430f2a6317cb33fc57d1 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 13 Jun 2026 00:09:56 +0200 Subject: [PATCH 22/77] ranked choice had no border --- apps/voting/templates/voting/topic.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/voting/templates/voting/topic.html b/apps/voting/templates/voting/topic.html index 32e892253..d381c44ca 100644 --- a/apps/voting/templates/voting/topic.html +++ b/apps/voting/templates/voting/topic.html @@ -172,7 +172,7 @@

                              {{ topic.name }}

                              {{ option.text }}
                              {% endif %}
                              - {{ option.name }} + {{ option.name }}
                              {% endif %} {% empty %} From db0cc965bd13373e229d3c88aa608a7d9bd3f77c Mon Sep 17 00:00:00 2001 From: jhunterjActual <47950049+jhunterjActual@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:00:43 -0400 Subject: [PATCH 23/77] Fix Python 3.12+ invalid escape sequence warnings in search_haystack (#714) * Fix Python 3.12+ invalid escape sequence warnings in search_haystack * Refactor line continuations to use parentheses per PEP 8 --- apps/gcd/views/search_haystack.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/gcd/views/search_haystack.py b/apps/gcd/views/search_haystack.py index fc7274ae6..f83b66efe 100644 --- a/apps/gcd/views/search_haystack.py +++ b/apps/gcd/views/search_haystack.py @@ -39,13 +39,15 @@ class GcdNameQuery(AutoQuery): def prepare(self, query_obj): query_string = super(GcdNameQuery, self).prepare(query_obj) query_return = '' - query_string = query_string.replace('[', '\[')\ - .replace(']', '\]')\ - .replace('{', '\{')\ - .replace('}', '\}')\ - .replace(':', '\:')\ - .replace('!', '\!')\ - .replace('/', ' ') + query_string = ( + query_string.replace('[', r'\[') + .replace(']', r'\]') + .replace('{', r'\{') + .replace('}', r'\}') + .replace(':', r'\:') + .replace('!', r'\!') + .replace('/', ' ') + ) if ((query_string[0] == '"' and query_string[-1] == '"') or (query_string[0] == "'" and query_string[-1] == "'")): query_return = query_string @@ -60,8 +62,8 @@ def prepare(self, query_obj): class GcdAutoQuery(AutoQuery): def prepare(self, query_obj): query_string = super(GcdAutoQuery, self).prepare(query_obj) - if '\*' in query_string and len(query_string) > 2: - query_string = query_string.replace('\*', '*') + if r'\*' in query_string and len(query_string) > 2: + query_string = query_string.replace(r'\*', '*') if ' ' in query_string: query_string = '"' + query_string + '"' return query_string From f4a383cb3bb0a68ecbe43c74deb132e73f142d77 Mon Sep 17 00:00:00 2001 From: jhunterjActual <47950049+jhunterjActual@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:49:50 -0400 Subject: [PATCH 24/77] Bugfix/issue 505 relations same object (#719) * Add implementation for current bug report * fix line lengths --- apps/oi/forms/character.py | 8 ++++++++ apps/oi/forms/creator.py | 4 ++++ apps/oi/forms/feature.py | 4 ++++ apps/oi/forms/story.py | 4 ++++ 4 files changed, 20 insertions(+) diff --git a/apps/oi/forms/character.py b/apps/oi/forms/character.py index 85bae2e0d..54fc6b828 100644 --- a/apps/oi/forms/character.py +++ b/apps/oi/forms/character.py @@ -437,6 +437,10 @@ def clean(self): else: cd['relation_type'] = CharacterRelationType.objects.get(id=type) + if 'from_character' in cd and 'to_character' in cd and \ + cd['from_character'] == cd['to_character']: + raise forms.ValidationError( + 'Character A and Character B cannot be the same character.') if cd['from_character'].language != cd['to_character'].language: if cd['relation_type'].id != 1: raise forms.ValidationError( @@ -509,6 +513,10 @@ def clean(self): else: cd['relation_type'] = GroupRelationType.objects.get(id=type) + if 'from_group' in cd and 'to_group' in cd and \ + cd['from_group'] == cd['to_group']: + raise forms.ValidationError( + 'Group A and Group B cannot be the same group.') if cd['from_group'].language != cd['to_group'].language: if cd['relation_type'].id != 1: raise forms.ValidationError( diff --git a/apps/oi/forms/creator.py b/apps/oi/forms/creator.py index 94b0eabd8..d5b7e8d4f 100644 --- a/apps/oi/forms/creator.py +++ b/apps/oi/forms/creator.py @@ -708,6 +708,10 @@ def clean_to_creator(self): def clean(self): cd = self.cleaned_data + if 'from_creator' in cd and 'to_creator' in cd and \ + cd['from_creator'] == cd['to_creator']: + raise forms.ValidationError( + 'Creator A and Creator B cannot be the same creator.') if cd['creator_name'] and not cd['relation_type'].id in [2, 3, 4, 9]: self.add_error( 'creator_name', 'Select a creator name only for owners or ' diff --git a/apps/oi/forms/feature.py b/apps/oi/forms/feature.py index 6fbcc5d80..818889107 100644 --- a/apps/oi/forms/feature.py +++ b/apps/oi/forms/feature.py @@ -277,4 +277,8 @@ def clean(self): cd['relation_type'] = FeatureRelationType.objects.get(id=-type) else: cd['relation_type'] = FeatureRelationType.objects.get(id=type) + if 'from_feature' in cd and 'to_feature' in cd and \ + cd['from_feature'] == cd['to_feature']: + raise forms.ValidationError( + 'Feature A and Feature B cannot be the same feature.') return cd diff --git a/apps/oi/forms/story.py b/apps/oi/forms/story.py index 3e8aaf6dd..74806cced 100644 --- a/apps/oi/forms/story.py +++ b/apps/oi/forms/story.py @@ -1388,4 +1388,8 @@ def clean(self): cd['relation_type'] = StoryArcRelationType.objects.get(id=-type) else: cd['relation_type'] = StoryArcRelationType.objects.get(id=type) + if 'from_story_arc' in cd and 'to_story_arc' in cd and \ + cd['from_story_arc'] == cd['to_story_arc']: + raise forms.ValidationError( + 'Story Arc A and Story Arc B cannot be the same story arc.') return cd From edcf7d0506efb7b61ef3488dd23c2067bd0fa93b Mon Sep 17 00:00:00 2001 From: jhunterjActual <47950049+jhunterjActual@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:53:47 -0400 Subject: [PATCH 25/77] Fix cross series variants (#717) * Fix series issue count for cross-series variants (For #235 and #157) * Fix cross-series variant issue counting - Updated `IssueRevision.series_changed` to accurately detect when a cross-series variant's parent issue moves to a different series, ensuring the routing engine applies stat deltas correctly. - Updated `scripts/reset_stats.py` to recalculate the `Series.issue_count` cache in bulk. The script now mirrors the real-time logic: issues are counted if they are base issues OR variants belonging to a different series than their parent. * Incorporate Gemini Code Assist feedback * update comment blocks * use update_fields as suggested by Jochen --- apps/gcd/models/issue.py | 7 ++++++- apps/oi/models.py | 32 ++++++++++++++++++++++++++++--- scripts/reset_stats.py | 41 ++++++++++++++++++++++++++++++++++------ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/apps/gcd/models/issue.py b/apps/gcd/models/issue.py index dcc46527b..71a0c2a10 100644 --- a/apps/gcd/models/issue.py +++ b/apps/gcd/models/issue.py @@ -553,7 +553,12 @@ def stat_counts(self): 'covers': self.active_covers(stats=True).count(), } - if not self.variant_of_id: + # Ensure the underlying Issue.stat_counts logic matches the bulk reset script! + # Base issues always contribute +1 to their series count. + # Standard variants return 0 to prevent inflating the base issue's series count. + # However, cross-series variants must return +1 to correctly populate the + # isolated target series they reside in. + if not self.variant_of_id or self.series_id != self.variant_of.series_id: counts['series issues'] = 1 if self.series.is_comics_publication: diff --git a/apps/oi/models.py b/apps/oi/models.py index f9b7c6748..9b2a4557e 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -4103,7 +4103,7 @@ def series_changed(self): return ((not self.deleted) and (self.previous_revision is not None) and self.previous_revision.series != self.series) - + @classmethod def fork_variant(cls, issue, changeset, variant_name, variant_cover_revision=None, @@ -4394,12 +4394,12 @@ def _post_save_object(self, changes): if not self.series.has_gallery and \ self.issue.active_covers().count(): self.series.has_gallery = True - self.series.save() + self.series.save(update_fields=['has_gallery']) # old series might have lost gallery after move if old_series.scan_count == 0: old_series.has_gallery = False - old_series.save() + old_series.save(update_fields=['has_gallery']) if self.source.variant_of and self.added: self.source.is_indexed = self.source.variant_of.is_indexed self.source.save() @@ -4487,6 +4487,32 @@ def _handle_dependents(self, changes): story.issue = self.issue story.save() + # --------------------------------------------------------------------- + # Cross-Series Variant Stat Routing + # --------------------------------------------------------------------- + # When a base issue moves to a new series, its variants do not automatically + # follow it. This means a variant left behind in the old series just became + # a "cross-series" variant (which contributes +1 to its series issue_count), + # or vice-versa. Adjust the cached counts of the affected series. + if changes.get('series changed'): + old_series = changes.get('old series') + new_series = self.issue.series + + IssueClass = type(self.issue) + variants = IssueClass.objects.filter(variant_of=self.issue, deleted=False) + + for variant in variants: + # 1. Variant left behind: Goes from Standard -> Cross-Series (+1) + if variant.series == old_series and variant.series != new_series: + variant.series.issue_count += 1 + variant.series.save(update_fields=['issue_count']) + + # 2. Base issue returns: Goes from Cross-Series -> Standard (-1) + elif variant.series != old_series and variant.series == new_series: + if variant.series.issue_count > 0: + variant.series.issue_count -= 1 + variant.series.save(update_fields=['issue_count']) + def extra_forms(self, request): from apps.oi.forms import IssueRevisionFormSet, \ PublisherCodeNumberFormSet diff --git a/scripts/reset_stats.py b/scripts/reset_stats.py index 9072eb004..343f28774 100644 --- a/scripts/reset_stats.py +++ b/scripts/reset_stats.py @@ -1,17 +1,46 @@ -from apps.stats.models import * +from django.db.models import Q, F, Count, Case, When + +from apps.stats.models import CountStats +from apps.gcd.models import Series, Issue +from apps.stddata.models import Language, Country + def main(): - CountStats.objects.all().delete() - CountStats.objects.init_stats() + CountStats.objects.all().delete() + CountStats.objects.init_stats() - for i in Language.objects.all(): - if Series.objects.filter(language=i).exists(): - CountStats.objects.init_stats(language=i) + for i in Language.objects.all(): + if Series.objects.filter(language=i).exists(): + CountStats.objects.init_stats(language=i) for i in Country.objects.all(): if Series.objects.filter(country=i).exists(): CountStats.objects.init_stats(country=i) + # ------------------------------------------------------------------------- + # Rebuild Series.issue_count Caches + # ------------------------------------------------------------------------- + # An issue contributes +1 to a Series count if: + # (a) It is a standard base issue (variant_of is NULL) + # (b) It is a cross-series variant (its series differs from its base issue's series) + # Standard variants within the same series do not count, preventing inflation. + # + # This bulk aggregation MUST remain synchronized with the real-time Python + # logic in `apps.gcd.models.issue.Issue.stat_counts()`. + + from django.db.models import Subquery, OuterRef + from django.db.models.functions import Coalesce + + subquery = Issue.objects.filter( + deleted=False, + series_id=OuterRef('pk') + ).filter( + Q(variant_of__isnull=True) | ~Q(series_id=F('variant_of__series_id')) + ).values('series_id').annotate(c=Count('id')).values('c') + + Series.objects.update(issue_count=Coalesce(Subquery(subquery), 0)) + + def run(): main() From 5942b60481a56eda1f2f8ce3557c8baa9541d7a0 Mon Sep 17 00:00:00 2001 From: jhunterjActual <47950049+jhunterjActual@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:54:34 -0400 Subject: [PATCH 26/77] Bugfix/issue 619 json export series overview (#718) * Fix series issue count for cross-series variants (For #235 and #157) * Fix cross-series variant issue counting - Updated `IssueRevision.series_changed` to accurately detect when a cross-series variant's parent issue moves to a different series, ensuring the routing engine applies stat deltas correctly. - Updated `scripts/reset_stats.py` to recalculate the `Series.issue_count` cache in bulk. The script now mirrors the real-time logic: issues are counted if they are base issues OR variants belonging to a different series than their parent. * Incorporate Gemini Code Assist feedback * update comment blocks * Add api and button for JSON export of cover and main story series overview. * Incorporate Gemini Code Assist feedback --- apps/api/serializers.py | 44 ++++++++++++++++++++++ apps/api/urls.py | 4 ++ apps/api/views.py | 40 +++++++++++++++++++- apps/gcd/views/details.py | 3 ++ apps/oi/models.py | 29 ++++++++------ templates/gcd/search/tw_list_sortable.html | 9 ++++- 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 5bd06be7f..33261b28b 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -135,6 +135,50 @@ class Meta: 'price', 'page_count', 'variant_of', 'series',] +class SeriesOverviewItemSerializer(serializers.ModelSerializer): + """ + Serializes one row of the Cover and Main Story Overview table + (/series//overview/) as structured JSON. + + Fields mirror what the HTML view renders per issue: + - cover_url: direct image link (empty string if no scan exists) + - longest_story: the longest comic-story sequence (type 19) for the issue, + or null if none exists. Variant issues fall back to their parent's story, + matching the HTML table behaviour. + """ + + class Meta: + model = Issue + fields = [ + 'issue_id', 'descriptor', 'number', 'publication_date', + 'on_sale_date', 'key_date', 'cover_url', 'longest_story', + ] + + issue_id = serializers.IntegerField(source='id') + + descriptor = serializers.SerializerMethodField() + + def get_descriptor(self, obj) -> str: + return obj.full_descriptor + + cover_url = serializers.SerializerMethodField() + + def get_cover_url(self, obj) -> str: + covers = getattr(obj, 'active_covers_list', []) + if covers: + cover = covers[0] + return cover.get_base_url() + ("/w400/%d.jpg" % cover.id) + return "" + + longest_story = serializers.SerializerMethodField() + + def get_longest_story(self, obj): + stories = getattr(obj, 'longest_story_list', []) + if stories: + return StorySerializer(stories[0]).data + return None + + class SeriesSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Series diff --git a/apps/api/urls.py b/apps/api/urls.py index 6cf5d7dbe..3a4e3e24c 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -21,6 +21,10 @@ path('series/name//year//', views.SeriesList.as_view()), path('series/name//', views.SeriesList.as_view()), + path('series//overview/', + views.SeriesOverviewList.as_view(), + name='series-overview-list'), path('issue/on_sale_weekly//week//', views.IssuesByOnSaleWeek.as_view()), ] + diff --git a/apps/api/views.py b/apps/api/views.py index 39e8d4e64..b65b3e6ea 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -1,10 +1,15 @@ # -*- coding: utf-8 -*- +from django.db.models import Prefetch +from django.shortcuts import get_object_or_404 + from rest_framework import viewsets, mixins, generics from apps.api.serializers import SeriesSerializer, PublisherSerializer, \ - IssueSerializer, IssueOnlySerializer + IssueSerializer, IssueOnlySerializer, \ + SeriesOverviewItemSerializer -from apps.gcd.models import Series, Publisher, Issue +from apps.gcd.models import Series, Publisher, Issue, Cover from apps.gcd.models.issue import issues_for_iso_week +from apps.gcd.models.story import Story class ReadOnlyModelView(mixins.RetrieveModelMixin, @@ -83,6 +88,37 @@ def get_queryset(self): return qs +class SeriesOverviewList(generics.ListAPIView): + """ + Returns the cover URL and longest comic story for each non-variant issue + in a series, mirroring the data shown at /series//overview/. + """ + serializer_class = SeriesOverviewItemSerializer + + def get_queryset(self): + series_id = self.kwargs['series_id'] + series = get_object_or_404(Series, id=series_id, deleted=False) + return ( + series.active_issues() + .filter(variant_of=None) + .prefetch_related( + Prefetch( + 'cover_set', + queryset=Cover.objects.filter(deleted=False), + to_attr='active_covers_list' + ), + Prefetch( + 'story_set', + queryset=Story.objects.filter(type_id=19, deleted=False) + .order_by('-page_count', 'sequence_number') + .prefetch_related('credits__creator__creator'), + to_attr='longest_story_list' + ) + ) + .select_related('series__publisher') + ) + + class PublisherViewSet(viewsets.ReadOnlyModelViewSet): """ API endpoint that allows Publishers to be viewed. diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index 3db7fd92c..69ab805bd 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -2292,6 +2292,9 @@ def series_overview(request, series_id): 'item_name': 'issue', 'plural_suffix': 's', 'heading': heading, + 'json_download_url': urlresolvers.reverse( + 'series-overview-list', kwargs={'series_id': series_id} + ) + '?format=json', } template = 'gcd/search/tw_list_sortable.html' table = CoverIssueStoryTable(issues, diff --git a/apps/oi/models.py b/apps/oi/models.py index 9b2a4557e..1b43b21ce 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -4487,32 +4487,39 @@ def _handle_dependents(self, changes): story.issue = self.issue story.save() - # --------------------------------------------------------------------- + # ------------------------------------------------------------------- # Cross-Series Variant Stat Routing - # --------------------------------------------------------------------- - # When a base issue moves to a new series, its variants do not automatically - # follow it. This means a variant left behind in the old series just became - # a "cross-series" variant (which contributes +1 to its series issue_count), - # or vice-versa. Adjust the cached counts of the affected series. + # ------------------------------------------------------------------- + # When a base issue moves to a new series, its variants do not + # automatically follow it. This means a variant left behind in the + # old series just became a "cross-series" variant (which contributes + # +1 to its series issue_count), or vice-versa. Adjust the cached + # counts of the affected series. if changes.get('series changed'): old_series = changes.get('old series') new_series = self.issue.series IssueClass = type(self.issue) - variants = IssueClass.objects.filter(variant_of=self.issue, deleted=False) + variants = IssueClass.objects.filter(variant_of=self.issue, + deleted=False) for variant in variants: - # 1. Variant left behind: Goes from Standard -> Cross-Series (+1) - if variant.series == old_series and variant.series != new_series: + # 1. Variant left behind: + # Goes from Standard -> Cross-Series (+1) + if variant.series == old_series and \ + variant.series != new_series: variant.series.issue_count += 1 variant.series.save(update_fields=['issue_count']) - # 2. Base issue returns: Goes from Cross-Series -> Standard (-1) - elif variant.series != old_series and variant.series == new_series: + # 2. Base issue returns: + # Goes from Cross-Series -> Standard (-1) + elif variant.series != old_series and \ + variant.series == new_series: if variant.series.issue_count > 0: variant.series.issue_count -= 1 variant.series.save(update_fields=['issue_count']) + def extra_forms(self, request): from apps.oi.forms import IssueRevisionFormSet, \ PublisherCodeNumberFormSet diff --git a/templates/gcd/search/tw_list_sortable.html b/templates/gcd/search/tw_list_sortable.html index 478918524..4852e3a00 100644 --- a/templates/gcd/search/tw_list_sortable.html +++ b/templates/gcd/search/tw_list_sortable.html @@ -19,13 +19,20 @@ {% endblock %} {% block view_body %} -
                              +
                              {{ result_disclaimer }} {% if not result_disclaimer and not filter_form %}   {% endif %}
                              +{% if json_download_url %} + +{% endif %} {% if list_grid %}
                              Date: Fri, 26 Jun 2026 14:53:42 -0400 Subject: [PATCH 27/77] filter the character names when a group is selected (#721) * filter the character names when a group is selected * incorporate Gemini Code Assist feedback --- apps/oi/forms/story.py | 2 +- apps/select/views.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/oi/forms/story.py b/apps/oi/forms/story.py index 74806cced..9ce3879a7 100644 --- a/apps/oi/forms/story.py +++ b/apps/oi/forms/story.py @@ -481,7 +481,7 @@ def __init__(self, *args, **kwargs): character = forms.ModelChoiceField( queryset=CharacterNameDetail.objects.all(), widget=autocomplete.ModelSelect2(url='character_name_autocomplete', - forward=['language_code'], + forward=['language_code', 'group_name'], attrs={'class': 'w-full lg:w-4/5', 'style': 'width: 80%'}), required=True, diff --git a/apps/select/views.py b/apps/select/views.py index d8334ae9a..8df63672b 100644 --- a/apps/select/views.py +++ b/apps/select/views.py @@ -711,8 +711,13 @@ def get_queryset(self): qs = qs.filter(character__language__code__in=[language, 'zxx']) if group_name: - qs = qs.filter( - character__memberships__group__group_names=group_name).distinct() + if not isinstance(group_name, list): + group_name = [group_name] + group_name = [g for g in group_name if g] + if group_name: + qs = qs.filter( + character__memberships__group__group_names__in=group_name + ).distinct() qs = _filter_and_sort(qs, self.q, parent_disambiguation='character', chrono_sort='character__year_first_published') From 966b1e46a74ba7ad979d7ac377fa71b3216afb8d Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Fri, 26 Jun 2026 21:46:30 +0200 Subject: [PATCH 28/77] move JSON button, order of numbered lists --- static/css/input.css | 2 +- static/css/output.css | 26 +++++++--------------- templates/gcd/bits/tw_sortable_table.html | 21 +++++++++++++---- templates/gcd/search/tw_list_sortable.html | 7 ------ 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/static/css/input.css b/static/css/input.css index 77a4f3214..a0f21aabf 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -169,7 +169,7 @@ html { @layer components { .object-page-numbered-list { - @apply ms-2 list-decimal sm:columns-2 list-outside ps-4; + @apply ms-2 list-decimal grid sm:grid-cols-2 ps-4; li { @apply pr-4; diff --git a/static/css/output.css b/static/css/output.css index 23ee3ee9d..0da8da500 100644 --- a/static/css/output.css +++ b/static/css/output.css @@ -1184,16 +1184,16 @@ a:hover { } .object-page-numbered-list { + /* @apply ms-2 list-decimal sm:columns-2 list-outside ps-4; */ margin-inline-start: 0.5rem; - list-style-position: outside; + display: grid; list-style-type: decimal; - padding-inline-start: 1rem; + padding-inline-start: 1rem } @media (min-width: 640px) { .object-page-numbered-list { - -moz-columns: 2; - columns: 2; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -1981,11 +1981,6 @@ a:hover { border-color: rgb(107 114 128 / var(--tw-border-opacity)); } -.border-orange-400 { - --tw-border-opacity: 1; - border-color: rgb(251 146 60 / var(--tw-border-opacity)); -} - .\!bg-white { --tw-bg-opacity: 1 !important; background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important; @@ -2081,11 +2076,6 @@ a:hover { background-color: rgb(169 48 42 / var(--tw-bg-opacity)); } -.bg-orange-400 { - --tw-bg-opacity: 1; - background-color: rgb(251 146 60 / var(--tw-bg-opacity)); -} - .bg-preview { --tw-bg-opacity: 1; background-color: rgb(255 233 68 / var(--tw-bg-opacity)); @@ -2349,6 +2339,10 @@ a:hover { font-weight: 600; } +.capitalize { + text-transform: capitalize; +} + .italic { font-style: italic; } @@ -3282,10 +3276,6 @@ select { padding-right: 0.75rem; } -.\[\&_td\]\:text-center td { - text-align: center; -} - .\[\&_td\]\:align-top td { vertical-align: top; } diff --git a/templates/gcd/bits/tw_sortable_table.html b/templates/gcd/bits/tw_sortable_table.html index 7adba181f..a2981c68d 100644 --- a/templates/gcd/bits/tw_sortable_table.html +++ b/templates/gcd/bits/tw_sortable_table.html @@ -73,10 +73,23 @@ {% endblock table.tfoot %}

      {% endblock table %} -{% if not table.no_export %} -

      Download the shown data as .csv or .json.
      - {% if not table.no_raw_export %} -Download the raw database fields for the shown objects as .csv or .json. Technical note, this download does include only the IDs of foreign keys and contains no many-to-many relationships.

      +{% if table.context.json_download_url %} +
      + + {% if not request.user.is_authenticated %} + Login required to download the JSON file. + {% endif %} +
      +{% else %} + {% if not table.no_export %} +

      Download the shown data as .csv or .json.
      + {% if not table.no_raw_export %} + Download the raw database fields for the shown objects as .csv or .json. Technical note, this download does include only the IDs of foreign keys and contains no many-to-many relationships.

      + {% endif %} {% endif %} {% endif %} diff --git a/templates/gcd/search/tw_list_sortable.html b/templates/gcd/search/tw_list_sortable.html index 4852e3a00..56fb68103 100644 --- a/templates/gcd/search/tw_list_sortable.html +++ b/templates/gcd/search/tw_list_sortable.html @@ -26,13 +26,6 @@   {% endif %} -{% if json_download_url %} - -{% endif %} {% if list_grid %}
      Date: Sat, 27 Jun 2026 13:49:41 +0200 Subject: [PATCH 29/77] prevent click on feature logo --- apps/gcd/templatetags/display.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/gcd/templatetags/display.py b/apps/gcd/templatetags/display.py index e6032b00d..58c03177f 100644 --- a/apps/gcd/templatetags/display.py +++ b/apps/gcd/templatetags/display.py @@ -57,9 +57,10 @@ def absolute_url(item, popup=None, descriptor=''): if popup and not settings.FAKE_IMAGES: image_link = '' \ + ' group-hover:opacity-100 pointer-events-none"> '\ + '' \ % popup.thumbnail.url - return mark_safe('%s%s' % + return mark_safe('%s%s' % (item.get_absolute_url(), descriptor, image_link)) else: return mark_safe('%s' % From af7f9de549ab27f02e678e44e62de994618a68a9 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 27 Jun 2026 13:54:28 +0200 Subject: [PATCH 30/77] handle edga case --- apps/gcd/models/creator.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/gcd/models/creator.py b/apps/gcd/models/creator.py index a3ee2510f..2f57158b3 100644 --- a/apps/gcd/models/creator.py +++ b/apps/gcd/models/creator.py @@ -171,6 +171,11 @@ def display_credit(self, credit, url=True, compare=False, search=False, # point to both, creator and house name as_name = self.creator_relation.get().to_creator\ .active_names().get(is_official_name=True) + if as_name != self.name: + if as_name.creator.active_names().filter( + name=self.name): + as_name = as_name.creator.active_names().filter( + name=self.name)[0] else: # handles case of house name with different spellings, # show only the name as entered, not also official name From a64747f58a694a4753ae2a742cdff007666beea8 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 27 Jun 2026 13:56:39 +0200 Subject: [PATCH 31/77] two columns only when needed --- templates/gcd/details/tw_single_story.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/gcd/details/tw_single_story.html b/templates/gcd/details/tw_single_story.html index 566421803..c49cdc1fb 100644 --- a/templates/gcd/details/tw_single_story.html +++ b/templates/gcd/details/tw_single_story.html @@ -94,7 +94,7 @@

      -
        +
          {% endif %}
        • {{ story|show_credit_tw_inline:"genre" }}
        • {{ story|show_credit_tw_inline:"job_number" }}
        From 22017972688d1261d79c13fcbf57585fb813fec1 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 27 Jun 2026 14:06:48 +0200 Subject: [PATCH 32/77] replace by brand_emblem m2m --- apps/gcd/views/details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index 69ab805bd..2f5c84c6f 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -1775,7 +1775,7 @@ def indicia_publisher(request, indicia_publisher_id): def show_indicia_publisher(request, indicia_publisher, preview=False): indicia_publisher_issues = indicia_publisher.active_issues()\ .prefetch_related('series', - 'brand') + 'brand_emblem',) image_tag, selected_issue = _get_random_cover_image(request, indicia_publisher, 'indicia_publisher', From bb3cd3d0f21c6e72ae8cb8332cf8c39c95ea9a1f Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 27 Jun 2026 14:37:12 +0200 Subject: [PATCH 33/77] replace by brand_emblem m2m --- apps/gcd/views/details.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index 2f5c84c6f..c257f81b9 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -1394,7 +1394,8 @@ def show_publisher_issues(request, publisher_id): publisher = get_gcd_object(Publisher, publisher_id) issues = Issue.objects.filter(series__publisher=publisher, deleted=False).order_by( - 'series__sort_name', 'sort_code').prefetch_related('series', 'brand', + 'series__sort_name', 'sort_code').prefetch_related('series', + 'brand_emblem', 'indicia_publisher') context = {'heading': 'of publisher %s' % publisher, 'item_name': 'issue', From e42bebb6ca10e647a2fe6c66e34ca0b98deca6bd Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 27 Jun 2026 14:45:00 +0200 Subject: [PATCH 34/77] add API to my --- urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/urls.py b/urls.py index 2f494c0d6..2e017f8b0 100644 --- a/urls.py +++ b/urls.py @@ -102,6 +102,7 @@ def get_context_data(self, **kwargs): [path('', include('apps.stats.urls'))] + \ [path('', include('apps.indexer.urls'))] + \ [path('', include('apps.select.urls'))] + \ + [path('api/', include('apps.api.urls'))] + \ read_only_patterns else: urlpatterns = basic_patterns + \ From 77c30191ad8343ac6da2d4c432713485fdbaffa7 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 28 Jun 2026 10:52:10 +0200 Subject: [PATCH 35/77] fix deprecated brand --- apps/gcd/views/search.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/gcd/views/search.py b/apps/gcd/views/search.py index dea74042b..0609822c0 100644 --- a/apps/gcd/views/search.py +++ b/apps/gcd/views/search.py @@ -2410,9 +2410,9 @@ def compute_prefix(target, current): if target == 'publisher': return 'brandgroup__' if target == 'issue': - return 'brand__group__' + return 'brand_emblem__group__' if target in ('series', 'sequence', 'feature', 'cover', 'issue_cover'): - return 'issue__brand__group__' + return 'issue__brand_emblem__group__' elif current == 'brand_emblem': if target == 'indicia_publisher': raise SearchError("Cannot search for Indicia Publishers by " @@ -2565,8 +2565,6 @@ def compute_order(data): elif target in ('sequence', 'feature', 'cover', 'issue_cover'): if order == 'publisher': terms.append('issue__series__publisher') - elif order == 'brand': - terms.append('issue__brand') elif order == 'indicia_publisher': terms.append('issue__indicia_publisher') elif order == 'series': From a7e4228ba6c988c14b53deae0445d50cdd81ff8c Mon Sep 17 00:00:00 2001 From: Josef Andersson Date: Thu, 9 Jul 2026 23:35:42 +0200 Subject: [PATCH 36/77] Fix brand emblem (#723) * fix: brand emblem regressions from the m2m migration The fork_variant exclude list still named the removed brand field, so forked variants started inheriting brand emblems. Group issue counts were not adjusted on adds and deletes, because the brand_emblem/group special case ignored the added and deleted state. And when two emblems shared a group, that group's issue count was counted twice, both in the incremental adjustment and in active_issues, so dedupe the groups and make the query distinct. Signed-off-by: Josef Andersson * test: update issue revision tests for the brand emblem changes Repairs the fixtures and assertions so the tests covering the fixes run: test_fork_variant_for_cover_no_reserve checks a forked variant starts without brand emblems, and test_delete_issue checks that brand group issue counts are decremented. Adds a second-emblem-same-group fixture and a test asserting the group is counted once. The remaining suite repairs are in a follow-up. Signed-off-by: Josef Andersson --------- Signed-off-by: Josef Andersson --- apps/gcd/models/publisher.py | 2 +- apps/oi/models.py | 25 ++-- apps/oi/tests/conftest.py | 25 +++- apps/oi/tests/db/test_issue_revision.py | 147 +++++++++++++----------- 4 files changed, 120 insertions(+), 79 deletions(-) diff --git a/apps/gcd/models/publisher.py b/apps/gcd/models/publisher.py index 3615f8d7c..6f43f8401 100644 --- a/apps/gcd/models/publisher.py +++ b/apps/gcd/models/publisher.py @@ -256,7 +256,7 @@ def active_issues(self): from apps.gcd.models.issue import Issue emblems_id = list(self.active_emblems().values_list('id', flat=True)) return Issue.objects.filter(brand_emblem__in=emblems_id, - deleted=False) + deleted=False).distinct() def stat_counts(self): """ diff --git a/apps/oi/models.py b/apps/oi/models.py index 1fbaf5d9d..d792db8dd 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -1737,15 +1737,20 @@ def _check_major_change(self, attrs): name = 'publisher' if attrs[-1] == 'parent' else attrs[-1] if attrs == ('brand_emblem', 'group'): - # Handle the special case of brand_emblem and group. - # If we have more m2m-related objects that need stats - # updating, we may need a more general mechanism. - old_value = [] - for brand_emblem in old.brand_emblem.all() if old else []: - old_value.extend(brand_emblem.group.all()) - new_value = [] - for brand_emblem in new.brand_emblem.all(): - new_value.extend(brand_emblem.group.all()) + # Special case: a two-hop m2m path that RelPath below cannot + # follow. If more m2m-related objects need stats updating, + # we may need a more general mechanism. + def brand_groups(issue_or_revision): + groups = set() + for emblem in issue_or_revision.brand_emblem.prefetch_related( + 'group'): + groups.update(emblem.group.all()) + return groups + + # As in the generic path: an add has no old value and a + # delete has no new value. + old_value = brand_groups(old) if old and not self.added else set() + new_value = brand_groups(new) if not self.deleted else set() multi_valued = True boolean_valued = False else: @@ -4123,7 +4128,7 @@ def fork_variant(cls, issue, changeset, 'on_sale_date', 'on_sale_date_uncertain', 'price', - 'brand', + 'brand_emblem', 'no_brand', 'isbn', 'no_isbn', diff --git a/apps/oi/tests/conftest.py b/apps/oi/tests/conftest.py index eaa9ddf9e..fc331a01b 100644 --- a/apps/oi/tests/conftest.py +++ b/apps/oi/tests/conftest.py @@ -366,6 +366,23 @@ def any_added_brand(any_added_brand_rev): return any_added_brand_rev.brand +@pytest.fixture +def second_brand_same_group(brand_add_values, any_indexer, + any_added_brand_group1): + changeset = Changeset(state=states.OPEN, indexer=any_indexer, + change_type=CTYPES['publisher']) + changeset.save() + values = dict(brand_add_values) + values['name'] = 'Second Test Brand' + br = BrandRevision(changeset=changeset, **values) + br.save() + br.group.add(any_added_brand_group1) + br.commit_to_display() + changeset.state = states.APPROVED + changeset.save() + return br.brand + + @pytest.fixture def brand_use_add_values(any_added_publisher, any_added_brand): return { @@ -522,7 +539,7 @@ def issue_add_values(any_adding_changeset, any_country, any_language, 'series': series_rev.series, 'indicia_publisher': any_added_indicia_publisher, 'indicia_printer': any_added_indicia_printer, - 'brand': any_added_brand, + 'brand_emblem': any_added_brand, 'publication_date': 'January 1947', 'key_date': '1947-01-00', 'year_on_sale': 1946, @@ -544,9 +561,11 @@ def issue_add_values(any_adding_changeset, any_country, any_language, @pytest.fixture def any_added_issue_rev(any_adding_changeset, issue_add_values): indicia_printer = issue_add_values.pop('indicia_printer') + brand = issue_add_values.pop('brand_emblem') rev = IssueRevision(changeset=any_adding_changeset, **issue_add_values) rev.save() rev.indicia_printer.set([indicia_printer,]) + rev.brand_emblem.set([brand,]) return rev @@ -565,15 +584,17 @@ def variant_add_values(any_added_issue): 'variant_name': 'varied variant', 'series': any_added_issue.series, 'indicia_publisher': any_added_issue.indicia_publisher, - 'brand': any_added_issue.brand, + 'brand_emblem': any_added_issue.brand_emblem.get(), } @pytest.fixture def any_added_variant_rev(any_variant_adding_changeset, variant_add_values): + brand = variant_add_values.pop('brand_emblem') rev = IssueRevision(changeset=any_variant_adding_changeset, **variant_add_values) rev.save() + rev.brand_emblem.set([brand,]) return IssueRevision.objects.get(pk=rev.pk) diff --git a/apps/oi/tests/db/test_issue_revision.py b/apps/oi/tests/db/test_issue_revision.py index cf38b3549..370b9938c 100644 --- a/apps/oi/tests/db/test_issue_revision.py +++ b/apps/oi/tests/db/test_issue_revision.py @@ -17,7 +17,6 @@ 'day_on_sale': None, 'on_sale_date_uncertain': False, 'price': '', - 'brand': None, 'no_brand': False, 'isbn': '', 'no_isbn': False, @@ -106,17 +105,21 @@ def test_commit_added_revision(any_added_issue_rev, issue_add_values, old_series_issue_count = rev.series.issue_count old_ind_pub_issue_count = rev.indicia_publisher.issue_count - old_brand_issue_count = rev.brand.issue_count + brand = rev.brand_emblem.get() + old_brand_issue_count = brand.issue_count old_publisher_issue_count = rev.series.publisher.issue_count old_brand_group_counts = {group.pk: group.issue_count - for group in rev.brand.group.all()} + for group in brand.group.all()} with mock.patch(UPDATE_ALL) as updater: rev.commit_to_display() - updater.has_calls([ + # An add pushes the new issue's own stat_counts to the global stats + # (nothing to remove first), so cross-check against stat_counts() + # rather than a hand-copied dict. + updater.assert_has_calls([ mock.call({}, language=None, country=None, negate=True), - mock.call({'issues': 1}, + mock.call(rev.issue.stat_counts(), language=rev.series.language, country=rev.series.country), ]) @@ -145,14 +148,14 @@ def test_commit_added_revision(any_added_issue_rev, issue_add_values, rev.issue.series.refresh_from_db() rev.issue.series.publisher.refresh_from_db() rev.issue.indicia_publisher.refresh_from_db() - rev.issue.brand.refresh_from_db() + issue_brand = rev.issue.brand_emblem.get() s = rev.issue.series assert s.issue_count == old_series_issue_count + 1 assert s.publisher.issue_count == old_publisher_issue_count + 1 - assert rev.issue.brand.issue_count == old_brand_issue_count + 1 + assert issue_brand.issue_count == old_brand_issue_count + 1 assert { - group.pk: group.issue_count for group in rev.issue.brand.group.all() + group.pk: group.issue_count for group in issue_brand.group.all() } == {k: v + 1 for k, v in old_brand_group_counts.items()} assert rev.issue.indicia_publisher.issue_count == \ old_ind_pub_issue_count + 1 @@ -166,17 +169,18 @@ def test_commit_variant_added_revision(any_added_variant_rev, old_series_issue_count = rev.series.issue_count old_ind_pub_issue_count = rev.indicia_publisher.issue_count - old_brand_issue_count = rev.brand.issue_count + brand = rev.brand_emblem.get() + old_brand_issue_count = brand.issue_count old_brand_group_counts = {group.pk: group.issue_count - for group in rev.brand.group.all()} + for group in brand.group.all()} old_publisher_issue_count = rev.series.publisher.issue_count with mock.patch(UPDATE_ALL) as updater: rev.commit_to_display() - updater.has_calls([ + updater.assert_has_calls([ mock.call({}, language=None, country=None, negate=True), - mock.call({'variant issues': 1}, + mock.call(rev.issue.stat_counts(), language=rev.series.language, country=rev.series.country), ]) @@ -199,9 +203,10 @@ def test_commit_variant_added_revision(any_added_variant_rev, # Variants do not affect the issue counts. assert s.issue_count == old_series_issue_count assert s.publisher.issue_count == old_publisher_issue_count - assert rev.issue.brand.issue_count == old_brand_issue_count + issue_brand = rev.issue.brand_emblem.get() + assert issue_brand.issue_count == old_brand_issue_count assert {group.pk: group.issue_count - for group in rev.issue.brand.group.all()} == old_brand_group_counts + for group in issue_brand.group.all()} == old_brand_group_counts assert rev.issue.indicia_publisher.issue_count == old_ind_pub_issue_count @@ -241,6 +246,28 @@ def test_create_variant_edit_revision(any_added_variant, variant_add_values, assert rev.date_inferred is False +@pytest.mark.django_db +def test_add_issue_two_emblems_same_group_counts_once( + any_adding_changeset, issue_add_values, any_added_brand, + second_brand_same_group, any_added_brand_group1): + group = any_added_brand_group1 + group.refresh_from_db() + old_group_count = group.issue_count + + issue_add_values.pop('brand_emblem') + indicia_printer = issue_add_values.pop('indicia_printer') + rev = IssueRevision(changeset=any_adding_changeset, **issue_add_values) + rev.save() + rev.indicia_printer.set([indicia_printer]) + rev.brand_emblem.set([any_added_brand, second_brand_same_group]) + + rev.commit_to_display() + + group.refresh_from_db() + assert group.issue_count == old_group_count + 1 + assert group.issue_count == group.active_issues().count() + + @pytest.mark.django_db def test_delete_issue(any_added_issue, any_deleting_changeset, any_added_issue_rev): @@ -257,9 +284,10 @@ def test_delete_issue(any_added_issue, any_deleting_changeset, old_series_issue_count = rev.series.issue_count old_ind_pub_issue_count = rev.indicia_publisher.issue_count - old_brand_issue_count = rev.brand.issue_count + brand = rev.brand_emblem.get() + old_brand_issue_count = brand.issue_count old_brand_group_counts = {group.pk: group.issue_count - for group in rev.brand.group.all()} + for group in brand.group.all()} old_publisher_issue_count = rev.series.publisher.issue_count rev.commit_to_display() @@ -276,9 +304,10 @@ def test_delete_issue(any_added_issue, any_deleting_changeset, s = rev.issue.series assert s.issue_count == old_series_issue_count - 1 assert s.publisher.issue_count == old_publisher_issue_count - 1 - assert rev.issue.brand.issue_count == old_brand_issue_count - 1 + issue_brand = rev.issue.brand_emblem.get() + assert issue_brand.issue_count == old_brand_issue_count - 1 assert { - group.pk: group.issue_count for group in rev.issue.brand.group.all() + group.pk: group.issue_count for group in issue_brand.group.all() } == {k: v - 1 for k, v in old_brand_group_counts.items()} assert rev.issue.indicia_publisher.issue_count == \ old_ind_pub_issue_count - 1 @@ -300,9 +329,10 @@ def test_delete_variant(any_added_variant, any_deleting_changeset, old_series_issue_count = rev.series.issue_count old_ind_pub_issue_count = rev.indicia_publisher.issue_count - old_brand_issue_count = rev.brand.issue_count + brand = rev.brand_emblem.get() + old_brand_issue_count = brand.issue_count old_brand_group_counts = {group.pk: group.issue_count - for group in rev.brand.group.all()} + for group in brand.group.all()} old_publisher_issue_count = rev.series.publisher.issue_count rev.commit_to_display() @@ -320,9 +350,10 @@ def test_delete_variant(any_added_variant, any_deleting_changeset, # Variants do not affect issue counts. assert s.issue_count == old_series_issue_count assert s.publisher.issue_count == old_publisher_issue_count - assert rev.issue.brand.issue_count == old_brand_issue_count + issue_brand = rev.issue.brand_emblem.get() + assert issue_brand.issue_count == old_brand_issue_count assert {group.pk: group.issue_count - for group in rev.issue.brand.group.all()} == old_brand_group_counts + for group in issue_brand.group.all()} == old_brand_group_counts assert rev.issue.indicia_publisher.issue_count == old_ind_pub_issue_count @@ -341,12 +372,10 @@ def test_noncomics_counts(any_added_series_rev, with mock.patch(UPDATE_ALL) as updater: s_rev.commit_to_display() - updater.has_calls([ - mock.call({}, language=None, country=None, negate=True), - mock.call({'series': 1}, - language=s_rev.series.language, country=s_rev.series.country), - ]) - + # Each commit makes two global-stats calls (remove old, apply new). + # The exact dicts are characterization-only and belong to the + # apps/stats tests; this test's value is the per-object count + # assertions below, so here we only check that shape. assert updater.call_count == 2 series = Series.objects.get(pk=s_rev.series.pk) @@ -354,16 +383,19 @@ def test_noncomics_counts(any_added_series_rev, issue_add_values['series'] = series indicia_printer = issue_add_values.pop('indicia_printer') + brand = issue_add_values.pop('brand_emblem') i_rev = IssueRevision(changeset=any_adding_changeset, **issue_add_values) i_rev.save() i_rev.indicia_printer.set([indicia_printer,]) + i_rev.brand_emblem.set([brand,]) i_rev = IssueRevision.objects.get(pk=i_rev.pk) old_series_issue_count = i_rev.series.issue_count old_ind_pub_issue_count = i_rev.indicia_publisher.issue_count - old_brand_issue_count = i_rev.brand.issue_count + brand = i_rev.brand_emblem.get() + old_brand_issue_count = brand.issue_count old_brand_group_counts = {group.pk: group.issue_count - for group in i_rev.brand.group.all()} + for group in brand.group.all()} old_publisher_issue_count = i_rev.series.publisher.issue_count with mock.patch(UPDATE_ALL) as updater: @@ -372,12 +404,6 @@ def test_noncomics_counts(any_added_series_rev, i_rev.changeset.state = states.APPROVED i_rev.changeset.save() - updater.has_calls([ - mock.call({}, language=None, country=None, negate=True), - mock.call({'stories': 0, 'covers': 0}, - language=i_rev.series.language, country=i_rev.series.country), - ]) - assert updater.call_count == 2 i_rev = IssueRevision.objects.get(pk=i_rev.pk) @@ -385,9 +411,10 @@ def test_noncomics_counts(any_added_series_rev, # Non-comics issues do not affect the issue counts EXCEPT on the series. assert s.issue_count == old_series_issue_count + 1 assert s.publisher.issue_count == old_publisher_issue_count - assert i_rev.issue.brand.issue_count == old_brand_issue_count + issue_brand = i_rev.issue.brand_emblem.get() + assert issue_brand.issue_count == old_brand_issue_count assert { - group.pk: group.issue_count for group in i_rev.issue.brand.group.all() + group.pk: group.issue_count for group in issue_brand.group.all() } == old_brand_group_counts assert i_rev.issue.indicia_publisher.issue_count == old_ind_pub_issue_count @@ -397,16 +424,17 @@ def test_noncomics_counts(any_added_series_rev, variant_of=i_rev.issue, variant_name='alternate cover', series=i_rev.series, - brand=i_rev.brand, indicia_publisher=i_rev.indicia_publisher) v_rev.save() + v_rev.brand_emblem.set([brand,]) v_rev = IssueRevision.objects.get(pk=v_rev.pk) old_series_issue_count = v_rev.series.issue_count old_ind_pub_issue_count = v_rev.indicia_publisher.issue_count - old_brand_issue_count = v_rev.brand.issue_count + brand = v_rev.brand_emblem.get() + old_brand_issue_count = brand.issue_count old_brand_group_counts = {group.pk: group.issue_count - for group in v_rev.brand.group.all()} + for group in brand.group.all()} old_publisher_issue_count = v_rev.series.publisher.issue_count with mock.patch(UPDATE_ALL) as updater: @@ -415,13 +443,6 @@ def test_noncomics_counts(any_added_series_rev, v_rev.changeset.state = states.APPROVED v_rev.changeset.save() - updater.has_calls([ - mock.call({'stories': 0, 'covers': 0}, language=None, country=None, - negate=True), - mock.call({'stories': 0, 'covers': 0}, - language=i_rev.series.language, country=i_rev.series.country), - ]) - assert updater.call_count == 2 v_rev = IssueRevision.objects.get(pk=v_rev.pk) @@ -429,10 +450,11 @@ def test_noncomics_counts(any_added_series_rev, # Non-comics variants do not affect the issue counts on anything. assert s.issue_count == old_series_issue_count assert s.publisher.issue_count == old_publisher_issue_count - assert v_rev.issue.brand.issue_count == old_brand_issue_count + issue_brand = v_rev.issue.brand_emblem.get() + assert issue_brand.issue_count == old_brand_issue_count assert { group.pk: group.issue_count - for group in v_rev.issue.brand.group.all() + for group in issue_brand.group.all() } == old_brand_group_counts assert v_rev.issue.indicia_publisher.issue_count == old_ind_pub_issue_count @@ -449,21 +471,16 @@ def test_noncomics_counts(any_added_series_rev, del_v_rev = IssueRevision.objects.get(pk=del_v_rev.pk) - updater.has_calls([ - mock.call({'stories': 0, 'covers': 0}, language=None, country=None, - negate=True), - mock.call({'stories': 0, 'covers': 0}, - language=i_rev.series.language, country=i_rev.series.country), - ]) assert updater.call_count == 2 s = Series.objects.get(pk=del_v_rev.series.pk) i = Issue.objects.get(pk=del_v_rev.issue.pk) assert s.issue_count == old_series_issue_count assert s.publisher.issue_count == old_publisher_issue_count - assert i.brand.issue_count == old_brand_issue_count + issue_brand = i.brand_emblem.get() + assert issue_brand.issue_count == old_brand_issue_count assert {group.pk: group.issue_count - for group in i.brand.group.all()} == old_brand_group_counts + for group in issue_brand.group.all()} == old_brand_group_counts assert i.indicia_publisher.issue_count == old_ind_pub_issue_count # Finally, delete the base issue, check for only series.issue_count @@ -483,12 +500,6 @@ def test_noncomics_counts(any_added_series_rev, del_i_rev.commit_to_display() del_i_rev = IssueRevision.objects.get(pk=del_v_rev.pk) - updater.has_calls([ - mock.call({'stories': 0, 'covers': 0}, language=None, country=None, - negate=True), - mock.call({'stories': 0, 'covers': 0}, - language=i_rev.series.language, country=i_rev.series.country), - ]) assert updater.call_count == 2 s = Series.objects.get(pk=del_i_rev.series.pk) i = Issue.objects.get(pk=del_i_rev.issue.pk) @@ -496,9 +507,10 @@ def test_noncomics_counts(any_added_series_rev, # Series issue counts are adjusted even for non comics. assert s.issue_count == old_series_issue_count - 1 assert s.publisher.issue_count == old_publisher_issue_count - assert i.brand.issue_count == old_brand_issue_count + issue_brand = i.brand_emblem.get() + assert issue_brand.issue_count == old_brand_issue_count assert {group.pk: group.issue_count - for group in i.brand.group.all()} == old_brand_group_counts + for group in issue_brand.group.all()} == old_brand_group_counts assert i.indicia_publisher.issue_count == old_ind_pub_issue_count @@ -527,6 +539,9 @@ def test_fork_variant_for_cover_no_reserve(any_added_issue, elif name == 'indicia_printer': indicia_printers = list(issue_rev.indicia_printer.order_by('id')) assert indicia_printers == [any_added_indicia_printer] + elif name == 'brand_emblem': + # Excluded from forking, so the variant starts without brands. + assert issue_rev.brand_emblem.count() == 0 elif name in EXCLUDED_FORK_FIELDS: assert getattr(issue_rev, name) == EXCLUDED_FORK_FIELDS[name] else: From 0c653293b75fc2440223f9f3451ba8d86d91399f Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 12 Jul 2026 11:11:13 +0200 Subject: [PATCH 37/77] add dependency checks for StoryXRevision --- apps/gcd/models/character.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/gcd/models/character.py b/apps/gcd/models/character.py index 6ad19152e..1218d3b39 100644 --- a/apps/gcd/models/character.py +++ b/apps/gcd/models/character.py @@ -317,6 +317,10 @@ def has_dependents(self): if StoryCharacter.objects.filter(character__character=self, deleted=False).exists(): return True + from apps.oi.models import StoryCharacterRevision + if StoryCharacterRevision.objects.active_set()\ + .filter(character__character=self).exists(): + return True return False # def stat_counts(self): @@ -508,6 +512,10 @@ def has_dependents(self): if StoryGroup.objects.filter(group_name__group=self, deleted=False).exists(): return True + from apps.oi.models import StoryGroupRevision + if StoryGroupRevision.objects.active_set()\ + .filter(group_name__group=self).exists(): + return True return False def get_issue_list_url(self): From d16201b5aceb64b183aa9775e1bec556072f7cc2 Mon Sep 17 00:00:00 2001 From: Josef Andersson Date: Sun, 12 Jul 2026 18:54:05 +0200 Subject: [PATCH 38/77] test: repair the suite after the brand emblem renames (#724) The brand FK became the brand_emblem and several fields were renamed without updating tests and fixtures; 97 tests were failing. Also replace the has_calls typo, which newer mock rejects and which had silently skipped those assertions, and adapt mocks. Signed-off-by: Josef Andersson --- apps/gcd/tests/test_issue.py | 6 +++++- apps/gcd/tests/test_publisher.py | 5 ++++- apps/gcd/tests/test_series.py | 21 ++++++++++++--------- apps/oi/tests/db/test_printer_revision.py | 7 ++----- apps/oi/tests/test_issue_revision.py | 22 ++++++++++++++-------- apps/oi/tests/test_reprint_revision.py | 16 ++++++++++------ apps/oi/tests/test_revision.py | 2 +- apps/oi/tests/test_series_revision.py | 6 +++++- apps/stats/tests/test_countstats.py | 18 ++++++++++-------- 9 files changed, 63 insertions(+), 40 deletions(-) diff --git a/apps/gcd/tests/test_issue.py b/apps/gcd/tests/test_issue.py index 3d4a7101c..7362196ba 100644 --- a/apps/gcd/tests/test_issue.py +++ b/apps/gcd/tests/test_issue.py @@ -518,10 +518,14 @@ def test_stat_counts_base_indexed_covers_stories(any_series, stat_count_mocks): def test_stat_counts_variant_partial(any_series, stat_count_mocks): + # A saved-looking parent in the same series, cached on the instance + # so that stat_counts() does not hit the database. + variant_parent = Issue(number='0', series=any_series) + variant_parent.pk = 1234 i = Issue(number='1', series=any_series, is_indexed=INDEXED['partial'], - variant_of_id=1234) + variant_of=variant_parent) counts = i.stat_counts() assert counts == { diff --git a/apps/gcd/tests/test_publisher.py b/apps/gcd/tests/test_publisher.py index 950bef303..bb562980e 100644 --- a/apps/gcd/tests/test_publisher.py +++ b/apps/gcd/tests/test_publisher.py @@ -208,16 +208,19 @@ def brand_dep_mocks(): b = '%s.Brand' % PATH with mock.patch('%s.use_revisions' % b) as bu_mock, \ mock.patch('%s.issue_revisions' % b) as ish_mock, \ - mock.patch('%s.in_use' % b) as in_use_mock: + mock.patch('%s.in_use' % b) as in_use_mock, \ + mock.patch('%s.active_issues' % b) as ai_mock: for m in (bu_mock, ish_mock): m.active_set.return_value.exists.return_value = False in_use_mock.exists.return_value = False + ai_mock.return_value.exists.return_value = False yield { 'bu': bu_mock, 'ish': ish_mock, 'in_use': in_use_mock, + 'ai': ai_mock, } diff --git a/apps/gcd/tests/test_series.py b/apps/gcd/tests/test_series.py index 287cacd06..14c439400 100644 --- a/apps/gcd/tests/test_series.py +++ b/apps/gcd/tests/test_series.py @@ -116,8 +116,10 @@ def test_delete(): (True, True), (True, False), (False, True), (False, False)]) def test_has_dependents(issues, issue_revisions): with mock.patch('%s.active_issues' % SERIES_PATH) as is_mock, \ - mock.patch('%s.active_set' % REVMGR_PATH) as rev_mock: + mock.patch('%s.active_set' % REVMGR_PATH) as rev_mock, \ + mock.patch('%s.has_series_bonds' % SERIES_PATH) as bond_mock: rev_mock.return_value.exists.return_value = issue_revisions + bond_mock.return_value = False s = Series() is_mock.return_value.exists.return_value = issues @@ -151,9 +153,10 @@ def test_active_non_base_variants(issues_qs): def test_active_indexed_issues(issues_qs): s = Series() + s.active_issues.return_value.filter.return_value = issues_qs assert s.active_indexed_issues() == issues_qs - s.active_issues.return_value.exclude.assert_called_once_with( - is_indexed=INDEXED['skeleton']) + s.active_issues.return_value.filter.assert_called_once_with( + is_indexed__gt=INDEXED['some_data']) def test_active_base_issues_variant_count(): @@ -265,8 +268,8 @@ def test_update_cached_counts_subtract(f_mock): def test_set_first_last_issues_empty(): with mock.patch('apps.gcd.models.series.Series.save'), \ - mock.patch('apps.gcd.models.series.models.QuerySet.order_by') \ - as order_mock, \ + mock.patch('apps.gcd.models.series.Series.active_issues') \ + as active_mock, \ mock.patch('apps.gcd.models.issue.Issue.series'): # Create some issues that are set as first/last even though no longer @@ -284,7 +287,7 @@ def index_faker(index): qs.count.return_value = 0 qs.__getitem__.side_effect = index_faker - order_mock.return_value = qs + active_mock.return_value.order_by.return_value = qs s.set_first_last_issues() @@ -295,8 +298,8 @@ def index_faker(index): def test_set_first_last_issues_nonempty(): with mock.patch('apps.gcd.models.series.Series.save'), \ - mock.patch('apps.gcd.models.series.models.QuerySet.order_by') \ - as order_mock, \ + mock.patch('apps.gcd.models.series.Series.active_issues') \ + as active_mock, \ mock.patch('apps.gcd.models.issue.Issue.series'): s = Series(issue_count=0) @@ -315,7 +318,7 @@ def index_faker(index): qs.__getitem__.side_effect = index_faker qs.__iter__.return_value = iter(issue_list) - order_mock.return_value = qs + active_mock.return_value.order_by.return_value = qs s.set_first_last_issues() diff --git a/apps/oi/tests/db/test_printer_revision.py b/apps/oi/tests/db/test_printer_revision.py index c0c228605..45dc42b58 100644 --- a/apps/oi/tests/db/test_printer_revision.py +++ b/apps/oi/tests/db/test_printer_revision.py @@ -29,11 +29,8 @@ def test_commit_added_revision(any_added_printer_rev, printer_add_values, with mock.patch(update_all) as updater: rev.commit_to_display() - updater.assert_has_calls([ - mock.call({}, country=None, language=None, negate=True), - mock.call({}, country=rev.printer.country, language=None), - ]) - assert updater.call_count == 2 + # Printers do not opt into _update_stats, so no stats calls are made. + assert updater.call_count == 0 assert rev.printer is not None assert rev.source is rev.printer diff --git a/apps/oi/tests/test_issue_revision.py b/apps/oi/tests/test_issue_revision.py index 6e42bc774..04b5870e0 100644 --- a/apps/oi/tests/test_issue_revision.py +++ b/apps/oi/tests/test_issue_revision.py @@ -58,10 +58,11 @@ def test_classification(): 'series': gf('series'), 'indicia_publisher': gf('indicia_publisher'), 'indicia_pub_not_printed': gf('indicia_pub_not_printed'), - 'brand': gf('brand'), + 'brand_emblem': gf('brand_emblem'), 'no_brand': gf('no_brand'), - 'no_indicia_printer': gf('no_indicia_printer'), 'indicia_printer': gf('indicia_printer'), + 'indicia_printer_not_printed': gf('indicia_printer_not_printed'), + 'indicia_printer_sourced_by': gf('indicia_printer_sourced_by'), } irregular_fields = { @@ -79,9 +80,13 @@ def test_classification(): single_value_fields = regular_fields.copy() del single_value_fields['keywords'] del single_value_fields['indicia_printer'] + del single_value_fields['brand_emblem'] assert IssueRevision._get_single_value_fields() == single_value_fields - assert IssueRevision._get_multi_value_fields() == {'indicia_printer': gf('indicia_printer'),} + assert IssueRevision._get_multi_value_fields() == { + 'indicia_printer': gf('indicia_printer'), + 'brand_emblem': gf('brand_emblem'), + } def test_conditional_field_mapping(): @@ -99,7 +104,7 @@ def test_conditional_field_mapping(): 'indicia_frequency': ('series', 'has_indicia_frequency'), 'no_indicia_frequency': ('series', 'has_indicia_frequency'), 'indicia_printer': ('series', 'has_indicia_printer'), - 'no_indicia_printer': ('series', 'has_indicia_printer'), + 'indicia_printer_not_printed': ('series', 'has_indicia_printer'), } @@ -107,9 +112,10 @@ def test_parent_field_tuples(): assert IssueRevision._get_parent_field_tuples() == { ('series',), ('series', 'publisher'), - ('brand', 'group'), - ('brand',), + ('brand_emblem', 'group'), + ('brand_emblem',), ('indicia_publisher',), + ('indicia_printer',), } @@ -604,7 +610,7 @@ def test_post_save_new_gains_gallery(patch_for_move): assert new.has_gallery is True assert not old.save.called - new.save.assert_called_once_with() + new.save.assert_called_once_with(update_fields=['has_gallery']) @pytest.mark.parametrize('has_gallery, count', [(True, 1), (False, 0)]) @@ -627,7 +633,7 @@ def test_post_save_old_loses_gallery(patch_for_move, has_gallery, count): assert old.has_gallery is False assert new.has_gallery is has_gallery - old.save.assert_called_once_with() + old.save.assert_called_once_with(update_fields=['has_gallery']) assert not new.save.called diff --git a/apps/oi/tests/test_reprint_revision.py b/apps/oi/tests/test_reprint_revision.py index fe4429576..def2b62d6 100644 --- a/apps/oi/tests/test_reprint_revision.py +++ b/apps/oi/tests/test_reprint_revision.py @@ -20,11 +20,13 @@ def issue_uni(self): mock.patch('apps.gcd.models.story.Story.__str__', story_uni), \ mock.patch('apps.gcd.models.issue.Issue.__str__', issue_uni): s = Series(name='Test Series') + o_issue = Issue(number='1', title='o issue', series=s) + o_issue.pk = 1 + t_issue = Issue(number='9', title='t issue', series=s) + t_issue.pk = 9 yield (save_mock, - Story(title='origin', - issue=Issue(number='1', title='o issue', series=s)), - Story(title='target', - issue=Issue(number='9', title='t issue', series=s))) + Story(title='origin', issue=o_issue), + Story(title='target', issue=t_issue)) @pytest.fixture @@ -87,7 +89,8 @@ def test_save_origin_mismatch(patched_for_save): v = str(exc_info.value) assert "origin story and issue do not match" in v - expected = "issue: '%s'; Issue: '%s'" % (origin.issue, target.issue) + expected = "issue: '%d: %s'; Issue: '%d: %s'" % ( + origin.issue.id, origin.issue, target.issue.id, target.issue) assert expected in v assert not save_mock.called @@ -102,7 +105,8 @@ def test_save_target_mismatch(patched_for_save): v = str(exc_info.value) assert "target story and issue do not match" in v - expected = "issue: '%s'; Issue: '%s'" % (target.issue, origin.issue) + expected = "issue: '%d: %s'; Issue: '%d: %s'" % ( + target.issue.id, target.issue, origin.issue.id, origin.issue) assert expected in v assert not save_mock.called diff --git a/apps/oi/tests/test_revision.py b/apps/oi/tests/test_revision.py index 1aac017be..d57c61b46 100644 --- a/apps/oi/tests/test_revision.py +++ b/apps/oi/tests/test_revision.py @@ -211,7 +211,7 @@ def test_set_source(): def test_source_class(): - assert Revision.source_class is NotImplemented + assert Revision.source_class is NotImplementedError assert DummyRevision.source_class is Dummy diff --git a/apps/oi/tests/test_series_revision.py b/apps/oi/tests/test_series_revision.py index 1bd701b4a..9d6085a8f 100644 --- a/apps/oi/tests/test_series_revision.py +++ b/apps/oi/tests/test_series_revision.py @@ -340,7 +340,11 @@ def pre_save_mocks(): with mock.patch('%s.get_ongoing_reservation' % SERIES) as get_ongoing, \ mock.patch('apps.gcd.models.series.Series.scan_count', new_callable=mock.PropertyMock) as scan_count: - yield SeriesRevision(series=Series()), get_ongoing, scan_count + # A previous_revision makes this an edit, not an add; the + # has_gallery update only runs for edits. + yield (SeriesRevision(series=Series(), + previous_revision=SeriesRevision()), + get_ongoing, scan_count) def test_pre_save_object_from_current(pre_save_mocks): diff --git a/apps/stats/tests/test_countstats.py b/apps/stats/tests/test_countstats.py index 47bbc95c0..8c666bb04 100644 --- a/apps/stats/tests/test_countstats.py +++ b/apps/stats/tests/test_countstats.py @@ -54,8 +54,10 @@ def patched_filters(): cr_filter.return_value.count.return_value = CREATOR_COUNT s_filter.return_value.count.return_value = SERIES_COUNT i_filter.return_value.count.return_value = ISSUE_COUNT - i_filter.return_value.exclude.return_value.count.side_effect = ( - VARIANT_COUNT, INDEX_COUNT) + i_filter.return_value.exclude.return_value.count.return_value = \ + VARIANT_COUNT + i_filter.return_value.filter.return_value.count.return_value = \ + INDEX_COUNT c_filter.return_value.count.return_value = COVER_COUNT t_filter.return_value.count.return_value = STORY_COUNT @@ -105,8 +107,8 @@ def test_init_stats_language(patched_filters): mock.call().exclude(variant_of=None), mock.call().exclude().count(), mock.call(variant_of=None, **i_kwargs), - mock.call().exclude(is_indexed=INDEXED['skeleton']), - mock.call().exclude().count()]) + mock.call().filter(is_indexed__gt=INDEXED['some_data']), + mock.call().filter().count()]) c_filter.assert_called_once_with(**c_t_kwargs) t_filter.assert_called_once_with(**c_t_kwargs) @@ -143,8 +145,8 @@ def test_init_stats_country(patched_filters): mock.call().exclude(variant_of=None), mock.call().exclude().count(), mock.call(variant_of=None, **i_kwargs), - mock.call().exclude(is_indexed=INDEXED['skeleton']), - mock.call().exclude().count()]) + mock.call().filter(is_indexed__gt=INDEXED['some_data']), + mock.call().filter().count()]) c_filter.assert_called_once_with(**c_t_kwargs) t_filter.assert_called_once_with(**c_t_kwargs) @@ -185,8 +187,8 @@ def test_init_stats_neither(patched_filters): mock.call().exclude(variant_of=None), mock.call().exclude().count(), mock.call(variant_of=None, **i_kwargs), - mock.call().exclude(is_indexed=INDEXED['skeleton']), - mock.call().exclude().count()]) + mock.call().filter(is_indexed__gt=INDEXED['some_data']), + mock.call().filter().count()]) c_filter.assert_called_once_with(**c_t_kwargs) t_filter.assert_called_once_with(**c_t_kwargs) From 2781af1c5b2c336ca364e826c90af63040605586 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 12 Jul 2026 19:36:04 +0200 Subject: [PATCH 39/77] fix compare & copy for m2m --- apps/oi/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/oi/views.py b/apps/oi/views.py index 5dd572f16..d1ad5212e 100644 --- a/apps/oi/views.py +++ b/apps/oi/views.py @@ -3038,6 +3038,7 @@ def compare_issues_copy(request, issue_revision_id, issue_id): setattr(revision, field, getattr(compare_revision, field)) # m2m fields if field in fields_to_set: + getattr(revision, field).clear() getattr(revision, field).add(*list(getattr(compare_revision, field).all())) if 'year_on_sale' in selected_fields: @@ -3843,6 +3844,7 @@ def compare_stories_copy(request, story_revision_id, story_id=None, setattr(revision, field, getattr(compare_revision, field)) # m2m fields if field in fields_to_set: + getattr(revision, field).clear() getattr(revision, field).add(*list(getattr(compare_revision, field).all())) # special handling for keywords due to their different storage From 5bf82f077bee43479320cbbe9679d0d90c31324d Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 12 Jul 2026 19:45:24 +0200 Subject: [PATCH 40/77] only reset the series issue count cache if explicitly requested --- scripts/reset_stats.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/reset_stats.py b/scripts/reset_stats.py index 343f28774..121772f01 100644 --- a/scripts/reset_stats.py +++ b/scripts/reset_stats.py @@ -17,6 +17,7 @@ def main(): if Series.objects.filter(country=i).exists(): CountStats.objects.init_stats(country=i) +def reset_series_issue_count_cache(): # ------------------------------------------------------------------------- # Rebuild Series.issue_count Caches # ------------------------------------------------------------------------- @@ -43,4 +44,6 @@ def main(): def run(): main() + # only reset the series issue count cache if explicitly requested + # reset_series_issue_count_cache() From 2ff185948672c42693acc6939b628a14460a44b0 Mon Sep 17 00:00:00 2001 From: Josef Andersson Date: Tue, 14 Jul 2026 21:16:43 +0200 Subject: [PATCH 41/77] fix(scripts): clear default ordering in the reset_stats subquery (#725) reset_stats crashed on MySQL with error 1093: Issue's default ordering ('series', 'sort_code') drags a join on gcd_series into the update's subquery, and MySQL won't update a table its subquery reads. Clear the ordering, as the Django aggregation docs advise for grouped querysets. See https://docs.djangoproject.com/en/5.2/topics/db/aggregation/#interaction-with-order-by Signed-off-by: Josef Andersson --- apps/gcd/tests/test_reset_stats.py | 78 ++++++++++++++++++++++++++++++ scripts/reset_stats.py | 7 ++- 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 apps/gcd/tests/test_reset_stats.py diff --git a/apps/gcd/tests/test_reset_stats.py b/apps/gcd/tests/test_reset_stats.py new file mode 100644 index 000000000..a5d592bae --- /dev/null +++ b/apps/gcd/tests/test_reset_stats.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" +Grouped querysets need the model's default ordering cleared, or Django folds +those fields into the GROUP BY and the counts come out wrong — hence the +.order_by() in reset_series_issue_count_cache, which rebuilds the cached +Series.issue_count. +""" + +import pytest + +from apps.gcd.models import Publisher, Series, Issue +from apps.stats.models import CountStats +from apps.stddata.models import Country, Language +from scripts.reset_stats import main, reset_series_issue_count_cache + + +@pytest.fixture +def two_series(db): + country, _ = Country.objects.get_or_create( + code='zz', defaults={'name': 'Zedland'}) + language, _ = Language.objects.get_or_create( + code='zz', defaults={'name': 'Zedish'}) + publisher = Publisher.objects.create( + name='Stats Publishing', country=country, year_began=1990) + + def series(name): + return Series.objects.create( + name=name, sort_name=name, year_began=1990, country=country, + language=language, publisher=publisher, + is_comics_publication=True, has_gallery=False) + + return series('Alpha'), series('Beta') + + +def _issue(series, sort_code, **kwargs): + return Issue.objects.create( + number=str(sort_code), series=series, sort_code=sort_code, + **kwargs) + + +@pytest.mark.django_db +def test_rebuild_counts_base_and_cross_series_variants(two_series): + alpha, beta = two_series + base = _issue(alpha, 1) + _issue(alpha, 2) # second base issue: counted + _issue(alpha, 3, variant_of=base) # same-series variant: not counted + _issue(beta, 1, variant_of=base) # cross-series variant: counted + _issue(beta, 2, deleted=True) # deleted: not counted + + # Corrupt the cached counts; the rebuild must repair them. + Series.objects.update(issue_count=99) + + reset_series_issue_count_cache() + + alpha.refresh_from_db() + beta.refresh_from_db() + assert alpha.issue_count == 2 + assert beta.issue_count == 1 + + +@pytest.mark.django_db +def test_rebuild_zeroes_series_without_issues(two_series): + alpha, _ = two_series + Series.objects.update(issue_count=7) + + reset_series_issue_count_cache() + + alpha.refresh_from_db() + assert alpha.issue_count == 0 + + +@pytest.mark.django_db +def test_main_rebuilds_countstats(two_series): + CountStats.objects.all().delete() + + main() + + assert CountStats.objects.exists() diff --git a/scripts/reset_stats.py b/scripts/reset_stats.py index 121772f01..a3393bf77 100644 --- a/scripts/reset_stats.py +++ b/scripts/reset_stats.py @@ -25,10 +25,10 @@ def reset_series_issue_count_cache(): # (a) It is a standard base issue (variant_of is NULL) # (b) It is a cross-series variant (its series differs from its base issue's series) # Standard variants within the same series do not count, preventing inflation. - # + # # This bulk aggregation MUST remain synchronized with the real-time Python # logic in `apps.gcd.models.issue.Issue.stat_counts()`. - + from django.db.models import Subquery, OuterRef from django.db.models.functions import Coalesce @@ -37,7 +37,7 @@ def reset_series_issue_count_cache(): series_id=OuterRef('pk') ).filter( Q(variant_of__isnull=True) | ~Q(series_id=F('variant_of__series_id')) - ).values('series_id').annotate(c=Count('id')).values('c') + ).values('series_id').annotate(c=Count('id')).values('c').order_by() Series.objects.update(issue_count=Coalesce(Subquery(subquery), 0)) @@ -46,4 +46,3 @@ def run(): main() # only reset the series issue count cache if explicitly requested # reset_series_issue_count_cache() - From 875f9ccfb6128b5f993dd8cb86ba4dd7b87b61fe Mon Sep 17 00:00:00 2001 From: Josef Andersson Date: Tue, 14 Jul 2026 21:21:12 +0200 Subject: [PATCH 42/77] Add pytest config (#726) * fix(scripts): clear default ordering in the reset_stats subquery reset_stats crashed on MySQL with error 1093: Issue's default ordering ('series', 'sort_code') drags a join on gcd_series into the update's subquery, and MySQL won't update a table its subquery reads. Clear the ordering, as the Django aggregation docs advise for grouped querysets. See https://docs.djangoproject.com/en/5.2/topics/db/aggregation/#interaction-with-order-by Signed-off-by: Josef Andersson * test: add a committed pytest configuration The pytest config was gitignored, so a fresh checkout couldn't find the settings module and the suite wouldn't run at all. Commit it to pyproject.toml, the standard home for tool config. Signed-off-by: Josef Andersson * test: replace deprecated pytest.yield_fixture with pytest.fixture They're the same thing; the yield_fixture alias is deprecated on current pytest and will eventually be removed. Signed-off-by: Josef Andersson --------- Signed-off-by: Josef Andersson --- .gitignore | 1 - apps/gcd/tests/test_issue.py | 8 ++++---- apps/gcd/tests/test_publisher.py | 12 ++++++------ apps/gcd/tests/test_reprint.py | 4 ++-- apps/gcd/tests/test_series.py | 4 ++-- apps/gcd/tests/test_story.py | 2 +- apps/stats/tests/test_countstats.py | 6 +++--- pyproject.toml | 5 +++++ 8 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 2110d78c7..349612501 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ # Local configuration settings_local.py -pytest.ini # Editor temp and config files *.py~ diff --git a/apps/gcd/tests/test_issue.py b/apps/gcd/tests/test_issue.py index 7362196ba..49352da4b 100644 --- a/apps/gcd/tests/test_issue.py +++ b/apps/gcd/tests/test_issue.py @@ -24,7 +24,7 @@ def any_series(): is_comics_publication=True) -@pytest.yield_fixture +@pytest.fixture def image_and_content_type(): """ Returns a 4-tuple of mocks for use in testing image properties. @@ -152,7 +152,7 @@ def test_cant_upload_variants_active_revisions(any_series): assert can_upload_variants is False -#@pytest.yield_fixture +#@pytest.fixture #def re_issue_mocks(any_series): #with mock.patch('%s.from_all_reprints' % ISSUE_PATH) as from_mock, \ #mock.patch('%s.to_all_reprints' % ISSUE_PATH) as to_mock: @@ -231,7 +231,7 @@ def test_delete(): i.save.assert_called_once_with() -@pytest.yield_fixture +@pytest.fixture def del_issue_mocks(): """ Dict of mocks of things needed for deletability testing. """ with mock.patch('%s.has_reprints' % ISSUE_PATH) as issue_hr_mock, \ @@ -471,7 +471,7 @@ def test_shown_covers(any_series): assert second == [v3, v4, v5] -@pytest.yield_fixture +@pytest.fixture def stat_count_mocks(): """ Yields a 2-tuple of mocks for testing statistics. diff --git a/apps/gcd/tests/test_publisher.py b/apps/gcd/tests/test_publisher.py index bb562980e..1c354b8d2 100644 --- a/apps/gcd/tests/test_publisher.py +++ b/apps/gcd/tests/test_publisher.py @@ -25,7 +25,7 @@ } -@pytest.yield_fixture +@pytest.fixture def f_mock(): with mock.patch('%s.F' % PATH) as f_mock: @@ -79,7 +79,7 @@ def test_update_cached_counts_subtract(f_mock): assert p.issue_count == ISSUE_COUNT - DELTAS['issues'] -@pytest.yield_fixture +@pytest.fixture def pub_dep_mocks(): p = '%s.Publisher' % PATH with mock.patch('%s.active_brands' % p) as ab_mock, \ @@ -135,7 +135,7 @@ def test_pub_has_non_dependents_nonzero_counts(pub_dep_mocks, which_count): assert has is False -@pytest.yield_fixture +@pytest.fixture def ipub_dep_mock(): ip = '%s.IndiciaPublisher' % PATH with mock.patch('%s.issue_revisions' % ip) as ip_mock: @@ -163,7 +163,7 @@ def test_ipub_has_dependents_issue_count(ipub_dep_mock): assert has is True -@pytest.yield_fixture +@pytest.fixture def group_dep_mocks(): g = '%s.BrandGroup' % PATH with mock.patch('%s.brand_revisions' % g) as br_mock, \ @@ -203,7 +203,7 @@ def test_group_has_dependents_issue_count(group_dep_mocks): assert has is True -@pytest.yield_fixture +@pytest.fixture def brand_dep_mocks(): b = '%s.Brand' % PATH with mock.patch('%s.use_revisions' % b) as bu_mock, \ @@ -303,7 +303,7 @@ def test_brand_use_active_issues(): issue__series__publisher=bu.publisher) -@pytest.yield_fixture +@pytest.fixture def pub_child_set_mocks(): p = '%s.Publisher' % PATH with mock.patch('%s.active_indicia_publishers' % p) as ip_mock, \ diff --git a/apps/gcd/tests/test_reprint.py b/apps/gcd/tests/test_reprint.py index 1d0ca33ff..705c39d19 100644 --- a/apps/gcd/tests/test_reprint.py +++ b/apps/gcd/tests/test_reprint.py @@ -8,7 +8,7 @@ from apps.gcd.models import Story, Issue, Series, Reprint -@pytest.yield_fixture +@pytest.fixture def patched_for_save(): with mock.patch('apps.gcd.models.reprint.GcdLink.save') as save_mock: yield (save_mock, @@ -64,7 +64,7 @@ def test_save_fields_target_with_story(patched_for_save): assert r.target_issue == target.issue -@pytest.yield_fixture +@pytest.fixture def patched_for_strings(): def full_name(self): return self.number diff --git a/apps/gcd/tests/test_series.py b/apps/gcd/tests/test_series.py index 14c439400..6fd124781 100644 --- a/apps/gcd/tests/test_series.py +++ b/apps/gcd/tests/test_series.py @@ -126,7 +126,7 @@ def test_has_dependents(issues, issue_revisions): assert s.has_dependents() is any((issue_revisions, issues)) -@pytest.yield_fixture +@pytest.fixture def issues_qs(): """ Provides a queryset mock for active_issues().exclude(...) @@ -235,7 +235,7 @@ def test_counts_deleted(): assert s.stat_counts() == {} -@pytest.yield_fixture +@pytest.fixture def f_mock(): with mock.patch('apps.gcd.models.series.F') as f_mock: diff --git a/apps/gcd/tests/test_story.py b/apps/gcd/tests/test_story.py index 4b4a38ab4..7f7942d86 100644 --- a/apps/gcd/tests/test_story.py +++ b/apps/gcd/tests/test_story.py @@ -19,7 +19,7 @@ def test_stat_counts(is_comics, deleted): assert story.stat_counts() == {} if deleted else {'stories': 1} -@pytest.yield_fixture +@pytest.fixture def re_story_mocks(): with mock.patch('%s.from_all_reprints' % STORY_PATH) as from_mock, \ mock.patch('%s.to_all_reprints' % STORY_PATH) as to_mock: diff --git a/apps/stats/tests/test_countstats.py b/apps/stats/tests/test_countstats.py index 8c666bb04..a45aef62a 100644 --- a/apps/stats/tests/test_countstats.py +++ b/apps/stats/tests/test_countstats.py @@ -30,7 +30,7 @@ def test_init_stats_not_both(): assert 'either country or language stats' in str(excinfo.value) -@pytest.yield_fixture +@pytest.fixture def patched_filters(): """ Returns all of the *.objects.filter/create methods patched as a tuple. @@ -208,7 +208,7 @@ def test_init_stats_neither(patched_filters): mock.call(name='stories', count=STORY_COUNT, **lc_args)]) -@pytest.yield_fixture +@pytest.fixture def mocks_for_update(): """ Returns a 4-tuple of mocks for testing CountStatsManager.update(). @@ -355,7 +355,7 @@ def fake_get(name=None, language=None, country=None): _check_delta_applications(f_mock, cs_mocks, 1) -@pytest.yield_fixture +@pytest.fixture def mocks_for_update_all(): """ Returns a 3-tuple of mocks for testing update_all(). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..c42c2ef4f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.pytest.ini_options] +# Boot Django with these settings first; without this the suite won't start at all. +DJANGO_SETTINGS_MODULE = "settings" +# Where the tests live, so a bare `pytest` finds them. +testpaths = ["apps"] From e1a81a759f45436a31324f7cc46207c047656812 Mon Sep 17 00:00:00 2001 From: Josef Andersson Date: Wed, 15 Jul 2026 22:28:04 +0200 Subject: [PATCH 43/77] fix(gcd): use editing credit type in advanced story search (#727) The story_editing branch used a loop variable left over from the text credit loop, so it searched letters credits. Use CREDIT_TYPES['editing'], as issue_editing already does. Signed-off-by: Josef Andersson --- apps/gcd/tests/test_search.py | 83 +++++++++++++++++++++++++++++++++++ apps/gcd/views/search.py | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 apps/gcd/tests/test_search.py diff --git a/apps/gcd/tests/test_search.py b/apps/gcd/tests/test_search.py new file mode 100644 index 000000000..2242f855e --- /dev/null +++ b/apps/gcd/tests/test_search.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +import pytest +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory + +from apps.stddata.models import Country, Language, Script +from apps.gcd.models import ( + Publisher, Series, Issue, Story, StoryType, StoryCredit, CreditType, +) +from apps.gcd.models.creator import Creator, CreatorNameDetail +from apps.gcd.models.story import CREDIT_TYPES +from apps.gcd.views.search import do_advanced_search + +CREATOR = 'Search Person' + +# The advanced-search field for each credit type. 'editing' is reached +# through story_editing; the rest share their name with the credit. +SEARCH_FIELD = { + 'script': 'script', + 'pencils': 'pencils', + 'inks': 'inks', + 'colors': 'colors', + 'letters': 'letters', + 'editing': 'story_editing', +} + + +def advanced_search(**fields): + request = RequestFactory().get( + '/search/advanced/process/', + dict({'target': 'sequence', 'method': 'icontains'}, **fields)) + request.user = AnonymousUser() + items, _target = do_advanced_search(request) + return set(items.values_list('id', flat=True)) + + +@pytest.fixture +def credited_stories(db): + script = Script.objects.get_or_create( + id=Script.LATIN_PK, + defaults={'code': 'Latn', 'number': Script.LATIN_PK, + 'name': 'Latin'})[0] + story_type = StoryType.objects.get_or_create( + name='test-sequence', defaults={'sort_code': 99001})[0] + country = Country.objects.get_or_create( + id=902, defaults={'code': 'zs', 'name': 'Searchland'})[0] + language = Language.objects.get_or_create( + id=902, defaults={'code': 'zs', 'name': 'Searchish'})[0] + publisher = Publisher.objects.create( + name='Search Publisher', country=country, year_began=1950) + series = Series.objects.create( + name='Search Series', sort_name='Search Series', year_began=1950, + country=country, language=language, publisher=publisher, + is_comics_publication=True, has_gallery=False, + publication_dates='1950') + issue = Issue.objects.create( + number='1', series=series, sort_code=0, + publication_date='1950', key_date='1950-01-00') + creator = Creator.objects.create( + gcd_official_name=CREATOR, sort_name=CREATOR) + name_detail = CreatorNameDetail.objects.create( + name=CREATOR, creator=creator, in_script=script) + + # One story per credit type, all crediting the same person, so a + # search that confuses two credit types returns a different story. + stories = {} + for name, type_id in CREDIT_TYPES.items(): + credit_type = CreditType.objects.get_or_create( + id=type_id, defaults={'name': name, 'sort_code': type_id})[0] + story = Story.objects.create( + issue=issue, type=story_type, sequence_number=0) + StoryCredit.objects.create( + creator=name_detail, credit_type=credit_type, story=story) + stories[name] = story + return stories + + +@pytest.mark.parametrize('credit', sorted(SEARCH_FIELD)) +def test_credit_search_matches_only_its_own_credit_type(credit, + credited_stories): + matched = advanced_search(**{SEARCH_FIELD[credit]: CREATOR}) + + assert matched == {credited_stories[credit].id} diff --git a/apps/gcd/views/search.py b/apps/gcd/views/search.py index 0609822c0..87b05193a 100644 --- a/apps/gcd/views/search.py +++ b/apps/gcd/views/search.py @@ -2262,7 +2262,7 @@ def search_stories(data, op): stories = list(Story.objects.filter( credits__creator__id__in=creators, credits__deleted=False, - credits__credit_type__id=CREDIT_TYPES[field]) + credits__credit_type__id=CREDIT_TYPES['editing']) .values_list('id', flat=True)) if (stories): linked_credits_q_objs.append( From 3127822ae4b35d1b23ae818054c5457d5aabce60 Mon Sep 17 00:00:00 2001 From: Josef Andersson Date: Wed, 22 Jul 2026 22:03:36 +0200 Subject: [PATCH 44/77] Fix/advanced story credit search (#728) * fix(gcd): unify story credit search and honour linked-credits-only One path for all six story credit fields, replacing the near-identical blocks that hid the editing-credit bug. Two behaviour changes fall out: linked-credits-only with no linked match now returns nothing for every credit field, not just issue editing; and story editing splits on ';' like the others. Signed-off-by: Josef Andersson * test(gcd): cover advanced search credit behaviour Credit-type matching, deleted credits, semicolon AND, empty segments, credit source selection, and issue vs story editing. Signed-off-by: Josef Andersson --------- Signed-off-by: Josef Andersson --- apps/gcd/tests/test_search.py | 52 ++++++- apps/gcd/tests/test_search_credit_sources.py | 148 +++++++++++++++++++ apps/gcd/tests/test_search_linked_only.py | 134 +++++++++++++++++ apps/gcd/views/search.py | 87 ++++++----- 4 files changed, 377 insertions(+), 44 deletions(-) create mode 100644 apps/gcd/tests/test_search_credit_sources.py create mode 100644 apps/gcd/tests/test_search_linked_only.py diff --git a/apps/gcd/tests/test_search.py b/apps/gcd/tests/test_search.py index 2242f855e..4b19c6a18 100644 --- a/apps/gcd/tests/test_search.py +++ b/apps/gcd/tests/test_search.py @@ -11,7 +11,7 @@ from apps.gcd.models.story import CREDIT_TYPES from apps.gcd.views.search import do_advanced_search -CREATOR = 'Search Person' +CREATOR = 'Test Person A' # The advanced-search field for each credit type. 'editing' is reached # through story_editing; the rest share their name with the credit. @@ -43,13 +43,13 @@ def credited_stories(db): story_type = StoryType.objects.get_or_create( name='test-sequence', defaults={'sort_code': 99001})[0] country = Country.objects.get_or_create( - id=902, defaults={'code': 'zs', 'name': 'Searchland'})[0] + id=902, defaults={'code': 'zs', 'name': 'Testland B'})[0] language = Language.objects.get_or_create( - id=902, defaults={'code': 'zs', 'name': 'Searchish'})[0] + id=902, defaults={'code': 'zs', 'name': 'Testish B'})[0] publisher = Publisher.objects.create( - name='Search Publisher', country=country, year_began=1950) + name='Test Publisher', country=country, year_began=1950) series = Series.objects.create( - name='Search Series', sort_name='Search Series', year_began=1950, + name='Test Series', sort_name='Test Series', year_began=1950, country=country, language=language, publisher=publisher, is_comics_publication=True, has_gallery=False, publication_dates='1950') @@ -81,3 +81,45 @@ def test_credit_search_matches_only_its_own_credit_type(credit, matched = advanced_search(**{SEARCH_FIELD[credit]: CREATOR}) assert matched == {credited_stories[credit].id} + + +def test_credit_search_ignores_deleted_credits(credited_stories): + StoryCredit.objects.filter( + story=credited_stories['inks']).update(deleted=True) + + assert advanced_search(inks=CREATOR) == set() + + +@pytest.mark.parametrize('credit', sorted(SEARCH_FIELD)) +def test_semicolon_separated_creators_must_all_be_credited( + credit, credited_stories): + # 'A; B' means stories credited to both A and B, not either of them. + solo = credited_stories[credit] + shared = Story.objects.create( + issue=solo.issue, type=solo.type, sequence_number=1) + credit_type = CreditType.objects.get(id=CREDIT_TYPES[credit]) + for name in (CREATOR, 'Test Person B'): + creator = Creator.objects.create( + gcd_official_name=name, sort_name=name) + StoryCredit.objects.create( + creator=CreatorNameDetail.objects.create( + name=name, creator=creator, + in_script=Script.objects.get(id=Script.LATIN_PK)), + credit_type=credit_type, story=shared) + + matched = advanced_search( + **{SEARCH_FIELD[credit]: '%s; Test Person B' % CREATOR}) + + assert matched == {shared.id} + + +@pytest.mark.parametrize('credit', sorted(SEARCH_FIELD)) +def test_empty_creator_segments_do_not_match_every_credit( + credit, credited_stories): + matched = advanced_search(**{SEARCH_FIELD[credit]: ';'}) + + assert matched == set() + + +def test_unmatched_creator_returns_no_stories(credited_stories): + assert advanced_search(script='Test Person Absent') == set() diff --git a/apps/gcd/tests/test_search_credit_sources.py b/apps/gcd/tests/test_search_credit_sources.py new file mode 100644 index 000000000..b4e3a1659 --- /dev/null +++ b/apps/gcd/tests/test_search_credit_sources.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +import pytest +from django.contrib.auth.models import AnonymousUser +from django.db import connection +from django.test import RequestFactory +from django.test.utils import CaptureQueriesContext + +from apps.stddata.models import Country, Language, Script +from apps.gcd.models import ( + Publisher, Series, Issue, Story, StoryType, StoryCredit, CreditType, +) +from apps.gcd.models.creator import Creator, CreatorNameDetail +from apps.gcd.models.issue import IssueCredit +from apps.gcd.models.story import CREDIT_TYPES +from apps.gcd.views.search import do_advanced_search, _linked_story_ids + +CREATOR = 'Test Person A' + +# credit_is_linked values, exactly as the advanced-search dropdown sends them. +# LINKED_ONLY matches only linked credit records, TEXT_CREDITS_ONLY only the +# free-text credit fields, and BOTH matches either. +LINKED_ONLY = '' # dropdown "linked credits only" +BOTH = 'True' # dropdown "both linked and text credits" +TEXT_CREDITS_ONLY = 'False' # dropdown "text credits only" + + +def advanced_search(**fields): + request = RequestFactory().get( + '/search/advanced/process/', + dict({'target': 'sequence', 'method': 'icontains'}, **fields)) + request.user = AnonymousUser() + items, _target = do_advanced_search(request) + return set(items.values_list('id', flat=True)) + + +@pytest.fixture +def world(db): + script = Script.objects.get_or_create( + id=Script.LATIN_PK, + defaults={'code': 'Latn', 'number': Script.LATIN_PK, + 'name': 'Latin'})[0] + story_type = StoryType.objects.get_or_create( + name='credit-sequence', defaults={'sort_code': 99003})[0] + country = Country.objects.get_or_create( + id=904, defaults={'code': 'q5', 'name': 'Testland C'})[0] + language = Language.objects.get_or_create( + id=904, defaults={'code': 'q6', 'name': 'Testish C'})[0] + publisher = Publisher.objects.create( + name='Test Publisher', country=country, year_began=1960) + series = Series.objects.create( + name='Test Series', sort_name='Test Series', year_began=1960, + country=country, language=language, publisher=publisher, + is_comics_publication=True, has_gallery=False, + publication_dates='1960') + creator = Creator.objects.create( + gcd_official_name=CREATOR, sort_name=CREATOR) + name = CreatorNameDetail.objects.create( + name=CREATOR, creator=creator, in_script=script) + return {'series': series, 'type': story_type, 'name': name} + + +def make_issue(world, number): + return Issue.objects.create( + number=number, series=world['series'], sort_code=int(number), + publication_date='1960', key_date='1960-01-00') + + +@pytest.fixture +def linked_and_text(world): + # One story credited through a StoryCredit object, one only in the + # free-text field. The migration moves stories from the latter to the + # former, so the two sources must stay distinguishable. + issue = make_issue(world, '1') + linked = Story.objects.create( + issue=issue, type=world['type'], sequence_number=0) + StoryCredit.objects.create( + creator=world['name'], + credit_type=CreditType.objects.get_or_create( + id=CREDIT_TYPES['script'], + defaults={'name': 'script', 'sort_code': 1})[0], + story=linked) + text = Story.objects.create( + issue=issue, type=world['type'], sequence_number=1, script=CREATOR) + return {'linked': linked, 'text': text} + + +@pytest.mark.parametrize('credit_is_linked,expected', [ + (LINKED_ONLY, ['linked']), + (BOTH, ['linked', 'text']), + (TEXT_CREDITS_ONLY, ['text']), +], ids=['linked_only', 'both', 'text_only']) +def test_credit_is_linked_picks_the_credit_source(credit_is_linked, expected, + linked_and_text): + matched = advanced_search(script=CREATOR, + credit_is_linked=credit_is_linked) + + assert matched == {linked_and_text[k].id for k in expected} + + +def test_linked_story_ids_materializes_the_creator_ids(linked_and_text): + # Creator ids must be materialized before the story lookup; a lazy + # queryset becomes IN (subquery), which MySQL optimizes poorly. See + # https://docs.djangoproject.com/en/5.2/ref/models/querysets/#nested-queries-performance + with CaptureQueriesContext(connection) as ctx: + stories = _linked_story_ids(CREATOR, 'script', 'icontains') + + assert stories == [linked_and_text['linked'].id] + queries = [q['sql'] for q in ctx.captured_queries] + assert len(queries) == 2 + # Ids appear as literals in the story query, not as a nested subquery. + assert 'creator_name_detail' in queries[0] + assert 'creator_name_detail' not in queries[1] + + +def test_issue_editing_matches_stories_of_the_edited_issue(world): + edited = make_issue(world, '1') + other = make_issue(world, '2') + IssueCredit.objects.create( + creator=world['name'], + credit_type=CreditType.objects.get_or_create( + id=CREDIT_TYPES['editing'], + defaults={'name': 'editing', 'sort_code': 6})[0], + issue=edited) + wanted = Story.objects.create( + issue=edited, type=world['type'], sequence_number=0) + Story.objects.create(issue=other, type=world['type'], sequence_number=0) + + assert advanced_search(issue_editing=CREATOR) == {wanted.id} + + +def test_story_editing_and_issue_editing_are_separate_credits(world): + story_credit_issue = make_issue(world, '1') + issue_credit_issue = make_issue(world, '2') + editing_type = CreditType.objects.get_or_create( + id=CREDIT_TYPES['editing'], defaults={'name': 'editing', + 'sort_code': 6})[0] + story_edited = Story.objects.create( + issue=story_credit_issue, type=world['type'], sequence_number=0) + issue_edited = Story.objects.create( + issue=issue_credit_issue, type=world['type'], sequence_number=0) + StoryCredit.objects.create( + creator=world['name'], credit_type=editing_type, story=story_edited) + IssueCredit.objects.create( + creator=world['name'], credit_type=editing_type, + issue=issue_credit_issue) + + assert advanced_search(story_editing=CREATOR) == {story_edited.id} + assert advanced_search(issue_editing=CREATOR) == {issue_edited.id} diff --git a/apps/gcd/tests/test_search_linked_only.py b/apps/gcd/tests/test_search_linked_only.py new file mode 100644 index 000000000..b97c17fbb --- /dev/null +++ b/apps/gcd/tests/test_search_linked_only.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +import pytest +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory + +from apps.stddata.models import Country, Language, Script +from apps.gcd.models import ( + Publisher, Series, Issue, Story, StoryType, StoryCredit, CreditType, +) +from apps.gcd.models.creator import Creator, CreatorNameDetail +from apps.gcd.models.story import CREDIT_TYPES +from apps.gcd.views.search import do_advanced_search + +PERSON = 'Test Person A' + +# credit_is_linked values, exactly as the advanced-search dropdown sends them. +# A story credited only in a free-text field is matched by TEXT_CREDITS_ONLY +# and BOTH, but not by LINKED_ONLY (it has no linked credit record). +LINKED_ONLY = '' # dropdown "linked credits only" +BOTH = 'True' # dropdown "both linked and text credits" +TEXT_CREDITS_ONLY = 'False' # dropdown "text credits only" + +# Search field -> the story text field holding the same credit. +TEXT_FIELD = { + 'script': 'script', + 'pencils': 'pencils', + 'inks': 'inks', + 'colors': 'colors', + 'letters': 'letters', + 'story_editing': 'editing', +} + + +def advanced_search(**fields): + request = RequestFactory().get( + '/search/advanced/process/', + dict({'target': 'sequence', 'method': 'icontains'}, **fields)) + request.user = AnonymousUser() + items, _target = do_advanced_search(request) + return set(items.values_list('id', flat=True)) + + +@pytest.fixture +def issue(db): + Script.objects.get_or_create( + id=Script.LATIN_PK, + defaults={'code': 'Latn', 'number': Script.LATIN_PK, + 'name': 'Latin'}) + country = Country.objects.get_or_create( + id=907, defaults={'code': 'q3', 'name': 'Testland A'})[0] + language = Language.objects.get_or_create( + id=907, defaults={'code': 'q4', 'name': 'Testish A'})[0] + publisher = Publisher.objects.create( + name='Test Publisher', country=country, year_began=1965) + series = Series.objects.create( + name='Test Series', sort_name='Test Series', + year_began=1965, country=country, language=language, + publisher=publisher, is_comics_publication=True, has_gallery=False, + publication_dates='1965') + return Issue.objects.create( + number='1', series=series, sort_code=0, + publication_date='1965', key_date='1965-01-00') + + +@pytest.fixture +def story_type(db): + return StoryType.objects.get_or_create( + name='linked-only-sequence', defaults={'sort_code': 99010})[0] + + +def text_credited_story(issue, story_type, field): + # Credited only in the free-text field: no StoryCredit object exists, + # as is the case before a creator's credits are migrated. + return Story.objects.create( + issue=issue, type=story_type, sequence_number=0, + **{TEXT_FIELD[field]: PERSON}) + + +@pytest.mark.parametrize('field', sorted(TEXT_FIELD)) +def test_linked_only_ignores_a_creator_with_no_linked_credit(field, issue, + story_type): + text_credited_story(issue, story_type, field) + + matched = advanced_search(**{field: PERSON}, + credit_is_linked=LINKED_ONLY) + + assert matched == set() + + +@pytest.mark.parametrize('credit_is_linked', [BOTH, TEXT_CREDITS_ONLY], + ids=['both', 'text_only']) +def test_text_credits_are_still_found_when_asked_for(credit_is_linked, issue, + story_type): + story = text_credited_story(issue, story_type, 'script') + + matched = advanced_search(script=PERSON, + credit_is_linked=credit_is_linked) + + assert matched == {story.id} + + +def linked_credited_story(issue, story_type): + creator = Creator.objects.create( + gcd_official_name=PERSON, sort_name=PERSON) + name = CreatorNameDetail.objects.create( + name=PERSON, creator=creator, + in_script=Script.objects.get(id=Script.LATIN_PK)) + linked = Story.objects.create( + issue=issue, type=story_type, sequence_number=1) + StoryCredit.objects.create( + creator=name, + credit_type=CreditType.objects.get_or_create( + id=CREDIT_TYPES['script'], + defaults={'name': 'script', 'sort_code': 1})[0], + story=linked) + return linked + + +def test_linked_only_still_finds_a_linked_credit(issue, story_type): + linked = linked_credited_story(issue, story_type) + + matched = advanced_search(script=PERSON, credit_is_linked=LINKED_ONLY) + + assert matched == {linked.id} + + +def test_both_sources_require_every_linked_creator(issue, story_type): + linked_credited_story(issue, story_type) + + matched = advanced_search( + script='%s; Test Person Absent' % PERSON, + credit_is_linked=BOTH) + + assert matched == set() diff --git a/apps/gcd/views/search.py b/apps/gcd/views/search.py index 87b05193a..86a3b384d 100644 --- a/apps/gcd/views/search.py +++ b/apps/gcd/views/search.py @@ -2216,6 +2216,37 @@ def handle_numbers(field, data, prefix): return reduce(lambda x, y: x | y, q_or_only) +def _linked_story_ids(creator, credit_field, op): + creator_q_obj = Q(**{'name__%s' % op: creator}) + creator_q_obj |= Q(**{ + 'creator__gcd_official_name__%s' % op: creator, + }) + # Materialize the ids; MySQL optimizes IN (subquery) poorly. See + # https://docs.djangoproject.com/en/5.2/ref/models/querysets/#nested-queries-performance + creators = list(CreatorNameDetail.objects.filter(creator_q_obj) + .values_list('id', flat=True)) + return list(Story.objects.filter( + credits__creator__id__in=creators, + credits__deleted=False, + credits__credit_type__id=CREDIT_TYPES[credit_field]) + .values_list('id', flat=True)) + + +def _linked_story_credit_filters(search_value, credit_field, prefix, op): + creator_names = [creator.strip() + for creator in search_value.split(';') + if creator.strip()] + if not creator_names: + return [Q(**{'%sid__in' % prefix: [-1]})] + + filters = [] + for creator in creator_names: + # Keep unmatched terms represented so AND searches cannot drop them. + stories = _linked_story_ids(creator, credit_field, op) or [-1] + filters.append(Q(**{'%sid__in' % prefix: stories})) + return filters + + def search_stories(data, op): """ Build the query against the story table. As it is the lowest @@ -2229,45 +2260,23 @@ def search_stories(data, op): linked_credits_q_objs = [] q_and_only = [] - for field in ('script', 'pencils', 'inks', 'colors', 'letters'): - if data[field]: - text_credits_q_objs.append( - Q(**{'%s%s__%s' % (prefix, field, op): data[field]})) - for creator in data[field].split(';'): - creator = creator.strip() - creator_q_obj = Q(**{'name__%s' % (op): creator}) - creator_q_obj |= Q(**{'creator__gcd_official_name__%s' % (op): - creator}) - creators = list(CreatorNameDetail.objects.filter(creator_q_obj) - .values_list('id', flat=True)) - stories = list(Story.objects.filter( - credits__creator__id__in=creators, - credits__deleted=False, - credits__credit_type__id=CREDIT_TYPES[field]) - .values_list('id', flat=True)) - if (stories): - linked_credits_q_objs.append( - (Q(**{'%sid__in' % (prefix): stories})) - ) - - if data['story_editing']: - text_credits_q_objs.append(Q(**{'%sediting__%s' % (prefix, op): - data['story_editing']})) - - creator_q_obj = Q(**{'name__%s' % (op): data['story_editing']}) - creator_q_obj |= Q(**{'creator__gcd_official_name__%s' % (op): - data['story_editing']}) - creators = list(CreatorNameDetail.objects.filter(creator_q_obj) - .values_list('id', flat=True)) - stories = list(Story.objects.filter( - credits__creator__id__in=creators, - credits__deleted=False, - credits__credit_type__id=CREDIT_TYPES['editing']) - .values_list('id', flat=True)) - if (stories): - linked_credits_q_objs.append( - (Q(**{'%sid__in' % (prefix): stories})) - ) + story_credit_fields = { + 'script': 'script', + 'pencils': 'pencils', + 'inks': 'inks', + 'colors': 'colors', + 'letters': 'letters', + 'story_editing': 'editing', + } + for search_field, credit_field in story_credit_fields.items(): + search_value = data[search_field] + if not search_value: + continue + + text_credits_q_objs.append( + Q(**{'%s%s__%s' % (prefix, credit_field, op): search_value})) + linked_credits_q_objs.extend(_linked_story_credit_filters( + search_value, credit_field, prefix, op)) for field in ('title', 'first_line', 'job_number', 'characters', 'synopsis', 'reprint_notes', 'notes'): From 8227388ee4e541f9322225958d3a2d727ec84eff Mon Sep 17 00:00:00 2001 From: jhunterjActual <47950049+jhunterjActual@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:54:07 -0400 Subject: [PATCH 45/77] Handle Cloudflare challenges in editing forms (#734) * Handle Cloudflare challenges in editing forms * Address review feedback and use the base site for the Cloudflare challenge URL instead of the autocomplete --- static/css/input.css | 21 +++++++++++- static/css/output.css | 22 ++++++++++++- static/js/cloudflare_challenge.js | 42 ++++++++++++++++++++++++ static/js/oi/issuerevision_form_utils.js | 3 -- templates/oi/base_view.html | 14 +++++++- templates/oi/bits/jquery.html | 3 ++ templates/oi/edit/add_frame.html | 6 ---- templates/oi/edit/bulk_frame.html | 6 ---- templates/oi/edit/revision.html | 6 ---- 9 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 static/js/cloudflare_challenge.js diff --git a/static/css/input.css b/static/css/input.css index a0f21aabf..71719bcb7 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -137,6 +137,25 @@ @apply text-red-400 font-bold; } + /* Keep verification visible while preserving unsaved form fields. */ + .cloudflare_challenge_notice { + position: fixed; + z-index: 50; + top: 0; + right: 0; + left: 0; + padding: 0.75rem 1rem; + background-color: #fef3c7; + border-bottom: 1px solid #854d0e; + color: #713f12; + text-align: center; + } + + .cloudflare_challenge_notice a { + font-weight: bold; + text-decoration: underline; + } + .added { @apply bg-green-400; } @@ -203,4 +222,4 @@ html { textarea, select { @apply py-[0.2em] ps-[0.5rem] border-gray-500; -} \ No newline at end of file +} diff --git a/static/css/output.css b/static/css/output.css index 3b19894a7..90a4965a8 100644 --- a/static/css/output.css +++ b/static/css/output.css @@ -1157,6 +1157,26 @@ a:hover { color: rgb(248 113 113 / var(--tw-text-opacity)); } +/* Keep verification visible while preserving unsaved form fields. */ + +.cloudflare_challenge_notice { + position: fixed; + z-index: 50; + top: 0; + right: 0; + left: 0; + padding: 0.75rem 1rem; + background-color: #fef3c7; + border-bottom: 1px solid #854d0e; + color: #713f12; + text-align: center; +} + +.cloudflare_challenge_notice a { + font-weight: bold; + text-decoration: underline; +} + .added { --tw-bg-opacity: 1; background-color: rgb(74 222 128 / var(--tw-bg-opacity)); @@ -3326,4 +3346,4 @@ select { .\[\&_ul\]\:columns-1 ul { -moz-columns: 1; columns: 1; -} \ No newline at end of file +} diff --git a/static/js/cloudflare_challenge.js b/static/js/cloudflare_challenge.js new file mode 100644 index 000000000..ca4d63fe6 --- /dev/null +++ b/static/js/cloudflare_challenge.js @@ -0,0 +1,42 @@ +/* + * Detect Cloudflare Challenge Pages returned to jQuery AJAX calls. + * + * A Challenge Page contains HTML instead of the JSON expected by + * autocomplete controls. The base template provides a translated + * notice that this script reveals when verification is required. + */ +(function(window, document, $) { + 'use strict'; + + // This template can be included more than once on a page. + if (!$ || window.gcdCloudflareChallengeHandlerInstalled) { + return; + } + window.gcdCloudflareChallengeHandlerInstalled = true; + + const bannerId = 'cloudflare_challenge_notice'; + + function showChallengeNotice() { + const notice = document.getElementById(bannerId); + if (!notice) { + return; + } + + const verifyLink = notice.querySelector('a'); + if (!verifyLink) { + return; + } + notice.classList.remove('hidden'); + } + + $(document).ajaxComplete(function(event, xhr, settings) { + if (!xhr || typeof xhr.getResponseHeader !== 'function') { + return; + } + // Cloudflare sets this header on every Challenge Page response. + const mitigation = xhr.getResponseHeader('cf-mitigated'); + if (mitigation && mitigation.toLowerCase() === 'challenge') { + showChallengeNotice(); + } + }); +})(window, document, window.jQuery); diff --git a/static/js/oi/issuerevision_form_utils.js b/static/js/oi/issuerevision_form_utils.js index d91254050..b2ac67e3d 100644 --- a/static/js/oi/issuerevision_form_utils.js +++ b/static/js/oi/issuerevision_form_utils.js @@ -405,9 +405,6 @@ $(function() { migrate_button.removeClass('btn-blue-disabled px-2 py-1'); migrate_button.addClass('btn-blue-editing'); }); - - // Add brand emblem images - $("#id_brand").msDropDown({addToWidth: $("#id_brand").addToOptionWidth()}); }); $(document).on('change', 'input[type=checkbox]', function () { diff --git a/templates/oi/base_view.html b/templates/oi/base_view.html index f28b6524f..831ce25c5 100644 --- a/templates/oi/base_view.html +++ b/templates/oi/base_view.html @@ -1,7 +1,19 @@ {% extends "gcd/tw_base_view.html" %} +{% load i18n %} {% block nav_bar %} {{ block.super }} +
      diff --git a/templates/gcd/details/tw_feature_logo.html b/templates/gcd/details/tw_feature_logo.html index 5ee17087c..732567893 100644 --- a/templates/gcd/details/tw_feature_logo.html +++ b/templates/gcd/details/tw_feature_logo.html @@ -20,8 +20,8 @@
      • Feature: - {% for feature in feature_logo.feature.all %} - {{ feature }} + {% for feature_name in feature_logo.feature_name.all %} + {{ feature_name }} {% if not forloop.last %}; {% endif %} {% endfor %}
      • @@ -43,7 +43,7 @@ {% endif %}
      From 180e9c481ec5c093dc2cb0bf88414be6f95f1be6 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Thu, 13 Aug 2026 23:06:17 +0200 Subject: [PATCH 52/77] remove not used field from imps --- apps/oi/models.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/oi/models.py b/apps/oi/models.py index 5e0a7e23f..0148d35fb 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -7694,11 +7694,6 @@ def _get_blank_values(self): } def _imps_for(self, field_name): - if field_name == 'sort_name': - if self.sort_name == self.name: - return 0 - else: - return 1 if field_name in self._field_list(): return 1 return 0 From 6192d7a6b3c23c9f9667152e76d124ecb265b3ea Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Fri, 14 Aug 2026 05:21:48 +0200 Subject: [PATCH 53/77] feature description on form --- apps/oi/forms/feature.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/oi/forms/feature.py b/apps/oi/forms/feature.py index 81f758f29..1b5ab860c 100644 --- a/apps/oi/forms/feature.py +++ b/apps/oi/forms/feature.py @@ -17,7 +17,8 @@ FeatureRelationRevision, FeatureNameDetailRevision, remove_leading_article) -from .support import (KeywordBaseForm, FEATURE_HELP_LINKS, HiddenInputWithHelp, +from .support import (CharacterBaseForm, FEATURE_HELP_LINKS, + HiddenInputWithHelp, _get_comments_form_field, combine_reverse_relations, GENERIC_ERROR_MESSAGE, _create_embedded_image_revision, _save_runtime_embedded_image_revision) @@ -112,7 +113,7 @@ def clean(self): formset=FeatureInlineFormSet) -class FeatureRevisionForm(KeywordBaseForm): +class FeatureRevisionForm(CharacterBaseForm): class Meta: model = FeatureRevision fields = model._base_field_list @@ -132,12 +133,12 @@ def __init__(self, *args, **kwargs): template='oi/bits/uni_field.html'))] field_list.append(Formset('feature_names_formset')) field_list.extend(BaseField(Field(field, - template='oi/bits/uni_field.html')) - for field in fields[:genres]) + template='oi/bits/uni_field.html')) + for field in fields[:genres]) field_list.append(HTML( 'Selected Genre:' '')) - description_pos = fields.index('notes') + description_pos = fields.index('description') field_list.extend([BaseField(Field(field, template='oi/bits/uni_field.html')) @@ -145,7 +146,7 @@ def __init__(self, *args, **kwargs): field_list.append(Formset('external_link_formset')) field_list.extend([BaseField(Field(field, template='oi/bits/uni_field.html')) - for field in fields[description_pos:]]) + for field in fields[description_pos:-1]]) self.helper.layout = Layout(*(f for f in field_list)) self.helper.doc_links = FEATURE_HELP_LINKS From c2759442793706f4e5aea7166944744645c7faba Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Fri, 14 Aug 2026 09:02:32 +0200 Subject: [PATCH 54/77] fix order search by brand --- apps/gcd/views/search.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/gcd/views/search.py b/apps/gcd/views/search.py index 86a3b384d..1e8106296 100644 --- a/apps/gcd/views/search.py +++ b/apps/gcd/views/search.py @@ -2563,7 +2563,7 @@ def compute_order(data): elif order == 'indicia_publisher': terms.append('indicia_publisher') elif order == 'brand': - terms.append('brand') + terms.append('brand_emblem') elif order == 'publisher': terms.append('series__publisher') elif order == 'country': @@ -2576,6 +2576,8 @@ def compute_order(data): terms.append('issue__series__publisher') elif order == 'indicia_publisher': terms.append('issue__indicia_publisher') + elif order == 'brand': + terms.append('issue__brand_emblem') elif order == 'series': terms.append('issue__series') elif order == 'date': From a0f275990a0ae44a42b4056aa36785eae6a7b61f Mon Sep 17 00:00:00 2001 From: jhunterjActual <47950049+jhunterjActual@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:42:56 -0400 Subject: [PATCH 55/77] Remove related revisions when deleting stories (#740) --- apps/oi/models.py | 41 +++++---- apps/oi/tests/db/test_story_revision.py | 111 +++++++++++++++++++++++- 2 files changed, 133 insertions(+), 19 deletions(-) diff --git a/apps/oi/models.py b/apps/oi/models.py index 0148d35fb..3e9f92997 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -5970,10 +5970,12 @@ def _get_stats_category_field_tuples(cls): ('issue', 'series', 'language',)}) def _pre_delete(self, changes): - # sigh, some people add story_credits to be deleted stories - for revision in self.story_credit_revisions.all(): - if revision.added: - revision.delete() + for related_name in ('story_credit_revisions', + 'story_character_revisions', + 'story_group_revisions'): + for revision in getattr(self, related_name).all(): + if revision.added: + revision.delete() @classmethod def copied_revision(cls, story, changeset, issue_revision, @@ -6232,10 +6234,20 @@ def _do_complete_added_revision(self, issue): self.issue = issue def _reset_values(self): - # TODO: undo StoryCredit and StoryCharacter changes - # TODO: remove added StoryCredit, StoryCharacter - # and StoryGroup revisions if self.deleted: + for related_name in ('story_credit_revisions', + 'story_character_revisions', + 'story_group_revisions'): + for revision in getattr(self, related_name).all(): + for field in revision._get_single_value_fields(): + setattr(revision, field, + getattr(revision.source, field)) + revision.deleted = True + revision.save() + for field in revision._get_multi_value_fields(): + getattr(revision, field).set( + getattr(revision.source, field).all()) + # users can edit story revisions before deleting them. # ensure that the final deleted revision matches the # final state of the story. @@ -6735,15 +6747,12 @@ def toggle_deleted(self): when the revision is committed. """ self.deleted = not self.deleted - if bool(self.story_credit_revisions.all()): - for story_credit_revision in self.story_credit_revisions.all(): - story_credit_revision.deleted = self.deleted - story_credit_revision.save() - if bool(self.story_character_revisions.all()): - for story_character_revision in self.story_character_revisions\ - .all(): - story_character_revision.deleted = self.deleted - story_character_revision.save() + for related_name in ('story_credit_revisions', + 'story_character_revisions', + 'story_group_revisions'): + for revision in getattr(self, related_name).all(): + revision.deleted = self.deleted + revision.save() self.save() def get_absolute_url(self): diff --git a/apps/oi/tests/db/test_story_revision.py b/apps/oi/tests/db/test_story_revision.py index 9e2fd016b..8e624c5c6 100644 --- a/apps/oi/tests/db/test_story_revision.py +++ b/apps/oi/tests/db/test_story_revision.py @@ -4,9 +4,13 @@ import mock import pytest -from apps.gcd.models import Publisher, Series, Issue, Story, INDEXED -from apps.oi.models import StoryRevision -from apps.stddata.models import Country, Language +from apps.gcd.models import ( + Publisher, Series, Issue, Story, StoryCredit, StoryCharacter, StoryGroup, + Creator, CreatorNameDetail, CreditType, Character, CharacterNameDetail, + Group, GroupNameDetail, INDEXED) +from apps.oi.models import ( + StoryCreditRevision, StoryCharacterRevision, StoryGroupRevision) +from apps.stddata.models import Country, Language, Script from apps.stats.models import CountStats UPDATE_ALL = 'apps.stats.models.CountStats.objects.update_all_counts' @@ -320,3 +324,104 @@ def set_indexed_status(self): language=rev.issue.series.language)) updater_mock.assert_has_calls(expected_calls, any_order=True) assert updater_mock.call_count == len(expected_calls) + + +@pytest.mark.django_db +def test_delete_story_resets_related_revisions( + any_added_story_rev, any_edit_story_rev, any_editing_changeset, + any_language): + story = any_edit_story_rev.story + approved_changeset = any_added_story_rev.changeset + + creator = Creator.objects.create( + gcd_official_name='Test Creator', birth_province='', birth_city='', + death_province='', death_city='', bio='', notes='') + script = Script.objects.create(code='Tst', number=999, + name='Test Script') + creator_name = CreatorNameDetail.objects.create( + name='Test Creator', creator=creator, is_official_name=True, + in_script=script) + credit_type = CreditType.objects.create(name='test credit', sort_code=999) + credit = StoryCredit.objects.create( + story=story, creator=creator_name, credit_type=credit_type, + signed_as='', credited_as='', sourced_by='', credit_name='') + StoryCreditRevision.objects.create( + changeset=approved_changeset, story_revision=any_added_story_rev, + story_credit=credit, creator=creator_name, credit_type=credit_type) + credit_revision = StoryCreditRevision.clone( + credit, any_editing_changeset, story_revision=any_edit_story_rev) + credit_revision.uncertain = True + credit_revision.save() + added_credit_revision = StoryCreditRevision.objects.create( + changeset=any_editing_changeset, story_revision=any_edit_story_rev, + creator=creator_name, credit_type=credit_type) + + character = Character.objects.create( + name='Test Character', disambiguation='', language=any_language, + description='', notes='') + character_name = CharacterNameDetail.objects.create( + name='Test Character', character=character, is_official_name=True) + group = Group.objects.create( + name='Test Group', disambiguation='', language=any_language, + description='', notes='') + group_name = GroupNameDetail.objects.create( + name='Test Group', group=group, is_official_name=True) + + story_character = StoryCharacter.objects.create( + story=story, character=character_name, notes='original character notes') + story_character.group_name.add(group_name) + previous_character_revision = StoryCharacterRevision.objects.create( + changeset=approved_changeset, story_revision=any_added_story_rev, + story_character=story_character, character=character_name, + notes='original character notes') + previous_character_revision.group_name.add(group_name) + character_revision = StoryCharacterRevision.clone( + story_character, any_editing_changeset, + story_revision=any_edit_story_rev) + character_revision.notes = 'changed character notes' + character_revision.save() + character_revision.group_name.clear() + added_character_revision = StoryCharacterRevision.objects.create( + changeset=any_editing_changeset, story_revision=any_edit_story_rev, + character=character_name) + + story_group = StoryGroup.objects.create( + story=story, group_name=group_name, notes='original group notes') + StoryGroupRevision.objects.create( + changeset=approved_changeset, story_revision=any_added_story_rev, + story_group=story_group, group_name=group_name, + notes='original group notes') + group_revision = StoryGroupRevision.clone( + story_group, any_editing_changeset, story_revision=any_edit_story_rev) + group_revision.notes = 'changed group notes' + group_revision.save() + added_group_revision = StoryGroupRevision.objects.create( + changeset=any_editing_changeset, story_revision=any_edit_story_rev, + group_name=group_name) + + any_edit_story_rev.toggle_deleted() + + credit_revision.refresh_from_db() + character_revision.refresh_from_db() + group_revision.refresh_from_db() + assert credit_revision.deleted is True + assert character_revision.deleted is True + assert group_revision.deleted is True + + any_edit_story_rev._pre_delete({}) + any_edit_story_rev._reset_values() + + for revision in (added_credit_revision, added_character_revision, + added_group_revision): + assert not type(revision).objects.filter(pk=revision.pk).exists() + + credit_revision.refresh_from_db() + character_revision.refresh_from_db() + group_revision.refresh_from_db() + assert credit_revision.deleted is True + assert character_revision.deleted is True + assert group_revision.deleted is True + assert credit_revision.uncertain is False + assert character_revision.notes == 'original character notes' + assert list(character_revision.group_name.all()) == [group_name] + assert group_revision.notes == 'original group notes' From c0b5c3aa17fed4ee784777fc2f8cc6a5d29ffb05 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 15 Aug 2026 11:29:15 +0200 Subject: [PATCH 56/77] update search link --- templates/projects/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/projects/index.html b/templates/projects/index.html index 0a8aef193..7bba2d043 100644 --- a/templates/projects/index.html +++ b/templates/projects/index.html @@ -18,7 +18,7 @@

      Overview of Current Data Projects

      From e76fa04d962c2c977a77942f5d0c2d5cd428157e Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 15 Aug 2026 11:29:50 +0200 Subject: [PATCH 57/77] change API access mode --- settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/settings.py b/settings.py index 66e743a79..bcefb3e7b 100644 --- a/settings.py +++ b/settings.py @@ -359,8 +359,7 @@ # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly', - 'rest_framework.permissions.IsAuthenticated', + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.BasicAuthentication', From 0a0767947c53c4813dbbeb613b3f63c2cfc4208e Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 15 Aug 2026 11:31:35 +0200 Subject: [PATCH 58/77] add missing colors --- static/css/output.css | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/static/css/output.css b/static/css/output.css index 90a4965a8..36a23985b 100644 --- a/static/css/output.css +++ b/static/css/output.css @@ -1204,11 +1204,10 @@ a:hover { } .object-page-numbered-list { - /* @apply ms-2 list-decimal sm:columns-2 list-outside ps-4; */ margin-inline-start: 0.5rem; display: grid; list-style-type: decimal; - padding-inline-start: 1rem + padding-inline-start: 1rem; } @media (min-width: 640px) { @@ -2005,6 +2004,11 @@ a:hover { border-color: rgb(107 114 128 / var(--tw-border-opacity)); } +.border-orange-400 { + --tw-border-opacity: 1; + border-color: rgb(251 146 60 / var(--tw-border-opacity)); +} + .\!bg-white { --tw-bg-opacity: 1 !important; background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important; @@ -2100,6 +2104,11 @@ a:hover { background-color: rgb(169 48 42 / var(--tw-bg-opacity)); } +.bg-orange-400 { + --tw-bg-opacity: 1; + background-color: rgb(251 146 60 / var(--tw-bg-opacity)); +} + .bg-preview { --tw-bg-opacity: 1; background-color: rgb(255 233 68 / var(--tw-bg-opacity)); From 8ddcf53f385b633763dd999da293d97c9eb066a3 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 15 Aug 2026 13:09:28 +0200 Subject: [PATCH 59/77] update use of new filter_issues --- apps/gcd/views/details.py | 4 +--- apps/gcd/views/search.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index edfad2326..4d623c075 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -4827,9 +4827,7 @@ def character_covers(request, character_id, universe_id=None): issues = Issue.objects.filter(**query).distinct()\ .select_related('series__publisher') - filter = filter_issues(request, issues) - filter.filters.pop('language') - issues = filter.qs + filter, issues = filter_issues(request, issues, language_filter=False) context = { 'result_disclaimer': (COVER_CHECKLIST_DISCLAIMER + diff --git a/apps/gcd/views/search.py b/apps/gcd/views/search.py index 1e8106296..9bbb38972 100644 --- a/apps/gcd/views/search.py +++ b/apps/gcd/views/search.py @@ -390,8 +390,7 @@ def generic_by_name(request, name, q_obj, sort, else: order_by = 'issue' if things: - filter = filter_issues(request, things) - things = filter.qs + filter, things = filter_issues(request, things) filter_form = filter.form else: filter_form = None From beb8052abc9b14761e8c734a3eeab88f696e88a9 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 15 Aug 2026 13:14:58 +0200 Subject: [PATCH 60/77] update use of new filter_issues --- apps/gcd/views/details.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index 4d623c075..850f9dfdd 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -4505,9 +4505,8 @@ def character_issues_character(request, character_id, character_with_id, issues = Issue.objects.filter(Q(**query_with)).distinct()\ .select_related('series__publisher') - filter = filter_issues(request, issues, story_type_filter=True) - filter.filters.pop('language') - issues = filter.qs + filter, issues = filter_issues(request, issues, story_type_filter=True, + language_filter=False) result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER From e095010d6055b1985bb202067c319b18548349f6 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 15 Aug 2026 13:49:59 +0200 Subject: [PATCH 61/77] remove story filter disclaimer --- apps/gcd/views/details.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index 850f9dfdd..6b38d6fa9 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -4508,7 +4508,7 @@ def character_issues_character(request, character_id, character_with_id, filter, issues = filter_issues(request, issues, story_type_filter=True, language_filter=False) - result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER + result_disclaimer = CHAR_MIGRATE_DISCLAIMER context = { 'result_disclaimer': result_disclaimer, @@ -4551,7 +4551,7 @@ def character_issues_group(request, character_id, group_id, filter, issues = filter_issues(request, issues, story_type_filter=True, language_filter=False) - result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER + result_disclaimer = CHAR_MIGRATE_DISCLAIMER context = { 'result_disclaimer': result_disclaimer, @@ -4594,7 +4594,7 @@ def character_issues_feature(request, character_id, feature_id, .select_related('series__publisher') filter, issues = filter_issues(request, issues, story_type_filter=True, language_filter=False) - result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER + result_disclaimer = CHAR_MIGRATE_DISCLAIMER context = { 'result_disclaimer': result_disclaimer, @@ -4632,7 +4632,7 @@ def character_issues_series(request, character_id, series_id, issues = Issue.objects.filter(Q(**query)).distinct()\ .select_related('series__publisher') - result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER + result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + CHAR_MIGRATE_DISCLAIMER context = { 'result_disclaimer': result_disclaimer, @@ -4723,25 +4723,25 @@ def character_creators(request, character_id, creator_names=False, 'working on character %s', (character,)) stories = Story.objects.filter(**query).distinct() + story_types = process_story_type_filter_from_request(request) filter, stories = filter_sequences(request, stories, language_filter=False) stories_ids = stories.values_list('id', flat=True) if creator_names: creators = CreatorNameDetail.objects.filter( storycredit__story__id__in=stories_ids, - storycredit__story__type__id__in=CORE_TYPES, + storycredit__story__type__id__in=story_types, storycredit__deleted=False, storycredit__credit_type__id__lt=6) creators = _annotate_creator_name_detail_list(creators) else: creators = Creator.objects.filter( creator_names__storycredit__story__id__in=stories_ids, - creator_names__storycredit__story__type__id__in=CORE_TYPES, + creator_names__storycredit__story__type__id__in=story_types, creator_names__storycredit__deleted=False, creator_names__storycredit__credit_type__id__lt=6) creators = _annotate_creator_list(creators) - result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER + \ - CHAR_MIGRATE_DISCLAIMER + result_disclaimer = MIGRATE_DISCLAIMER + CHAR_MIGRATE_DISCLAIMER context = { 'result_disclaimer': result_disclaimer, @@ -4786,7 +4786,6 @@ def character_sequences(request, character_id, universe_id=None): (character,)) stories = Story.objects.filter(**query).distinct()\ .select_related('issue__series__publisher') - filter, stories = filter_sequences(request, stories, language_filter=False) context = { @@ -4899,7 +4898,7 @@ def character_name_issues(request, character_name_id, universe_id=None): issues = Issue.objects.filter(**query).distinct()\ .select_related('series__publisher') - result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER + result_disclaimer = CHAR_MIGRATE_DISCLAIMER filter, issues = filter_issues(request, issues, story_type_filter=True, language_filter=False) From 7d82158c773d61525979cf9468091d0b8c466d93 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 10:51:35 +0200 Subject: [PATCH 62/77] =?UTF-8?q?use=20feature=20names,=20remove=C2=A0sequ?= =?UTF-8?q?ence=20disclaimer=20partly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gcd/views/details.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index 6b38d6fa9..df6ab5db6 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -140,6 +140,9 @@ CHAR_MIGRATE_DISCLAIMER = ' Text character appearances are currently being ' \ 'migrated to links. Therefore not all appearances ' \ ' in our database are shown here.' +FEATURE_MIGRATE_DISCLAIMER = ' Text features are currently being migrated ' \ + 'to links. Therefore not all occurrences ' \ + ' in our database are shown here.' WITHOUT_UNIVERSE_NAME = 'without a universe' @@ -841,7 +844,7 @@ def checklist_by_id(request, creator_id, series_id=None, character_id=None, else: filter, issues = filter_issues(request, issues, story_type_filter=True) context = { - 'result_disclaimer': ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER, + 'result_disclaimer': MIGRATE_DISCLAIMER, 'item_name': 'issue', 'plural_suffix': 's', 'heading': heading, @@ -1167,7 +1170,7 @@ def creator_name_checklist(request, creator_name_id, character_id=None, issues = issues.filter(series__language=language) context = { - 'result_disclaimer': ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER, + 'result_disclaimer': MIGRATE_DISCLAIMER, 'item_name': 'issue', 'plural_suffix': 's', 'heading': 'for creator %s %s' % (creator, heading_addon) @@ -3636,7 +3639,7 @@ def feature_genres(request, feature_id): def feature_sequences(request, feature_id, country=None): feature = get_gcd_object(Feature, feature_id) - stories = Story.objects.filter(feature_object=feature, + stories = Story.objects.filter(feature_name__feature=feature, deleted=False).distinct()\ .select_related('issue__series__publisher') if country: @@ -3678,12 +3681,12 @@ def feature_issues(request, feature_id, to_be_migrated=False): story__type__id__in=story_types, story__deleted=False).distinct()\ .select_related('series__publisher') - result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER + result_disclaimer = FEATURE_MIGRATE_DISCLAIMER else: issues = Issue.objects.filter(story__feature_name__feature=feature, story__deleted=False).distinct()\ .select_related('series__publisher') - result_disclaimer = MIGRATE_DISCLAIMER + result_disclaimer = FEATURE_MIGRATE_DISCLAIMER filter, issues = filter_issues(request, issues, story_type_filter=True, language_filter=False) @@ -3739,7 +3742,7 @@ def feature_overview(request, feature_id): feature = get_gcd_object(Feature, feature_id) if feature.feature_type.id == 1: - issues = Issue.objects.filter(story__feature_object=feature, + issues = Issue.objects.filter(story__feature_name__feature=feature, story__type__id=19, story__deleted=False).distinct()\ .select_related('series__publisher') @@ -3776,7 +3779,7 @@ def feature_overview(request, feature_id): def feature_characters(request, feature_id): feature = get_gcd_object(Feature, feature_id) characters = Character.objects.filter( - character_names__storycharacter__story__feature_object=feature, + character_names__storycharacter__story__feature_name__feature=feature, character_names__storycharacter__story__type__id__in=CORE_TYPES, character_names__storycharacter__deleted=False, deleted=False).distinct() @@ -3811,13 +3814,13 @@ def feature_creators(request, feature_id, creator_names=False): creators = CreatorNameDetail.objects.all() if feature.feature_type.id == 1: creators = creators.filter( - storycredit__story__feature_object__id=feature_id, + storycredit__story__feature_name__feature=feature, storycredit__story__type__id__in=CORE_TYPES, storycredit__deleted=False).distinct().select_related('creator') result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER else: creators = creators.filter( - storycredit__story__feature_object__id=feature_id, + storycredit__story__feature_name__feature=feature, storycredit__deleted=False).distinct().select_related('creator') result_disclaimer = MIGRATE_DISCLAIMER creators = _annotate_creator_name_detail_list(creators) @@ -3825,13 +3828,13 @@ def feature_creators(request, feature_id, creator_names=False): creators = Creator.objects.all() if feature.feature_type.id == 1: creators = creators.filter( - creator_names__storycredit__story__feature_object__id=feature_id, + creator_names__storycredit__story__feature_name__feature=feature, creator_names__storycredit__story__type__id__in=CORE_TYPES, creator_names__storycredit__deleted=False).distinct() result_disclaimer = ISSUE_CHECKLIST_DISCLAIMER + MIGRATE_DISCLAIMER else: creators = creators.filter( - creator_names__storycredit__story__feature_object__id=feature_id, + creator_names__storycredit__story__feature_name__feature=feature, creator_names__storycredit__deleted=False).distinct() result_disclaimer = MIGRATE_DISCLAIMER creators = _annotate_creator_list(creators) @@ -3858,7 +3861,7 @@ def feature_creators(request, feature_id, creator_names=False): def feature_covers(request, feature_id): feature = get_gcd_object(Feature, feature_id) - issues = Issue.objects.filter(story__feature_object=feature, + issues = Issue.objects.filter(story__feature_name__feature=feature, story__type__id=6, story__deleted=False).distinct()\ .select_related('series__publisher') From 9d4b57a52067a531f47a546369855df48cf7bb25 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 15:21:29 +0200 Subject: [PATCH 63/77] display reboot --- apps/gcd/models/__init__.py | 2 +- apps/gcd/models/seriesbond.py | 1 + apps/gcd/templatetags/display.py | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/gcd/models/__init__.py b/apps/gcd/models/__init__.py index 1f18799be..c7210258c 100644 --- a/apps/gcd/models/__init__.py +++ b/apps/gcd/models/__init__.py @@ -26,7 +26,7 @@ from .cover import Cover from .reprint import Reprint from .seriesbond import SeriesBondType, SeriesBond, BOND_TRACKING, \ - SUBNUMBER_TRACKING, MERGE_TRACKING + SUBNUMBER_TRACKING, MERGE_TRACKING, REBOOT_TRACKING from .image import ImageType, Image from .creator import CreatorArtInfluence, Creator, CreatorDegree, \ CreatorNameDetail, CreatorSignature, CreatorSchool,\ diff --git a/apps/gcd/models/seriesbond.py b/apps/gcd/models/seriesbond.py index 787865e40..187ad08c8 100644 --- a/apps/gcd/models/seriesbond.py +++ b/apps/gcd/models/seriesbond.py @@ -5,6 +5,7 @@ BOND_TRACKING = {1, 2, 3, 4, 5, 6, 7} SUBNUMBER_TRACKING = 4 MERGE_TRACKING = {5, 6} +REBOOT_TRACKING = {7} class SeriesBondType(models.Model): diff --git a/apps/gcd/templatetags/display.py b/apps/gcd/templatetags/display.py index 58c03177f..c0cdfd831 100644 --- a/apps/gcd/templatetags/display.py +++ b/apps/gcd/templatetags/display.py @@ -24,7 +24,7 @@ Character, Group, CharacterRelation, \ GroupRelation, GroupMembership, Universe, \ INDEXED, SeriesBond, BOND_TRACKING, \ - SUBNUMBER_TRACKING, MERGE_TRACKING + SUBNUMBER_TRACKING, MERGE_TRACKING, REBOOT_TRACKING from apps.gcd.views.covers import get_image_tag register = template.Library() @@ -230,7 +230,9 @@ def show_series_tracking(series): if srbond.bond.bond_type.id == SUBNUMBER_TRACKING: tracking_line += '
    • subnumbering continues ' elif srbond.bond.bond_type.id in MERGE_TRACKING: - tracking_line += '
    • merged ' + tracking_line += '
    • merges ' + elif srbond.bond.bond_type.id in REBOOT_TRACKING: + tracking_line += '
    • reboots ' else: tracking_line += '
    • numbering continues ' if (srbond.near_issue != srbond.near_issue_default): From ba64af2c34bc68e4ae2d40cabfda5d159f24d9e0 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 15:22:47 +0200 Subject: [PATCH 64/77] filter_sequence change --- apps/gcd/views/search.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/gcd/views/search.py b/apps/gcd/views/search.py index 9bbb38972..1409c24f2 100644 --- a/apps/gcd/views/search.py +++ b/apps/gcd/views/search.py @@ -460,8 +460,7 @@ def generic_by_name(request, name, q_obj, sort, query_val[credit] = name credit = None things = things.prefetch_related('feature_object') - filter = filter_sequences(request, things) - things = filter.qs + filter, things = filter_sequences(request, things) table = StoryTable( things, template_name='gcd/bits/tw_sortable_table.html', @@ -481,8 +480,7 @@ def generic_by_name(request, name, q_obj, sort, query_val['logic'] = True else: target = credit - filter = filter_sequences(request, things) - things = filter.qs + filter, things = filter_sequences(request, things) table = MatchedSearchStoryTable( things, attrs={'class': 'sortable_listing'}, From 2d1274397b20722d468fc9e4279d1ee948b2015b Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 15:23:13 +0200 Subject: [PATCH 65/77] unneeded check --- apps/gcd/models/feature.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/gcd/models/feature.py b/apps/gcd/models/feature.py index 944129a6a..f124ee6f0 100644 --- a/apps/gcd/models/feature.py +++ b/apps/gcd/models/feature.py @@ -68,7 +68,6 @@ class Meta: def has_dependents(self): return bool(self.active_logos().exists()) or \ bool(self.active_stories().exists()) or \ - bool(self.active_names().exists()) or \ bool(self.from_related_feature.all().exists()) or \ bool(self.to_related_feature.all().exists()) From 64d7e2bb847544288d0e3c62238a0e7b185266b7 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 15:50:36 +0200 Subject: [PATCH 66/77] feature_name instead of feature_object --- apps/gcd/views/details.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index df6ab5db6..a2b25b417 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -815,7 +815,7 @@ def checklist_by_id(request, creator_id, series_id=None, character_id=None, issues = issues.filter(story__credits__creator__creator=creator, story__type__id__in=story_types, story__credits__credit_type__id__lt=6, - story__feature_object=feature, + story__feature_name__feature=feature, story__credits__deleted=False, story__deleted=False).distinct() heading = 'for creator %s on feature %s' % (creator, @@ -1155,7 +1155,7 @@ def creator_name_checklist(request, creator_name_id, character_id=None, if feature_id: feature = get_gcd_object(Feature, feature_id) issues = issues.filter(story__credits__creator=creator, - story__feature_object=feature) + story__feature_name__feature=feature) heading_addon = 'on feature %s' % (feature) if series_id: series = get_gcd_object(Series, series_id) @@ -3603,7 +3603,7 @@ def show_feature(request, feature, preview=False): table.no_export = True table.not_sticky = True - issues = Issue.objects.filter(story__feature_object=feature, + issues = Issue.objects.filter(story__feature_name__feature=feature, story__type__id=6, story__credits__deleted=False, cover__isnull=False, @@ -3748,7 +3748,7 @@ def feature_overview(request, feature_id): .select_related('series__publisher') issues = issues.annotate( longest_story_id=Subquery(Story.objects.filter( - feature_object=feature, + feature_name__feature=feature, issue_id=OuterRef('pk'), type_id=19, deleted=False) .values('pk') @@ -4582,7 +4582,7 @@ def character_issues_feature(request, character_id, feature_id, filter_character, 'story__appearing_characters__deleted': False, 'story__type__id__in': story_types, - 'story__feature_object__id': feature_id, + 'story__feature_name__feature__id': feature_id, 'story__deleted': False } @@ -5270,7 +5270,7 @@ def group_issues_feature(request, group_id, feature_id, universe_id=None): filter_group, 'story__appearing_groups__deleted': False, 'story__type__id__in': story_types, - 'story__feature_object__id': feature_id, + 'story__feature_name__feature__id': feature_id, 'story__deleted': False} heading = _build_universe_filter_and_heading( @@ -5829,7 +5829,7 @@ def show_story_modal(request, story_id): Show a single story in a modal. """ story = get_object_or_404(Story.objects.prefetch_related( - 'feature_object', + 'feature_name', 'feature_logo__feature', 'credits__creator__creator', 'credits__creator__type'), From b6d6cfb3ed3e6d2fc819935f86df16ea2110d1a9 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 16:18:14 +0200 Subject: [PATCH 67/77] names --- templates/search/indexes/gcd/feature_text.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/search/indexes/gcd/feature_text.txt b/templates/search/indexes/gcd/feature_text.txt index 1b4000713..82eb1c504 100644 --- a/templates/search/indexes/gcd/feature_text.txt +++ b/templates/search/indexes/gcd/feature_text.txt @@ -1,6 +1,9 @@ {% load credits %} {{ object.name_with_disambiguation|safe }} {{ object.notes|safe }} +{% for feature_name in object.active_names %} + {{ feature_name.name|safe }} +{% endfor %} {% for feature_logo in object.active_logos %} {{ feature_logo.name|safe }} {% endfor %} From 7424d7b6e227777ec4a84772624aab2d0a33cf7b Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 18:55:09 +0200 Subject: [PATCH 68/77] extend add_generic with extra_forms, use for add_feature --- apps/oi/views.py | 57 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/apps/oi/views.py b/apps/oi/views.py index cc2bf5a9d..518263dca 100644 --- a/apps/oi/views.py +++ b/apps/oi/views.py @@ -112,6 +112,7 @@ GroupMembershipRevisionForm, CharacterRevisionFormSet, GroupRevisionFormSet, + FeatureRevisionFormSet, ReceivedAwardRevisionForm, CreatorNonComicWorkRevisionForm, CreatorRelationRevisionForm, @@ -3085,13 +3086,13 @@ def compare_issues_copy(request, issue_revision_id, issue_id): @permission_required('indexer.can_reserve') def add_generic(request, model_name, object_url='', object_name=None, - initial={}, cancel='', save_kwargs={}): + initial={}, cancel='', save_kwargs={}, + extra_forms=None): """ Add a new object through the Online Indexer interface. - This view handles the creation of new objects that do not have extra - forms (such as publishers, features, etc.) through a revision/changeset - workflow. + This view handles the creation of new objects through a + revision/changeset workflow. It requires the user to have the 'indexer.can_reserve' permission and checks if the user can reserve another item. @@ -3108,6 +3109,9 @@ def add_generic(request, model_name, cancel (str, optional): URL to redirect to on cancel. Defaults to ''. save_kwargs (dict, optional): Additional keyword arguments to pass to the save_added_revision method. Defaults to {}. + extra_forms (dict, optional): Extra forms/formsets to validate and + process, keyed by template context name. Values are form/formset + classes (instantiated with POST data). Defaults to None. Returns: HttpResponse: @@ -3119,13 +3123,16 @@ def add_generic(request, model_name, Notes: - Creates a new Changeset with OPEN state when form is valid - - Uses get_revision_form to dynamically get the appropriate form - class + - Uses get_revision_form to dynamically get the appropriate form class + - Optionally validates and processes extra forms/formsets - Object name and URL are auto-generated if not provided """ if not request.user.indexer.can_reserve_another(): return render_error(request, REACHED_CHANGE_LIMIT) + if extra_forms is None: + extra_forms = {} + if request.method == 'POST' and 'cancel' in request.POST: if cancel: return HttpResponseRedirect(cancel) @@ -3134,12 +3141,24 @@ def add_generic(request, model_name, form = get_revision_form(model_name=model_name, user=request.user)(request.POST or None, initial=initial) - if form.is_valid(): + + instantiated_extra_forms = {} + for extra_form_name, extra_form in extra_forms.items(): + instantiated_extra_forms[extra_form_name] = extra_form( + request.POST or None) + + valid = form.is_valid() + for extra_form in instantiated_extra_forms.values(): + valid = extra_form.is_valid() and valid + + if valid: changeset = Changeset(indexer=request.user, state=states.OPEN, change_type=CTYPES[model_name]) changeset.save() revision = form.save(commit=False) revision.save_added_revision(changeset=changeset, **save_kwargs) + if instantiated_extra_forms: + revision.process_extra_forms(instantiated_extra_forms) return submit(request, changeset.id) else: if not object_name: @@ -3147,18 +3166,28 @@ def add_generic(request, model_name, if not object_url: object_url = urlresolvers.reverse('add_%s' % model_name) + context = { + 'object_name': object_name, + 'object_url': object_url, + 'action_label': 'Submit New', + 'form': form, + } + context.update(instantiated_extra_forms) + return oi_render( request, 'oi/edit/add_frame.html', - { - 'object_name': object_name, - 'object_url': object_url, - 'action_label': 'Submit New', - 'form': form, - }) + context) def add_feature(request): - return add_generic(request, 'feature') + feature_names_formset = FeatureRevisionFormSet + external_link_formset = ExternalLinkRevisionFormSet + + return add_generic(request, 'feature', + extra_forms={'feature_names_formset': + feature_names_formset, + 'external_link_formset': + external_link_formset}) @permission_required('indexer.can_reserve') From 02ad21cb1b8c5a392fe2b4c8f83bf60f2a069b74 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 21:35:26 +0200 Subject: [PATCH 69/77] genre from featurename when editing --- apps/gcd/views/details.py | 4 ++-- static/js/oi/storyrevision_form_utils.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index a2b25b417..d6a003791 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -3631,8 +3631,8 @@ def show_feature(request, feature, preview=False): def feature_genres(request, feature_id): - feature = get_gcd_object(Feature, feature_id) - genres = feature.genre + feature_name = get_gcd_object(FeatureNameDetail, feature_id) + genres = feature_name.feature.genre return HttpResponse(genres, content_type='text/plain') diff --git a/static/js/oi/storyrevision_form_utils.js b/static/js/oi/storyrevision_form_utils.js index f121821c3..3641a1f49 100644 --- a/static/js/oi/storyrevision_form_utils.js +++ b/static/js/oi/storyrevision_form_utils.js @@ -93,7 +93,7 @@ $(document).on('change', 'input[type=checkbox]', function () { $('input[type=checkbox]').change() $(document).ready(function() { - const featureSelect = document.getElementById('id_feature_object'); + const featureSelect = document.getElementById('id_feature_name'); const genresContainer = document.getElementById('feature-genres'); // Use jQuery to listen for select2 change event as the page already uses it From a81065571a5fb806e81919c6083c67fd32c90969 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 22:37:05 +0200 Subject: [PATCH 70/77] feature_name path to active_stories --- apps/gcd/models/feature.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/gcd/models/feature.py b/apps/gcd/models/feature.py index f124ee6f0..39c1e08ec 100644 --- a/apps/gcd/models/feature.py +++ b/apps/gcd/models/feature.py @@ -76,7 +76,8 @@ def active_logos(self): deleted=False) def active_stories(self): - return self.story_set.filter(deleted=False) + return Story.objects.filter(deleted=False, + feature_name__feature_id=self.id) def active_names(self): return self.feature_names.filter(deleted=False) From 780718bb644e97b264fa35c1f25c3b7494a1d937 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sun, 16 Aug 2026 22:39:43 +0200 Subject: [PATCH 71/77] feature_name path to active_stories --- apps/gcd/models/feature.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/gcd/models/feature.py b/apps/gcd/models/feature.py index 39c1e08ec..f0964d7b7 100644 --- a/apps/gcd/models/feature.py +++ b/apps/gcd/models/feature.py @@ -76,6 +76,7 @@ def active_logos(self): deleted=False) def active_stories(self): + from apps.gcd.models import Story return Story.objects.filter(deleted=False, feature_name__feature_id=self.id) From 77a15bc7ad2cf6e1738782a0535f9a7e89343c94 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Mon, 17 Aug 2026 22:37:53 +0200 Subject: [PATCH 72/77] feature_name instead of feature_object --- apps/gcd/models/feature.py | 2 +- apps/gcd/models/issue.py | 2 +- apps/gcd/models/story.py | 2 +- apps/gcd/templatetags/credits.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/gcd/models/feature.py b/apps/gcd/models/feature.py index f0964d7b7..7f3455fef 100644 --- a/apps/gcd/models/feature.py +++ b/apps/gcd/models/feature.py @@ -150,7 +150,7 @@ class Meta: def get_absolute_url(self): return urlresolvers.reverse( 'show_feature', - kwargs={'feature_id': self.feature.id}) + kwargs={'feature_id': self.feature_id}) def name_with_disambiguation(self): extra = '' diff --git a/apps/gcd/models/issue.py b/apps/gcd/models/issue.py index 39791fd35..cb1081291 100644 --- a/apps/gcd/models/issue.py +++ b/apps/gcd/models/issue.py @@ -286,7 +286,7 @@ def shown_stories(self): .order_by('sequence_number') .select_related('type', 'migration_status') .prefetch_related( - 'feature_object', + 'feature_name', 'feature_logo__feature', 'credits__creator__creator', 'credits__creator__type')) diff --git a/apps/gcd/models/story.py b/apps/gcd/models/story.py index 3c7924a98..491416c51 100644 --- a/apps/gcd/models/story.py +++ b/apps/gcd/models/story.py @@ -1056,7 +1056,7 @@ def has_content(self): self.synopsis or \ self.has_keywords() or \ self.has_reprints() or \ - self.feature_object.exclude(genre='').values('genre').exists() or \ + self.feature_name.exclude(feature__genre='').exists() or \ self.feature_logo.count() or \ self.active_awards().count() diff --git a/apps/gcd/templatetags/credits.py b/apps/gcd/templatetags/credits.py index eb78896a0..8fcce9af5 100644 --- a/apps/gcd/templatetags/credits.py +++ b/apps/gcd/templatetags/credits.py @@ -173,7 +173,7 @@ def show_credit(story, credit, tailwind=False, bare_value=False): character_string, tailwind=tailwind) - if story.feature or story.feature_object.count(): + if story.feature or story.feature_name.count(): feature_string = story.show_feature_as_text() search = icu.StringSearch(target.lower(), feature_string.lower(), @@ -185,8 +185,8 @@ def show_credit(story, credit, tailwind=False, bare_value=False): return formatted_credit elif credit == 'genre': genres = story.genre.lower() - for feature in story.feature_object.all(): - for genre in feature.genre.split(';'): + for feature in story.feature_name.all(): + for genre in feature.feature.genre.split(';'): genre = genre.strip() if genre not in genres: if genres == '': From cbc7b987b0e96bba93a67af439fce4c89fc354f1 Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Tue, 18 Aug 2026 20:54:28 +0200 Subject: [PATCH 73/77] feature_name --- apps/gcd/views/details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gcd/views/details.py b/apps/gcd/views/details.py index d6a003791..3ac37818a 100644 --- a/apps/gcd/views/details.py +++ b/apps/gcd/views/details.py @@ -3932,7 +3932,7 @@ def feature_logo_sequences(request, feature_logo_id, country=None): def feature_logo_issues(request, feature_logo_id): feature_logo = get_gcd_object(FeatureLogo, feature_logo_id) - if feature_logo.feature.all()[0].feature_type.id == 1: + if feature_logo.feature_name.all()[0].feature.feature_type.id == 1: issues = Issue.objects.filter(story__feature_logo=feature_logo, story__type__id__in=CORE_TYPES, story__deleted=False).distinct()\ From 80df1e7457abee2ec5c1bb52158f412fcc5d7c4a Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Tue, 18 Aug 2026 21:02:58 +0200 Subject: [PATCH 74/77] feature_name for feature_logo --- apps/select/views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/select/views.py b/apps/select/views.py index 8ebbea1d5..e2a56a0cb 100644 --- a/apps/select/views.py +++ b/apps/select/views.py @@ -674,18 +674,19 @@ def get_queryset(self): type = self.forwarded.get('type', None) if language and language not in ['zxx', 'und']: - qs = qs.filter(feature__language__code__in=[language, 'zxx']) + qs = qs.filter(feature_name__feature__language__code__in=[language, + 'zxx']) if type: type = int(type) if type == STORY_TYPES['cover']: qs = FeatureLogo.objects.none() elif type == STORY_TYPES['letters_page']: - qs = qs.filter(feature__feature_type__id=2) + qs = qs.filter(feature_name__feature__feature_type__id=2) else: - qs = qs.exclude(feature__feature_type__id=2) + qs = qs.exclude(feature_name__feature__feature_type__id=2) if type not in [STORY_TYPES['ad'], STORY_TYPES['comics-form ad']]: - qs = qs.exclude(feature__feature_type__id=3) + qs = qs.exclude(feature_name__feature__feature_type__id=3) qs = _filter_and_sort(qs, self.q) From c442f5d4f3d2dd7e6e078e64bf09c879e02ab7fe Mon Sep 17 00:00:00 2001 From: jhunterjActual <47950049+jhunterjActual@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:53:43 -0400 Subject: [PATCH 75/77] Show reviewing changeset states on mobile devices (#742) * Show reviewing states on mobile * Reformat mobile state badge classes --- .../oi/tests/test_standard_queues_template.py | 69 +++++++++++++++++++ templates/oi/bits/standard_queues.html | 14 ++++ 2 files changed, 83 insertions(+) create mode 100644 apps/oi/tests/test_standard_queues_template.py diff --git a/apps/oi/tests/test_standard_queues_template.py b/apps/oi/tests/test_standard_queues_template.py new file mode 100644 index 000000000..2c75bc6f3 --- /dev/null +++ b/apps/oi/tests/test_standard_queues_template.py @@ -0,0 +1,69 @@ +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest +from django.template.loader import render_to_string + +from apps.oi import states + + +class ChangesetList(list): + def count(self): + return len(self) + + +def render_queue(changeset_state, queue_name='reviews'): + changeset = SimpleNamespace( + id=123, + state=changeset_state, + display_state=states.DISPLAY_NAME[changeset_state], + country=None, + queue_name='Northstar Comics #12', + changeset_action='', + queue_descriptor='', + indexer=SimpleNamespace(indexer=None), + approver=SimpleNamespace(indexer=None), + modified=datetime(2026, 8, 12, tzinfo=timezone.utc), + ) + context = { + 'actions': 'oi/bits/approval_actions.html', + 'countries': {}, + 'country_names': {}, + 'data': [{ + 'object_name': 'Issues', + 'changesets': ChangesetList([changeset]), + }], + 'link_target': 'preview', + 'perms': SimpleNamespace( + indexer=SimpleNamespace(can_approve=False)), + 'queue_name': queue_name, + 'states': states, + 'user': SimpleNamespace( + indexer=SimpleNamespace(collapse_compare_view=False)), + } + return render_to_string('oi/bits/standard_queues.html', context) + + +@pytest.mark.parametrize( + ('changeset_state', 'label', 'classes'), + ( + (states.OPEN, 'EDITING', 'bg-index-status-edit'), + (states.DISCUSSED, 'IN DISCUSSION', + 'bg-index-status-in-queue border border-gray-400'), + (states.REVIEWING, 'UNDER REVIEW', 'bg-index-status-in-queue'), + ), +) +def test_reviewing_queue_shows_mobile_state_badge( + changeset_state, label, classes): + rendered = render_queue(changeset_state) + + assert label in rendered + assert 'sm:hidden block w-fit' in rendered + assert classes in rendered + + +def test_mobile_state_badge_is_limited_to_reviewing_queue(): + rendered = render_queue(states.REVIEWING, queue_name='pending') + + assert 'UNDER REVIEW' not in rendered + assert 'sm:hidden block w-fit' not in rendered diff --git a/templates/oi/bits/standard_queues.html b/templates/oi/bits/standard_queues.html index 05153caba..ae3df6c78 100644 --- a/templates/oi/bits/standard_queues.html +++ b/templates/oi/bits/standard_queues.html @@ -47,6 +47,20 @@

      {% endif %} + {% if queue_name == 'reviews' %} + + {{ changeset.display_state|upper }} + + {% endif %} {% if link_target == 'preview' or changeset.state == states.PENDING or changeset.state == states.REVIEWING or changeset.state == states.DISCUSSED %} {% else %} From b8355f6ffa19f40bf77c99802ae40fa4ff7990bd Mon Sep 17 00:00:00 2001 From: jochenGCD Date: Sat, 22 Aug 2026 13:38:23 +0200 Subject: [PATCH 76/77] reorg display of state for mobile reviews --- .../oi/tests/test_standard_queues_template.py | 14 ++++---- templates/oi/bits/standard_queues.html | 33 ++++++++++--------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/oi/tests/test_standard_queues_template.py b/apps/oi/tests/test_standard_queues_template.py index 2c75bc6f3..187844431 100644 --- a/apps/oi/tests/test_standard_queues_template.py +++ b/apps/oi/tests/test_standard_queues_template.py @@ -47,10 +47,10 @@ def render_queue(changeset_state, queue_name='reviews'): @pytest.mark.parametrize( ('changeset_state', 'label', 'classes'), ( - (states.OPEN, 'EDITING', 'bg-index-status-edit'), - (states.DISCUSSED, 'IN DISCUSSION', - 'bg-index-status-in-queue border border-gray-400'), - (states.REVIEWING, 'UNDER REVIEW', 'bg-index-status-in-queue'), + (states.OPEN, 'E', 'bg-red-400'), + (states.DISCUSSED, 'D', + 'bg-yellow-400'), + (states.REVIEWING, 'R', 'bg-green-400'), ), ) def test_reviewing_queue_shows_mobile_state_badge( @@ -58,12 +58,12 @@ def test_reviewing_queue_shows_mobile_state_badge( rendered = render_queue(changeset_state) assert label in rendered - assert 'sm:hidden block w-fit' in rendered + assert 'sm:hidden block text-center' in rendered assert classes in rendered def test_mobile_state_badge_is_limited_to_reviewing_queue(): rendered = render_queue(states.REVIEWING, queue_name='pending') - assert 'UNDER REVIEW' not in rendered - assert 'sm:hidden block w-fit' not in rendered + assert 'bg-green-400">R' not in rendered + assert 'sm:hidden block text-center' not in rendered diff --git a/templates/oi/bits/standard_queues.html b/templates/oi/bits/standard_queues.html index ae3df6c78..4148391e6 100644 --- a/templates/oi/bits/standard_queues.html +++ b/templates/oi/bits/standard_queues.html @@ -24,8 +24,10 @@

      {% endif %} {% if queue_name != 'reviews' %} Approver - {% endif %} State + {% else %} + State S + {% endif %} {% if queue_name == 'editing' %} Last State Change Expires @@ -47,20 +49,6 @@

      {% endif %} - {% if queue_name == 'reviews' %} - - {{ changeset.display_state|upper }} - - {% endif %} {% if link_target == 'preview' or changeset.state == states.PENDING or changeset.state == states.REVIEWING or changeset.state == states.DISCUSSED %} {% else %} @@ -79,8 +67,21 @@

      {% endif %} {% if queue_name != 'reviews' %} {{ changeset.approver.indexer|absolute_url|default:"None" }} - {% endif %} {{ changeset.display_state }} + {% else %} + {{ changeset.display_state }} + E + {% elif changeset.state == states.DISCUSSED %} + bg-yellow-400">D + {% else %} + bg-green-400">R + {% endif %} + + + {% endif %} {{ changeset.modified|naturaltime }} {% if queue_name == 'editing' %} {{ changeset.expires|naturaltime }} From a2111eaa351bd7dd3f802ba02274a3f2ad0cc1eb Mon Sep 17 00:00:00 2001 From: DeusExTaco Date: Sat, 22 Aug 2026 19:46:28 -0700 Subject: [PATCH 77/77] fix(api-v2): use feature names for story links --- apps/api_v2/filters/features.py | 15 +++- apps/api_v2/filters/stories.py | 6 +- apps/api_v2/serializers/features.py | 57 +++++++++++++-- apps/api_v2/serializers/stories.py | 30 ++++---- .../tests/test_filters/test_features.py | 37 +++++++++- .../api_v2/tests/test_filters/test_stories.py | 21 +++++- .../test_series_bond_timestamps.py | 12 ++-- .../tests/test_performance/test_features.py | 11 ++- .../tests/test_performance/test_stories.py | 11 ++- .../tests/test_serializers/test_features.py | 43 +++++++++++- .../tests/test_serializers/test_stories.py | 69 +++++++++++-------- apps/api_v2/tests/test_views/test_features.py | 11 ++- apps/api_v2/tests/test_views/test_stories.py | 21 ++++-- apps/api_v2/views/features.py | 31 ++++++--- apps/api_v2/views/stories.py | 17 +++-- apps/oi/forms/feature.py | 2 +- apps/oi/forms/story.py | 2 +- apps/oi/models.py | 4 +- 18 files changed, 313 insertions(+), 87 deletions(-) diff --git a/apps/api_v2/filters/features.py b/apps/api_v2/filters/features.py index 26b25bb0f..df9f5af71 100644 --- a/apps/api_v2/filters/features.py +++ b/apps/api_v2/filters/features.py @@ -4,6 +4,7 @@ """django-filter configuration for v2 Feature endpoints.""" import django_filters +from django.db.models import Q from apps.api_v2.filters.common import ( TIMESTAMP_FILTER_FIELDS, @@ -17,8 +18,7 @@ class FeatureFilterSet(TimestampFilterSet): """Filters for Feature list endpoints.""" name = django_filters.CharFilter( - field_name='name', - lookup_expr='icontains', + method='filter_name', ) feature_type = django_filters.NumberFilter(field_name='feature_type_id') language = LanguageCodeFilter(field_name='language') @@ -27,6 +27,17 @@ class FeatureFilterSet(TimestampFilterSet): lookup_expr='icontains', ) + def filter_name(self, queryset, name, value): + """Match a Feature's canonical or active alternate names.""" + del name + return queryset.filter( + Q(name__icontains=value) + | Q( + feature_names__deleted=False, + feature_names__name__icontains=value, + ), + ).distinct() + class Meta: """FilterSet metadata for Feature filtering.""" diff --git a/apps/api_v2/filters/stories.py b/apps/api_v2/filters/stories.py index d117c6a98..fa701583c 100644 --- a/apps/api_v2/filters/stories.py +++ b/apps/api_v2/filters/stories.py @@ -32,7 +32,11 @@ def filter_genre(self, queryset, name, value): del name return queryset.filter( Q(genre__icontains=value) - | Q(feature_object__genre__icontains=value), + | Q( + feature_name__deleted=False, + feature_name__feature__deleted=False, + feature_name__feature__genre__icontains=value, + ), ).distinct() class Meta: diff --git a/apps/api_v2/serializers/features.py b/apps/api_v2/serializers/features.py index 20af4f38c..447acc640 100644 --- a/apps/api_v2/serializers/features.py +++ b/apps/api_v2/serializers/features.py @@ -3,9 +3,10 @@ """Serializers for v2 Feature endpoints.""" +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from apps.gcd.models import Feature, FeatureLogo +from apps.gcd.models import Feature, FeatureLogo, FeatureNameDetail def _feature_type_reference(feature_type): @@ -44,6 +45,21 @@ class Meta: ) +class FeatureNameDetailSerializer(serializers.ModelSerializer): + """Serialize active names for a Feature.""" + + class Meta: + """Serializer metadata for Feature name-detail fields.""" + + model = FeatureNameDetail + fields = ( + 'id', + 'name', + 'sort_name', + 'is_official_name', + ) + + class FeatureTypeReferenceSerializer(serializers.Serializer): """Describe a Feature Type nested reference.""" @@ -117,6 +133,7 @@ class FeatureSerializer(FeatureListSerializer): read_only=True, slug_field='name', ) + name_details = serializers.SerializerMethodField() logos = serializers.SerializerMethodField() relations = serializers.SerializerMethodField() @@ -125,20 +142,48 @@ class Meta(FeatureListSerializer.Meta): fields = FeatureListSerializer.Meta.fields + ( 'year_first_published_uncertain', + 'description', 'notes', + 'name_details', 'keywords', 'logos', 'relations', ) - def get_logos(self, obj): - """Return ordered active Feature Logos.""" - logos = getattr(obj, 'active_feature_logo_list', None) - if logos is None: - logos = obj.featurelogo_set.filter(deleted=False).order_by( + @extend_schema_field(FeatureNameDetailSerializer(many=True)) + def get_name_details(self, obj): + """Return ordered active names for the Feature.""" + name_details = getattr(obj, 'active_name_detail_list', None) + if name_details is None: + name_details = obj.feature_names.filter(deleted=False).order_by( 'sort_name', 'id', ) + return FeatureNameDetailSerializer(name_details, many=True).data + + def get_logos(self, obj): + """Return ordered active Feature Logos.""" + name_details = getattr(obj, 'active_name_detail_list', None) + if name_details is None: + logos = ( + FeatureLogo.objects.filter( + deleted=False, + feature_name__deleted=False, + feature_name__feature=obj, + ) + .distinct() + .order_by('sort_name', 'id') + ) + else: + logos_by_id = { + logo.pk: logo + for name_detail in name_details + for logo in name_detail.active_feature_logo_list + } + logos = sorted( + logos_by_id.values(), + key=lambda logo: (logo.sort_name, logo.pk), + ) return FeatureLogoSerializer(logos, many=True).data def get_relations(self, obj): diff --git a/apps/api_v2/serializers/stories.py b/apps/api_v2/serializers/stories.py index f6c597bea..c6dec80c2 100644 --- a/apps/api_v2/serializers/stories.py +++ b/apps/api_v2/serializers/stories.py @@ -7,7 +7,7 @@ from rest_framework import serializers from apps.api_v2.utils.credits import collect_story_credit_entries -from apps.gcd.models import Feature, FeatureLogo, Story +from apps.gcd.models import FeatureLogo, FeatureNameDetail, Story LEGACY_CREDIT_FIELDS = ( 'script', @@ -107,14 +107,15 @@ def get_issue(self, obj): class FeatureObjectSerializer(serializers.ModelSerializer): - """Serialize trimmed feature references for story detail.""" + """Serialize selected feature names as parent Feature references.""" + id = serializers.IntegerField(source='feature_id') feature_type = serializers.SerializerMethodField() class Meta: """Serializer metadata for feature references.""" - model = Feature + model = FeatureNameDetail fields = ( 'id', 'name', @@ -123,14 +124,14 @@ class Meta: def get_feature_type(self, obj): """Return the minimal nested feature type reference.""" - if obj.feature_type_id is None: + if obj.feature.feature_type_id is None: return None try: - feature_type_name = obj.feature_type.name + feature_type_name = obj.feature.feature_type.name except ObjectDoesNotExist: return None return { - 'id': obj.feature_type_id, + 'id': obj.feature.feature_type_id, 'name': feature_type_name, } @@ -190,15 +191,18 @@ class Meta(StoryListSerializer.Meta): ) def get_feature_object(self, obj): - """Return structured feature references for the story.""" - features = getattr(obj, 'active_feature_list', None) - if features is None: - features = ( - obj.feature_object.filter(deleted=False) - .select_related('feature_type') + """Return selected feature names with parent Feature identities.""" + feature_names = getattr(obj, 'active_feature_name_list', None) + if feature_names is None: + feature_names = ( + obj.feature_name.filter( + deleted=False, + feature__deleted=False, + ) + .select_related('feature', 'feature__feature_type') .order_by('sort_name', 'id') ) - return FeatureObjectSerializer(features, many=True).data + return FeatureObjectSerializer(feature_names, many=True).data def get_feature_logo(self, obj): """Return structured feature-logo references for the story.""" diff --git a/apps/api_v2/tests/test_filters/test_features.py b/apps/api_v2/tests/test_filters/test_features.py index 143db8c40..f53b3d7b8 100644 --- a/apps/api_v2/tests/test_filters/test_features.py +++ b/apps/api_v2/tests/test_filters/test_features.py @@ -9,7 +9,7 @@ from django.utils import timezone from apps.api_v2.filters.features import FeatureFilterSet -from apps.gcd.models import Feature, FeatureType +from apps.gcd.models import Feature, FeatureNameDetail, FeatureType from apps.stddata.models import Language pytestmark = pytest.mark.django_db @@ -67,6 +67,41 @@ def test_feature_filter_matches_name_icontains(language): assert list(queryset) == [matching] +def test_feature_filter_matches_active_alternate_name(language): + """The name filter finds active aliases without matching deleted ones.""" + feature_type = FeatureType.objects.create(name='Character') + matching = _create_feature( + language=language, + feature_type=feature_type, + name='Captain Marvel', + ) + FeatureNameDetail.objects.create( + feature=matching, + name='Shazam', + sort_name='Shazam', + is_official_name=False, + ) + deleted_alias = _create_feature( + language=language, + feature_type=feature_type, + name='Different Feature', + ) + FeatureNameDetail.objects.create( + feature=deleted_alias, + name='Shazam Family', + sort_name='Shazam Family', + is_official_name=False, + deleted=True, + ) + + queryset = FeatureFilterSet( + {'name': 'shazam'}, + queryset=Feature.objects.all(), + ).qs + + assert list(queryset) == [matching] + + def test_feature_filter_matches_type_language_genre_and_year(language): """Type, language, genre, and exact year filters narrow results.""" character_type = FeatureType.objects.create(name='Character') diff --git a/apps/api_v2/tests/test_filters/test_stories.py b/apps/api_v2/tests/test_filters/test_stories.py index 033bb14a3..d76ff66e1 100644 --- a/apps/api_v2/tests/test_filters/test_stories.py +++ b/apps/api_v2/tests/test_filters/test_stories.py @@ -9,7 +9,13 @@ from django.utils import timezone from apps.api_v2.filters.stories import StoryFilterSet -from apps.gcd.models import Feature, FeatureType, Story, StoryType +from apps.gcd.models import ( + Feature, + FeatureNameDetail, + FeatureType, + Story, + StoryType, +) def _create_story_type(name='Comic Story', sort_code=19): @@ -195,7 +201,7 @@ def test_story_filter_matches_story_and_feature_genres(issue): sequence_number=2, genre='', ) - feature_genre_match.feature_object.add( + linked_features = ( _create_feature( issue.series.language, name='Space Feature', @@ -207,6 +213,17 @@ def test_story_filter_matches_story_and_feature_genres(issue): genre='superhero', ), ) + feature_genre_match.feature_name.add( + *[ + FeatureNameDetail.objects.create( + feature=feature, + name=feature.name, + sort_name=feature.sort_name, + is_official_name=True, + ) + for feature in linked_features + ], + ) _create_story( issue, title='Western Story', diff --git a/apps/api_v2/tests/test_migrations/test_series_bond_timestamps.py b/apps/api_v2/tests/test_migrations/test_series_bond_timestamps.py index 565b477e7..8dc311ac5 100644 --- a/apps/api_v2/tests/test_migrations/test_series_bond_timestamps.py +++ b/apps/api_v2/tests/test_migrations/test_series_bond_timestamps.py @@ -3,7 +3,7 @@ """Migration tests for persistent Series Bond timestamps.""" -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest from django.contrib.auth import get_user_model @@ -88,11 +88,11 @@ def test_series_bond_timestamp_migration_uses_history_or_shared_baseline( state=1, name='unapproved', ) - earliest_created = datetime(2010, 1, 1, tzinfo=timezone.utc) - earliest_modified = datetime(2010, 1, 2, tzinfo=timezone.utc) - latest_created = datetime(2020, 1, 1, tzinfo=timezone.utc) - latest_modified = datetime(2020, 1, 2, tzinfo=timezone.utc) - ignored_created = datetime(2025, 1, 1, tzinfo=timezone.utc) + earliest_created = datetime(2010, 1, 1, tzinfo=UTC) + earliest_modified = datetime(2010, 1, 2, tzinfo=UTC) + latest_created = datetime(2020, 1, 1, tzinfo=UTC) + latest_modified = datetime(2020, 1, 2, tzinfo=UTC) + ignored_created = datetime(2025, 1, 1, tzinfo=UTC) SeriesBondRevision.objects.filter(pk=earliest.pk).update( created=earliest_created, modified=earliest_modified, diff --git a/apps/api_v2/tests/test_performance/test_features.py b/apps/api_v2/tests/test_performance/test_features.py index aa98cf478..096e70f21 100644 --- a/apps/api_v2/tests/test_performance/test_features.py +++ b/apps/api_v2/tests/test_performance/test_features.py @@ -10,6 +10,7 @@ from apps.gcd.models import ( Feature, FeatureLogo, + FeatureNameDetail, FeatureRelation, FeatureRelationType, FeatureType, @@ -32,6 +33,12 @@ def _create_feature(*, language, feature_type, name): def _create_relationships(feature, *, language, feature_type, count): """Attach logos plus incoming and outgoing relations to ``feature``.""" + feature_name = FeatureNameDetail.objects.create( + feature=feature, + name=feature.name, + sort_name=feature.sort_name, + is_official_name=True, + ) relation_type = FeatureRelationType.objects.create( name='alternate_version', description='is an alternate version of', @@ -43,7 +50,7 @@ def _create_relationships(feature, *, language, feature_type, count): sort_name=f'Logo {number:03d}', notes='', ) - logo.feature.add(feature) + logo.feature_name.add(feature_name) outgoing_target = _create_feature( language=language, feature_type=feature_type, @@ -116,4 +123,4 @@ def test_feature_detail_query_count_is_relationship_count_independent( assert response.status_code == 200 assert len(response.data['logos']) == 8 assert len(response.data['relations']) == 16 - assert len(context) == 6 + assert len(context) == 7 diff --git a/apps/api_v2/tests/test_performance/test_stories.py b/apps/api_v2/tests/test_performance/test_stories.py index 913f3ff09..d44f9f696 100644 --- a/apps/api_v2/tests/test_performance/test_stories.py +++ b/apps/api_v2/tests/test_performance/test_stories.py @@ -19,6 +19,7 @@ CreditType, Feature, FeatureLogo, + FeatureNameDetail, FeatureType, Reprint, Story, @@ -146,6 +147,12 @@ def _add_story_detail_relations(story): year_first_published=1939, notes='', ) + feature_name = FeatureNameDetail.objects.create( + feature=feature, + name='The Bat-Man', + sort_name='Bat-Man, The', + is_official_name=False, + ) logo = FeatureLogo.objects.create( name='Bat Logo', sort_name='Bat Logo', @@ -154,8 +161,8 @@ def _add_story_detail_relations(story): year_ended=1945, notes='', ) - logo.feature.add(feature) - story.feature_object.add(feature) + logo.feature_name.add(feature_name) + story.feature_name.add(feature_name) story.feature_logo.add(logo) _creator, creator_name = _create_creator('Writer One', 'Writer, One') script_type = _create_credit_type('script', 1) diff --git a/apps/api_v2/tests/test_serializers/test_features.py b/apps/api_v2/tests/test_serializers/test_features.py index c1cb4c0c7..a04dc11b1 100644 --- a/apps/api_v2/tests/test_serializers/test_features.py +++ b/apps/api_v2/tests/test_serializers/test_features.py @@ -12,6 +12,7 @@ from apps.gcd.models import ( Feature, FeatureLogo, + FeatureNameDetail, FeatureRelation, FeatureRelationType, FeatureType, @@ -38,6 +39,7 @@ def _create_feature( feature_type=feature_type, year_first_published=1960, year_first_published_uncertain=True, + description='Feature description', notes='Feature notes', deleted=deleted, ) @@ -63,7 +65,15 @@ def _create_logo( notes='', deleted=deleted, ) - logo.feature.add(feature) + feature_name, _ = FeatureNameDetail.objects.get_or_create( + feature=feature, + name=feature.name, + defaults={ + 'sort_name': feature.sort_name, + 'is_official_name': True, + }, + ) + logo.feature_name.add(feature_name) return logo @@ -121,6 +131,18 @@ def test_feature_detail_serializer_normalizes_active_relationships(language): name='Main Feature', ) feature.keywords.add('alpha', 'beta') + official_name = FeatureNameDetail.objects.create( + feature=feature, + name='Main Feature', + sort_name='Feature, Main', + is_official_name=True, + ) + alternate_name = FeatureNameDetail.objects.create( + feature=feature, + name='Alternate Feature', + sort_name='Feature, Alternate', + is_official_name=False, + ) beta_logo = _create_logo( feature, name='Beta Logo', @@ -132,6 +154,8 @@ def test_feature_detail_serializer_normalizes_active_relationships(language): name='Alpha Logo', sort_name='Alpha Logo', ) + alpha_logo.feature_name.add(official_name) + beta_logo.feature_name.add(alternate_name) _create_logo( feature, name='Deleted Logo', @@ -189,13 +213,30 @@ def test_feature_detail_serializer_normalizes_active_relationships(language): 'created', 'modified', 'year_first_published_uncertain', + 'description', 'notes', + 'name_details', 'keywords', 'logos', 'relations', } assert data['year_first_published_uncertain'] is True + assert data['description'] == 'Feature description' assert data['notes'] == 'Feature notes' + assert data['name_details'] == [ + { + 'id': alternate_name.pk, + 'name': 'Alternate Feature', + 'sort_name': 'Feature, Alternate', + 'is_official_name': False, + }, + { + 'id': official_name.pk, + 'name': 'Main Feature', + 'sort_name': 'Feature, Main', + 'is_official_name': True, + }, + ] assert set(data['keywords']) == {'alpha', 'beta'} assert data['logos'] == [ { diff --git a/apps/api_v2/tests/test_serializers/test_stories.py b/apps/api_v2/tests/test_serializers/test_stories.py index 27b2c6fa0..45e44a5dd 100644 --- a/apps/api_v2/tests/test_serializers/test_stories.py +++ b/apps/api_v2/tests/test_serializers/test_stories.py @@ -20,6 +20,7 @@ CreditType, Feature, FeatureLogo, + FeatureNameDetail, FeatureType, Reprint, Story, @@ -173,42 +174,50 @@ def _create_feature(language): def test_feature_object_serializer_handles_missing_feature_type(issue): """Malformed feature objects serialize without raising.""" - features = [ - Feature( - id=123, - name='Batman', - sort_name='Batman', - disambiguation='', - genre='superhero', - language=issue.series.language, - feature_type=None, - year_first_published=1939, - notes='', + feature_names = [ + FeatureNameDetail( + feature=Feature( + id=123, + name='Batman', + sort_name='Batman', + disambiguation='', + genre='superhero', + language=issue.series.language, + feature_type=None, + year_first_published=1939, + notes='', + ), + name='The Bat-Man', + sort_name='Bat-Man, The', ), - Feature( - id=124, - name='Robin', - sort_name='Robin', - disambiguation='', - genre='superhero', - language=issue.series.language, - feature_type_id=999999, - year_first_published=1940, - notes='', + FeatureNameDetail( + feature=Feature( + id=124, + name='Robin', + sort_name='Robin', + disambiguation='', + genre='superhero', + language=issue.series.language, + feature_type_id=999999, + year_first_published=1940, + notes='', + ), + name='Robin the Boy Wonder', + sort_name='Robin the Boy Wonder', ), ] - data = FeatureObjectSerializer(features, many=True).data + data = FeatureObjectSerializer(feature_names, many=True).data assert data == [ { 'id': 123, - 'name': 'Batman', + 'name': 'The Bat-Man', 'feature_type': None, }, { 'id': 124, - 'name': 'Robin', + 'name': 'Robin the Boy Wonder', 'feature_type': None, }, ] @@ -251,6 +260,12 @@ def test_story_detail_serializer_exposes_detail_contract(issue): story = _create_story(issue, title='Lead Story', sequence_number=1) story.keywords.add('alpha', 'beta') feature = _create_feature(issue.series.language) + feature_name = FeatureNameDetail.objects.create( + feature=feature, + name='The Bat-Man', + sort_name='Bat-Man, The', + is_official_name=False, + ) logo = FeatureLogo.objects.create( name='Bat Logo', sort_name='Bat Logo', @@ -259,8 +274,8 @@ def test_story_detail_serializer_exposes_detail_contract(issue): year_ended=1945, notes='', ) - logo.feature.add(feature) - story.feature_object.add(feature) + logo.feature_name.add(feature_name) + story.feature_name.add(feature_name) story.feature_logo.add(logo) creator, creator_name = _create_creator('Writer One', 'Writer, One') script_type = _create_credit_type('script', 1) @@ -355,7 +370,7 @@ def test_story_detail_serializer_exposes_detail_contract(issue): assert data['feature_object'] == [ { 'id': feature.pk, - 'name': 'Batman', + 'name': 'The Bat-Man', 'feature_type': { 'id': feature.feature_type_id, 'name': 'Character', diff --git a/apps/api_v2/tests/test_views/test_features.py b/apps/api_v2/tests/test_views/test_features.py index dd1270e86..8c12379f2 100644 --- a/apps/api_v2/tests/test_views/test_features.py +++ b/apps/api_v2/tests/test_views/test_features.py @@ -8,6 +8,7 @@ from apps.gcd.models import ( Feature, FeatureLogo, + FeatureNameDetail, FeatureRelation, FeatureRelationType, FeatureType, @@ -48,7 +49,15 @@ def _create_logo(feature, *, name, deleted=False): notes='', deleted=deleted, ) - logo.feature.add(feature) + feature_name, _ = FeatureNameDetail.objects.get_or_create( + feature=feature, + name=feature.name, + defaults={ + 'sort_name': feature.sort_name, + 'is_official_name': True, + }, + ) + logo.feature_name.add(feature_name) return logo diff --git a/apps/api_v2/tests/test_views/test_stories.py b/apps/api_v2/tests/test_views/test_stories.py index ebe163465..65525e18f 100644 --- a/apps/api_v2/tests/test_views/test_stories.py +++ b/apps/api_v2/tests/test_views/test_stories.py @@ -23,6 +23,7 @@ CreditType, Feature, FeatureLogo, + FeatureNameDetail, FeatureType, Reprint, Story, @@ -182,6 +183,12 @@ def _add_story_detail_relations(story): year_first_published=1939, notes='', ) + feature_name = FeatureNameDetail.objects.create( + feature=feature, + name='The Bat-Man', + sort_name='Bat-Man, The', + is_official_name=False, + ) logo = FeatureLogo.objects.create( name='Bat Logo', sort_name='Bat Logo', @@ -190,8 +197,8 @@ def _add_story_detail_relations(story): year_ended=1945, notes='', ) - logo.feature.add(feature) - story.feature_object.add(feature) + logo.feature_name.add(feature_name) + story.feature_name.add(feature_name) story.feature_logo.add(logo) creator, creator_name = _create_creator('Writer One', 'Writer, One') script_type = _create_credit_type('script', 1) @@ -373,7 +380,7 @@ def test_story_detail_returns_expected_payload(api_client, issue): assert response.data['feature_object'] == [ { 'id': feature.pk, - 'name': 'Batman', + 'name': 'The Bat-Man', 'feature_type': { 'id': feature.feature_type_id, 'name': 'Character', @@ -577,7 +584,13 @@ def test_story_list_filters_by_linked_feature_genre(api_client, issue): year_first_published=1939, notes='', ) - matching.feature_object.add(feature) + feature_name = FeatureNameDetail.objects.create( + feature=feature, + name='Space Feature', + sort_name='Space Feature', + is_official_name=True, + ) + matching.feature_name.add(feature_name) _create_story( issue, title='Western Story', diff --git a/apps/api_v2/views/features.py b/apps/api_v2/views/features.py index dcddb614d..e7784ce00 100644 --- a/apps/api_v2/views/features.py +++ b/apps/api_v2/views/features.py @@ -17,7 +17,12 @@ make_last_modified, ) from apps.api_v2.views import GCDBaseViewSet -from apps.gcd.models import Feature, FeatureLogo, FeatureRelation +from apps.gcd.models import ( + Feature, + FeatureLogo, + FeatureNameDetail, + FeatureRelation, +) def _feature_filter_queryset(request, *, pk=None, **kwargs): @@ -39,13 +44,23 @@ def _feature_filter_queryset(request, *, pk=None, **kwargs): queryset_getter=_feature_filter_queryset, ) -ACTIVE_FEATURE_LOGO_PREFETCH = Prefetch( - 'featurelogo_set', - queryset=FeatureLogo.objects.filter(deleted=False).order_by( - 'sort_name', - 'id', +ACTIVE_FEATURE_NAME_PREFETCH = Prefetch( + 'feature_names', + queryset=( + FeatureNameDetail.objects.filter(deleted=False) + .prefetch_related( + Prefetch( + 'featurelogo_set', + queryset=FeatureLogo.objects.filter(deleted=False).order_by( + 'sort_name', + 'id', + ), + to_attr='active_feature_logo_list', + ), + ) + .order_by('sort_name', 'id') ), - to_attr='active_feature_logo_list', + to_attr='active_name_detail_list', ) OUTGOING_FEATURE_RELATION_PREFETCH = Prefetch( 'to_related_feature', @@ -102,7 +117,7 @@ def get_queryset(self): if self.action == 'retrieve': queryset = queryset.prefetch_related( 'keywords', - ACTIVE_FEATURE_LOGO_PREFETCH, + ACTIVE_FEATURE_NAME_PREFETCH, OUTGOING_FEATURE_RELATION_PREFETCH, INCOMING_FEATURE_RELATION_PREFETCH, ) diff --git a/apps/api_v2/views/stories.py b/apps/api_v2/views/stories.py index 388c88dcc..d9b968d94 100644 --- a/apps/api_v2/views/stories.py +++ b/apps/api_v2/views/stories.py @@ -19,8 +19,8 @@ ) from apps.api_v2.views import GCDBaseViewSet from apps.gcd.models import ( - Feature, FeatureLogo, + FeatureNameDetail, Reprint, Story, StoryCharacter, @@ -72,12 +72,15 @@ def _story_filter_queryset(request, *, pk=None, **kwargs): ), to_attr='active_character_list', ) -ACTIVE_FEATURE_PREFETCH = Prefetch( - 'feature_object', - queryset=Feature.objects.filter(deleted=False) - .select_related('feature_type') +ACTIVE_FEATURE_NAME_PREFETCH = Prefetch( + 'feature_name', + queryset=FeatureNameDetail.objects.filter( + deleted=False, + feature__deleted=False, + ) + .select_related('feature', 'feature__feature_type') .order_by('sort_name', 'id'), - to_attr='active_feature_list', + to_attr='active_feature_name_list', ) ACTIVE_FEATURE_LOGO_PREFETCH = Prefetch( 'feature_logo', @@ -164,7 +167,7 @@ def get_queryset(self): if self.action == 'retrieve': queryset = queryset.prefetch_related( 'keywords', - ACTIVE_FEATURE_PREFETCH, + ACTIVE_FEATURE_NAME_PREFETCH, ACTIVE_FEATURE_LOGO_PREFETCH, ACTIVE_STORY_CREDIT_PREFETCH, ACTIVE_STORY_CHARACTER_PREFETCH, diff --git a/apps/oi/forms/feature.py b/apps/oi/forms/feature.py index e9420371d..1b5ab860c 100644 --- a/apps/oi/forms/feature.py +++ b/apps/oi/forms/feature.py @@ -361,7 +361,7 @@ def clean(self): else: cd['relation_type'] = FeatureRelationType.objects.get(id=type) if 'from_feature' in cd and 'to_feature' in cd and \ - cd['from_feature'] == cd['to_feature']: + cd['from_feature'] == cd['to_feature']: raise forms.ValidationError( 'Feature A and Feature B cannot be the same feature.') return cd diff --git a/apps/oi/forms/story.py b/apps/oi/forms/story.py index 3854dde9b..030e229c7 100644 --- a/apps/oi/forms/story.py +++ b/apps/oi/forms/story.py @@ -1457,7 +1457,7 @@ def clean(self): else: cd['relation_type'] = StoryArcRelationType.objects.get(id=type) if 'from_story_arc' in cd and 'to_story_arc' in cd and \ - cd['from_story_arc'] == cd['to_story_arc']: + cd['from_story_arc'] == cd['to_story_arc']: raise forms.ValidationError( 'Story Arc A and Story Arc B cannot be the same story arc.') return cd diff --git a/apps/oi/models.py b/apps/oi/models.py index 7abec35d7..3e9f92997 100644 --- a/apps/oi/models.py +++ b/apps/oi/models.py @@ -4107,7 +4107,7 @@ def series_changed(self): return ((not self.deleted) and (self.previous_revision is not None) and self.previous_revision.series != self.series) - + @classmethod def fork_variant(cls, issue, changeset, variant_name, variant_cover_revision=None, @@ -4530,7 +4530,7 @@ def _handle_dependents(self, changes): # 1. Variant left behind: # Goes from Standard -> Cross-Series (+1) if variant.series == old_series and \ - variant.series != new_series: + variant.series != new_series: variant.series.issue_count += 1 variant.series.save(update_fields=['issue_count'])