Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

[Draft] Refactor: Consolidate taxonomy, tag and object-tag writes into one module

Open
#894 0 comments 0 reactions 1 assignee View on GitHub

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, Tag or ObjectTag row 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
    with api.py as the public facade over it, where the archived gates live, the admin
    and ORM as the raw bypass, the Taxonomy model 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.py keeps its name, signature, defaults, return
    type and raised exception types, and callers that catch ValueError or
    Tag.DoesNotExist keep working.
  • Taxonomy.add_tag, Taxonomy.update_tag and Taxonomy.delete_tags keep 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_tagging and openedx_learning test 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_type field 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 Taxonomy subclass, 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 in openedx_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 DeleteTag on a parent tag that still has children, asserting the children
    and the affected object tags afterwards; and DeleteTag on 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_tags with and
    without subtags, import DeleteTag, 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_tag and delete_tags asserting the
    exact free-text, duplicate-value and parent-not-found messages, update_tag on a
    missing tag, and delete_tags without 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_taxonomy with a taxonomy_cls subclass, asserting both tables are written.
    No test in tests/openedx_tagging exercises that argument today.
  • delete_object_tags on its own, and tag_object called 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_object deletes hidden
    archived associations.
  • ADR 0006 (docs/openedx_tagging/decisions/0006-tag-changes.rst) defines the copy-and-resync
    semantics of ObjectTag._value and _export_id that 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.py stays one file or becomes a package with
    taxonomy.py, tags.py, object_tags.py once 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
  1. One module writes. Every INSERT, UPDATE or DELETE on oel_tagging_taxonomy,
    oel_tagging_tag and oel_tagging_objecttag originates in tagging_mutations.py. The
    two exceptions are Tag.save doing its own lineage cascade (persistence invariant) and
    the Django admin (raw bypass).
  2. The module is internal. api.py is the public facade and decides which functions are
    public. Nothing outside openedx_tagging imports tagging_mutations.
  3. 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.
  4. 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.
  5. Stage 1 is a move. If a test has to change in stage 1, the move is wrong.
  6. Primitives take instances; public-shaped operations take values. rename_tag(taxonomy, tag, new_value) takes a Tag; update_tag(taxonomy, tag, new_value) takes a value,
    looks the tag up, runs the value-based checks, then calls rename_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_object and object-tag archived semantics (#779). Related spots inside the module
    that #779 will touch, all now in one file: _get_current_tags returns archived rows, so a
    re-applied archived association stays archived; _check_new_tag_count counts archived
    rows toward the 100-tag cap; copy_tags uses update_or_create, which can match an
    archived destination row.
  • Deleting archived records and the deletion_locked field (#780). The object-tag deletes
    it must consider are delete_object_tags, the replace step of tag_object, and the
    is_copied delete in copy_tags, plus the SET_NULL on Tag and Taxonomy deletes.
  • Events for archived associations (#781). When archive and unarchive become module
    functions, update_taxonomy should reject an archived field 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.py and import_plan.py tag_set lookups), 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 passes replace=True (views.py:362),
    and the replace set is tag_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 Taxonomy model methods (separate DEPR).
  • Pre-existing debt the move exposes but does not cause: copy_tags skipping full_clean,
    the missing cycle check on reparent, three different duplicate-value semantics,
    TaxonomyView.update skipping full_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-cov with no
    test changed; lint-imports; pylint, pycodestyle, isort, mypy on 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_tagging tests 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

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from openedx/openedx-core

All issues in openedx/openedx-core

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.