I have a site using the course module that has been heavily customised when it comes to display of the course outline (as in the the take course display) and the way that completion/progress is displayed. As such, this may be something that many people do not run into and is fine if you use this module with it's out-of-the-box functionality and displays.

In the system I've setup, I have two types of course objects: course materials and quizzes. Materials are just a regular node type that has been configured as a course object. As per the way the course module works, when you view a course material by following the node/[nid]/course-object/[coid] URL, the fulfilment data about that course object gets updated to include a start and end date and is effectively marked as completed. In my particular case, I have setup a custom course outline view that links users first to the regular node view URL and provides a link to "Mark as completed" which goes to the node/[nid]/course-object/[coid] URL, as the client wanted people to have to actively mark materials as viewed/completed rather than it just happening automatically the moment they viewed it. This system works fine.

I have also implemented my own system for showing completion progress of courses, by way of progress bars. This involved writing custom code that has to look up the fulfilment status of all the objects in a course, and add up how many are completed out of the total and then calculate a percentage based on that.

My system also groups courses into programs, so there is a function for determining the overall completion of the whole program, based on the percentage completion of each course within the program.

When I was implementing the function that calculates the percentage completion of the course, it was looping through all the course objects and checking the result of the isComplete() method on each object. This was producing false positives. For example, it was showing that I was 3% complete of a program in which I had enrolled in some of the courses but never marked any of the materials within those courses as complete. I took a look at the database to see what the course_outline_fulfillment table had in the way of data for these course objects for my user ID. What I noticed is that for almost all course materials, there was an entry with a non-NULL "date_completed" value, a "complete" value of 1 but no "date_started" value. Quizzes, on the other hand, that had been started but not finished, had a start date, no completion date and a completed value of 0.

After doing some testing, I found that I could reliable determine completion of a course object by checking if BOTH a start and completion date was present, as there never seems to be both until you visit the URL that updates the fulfilment to count it as completed.

This seems to me like a bug - why is it that some, though not all, course objects are getting an entry in this table with data that is not actually representative of the correct status of the object without the user seeming to have done anything?

Now, I am logged in with an admin user that also has permission to manage the course, create the materials, modify their settings etc, so perhaps something happens when I'm managing the course content that causes the fulfilment table to be updated, but that seems a bit strange.

So while I've solved my problem by checking dates instead of using the isComplete() method, that seems wrong and it should be possible to use the isComplete() method and trust that it's reliable. The DB table, I would think, shouldn't be getting entries with the completion flag set unless you have actually visited the URL for a piece of course material that is intended to mark the item as viewed/complete.

Screenshot attached shows my custom course outline view working with progress bars and such.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

teknocat created an issue. See original summary.

djdevin’s picture

Hi,

You mentioned that you have your course outline heavily customized, and:

What I noticed is that for almost all course materials, there was an entry with a non-NULL "date_completed" value, a "complete" value of 1 but no "date_started" value. Quizzes, on the other hand, that had been started but not finished, had a start date, no completion date and a completed value of 0.

The date_started is only set when a user goes to the course object via the object URL, like node/123/course-object/789. It is not set anywhere else.

If you had a custom outline display that did not use that link, it's possible that a user could complete the object without starting it - for example if you linked directly to the node/quiz.

Does that help?

teknocat’s picture

@djdevin Are you suggesting that by visiting just the regular node view page that it marks it as completed? Is that the trick then? Going to node/123/course-object/789 sets a start date, after which going to node/123 would set it as completed?

I thought that going to node/123/course-object/789 set it as completed, as well as started?

I'm talking here about regular course materials, not quizzes, just to be clear.

djdevin’s picture

Yep, visiting the router (course-object/123) starts/continues the "attempt" at the object, and then it handles how the course content should be delivered. In this case it delivers it as a redirect to a node. And upon viewing the node, another process detects the course context and then completes the object. Other kinds of objects might not redirect, and instead, display content inline, popup, etc. but they would have their own completion logic like Quizzes do.

So maybe that's the root of the issue here? In your custom outline handler make sure you call $CourseObject->getUrl() to get the path to the router and not to the node itself. This also makes sure that your outline can handle any type of object if you ended up using non-node objects too.

teknocat’s picture

Thanks for explaining that more clearly.

So how would you suggest I go about letting the user view the course material such that it either does nothing or at least marks it as started, but not completed, and then give them a URL they can visit to mark it as completed when they are ready?

Going to the node/123/course-object/789 URL, as mentioned, immediately marks it as both started and completed instantly, and this is not desired.

teknocat’s picture

Ah, now I see the code where it sets the fulfilment status to complete upon viewing the node.

Line 25 is course_content.module:

/**
 * Implements hook_node_view().
 */
function course_content_node_view($node, $view_mode = 'full') {
  if ($view_mode == 'full' && variable_get("course_content_use_{$node->type}", 0)) {
    global $user;
    if ($courseObject = course_get_course_object('course_content', $node->type, $node->nid, $user)) {
      $courseObject->getFulfillment()->setComplete()->save();
    }
  }
}

This is essentially what I'd like to avoid. Clearly this hook fires whether you just go to the normal node view, or the full course object URL, but the latter also sets the start time.

I'm not sure I fully appreciate why it works this way. Is my particular use case really that unique? Am I the only person who has ever wanted to be able to view a course object without immediately marking it as complete, and instead allow the user to take an action that does that instead?

djdevin’s picture

It's not unique but we usually do it in a different way. The "action" you are talking about, from my experience is usually a follow-up quiz/webform/poll that evaluates the user's comprehension. So we don't really care if they "complete" the content because there is a next step they have to take.

Something that has come up before is being able to add a checkbox to the bottom of course pages like "I've read this material" that would complete the object only when it is checked. Flag module might be able to help with this as you'd be able to add the checkbox/links with no code, and then only have code to not complete the object upon view, but to complete it upon flag.

teknocat’s picture

Yeah what my client wants really is a way to flag that they have read the material, so what you're talking about there is one idea. Though, because it's on node view that the course object gets marked as completed, even if you used the flag module I'm not sure how you'd prevent that from happening.

I think that if I want to work with the system properly that what I should do perhaps is implement my own URL for viewing the node and marking the course object as started, which then redirects to the regular node view URL when you click the "I have read this material" link that I currently have in place, and that will trigger it to automatically mark it as completed.

teknocat’s picture

So after further thought I think I see what you're suggesting with the flag module - to use that as the way to check status of completion instead of using the course module's fulfilment data. Just leave the internals alone and use your own method, basically.

Since I've already done a lot of work that relies on using the internal fulfilment data - and would need to for quizzes anyway - this didn't seem like the pragmatic solution to me. However, I do want it to be able to rely on the isComplete() method and have it work properly, so I implemented a workaround. I checked to see that my own module's hook_node_view is running after the hook in course_content, which it is, and basically had it undo what the course_content_hook_node_view does, like so:

if ($view_mode == 'full' && variable_get("course_content_use_{$node->type}", 0)) {
  global $user;
  if ($courseObject = course_get_course_object('course_content', $node->type, $node->nid, $user)) {
    // The course content module always automatically
    // sets the fulfillment status to complete when the
    // node is viewed. However, we want to undo that if
    // the course has not yet been marked as started,
    // which is what happens when you view the node directly
    // BEFORE following the full course object link. This
    // accommodates our need to be able to not have course
    // objects marked as completed until the user chooses
    // to do so. They are linked directly to the node view
    // first, and have a button to go to the full course object
    // URL when they choose. So with this code, we ensure that
    // it does not get marked as complete until you have followed
    // that particular path.
    $fulfillment = $courseObject->getFulfillment();
    if ($fulfillment->isComplete() && !$fulfillment->getOption('date_started')) {
      $fulfillment->delete();
    }
  }
}

So basically, if it's been marked as completed but not started, then I change it back to having no completion date and completion flag set to zero. Then, the user will click a link to "mark it as completed" that follows the full course object URL, which then marks the start date, redirects to the node view which triggers the hook to mark it completed, and all is well. My hook will do nothing if it's marked as complete AND has a start date.

So, it seems like a bit of a hackish workaround, but it did seem like the most pragmatic solution for now. Hopefully others who need some similar functionality can learn something helpful from this issue thread.

djdevin’s picture

For a cleaner approach maybe try using hook_course_object_fulfillment_presave($fulfillment) instead. That way you can prevent the completion before it is saved. Something like

function hook_course_object_fulfillment_presave($fulfillment) {
  if ($courseObject->getModule() == 'course_content') {
    if (!some_flag_check()) {
      $fulfillment->setComplete(0);
    }
  }
}

Then you can have another hook that detects the flag being marked, and fulfills it for real.

teknocat’s picture

Ah yes that is a much more elegant solution, thank you! I had not thought about using hook_entity_presave. The following code works in D7 (hook_ENTITY_TYPE_presave(), as you've suggested, is D8 only):

function maca_courses_entity_presave($entity, $entity_type) {
  if ($entity_type == 'course_object_fulfillment' && $entity->getCourseObject()->getModule() == 'course_content') {
    if ($entity->isComplete() && !$entity->getOption('date_started')) {
      $entity->setComplete(0);
      $entity->setOption('date_completed', NULL);
    }
  }
}

This still saves fulfilment data in the database, while my other solution would just delete it, but the end result is the same and doesn't cause any problems.

djdevin’s picture

Title: Course object completion incorrectly flagged » Prevent completion of content until flagged by user
Category: Bug report » Support request
Status: Active » Fixed

Great, hook_ENTITY_TYPE_presave() does actually exist in D7 but it's provided by the Entity API module which Course has a dependency on. Core in D8 :) I think it should be firing.

http://cgit.drupalcode.org/entity/tree/includes/entity.controller.inc#n352

Closing this issue for now, but considering making this into a module or configuration if more people need it.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

deggertsen’s picture

Can any of you actually outline the process of how to do this then? I think I might be able to figure it out based on what @djdevin and @teknocat have said, but it would be nice if a step by step guide could be written out to make it fool proof (me being the fool).

Thanks for all the great information so far. This definitely seems like functionality that should be supported by default.