Appearance
Are you an LLM? You can read better optimized documentation at /features/courses_lessons.md for this page in Markdown format
Course and lesson system
This document provides a detailed technical overview of the course and lesson system within TheExampleApp. It is intended for developers responsible for maintaining and extending this functionality. The system is built using ASP.NET Core Razor Pages and sources all its content from a Contentful headless CMS.
Content structure
The organization of courses and lessons is defined by two primary C# models that directly map to content types in Contentful. This content-first approach dictates the application's data structure. For more details on the base models and their mapping from Contentful, see the Models documentation.
A Course acts as a container for a collection of Lesson entries.
Course Model
The Course model represents a single educational course. It contains metadata like title, duration, and skill level, as well as linked entries for its constituent lessons and categories.
Source File: TheExampleApp/Models/Course.cs
csharp
// TheExampleApp/Models/Course.cs
using System.Collections.Generic;
using Contentful.Core.Models;
namespace TheExampleApp.Models
{
public class Course
{
// System properties from Contentful (ID, timestamps, etc.)
public SystemProperties Sys { get; set; }
public string Title { get; set; }
public string Slug { get; set; }
public Asset Image { get; set; }
public string ShortDescription { get; set; }
public string Description { get; set; }
public int Duration { get; set; } // In minutes
public string SkillLevel { get; set; }
// A course contains a list of linked Lesson entries.
public List<Lesson> Lessons { get; set; }
// A course can belong to multiple categories.
public List<Category> Categories { get; set; }
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Lesson Model
The Lesson model represents a single lesson within a course. Its most important feature is the Modules property, which allows for flexible and composite lesson content.
Source File: TheExampleApp/Models/Lesson.cs
csharp
// TheExampleApp/Models/Lesson.cs
using System.Collections.Generic;
using Contentful.Core.Models;
namespace TheExampleApp.Models
{
public class Lesson
{
public SystemProperties Sys { get; set; }
public string Title { get; set; }
public string Slug { get; set; }
// A lesson is composed of one or more modules.
// ILessonModule allows for different content block types (e.g., text, code).
public List<ILessonModule> Modules { get; set; }
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Course listing
The course listing page is responsible for displaying all available courses and allowing users to filter them by category. It is implemented as a Razor Page.
Page Model: TheExampleApp/Pages/Courses.cshtml.csRoute: /courses or /courses?category={category-slug}
Implementation Details
The OnGet method in CoursesModel handles the logic for fetching and filtering courses.
- Data Fetching: Two separate calls are made to the Contentful API: one to fetch all
categoryentries for the filter UI, and another to fetch allcourseentries. - Deep Linking: The query uses
.Include(5)to ensure that linked entries (like a course's lessons and categories) are fetched in the same API call, preventing N+1 query problems. - Localization: Content is fetched for the current locale, which is retrieved from the user's session (
HttpContext.Session). This ensures that users see content in their selected language. - Category Filtering: If a
categoryquery string parameter is provided, the fetched list of courses is filtered in-memory using LINQ. This is efficient as all courses are already loaded. - Error Handling: If a non-existent category slug is provided, the system sets a
TempDatamessage and returns a404 Not Foundresult. This prevents crashes and provides clear user feedback.
csharp
// TheExampleApp/Pages/Courses.cshtml.cs
public async Task<IActionResult> OnGet(string category)
{
// 1. Fetch all categories for the filter menu.
var categories = await _client.GetEntriesByType("category",
QueryBuilder<Category>.New.Include(5).LocaleIs(
HttpContext?.Session?.GetString(Startup.LOCALE_KEY) ?? CultureInfo.CurrentCulture.ToString()));
// 2. Build a query for all courses, including linked entries and respecting locale.
var queryBuilder = QueryBuilder<Course>.New.ContentTypeIs("course").Include(5).LocaleIs(
HttpContext?.Session?.GetString(Startup.LOCALE_KEY) ?? CultureInfo.CurrentCulture.ToString()).OrderBy("-sys.createdAt");
var courses = await _client.GetEntries(queryBuilder);
Categories = categories.ToList();
SelectedCategory = categories.FirstOrDefault(c => c.Slug == category);
if (!string.IsNullOrEmpty(category))
{
// 3. Filter courses in-memory if a category is selected.
Courses = courses.Where(c => c.Categories != null && c.Categories.Any(x => x.Slug == category.ToLower())).ToList();
var cat = Categories.FirstOrDefault(c => c.Slug == category.ToLower());
// 4. Handle invalid category slugs.
if(cat == null)
{
TempData["NotFound"] = _localizer["errorMessage404Category"].Value;
return NotFound();
}
// Update breadcrumbs for better navigation context.
_breadcrumbsManager.ReplaceCrumbForSlug(category.ToLower().Replace('-', ' '), cat.Title);
}
else
{
// If no category, display all courses.
Courses = courses.ToList();
}
return Page();
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
The resulting List<Course> Courses is then passed to the associated Razor view for rendering. For more on this pattern, see the Razor Pages documentation.
Individual course view
This page displays the details for a single course, including its description, metadata, and a list of its lessons (which serves as a table of contents).
Page Model: TheExampleApp/Pages/Courses/Index.cshtml.csRoute: /courses/{slug}
Implementation Details
The OnGet method in IndexModel is responsible for fetching the specific course requested via the URL slug.
- Data Fetching: A
QueryBuilderis constructed to fetch a singlecourseentry where theslugfield matches the route parameter. - Error Handling: If no course matches the provided slug,
(await _client.GetEntries(queryBuilder)).FirstOrDefault()will returnnull. The code checks for this and returns a404 Not Foundresult, preventing null reference exceptions in the view. - Progress Tracking: Upon successfully loading a course, its Contentful System ID (
Course.Sys.Id) is passed to theIVisitedLessonsManager. This service tracks which courses and lessons the user has viewed. See the Visited Lessons documentation for more details. - ViewData: The
VisitedLessonscollection is added toViewDataso the Razor view can conditionally apply styles to lessons that have already been visited (e.g., adding a checkmark).
csharp
// TheExampleApp/Pages/Courses/Index.cshtml.cs
public async Task<IActionResult> OnGet(string slug)
{
// 1. Build a query to find the course by its slug.
var queryBuilder = QueryBuilder<Course>.New.ContentTypeIs("course").FieldEquals(f => f.Slug, slug?.ToLower())
.Include(5).LocaleIs(HttpContext?.Session?.GetString(Startup.LOCALE_KEY) ?? CultureInfo.CurrentCulture.ToString());
var courses = await _client.GetEntries(queryBuilder);
Course = (await _client.GetEntries(queryBuilder)).FirstOrDefault();
// 2. Handle the case where the course is not found.
if(Course == null)
{
TempData["NotFound"] = _localizer["errorMessage404Course"].Value;
return NotFound();
}
// 3. Mark the course as visited for progress tracking.
_visitedLessonsManager.AddVisitedLesson(Course.Sys.Id);
// Update breadcrumbs for navigation.
_breadcrumbsManager.ReplaceCrumbForSlug(Course.Slug.ToLower().Replace('-', ' '), Course.Title);
// 4. Pass visited lesson data to the view.
ViewData["VisitedLessons"] = _visitedLessonsManager.VisitedLessons;
return Page();
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
The Course object, now populated with its Lessons list, is rendered by the Razor view. The view iterates over Model.Course.Lessons to build the table of contents.
Lesson viewer
The lesson viewer displays the content of a single lesson and provides navigation to the next lesson in the course.
Page Model: TheExampleApp/Pages/Courses/Lessons.cshtml.csRoute: /courses/{slug}/lessons/{lessonSlug}
Implementation Details
The OnGet method in LessonsModel orchestrates the fetching and display of a lesson.
- Fetch Course Context: The parent
Courseis fetched first using the courseslug. This is essential for context, breadcrumbs, and inter-lesson navigation. - Find Lesson: The specific
Lessonis located from theCourse.Lessonscollection using LINQ. This is highly efficient as the lesson data was already loaded with the course thanks to the.Include(5)directive. - Robust Error Handling: The code checks for both a missing course and a missing lesson, returning a
404 Not Foundwith a specific error message for each case. - Progress Tracking: The
IVisitedLessonsManageris invoked to mark the current lesson as visited. This is a core part of the user's progress tracking. See the Visited Lessons documentation. - System Properties Collection: The code collects
SystemPropertiesfor the lesson and all its modules. This metadata (containing Contentful IDs, timestamps, etc.) is made available to the view, typically for debugging, cache management, or displaying entry metadata. - Multi-Module Content: The lesson's content is rendered from its
Modulesproperty. The actual rendering logic, which likely uses the Markdig library to process Markdown, resides in the Razor view. For more on this, see the Content Display documentation. - Next Lesson Navigation: A calculated property,
NextLessonSlug, provides the slug for the "Next Lesson" link. It uses a LINQ query to find the next item in theCourse.Lessonslist relative to the current lesson.
csharp
// TheExampleApp/Pages/Courses/Lessons.cshtml.cs
public async Task<IActionResult> OnGet(string slug, string lessonSlug)
{
// 1. Fetch the parent course for context.
var queryBuilder = QueryBuilder<Course>.New.ContentTypeIs("course").FieldEquals(f => f.Slug, slug?.ToLower()).Include(5).LocaleIs(HttpContext?.Session?.GetString(Startup.LOCALE_KEY) ?? CultureInfo.CurrentCulture.ToString());
Course = (await _client.GetEntries(queryBuilder)).FirstOrDefault();
if (Course == null)
{
TempData["NotFound"] = _localizer["errorMessage404Course"].Value;
return NotFound();
}
// 2. Find the specific lesson within the course's lesson list.
SelectedLesson = Course.Lessons.FirstOrDefault(c => c.Slug == lessonSlug?.ToLower());
if (SelectedLesson == null)
{
TempData["NotFound"] = _localizer["errorMessage404Lesson"].Value;
return NotFound();
}
// 3. Update breadcrumbs for navigation context.
_breadcrumbsManager.ReplaceCrumbForSlug(Course.Slug.ToLower().Replace('-', ' '), Course.Title);
_breadcrumbsManager.ReplaceCrumbForSlug(SelectedLesson.Slug.ToLower().Replace('-', ' '), SelectedLesson.Title);
// 4. Mark the current lesson as visited.
_visitedLessonsManager.AddVisitedLesson(SelectedLesson.Sys.Id);
ViewData["VisitedLessons"] = _visitedLessonsManager.VisitedLessons;
// 5. Collect system properties for the lesson and its modules.
var systemProperties = new List<SystemProperties> { SelectedLesson.Sys };
if (SelectedLesson.Modules != null && SelectedLesson.Modules.Any())
{
systemProperties.AddRange(SelectedLesson.Modules?.Select(c => c.Sys));
}
SystemProperties = systemProperties;
return Page();
}
/// <summary>
/// The slug of the next lesson of the course.
/// </summary>
public string NextLessonSlug { get => Course.Lessons.SkipWhile(x => x != SelectedLesson).Skip(1).FirstOrDefault()?.Slug; }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
This design ensures that the lesson page is self-contained while remaining aware of its position within the broader course structure, enabling rich navigation and progress tracking features.