$post->ID not working in combination with a custom query

That $query->have_posts() modifies the global $post variable which is also used on the admin side, and on the front-end/public side of the site, you would simply call wp_reset_postdata() to restore the global $post back to the post before you call the $query->have_posts().

But on the admin side, you need to manually restore the variable like so, after your loop ends:

function module_group_callback( $post ) {
    ...
    while ( $query->have_posts() ) : $query->the_post();
        ...
    endwhile;

    // Restore the global $post back to the one passed to your metabox callback.
    $GLOBALS['post'] = $post;
}

But if you call global $post in your code, then yes, you can use the backup method:

function module_group_callback( $post ) {
    global $post;   // this is required
    $_post = $post; // backup the current $post variable in the global scope

    ...
    while ( $query->have_posts() ) : $query->the_post();
        ...
    endwhile;

    $post = $_post; // restore
}

Alternatively, you could just omit the $query->have_posts() and loop manually through $query->posts:

function module_group_callback( $post ) {
    ...
    // Don't use $post with the 'as'. Instead, use $p, $post2 or another name.
    foreach ( $query->posts as $p ) {
        echo '<option value="' . $p->ID . '">' . esc_attr( get_the_title( $p ) ) . '</option>';
    }
}

Either way, if your code modifies the global $post, then make sure to restore it later.

And btw, you would want to use esc_html() there, because esc_attr() is for escaping attribute values, e.g. <input value="<?php echo esc_attr( 'some <b>HTML</b>' ); ?>">. 🙂