Can I lock down custom taxonomies on a parent term level, but not a child term?

This not a complete and ready-made answer, but the below should get you started.

First thing to note is, deleting and modifying are different functionalities, so different functions and hooks are relevant to it. Namely we are talking about wp_delete_term()source and wp_update_term()source – regarding the functions.

So lets take a look at wp_delete_term(), you have to at least consider two hooks: edit_term_taxonomies and delete_term_taxonomy. They are both relevant because of the bulk action and because of the handling of hierarchical taxonomies.

Below some exemplary code how to approach this. I’ve tried to explain the most important things inside the code. The rest you have to read up on yourself.

First the edit_term_taxonomies hook:

add_action(
    'edit_term_taxonomies',
    'wpse160191_top_level_lock_down_edit_term_taxonomies',
    10,
    1
);
function wpse160191_top_level_lock_down_edit_term_taxonomies(
    $edit_tt_ids
) {
    $screen_obj = get_current_screen();
    $base = $screen_obj->base;
    $taxonomy = $screen_obj->taxonomy;

    if(
        // these are the conditions we are checking against
        // is this a tag edit page
        $base == 'edit-tags'
        // we want this only for a specific taxonomy
        && $taxonomy == 'category'
        // not super and/or admin
        && ! current_user_can( 'manage_options' )
        // a least an editor
        && current_user_can( 'manage_categories' )
    ) {

        // these are the terms up for deletion
        $terms_queue_for_deletion = $_POST['delete_tags'];
        $term_parents_ids = array();
        // we need information if or not one of the terms is a top level one
        foreach ( $terms_queue_for_deletion as $term_id ) {
            $term_obj = get_term_by( 'id', $term_id, 'category' );
            $term_parent = $term_obj->parent;
            $term_parents_ids[] = $term_parent;
        }

        if( empty( $edit_tt_ids ) ) {
            return;
        }

        if( ! empty( $edit_tt_ids ) ) {
            if ( in_array( 0, $term_parents_ids ) ) {
                $msg = new WP_Error(
                'parent_terms_are_forever',
                __('Sorry, you are not allowed to delete top level terms.')
                );
                wp_die( $msg );
            } else {
                return;
            }
        }
    }
}

Second the delete_term_taxonomy hook:

add_action(
    'delete_term_taxonomy',
    'wpse160191_top_level_lock_down_delete_term_taxonomy',
    10,
    1
);
function wpse160191_top_level_lock_down_delete_term_taxonomy(
    $tt_id
) {

    $screen_obj = get_current_screen();
    $base = $screen_obj->base;
    $taxonomy = $screen_obj->taxonomy;

    if (
        // these are the conditions we are checking against
        // is this a tag edit page
        $base == 'edit-tags'
        // we want this only for a specific taxonomy
        && $taxonomy == 'category'
        // not super and/or admin
        && ! current_user_can( 'manage_options' )
        // a least an editor
        && current_user_can( 'manage_categories' )
    ) {
        $term_obj = get_term_by( 'term_taxonomy_id', $tt_id, 'category' );
        $parent_id = $term_obj->parent;
        if (
            // this applies only to top level terms
            $parent_id == 0
        ) {
            $msg = new WP_Error(
                'parent_terms_are_forever',
                __('s:Sorry, you are not allowed to delete top level terms.')
            );
            wp_die( $msg );
        }
    }
}

As mentioned above, it gets a bit more complicated, mainly because of the AJAX actions. If you are thinking about doing this with the well known and simple check:

if ( defined('DOING_AJAX') && DOING_AJAX ) {
    // code
}

I can tell you right now, that I’m pretty certain it won’t be sufficient enough. Mostly because you won’t be able to do all the checks you need. If you are fine with just disabling or, to be exact, removing the link to the AJAX action, you can do it somewhat like this:

add_action(
    'admin_head' ,
    'wpse160191_remove_ajax_delete_link'
);
function wpse160191_remove_ajax_delete_link() {
    $screen_obj = get_current_screen();
    $base = $screen_obj->base;
    $taxonomy = $screen_obj->taxonomy;

    if(
        // lets just remove the link on that one page and not everywhere
        $base == 'edit-tags'
        && $taxonomy == 'category'
    ) {
    ?>
        <script type="text/javascript">
            jQuery(document).ready(function($) {
                var element="span.delete";
                $(element).each(function(){
                    $(element).css('opacity', '0');
                    $(element).css('pointer-events', 'none');
                    $(element).remove();
                });
            });
        </script>
    <?php
    }
}

Of course you shouldn’t do it inline and so on, but you can optimize yourself. This of course is not very satisfying, because we just removed the functionality.

One way I imagine to deal with the AJAX action would be to make use of debug_backtrace(). This would offer a way to determine and handle the AJAX action correctly. It could be implemented into the above shown code. A caveat of course is that debug_backtrace() is performance-wise expensive and in a larger scale surely not recommendable.
Another way would be to implement your own AJAX action as replacement for wp_ajax_delete_tag(), but to do so you have to additionally either have to extend the WP_Terms_List_Table or use some hooks in there to achieve this. Not exactly sure at the moment, so you have to inspect that further yourself.

The procedure for wp_update_term() and disallowing updating/modifying is analogous to the above shown. With the same considerations concerning the AJAX part, especially because of the inline quick edit possibility.


At first I totally misread the question and gave this as an answer.
Keeping it because it might be useful to others.

What you can do is hooking into the pre_insert_term to do the checks needed.

Code:

add_action( 'pre_insert_term', 'wpse160191_restrict_top_level_term_creation', 10, 2 );
function wpse160191_restrict_top_level_term_creation( $term, $taxonomy ) {
    if( 
        // these are the conditions we are checking against
        // we want this only for a specific taxonomy
        $taxonomy == 'topics'
        // a value of 0 or -1 does indicate the top level
        && $_POST[ 'parent' ] <= '0'
        // not super and/or admin
        && ! current_user_can( 'manage_options' )
        // a least an editor
        && current_user_can( 'manage_categories' )
    ) {
        // the user is an editor and not allowed to add parent terms
        // so return a friendly message so the user at least knows 
        return new WP_Error(
            'parent_terms_not_allowed_for_editors',
            __('Sorry, you are not allowed to add parent terms.')
        );
    } else {
        // a super and/or admin, s|he is allowed to do what s|he wants
        return $term;
    }
}

The pre_insert_term hook is part of the wp_insert_term() function.

Some Notes:

  • The parent value of the $args is 0, when no parent is selected. But we have no access to those inside the action, the form does return the value of -1 for $_POST[ 'parent' ] if »None« is selected. So <= '0' should work in any case.
  • We are not passing the user role to the current_user_can() function, because that is just wrong – although you see it a lot, it still is bad. Instead capabilities are used to determine, which kind of user we are dealing with.

Leave a Comment