Table of Contents
Last year I presented a Silvelight application which I called YouGrade. At that time
it had a good reception in the Code Project community, in fact better than I expected it to have, and I thought it would be a good programming exercise to
rewrite it for the Asp.NET MVC platform. Althought one could argue about "which one is the best", the fact is that Silverlight and Asp.NET are two first-class
citizens on the web ecosystem and deserve all my respect. Another reason for me to post this article about Asp.NET is that I haven't found anything similar
(so far, at least), so I hope the Asp.NET community enjoys it.
Besides exploring the potential use of this kind of application, this article also describes the use of various programming techniques, such as the MVC pattern
programming, the jQuery and jQuery-UI javascript frameworks for the client-side development, AJAX techiniques, the basics of YouTube API programming, Entity Framework
for object-relational mapping and querying, AutoMapper for mapping between entities and plain data transfer object classes.
To use YouGrade application provided with this article, if you already have Visual Studio 2010 with Asp.NET MVC 3.0, that's fine. If you don't, you can download the following 100% free development tool directly from Microsoft:
The goal of this article is to provide a muiti-purpose suite for multimedia online exams. It is "multimedia" because it allows you to use YouTube videos as
resource material for the questions. In short, the person who applies to the test can watch/listen to the YouTube video, read the question and answer it accordingly.
This article shows how to take advantage of some neat qualities of YouTube: you can create your own videos for your own tests, and upload them for free to YouTube and
have them working for your tests. Or you can use already existent YouTube videos in your exams. It's up to you to decide, based upon your needs. As readers will see
later on the article, YouTube exposes an API for JavaScript that turns YouTube into a fully-programmable tool.
In my opinion, MVC is the best thing that happened to Asp.NET development in the last years. I find it beautiful and elegant pattern. I never liked the
Asp.NET WebForms that much. And even so, I'm happy that Asp.NET MVC is by no means a replacement for WebForms: instead, it's just another way of doing things.
And its adherence to the principle of separation of concerns indeed forces the experienced Asp.NET WebForms developer to think things in a different ways, but it
doesn't mean things must be harder for the developer.
This means that there are no more codebehind classes on the view side to perform business logic. No more viewstate and no more postbacks to handle.
As soon as you install the Asp.NET MVC project template with Visual Studio, you notice that there are some folders created especifically for the MVC development:
There's the Models folder, then the Views Folder and then the Controllers folder. This is the convention for MVC projects, and it means "it's a good idea to
put your views on the Views folder, models on the Models folder and controllers on the Controllers folder". Of course, you can move your controllers to another
folder, or even to another assembly (this last option is very usual). But these are conventions, so if you're not planning on splitting your code among several
assemblies, so it's a good idea to stick to the conventions. This approach is called "convention over configuration" and applies to many modern frameworks
(such as MonoRail, Asp.NET MVC and Ruby on Rails) and frees the developer from the necessity of setting up several configuration files. That is, Asp.NET MVC
is a flexible framework, and yet you only need to configure it for your needs when your needs are unconventional for the Asp.NET MVC framework. For example: if you
want a new controller, just create a controller class and make it inherit from the Controller
class, and you're good to go. But if you want to keep
your controller classes on different assemblies then that you must configure by yourself, so that the MVC framework be able to locate the controllers.
The data model for the YouGrade application is persisted in a local database using the object-relational mapper provided by Entity Framework. The following
diagram shows the entities and their relationships:
The entities in the model were create to represent the minimum data structure required for running the exams:
- The User represents the person that takes the test.
- The ExamDef represents the Exam Definition, there is, a exam template to which many users can apply.
- The QuestionDef is the entity that holds each question in the exam.
- The Alternative describes each of the valid alternatives and one or more correct alternatives.
- The ExamTake represents each attempt of the user in applying to an exam.
- The Answer represents the user's answer to each alternative in each question.
The View
part of the MVC is indeed very simple: there's only one view, which is responsible for displaying the exam title,
the current question number, the current question text, the current associated video, the given alternatives. Also, it provides the user with
interface controls, such as the question navigation buttons, and the checkboxes/radiobuttons for the question valid alternatives.
The HomeController
is the counterpart of the Home
Views, and provides all functionalities needed for the interaction
between the client-side and server-side operations.
In order to interact with the View
, the HomeController
must expose a set of actions. These actions are called by the browser or by the
View
itself (via AJAX calls) for navigating through the questions or saving the user's answers:
- The Index action is called right away when the application is started, and tells the Asp.NET MVC framework to render the
Index
with data from the exam.
- The GetQuestion action is called by the
Index
view (via AJAX call) so that the question, the video and the alternatives can be rendered on the screen.
- The SaveAnswer is a POST action that receives the question ID and the user's answers for that question. These answers are then saved in an in-memory object that holds temporarily the user's answers before being persisted to the database.
- The MoveToPreviousQuestion action is called by the
Index
view (via AJAX call) to navigate to the previous question, and it also returns that question data back to the view.
- The MoveToNextQuestion action is called by the
Index
view (via AJAX call) to navigate to the next question, and it also returns that question data back to the view.
- The EndExam action is called by the
Index
view (via AJAX call) to calculate the user results (from a correct percentage range varying from 0 to 100 points).
I dare to say that, after the introduction of jQuery, programming JavaScript turned to be a real joy. I used jQuery whenever possible, and it gave me a
compact, yet readable, set of instructions:
This is how we handle the window load event with jQuery:
$(window).load(function () {
...
In order to deal with the hover events on view buttons, we handle the hover jQuery event like this:
$('.button').hover(
function () {
$(this).removeClass('ui-state-default');
$(this).addClass('ui-state-hover');
},
function () {
$(this).addClass('ui-state-default');
$(this).removeClass('ui-state-hover');
});
The AJAX calls are done in a clear and simple manner: here we provide the url to the
GetQuestion
action on the HomeController
, the type (GET), the
return type (json - javascript simple object notation) and the event handlers for both
the error and success events. In case of success, the question is retrieved by the JSON object
and rendered by the view.
$.ajax({
url: '/Home/GetQuestion',
type: 'GET',
cache: false,
dataType: 'json',
error: function (jqXHR, textStatus, errorThrown) {
alert(errorThrown);
},
success: function (json) {
question = json;
renderQuestion(json);
}
});
...
And here is how I manage to iterate through the alternatives. Notice the smart $.each($('.alternatives > input') jQuery syntax, that
allows iteration over each input
element inside the element of the alternative
class in a clean and readable way:
$.each($('.alternatives > input'), function (key, value) {
if ($(this).attr('value') == 'on') {
answers = answers + String.fromCharCode(65 + key);
}
});
...
The following code snippet shows how to render html code inside divs
pertaining to the "questionTitle" and "questionText" classes:
$('.questionTitle').html('Question ' + q.Id);
$('.questionText').html(q.Text);
...
I think the YouTube API is the icing in the cake of this application.
Today, there are plenty of websites and blogs that make use of embedded YouTube videos. This application is not different.
But something is really different here: instead of just embedding, we also use a set of instructions that control the embedded video. This is possible thanks to
the YouTube API.
Some simple steps are necessary to make this happen. First, we create a div
element to embed our video:
<div id="videoDiv" style="z-index: -1;">
You need Flash player 8+ and JavaScript enabled to view this video.
</div>
Then we use the "swfobject.embedSWF" method passing some parameters, such as the name of the div which holds the video, the
video size, and the window mode:
var question;
var videoID = "iapcKVn7DdY"
http: var params = { allowScriptAccess: "always", wmode: "transparent" };
var atts = { id: "ytPlayer" };
swfobject.embedSWF("http://www.youtube.com/v/" +
videoID + "&enablejsapi=1&playerapiid=ytPlayer&wmode=opaque",
"videoDiv", "480", "295", "8", null, null, params, atts);
var ytplayer;
Below is the full syntax:
swfobject.embedSWF(swfUrlStr, replaceElemIdStr, widthStr, heightStr, swfVersionStr, xiSwfUrlStr, flashvarsObj, parObj, attObj)
- swfUrlStr - This is the URL of the SWF. Note that we have appended the enablejsapi and playerapiid parameters to the normal YouTube SWF URL to enable JavaScript API calls.
- replaceElemIdStr - This is the HTML DIV id to replace with the embed content. In the example above, it is ytapiplayer.
- widthStr - Width of the player.
- heightStr - Height of the player.
- swfVersionStr - The minimum required version for the user to see the content. In this case, version 8 or above is needed. If the user does not have 8 or above, they will see the default line of text in the HTML DIV.
- xiSwfUrlStr - (Optional) Specifies the URL of your express install SWF. Not used in this example.
- flashVarsObj - (Optional) Specifies your FlashVars in name:value pairs. Not used in this example.
- parObj - (Optional) The parameters for the embed object. In this case, we've set allowScriptAccess.
- AttObj - (Optional) The attributes for the embed object. In this case, we've set the id to myytplayer.
Once the video is embedded and the player is ready, the YouTube API will call the onYouTubePlayerReady
function and give you control of the video.
So, you must have this code if you want to make use of the API:
function onYouTubePlayerReady(playerId) {
ytplayer = document.getElementById(playerId);
getQuestion();
}
The following code was extracted from the renderQuestion
function, and shows 3 functions from YouTube API: stopVideo
, loadVideoById
and playVideo
. Notice that the ytplayer is the object we instantiated in the above function. The loadVideo function receives a video id as the first parameter
and the position (in seconds) in which the video must start. This is particularly useful when you have a long video and you want the user to start watching it at
some specific point:
function renderQuestion(q) {
question = q;
ytplayer.stopVideo();
ytplayer.loadVideoById(q.Url, q.StartSeconds);
ytplayer.playVideo();
...
YouTube video links are cool, and I think it would be a nice enhancement for this project. The idea is to find any mm:ss matches inside the
question text and replace these time markers with time links so that the user can "jump" to that specific time in the video.
First, we must create the question text with the time markers in our database. We should be able to create as many time markers as we want. It's up to the application to
deal with them all. Notice that we want to keep the question text in our database clean and readable. So, we don't put HTML tags in it:
Second, we handle the question text on the server side, so that the timer markers be replaced with the proper html tags. The time link tag should look like:
<a href="#" onclick="ytplayer.seekTo( 60 * mm * 0 + ss);return false;">mm:ss</a>
Where:
- ytplayer is our player object name.
- seekTo is the built-in YouTube API method. This method makes the player jump to the specified second. Notice that we use 60 * mm * 0 + ss as
the formula for calculating the total seconds.
- mm:ss is the time marker we are trying to replace by the html link.
In order to convert the ordinary text into HTML links, we add a few lines of code to our GetQuestion
on the server side,
using the Regular Expression "(\d|\d\d):(\d{2})" (that finds the mm:ss pattern) to replace the question text accordingly:
public QuestionDefDto GetQuestion()
{
...
... some code here
...
var regexTime = new Regex(@"(\d|\d\d):(\d{2})");
string newQuestionText = regexTime.Replace(questionDefDto.Text,
new MatchEvaluator(
(target) =>
{
var timeSplit = target.ToString().Split(':');
return string.Format("<a href="\"#\"" önclick="\"ytplayer.seekTo(60*{0}+{1});return">{0}:{1}</a>",
timeSplit[0], timeSplit[1]);
}
));
questionDefDto.Text = newQuestionText;
return questionDefDto;
}
If we didn't do any mistake, the above code should be enough. Now we run the application and find out if the links
are working:
Okay, now that both links are working, we inspect our browser elements and see the html code that has been generated
by our regular expression replacement:
There are instances when the question requires more than one answer. In such cases, you can use multi-select questions:
Multi-select question is a question where the IsMultiSelect
attribute is set to true.
Unlike single-select questions, where the alternatives are radio buttons
, the alternatives for multi-select questions are rendered as check boxes
on the browser side by this javascript code:
for (var i = 0; i < q.Alternatives.length; i++) {
var checked = q.Alternatives[i].IsChecked
'? 'checked="true"' : '';
var type = q.IsMultiSelect ? 'checkbox' : 'radio';
$('.alternatives').append('<input id="alt' + q.Alternatives[i].Id +
'" name="alternatives" type="' + type + '" ' + checked + ' />' +
q.Alternatives[i].Id + '. ' + q.Alternatives[i].Text + '<br />');
}
When the user finishes the exam, he/she must end it in order to see the results.
Along with the results, the user also receives the minimum results for the exam, so that both can be compared. This is done by a pair of progress bars
, provided
by the jQuery-ui, a jQuery plugin.
Once again, jQuery is our friend, and we're lucky it comes with a beautiful syntax for ajax commands.
function endExam() {
ytplayer.pauseVideo();
saveAnswer(function () {
$('#endExamDialog').dialog('open');
$.ajax({
url: '/Home/EndExam',
type: 'GET',
cache: false,
dataType: 'json',
data: ({}),
error: function (jqXHR, textStatus, errorThrown) {
alert(errorThrown);
},
success: function (json) {
$("#progressbarYourResults").progressbar({
value: json.result
});
$('#yourResult').html(json.result);
$("#progressbarMinimum").progressbar({
value: json.minimum
});
$('#minimum').html(json.minimum);
}
});
});
}
Notice that the above code shows that the ajax command is called only after the saveAnswer
is terminated.
This is so because the saveAnswer
itself also makes another ajax calls. Since ajax are asynchronous invocations
to the server, we must for the results of saveAnswer
call before requesting the results. Otherwise we
might get inconsistent results.
The progress bars are created by the results provided by the EndExam
action:
[HttpGet]
public ActionResult EndExam()
{
var result = ExamManager.Instance.EndExam();
var examDefDto = ExamManager.Instance.GetExam();
return Json(
new {
success = true,
result = result,
minimum = (int)((100 * examDefDto.MinimumOfCorrectAnswers) / examDefDto.Questions.Count())
},
JsonRequestBehavior.AllowGet);
}
The ADO.NET Entity Framework plays a big role in our application. This object-relation mapping (ORM) framework abstracts the relational data residing in
our YouGrade.mdf local database and presents the conceptual schema to the application.
By generating the conceptual schema from the local database, we now have a set of entities that map to the corresponding tables. Any change made to the
database schema can be updated in the conceptual schema through the help of a wizard. Likewise, changes to the conceptual entities can be also propagated
to the underlying database tables. This allows a fast development and works great for our YouGrade application.
As you can see below, there are only 2 methods in the YouGradeService
class that uses the Entity Framework entities. Simply put: the first one
retrieves the exam data from the database. The other one saves the user's answers back to the database.
The GetExamDef
method on YouGradeService
class retrieves the entity containing the Exam Definition. In addition,
all related questions and alternatives are also included in the result via the "Include" method. Without this metod, the return entity would
contain only the data pertaining to the exam itself (such as Id, name and Description).
public ExamDef GetExamDef()
{
using (YouGradeEntities1 ctx = new YouGradeEntities1())
{
return ctx.ExamDef.Include("QuestionDef.Alternative").First();
}
}
The SaveExamTake
method receives an ExamTakeDto
parameter and persist its data to the database.
At first this method looks a bit complicated, but it simply saves the exam take data, and then the answers provided by the user.
public double SaveExamTake(ExamTakeDto examTakeTO)
{
double grade = 0;
try
{
using (YouGradeEntities1 ctx = new YouGradeEntities1())
{
var user = ctx.User.Where(e => (e.Id == examTakeTO.UserId)).First();
ExamDef examDef = ctx.ExamDef.Where(e => e.Id == examTakeTO.ExamId).First();
ExamTake newExamTake = ExamTake.CreateExamTake
(
0,
examDef.Id,
examTakeTO.UserId,
examTakeTO.StartDateTime,
examTakeTO.Duration,
examTakeTO.Grade,
examTakeTO.Status.ToString()
);
newExamTake.User = user;
newExamTake.ExamDef = examDef;
ctx.AddToExamTake(newExamTake);
ctx.SaveChanges();
foreach (AnswerDto a in examTakeTO.Answers)
{
ExamTake examTake = ctx.ExamTake
.Where(e => e.Id == newExamTake.Id).First();
Alternative alternative = ctx.Alternative.Where
(e => e.QuestionId ==
a.QuestionId).Where(e => e.Id == a.AlternativeId).First();
Answer newAnswer = Answer
.CreateAnswer(newExamTake.Id, a.QuestionId, a.AlternativeId, a.IsChecked);
newAnswer.ExamTake = examTake;
newAnswer.Alternative = alternative;
ctx.AddToAnswer(newAnswer);
}
ctx.SaveChanges();
foreach (QuestionDef q in ctx.QuestionDef)
{
var query = from qd in ctx.QuestionDef
join a in ctx.Answer on qd.Id equals a.QuestionId
join alt in ctx.Alternative on new
{ qId = a.QuestionId, aId = a.AlternativeId }
equals new { qId = alt.QuestionId, aId = alt.Id }
where qd.Id == q.Id
where a.ExamTakeId == newExamTake.Id
select new { alt.Correct, a.IsChecked };
bool correct = true;
foreach (var v in query)
{
if (v.Correct != v.IsChecked)
{
correct = false;
break;
}
}
grade += correct ? 1 : 0;
}
int examTakeId = examTakeTO.Id;
}
using (YouGradeEntities1 ctx = new YouGradeEntities1())
{
ExamTake et = ctx.ExamTake.First();
string s = et.Status;
}
return grade;
}
catch (Exception exc)
{
string s = exc.ToString();
throw;
}
}
It would be very nice if we could serialize the entities generated by Entity Framework directly and use it directly in the view, as json objects.
Unfortunately, this is not so simple. If you try to serialize this question definition entity, you get an error indicating that there is a circular reference
in the question definition object. Why this happens? In our case, the entities have "navigation properties", which means, for example, that for a given
question definition, there are a property that points to a list of child alternatives. And for each alternative, there is also a navigation property
that points back to the parent quetion. In order to solve this problem, I create a corresponding Data Transfer Object (DTO) class for each entity,
so that I can "transfer" data from the entity objects to these POCO (Plain Old CLR Objects) and then serialize them to the view.
But how do we do this mapping? Creating new instances of DTO objects and transferring data to them can be a cumbersome task. Instead, we could use
some automated mapping techinique. In this project I used AutoMapper, which proved to be friendly and powerful.
Here goes the description of AutoMapper:
AutoMapper uses a fluent configuration API to define an object-object mapping strategy. AutoMapper uses a convention-based matching algorithm to
match up source to destination values. Currently, AutoMapper is geared towards model projection scenarios to flatten complex object models to DTOs and
other simple objects, whose design is better suited for serialization, communication, messaging, or simply an anti-corruption layer between the domain
and application layer.
Before we start mapping, we have to configure AutoMapper first. We do this by adding some code to the Global.asax.cs
class:
protected void Application_Start()
{
...
Mapper.CreateMap>ExamDef, ExamDefDto>()
.ForMember(e => e.Questions, options => options.MapFrom(e => e.QuestionDef));
Mapper.CreateMap<QuestionDef, QuestionDefDto>()
.ForMember(e => e.Alternatives, options => options.MapFrom(e => e.Alternative));
Mapper.CreateMap<Alternative, AlternativeDto>();
Mapper.CreateMap<ExamTake, ExamTakeDto>();
Mapper.CreateMap<Answer, AnswerDto>();
...
}
The above instructions tell the AutoMapper which entity classes map to each DTO classes. And the code below shows how to actually map from one object to another:
var examDefDto = new ExamDefDto();
Mapper.Map(examDef, examDefDto);
return examDefDto;
That's it! I hope you have enjoyed the article as much as I have. Please comment below, and any suggestions, complaints and ideas will be welcome.
- 2011-05-30: Initial version.
- 2011-06-01: YouTube Time links added.
- 2011-06-05: Multi-select questions added.
- 2011-06-06: Exam results added.