[Draft] Refactor: Consolidate taxonomy, tag and object-tag writes into one module
Maintainers usually reply within 1 day
@jesperhodge is already working on this.
Since Oct 20, 2026.
Assessment
This issue has not been assessed yet.
Description
Consolidate taxonomy, tag and object-tag writes into one module
Draft for review. Part 1 is the GitHub issue body. Part 2 is the spec, attached to the
issue as a collapsible section or a linked document. Part 3 is notes for Jesper and is
not part of the issue.
This is a proposed followup for some of the tagging and CBE work to make tagging less brittle. Changes to the tagging base models right now break write methods in different places in the app. Extracting this business logic into one single place establishes looser coupling and prevents a change in one place breaking code in a different place.
Part 1: GitHub issue draft
Title
Consolidate taxonomy, tag and object-tag writes into one module (tagging_mutations)
User Story
As a developer adding a cross-cutting rule to tagging (such as "an archived record cannot
be edited"), I want every write to a taxonomy, tag or object tag to pass through one
module, in order to enforce the rule in one place instead of finding and patching every
write path by hand.
Description
Move the code that creates, updates, moves and deletes Taxonomy, Tag and ObjectTag
rows out of the model methods, the REST views and the import actions, into one new internal
module, openedx_tagging/tagging_mutations.py. Public functions in api.py keep
their names and signatures and delegate to it. Then add two small "is this record
archived?" checks that every taxonomy write and tag-tree write calls first, so the archived
rule applies identically on every entry point for those writes.
Context
openedx_tagging/api.py says in its module docstring that callers should use its functions
"instead of creating or modifying the models directly". In practice the app has five write
paths with different rule coverage:
| Write path | Where the rules live today |
|---|---|
api.py functions (tag_object, add_tag_to_taxonomy, copy_tags, ...) |
Partly in api.py (allow_multiple, the 100-tag cap, full-list replace), partly in the model methods they wrap. |
Taxonomy.add_tag, update_tag, delete_tags model methods |
Free-text, read-only, duplicate-value and parent-exists rules. update_tag also checks archived; add_tag and delete_tags do not. |
Import actions in import_export/actions.py |
CreateTag calls Taxonomy.add_tag. RenameTag, UpdateParentTag and DeleteTag call tag.save() and tag.delete() directly, so they skip every rule except what Tag.save computes (depth, lineage, external_id). |
REST views in rest_api/v1/views.py |
Taxonomy update and delete have no Python function at all: Django REST Framework saves the model directly. The "archived taxonomy cannot be edited" rule exists only in TaxonomyView.get_object. |
| Django admin | Saves models directly. No tagging rules apply. |
Reparenting a tag exists only in the import path and the admin. The duplicate-value check
exists three times (Taxonomy.add_tag, the tag serializer, the import action validator)
with three different case-sensitivity semantics.
The archived-records PR for #778 showed the cost. Its review found that a new tag
can still be created under an archived parent (finding 2), that a write on an archived
taxonomy answers 400 on some endpoints and 404 on others depending on which code path
rejects it first (finding 6), and that the Python API and the import actions do not check
archived at all. #779, #780 and #781 will each add more archived-related rules, and each
one today has to find and patch the same scattered places.
Acceptance Criteria
The work is done when all of the following hold. The spec at the end of this issue describes
one way to get there and sequences the work in stages; the stages are implementation
order within this one issue, not separate tickets.
The refactoring goal is met and stays met
- Every create, update, move and delete of a
Taxonomy,TagorObjectTagrow that
the app performs on behalf of a caller (Python API, REST, import) originates in one
internal module. Persistence invariants that must hold on any save stay on the
models; the Django admin and raw ORM access remain the documented bypass. - An automated test fails the build when a model write appears outside that module and
its documented allow list, and its failure message names the offending file and line. - A reviewer can confirm the goal by example: the "archived record cannot be edited"
rule is implemented in one place in that module, and no write path outside the module
carries its own copy of the check. - A proposed ADR records the decision and its consequences: one internal write module
withapi.pyas the public facade over it, where the archived gates live, the admin
and ORM as the raw bypass, theTaxonomymodel methods no longer being override
points for the API and import paths, and the alternatives that were rejected.
Behavior for unarchived records is unchanged
The only intended behavior change in this issue is the archived rule in the next section.
For unarchived taxonomies and tags, every behavior below is identical before and after.
- Every public write function in
api.pykeeps its name, signature, defaults, return
type and raised exception types, and callers that catchValueErroror
Tag.DoesNotExistkeep working. -
Taxonomy.add_tag,Taxonomy.update_tagandTaxonomy.delete_tagskeep working for
direct callers with the same signatures and behavior. - The REST URL contract is unchanged, and a taxonomy update through REST (PUT and
PATCH) returns the same response body as before the change. - A tag import (create, rename, reparent, delete) produces the same resulting
taxonomy, tag tree and object-tag state as before the change, runs no additional
lookups or checks per action, and resyncs object tags no more often than today. - Deleting a tag through the Python API and deleting a tag during an import leave the
affected object tags in the same state as before the change, including the behavior
the delete signal handlers depend on. - The existing
openedx_taggingandopenedx_learningtest suites pass with no
existing test modified for the move. Test changes in this issue are limited to the
new tests in the last section of these criteria, the write-boundary test, the tests
for the archived-rule scenarios, and the updates of existing assertions that those
scenarios change. - The free-text, read-only, duplicate-value and parent-not-found error messages are
unchanged.
The archived rule applies on every taxonomy and tag-tree write
The scenarios below describe what a caller observes; the exception type and the per-function
gate assignment are in the spec. Object-tag writes (tag_object and the functions around
it) are out of scope here and keep today's behavior; #779 owns their archived semantics.
Scenario: Taxonomy and tag-tree writes to an archived taxonomy are refused on every entry point
Given a taxonomy that is archived
When a caller updates the taxonomy, adds a tag, renames a tag, moves a tag, deletes tags,
or imports tags, whether through the Python API or through REST
Then the write is refused with an error that identifies the taxonomy as archived
Scenario: Deleting an archived taxonomy is still allowed
Given a taxonomy that is archived
When a caller deletes the taxonomy
Then the deletion succeeds as it does today
Scenario: A tag cannot be created or moved under an archived parent
Given an unarchived taxonomy containing an archived tag
When a caller adds a new tag under it, or moves an existing tag under it
Then the write is refused with an error that identifies the parent as archived
Scenario: An archived tag cannot be renamed or moved
Given an archived tag in an unarchived taxonomy
When a caller renames or moves that tag
Then the write is refused with an error that identifies the tag as archived
Scenario: Refusals are consistent across REST endpoints
Given an archived taxonomy or tag
When a write is refused through any REST endpoint
Then every endpoint answers with the same client-error status (400, as the spec decides),
instead of some answering "not found" and others "bad request" depending on which
code path rejected it first
- Every row of the spec's test matrix for the archived rule has a test, and existing
tests that asserted the old refusal status for these writes are updated to the new one.
Tests added before the move, so the refactoring has a safety net
The existing suites cover most write paths only by status code or by re-reading the record
afterwards. The gaps below are the places where a moved function could change behavior
without any current test failing. Each item is a test that exists in the suite before the
corresponding code moves, and passes on both sides of the move.
- REST taxonomy update (PUT and PATCH) with the write-only
taxonomy_typefield in the
payload, asserting the full response body. Today only create and import send that
field, and the update tests check the result through a follow-up GET. - REST taxonomy delete of a multi-table-inheritance
Taxonomysubclass, asserting that
both the child row and the parent row are gone. Today only ORM-level deletes of such
a child are tested, and only inopenedx_learning; the tagging test may need its own
test-only subclass to respect the import layering. - The taxonomy-creating import endpoint when the import itself fails after the taxonomy
row was created (plan or validation error, as opposed to a parse error), asserting
the error response and that the taxonomy row was rolled back. - Import
DeleteTagon a parent tag that still has children, asserting the children
and the affected object tags afterwards; andDeleteTagon a tag that is already
gone. Today the only direct test deletes a leaf, and the replace-import test with
children asserts only that the import returned success. - Object-tag state after a tag is deleted through each path (
delete_tagswith and
without subtags, importDeleteTag, taxonomy cascade), run against the real delete
signal handlers rather than with the explicit-delete check patched out. The handlers
branch on how the delete was invoked, which is exactly what a move can change. - Direct calls to
Taxonomy.add_tag,update_taganddelete_tagsasserting the
exact free-text, duplicate-value and parent-not-found messages,update_tagon a
missing tag, anddelete_tagswithout subtags on a tag that has children. Today the
read-only refusal is asserted by substring only, and the rest is reached only through
REST status codes. -
create_taxonomywith ataxonomy_clssubclass, asserting both tables are written.
No test intests/openedx_taggingexercises that argument today. -
delete_object_tagson its own, andtag_objectcalled twice on the same object,
asserting the second call removes tags absent from the new list. - Current archived behavior that the archived-rule scenarios will change or must keep,
pinned first: deleting an archived taxonomy succeeds; deleting tags in an archived
taxonomy and importing into an archived taxonomy are refused with today's status.
These tests are then updated, deliberately, when the gate lands.
Technical Details
Technical Details
This is a suggested approach, not the source of truth. The User Story, Description, and
Acceptance Criteria define what must be true when the work is done.
Summary
This is a strangler-pattern refactoring in three stages. Stages 1a and 1b move code and add
a guard test without changing behavior. Stage 2 adds the archived gates, which does change
behavior. The public Python API, the model classes, the REST URL contract and the exception
types callers already catch (ValueError, Tag.DoesNotExist) stay unchanged.
Approach
tagging_mutations.py is an internal module. Every function that writes a Taxonomy,
Tag or ObjectTag row lives there, and nothing else in the app writes those rows.
Rules that must hold on any save, regardless of caller, stay on the models as persistence
invariants: Tag.save (depth, lineage, lineage cascade to descendants, external_id
generation), Tag.clean, Taxonomy.clean, ObjectTag.clean, the ObjectTag.__init__
defaults, and the foreign-key on_delete behavior. Rules about whether an operation is
allowed at all live in the module. Read paths are out of scope: the unarchived()
queryset methods are already the single read-side mechanism.
In stage 2, each taxonomy or tag-tree write opens with a gate call. A gate is a two-line
function that raises ArchivedError. There are two, one for a taxonomy and one for a tag.
Why a new module instead of api.py. OEP-49 puts business logic in api.py, but
api.py is also the public contract that edx-platform and plugins import, and every name
in it is stable under the README's rule. The write module needs functions that are
internal today (move_tag and delete_tag for the import plan, update_taxonomy,
delete_taxonomy) and will need archive and unarchive operations for #779 and #780. Putting any of
these in api.py would make them public on day one. The module is private, and api.py decides what to
expose.
Why the model methods stay. Taxonomy.add_tag, update_tag and delete_tags have
callers in tests and possibly in plugins. Keeping them as delegates keeps stage 1 free of
behavior change, and they can go through the DEPR process separately. One thing does
change: api.add_tag_to_taxonomy and the import's CreateTag call the module directly, so
a Taxonomy subclass that overrode add_tag would no longer intercept those calls. No
subclass in openedx-core or edx-platform does. The ADR records this.
Why a guard test enforces the module boundary. Import Linter cannot tell a read from a
write, and the views legitimately import the model classes for querysets. A short test that
walks the AST of each source file for write calls outside an allow list is simple, fast, and
names the offending file and line. It is the only thing that keeps the module honest after
its authors move on. The docstring in api.py already tried the convention-only route.
Why ArchivedError subclasses ValueError. Every tag view already maps ValueError
to HTTP 400 and TagDoesNotExist to 404, and edx-platform catches ValueError from
tag_object. A subclass gives callers a precise type to catch without breaking any
existing handler. It also settles finding 6 of the #778 review: 400 everywhere for a write
on an archived record.
Why the free-text and read-only checks are not a gate. They already exist on every
path that needs them, with four different message strings that tests here and in
edx-platform assert on. A single gate would fix no bug and would change those messages.
Each check stays inline in its own function body and moves with it into the module.
Risks and controls
| Risk | Control |
|---|---|
| A moved function changes behavior by accident. | Stage 1 requires the existing tests to pass unchanged, so a test that needs an edit is a defect in the move. |
| The import actions pick up checks or queries they do not run today. | The module's tag primitives (rename_tag, move_tag, delete_tag) take the Tag instance the action already holds. Their bodies are the action's current two lines. The value-based checks stay in update_tag, which only the REST and Python API paths call. |
An import cycle forms between models/base.py and the module. |
The model delegates import the module inside the method body. The module imports models at the top. api.py imports the module, and the module never imports api.py. |
| Import performance regresses because each action now resyncs object tags. | Resync stays where it is today: in the api.py functions for the REST tag endpoints, and once globally at the end of the import views. The module's primitives do not resync. |
perform_update skips Django REST Framework's serializer.save(). |
perform_update pops the write-only taxonomy_type field from validated_data the same way perform_create does, calls update_taxonomy, and sets serializer.instance so the response renders. edx-platform's taxonomy serializer overrides neither update nor save. |
| Stage 2 changes HTTP statuses. | The changes are 404 to 400 on POST and DELETE tags and on PUT import for an archived taxonomy, and 201 to 400 on POST tags with an archived parent. Nothing sets archived=True in any product path yet, so no live client sees them. |
tag_object behavior for archived tags and associations collides with #779. |
Stage 2 gates tag_object on nothing. It resolves archived tags on purpose today. #779 owns archived handling inside tag_object, and the module gives it one place to do it. |
| The admin bypasses the gates. | The ADR and the module docstring document the admin as a raw bypass, like the ORM. It is also the only place that can set archived until #779 and #780 add operations for it. |
Files to create and modify
| File | Change |
|---|---|
src/openedx_tagging/tagging_mutations.py |
new: all write functions, the two gates, ArchivedError |
src/openedx_tagging/api.py |
modified: write functions call the module; re-export ArchivedError |
src/openedx_tagging/models/base.py |
modified: Taxonomy.add_tag, update_tag, delete_tags become one-line delegates. Nothing else in the file changes. |
src/openedx_tagging/rest_api/v1/views.py |
modified: TaxonomyView.perform_update and perform_destroy call the module; create_import rollback calls delete_taxonomy; in stage 2, the archived check leaves get_object and archived taxonomies resolve for all write methods and import actions |
src/openedx_tagging/import_export/actions.py |
modified: execute() of the four tag actions calls the module |
src/openedx_tagging/import_export/api.py |
modified: in stage 2, _import_validations calls require_unarchived_taxonomy |
tests/openedx_tagging/test_tagging_mutations.py |
new: guard test (stage 1b), gate tests (stage 2) |
docs/openedx_tagging/decisions/0014-tagging-mutations-module.rst |
new: proposed ADR |
Additional context
- Finding 7 of the #778 review: the full-list replace in
tag_objectdeletes hidden
archived associations. - ADR 0006 (
docs/openedx_tagging/decisions/0006-tag-changes.rst) defines the copy-and-resync
semantics ofObjectTag._valueand_export_idthat the moved code must preserve. - ADR 0010 makes the import path the only place that changes
Tag.external_id, so the import
actions are a legitimate write entry point that the refactoring routes through the module,
not a side path to remove.
Open Questions
- (implementer) Whether
tagging_mutations.pystays one file or becomes a package with
taxonomy.py,tags.py,object_tags.pyonce stage 1b lands. The spec recommends one
file unless it exceeds about 800 lines.
Part 2: Spec
Spec: contract for the implementation
The spec was written when the work was planned as three child tickets and is kept as
written. Read "child ticket" and "stage" below as implementation order within this one
issue.
This spec is the contract for the three child tickets. Line numbers refer to the
pr-tbain-1-778_archived_read_paths branch of openedx-core and will drift; function
names are the stable reference.
1. Principles
- One module writes. Every
INSERT,UPDATEorDELETEonoel_tagging_taxonomy,
oel_tagging_tagandoel_tagging_objecttagoriginates intagging_mutations.py. The
two exceptions areTag.savedoing its own lineage cascade (persistence invariant) and
the Django admin (raw bypass). - The module is internal.
api.pyis the public facade and decides which functions are
public. Nothing outsideopenedx_taggingimportstagging_mutations. - Models keep persistence invariants only: things that must be true of a row no matter
who wrote it. Whether an operation is allowed is the module's job. - A gate is a tiny function that raises. Every taxonomy and tag-tree write calls its gate
first. No write function reimplements a gate's check inline. - Stage 1 is a move. If a test has to change in stage 1, the move is wrong.
- Primitives take instances; public-shaped operations take values.
rename_tag(taxonomy, tag, new_value)takes aTag;update_tag(taxonomy, tag, new_value)takes a value,
looks the tag up, runs the value-based checks, then callsrename_tag. The import plan
already holds instances, so it calls primitives and inherits no lookup or check it does
not run today.
2. Module layout
src/openedx_tagging/tagging_mutations.py
"""
Module docstring: what lives here, the "one module writes" rule, the gates,
and the admin/ORM bypass.
"""
# Exceptions (stage 2)
class ArchivedError(ValueError)
# Gates (stage 2). No leading underscore: import_export/api.py calls one of them.
def require_unarchived_taxonomy(taxonomy: Taxonomy) -> None
def require_unarchived_tag(tag: Tag) -> None
# Taxonomy (stage 1a)
def create_taxonomy(name, description=None, enabled=True, allow_multiple=True,
allow_free_text=False, taxonomy_cls=None, export_id=None) -> Taxonomy
def update_taxonomy(taxonomy: Taxonomy, **fields) -> Taxonomy
def delete_taxonomy(taxonomy: Taxonomy) -> None
# Tags, value-based (stage 1a): today's model method bodies, same parameter names
def add_tag(taxonomy, tag_value, parent_tag_value=None, external_id=None) -> Tag
def update_tag(taxonomy, tag, new_value) -> Tag
def delete_tags(taxonomy, tags, with_subtags=False) -> None
# Tags, instance-based primitives (stage 1a): today's import action bodies
def rename_tag(taxonomy, tag: Tag, new_value) -> Tag
def move_tag(taxonomy, tag: Tag, new_parent: Tag | None) -> Tag
def delete_tag(tag: Tag) -> None
# Object tags (resync in 1a, the rest in 1b)
def resync_object_tags(object_tags=None) -> int
def tag_object(object_id, taxonomy, tags, create_invalid=False, taxonomy_export_id=None) -> None
def delete_object_tags(object_id) -> None
def copy_tags(source_object_id, dest_object_id) -> None
def unmark_copied_tags(object_id) -> None
add_tag, update_tag and delete_tags have the same names and parameters as the
Taxonomy methods they replace, plus the taxonomy as the first argument.
3. Where each piece of today's code goes
| Today | Stage | Becomes |
|---|---|---|
api.create_taxonomy body (api.py:44-80), including taxonomy_cls handling |
1a | tagging_mutations.create_taxonomy, moved verbatim. api.create_taxonomy calls it. |
DRF default update on TaxonomyView (no perform_update; views.py:60-229) |
1a | tagging_mutations.update_taxonomy(taxonomy, **fields): setattr each field, taxonomy.save(), return it. TaxonomyView.perform_update pops taxonomy_type from serializer.validated_data (as perform_create does at views.py:275), calls update_taxonomy(serializer.instance, **validated_data) and assigns the result to serializer.instance. No full_clean, same as today; the existing TODO stays. |
DRF default destroy on TaxonomyView; taxonomy.delete() in create_import (views.py:343) |
1a | tagging_mutations.delete_taxonomy(taxonomy): taxonomy.delete(). perform_destroy and the create_import rollback call it. Same instance, so a multi-table-inheritance child still deletes correctly. |
Taxonomy.add_tag body (base.py:607-645) |
1a | tagging_mutations.add_tag(taxonomy, tag_value, parent_tag_value, external_id). The model method becomes a one-line delegate with a local import. |
Taxonomy.update_tag body (base.py:647-671), including its archived check |
1a | tagging_mutations.update_tag(taxonomy, tag, new_value): the moved body up to and including the lookup and checks, then return rename_tag(taxonomy, tag_to_update, new_value). The model method delegates. |
Taxonomy.delete_tags body (base.py:673-706) |
1a | tagging_mutations.delete_tags(taxonomy, tags, with_subtags). The model method delegates. The queryset .delete() call form is kept, because signal_handlers._is_explicit_tag_delete branches on the delete's origin. |
api.add_tag_to_taxonomy (api.py:446-467) |
1a | Unchanged shape: calls tagging_mutations.add_tag(...), then resync_object_tags(taxonomy.objecttag_set.all()). Same for update_tag_in_taxonomy (calls update_tag) and delete_tags_from_taxonomy (calls delete_tags; keeps its no-default with_subtags). |
api.resync_object_tags (api.py:174-190) |
1a | Moved to the module so api.py holds no write call. api.resync_object_tags calls it. |
CreateTag.execute (actions.py:238-246) calling self.taxonomy.add_tag(tag_value=..., ...) |
1a | Calls tagging_mutations.add_tag(self.taxonomy, tag_value=..., ...). Same keywords. |
RenameTag.execute (actions.py:360-366): tag.value = ...; tag.save() |
1a | Calls tagging_mutations.rename_tag(self.taxonomy, self._get_tag(), self.tag.value), whose body is those two lines plus return tag. No lookup by value, no new check. |
UpdateParentTag.execute (actions.py:302-311): tag.parent = parent; tag.save() |
1a | Calls tagging_mutations.move_tag(self.taxonomy, tag, parent), whose body is those two lines plus return tag. |
DeleteTag.execute (actions.py:398-403): self._get_tag().delete() |
1a | Calls tagging_mutations.delete_tag(tag), whose body is tag.delete(). The instance .delete() call form is kept for the signal handler's origin check. The DoesNotExist swallow stays in the action; it is about the plan's cascade order. delete_tag must not route through delete_tags: that would change the signal origin and add a children check that breaks deleting parent tags during import. |
api.tag_object with _check_new_tag_count, _get_current_tags (api.py:300-443) |
1b | Moved whole, including the two private helpers and the atomic() block. api.tag_object calls it. |
api.delete_object_tags, copy_tags, unmark_copied_tags (api.py:291-297, 499-535) |
1b | Moved whole. api.py calls each. |
ObjectTag.resync (base.py:948-988) |
stays | In-memory reconciliation of one row from its taxonomy and tag; the save() is in resync_object_tags. |
Tag.save lineage and depth logic (base.py:234-298) |
stays | Persistence invariant. Tests create tags with Tag.objects.create and depend on it. |
Taxonomy.tag_for_value, validate_value, tag_for_external_id, validate_external_id, get_filtered_tags, _unarchived_tag_for_value |
stays | Reads. |
Free-text and read-only checks in add_tag, update_tag, delete_tags, _import_validations |
stays inline | Four call sites, four message strings that tests assert on (tests/openedx_tagging/test_views.py:3760, 3795; edx-platform content_tagging/rest_api/v1/tests/test_views.py:2980). They move with their function bodies and are not unified. |
TaxonomyView.get_object archived check (views.py:248) |
2 | Removed. update_taxonomy calls require_unarchived_taxonomy; perform_update maps ValueError to ValidationError like the tag views do. The message string is reused verbatim so test_views.py:530, 599 keep passing. |
TaxonomyTagsView.get_taxonomy method-dependent lookup (views.py:810-824) |
2 | Uses the unfiltered Taxonomy.objects lookup for POST, PUT, PATCH and DELETE. GET keeps get_taxonomy() and 404. Object permissions already run against the unfiltered manager (permissions.py:68), so permission ordering is unchanged. |
TaxonomyView.get_object is_edit list (views.py:240) |
2 | Add update_import and plan_update_import so they resolve archived taxonomies and let _import_validations raise ArchivedError (400) instead of 404. |
Parent lookup in add_tag via tag_set.get(value__iexact=...) |
2 | Unchanged lookup, followed by require_unarchived_tag(parent). Fixes finding 2 of the #778 review. |
4. The gates (stage 2)
class ArchivedError(ValueError):
"""Raised when a write targets a taxonomy or tag that is archived."""
def require_unarchived_taxonomy(taxonomy: Taxonomy) -> None:
"""Raise ArchivedError if the taxonomy is archived. Deleting it is still allowed."""
if taxonomy.archived:
raise ArchivedError(_("This taxonomy is archived and cannot be edited."))
def require_unarchived_tag(tag: Tag) -> None:
"""Raise ArchivedError if the tag is archived."""
if tag.archived:
raise ArchivedError(_("Tag '{value}' is archived.").format(value=tag.value))
require_unarchived_tag does not look at tag.taxonomy.archived. At every call site the
tag was fetched through taxonomy.tag_set after require_unarchived_taxonomy(taxonomy)
already ran on that same taxonomy, so the check would be redundant.
Which write calls which gate, as the first statement after any lookup it needs:
| Function | Gate(s) |
|---|---|
create_taxonomy |
none |
update_taxonomy |
require_unarchived_taxonomy(taxonomy) |
delete_taxonomy |
none. Deleting an archived taxonomy is #780's concern and stays allowed. |
add_tag |
require_unarchived_taxonomy(taxonomy) next to the existing free-text and read-only checks; after the parent lookup, require_unarchived_tag(parent) |
update_tag |
require_unarchived_taxonomy(taxonomy) next to the existing checks; then calls rename_tag, which gates the tag |
delete_tags |
require_unarchived_taxonomy(taxonomy) next to the existing checks |
rename_tag |
require_unarchived_taxonomy(taxonomy); require_unarchived_tag(tag) |
move_tag |
require_unarchived_taxonomy(taxonomy); require_unarchived_tag(tag); require_unarchived_tag(new_parent) when not None |
delete_tag |
require_unarchived_taxonomy(taxonomy) only. A tag-level gate here would make every replace-import fail as soon as one tag is archived (see section 9). |
_import_validations in import_export/api.py |
require_unarchived_taxonomy(taxonomy) after its existing checks |
resync_object_tags, tag_object, delete_object_tags, copy_tags, unmark_copied_tags |
none in stage 2. #779 decides what tagging an object does with an archived tag or association, and owns that gate. |
The stage 1a archived check moved from Taxonomy.update_tag (base.py:667) is replaced in
stage 2 by these gate calls; it is the only inline archived check the module carries after
stage 2.
Plan versus execution. Gates in rename_tag and move_tag raise inside
TagImportPlan.execute, which import_tags catches and reports as a failed import
(import_export/api.py:154-157), so PUT tags/import/plan can return a clean plan while
PUT tags/import returns 400 for the same CSV. The 400 is the decided behavior. Plan-time
reporting of archived tags is #814's scope.
5. What api.py looks like after stage 1b
Reads are unchanged. Each write function keeps its public docstring and signature and
holds no model write call:
def add_tag_to_taxonomy(taxonomy, tag, parent_tag_value=None, external_id=None) -> Tag:
"""<existing docstring>"""
new_tag = tagging_mutations.add_tag(taxonomy, tag, parent_tag_value, external_id)
resync_object_tags(taxonomy.objecttag_set.all())
return new_tag
Plus, in stage 2:
# Export these as part of the API
TagDoesNotExist = Tag.DoesNotExist
ArchivedError = tagging_mutations.ArchivedError
6. What the model methods look like after stage 1a
def add_tag(self, tag_value, parent_tag_value=None, external_id=None) -> Tag:
"""
Add a tag to this taxonomy. The logic lives in openedx_tagging.tagging_mutations;
this method is kept for existing callers.
"""
from openedx_tagging import tagging_mutations # local import avoids a module cycle
return tagging_mutations.add_tag(self, tag_value, parent_tag_value, external_id)
Same for update_tag and delete_tags.
7. The guard test (stage 1b)
WRITE_ATTRS = {"save", "delete", "create", "update_or_create", "bulk_create"}
ALLOWED = {
"tagging_mutations.py",
"models/base.py", # Tag.save lineage cascade; model-level super().save()
"models/import_export.py", # TagImportTask bookkeeping
"import_export/api.py", # TagImportTask.create and log writes
"import_export/import_plan.py", # task.save() for import-task status
}
def test_only_tagging_mutations_writes_models():
offenders = []
for path in SRC_ROOT.rglob("*.py"):
rel = str(path.relative_to(SRC_ROOT))
if "migrations" in path.parts or rel in ALLOWED:
continue
tree = ast.parse(path.read_text(), filename=rel)
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr in WRITE_ATTRS
):
offenders.append(f"{rel}:{node.lineno}: .{node.func.attr}(")
assert not offenders, "Model writes outside tagging_mutations.py:\n" + "\n".join(offenders)
Parsing the AST instead of matching text means docstrings and comments cannot trigger it
(signal_handlers.py:30-43 mention .delete() in prose). A dry run of this test against the
branch before any change flags exactly the eleven call sites stage 1 moves (seven in api.py,
three in import_export/actions.py, one in rest_api/v1/views.py) and nothing else, so the
allow list is complete and the test passes once stage 1b lands. Known limits, accepted on
purpose: the allow list is per file, so a new write added to models/base.py is not
caught; queryset .update() is not scanned because of dict .update() false positives;
and writes that Django REST Framework performs inside its own mixins (UpdateModelMixin,
DestroyModelMixin) are invisible to it, which is why TaxonomyView must define
perform_update and perform_destroy explicitly. The test is a tripwire, not a proof.
8. Test matrix for stage 2
Module-level tests prove the gates; the guard test proves every write reaches the module;
REST tests pin the status codes. The import column therefore needs one test.
| Scenario | Module / Python API | Import | REST |
|---|---|---|---|
| Update archived taxonomy | update_taxonomy raises ArchivedError |
PUT, PATCH taxonomy: 400 (exists today) | |
| Add tag to archived taxonomy | add_tag raises |
import_tags raises ArchivedError |
POST tags: 400 (today 404) |
| Add tag under archived parent | add_tag raises |
POST tags with archived parent_tag_value: 400 (today 201) |
|
| Rename archived tag | update_tag raises (exists today); rename_tag raises |
PUT tags: 400 (exists today) | |
| Move tag, or move under archived parent | move_tag raises for an archived tag and for an archived new_parent |
||
| Delete tags in archived taxonomy | delete_tags raises; delete_tag raises |
DELETE tags: 400 (today 404) | |
| Import into archived taxonomy | covered by the import_tags test above |
PUT tags/import, tags/import/plan: 400 (today 404) |
|
| Delete archived taxonomy | delete_taxonomy succeeds |
DELETE taxonomy: 204 (exists today, untested; #778 review finding 3) | |
| Free-text and read-only refusals | unchanged messages | unchanged | unchanged |
9. Explicitly out of scope
- Any read path.
unarchived()stays as is. tag_objectand object-tag archived semantics (#779). Related spots inside the module
that #779 will touch, all now in one file:_get_current_tagsreturns archived rows, so a
re-applied archived association stays archived;_check_new_tag_countcounts archived
rows toward the 100-tag cap;copy_tagsusesupdate_or_create, which can match an
archived destination row.- Deleting archived records and the
deletion_lockedfield (#780). The object-tag deletes
it must consider aredelete_object_tags, the replace step oftag_object, and the
is_copieddelete incopy_tags, plus theSET_NULLonTagandTaxonomydeletes. - Events for archived associations (#781). When archive and unarchive become module
functions,update_taxonomyshould reject anarchivedfield so there is one way to
archive and it emits the event. - Import resolution to archived tags (#814). The lookups it needs to change are read-side
(actions.pyandimport_plan.pytag_setlookups), outside this module. A
pre-existing data-loss path belongs to it: export leaves archived tags out
(test_parsers.py:221-230), the REST import always passesreplace=True(views.py:362),
and the replace set istag_set.all()(import_plan.py:155), so an export, edit, re-import
round trip hard-deletes every archived tag and nulls their object tags. This exists today
and the refactoring does not change it. - The Django admin. It is documented as the raw bypass.
- Deprecating the
Taxonomymodel methods (separate DEPR). - Pre-existing debt the move exposes but does not cause:
copy_tagsskippingfull_clean,
the missing cycle check on reparent, three different duplicate-value semantics,
TaxonomyView.updateskippingfull_clean. Each gets its own issue if wanted. The module
makes each of them a one-place fix later.
10. Verification per stage
- Stage 1a and 1b:
pytest tests/openedx_tagging tests/openedx_learning --no-covwith no
test changed;lint-imports;pylint,pycodestyle,isort,mypyon touched files;
makemigrations --check(no model changes expected). - Stage 2: the test matrix above, plus the full suite.
- All stages: run edx-platform's
openedx/core/djangoapps/content_taggingtests against
the branch if a devstack is available, since edx-platform subclasses the views this
touches. Report it as a gap if not run.
- Dominant language
- Python
- Stars
- 10
- Forks
- 33
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 22
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from openedx/openedx-core
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
openedx/openedx-core#831 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
openedx/openedx-core#827 ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 20/100
openedx/openedx-core#895 ·
Maintainers usually reply within 1 day
-
Tag import: reject a parent_id that names a tag the full replace import deletesPossibly taken @ufedaseyeuconsultant claimed this today. Open
openedx/openedx-core#893 · 1 assignee ·
Maintainers usually reply within 1 day
-
Tag import: parent errors name their row and report every cycle as a cyclePossibly taken @ufedaseyeuconsultant claimed this today. Open
openedx/openedx-core#892 · 1 assignee ·
Maintainers usually reply within 1 day
All issues in openedx/openedx-core
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
pgvector/pgvector-python#169 ·
-
ChaosEngine: backup-remote doctor row passes while consumer mode hides bot memory from gitPossibly taken @MohabMohie claimed this today. Openagentic
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
ShaftHQ/SHAFT_ENGINE#7263 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 87/100
huggingface/smolagents#3045 ·
Maintainers usually reply within 1 day