Appearance
Are you an LLM? You can read better optimized documentation at /features/course_management.md for this page in Markdown format
Course management
This document provides a detailed technical overview of the course and lesson management functionality within the application. It covers data retrieval from Contentful, page rendering logic, state management for user progress, and the underlying data models.
Course listing
The main course listing page, available at /courses, displays all available courses, sorted by their creation date.
Implementation Details
The logic for this page is handled by the CoursesModel Razor Page model located at TheExampleApp/Pages/Courses.cshtml.cs.
Upon a GET request, the OnGet method orchestrates the fetching of all necessary data. It executes two sequential queries to Contentful: one for all category entries and another for all course entries.
Courses are fetched and ordered by their creation date in descending order to display the newest courses first.
csharp
// From: TheExampleApp/Pages/Courses.cshtml.cs
public async Task<IActionResult> OnGet(string category)
{
// Fetch all categories for the filter menu
var categories = await _client.GetEntriesByType("category",
QueryBuilder<Category>.New.Include(5).LocaleIs(/* locale */));
// Build the query for courses, ordering by creation date descending
var queryBuilder = QueryBuilder<Course>.New
.ContentTypeIs("course")
.Include(5)
.LocaleIs(/* locale */)
.OrderBy("-sys.createdAt");
var courses = await _client.GetEntries(queryBuilder);
Categories = categories.ToList();
// ... filtering logic ...
// If no category is selected, 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
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
The fetched List<Course> is assigned to the Courses property on the model, which is then used by the corresponding Razor view (/Pages/Courses.cshtml) to render the course cards.
For more information on the views, see the Course Views documentation.
Course filtering
The course listing page supports filtering by category. When a user selects a category, the URL is updated to /courses?category={category-slug}, and the page displays only the courses associated with that category.
Implementation Details
The filtering logic resides within the same OnGet method in CoursesModel as the main course listing. The method accepts an optional category string parameter from the query string.
If the category parameter is present, the code filters the master list of courses (which has already been fetched) using a LINQ Where clause. This in-memory filtering approach is efficient as it avoids making a new API call to Contentful for each filter action.
csharp
// From: TheExampleApp/Pages/Courses.cshtml.cs
// This block executes if a 'category' query parameter is present.
if (!string.IsNullOrEmpty(category))
{
// Filter the pre-fetched list of courses using LINQ.
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());
// Handle cases where the category slug is invalid.
if(cat == null)
{
TempData["NotFound"] = _localizer["errorMessage404Category"].Value;
return NotFound();
}
// Update breadcrumbs for better UX
_breadcrumbsManager.ReplaceCrumbForSlug(category.ToLower().Replace('-', ' '), cat.Title);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Gotcha: The system is designed to handle invalid category slugs gracefully. If a slug is provided in the URL that does not correspond to an existing category, the application will return a 404 Not Found page. This is handled by checking if the cat variable is null after attempting to find the selected category.
Course details
The course detail page, routed at /courses/{slug}, provides a comprehensive overview of a single course, including its description, metadata, and a table of contents for its lessons.
Implementation Details
This page is managed by the IndexModel located at TheExampleApp/Pages/Courses/Index.cshtml.cs. The OnGet method is triggered with the course slug from the URL.
A specific query is constructed using the QueryBuilder to fetch exactly one course matching the provided slug.
csharp
// From: TheExampleApp/Pages/Courses/Index.cshtml.cs
public async Task<IActionResult> OnGet(string slug)
{
// Query for a single course by its slug.
var queryBuilder = QueryBuilder<Course>.New
.ContentTypeIs("course")
.FieldEquals(f => f.Slug, slug?.ToLower())
.Include(5) // Include linked entries like lessons and categories.
.LocaleIs(/* locale */);
Course = (await _client.GetEntries(queryBuilder)).FirstOrDefault();
// If no course is found for the slug, return 404.
if(Course == null)
{
TempData["NotFound"] = _localizer["errorMessage404Course"].Value;
return NotFound();
}
// Mark the course itself as "visited".
_visitedLessonsManager.AddVisitedLesson(Course.Sys.Id);
// Update breadcrumbs with the actual course title.
_breadcrumbsManager.ReplaceCrumbForSlug(Course.Slug.ToLower().Replace('-', ' '), Course.Title);
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
29
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
Key points for this page:
- Error Handling: If the
GetEntriescall returns no items, it means the slug is invalid. The model handles this by setting aTempDatamessage and returning aNotFound()result, which renders a 404 page. - Progress Tracking: Upon successfully loading a course, its Contentful entry ID (
Course.Sys.Id) is passed to the_visitedLessonsManagerto mark it as visited. - Data Binding: The fetched
Courseobject, which includes its list ofLessonsthanks to the.Include(5)directive, is bound to the page. The view uses this object to display the course title, description, and the lesson table of contents.
Lesson navigation
Individual lessons are displayed on a dedicated page, routed at /courses/{course-slug}/lessons/{lesson-slug}. This page shows the lesson content and provides navigation to the next lesson in the sequence.
Implementation Details
The LessonsModel (TheExampleApp/Pages/Courses/Lessons.cshtml.cs) governs this functionality. Its OnGet method takes both the course slug and the lessonSlug as parameters.
The logic first fetches the entire parent Course object, including all of its Lesson entries. It then uses LINQ to find the specific Lesson that matches the lessonSlug.
csharp
// From: TheExampleApp/Pages/Courses/Lessons.cshtml.cs
public async Task<IActionResult> OnGet(string slug, string lessonSlug)
{
// 1. Fetch the parent course, which contains all lesson data.
var queryBuilder = QueryBuilder<Course>.New.ContentTypeIs("course").FieldEquals(f => f.Slug, slug?.ToLower()).Include(5).LocaleIs(/* ... */);
Course = (await _client.GetEntries(queryBuilder)).FirstOrDefault();
if (Course == null)
{
// ... return 404 if course not found
}
// 2. Find the specific lesson from the course's lesson list.
SelectedLesson = Course.Lessons.FirstOrDefault(c => c.Slug == lessonSlug?.ToLower());
if (SelectedLesson == null)
{
// ... return 404 if lesson not found within the course
}
// 3. Mark the current lesson as visited.
_visitedLessonsManager.AddVisitedLesson(SelectedLesson.Sys.Id);
// ... breadcrumb and ViewData setup ...
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
A key feature of this page is the "Next Lesson" link. This is enabled by a calculated property on the LessonsModel:
csharp
// From: TheExampleApp/Pages/Courses/Lessons.cshtml.cs
/// <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
2
3
4
5
6
This elegant LINQ expression works by:
- Iterating through the course's ordered list of lessons (
Course.Lessons). SkipWhile(x => x != SelectedLesson): Skips all lessons until it finds the current one.Skip(1): Skips the current lesson itself.FirstOrDefault(): Takes the very next lesson in the list.?.Slug: Returns the slug of that next lesson, ornullif there is no next lesson (i.e., the user is on the last lesson).
The view can then use Model.NextLessonSlug to construct the link to the next lesson, or hide the link if the property is null. For more details on the content of a lesson, see the Lesson Modules documentation.
Visited lessons tracking
The application tracks which courses and lessons a user has viewed to provide visual feedback on the UI (e.g., a "completed" checkmark). This state is persisted across sessions using a browser cookie.
Implementation Details
This functionality is encapsulated in the VisitedLessonsManager class (TheExampleApp/Configuration/VisitedLessons.cs), which is registered for dependency injection as IVisitedLessonsManager.
Mechanism:
- Storage: A client-side cookie named
ContentfulVisitedLessonsstores a semicolon-delimited string of Contentful entry IDs. - Initialization: On each request, the
VisitedLessonsManagerconstructor is called. It reads the cookie viaIHttpContextAccessorand populates aList<string> VisitedLessons. - Tracking: When a user visits a course or lesson page, the respective page model calls the
AddVisitedLessonmethod.
csharp
// From: TheExampleApp/Configuration/VisitedLessons.cs
public class VisitedLessonsManager: IVisitedLessonsManager
{
private readonly string _key = "ContentfulVisitedLessons";
private readonly IHttpContextAccessor _accessor;
public List<string> VisitedLessons { get; private set; }
public VisitedLessonsManager(IHttpContextAccessor accessor)
{
VisitedLessons = new List<string>();
_accessor = accessor;
// Read the cookie on instantiation.
var cookie = _accessor.HttpContext.Request.Cookies[_key];
if (!string.IsNullOrEmpty(cookie))
{
VisitedLessons = new List<string>(cookie.Split(';', StringSplitOptions.RemoveEmptyEntries));
}
}
public void AddVisitedLesson(string lessonId)
{
VisitedLessons.Add(lessonId);
var options = new CookieOptions
{
HttpOnly = true,
Expires = DateTime.Now.AddDays(7)
};
// Write the updated list back to the cookie.
_accessor.HttpContext.Response.Cookies.Append(_key, string.Join(';', VisitedLessons.Distinct()), options);
}
}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
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
- UI Integration: The page models place the
VisitedLessonslist intoViewData. The Razor views can then check if a given course or lesson's ID exists in this list to apply conditional styling.
ViewData["VisitedLessons"] = _visitedLessonsManager.VisitedLessons;
Course metadata
The Course data structure is defined by a C# model that maps directly to the fields of the "Course" content type in Contentful.
Implementation Details
The model is defined in TheExampleApp/Models/Course.cs. It serves as the primary data transfer object for all course-related information.
| Property | Type | Description |
|---|---|---|
Sys | SystemProperties | Standard Contentful metadata, including the unique entry ID (Sys.Id). |
Title | string | The main title of the course. |
Slug | string | The URL-friendly identifier for the course. |
Image | Asset | A linked image from the Contentful media library, used for the course card. |
ShortDescription | string | A brief summary displayed on the course listing page. |
Description | string | The full description of the course, displayed on the detail page. This field likely contains Markdown, which is rendered by the Markdig library. |
Duration | int | The estimated time to complete the course, in minutes. |
SkillLevel | string | The target audience for the course (e.g., "Beginner", "Advanced"). |
Lessons | List<Lesson> | A list of linked Lesson entries, representing the course's curriculum. This is a one-to-many relationship. |
Categories | List<Category> | A list of linked Category entries the course belongs to. This is a many-to-many relationship. |
For a complete definition of all content types, refer to the Content Models documentation.
Data fetching
Data is retrieved from the Contentful Delivery API at runtime using the contentful.aspnetcore SDK. The application employs a consistent and efficient strategy for querying and processing data.
Implementation Details
The core of the data fetching strategy revolves around the IContentfulClient and the QueryBuilder<T> class.
- Dependency Injection: The
IContentfulClientis registered in the service container and injected into the constructors of the Razor Page models. - Type-Safe Queries: The
QueryBuilder<T>provides a fluent, type-safe interface for constructing API requests. This reduces the risk of runtime errors from typos in field names. - Link Resolution: The
.Include(5)method is used in nearly all queries. This is a critical performance optimization. It instructs the Contentful API to resolve and embed linked entries up to 5 levels deep in a single API response. This effectively prevents the "N+1 query problem" where separate API calls would be needed to fetch each lesson for a course. - Localization: The
.LocaleIs()method is used to fetch content for the correct language. The locale is determined from the user's session, with a fallback to the server's current culture.
A typical query combines these elements:
csharp
// A representative query from LessonsModel.cs
var queryBuilder = QueryBuilder<Course>.New
.ContentTypeIs("course") // Filter by content type
.FieldEquals(f => f.Slug, slug?.ToLower()) // Filter by a specific field
.Include(5) // Resolve linked entries (lessons, categories, etc.)
.LocaleIs(HttpContext?.Session?.GetString(Startup.LOCALE_KEY) ?? CultureInfo.CurrentCulture.ToString()); // Specify language
var courses = await _client.GetEntries(queryBuilder);1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
This centralized approach ensures that data fetching is consistent, performant, and maintainable. For a higher-level view of the integration, see the Contentful Integration architecture document.