Fixes #708.
Reprint links whose origin and target belong to the same issue are not meaningful reprints. The site previously allowed users to create these internal links, after which several reprint-editing operations could fail with unhandled exceptions.
This change establishes the invariant that a reprint’s origin and target cannot belong to the same issue. It enforces that invariant at the model boundary and throughout the OI workflows that can create or indirectly produce a reprint link.
The change also preserves a limited cleanup path for legacy internal links already present in imported data:
No existing reprint records are automatically deleted or modified by this PR.
As described in #708, the application could accept reprint links between two sequences in the same issue even though those relationships are not reprints.
There was no single invariant preventing this at either of the main persistence boundaries:
Reprint, for records in the display database.ReprintRevision, for changes being processed through OI.This meant an internal link could arise through several paths:
Some of these paths reached the persistence layer and raised an unhandled ValueError; others could partially save related revisions before the invalid relationship was discovered.
| Operation | Result |
|---|---|
| Add a reprint to the current issue | Rejected |
| Add a reprint to a sequence in the current issue | Rejected |
| Add a reprint to a cover in the current issue | Rejected |
| Manually enter the current issue or story ID | Rejected before the reprint edit/save workflow proceeds |
Save an internal Reprint directly |
Rejected by the model |
Save a new or modified internal ReprintRevision |
Rejected by the model |
| Change the target of a legacy internal link | Allowed, so the link can be repaired |
| Change the origin of a legacy internal link | Disabled |
| Edit the note on a legacy internal link | Disabled |
| Flip a legacy internal link | Disabled and rejected server-side |
| Create a corresponding sequence from an internal link | Disabled and rejected server-side |
| Mark a legacy internal link for deletion | Allowed |
| Restore a deleted internal link | Disabled and rejected server-side |
| Move a story or sequence when the move would make a reprint internal | Rejected before reservation or mutation |
| Create or edit a normal cross-issue reprint | Unchanged |
The common user-facing validation message is:
Reprint origin and target cannot be in the same issue.
Reprint and ReprintRevision expose an is_internal() helper and validate the issue boundary during save().
The display model rejects any persisted reprint whose resolved origin and target issue IDs are equal.
The revision model rejects new or modified non-deleted internal revisions. It has one deliberately narrow exception: OI may create the initial revision history for an already-existing legacy internal Reprint.
That exception:
The affected reprint views check the invariant before performing related writes. This includes:
The save flow validates the assembled reprint revision before saving an associated StoryRevision. An invalid request therefore does not leave behind an unrelated story revision.
Story and sequence moves are checked before objects are reserved or mutated, preventing an existing cross-issue link from silently becoming internal as a side effect of a move.
Server-side checks remain in place even when the corresponding control is disabled in the template. Stale pages, direct POST requests, and hand-entered IDs therefore return a normal validation response rather than a server error.
The object selector accepts the current issue as an exclusion and disables choices representing:
The disabled choices are maintained as an immutable tuple on the form instance.
Users can still enter IDs manually, so the selector behavior is backed by server-side validation during selection confirmation and save.
For an existing internal reprint link:
This behavior is applied consistently to:
Disabled controls use the native disabled attribute and a visibly inactive button style. They do not retain the active blue color or active hover treatment.
Each disabled control includes:
aria-describedby pointing to the visible explanation.Redundant aria-disabled="true" attributes were removed where the native disabled attribute already supplies the disabled state.
Coverage was added for:
StoryRevision.python manage.py check --settings=settings_dev: no issues 12 pre-existing silenced messages comprise one development reCAPTCHA test-key check and eleven intentional OIgit diff --check: cleanStoryRevision.A full-file Flake8 run on apps/oi/models.py reports two pre-existing, unrelated violations:
apps/oi/models.py:6031:27 E122apps/oi/models.py:7685:80 E501The lines added by this PR are clean.
The development database dump contains useful legacy internal-reprint fixtures, but those objects do not necessarily have the OI revision history required to reserve and edit them.
The separately attached seed_issue_708_history.py helper creates the missing development-only history for issue 26941 and the reprints touching it. It is intentionally not included in the branch.
Run the helper against a fresh development import before reserving issue 26941.
Follow the Docker repository’s normal database-import procedure, then apply the existing migrations and load the development users:
docker compose up -d --build web docker compose run --rm web python gcd-django/manage.py migrate docker compose run --rm web python gcd-django/manage.py loaddata gcd-django/apps/indexer/fixtures/users.yaml
This PR contains no schema changes or new migrations.
From PowerShell:
Get-Content -Raw .\seed_issue_708_history.py | docker compose exec -T ` -e DJANGO_SETTINGS_MODULE=settings ` -e PYTHONPATH=/code/gcd-django ` web python -
From a POSIX shell:
docker compose exec -T \ -e DJANGO_SETTINGS_MODULE=settings \ -e PYTHONPATH=/code/gcd-django \ web python - < seed_issue_708_history.py
On the current UAT data, the two internal fixture IDs are:
14873751487376The helper is transactional and idempotent. Running it again after a successful setup should report that no additional histories were created. It also refuses to seed missing history when one of the affected objects is already locked.
26941.dexter_1234 with the standard development password.26941.26941, or a story ID belonging to it, manually:
1487375 and 1487376:
This PR does not:
The implementation is limited to preventing new internal reprint links, preventing unsafe modifications of legacy ones, and preserving explicit repair and deletion paths.
https://github.com/GrandComicsDatabase/gcd-django/pull/743
(10 files)
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@gemini-code-assist[bot] commented on this pull request.
This pull request prevents the creation of internal reprint links (where both origin and target are in the same issue) by adding validation checks in models, views, and forms, and disabling invalid options in the UI. The review feedback is highly constructive, identifying three opportunities to optimize database performance by using foreign key ID fields directly instead of triggering implicit queries on related model instances.
In apps/gcd/models/reprint.py:
> + def is_internal(self): + """Return whether both ends of the link are in the same issue.""" + origin_issue = self.origin.issue if self.origin else self.origin_issue + target_issue = self.target.issue if self.target else self.target_issue + return (origin_issue is not None and target_issue is not None and + origin_issue == target_issue)
In is_internal(), accessing self.origin.issue and self.target.issue triggers database queries to fetch the related Issue model instances if they are not already prefetched. Since we only need to compare the issue IDs, we can use the _id fields (issue_id on the Story model, and origin_issue_id/target_issue_id on the Reprint model) to perform the comparison entirely without fetching the Issue rows from the database.
- def is_internal(self): - """Return whether both ends of the link are in the same issue.""" - origin_issue = self.origin.issue if self.origin else self.origin_issue - target_issue = self.target.issue if self.target else self.target_issue - return (origin_issue is not None and target_issue is not None and - origin_issue == target_issue) + def is_internal(self): + """Return whether both ends of the link are in the same issue.""" + origin_issue_id = self.origin.issue_id if self.origin else self.origin_issue_id + target_issue_id = self.target.issue_id if self.target else self.target_issue_id + return (origin_issue_id is not None and target_issue_id is not None and + origin_issue_id == target_issue_id)
> + def is_internal(self): + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue = self.origin_revision.issue + elif self.origin: + origin_issue = self.origin.issue + else: + origin_issue = self.origin_issue + + if self.target_revision: + target_issue = self.target_revision.issue + elif self.target: + target_issue = self.target.issue + else: + target_issue = self.target_issue + + return (origin_issue is not None and target_issue is not None and + origin_issue == target_issue)
Similar to the display model, we can optimize is_internal() in ReprintRevision by using the _id fields (issue_id on StoryRevision and Story, and origin_issue_id/target_issue_id on ReprintRevision) to avoid fetching the related Issue rows from the database.
- def is_internal(self): - """Return whether both ends of the link are in the same issue.""" - if self.origin_revision: - origin_issue = self.origin_revision.issue - elif self.origin: - origin_issue = self.origin.issue - else: - origin_issue = self.origin_issue - - if self.target_revision: - target_issue = self.target_revision.issue - elif self.target: - target_issue = self.target.issue - else: - target_issue = self.target_issue - - return (origin_issue is not None and target_issue is not None and - origin_issue == target_issue) + def is_internal(self): + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + return (origin_issue_id is not None and target_issue_id is not None and + origin_issue_id == target_issue_id)
In apps/oi/views.py:
> +def _story_move_creates_internal_reprint(story, new_issue): + if not story.story_id or not new_issue.issue_id: + return False + return ( + story.story.from_all_reprints.filter( + origin_issue_id=new_issue.issue_id).exists() or + story.story.to_all_reprints.filter( + target_issue_id=new_issue.issue_id).exists())
In _story_move_creates_internal_reprint(), accessing story.story triggers a database query to fetch the Story model instance. We can completely avoid this query by querying the Reprint model directly using story.story_id (which is already populated on the StoryRevision instance without triggering a query). This reduces the number of database queries and avoids instantiating the Story model.
-def _story_move_creates_internal_reprint(story, new_issue): - if not story.story_id or not new_issue.issue_id: - return False - return ( - story.story.from_all_reprints.filter( - origin_issue_id=new_issue.issue_id).exists() or - story.story.to_all_reprints.filter( - target_issue_id=new_issue.issue_id).exists()) +def _story_move_creates_internal_reprint(story, new_issue): + if not story.story_id or not new_issue.issue_id: + return False + return ( + Reprint.objects.filter( + target_id=story.story_id, + origin_issue_id=new_issue.issue_id + ).exists() or + Reprint.objects.filter( + origin_id=story.story_id, + target_issue_id=new_issue.issue_id + ).exists() + )
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual pushed 3 commits.
—
View it on GitHub or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
In apps/gcd/models/reprint.py:
> + def is_internal(self): + """Return whether both ends of the link are in the same issue.""" + origin_issue = self.origin.issue if self.origin else self.origin_issue + target_issue = self.target.issue if self.target else self.target_issue + return (origin_issue is not None and target_issue is not None and + origin_issue == target_issue)
Addressed in 779e377. The normal path now compares issue IDs without loading Issue rows. I retained an object-comparison fallback when both IDs are absent to preserve validation for shared unsaved Issue objects.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
> + def is_internal(self): + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue = self.origin_revision.issue + elif self.origin: + origin_issue = self.origin.issue + else: + origin_issue = self.origin_issue + + if self.target_revision: + target_issue = self.target_revision.issue + elif self.target: + target_issue = self.target.issue + else: + target_issue = self.target_issue + + return (origin_issue is not None and target_issue is not None and + origin_issue == target_issue)
Addressed in 5ed78d2, with the same ID-based optimization and unsaved-object compatibility.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
In apps/oi/views.py:
> +def _story_move_creates_internal_reprint(story, new_issue): + if not story.story_id or not new_issue.issue_id: + return False + return ( + story.story.from_all_reprints.filter( + origin_issue_id=new_issue.issue_id).exists() or + story.story.to_all_reprints.filter( + target_issue_id=new_issue.issue_id).exists())
Addressed in 529cc1e. The move guard now queries Reprint directly using story_id and combines both directions into one exists() query.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jochengcd commented on this pull request.
Thanks for tackling this.
I am not sure if we really want to all this checking in the form.
These are very limited cases, so the check on save could be enough ? It is a reasonable amount of code to avoid coming to that, which makes maintenance difficult.
In apps/gcd/models/reprint.py:
> @@ -24,6 +24,25 @@ class Meta:
notes = models.TextField(max_length=255)
+ def is_internal(self):
Why do we need this on the model ?
origin_issue_id is always set, so if we keep this, it can be simplified
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
it is not evident how this can happen, needs documentation
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jochengcd commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
Instead of checking for existing internal reprint links in the code, we should edit the existing 90 occurrences and focus on the prevention of new ones. These are the key problem since ISEs arise, which don't for existing.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jochengcd commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
We might need to check on main on this, people might see this internal ones as relevant. Maybe the preview bug can be fixed instead.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
Instead of checking for existing internal reprint links in the code, we should edit the existing 90 occurrences and focus on the prevention of new ones. These are the key problem since ISEs arise, which don't for existing.
Understood. The legacy-clone exception and special button behavior were added so imported internal links could be repaired or deleted after the new invariant landed. If the existing links will be corrected before deployment, that compatibility layer is unnecessary. I can remove the legacy-specific code and focus the PR on preventing new links, but the cleanup would need to be finished before deployment because the new Save guard would prevent those links from being cloned into OI afterward.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
We might need to check on main on this, people might see this internal ones as relevant. Maybe the preview bug can be fixed instead.
I suspect this means I should suspend tweaks on the rest of this pending the answer to that question.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jochengcd commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
Yes, this should wait a few days.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jochengcd commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
seems like we don't want internal reprint links
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
seems like we don't want internal reprint links
I'll pick up the refactor work again.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
Thanks for tackling this.
I am not sure if we really want to all this checking in the form. These are very limited cases, so the check on save could be enough ? It is a reasonable amount of code to avoid coming to that, which makes maintenance difficult.
I'm going to try to refactor the checking to reduce the maintenance headache -- I find it irritating when forms accept things they could have been designed against and just error out at the end, but if the reduced refactor is still onerous, I can remove it.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
I'm going
it is not evident how this can happen, needs documentation
I deliberately preserved a synthetic unsaved-object test after Gemini’s suggestion. There is no demonstrated supported workflow requiring it.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
@jhunterjActual commented on this pull request.
> + """Return whether both ends of the link are in the same issue.""" + if self.origin_revision: + origin_issue_id = self.origin_revision.issue_id + elif self.origin: + origin_issue_id = self.origin.issue_id + else: + origin_issue_id = self.origin_issue_id + + if self.target_revision: + target_issue_id = self.target_revision.issue_id + elif self.target: + target_issue_id = self.target.issue_id + else: + target_issue_id = self.target_issue_id + + if origin_issue_id is None and target_issue_id is None:
Instead of checking for existing internal reprint links in the code, we should edit the existing 90 occurrences and focus on the prevention of new ones. These are the key problem since ISEs arise, which don't for existing.
If we're going to pre-clean the current internal reprints, the prevention code can be simplified, yes.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()