The example site is basically a container for content targeted to beer, food, and pub enthusiasts. Content can be in the form of news, articles, reports of special events, reviews, photo galleries, and so forth. This chapter describes the typical content-related problems that should be considered for a site of this type. You'll then design and develop an online article manager that allows the complete management of the site's content, in terms of acquiring articles; adding, activating, and removing articles; sharing articles with other parties, and so on.
Different sites use different methods of gathering news and information: some site administrators hunt for news events and write their own articles, while others get news and articles directly from their users (a great example of this is the Add Your News link at www.aspwire.com
) or they rely upon a company whose business is to gather and organize news to be sold to third-party sites. In the old days, some sites did screen-scraping, retrieving data from an external site's page and showing it on their pages with a custom appearance (of course, you must have the authorization from the external company and you must know the format they use to show the news on their pages). During the last few years, we've seen an explosion in the use of RSS (Really Simple Syndication), a simple XML format for syndicating content, making it available to other clients. Atom is another XML-based syndication standard that was created to solve some problems of RSS — it is relatively new but already very popular. The basic idea with RSS and ATOM is for sites to provide an index of news items in the form of an XML document. A client program can fetch that XML document and provide users with a list of news items and hyperlinks that can direct them to the individual stories they are interested in. One site's XML index document is called a newsfeed. The client program is called a news aggregator (or feed reader) because it can extract newsfeeds from many sites and present them in one list, possibly arranged by categories. Users can subscribe to the XML feed and their aggregator program can periodically poll for new stories by fetching new XML documents automatically in the background. Because RSS and Atom are open standards, there are many web-based and fat-client desktop applications that can subscribe to any site that provides such feeds. Some popular open-source feed readers written in C# are RSS Bandit (www.rssbandit.org
) and SharpReader (www.sharpreader.com
). RSS and Atom are very convenient for users who want to keep up on the latest news and articles. You can advertise your new content via RSS and ATOM feeds, or you can even display a list of content from other sites by showing RSS links on one of your web pages. Your page can have an aggregator user control that makes it simple to display the content of specified RSS and ATOM feeds. This adds to any unique content you provide, and users will find value in returning to your site frequently to see your own updated content as well as a list of interesting links to updated news items on other sites.
It doesn't matter which methods you decide to use, but you must have fresh and updated content as often as possible for your site to be successful and entice users to return. Users will not return regularly to a site if they rarely find new content. You should use a variety of methods to acquire new content. You can't rely entirely on external content (retrieved as an RSS feed, by screen-scraping, or by inserting some JavaScript) because these methods often imply that you just publish a small extract of the external content on your site, and publish a link to the full article, thus driving traffic away from your site. It can be a solution for daily news about weather, stock exchanges, and the like, but not for providing original content, which is why users surf the web. You must create and publish some content on your own, and possibly syndicate that content as RSS feeds, so that other sites can consume it, and bring new visitors to your site.
Once you have a source of articles, a second problem arises: how do you add them to your site? You can immediately rule out manually updating pages or adding new static HTML pages — if you have to add news several times a day, or even just every week, creating and uploading pages and editing all the links becomes an administrative nightmare. Additionally, the people who administer the site on a daily basis may not have the skills required to edit or create new HTML pages. You need a much more flexible system, one that allows the site administrators to easily publish fresh content without requiring special HTML code generation tools or knowledge of HTML. You want it to have many features, such as the capability to organize articles in categories and show abstracts, and even to allow some site users to post their own news items. You'll see the complete list of features you're going to implement in the "Design" section of this chapter. For now, suffice it to say that you must be able to manage the content of your site remotely over the web, without requiring any other tools. Think about what this implies: you can add or edit news as soon as it is available, in a few minutes, even if you're not in your office and even if you don't have access to your own computer; all you need is a connection to the Internet and a browser. And this can work the same way for your news contributors and partners. They won't have to e-mail the news to you and then wait for you to publish it. They can submit and publish content without your intervention (although in our case we will give administrators and editors the option to approve or edit the content before publication).
The last problem is the implementation of security. We want to give full control to one or more administrators and editors, allow a specific group of users (contributors) to submit news, and allow normal users to just read the news. You could even prevent them from reading the content if they have not registered with the site.
Once new articles or news is made available, you need to promote the content. In today's Web 2.0 world, this involves pushing content to social media sites such as Twitter, Facebook, and many other locations. The other side of Web 2.0 design is allowing visitors to comment on the content. The Beer House already allows comments, but the user interface needs a slight facelift.
To summarize the problem, you need the following:
An online tool for managing news content that allows specific users to add, update, and delete articles without knowing HTML or other publishing software.
A common infrastructure to post announcements to Twitter. This will provide a way to get news to patrons through the web and SMS (Short Message Service). Twitter also allows the Beer House to reach a growing Web 2.0 crowd of very interactive customers.
A common infrastructure to leverage Gravatar.com
to create user avatars used to visually identify them on the site when they post a comment to an article.
A method of allowing other sites to use your content so that they publish an extract and link to your site for the entire articles, thus bringing more traffic.
A system that allows various users different levels of access to the site's content.
This section introduces the design of the solution and an online tool for acquiring, managing, and sharing the content of our site. Specifically, we will do the following:
Provide a full list of the features we want to implement.
Design the database tables for this module.
Create a list and a description of the stored procedures needed to provide access to the database.
Design the object models of the data and business layers.
Describe the user interface services needed for content management, such as the site pages and reusable user controls.
Explain how we will ensure security for the administration section and for other access-restricted pages.
Let's start our discussion by writing down a partial list of the features that the article manager module should provide to be flexible and powerful, but still easy to use:
An article can be added to the database at any time, with an option to delay publication until a specified release date. Additionally, the person submitting the article must be able to specify an expiration date, after which the article will be automatically retired. If these dates are not specified, then the article should be immediately published and remain active indefinitely.
Articles can have an approved status. If an administrator or editor submits the article, it should be approved immediately. If you allow other people, such as staff or users of the site (we will call them contributors), to post their own news and articles, then this content should be added to the database in a "pending" state. The site administrators or editors will then be able to control this content, apply any required modifications, and finally approve the articles for publishing once they are ready.
The system must also track who originally submitted an article or news item. This is important because it provides information regarding whether a contributor is active, who is responsible for incorrect content, who to contact for further details if the article is particularly interesting, and so on.
The administrator/editor must be able to decide whether an article can be read by all readers or only by registered users.
There can be multiple categories, enabling articles to be organized in different virtual folders. Each category should have a description and an image that graphically represents it.
There should be a page with the available categories as a menu. Each category should be linked to a page that shows a short abstract for each published article. Clicking on the article's title should allow the user to read the whole text.
Articles can be targeted to users from a specified location, for example, country, state/province, or city. Consider the case where you might have stories about concerts, parties, and special events that will happen in a particular location. In Chapter 4, you implemented a registration and profiling system that includes the user's address. That will be used here to highlight events that are going to happen close to the user's location. This is a feature that can entice readers to provide that personal information, which you could use later for marketing purposes (ads can be geographically targeted also).
Users can leave comments or ask questions about articles, and this feedback should be published at the end of the article itself, so that other readers can read it and create discussions around it (this greatly helps to increase traffic). You might recognize this approach as being common to blogs, which are web logs in which an individual publishes personal thoughts and opinions and other people add comments. As another form of feedback, users can rate articles to express how much they liked them.
Each comment needs to be screened for spam. Letting comment spam infiltrate the site can quickly diminish the desirability of the site to users. It could also degrade the quality of the site in the eyes of search engines, meaning fewer potential customers. Automatic checking and manual approval must be implemented.
The module must count how many times an article is read. This information will also be shown to the reader, together with the abstract, the author name, the publication date, and other information. But it will be most important for the editors/administrators because it greatly helps them understand which topics the readers find most interesting, enabling administrators to direct energy, money, and time to adding new content on those topics.
The new content must be available as an RSS feed to which a reader can subscribe to read through his or her favorite RSS aggregator.
Above all, the article manager and the viewer must be integrated with the existing site. In our case, this means that the pages must tie in with the current layout and that we must take advantage of the current authentication/authorization system to protect each section and to identify the author of the submitted content.
It's essential to have this list of features when designing the database tables, as we now know what information we need to store, and the information that we should retrieve from existing tables and modules (such as the user account data).
As described in Chapter 3 (where we looked at building the foundations for our site), we're going to use the tbh_
prefix for all our tables, so that we avoid the risk of naming a table such that it clashes with another table used by another part of the site (this may well be the case when you have multiple applications on the site that store their data on the same shared DB). We need three tables for this module: one for the categories, another one for the articles, and the last one for the user feedback. The diagram shown in Figure 5-1 illustrates how they are linked to each other.
Let's start by looking at these tables and their relationship in more detail.
Unsurprisingly, the tbh_Categories
table stores some information about the article categories:
Column Name | Type | Size | Allow Null | Description |
---|---|---|---|---|
CategoryID | int - PK | 4 | No | Unique ID for the category. |
AddedDate | datetime | 8 | No | Category creation date/time. |
AddedBy | nvarchar | 256 | No | Name of the user who created the category. |
Title | nvarchar | 256 | No | Category's title. |
Importance | int | 4 | No | Category's importance. Used to sort the categories with a custom order, other than by name or by date. |
Description | 4000 | Yes | Category's description. | |
ImageUrl | nvarchar | 256 | Yes | URL of an image that represents the category graphically. |
Active | Bit | 1 | No | Indicates if the record has been deleted or is active. |
datetime | 8 | No | Category last update date/time. | |
nvarchar | 256 | No | Name of the user who last updated the category. |
This system supports a single-level category, meaning that we cannot have subcategories. This is plenty for small-to-midsized sites that don't have huge numbers of new articles on a wide variety of topics. Having too many categories in sites of this size can even hinder the user's experience, because it makes it more difficult to locate desired content. Enhancing the system to support subcategories is left as an exercise if you really need it, but as a suggestion, the DB would only require an additional ParentCategoryID
column containing the ID of the parent category.
AddedDate, AddedBy, UpdatedDate
, and UpdatedBy
are four columns that you will find in all our tables — they record when a category/article/comment/product/message/newsletter was created and last updated, and by whom, to provide an audit trail. Of course, this will only provide information about when the record was created and last modified. For a more robust auditing system, you might look at adding a general table to log all changes to the database, but again this is a bit more than the Beer Houses needs at this point. You may have thought that, instead of having an nvarchar
column for storing the username, we could use an integer
column that would contain a foreign key pointing to records of the aspnet_Users
table introduced in Chapter 4. However, that would be a bad choice for a couple of reasons:
The membership data may be stored in a separate database, and possibly on a different server.
The membership module might use a provider other than the default one that targets SQL Server. In some cases, the user account data will be stored in Active Directory or maybe an Oracle database, and thus there would be no SQL Server table to link to.
Another column that has been added to all the tables is Active
. This is a bit value that is either true (1) or false (0) to indicate if the record has been deleted or not. One of the rules I was taught early in my development career is to retain all records and to set flags to indicate the state each record is in. Active allows records to be retained in the database, yet be considered deleted by the application. This is also pretty handy when a record was accidentally deleted by a user and needs to be restored. Simply flipping the bit back to true saves you from having to restore the record or, even worse, the entire database.
The tbh_Articles
table contains the content and all further information for all the articles in all categories. It is structured as follows.
Column Name | Type | Size | Allow Null | Description |
---|---|---|---|---|
ArticleID | int – PK | 4 | No | Unique ID for the article. |
AddedDate | Datetime | 8 | No | Date/time the article was added. |
AddedBy | Nvarchar | 256 | No | Name of the user who created the article. |
CategoryID | int – FK | 4 | No | ID of the category to which the news item belongs. |
Title | Nvarchar | 256 | No | Article's title. |
Abstract | Nvarchar | 4000 | Yes | Article's abstract (short summary) to be shown in the page that lists the article, and in the RSS feed. |
Body | Ntext | No | Article's content (full version). | |
Country | Nvarchar | 256 | Yes | Country to which the article (concert/event) refers. |
State | nvarchar | 256 | Yes | State/province to which the article refers. |
City | nvarchar | 256 | Yes | City to which the article refers. |
ReleaseDate | datetime | 8 | Yes | Date/time the article will be publicly readable. |
ExpireDate | datetime | 8 | Yes | Date/time the article will be retired and no longer readable by the public. |
Approved | bit | 1 | No | Approved status of the article. If false, an administrator/editor has to approve the article before it is actually published and available to readers. |
Listed | bit | 1 | No | Whether the article is listed in the articles page (indexed). If false, the article will not be listed, but will be still accessible if the user types the right URL, or if there is a direct link to it. |
CommentsEnabled | bit | 1 | No | Whether the user can leave public comments on the article. |
OnlyForMembers | bit | 1 | No | Whether the article is available to registered and authenticated users only or to everyone. |
int | 4 | No | Number of times the article has been viewed. | |
Votes | int | 4 | No | Number of votes the article has received. |
TotalRating | int | 4 | No | Total rating score the article has received. This is the sum of all the ratings posted by users. |
Active | Bit | 1 | No | Indicates if the record has been deleted. |
UpdateDate | Datetime | 8 | No | Datetime when the last update to the record was made. |
UpdatedBy | nvarchar | 256 | No | Who made the last update to the record. |
The ReleaseDate
and ExpireDate
columns are useful because the site's staff can prepare content in advance and postpone its publication, and then let the site update itself at the specified date/time. In addition to the obvious benefit of spreading out the workload, this is also great during vacation periods, when the staff would not be in the office to write new articles but you still want the site to publish fresh content regularly.
The Listed
column is also very important, because it enables you to add articles that will be hidden from the main article list page, and from the RSS feeds. Why would you want to do this? Suppose that you have a category called Photo Galleries (we'll actually create it later in the chapter) in which you publish the photos of a past event or meeting. In such photo gallery articles, you would insert thumbnails of the photos with links to their full-sized version. It would be nice if the reader could comment and rate each and every photo, not just the article listing them all, right? You can do that if instead of linking the big photo directly you link a secondary article that includes the photo. However, if you have many photos, and thus many short articles that contain each of them, you certainly don't want to fill the category's article listing with a myriad of links to the single photos. Instead, you will want to list only the parent gallery. To do this, you set the Listed
property of all the photo articles to false
, and leave it set to true
only on the article with the thumbnails.
The Country, State
, and City
fields enable you to specify an accurate location for those articles that refer to an event (such as parties, concerts, beer contests, etc.). You may recall that we created the same properties in Chapter 2 for the user's profile. If the location for the article matches a specific user's location, even partially, then you could highlight the article with a particular color when it's listed on the web page. You may be wondering why it was necessary to define the Country
and State
fields as varchar
fields, instead of an int
foreign key pointing to corresponding records of the tbh_Countries
and tbh_States
lookup tables. The answer is that I want to use the City
field to support not only U.S. states, but states and provinces for any other country, so I defined this as free text field. It's also good for performance if we denormalize these fields. Using a lookup table is particularly useful when there is the possibility that some values may change; storing the information in one location minimizes the effort to update the data and makes it easier to ensure that we don't get out of sync. However, realistically, the list of countries will not change, so this isn't much of a problem. In the remote case that this might happen, you will simply execute a manual update for all those records that have Country="USA"
instead of "United States"
, for example. This design decision can greatly improve the performance of the application.
Be aware that IP address databases are available that can be used to determine the user's location as close as his ZIP Code in the United States. The targeting detail varies among these databases, but even one that gets the general area correct can be very useful for most sites to target the user's content.
You may be wondering why I decided to put the Votes
and TotalRating
columns into this table, instead of using a separate table to store all the single votes for all articles. That alternative has its advantages, surely: you could track the name and IP address of the user who submits the vote, and produce interesting statistics such as the number of votes for every level of rating (from one to five stars). However, retrieving the total number of votes, the total rating, and the number of votes for each rating level would require several SUM
operations, in addition to the SELECT
to the tbh_Articles
table. I don't think the additional features are worth the additional processing time and traffic over the network, and thus I opted for this much lighter solution instead.
The tbh_Comments
table contains the feedback (comments, questions, answers, etc.) for the published articles. The structure is very simple:
Column Name | Type | Size | Allow Null | Description |
---|---|---|---|---|
CommentID | int - PK | 4 | No | Unique ID for the comment. |
AddedDate | datetime | 8 | No | Date/time the comment was added. |
AddedBy | nvarchar | 256 | No | Name of the user who wrote the comment. |
AddedByEmail | nvarchar | 256 | No | User's e-mail address. |
AddedByIP | nchar | 15 | No | User's IP address. |
ArticleID | int | 4 | No | Article to which the comment refers. |
Body | ntext | No | Text of the comment. | |
Active | Bit | 1 | No | Indicates if the record has been deleted. |
UpdatedDate | Datetime | 8 | No | When the record was last updated. |
UpdatedBy | nvarchar | 256 | No | The username of the user who last updated the record. |
We will track the name of the user posting the comment, but she could even be an anonymous user, so this value will not necessarily be one of the registered usernames. We also store the user's e-mail address, so that the reader can be contacted with a private answer to her questions. Storing the IP address might be legally necessary in some cases, especially when you allow anonymous users to post content on a public site. In case of offensive or illegal content, it may be possible to geographically locate the user if you know her IP address and the time when the content was posted. In simpler cases, you may just block posts from that IP (not a useful option if it were a dynamically assigned IP, though).
Chapter 3 walked through creating an Entity Data Model using the Entity Framework Wizard. The same process holds for the articles model, but uses the tbh_Articles, tbh_Categories
and tbh_Comments
tables.
But, just as you saw with the SiteMap
model, the Articles model needs to be customized. The thb_Articles
entity needs to have the Entity Set Name set to Articles
and the Name set to Article
. The navigational properties need to be changed to Category
and Comments
, respectively. For the thb_Categories
entity, change the Entity Set Name to Categories
and the Name to Category
. Rename the tbh_Articles
navigational property to Article
. Similary rename the tbh_Comments
entity appropriately, so the final result looks like Figure 5-2.
Once the model is generated, the classes in the model need to be placed in the TheBeerHouse.BLL.Articles
namespace. This requires editing the generated code file by adding the namespace wrapper around classes. This is done below the Assembly definitions at the top of the file, which means an Imports TheBeerHouse.BLL.Articles
directive at the top of the page. That's because the Assembly definitions reference the classes, now wrapped in the Articles
namespace. Each of these entities is extended in corresponding partial classes defined in the class library.
One property exposed by the Comment entity is EncodedBody
, which returns the same text returned by the Body
property, but first performs HTML encoding on it. This protects us against the so-called script-injection and cross-site scripting attacks. As a very simple example, consider a page on which you allow users to anonymously post a comment. If you don't validate the input, they may write something like the following:
<script>document.location = 'http://www.usersite.com';</script>
This text is sent to the server, and you save it into the DB. Later, when you have to show the comments, you would retrieve the original comment text and send to the browser as is. However, when you output the preceding text, it won't be considered as text by the browser, but rather as a JavaScript routine that redirects the user to another website, hijacking the user away from your website! And this was just a basic attack — more complex scripts could be used to steal users' cookies, which could include authentication tickets and personal data, with potentially grave consequences. For our protection, ASP.NET automatically validates the user input sent to the server during a postback, and checks whether it matches a pattern of suspicious text. If so, it raises an exception and shows an error page. You should consider the case where a legitimate user tries to insert some simple HTML just to format the text, or maybe hasn't really typed HTML but only a < character. In that case, you don't want to show an error page; you only need to ensure that the HTML code isn't displayed in a browser (because you don't want users to put links or images on your site, or text with a font so big that it creates a mess with your layout). To make sure it isn't displayed, you can disable ASP.NET's input validation (only for those pages on which the user is actually expected to insert text, not for all pages!), and save the text into the DB, but only show it on the page after HTML encoding, as follows:
<script> document.location = 'http://www.usersite.com'; </script>
This way, text inserted by the user is actually shown on the page, instead of being considered HTML. The link will show as a link, but it will not be a clickable link, and no JavaScript can be run this way. The EncodedBody
property returns the HTML encoded text, but it can't completely replace the Body
property, because the original comment text is still required in certain situations — for example, in the administration pages where you show the text in a textbox and allow the administrator to edit it.
Scripting-based attacks must not be taken lightly, and you should ensure that your site is not vulnerable. One good reference on the web is
www.technicalinfo.net/gunter/index.html
, but you can easily find many others. Try searching for "XSS," using your favorite search engine.
There are two new features for the comment functionality of the Beer House, validating comments through Akismet and Gravatar support. Akismet is a comment spam-filtering service. Simply put a site can pass a comment through the Akismet API and it will return if Akismet thinks the comment is spam or not.
The last helper members help with accessing the user's Gravatar. Simply put, a Gravatar is a Globally Recognized Avatar and is managed at www.Gravatar.com
. Anyone can go to Gravatar.com
and create an account and assign images or photos to be used anywhere to represent them. If a commentor does not have a Gravatar account, the API returns a random image to represent the user, but more about that later, too.
Instead of using stored procedures and a DAL (Data Access Layer) to access those stored procedures, with the Entity Framework and LINQ these tasks are contained within a repository class. As explained in Chapter 3, repository classes now manage the business aspect of the Beer House application. Repositories provide for some separation of concerns that will allow the architecture to be further extended in the future if desired. Figure 5-3 shows the class diagram for the Articles module.
Each of the entities related to the articles module has its own repository class. Each of these classes inherits a BaseRepository
class that contains some common logic used by all the repository classes, such as the Dispose
method and a common reference to the ArticlesEntities DataContext
class to interact with the Entity Data Model for the articles module.
The ArticleRepository
manages all the interactions with the Article entity, using LINQ to entities to build queries in the database. The repository is a class composed of methods to create, retrieve, update, and delete (CRUD) records in the tbh_Articles table, most manage retrieving and caching records in memory.
In the previous edition of the Beer House, the articles were retrieved in chunks or pages. This can still be done, but since the records are going to be cached in memory and pages using AJAX, this is not as important. If the site were to contain hundreds of thousands of records, then caching by page is slightly more feasible and can be done with LINQ by using the Skip
and Take
methods on the IQueryable(of Article)
list returned by the LINQ query.
lArticles = (From lArticle In Articlesctx.Articles _ Where lArticle.Published = True _ Order By lArticle.ReleaseDate Descending).Skip(15).Take(15).ToList()
Skip
accepts a numerical value that indicates how many rows to skip over before beginning the list of records to return. Similarly, the Take
method accepts a numerical value to indicate how many records should be returned. So, the sample query returns the second displayed page of records.
The GetArticleById
method returns a single article by its ID, but the method contains a couple of overloads. The primary returns just the article entity itself and does not retrieve the associated category or list of comments. This is the most efficient query but may not always meet the needs of the page asking for the article. In that case, the second overload accepts Boolean parameters to indicate if the associated category and comments should also be retrieved.
Public Function GetArticleById(ByVal ArticleId As Integer, ByVal bIncludeCategories As Boolean, ByVal bIncludeComments As Boolean) As Article If ArticleId > 0 Then If bIncludeCategories And bIncludeComments Then Return (From lai In Articlesctx.Articles.Include("Categories").Include("Comments") _ Where lai.ArticleID = ArticleId).FirstOrDefault ElseIf bIncludeCategories And Not bIncludeComments Then Return (From lai In Articlesctx.Articles.Include("Categories") _ Where lai.ArticleID = ArticleId).FirstOrDefault ElseIf Not bIncludeCategories And Not bIncludeComments Then Return (From lai In Articlesctx.Articles _ Where lai.ArticleID = ArticleId).FirstOrDefault End If End If Throw New ArgumentException("The ArticleId is not valid.") End Function
The RateArticle
method is not necessarily the most efficient use of SQL because it retrieves an instance of the article's entity and changes the TotalRating
and Votes
members, then commits the changes to the database. What is more efficient is to change the values in an existing article entity you may be working with and save that to the database, but that may not always be possible. So, this method performs that duty for you using LINQ.
Public Function RateArticle(ByVal ArticleId As Integer, ByVal rating As Integer) As Boolean Dim lArticle As Article = GetArticleById(ArticleId) lArticle.TotalRating += rating lArticle.Votes += 1 Try Articlesctx.SaveChanges() MyBase.PurgeCacheItems(CacheKey)
Return True Catch ex As OptimisticConcurrencyException ' catching this exception allows you to ' refresh entities with either store/client wins ' project the entities into this failed entities. Dim failedEntities = From e1 In ex.StateEntries _ Select e1.Entity ' Note: in future you should be able to just pass the opt.StateEntities in to refresh. Articlesctx.Refresh(RefreshMode.ClientWins, failedEntities.ToList()) Articlesctx.SaveChanges() Catch ex As Exception Return False End Try End Function
The RateArticle
method commits the changes to the database and also includes some special code to check for a OptimisticConcurrencyException
and deals with it by specifying how the issue is to be resolved. While this should not be an issue with this method because the entity is being retrieved so fast, I thought it would be a good idea to include the code to demonstrate how this is done with the Entity Framework.
The IncrementArticleViewCount
and ApproveArticle
methods are structured in much the same way as the RateArticle
method. They retrieve the article, change the relevant (ViewCount or Approved) value, and commit the change to the database.
The AddArticle
method accepts an article entity and adds it to the database. There is a corresponding overload that accepts a series of parameters to create a new Article
object. First, it checks to see if the Article
already exists; if so, it uses the existing entity to make updates against. If it does not already exist, the method creates a new instance of an Article
, and it is committed to the database by calling the other version of the AddArticle
method.
Public Function AddArticle(ByVal articleID As Integer, ByVal title As String, ByVal body As String, _ ByVal approved As Boolean, ByVal listed As Boolean, ByVal commentsEnabled As Boolean, _ ByVal onlyForMembers As Boolean, ByVal viewCount As Integer, ByVal votes As Integer, ByVal totalRating As Integer) As Article Dim article As Article If articleID > 0 Then article = GetArticleById(articleID) article.ArticleID = articleID article.UpdatedDate = Now article.UpdatedBy = Helpers.CurrentUserName
article.Title = title article.Body = body article.Approved = approved article.Listed = listed article.CommentsEnabled = commentsEnabled article.OnlyForMembers = onlyForMembers article.ViewCount = viewCount article.Votes = votes article.TotalRating = totalRating Else article = article.CreateArticle(articleID, Now, Helpers.CurrentUserName, Now, True, title, body, _ approved, listed, commentsEnabled, onlyForMembers, viewCount, votes, totalRating) End If Return AddArticle(article) End Function
In the second AddArticle
method leverages the built-in functionality of the Entity Framework to simply update the existing record. If the entity is not attached to the current context, it is added to the context before it is saved to the database. All the cache related to the Article
entity is flushed from the system as well, to ensure that the freshest data is used by the application. Finally, a ternary If
statement is used to return True
or False
, depending on if any records were affected, which typically will be true. If an exception occurs in the save operation, it is added to the ActiveExceptions
list, which can then be used by the page working with the entity to inform the user about what happened.
Public Function AddArticle(ByVal vArticle As Article) As Article Try If vArticle.EntityState = EntityState.Detached Then Articlesctx.AddToArticles(vArticle) End If MyBase.PurgeCacheItems(CacheKey) Return If(Articlesctx.SaveChanges > 0, vArticle, Nothing) Catch ex As Exception ActiveExceptions.Add(CacheKey & "_" & vArticle.ArticleID, ex) Return Nothing End Try End Function
ActiveExceptions
is a property defined in the BaseRepository
class. It is a Dictionary
of exceptions, so more than one exception can be caught and stored. I did this in case more than one exception was thrown, so that the page could loop through the exceptions and inform the user about each issue. Generally, there will be one exception, but there are some fringe cases where there might be more than one exception.
Private _activeExceptions As Dictionary(Of String, Exception) Public Property ActiveExceptions() As Dictionary(Of String, Exception)
Get If IsNothing(_activeExceptions) Then _activeExceptions = New Dictionary(Of String, Exception) End If Return _activeExceptions End Get Set(ByVal Value As Dictionary(Of String, Exception)) _activeExceptions = Value End Set End Property
The AddEdit
pages in the site's admin all use the following routine to echo a list of the exceptions that occurred during a commit to the database:
Private Sub IndicateNotUpdated(ByVal vRepository As BaseRepository) ltlStatus.Text = String.Empty If vRepository.ActiveExceptions.Count > 0 Then For Each kv As KeyValuePair(Of String, Exception) In vRepository.ActiveExceptions ltlStatus.Text += DirectCast(kv.Value, Exception).Message & "<BR/>" Next Else ltlStatus.Text = String.Format(My.Resources.EntityHasNotBeenUpdated, "Article") End If End Sub
DeleteArticle
and UnDeleteArticle
both call the ChangeDeletedState
method but pass in either false to delete or true to undelete a record. The ChangeDeleteState
method sets the Active
field to the supplied vState
parameter value, sets the UpdatedDate
property to the current time and the UpdatedBy
property to the current user's UserName
.
Private Function ChangeDeletedState(ByVal vArticle As Article, ByVal vState As Boolean) As Boolean vArticle.Active = vState vArticle.UpdatedDate = Now() vArticle.UpdatedBy = CurrentUserName Try Articlesctx.SaveChanges() MyBase.PurgeCacheItems(CacheKey) Return True Catch ex As Exception ActiveExceptions.Add(vArticle.ArticleID, ex) Return False End Try End Function
The overall composition of the CategoryRepository
class is very similar to the ArticleRepository
as it applies to the basic CRUD operations.
Method | Description |
---|---|
Returns a list of | |
Returns a list of all active | |
Returns a | |
Takes all the data for creating a new category and returns true if the addition was successful. | |
Updates data for an existing category, and returns a Boolean value indicating whether the operation was successful. | |
Changes the | |
Changes the | |
Returns the number of categories stored in the database. |
The CategoryRepository
, located in the Articles folder of the class library, works much like the ArticleRepository
. It does contain fewer members, since there are not many custom views of categories in the application. It differs from the previous edition of the Beer House; the AllArticles
and PublishedArticles
members have been removed and replaced with adequate corresponding members in the ArticleRepository
class.
The CommentRepository
is a little simpler than the other two repository classes because it contains fewer methods and does not allow the deletion of comments. Instead, a comment is not going to be displayed unless it is approved.
Method | Description |
---|---|
Returns a list of | |
Overloaded function that returns a list of all active | |
Returns a list of | |
Returns the total number of | |
| Returns a |
Takes all the data for creating a new comment and returns true if the addition was successful. | |
Updates data for an existing comment and returns a Boolean value indicating whether the operation was successful. | |
Changes the | |
Changes the | |
Changes the |
The AddComment
method does more than just add the comment to the database; it can also call the Akismet service to determine if the comment is spam or not. The method creates a new instance of the Akismet
class, part of the Akismet.net library and passes an AkismetCommet
object to the library, which subsequently calls Akisment to score the comment. If the comment is determined to be a spam comment, the method returns true; otherwise, it returns false to indicate the comment should be safe.
Working with Akismet requires having an account at WordPress.com
, which is free to anyone who signs up (http://akismet.com
). If you have higher traffic, there is a commercial license you might need to get, so check the Akismet website for more details.
The Akismet.net library has four methods that can be used to communicate with Akismet, VerifyKey, CommentCheck, SubmitSpam
, and SubmitHam
. If you want to verify your Word Press Key and blog, call the VerifyKey
method, it returns True
if you have a valid combination. To check a comment for spam, call the CommentCheck
method, if Akismet returns false, then the comment should be safe. If you find items you want to submit to Akismet for their database, then use SubmitSpam
, but only for something that is absolutely spam. Use the SubmitHam
method if you are not quite sure if the comment is spam or not. Submitting suspect comments helps the Akismet system work better to protect the Internet from comment spam in the future!
We will not implement sorting features for the categories and the articles. This is because categories will always be sorted by importance (the Importance
field) and then by name, whereas articles will always be sorted by release date, from the newest to the oldest, which is the right kind of sorting for these features. However, comments should be sorted in two different ways according to the situation:
From the oldest to the newest when they are listed on the user page, under the article itself, so that users will read them in chronological order, allowing them to follow a discussion made up of questions and answers between the readers and the article's author, or among different readers.
From the newest to the oldest in the administration page, so that the administrator finds the new comments at the top of the list, and in the first page (remember that comments support pagination), so they can be immediately read, edited, and, if necessary, deleted if found offensive.
Chapter 3 introduced a custom configuration section named <theBeerHouse>
that you must define in the root folder's web.config
file, to specify some settings required in order for the site's modules to work. In that chapter, we also developed a configuration class that would handle the <contact>
subelement of <theBeerHouse>
, with settings for the Contact
form in the Contact.aspx
page. For the articles module of this chapter, you'll need some new settings that will be grouped into a new configuration subelement under <theBeerHouse>
, called <articles>
. This will be read by a class called ArticlesElement
that will inherit from System.Configuration.ConfigurationElement
and that will have the public properties shown in the following table.
Property | Description |
---|---|
Full name (namespace plus class name) of the concrete provider class that implements the data access code for a specific data store. | |
Name of the entry in | |
Default number of articles listed per page. The user will be able to change the page size from the user interface. | |
Number of items returned by the module's RSS feeds. | |
Boolean value indicating whether the caching of data is enabled. | |
Number of seconds for which the data is cached. | |
Used in the search engine friendly URLs to indicate the request is an article. | |
Boolean value indicating is posting to Twitter is enabled. | |
The site's Twitter account username. | |
The site's Twitter account password. | |
Holds the Word Press Key needed to use Akismet. | |
Indicates if Akismet checking is turned on. | |
Indicates if automatic submission of comment spam to Akismet should be done. |
The settings in the web.config
file will have the same name, but will follow the camelCase
naming convention; therefore, you will use providerType, connectionStringName, pageSize
, and so on, as shown in the following example:
<theBeerHouse> <contactForm mailTo="[email protected]" /> <articles pageSize="10" twitterUrserName="twitterBH" twitterPassword="twitterPHPwd" enableTwitter="false" /> : </theBeerHouse>
A Helpers
class is part of the BeerHouse class library that contains a series of shared (VB.NET) or static (C#) members. One of the members returns an instance of theBeerHouse
configuration section.
Public Shared ReadOnly Settings As TheBeerHouseSection = _ CType(WebConfigurationManager.GetSection("theBeerHouse"), TheBeerHouseSection)
An instance of ArticlesElement
is returned by the Articles
property of the TheBeerHouseSection
class, described in Chapter 3, that represents the <theBeerHouse>
parent section. This class will also have a couple of other new properties, DefaultConnectionStringName
and DefaultCacheDuration
, to provide default values for the module-specific ConnectionStringName
and CacheDuration
settings. These settings will be available for each module, but you want to be able to set them differently for each module. For example, you may want to use one database for storing articles data, and a second database to store forums data, so that you can easily back them up independently and with a different frequency according to how critical the data is and how often it changes. This is one of the reasons for having dedicated Entity Data Models for each module used in the site.
The same goes for the cache duration. However, in case you want to assign the same settings to all modules (which is probably what you will do for small to midsized sites), you can just assign the default values at the root section level, instead of copying and pasting them for every configuration subelement.
In addition to the properties listed earlier, the ArticlesElement
will have another property, ConnectionString
. This is a calculated property, though, not one that is read from web.config
. It uses the <articles>
's connectionStringName
or the <theBeerHouse>
's defaultConnectionStringName
and then looks up the corresponding connection string in the web.config
file's <connectionStrings>
section, so the caller will get the final connection string and not just the name of its entry.
The design of the ASP.NET pages in this module is not particularly special, so there's not much to discuss. We have a set of pages, some for the administrators and some for the end users, which allow us to manage articles, and navigate through categories and read articles, respectively. In the first edition of this book, the most important consideration for the UI section of the first chapters was the approach used to integrate the module-specific pages into the rest of the site. However, you've already seen from previous chapters that this is very straightforward in ASP.NET 2.0, thanks to master pages. Following are the pages we will code later:
~/Admin/ManageCategories.aspx — Lists the current categories, and allows administrators to create new ones and delete and update existing ones.
~/Admin/AddEditCategory.aspx — Allows administrators to create new categories and update existing categories.
~/Admin/ManageArticles.aspx — Lists the current articles (with pagination support) and allows administrators to delete them. The creation of new articles and the editing of existing articles will be delegated to a secondary page.
~/Admin/AddEditArticle.aspx — Allows administrators to create new articles and update existing articles.
~/Admin/ManageComments.aspx — Lists the current comments for any article, has pagination support, and supports deletion and updates of existing comments.
~/ShowCategories.aspx — An end-user page that lists all categories, with their title, description, and image.
~/BrowseArticles.aspx — An end-user page that allows users to browse published articles for a specific category or for all categories. The page shows the title, abstract, author, release date, average rating, and location of the articles.
~/ShowArticle.aspx — An end-user page that shows the complete article, along with the current comments at the bottom, and a box to let users post new comments and rate the article.
RSSFeed.vb — A custom HttpHandler
that is mapped to handle all requests for any .RSS resource in the Beer House site. It is intelligent enough to apply appropriate filtering to the list of RSS items it serves.
The first and most important challenge you face is that the site must be easily updatable by the client herself, without requiring help from any technical support people. Some regular employees working in the pub must be able to write and publish new articles, and make them look good by applying various formatting, colors, pictures, tables, and so forth. All this must be possible without knowing any HTML, of course! This problem can be solved by using a WYSIWYG (the acronym for "what you see is what you get") text editor: these editors enable users to write and format text, and to insert graphical elements, much like a typical word processor (which most people are familiar with), and the content is saved in HTML format that can be later shown on the end-user page "as is." There are various editors available, some commercial and some free. Among the different options I picked FCKeditor (www.fckeditor.net
), mainly because it is open source and because it is compatible with most Internet browsers, including IE 5.5+, Firefox 1.0+, Mozilla 1.3+, and Netscape 7+. Figure 5-4 shows a screenshot of an online demo from the editor's website.
The editor is even localizable (language packs for many languages are already provided), and its user interface can be greatly customized, so you can easily decide what toolbars and what command buttons (and thus formatting and functions) you want to make available to users.
The editor must be able to upload files, typically images for an article or to publish in a photo gallery, and maybe upload documents, screen savers, or other goodies that editors want to distribute to their end users. An administrator of a site would be able to use an FTP program to upload files, but an editor typically does not have the expertise, or the credentials, needed to access the remote server and its file system. An online file manager might be very helpful in this situation. In the first edition of this book, an entire chapter was devoted to showing you how to build a full-featured online file manager that would enable users to browse and remove folders and files; upload new files; download, rename, copy and delete existing files; and even edit the content of text files. However, this would be overkill in most situations, as the administrator is the only one who needs to have full control over the files and folders and structure of the site, and the administrator will presumably use an FTP client for this purpose. Editors and contributors only need the capability to upload new files. To implement this functionality, we will develop a small user control that allows users to upload one file at a time, and when done, displays the full URL of the file saved on the server, so the user can easily link to it using the WYSIWYG editor. The control will be used in various pages: in the page used to add and edit an article and in the page used to manage categories (as each category can have an image representing it); later in the book, we'll use this in the pages that send newsletters and submit forum posts.
This user control, named FileUploader.ascx
, will utilize the new ASP.NET 2.0 FileUpload
control to select the file, submit it, and save it on the server. This control simply translates to an <input type="file" />
control, with server-side methods to save the image. Under ASP.NET 1.x there was no such control; you had to add the runat="server"
attribute to a plain HTML control declaration.
One important design decision we need to consider is how to avoid the possibility that different editors might upload files with the same name, overwriting previous files uploaded by someone else. A simple, but effective, solution is to save the file under ~/Uploads/{UserName}
, where the {UserName}
placeholder is replaced by the actual user's name. This works because only registered and authenticated users will have access to pages where they can upload files. We do want to let users overwrite a file that they uploaded themselves, as they might want to change the file.
Remember that you will need to add NTFS write permission to the remote Uploads folder at deployment time, for the ASP.NET (Windows 2000 and XP) or Network Service user account (Windows Server 2003). It's easy to overlook this kind of thing, and you don't want to leave a bad impression with users when you set up a new site for them.
You will need a way to quickly add the list of articles (with title, author, abstract, and a few more details) to any page. It's not enough to have entirely new articles; you also need to show them on existing pages so users will know about them! You'll need to show the list on the BrowseArticles.aspx
page for end users and on the ManageArticles.aspx
page for administrators. You may also want to show the article list on the home page. If you've got a good understanding of user controls, you may have already guessed that a user control is the best solution for this list because it enables us to encapsulate this functionality into a single code unit (the .ascx
file plus the cs code-behind file), which enables us to write the code once and then place that user control on any page using one line of code.
This user control will be named ArticleListing.ascx
. It produces different output according to whether the user is a regular user, an administrator, or an editor. If they belong to one of the special roles, each article item will have buttons to delete, edit, or approve them. This way, we can have a single control that will behave differently according to its context. Besides this, when the control is placed into an administration page, it must show all articles, including those that are not yet published (approved), or those that have already been retired (based on the date). When the control is on an end-user page, it must show only the active and published articles. The control will expose the following public properties (all Boolean), so that its content and its behavior can be changed in different pages:
Property | Description |
---|---|
Indicates whether articles referring to events in the user's country, state/province, or city are highlighted with different colors. | |
Indicates whether the control lists only articles that are approved, and whose | |
The number of days that must pass before a user can again rate the same article. | |
Indicates whether the control shows a drop-down list filled with all article categories, which lets the user filter articles by category. If the property is false the drop-down list will be hidden, and the control will filter the articles by category according to the CategoryID parameter passed on the querystring. | |
Indicates whether the control shows a drop-down list representing the number of articles to be listed per page. If the property is true, the user will be able to change the page size to a value that best meets his desires and his connection speed (users with a slow connection may prefer to have fewer items per page so that it loads faster). | |
Indicates whether the control will paginate the collection of articles resulting from the current filters (category and published status). When false, the control will have no paging bar and will only show the first n articles, where n is the page size. This allows us to use the control on the home page, for example, to list the n most recent additions. When true, it will show only the first n articles but will also show an indication of which page is displayed, and the user can switch between pages of articles. |
You've already learned from the introduction that we're going to implement a mechanism to provide the headlines of the site's new content as an RSS feed, so that external (online or desktop-based) aggregator programs can easily consume them, adding new content to their own site, but also driving new traffic to our site. This process of providing a list of articles via RSS is called syndication. The XML format used to contain RSS content is simple in nature (it's not an accident that the RSS acronym stands for "Really Simple Syndication"), and here's an example of one RSS feed that contains an entry for two different articles:
<rss version="2.0"> <channel> <title>My RSS feed</title> <link>http://www.contoso.com</link> <description>A sample site with a sample RSS</description> <copyright>Copyright 2005 by myself</copyright> <item> <title>First article</title> <author>Marco</author> <description>Some abstract text here...</description> <link>http://www.contoso.com/article1.aspx</link> <pubDate>Sat, 03 Sep 2005 12:00:34 GMT</pubDate> </item> <item> <title>Second article</title> <author>Mary</author> <description>Some other abstract text here...</description> <link>http://www.contoso.com/article2.aspx</link> <pubDate>Mon, 05 Sep 2005 10:30:22 GMT</pubDate> </item> </channel> </rss>
As you can see, the root node indicates the version of RSS used in this file, and just below that is a <channel>
section, which represents the feed. It contains several required subelements, <title>, <link>
, and <description>
, whose names are self-descriptive. There can also be a number of optional subelements, including <copyright>, <webMaster>, <pubDate>, <image>
, and others. After all those feed-level elements is the list of actual posts/articles/stories, represented by <item>
subsections. An item can have a number of optional elements, a few of which (title, author, description, link, pubDate
) are shown in the preceding example. For details on the full list of elements supported by RSS, you can check the link http://blogs.law.harvard.edu/tech/rss
or just search for "RSS 2.0 Specification."
One important thing to remember is that this must be a valid XML format, and therefore you cannot insert HTML into the <description>
element to provide a visual "look and feel," unless you ensure that it meets XML standards (XHTML is the name for tighter HTML that meets XML requirement). You must ensure that the HTML is well formed, so that all tags have their closing part (<p>
has its </p>
) or are self-closing (as in <img.../>
), among other rules. If you don't want the hassle of making sure that the HTML is XML compliant, you can just wrap the text into a CDATA
section, which can include any kind of data. Another small detail to observe is that the value for the pubDate
elements must be in the exact format "ddd, dd MMM yyyy HH:mm:ss GMT," as in "Thu, 03 Jan 2002 10:20:30 GMT." If you aren't careful to meet these RSS requirements, your users may get errors when they try to view your RSS feed. Some feed readers are more tolerant than others, so it's not sufficient to make sure that it works in your own feed reader — you need to meet the RSS specifications.
A custom HttpHandler
returns the RSS feed much more efficiently than a Web Form can. An HttpHandler
is the foundational unit that any request processed by the ASP.NET engine uses to return any content. The Page
class implements iHttpHandler
, which requires a class implement two members, IsReusable
and ProcessRequest
.
Once you have an RSS feed for your site, you can also consume the feed on this site itself, on the home page, to provide a list of articles! The ArticleListing.ascx
user control we already discussed is good for a page whose only purpose is to list articles, but it's too heavy for the home page. On the home page you don't need details such as the location, the rating, and other information. The new article's title and the abstract is enough — when users click on the title, they will be redirected to the page with the whole article, according to the link entry for that article in the RSS feed. We'll build our own RSS reader user control to consume our own RSS feed. The control will be generic, so that it will be able to consume RSS feeds from other sources as well, such as the site forum's RSS, the products RSS, or some other external RSS feeds. Its public properties are listed in the following table:
Property | Description |
---|---|
Full URL of the RSS feed. | |
| Title to be displayed. |
URL of a page with the full listing of items (versus the last n items returned by the RSS items). In the case of the articles module, this will be the URL for the | |
Text used for the link pointing to |
The only question left is how you can take the XML of the RSS feed and transform it into HTML to be shown on the page. Here are two possible solutions:
Use an XSL stylesheet to apply to the XML content, and use an XSLT transform to write the templates that define how to extract the content from the XML and represent it with HTML.
Dynamically build a DataTable
with columns for the title, author, description, and the other <item>
's elements. Then fill the DataTable
with the data read from the RSS feed, and use it as the data source for a Repeater, DataList
, or other template-based control.
Personally, I strongly prefer the latter option, mostly because I find it much faster to change the template of a Repeater
rather than to change the template in an XSL file. Additionally, with a DataTable
I can easily add calculated columns, apply filters and sorting, and merge feeds coming from different sources (consider the case where you have multiple blogs or sources of articles and want to show their RSS feeds in a single box, with all items merged together and sorted by date). The DataTable
approach is more flexible and easier to work with.
The articles manager module is basically divided into two parts:
The administration section, which allows the webmaster, or another designated individual, to add, delete, or edit the categories, publish articles, and moderate comments.
The end-user section, which has pages to navigate through the categories, read the articles, rate an article or post feedback, and display the headlines on the home page.
Obviously, different pages may have different security constraints: an administration page should not be accessible by end users, and an article with the OnlyForMembers
flag set should not be accessible by the anonymous users (users who aren't logged in). In the previous chapter, we developed a very flexible module that allows us to administer the registered users, read or edit their profile, and dynamically assign them to certain roles. For the articles manager module, we will need the following roles:
Administrators and Editors: These users have full control over the articles system. They can add, edit, or delete categories; approve and publish articles; and moderate comments. Only a very few people should belong to this role. (Note that Administrators also have full rights over the user management system and all the other modules of the site, so it might be wise if only a single individual has this role.)
Contributors: These users can submit their own articles, but they won't be published until an administrator or editor approves them. You could give this permission to many users if you wanted to gather as much content as possible, or just to a selected group of people.
Enforcing these security rules is a simple task, as you've learned in the previous chapter. In many cases, it suffices to protect an entire page against unauthorized users by writing some settings in that page's folder's web.config
file. Settings done in a configuration file are called declarative coding, and settings made with source code are called imperative coding. I favor declarative coding because it's easier to modify without recompiling source code, but in some more complex cases you have to perform some security checks directly from code. An example is the AddEditArticle.aspx
page, which is used to post a new article or edit an existing one. The first action (post) is available to Contributors and upper roles, while the second (edit) is available only to Editors and Administrators. When the page loads you must understand in which mode the page is being loaded, according to some querystring settings, and check the user's roles accordingly.
In coding the solution, we'll follow the same path we used in the "Design" section: from database tables and stored procedure creation, to the implementation of security, passing through the DAL, BLL, and lastly the user interface.
Creating the database tables is straightforward with Visual Studio's integrated Server Explorer and database manager, so we won't cover it here. You can refer to the tables in the "Design" section to see all the settings for each field. In the downloadable code file for this book, you will find the complete DB ready to go. Instead, here you'll create relationships between the tables and write some stored procedures.
You create a new diagram from the Server Explorer: drill down from Data Connections to your database (if you don't see your database, you can add it as a new Data Connection), and then Database Diagrams. Right-click on Database Diagrams and select Add New Diagram. By following the wizard, you can add the tbh_Categories, tbh_Articles
, and tbh_Comments
tables to your diagram. As soon as the three tables are added to the underlying window, Server Explorer should recognize a relationship between tbh_Categories
and tbh_Articles
, and between tbh_Articles
and tbh_Comments
, and automatically create a parent-child relationship between them over the correct fields, as shown in Figure 5-5. However, if it does not, click on the tbh_Articles' CategoryID
field and drag and drop the icons that appear over the tbh_Categories
table. Once you release the button, a dialog with the relationship's properties appears, and you can ensure that the foreign key is the tbh_Articles' CategoryID
field, while the primary key is tbh_Categories' CategoryID
.
Now you have to create a relationship between tbh_Comments
and tbh_Articles
, based on the ArticleID
field of both tables. As before, click the tbh_Comments' ArticleID
field, drag and drop the icon over the tbh_Articles
table, and complete the Properties dialog as before. When you're done with the diagram, go up to the tab, right-click on it, and save the diagram. Make sure that you let it change your tables as specified in the diagram.
The ArticlesElement
class is implemented in the ~/App_Code/ConfigSection.cs
file. It descends from System.Configuration.ConfigurationElement
and implements the properties that map the attributes of the <articles>
element under the <theBeerHouse>
custom section in the web.config
file. The properties, listed and described in the "Design" section, are bound to the XML settings by means of the ConfigurationProperty
attribute. Here's its code:
Public Class ArticlesElement Inherits ConfigurationElement <ConfigurationProperty("connectionStringName")> _ Public Property ConnectionStringName() As String Get Return CStr(Me("connectionStringName")) End Get Set(ByVal value As String) Me("ConnectionStringName") = value End Set End Property Public ReadOnly Property ConnectionString() As String Get Dim connStringName As String If String.IsNullOrEmpty(Me.ConnectionStringName) Then connStringName = Globals.Settings.DefaultConnectionStringName Else connStringName = Me.ConnectionStringName End If Return WebConfigurationManager.ConnectionStrings(connStringName) .ConnectionString End Get End Property <ConfigurationProperty("providerType", DefaultValue:="MB.TheBeerHouse.DAL.SqlClient.SqlArticlesProvider")> _ Public Property ProviderType() As String Get Return CStr(Me("providerType")) End Get Set(ByVal value As String) Me("providerType") = value End Set End Property <ConfigurationProperty("ratingLockInterval", DefaultValue:="15")> _ Public Property RatingLockInterval() As Integer Get Return CInt(Me("ratingLockInterval")) End Get Set(ByVal value As Integer) Me("ratingLockInterval") = value End Set End Property <ConfigurationProperty("pageSize", DefaultValue:="10")> _ Public Property PageSize() As Integer Get
Return CInt(Me("pageSize")) End Get Set(ByVal value As Integer) Me("pageSize") = value End Set End Property <ConfigurationProperty("rssItems", DefaultValue:="5")> _ Public Property RssItems() As Integer Get Return CInt(Me("rssItems")) End Get Set(ByVal value As Integer) Me("rssItems") = value End Set End Property <ConfigurationProperty("enableCaching", DefaultValue:="true")> _ Public Property EnableCaching() As Boolean Get Return CBool(Me("enableCaching")) End Get Set(ByVal value As Boolean) Me("enableCaching") = value End Set End Property <ConfigurationProperty("cacheDuration")> _ Public Property CacheDuration() As Integer Get Dim duration As Integer = CInt(Me("cacheDuration")) If duration > 0 Then Return duration Else Return Globals.Settings.DefaultCacheDuration End If End Get Set(ByVal value As Integer) Me("cacheDuration") = value End Set End Property <ConfigurationProperty("urlIndicator")> _ Public Property URLIndicator() As String Get Dim lurlIndicator As String = Me("urlIndicator").ToString If String.IsNullOrEmpty(lurlIndicator) Then lurlIndicator = "Article" End If Return lurlIndicator End Get Set(ByVal Value As String) Me("urlIndicator") = Value End Set
End Property <ConfigurationProperty("akismetKey")> _ Public Property AkismetKey() As String Get Dim lakismetKey As String = Me("akismetKey").ToString If String.IsNullOrEmpty(lakismetKey) Then lakismetKey = "" End If Return lakismetKey End Get Set(ByVal Value As String) Me("akismetKey") = Value End Set End Property <ConfigurationProperty("enableAkismet", DefaultValue:="false")> _ Public Property EnableAkismet() As Boolean Get Return CBool(Me("enableAkismet")) End Get Set(ByVal value As Boolean) Me("enableAkismet") = value End Set End Property <ConfigurationProperty("reportAkismet", DefaultValue:="false")> _ Public Property ReportAkismet() As Boolean Get Return CBool(Me("reportAkismet")) End Get Set(ByVal value As Boolean) Me("reportAkismet") = value End Set End Property <ConfigurationProperty("enableTwitter", DefaultValue:="false")> _ Public Property EnableTwitter() As Boolean Get Return CBool(Me("enableTwitter")) End Get Set(ByVal value As Boolean) Me("enableTwitter") = value End Set End Property <ConfigurationProperty("twitterUrserName")> _ Public Property TwitterUrserName() As String Get Dim lurlIndicator As String = Me("twitterUrserName").ToString If String.IsNullOrEmpty(lurlIndicator) Then lurlIndicator = "TwitterUserName" End If Return lurlIndicator
End Get Set(ByVal Value As String) Me("twitterUrserName") = Value End Set End Property <ConfigurationProperty("twitterPassword")> _ Public Property TwitterPassword() As String Get Dim lurlIndicator As String = Me("twitterPassword").ToString If String.IsNullOrEmpty(lurlIndicator) Then lurlIndicator = "TwitterPassword" End If Return lurlIndicator End Get Set(ByVal Value As String) Me("twitterPassword") = Value End Set End Property End Class
The ConnectionString
property does not directly read/write a setting from/to the configuration file, but rather returns the value of the entry in the web.config's <connectionStrings>
section identified by the name indicated in the <articles>
's connectionStringName
attribute, or the <theBeerHouse>
's defaultConnectionStringName
if the first setting is not present. The CacheDuration
property returns the <articles>
's cacheDuration
setting if it is greater than zero, or the <theBeerHouse>
's defaultCacheDuration
setting otherwise.
DefaultConnectionStringName
and DefaultCacheDuration
are two new properties of the TheBeerHouseSection
created in Chapter 3, now modified as shown here:
Public Class TheBeerHouseSection Inherits ConfigurationSection Private Shared ReadOnly instance As TheBeerHouseSection = New TheBeerHouseSection <ConfigurationProperty("defaultConnectionStringName", DefaultValue:="LocalSqlServer")> _ Public Property DefaultConnectionStringName() As String Get Return CStr(Me("defaultConnectionStringName")) End Get Set(ByVal value As String) Me("DefaultConnectionStringName") = value End Set End Property <ConfigurationProperty("siteDomainName", DefaultValue:="localhost")> _ Public Property SiteDomainName() As String Get
Return CStr(Me("siteDomainName")) End Get Set(ByVal value As String) Me("siteDomainName") = value End Set End Property <ConfigurationProperty("defaultCacheDuration", DefaultValue:="600")> _ Public Property DefaultCacheDuration() As Integer Get Return CInt(Me("defaultCacheDuration")) End Get Set(ByVal value As Integer) Me("defaultCacheDuration") = value End Set End Property <ConfigurationProperty("contactForm", IsRequired:=True)> _ Public ReadOnly Property ContactForm() As ContactFormElement Get Return CType(Me("contactForm"), ContactFormElement) End Get End Property <ConfigurationProperty("articles", IsRequired:=True)> _ Public ReadOnly Property Articles() As ArticlesElement Get Return CType(Me("articles"), ArticlesElement) End Get End Property End Class
The updated <theBeerHouse>
section in web.config
looks like this:
<theBeerHouse defaultConnectionStringName="TheBeerHouseEntities" siteDomainName="http://TheBeerHouseBook.com"> <contactForm mailTo="[email protected]" /> <articles pageSize="10" twitterUrserName="TheBeerHouse" twitterPassword="132456" enableTwitter="true" akismetKey="11aaaaa11111" enableAkismet="true"/> </theBeerHouse>
To read the settings from code you can do it this way: Helpers.Settings.Articles.RssItems
.
As we did for the data access classes, the business classes are created in the TBHBLL class library, which needs to be compiled before any changes can be used. The basic architecture of the business layer used in this edition is explained in Chapter 3. For the Articles
module this includes a series of repository classes and extensions of the ObjectContext
and Entity
classes generated by the Entity Framework Wizard.
The Articles
module has three main repository classes, one for Categories, Articles
, and Comments
. But because each of these repositories share the same entity data model and thus a common DataContext
class and base CacheKey
, the BaseArticleRepository
manages these members. It also manages the Dispose
method.
The BaseArticleRepository
inherits the BaseRepository
that was discussed in Chapter 3. Its constructors are very similar to the SiteMapRepository
; they set the connection string and set the core CacheKey
value.
Public Sub New(ByVal sConnectionString As String) ConnectionString = sConnectionString CacheKey = "Articles" End Sub Public Sub New() ConnectionString = Globals.Settings.DefaultConnectionStringName CacheKey = "Articles" End Sub
The balance of the BaseArticleRepository
follows the same patterns discussed in Chapter 3, as will all the remaining repositories in the application.
The ArticleRepository
class, in the Articles folder of the class library (ArticlesRepository.vb
) contains all the worker methods that interact with the articles entity model to manage articles. These include the common CRUD methods as well as some specialized methods to return records according to specific needs in the application. Each of these methods uses LINQ to entities as much as possible.
In the previous edition, the values related to paging had to be calculated by calling an article count stored procedure, the list of articles, and so forth. In this edition, I use the ListView
and the DataPager
control to manage paging the list of records for all the list views of data. The UpdatePanel
also makes it easier to do "seamless" paging from the client by using AJAX queries to the server to perform databinding. All this combined with caching the records in memory means managing the image around paging is no longer a concern.
Public Function GetArticles() As List(Of Article) Implements IArticleRepository.GetArticles Dim key As String = String.Format(CacheKey) If EnableCaching AndAlso Not IsNothing(Cache(key)) Then Return CType(Cache(key), List(Of Article)) End If Dim lArticles As List(Of Article) = (From lArticle In Articlesctx.Articles _ Order By lArticle.ReleaseDate Descending).ToList() If EnableCaching Then
CacheData(key, lArticles) End If Return lArticles End Function
The GetArticles
method returns an unfiltered generic list of articles and caches the results in memory. This is a basic LINQ to Entities query that does order the articles by release date in descending order. the Entity Framework translates this LINQ query into the following SQL statement that is executed in the database:
SELECT [Project1].[C1] AS [C1], [Project1].[ArticleID] AS [ArticleID], [Project1].[AddedDate] AS [AddedDate], [Project1].[AddedBy] AS [AddedBy], [Project1].[UpdatedDate] AS [UpdatedDate], [Project1].[UpdatedBy] AS [UpdatedBy], [Project1].[Active] AS [Active], [Project1].[Title] AS [Title], [Project1].[Abstract] AS [Abstract], [Project1].[Body] AS [Body], [Project1].[Country] AS [Country], [Project1].[State] AS [State], [Project1].[City] AS [City], [Project1].[ReleaseDate] AS [ReleaseDate], [Project1].[ExpireDate] AS [ExpireDate], [Project1].[Approved] AS [Approved], [Project1].[Listed] AS [Listed], [Project1].[CommentsEnabled] AS [CommentsEnabled], [Project1].[OnlyForMembers] AS [OnlyForMembers], [Project1].[ViewCount] AS [ViewCount], [Project1].[Votes] AS [Votes], [Project1].[TotalRating] AS [TotalRating], [Project1].[Keywords] AS [Keywords], [Project1].[CategoryID] AS [CategoryID] FROM ( SELECT [Extent1].[ArticleID] AS [ArticleID], [Extent1].[AddedDate] AS [AddedDate], [Extent1].[AddedBy] AS [AddedBy], [Extent1].[UpdatedDate] AS [UpdatedDate], [Extent1].[UpdatedBy] AS [UpdatedBy], [Extent1].[Active] AS [Active], [Extent1].[CategoryID] AS [CategoryID], [Extent1].[Title] AS [Title], [Extent1].[Abstract] AS [Abstract], [Extent1].[Body] AS [Body], [Extent1].[Keywords] AS [Keywords], [Extent1].[Country] AS [Country], [Extent1].[State] AS [State], [Extent1].[City] AS [City], [Extent1].[ReleaseDate] AS [ReleaseDate], [Extent1].[ExpireDate] AS [ExpireDate],
[Extent1].[Approved] AS [Approved], [Extent1].[Listed] AS [Listed], [Extent1].[CommentsEnabled] AS [CommentsEnabled], [Extent1].[OnlyForMembers] AS [OnlyForMembers], [Extent1].[ViewCount] AS [ViewCount], [Extent1].[Votes] AS [Votes], [Extent1].[TotalRating] AS [TotalRating], 1 AS [C1] FROM [dbo].[tbh_Articles] AS [Extent1] ) AS [Project1] ORDER BY [Project1].[ReleaseDate] DESC
I am not an expert on optimized Transact SQL, but I do find it interesting to see almost the same SQL query nested inside itself to perform the sorting, since the Entity Framework has been designed to optimize SQL queries. Also notice that, instead of a Select * from
statement, each field is explicitly stated. This is a requirement when you write Entity SQL; all fields in the query must be explicitly stated. You can monitor any activity in a SQL Server by running SQL Profiler, one of the tools you can install with SQL Server. Using this tool can help you understand how queries are being composed by Entity Framework to see where you might need to adjust a query.
I actually included three overloads of the GetArticles
method so that you could see how to make various LINQ to Entities queries. The second overload accepts a parameter to filter for published articles, while the third adds pageIndex
and pageSize
parameters that allow you to retrieve just the desired pages of articles.
Another slight variation on the GetArticles
methods is the GetHomePage
articles method. It returns the three most recently published articles to be displayed on the home page. It filters for articles that are approved, listed, and have a release date that is before the current time. It then sorts by a descending date and takes the first three articles.
Public Function GetHomePageArticles() As List(Of Article) Dim lArticles As List(Of Article) Dim key As String = CacheKey & "_HomePage" If EnableCaching AndAlso Not IsNothing(Cache(key)) Then Return CType(Cache(key), List(Of Article)) End If lArticles = (From lArticle In Articlesctx.Articles .Include("Category") _ Where lArticle.Active = True And lArticle.Approved = True And _ lArticle.Listed = True And lArticle.ReleaseDate < Now() And lArticle.ExpireDate > Now() _ Order By lArticle.AddedDate Descending).Take(3).ToList() If EnableCaching Then CacheData(key, lArticles, CacheDuration) End If Return lArticles End Function
The GetArticleCount
method is a simple statement that uses the LINQ Count
method to return the number of articles in the database. The problem with this approach is we might need to filter for the number of articles meeting specified criteria. In those cases, the Count
member of the generic List
class can be used to get a count of records matching the filter being applied to the database.
Public Function GetArticleCount() As Integer Return Articlesctx.Articles.Count() End Function
The CategoryRepository
class, in the Articles folder of the class library (CategoriesRepository.vb
), contains all the worker methods that interact with the articles entity model to manage categories. These include the common CRUD methods as well as some specialized methods to return records according to specific needs in the application. Each of these methods uses LINQ to entities as much as possible. The members of this class use common LINQ query patterns that are explained in more detail under the ArticleRepository
description.
The one special aspect of the CommentRepository
class is the Add and Delete members. Each of these members interact with Akismet, assuming that this feature is enabled in the site's web.config
.
The comment is still going to be added to the database, but instead of setting the initial Active flag to true, it is set to false. This means a site administrator can evaluate the comment to determine if it is really spam.
Public Function AddComment(ByVal vComment As Comment) As Comment Try If Helpers.Settings.Articles.EnableAkismet Then Dim lAkismet As New Akismet(Helpers.Settings.Articles .AkismetKey, _ "http://TheBeerHouseBook.com", "TheBeerHouse | Akismet/1.11") If lAkismet.CommentCheck(vComment.GetAkismetComment) Then vComment.Active = False End If End If If vComment.EntityState = EntityState.Detached Then Articlesctx.AddToComments(vComment) End If Return If(Articlesctx.SaveChanges > 0, vComment, Nothing) Catch ex As Exception ActiveExceptions.Add(vComment.CommentID, ex) Return Nothing
End Try End Function
The ApproveComment
method accepts either a CommentId
or a Comment
entity and sets the Approved
flag.
Public Function ApproveComment(ByVal lComment As Comment) As Boolean lComment.Approved = True lComment.UpdatedDate = Now() lComment.UpdatedBy = CurrentUserName Try Articlesctx.SaveChanges() Return True Catch ocEx As OptimisticConcurrencyException ActiveExceptions.Add(lComment.CommentID, ocEx) Return False Catch Ex As Exception ActiveExceptions.Add(lComment.CommentID, Ex) Return False End Try End Function
When a comment is deleted, it can be assumed that it is spam in most cases. The Akismet API offers the ability for you to submit spam comments to add to its system for further analysis. How this analysis is applied to the scoring algorithm I am not sure. In case you do not want to automatically report deleted comments as spam, the ArticlesElement
has a ReportAkismet
Boolean property to turn that feature off.
Public Function DeleteComment(ByVal vComment As Comment) As Boolean If Helpers.Settings.Articles.EnableAkismet And Helpers.Settings.Articles.ReportAkismet Then 'Submit uncaught Comment Spam to Akismet to add to their database. Dim lAkismet As New Joel.Net.Akismet(Helpers.Settings.Articles.AkismetKey, _ "http://beerhouse.extremewebworks.com", "TheBeerHouse | Akismet/1.11") lAkismet.SubmitSpam(vComment.GetAkismetComment) End If Return ChangeDeletedState(vComment, False) End Function
In the previous edition, a compromise was made to programmatically sort the generic list of comments with a custom sorting method. LINQ makes this operation even easier by allowing us to apply LINQ syntax to sort the list of comments. The CommentRepository
list retrieval methods have overloads to allow specification of which order in which the comments are listed in the returned generic list.
Public Function GetApprovedComments(ByVal ArticleId As Integer, ByVal bMostRecentFirst As Boolean) As List(Of Comment) Dim key As String = CacheKey & "_Comments_Approved_ArticleId_" & ArticleId & "_MostRecent_" & bMostRecentFirst If EnableCaching AndAlso Not IsNothing(Cache(key)) Then Return CType(Cache(key), List(Of Comment)) End If Dim lComments As List(Of Comment) = DirectCast(Cache(key), List(Of Comment)) If IsNothing(lComments) Then Articlesctx.Comments.MergeOption = MergeOption.NoTracking If bMostRecentFirst Then lComments = (From lComment In Articlesctx.Comments _ Where lComment.Approved = True And lComment.Article.ArticleID = ArticleId _ Order By lComment.AddedDate Ascending).ToList() Else lComments = (From lC In Articlesctx.Comments _ Where lC.Approved = True And lC.Article.ArticleID = ArticleId _ Order By lC.AddedDate Descending).ToList() End If End If CacheData(key, lComments) Return lComments End Function
The Article
class is extended in the class library's Articles/Article.vb
file. It is placed in the TheBeerHouse.BLL.Articles
namespace. The Article
class adds custom members, including immutable properties composed from values supplied by the entity framework. Since each entity class in the Entity Data Model is a partial class, they can be extended by creating another class with the same name in another file. They cannot be inherited by another class, they but can implement any interface needed. In the case of The Beer House, all entities implement the IBaseEntity
interface. This means that the entity will implement an IsValid
property and four methods to validate activity permission (CanRead, CanEdit, CanDelete
, and CanAdd
).
Public ReadOnly Property IsValid() As Boolean Implements IBaseEntity.IsValid Get If String.IsNullOrEmpty(Me.Title) = False And _
String.IsNullOrEmpty(Me.Abstract) = False And _ String.IsNullOrEmpty(Me.Body) = False And _ Me.ReleaseDate < Me.ExpireDate Then Return True End If Return False End Get End Property
The IsValid
property determines if the entity has required values and they are in an acceptable state. For the Article
entity, this means that it must have Title, Abstract
, and Body
values. The ReleaseData
must also be less than the ExpireDate
, or the article will never be published.
For the article module site, Administrators and Editors are allowed to add or edit article related entities. Each one of the entities in the articles model is extended to have authorization properties that can be used to verify action authorization.
Public ReadOnly Property CanAdd() As Boolean Implements BLL.IBaseEntity.CanAdd Get If Helpers.CurrentUser.IsInRole("Administrator") Or Helpers.CurrentUser.IsInRole("Editor") Then Return True End If Return False End Get End Property Public ReadOnly Property CanDelete() As Boolean Implements IBaseEntity.CanDelete Get If Helpers.CurrentUser.IsInRole("Administrator") Or Helpers.CurrentUser.IsInRole("Editor") Then Return True End If Return False End Get End Property Public ReadOnly Property CanEdit() As Boolean Implements IBaseEntity.CanEdit Get If Helpers.CurrentUser.IsInRole("Administrator") Or Helpers.CurrentUser.IsInRole("Editor") Then Return True End If Return False End Get End Property Public ReadOnly Property CanRead() As Boolean Implements IBaseEntity.CanRead Get Return True End Get End Property
The Helpers.CurrentUser
property returns an IPrincipal
that represents the current user. It can be used to determine if the user belongs to a role or not.
Public Shared ReadOnly Property CurrentUser() As IPrincipal Get Return HttpContext.Current.User End Get End Property
Each property of an entity has two events associated with it for changing values: [FieldName]Changing
and [FieldName]Changed
. The ArticleID
property cannot be less than 0, so in the ArticleIDChanging
event, a check to ensure the value is greater than 0 is done. If the value is negative, an ArgumentException
is thrown.
Private Sub OnArticleIDChanging(ByVal value As Integer) If value < 0 Then Throw New ArgumentException("The ArticleId cannot be less than 0.") End If If value <> ArticleID Then IsDirty = True End If End Sub
This idea can be carried over to any field in any entity in an Entity Data Model to verify that the value being set passes the requirements to be properly set.
While this is how validation is to be done based on the pattern generated by the Entity Data Model Wizard, I find it less than ideal because it requires throwing an exception when values do not meet the validation requirements. I think the IsValid
method should be used to ultimately perform validation because it can be used before the entity is committed to the data store. Throwing exceptions is never an optimal way to perform data validation as values are being set. The IsValid
property gives you the opportunity to stop the commitment and report the issues.
One of the drawbacks of the Entity Framework is access to foreign key values. The Entity Data Model for a database does not expose foreign key values. For the Article
entity, this would be CategoryId
since each article belongs to a category that is defined in the tbh_Categories
table. Accessing this value requires a bit of work by referencing the EntityKey
value. The Article
entity has a CategoryReference
property which can be used to access the foreign key value. It is of type System.Data.Objects.DataClasses.EntityReference(Of Category)
, which contains an EntityKey
property. This property, in turn, contains a collection of EntityKeyValues
, one for each member of the foreign key, which is typically one value. Since this value may not exist, you must check to see if the CategoryReference
has been created before accessing it. In the case of the CategoryId
, if there is no reference, then the property returns a 0.
Public Property CategoryId() As Integer Get If Not IsNothing(Me.CategoryReference.EntityKey) Then Return Me.CategoryReference.EntityKey.EntityKeyValues(0).Value End If
Return 0 End Get Set(ByVal Value As Integer) If Not IsNothing(Me.CategoryReference.EntityKey) Then Me.CategoryReference = Nothing End If Me.CategoryReference.EntityKey = New EntityKey("ArticlesEntities.Categories", "CategoryID", Value) End Set End Property
The EntityKey
value can be set, but if there is an existing value, it must be destroyed and a new one created. In the case of the CategoryId
, a new EntityKey
is created by passing the entity object, the field name, and the value for the key.
If you are creating a new entity, the primary key value of the entity itself cannot be set; let the database create it for you. If you do set the value, an exception is thrown by the framework.
Carried over from the previous edition of the Beer House, immutable properties make up the Article
entity. These are properties that are composed or calculated from values stored in the database for the article. Immutable properties for an article include the ArticleReleaseDate, AverageRating, CategoryTitle, Location
, and Published
.
The ArticleReleaseDate
actually wraps around the ReleaseDate
value because it can be null. The property is a string composed by calling the ToShortDateString
property of the DateTime
class. The ReleaseDate
property is created as a nullable value, since the field can be null in the database. This can cause issues when dealing with the value in the user interface. This is solved by calling the GetValueOrDefault
method and setting its value to a local variable.
Public ReadOnly Property ArticleReleaseDate() As String Get If Not IsNothing(ReleaseDate.GetValueOrDefault) Then Dim lRelaseDate As DateTime = ReleaseDate.GetValueOrDefault Return lRelaseDate.ToShortDateString End If Return String.Empty End Get End Property Public ReadOnly Property AverageRating() As Double Get If Me.Votes >= 1 Then Return CDbl(Me.TotalRating) / CDbl(Me.Votes) Else Return 0.0 End If End Get
End Property Public ReadOnly Property CategoryTitle() As String Get If Not IsNothing(Me.Category) Then Return Me.Category.Title End If Return String.Empty End Get End Property Public ReadOnly Property Location() As String Get Dim _location As String = String.Empty If Not IsNothing(Me.City) Then _location = Me.City.Split(";")(0) End If If String.IsNullOrEmpty(Me.State) = False Then If _location.Length > 0 Then _location += ", " End If _location += Me.State.Split(";")(0) End If If String.IsNullOrEmpty(Me.Country) = False Then If _location.Length > 0 Then _location += ", " End If _location += Me.Country End If Return _location End Get End Property Public ReadOnly Property Published() As Boolean Get Return (Me.Approved And Me.ReleaseDate <= DateTime.Now And Me.ExpireDate > DateTime.Now) End Get End Property
The Category
class has instance properties that fully describe a category of articles, instance methods to delete and update an existing category, and static methods to create, update, or delete one category. I won't describe all of them here as they are similar to the corresponding methods of the Article
class. They're actually a little simpler because you don't need multiple overloads to support pagination and other filters. There are two properties, Articles
and PublishedArticles
, that use a couple of overloads of the Article.GetArticles
static methods to return a list of Article
objects. Like the Article.Comments
property, these two properties also use the lazy load pattern, so articles are retrieved only once, when the property is read for the first time.
The IsValid
property checks to make sure that the Category
contains a Title
and a positive Importance
value.
Public ReadOnly Property IsValid() As Boolean Implements IBaseEntity.IsValid Get If String.IsNullOrEmpty(Title) And Importance > −1 Then Return False End If Return True End Get End Property
The authorization members are the same as the Article
class:
#Region " Authorization " Public ReadOnly Property CanAdd() As Boolean Implements BLL.IBaseEntity.CanAdd Get If Helpers.CurrentUser.IsInRole("Administrator") Or Helpers.CurrentUser.IsInRole("Editor") Then Return True End If Return False End Get End Property Public ReadOnly Property CanDelete() As Boolean Implements IBaseEntity.CanDelete Get If Helpers.CurrentUser.IsInRole("Administrator") Or Helpers.CurrentUser.IsInRole("Editor") Then Return True End If Return False End Get End Property Public ReadOnly Property CanEdit() As Boolean Implements IBaseEntity.CanEdit Get If Helpers.CurrentUser.IsInRole("Administrator") Or Helpers.CurrentUser.IsInRole("Editor") Then Return True End If Return False End Get End Property Public ReadOnly Property CanRead() As Boolean
Implements IBaseEntity.CanRead Get Return True End Get End Property #End Region
The Comment
class is extended in several ways: to validate the data, give structured access to related data and to manage interactions with Akismet and Gravatar. The first is a property to allow managed access to the ArticleId
foreign key value:
Public Property ArticleId() As Integer Get If Not IsNothing(Me.ArticleReference.EntityKey) Then Return Me.ArticleReference.EntityKey.EntityKeyValues(0).Value End If Return 0 End Get Set(ByVal Value As Integer) If Not IsNothing(Me.ArticleReference.EntityKey) Then Me.ArticleReference = Nothing End If Me.ArticleReference.EntityKey = New EntityKey("ArticlesEntities.Articles", "ArticleID", Value) End Set End Property
Next is a ReadOnly
property called DisplayTitle
; it wraps access to the Article's Title
. This property makes it easy to return the title if the related article has also been retrieved with the comment itself. But this is not always going to be the case, and to handle this a ternary If
statement is used and "Not Supplied" is returned if the related Article does not exist.
Public ReadOnly Property DisplayTitle() As String Get Return If(String.IsNullOrEmpty(Me.Title), _ If(Not IsNothing(Me.Article), Me.Article.Title, "Not Supplied"), Me.Title) End Get End Property
The EncodedBody
property returns an HTMLEncoded
version of the comment body to guard against cross-site scripting attacks:
Public ReadOnly Property EncodedBody() As String Get Return HttpUtility.HtmlEncode(Me.Body) End Get End Property
The IsValid
property checks to make sure that the comment has a Body
; if there is nothing in the body of the comment, there is no comment after all. But as I discussed earlier, documenting who made the comment is important for a comment on a website, so their e-mail and IP addresses must be submitted.
Public ReadOnly Property IsValid() As Boolean Implements IBaseEntity.IsValid Get If String.IsNullOrEmpty(Me.Body) = False And _ String.IsNullOrEmpty(Me.AddedByEmail) = False And _ String.IsNullOrEmpty(Me.AddedByIP) = False Then Return True End If Return False End Get End Property
All comments must be scanned for spam because too many unscrupulous webmasters look to place unrelated comments on blogs trying to gain search engine placement and the occasional visitor. Akismet is a free scoring service available to anyone with a WordPress.com
account. I will talk about that in more detail later in the chapter. But Akismet has a URL-based API that can be used to check a comment. For the Beer House, I decided to use a popular .NET library, Akismet.Net, available on CodePlex.com
(www.codeplex.com/AkismetApi
).
The Akismet.Net library requires that an AkismetComment
class be created, which it then passes to Akismet for checking. The Comment
entity has been extended to include the GetAkismetComment
method, which builds an AkismetComment
class:
Public Function GetAkismetComment() As AkismetComment Dim lComment As New AkismetComment lComment.Blog = "http://TheBeerHouseBook.com" lComment.CommentAuthor = Me.AddedBy lComment.CommentAuthorEmail = Me.AddedByEmail lComment.CommentContent = Me.Body lComment.CommentType = "comment" lComment.UserIp = Me.AddedByIP lComment.CommentAuthorUrl = Me.CommenterURL Return lComment End Function
The last custom property, EMailHash
, returns a hash of the commentor's e-mail address. This is used to retrieve the user's Gravatar.
Public ReadOnly Property EMailHash() As String Get Return GravatarHelper.GetGravatarHash(Me.AddedByEmail) End Get End Property
Gravatar.com
is a service that makes it easy for a person to store a standard Avatar in a central location. But it is also a very easy service to integrate into the Beer House site as an automatic service for visitors when they leave comments on the site or other content that may utilize an Avatar, such as the forums. The service works by calling a URL with a Hexidecimal MD5 hash of the commentor's e-mail address. The URL is in the form of http://www.gravatar.com/avatar/[EMail Hash].jpg?s=XY. The querystring is optional but is useful because it allows you to specify the image width. Additionally, you can pass a "d:" parameter to specify a default avatar to be used when one is not available for the user. More information can be found on the Gravatar implementation page, http://en.gravatar.com/site/implement/url
.
The database design and the data access classes for the Articles
module are now complete, so it's time to code the user interface. We will use the business classes to retrieve and manage Article
data from the DB. We'll start by developing the administration console, so that we can use it later to add and manage sample records when we code and test the UI for end users.
The ManageCategories.aspx
page, located under the ~/Admin
folder, allows the administrator and editors to add, delete, and edit article categories, as well as directly jump to the list of articles for a specific category. The screenshot of the page, shown in Figure 5-6, demonstrates what I'm talking about, and then you'll learn how to build it.
Each of the list administrative pages in the Beer House will typically consist of a ListView
with a DataPager
wrapped in an UpdatePanel
. The ListView
is a much leaner tabular data control that works in tandem with the DataPage
control to allow easy navigation through a set of entities. Wrapping the ListView
inside an UpdatePanel
quickly adds AJAX capabilities without changing any of the code from normal ASP.NET Web Form authoring.
These same administrative pages will also follow a common layout with a title displayed at the top and some relevant navigational links to other activities in that module. Typically, these links will take you to other lists in the module, such as Articles
or Comments
, or give you the ability to add a new record to the site.
The ListView
used to manage the article categories displays the category's Title, an Edit icon (a pencil), and a Delete icon (a trash can). Clicking the Edit link takes you to the AddEditCategory.aspx
page, where the category can be changed.
The ListView
is a templated data control that works in a slightly different way than the ListView, DataList
, and Repeater
. It is more of a hybrid of the DataList
and Repeater
, with the option of paging and sorting built into it. The overall layout of the ListView
is defined in the LayoutTemplate
. The layout of the individual records is defined the ItemTemplate
and the AlternatingItemTemplate
. There is also an EmptyDataTemplate
to display a message to the user if there are no items available to display. There are other templates available to handle in-place editing, and so forth, but they will not be used in the Beer House.
<asp:UpdatePanel runat="server" ID="uppnlCategories"> <ContentTemplate> <asp:ListView ID="lvCategories" runat="server" DataKeyNames= "CategoryId"> <LayoutTemplate> <table cellspacing="0" cellpadding="0" class="AdminList"> <thead> <tr class="AdminListHeader"> <td> Title </td> <td> Edit </td> <td> Delete </td> </tr> </thead> <tbody> <tr id="itemPlaceholder" runat="server"> </tr> </tbody> <tfoot> <tr> <td colspan="3"> <div class="pager"> <asp:DataPager ID="pagerBottom" runat="server" PageSize="15" PagedControlID="lvCategories"> <Fields>
<asp:NextPreviousPagerField ButtonCssClass="command" FirstPageText="«" PreviousPageText="<" RenderDisabledButtonsAsLabels="true" ShowFirstPageButton="true" ShowPreviousPageButton="true" ShowLastPageButton="false" ShowNextPageButton="false" /> <asp:NumericPagerField ButtonCount="7" NumericButtonCssClass="command" CurrentPageLabelCssClass="current" NextPreviousButtonCssClass="command" /> <asp:NextPreviousPagerField ButtonCssClass="command" LastPageText="«" NextPageText=">" RenderDisabledButtonsAsLabels="true" ShowFirstPageButton="false" ShowPreviousPageButton="false" ShowLastPageButton="true" ShowNextPageButton="true" /> </Fields> </asp:DataPager> </div> </td> </tr> </tfoot> </table> </LayoutTemplate> <EmptyDataTemplate> <tr> <td colspan="3"> Sorry there are no Categories available at this time. </td> </tr> </EmptyDataTemplate> <ItemTemplate> <tr> <td class="ListTitle"> <a href='<%# String.Format("AddEditCategory.aspx?categoryid={0}", Eval("CategoryId")) %>'> <%# Eval("Title") %></a> </td> <td align="center"> <a href="<%# String.Format("AddEditCategory.aspx?categoryid={0}", Eval("CategoryId")) %>"> <img src="../images/edit.gif" alt="" width="16" height="16" class="AdminImg" /></a> </td> <td align="center"> <img src="../images/delete.gif" alt="" width="16" height="16" class="AdminImg" /> </td> </tr> </ItemTemplate> </asp:ListView>
</ContentTemplate> </asp:UpdatePanel>
Inside the LayoutTemplate
, one of the elements needs to be declared to runat
the server and either have the name itemPlaceHolder
or match a custom id supplied in the itemPlaceHolderID
property of the ListView
. This element will automatically be replaced by the Item and AlternatingItem
templates with the appropriate databinding. You should also set the DataKeyNames
property to the name of the entity's primary key value. This will be used in the deleting operation.
The DataPager
can be declared inside or outside the LayoutTemplate
. I like to place it in the LayoutTemplate
because it gives more control over positioning. You can place it outside the template as long as the DataPager
sets the PagedControlId
property to the ListView
. This means the DataPager
can be placed anywhere on the page. You can have more than one pager assigned to the ListView
.
Clicking the Delete icon calls the DeleteCategory
member and reloads the categories in the ListView
. When an item is deleted using a ListView
the ListView
's ItemDeleting
event is called, then the ItemDeleted
. You are still responsible for actually deleting the item and rebinding the list to the ListView
.
Protected Sub lvCategories_ItemDeleting(ByVal sender As Object, ByVal e As System.Web.UI.WebControls.ListViewDeleteEventArgs) Handles lvCategories.ItemDeleting Using lCategoryrpt As New CategoryRepository lCategoryrpt.DeleteCategory(lvCategories.DataKeys(e.ItemIndex).Value) BindCategories() End Using End Sub
In the ItemDeleting
event handler a new CategoryRepository
is created and the DeleteCategory
method is called, passing the CategoryId
of the record to be deleted. Then the BindCategories
method is called to rebind the list. The CategoryId
is retrieved from the ListView
by accessing the DataKeys
value for the selected row. This value was set by defining the DataKeyNames
property in the ListView
's declaration, DataKeyNames="CategoryId"
.
The code needed to manage the list of categories is very simple. The form's class inherits from the AdminPage
class, which contains some helper members used by all the pages in the admin section. Specifically, for the administrative pages of the Articles
module, the base admin page contains wrappers for the primary key parameters that are used to identify specific items. I will cover how that works in the AddEditCategory
page.
The first thing the page does is check to ensure that the user is authorized to access this page. While the site's entire Admin folder is protected by the web.config
security settings, it is always a good idea to check.
If the user is authorized, the BindCategories
method is called. This method uses a CategoryRepository
to bind a list of categories to the ListView
.
Imports TheBeerHouse.UI Imports TheBeerHouse.BLL.Articles
Imports System.Security Partial Public Class ManageCategories Inherits AdminPage Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load If Not IsPostBack Then If Me.User.Identity.IsAuthenticated AndAlso _ (Me.User.IsInRole("Administrators") Or _ Me.User.IsInRole("Editors") Or _ Me.User.IsInRole("Contributors") Or _ Me.User.IsInRole("Posters")) Then BindCategories() Else Throw New SecurityException( _ "You are not allowed to edit existing articles!") End If End If End Sub
The BindCategories
method creates a CategoryRepository
, and the GetActiveCategories
method returns a list of Categories
that is bound to a list. This list is then bound to the ListView
. As nice as the DataPager
control is, it does not have a property to hide it when there is less than one page of records to display. So, I created a method, SetupListViewPager
, and placed it in the BasePage
class to handle this. It accepts the count of records and a reference to the DataPager
. The SetUpListViewPager
method then checks to see if the pager should be visible, which it should if there are more records being bound than records being displayed on a page. The method follows; remember this is not part of the ManageCategories.aspx
page but part of the BasePage
class.
Public Sub SetupListViewPager(ByVal vCount As Integer, ByVal vPager As DataPager) If Not IsNothing(vPager) Then vPager.Visible = IIf(vCount <= vPager.PageSize, False, True) End If End Sub
You can see how this is applied in the BindCategories
method:
Private Sub BindCategories() Using Categoryrpt As New CategoryRepository Dim lCategories As List(Of Category) = Categoryrpt.GetActiveCategories() lvCategories.DataSource = lCategories lvCategories.DataBind() SetupListViewPager(lCategories.Count,
lvCategories.FindControl("pagerBottom")) End Using End Sub
The only task remaining is handling when the list is paged. The ListView
control fires the PagePropertiesChanged
event when the user pages the list. Since the ListView
has a DataPager
hooked into it, the ListView
handles working with the records to display the desired set of records. Since the records are being cached in memory, this should be a pretty efficient way to manage paging through the records. If the site grows to have hundreds of thousands of categories, this strategy should be rethought. But for now it is sufficient.
Protected Sub lvCategories_PagePropertiesChanged(ByVal sender As Object, ByVal e As System.EventArgs) Handles lvCategories.PagePropertiesChanged BindCategories() End Sub End Class
The AddEditCategory
page (see Figure 5-7) displays the data in an editable format for a Category
specified in the CategoryId QueryString
parameter. The data is displayed in a form with appropriate controls, such as TextBoxes, DropDownList, FCKeditor
, and so forth. If a new Category
is being created, the controls are set to their blank state and the administrator can create a new Category
.
I won't go into the details of the page's markup because there is nothing too fancy in play here. I will go over the standard AddEdit
page markup with the AddEditArticle
page because there are more interesting user interface elements on that page.
The one particular item I want to discuss on the Category administration page is managing the uploading of the category's image. This is done with a FileUpload
control, an ASP.NET web control that manages a file upload from the client. I created a method to manage the uploading of a file for an admin page called GetItemImage
. It accepts a reference to a FileUpload
and a TextBox
that echoes the URL to the image. The first thing that the method does is check the FileUpload
's HasFile
property. If it is true, then there is a file being uploaded and the method saves the file to the "images" folder. The function then returns the URL reference to the stored image. If there is no file being uploaded, it checks to see if there is a value in the txtImageURL
and returns that value. If none exists, it returns a reference to a stock image.
Protected Function GetItemImage(ByVal vFileUpload As FileUpload, ByVal txtImageURL As TextBox) As String If vFileUpload.HasFile Then vFileUpload.SaveAs(Path.Combine( _ Path.Combine(Request.PhysicalApplicationPath, "images"), vFileUpload.FileName)) Return Path.Combine("~/images", vFileUpload.FileName) ElseIf String.IsNullOrEmpty(txtImageURL.Text) = False Then Return txtImageURL.Text Else Return "~/Images/pencil45_32.png" End If End Function
Similarly to the ManageCategories.aspx
page the ManageArticles.aspx
page gives site administrators the capability to view a list of articles and go edit them. It also lists the Article title with an Edit and a Delete button, displayed in Figure 5-8.
It uses the same basic coding patterns used in the ManageCategory.aspx
page.
The AddEditArticle.aspx
page allows Administrators, Editors, and Contributors to add new articles or edit existing ones. It decides whether to use edit or insert mode according to whether an ArticleID
parameter was passed on the querystring. If it was, then the page loads in edit mode for that article, but only if the user is an Administrator or Editor (the edit mode is only available to Administrators and Editors, while the insert mode is also available to Contributors). This security check must be done programmatically, instead of declaratively from the web.config
file. Figure 5-9 shows the page in edit mode for an article.
As you might guess from studying this picture, the page is a table with a series of input fields, a few read-only fields (ArticleID, AddedDate
, and AddedBy
as usual, but also ViewCount, Votes
, and Rating
) and many other editable fields. The Body
field uses the open-source FCKeditor
described earlier. It is declared on the page as any other custom control, but it requires some configuration first. To set up FCK, you must download two packages from www.fckeditor.net:
FCKeditor (which at the time of writing is in version 2.6.3) includes the set of HTML pages and JavaScript files that implement the control. The control can be used not only with ASP.NET but also with ASP, JSP, PHP, and normal HTML pages. This first package includes the "host-independent" code, and some ASP/ASP.NET/JSP/PHP pages that implement an integrated file browser and file uploader.
FCKedit.Net
is the .NET custom control that wraps the HTML and JavaScript code of the editor.
Unzip the first package into an FCKeditor folder, underneath the site's root folder. Then unzip the second package and put the compiled DLL underneath the site's bin folder. The .NET custom control class has a number of properties that let you customize the look and feel of the editor; some properties can only be set in the fckconfig.js
file found under the FCKeditor folder. The global FCKConfig JavaScript editor enables you to configure many properties, such as whether the File Browser
and Image Upload
commands are enabled. Here's how to disable them in code (we already have our own file uploader, so we don't want to use it here, and we don't want to use the file browser for security reasons):
FCKConfig.LinkBrowser = false; FCKConfig.ImageBrowser = false; FCKConfig.LinkUpload = false; FCKConfig.ImageUpload = false;
You can even create a customized editor toolbar by adding the commands that you want to implement. For example, this shows how you can define a toolbar named "TheBeerHouse" with commands to format the text, and insert smileys and images, but not insert input controls or Flash animations:
FCKConfig.ToolbarSets["TheBeerHouse"] = [ ['Source','Preview','Templates'], ['Cut','Copy','Paste','PasteText','PasteWord','-','Print','SpellCheck'], ['Undo','Redo','-','Find','Replace','-','SelectAll','RemoveFormat'], ['Bold','Italic','Underline','StrikeThrough','-','Subscript','Superscript'], ['OrderedList','UnorderedList','-','Outdent','Indent'], ['JustifyLeft','JustifyCenter','JustifyRight','JustifyFull'], ['Link','Unlink','Anchor'], ['Image','Table','Rule','Smiley','SpecialChar','UniversalKey'], ['Style','FontFormat','FontName','FontSize'], ['TextColor','BGColor'], ['About'] ];
Many other configurations can be set directly from the ASP.NET page that hosts the control, as you'll see shortly.
The AddEditArticle.aspx
page uses typical ASP.NET Web controls such as TextBoxes, DropDowns
, and the like, but also leverages a few controls and extenders from the ASP.NET AJAX Control Toolkit, www.codeplex.com/AjaxControlToolkit
. Normally, a control reference is added to the top of the page for a set of controls not included with the core .NET Framework, but a sitewide reference can be made in the controls section of the web.config
file. Scott Guthrie details this technique on this blog, http://weblogs.asp.net/scottgu/archive/2006/11/26/tip-trick-how-to-register-user-controls-and-custom-controls-in-web-config.aspx:
<add tagPrefix="asp" namespace="AjaxControlToolkit" assembly="AjaxControlToolkit, Version=3.0.20820.16598, Culture=neutral, PublicKeyToken=28f01b0e84b6d53e" />
Now the control toolkit has been registered for sitewide use the controls and extenders can be added to any form on the site as if they were common web controls. For articles, the CalendarExtender, MaskEditExtender
, and MaskEditValidator
are used. The CalendarExtender
is used for the date related fields. The MaskEditExtender
is used to limit the input a user can make to a valid date format. The MaskEditValidator
enforces the data format on the client. If the user happens to enter something that does not match the mask, a error message is displayed, as has been done with the traditional validator controls since ASP.NET was introduced.
The following excerpt shows the use of the MaskEditExtendor, MaskEditValidator
, and CalendarExtender
to manage a datetime input on the AddEditArticle
page:
<tr> <td> Release Date : </td> <td> <asp:TextBox runat="server" ID="txtReleaseDate" Width="70" CssClass="formField"></asp:TextBox> <asp:Image runat="Server" ID="iReleaseDate" ImageUrl="~/images/Calendar.png" /><br /> <asp:CalendarExtender ID="CalendarExtender1" runat="server" TargetControlID="txtReleaseDate" PopupButtonID="iReleaseDate"> </asp:CalendarExtender> <asp:MaskedEditExtender ID="MaskedEditExtender1" runat="server" TargetControlID="txtReleaseDate" Mask="99/99/9999" MessageValidatorTip="true" OnFocusCssClass="MaskedEditFocus" OnInvalidCssClass="MaskedEditError" MaskType="Date" DisplayMoney="Left" AcceptNegative="Left" /> <asp:MaskedEditValidator ID="MaskedEditValidator1" runat="server" ControlExtender="MaskedEditExtender1" ControlToValidate="txtReleaseDate" IsValidEmpty="True" EmptyValueMessage="Date is required" InvalidValueMessage="Date is invalid" ValidationGroup="Demo1" Display="Dynamic" TooltipMessage="Input a Date" /> </td> </tr>
The declaration of the FCKeditor
shows the use of the ToolbarSet
property, which references the TheBeerHouse toolbar defined earlier in the JavaScript configuration file.
If the user decides to cancel the operation, the button does a response redirect to the ManageArticles.aspx
page. This is done by using a button
HTML element with the client-side OnClick
event handler loading the ManageArticles.aspx
page.
<button onclick ="window.location.assign('ManageArticles.aspx')," value="">Cancel</button>
The AddEditArticle
page allows Administrators, Editors, Contributors, and Posters to work with articles. However, if an article has been loaded, only site Administrators and Editors are allowed to perform this action. If an ArticleId
is passed through the QueryString
the BindArticle
method is called and the article's data is bound to the controls on the page. If the user does not belong to these roles, the page will throw a SecurityException
:
Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load If Not IsPostBack Then If Me.User.Identity.IsAuthenticated AndAlso _ (Me.User.IsInRole("Administrators") Or _ Me.User.IsInRole("Editors") Or _ Me.User.IsInRole("Contributors") Or _ Me.User.IsInRole("Posters")) Then txtBody.BasePath = Me.BaseUrl & "FCKeditor/" If ArticleId > 0 Then If Me.User.IsInRole("Administrators") Or _ Me.User.IsInRole("Editors") Then BindArticle() Else Throw New SecurityException( _ "You are not allowed to edit existent articles!") End If Else ClearItems() End If Else Throw New SecurityException( _ "You are not allowed to edit existent or insert new articles!") End If End If End Sub
If the page was loaded to create a new article the ClearItems
method is called, which sets all the input controls to either empty (TextBox
for example) or to a default selection (DropDownList
for example). If the user is not a member of any of the preceding groups, a SecurityException
is thrown, but most likely there are some other security issues with the site, since the Admin
section of the site is closed off to anyone not in these groups in the web.config
file!
The FCKeditor
requires another property, BasePath
, that points to the URL of the FCKeditor
folder that contains all its HTML, JavaScript, and image files. This is also done in the page load event handler.
The ClearItems
method is called when an article is being created. This clears all the TextBoxes
and sets other controls to their initial state. You should preselect the checkboxes that make the article listed and to allow comments. You should also select and enable the Approved checkbox, but only if the current user belongs to the Administrators or Editors roles. This can be done by setting a local Boolean variable to the result of checking the user Role membership. The status Literal
control should also have its text set to "Create a New Article" to describe what the user is doing:
Private Sub ClearItems() ltlArticleID.Text = String.Empty ltlAddedDate.Text = String.Empty ltlAddedBy.Text = String.Empty txtTitle.Text = String.Empty txtAbstract.Text = String.Empty txtBody.Value = String.Empty ddlState.ClearSelection() ddlCountry.ClearSelection() txtCity.Text = String.Empty txtReleaseDate.Text = String.Empty txtExpireDate.Text = String.Empty Dim bApprove As Boolean = (Me.User.IsInRole("Administrators") Or _ Me.User.IsInRole("Editors")) cbApproved.Checked = bApprove cbApproved.Enabled = bApprove cbListed.Checked = True cbCommentsEnabled.Checked = True cbOnlyForMembers.Checked = False ArticleHelper.BindCategoriesToListControl(ddlCategories, 0) ltlViewCount.Text = "0" ltlVotes.Text = "0" ltlRating.Text = "0" ltlUpdatedDate.Text = String.Empty ltlUpdateBy.Text = String.Empty ltlStatus.Text = "Create a New Article" End Sub
Notice the use of the ArticleHelper.BindCategoriesToListControl
method. This is a little helper method that is useful to the Articles
module for binding a list of categories to a DropDownList
control. This method accepts a ListControl
, which is the base class for several controls such as the DropDownList
, and a selectedId
or the CategoryId
to be the selected Category
. It then binds a list of active categories to the control and sets the selected value.
Public Shared Sub BindCategoriesToListControl(ByVal lCtrl As ListControl, ByVal selectedId As Integer) Using Categoryrpt As New CategoryRepository lCtrl.DataTextField = "Title" lCtrl.DataValueField = "CategoryID" lCtrl.DataSource = Categoryrpt.GetActiveCategories
lCtrl.DataBind() lCtrl.SelectedValue = selectedId End Using End Sub
The BindArticle
method binds the requested article, based on the ArticleId
to the controls on the page:
Private Sub BindArticle() Using Articlerpt As New ArticleRepository Dim lArticle As Article = Articlerpt.GetArticleById(ArticleId, False, False) If Not IsNothing(lArticle) Then ltlArticleID.Text = lArticle.ArticleID ltlAddedDate.Text = lArticle.AddedDate ltlAddedBy.Text = lArticle.AddedBy txtTitle.Text = lArticle.Title txtAbstract.Text = lArticle.Abstract txtKeywords.Text = lArticle.Keywords txtBody.Value = lArticle.Body ddlState.SelectedValue = lArticle.State ddlCountry.SelectedValue = lArticle.Country txtCity.Text = lArticle.City txtReleaseDate.Text = lArticle.ReleaseDate txtExpireDate.Text = lArticle.ExpireDate cbApproved.Checked = lArticle.Approved cbListed.Checked = lArticle.Listed cbCommentsEnabled.Checked = lArticle.CommentsEnabled cbOnlyForMembers.Checked = lArticle.OnlyForMembers ArticleHelper.BindCategoriesToListControl(ddlCategories, lArticle.CategoryId) ltlViewCount.Text = lArticle.ViewCount ltlVotes.Text = lArticle.Votes ltlRating.Text = lArticle.AverageRating ltlUpdatedDate.Text = lArticle.UpdatedDate ltlUpdateBy.Text = lArticle.UpdatedBy ltlStatus.Text = "Edit The Article." Else ArticleId = 0 ltlStatus.Text = "Create a New Article." End If End Using End Sub
The FileUploader.ascx
user control, located under the ~/Controls
folder, allows administrators and editors to upload a file (normally an image file) to the server and save it in their own private user-specific folder. Once the file is saved, the control displays the URL so that the editor can easily copy and paste it into the ImageUrl
field for a property, or reference the image file in the article's WYSIWYG editor. The markup code is simple — it just declares an instance of the FileUpload
control, a Submit button, and a couple of Labels
for the positive or negative feedback:
Upload a file: <asp:FileUpload ID="filUpload" runat="server" /> <asp:Button ID="btnUpload" runat="server" OnClick="btnUpload_Click" Text="Upload" CausesValidation="false" /><br /> <asp:Label ID="lblFeedbackOK" SkinID="FeedbackOK" runat="server"></asp:Label> <asp:Label ID="lblFeedbackKO" SkinID="FeedbackKO" runat="server"></asp:Label>
The file is saved in the code-behind's btnUpload_Click
event handler, into a user-specific folder under the ~/Uploads
folder. The actual saving is done by calling the SaveAs
method of the FileUpload
's PostedFile
object property. If the folder doesn't already exist, it is created by means of the System.IO.Directory.CreateDirectory
static method:
Protected Sub btnUpload_Click(ByVal sender As Object, ByVal e As System.EventArgs) Handles btnUpload.Click If Not filUpload.HasFile Then Try ' if not already present, create a directory ' names /uploads/{CurrentUserName} Dim dirUrl As String = CType(Me.Page, BasePage).BaseUrl + _ "Uploads/" + Me.Page.User.Identity.Name Dim dirPath As String = Server.MapPath(dirUrl) If Not Directory.Exists(dirPath) Then Directory.CreateDirectory(dirPath) End If ' save the file under the users's personal folder Dim fileUrl As String = dirUrl + "/" + _ Path.GetFileName(filUpload.PostedFile.FileName) filUpload.PostedFile.SaveAs(Server.MapPath(fileUrl)) lblFeedbackOK.Visible = True lblFeedbackOK.Text = "File successfully uploaded: " + fileUrl Catch ex As Exception lblFeedbackKO.Visible = True lblFeedbackKO.Text = ex.Message End Try End If End Sub
To register this control on a page, you write the following directive at the top of the page (for example, the ManageCategories.aspx
page):
<%@ Register Src="~/Controls/FileUploader.ascx" TagName=" FileUploader" TagPrefix="mb" %>
And use this tag to create an instance of the control:
<mb:FileUploader ID="FileUploader1" runat="server" />
The ManageComments.aspx
page is located under the ~/Admin/
folder, and it displays all comments of all articles, from the newest to the oldest, and allows an administrator or an editor to moderate the feedback by editing or deleting comments that may not be considered suitable. The page uses a pagable ListView
for displaying the comments. Figure 5-10 shows this page.
I won't cover the code for this page in detail because it's similar to other code that's already been discussed. The pagination for the ListView
is implemented the same way as the pagination in the ArticleListing
user control. You can refer to the downloadable code for the complete implementation.
There are a couple of visual differences from the majority of other admin list interfaces to note. The edit column is titled Approve because that is all you can do. I chose not to make this an inline process with AJAX because the comment itself is not displayed and I want to force the editor to read the comment before making the decision to ban it. The delete column is also absent for this same reason. I chose not to display comments in the grid because sometimes they can be rather long for this type of interface. Finally, the last column displays a graphic to indicate if the comment is approved or not. The following code shows the ListView
, wrapped in an UpdatePanel
. To view the full code, open the source file in Visual Studio.
<asp:UpdatePanel runat="server" ID="uppnlComments"> <ContentTemplate> <asp:ListView ID="lvComments" runat="server"> <LayoutTemplate> ... </LayoutTemplate> <EmptyDataTemplate> ... </EmptyDataTemplate> <ItemTemplate> ... </ItemTemplate> <AlternatingItemTemplate> ... </AlternatingItemTemplate> </asp:ListView> <div class="pager"> ... </div> </ContentTemplate> </asp:UpdatePanel>
While the title of the AddEditComment.aspx
page is slightly misleading, I wanted to keep a consistent naming convention for the details page. This page (see Figure 5-11) allows only administrators to approve or delete comments.
The only actionable control on the page is the Approved checkbox. This can either be true or false and is set from the value stored in the database. The Delete function flips the Active bit to false and also reports the comment to Akismet. This was covered in the section on the CommentRepository
earlier, so check that out for more details.
ShowCategories.aspx
is the first end-user page of this module, located in the site's root folder. Its only purpose is to display the article categories in a nice format, so that the reader can easily and clearly understand what the various categories are about and quickly jump to their content by clicking on the category's title. Figure 5-12 shows this page.
The list of categories is implemented as a ListView
using grouping, with two columns (with GroupItemCount
set to 2
). We don't need pagination, sorting, or editing features here, so a simple ListView
with support for repeated columns is adequate. Following is the code for the ListView
:
<asp:UpdatePanel runat="server" ID="uppnlArticles"> <ContentTemplate> <asp:ListView ID="lvCategories" runat="server" DataKeyField="CategoryID" GroupItemCount="2"> <LayoutTemplate> <table id="tblCategories" runat="server" cellspacing="0" cellpadding="6" style="width: 100%;"> <tr runat="server" id="groupPlaceholder" /> </table> <div class="pager"> <asp:DataPager ID="pagerBottom" runat="server" PageSize="4" PagedControlID="lvCategories"> <Fields> <asp:NextPreviousPagerField ButtonCssClass="command" FirstPageText="«" PreviousPageText="<"
RenderDisabledButtonsAsLabels="true" ShowFirstPageButton="true" ShowPreviousPageButton="true" ShowLastPageButton="false" ShowNextPageButton="false" /> <asp:NumericPagerField ButtonCount="7" NumericButtonCssClass="command" CurrentPageLabelCssClass="current" NextPreviousButtonCssClass="command" /> <asp:NextPreviousPagerField ButtonCssClass="command" LastPageText="«" NextPageText=">" RenderDisabledButtonsAsLabels="true" ShowFirstPageButton="false" ShowPreviousPageButton="false" ShowLastPageButton="true" ShowNextPageButton="true" /> </Fields> </asp:DataPager> </div> </LayoutTemplate> <GroupTemplate> <tr runat="server" id="CategoriesRow" style="background-color: #FFFFFF"> <td runat="server" id="itemPlaceholder" /> </tr> </GroupTemplate> <ItemTemplate> <td style="width: 1px"> <asp:HyperLink ID="lnkCatImage" runat="server" NavigateUrl='<%# "BrowseArticles.aspx?CategoryID=" & Eval("CategoryID") %>'> <asp:Image ID="imgCategory" runat="server" BorderWidth="0px" AlternateText='<%# Eval("Title") %>' ImageUrl='<%# Eval("ImageUrl") %>' /></asp:HyperLink> </td> <td> <div class="sectionsubtitle"> <a class="articletitle" href='<%# SEOFriendlyURL( _ Path.Combine("Category", Eval("Title")), ".aspx") %>'> <%# httputility.HTMLEncode(Eval("Title")) %></a> <br /> <asp:Literal ID="lblDescription" runat="server" Text='<%# Eval("Description") %>'></asp:Literal> <a class="articletitle" href='<%# SEOFriendlyURL( _ Path.Combine("Category", Eval("Title")), ".rss") %>'> <img src="Images/rss.gif" alt="Get the Rss for this category" style="border-width: 0px;" /></a> </td> </ItemTemplate> </asp:ListView> </ContentTemplate> </asp:UpdatePanel>
The category's image and title both link to the BrowseArticles.aspx
page, with a CategoryID
parameter on the querystring equal to the CategoryID
of the clicked row. If you look at the code, you will notice that a direct URL is not formulated, but rather there is a search engine friendly URL for both the BrowseArticles
page and the corresponding RSS feed. The code-behind for this page contains nothing new to review.
BrowseArticles.aspx
is the end-user version of the ManageArticles.aspx
page presented earlier. It shows only published content instead of all content, but otherwise it's the same as ManageArticles.aspx;
it just declares an instance of the shared ArticleListing
user control:
<mb:ArticleListing id="ArticleListing1" runat="server" PublishedOnly="True" />
Figure 5-13 represents a screenshot of the page. Note that the page is available to all users, but because the current user is anonymous and the two articles listed on the page have their OnlyForMembers
property set to false
, the key image is shown next to them. If the user clicks the article's title, she will be redirected to the login page.
The ShowArticle.aspx
end-user page outputs the article's entire text and all its other information (author, release date, average rating, number of views, etc.). It also lets the user rate the article (from one to five glasses of beer) and to submit comments. All comments are listed in chronological order, on a single page, so that it's easy to follow the discussion. When the page is loaded by an editor or an administrator, some additional buttons to delete, approve, and edit the article are also visible. Figure 5-14 shows a screenshot of the page as seen by an end user.
The Administrative links are displayed if the user is a site Administrator or an Editor. The Edit link is a HyperLink
control that points to the AddEditArticle.aspx
page in the site's Admin folder. The Approve and Delete links are LinkButton
controls that post back to the server and perform the associated task. In the case of the Approve link, it is only displayed if the article has not been approved yet.
If Me.User.Identity.IsAuthenticated AndAlso _ (Me.User.IsInRole("Administrators") Or _ Me.User.IsInRole("Editors")) Then hlnkEdit.NavigateUrl = String.Format("~/Admin/AddEditArticle.aspx?ArticcleId={0}", ArticleId) hlnkEdit.Visible = True lbtnApprove.Visible = IIf(lArticle.Approved, False, True) lbtnDelete.Visible = True End If
Approving and deleting an article are handled by using an ArticleRepository
and calling the associated members. When an article is deleted, the user is redirected to the ShowCategories.aspx
page.
Private Sub lbtnDelete_Click(ByVal sender As Object, ByVal e As System.EventArgs) Handles lbtnDelete.Click Using lArticlerpt As New ArticleRepository lArticlerpt.DeleteArticle(ArticleId) Response.Redirect("~/ShowCategories.aspx") End Using End Sub Private Sub lbtnApprove_Click(ByVal sender As Object, ByVal e As System.EventArgs) Handles lbtnApprove.Click Using lArticlerpt As New ArticleRepository lArticlerpt.ApproveArticle(ArticleId) lbtnApprove.Visible = False End Using End Sub
Another control available in the ASP.NET AJAX Control Toolkit is the Rating
control. It provides a much improved way to collect and display ratings for articles. In the case of the Beer House, I decided to use beer mugs as a visual indicator of how readers liked the article. Again, since the Control Toolkit has been registered in the web.config
file, the Rating
control can be added just like any other control using the asp
prefix. I did wrap it up in an update panel to make the rating a seamless process:
<asp:UpdatePanel ID="UpdatePanel1" runat="server"> <ContentTemplate>Rate This Article:<br /> <asp:Rating ID="ArticleRating" runat="server" BehaviorID="RatingBehavior1" CssClass="ArticleRating" StarCssClass="ratingStar" WaitingStarCssClass="savedRatingStar" FilledStarCssClass="filledRatingStar" EmptyStarCssClass="emptyRatingStar"> </asp:Rating> </ContentTemplate> </asp:UpdatePanel>
The Rating
control displays images based on the style assigned to corresponding properties of the control. An empty value is the StarCssClass
, and a filled value is the FilledStarCssClass
. These are defined in the stylesheet.
/* Rating */ .ArticleRating { float: right; margin:3px 75px 10px 10px; } .ratingStar { font-size: 0pt; width: 19px; height: 19px;
margin: 0px; padding: 0px; cursor: pointer; display: block; background-repeat: no-repeat; } .filledRatingStar { background-image: url(Images/filledbeer.gif); } .emptyRatingStar { background-image: url(Images/EmptyBeer.gif); } .savedRatingStar { background-image: url(Images/SavedBeer.gif); }
The current rating of the article is set in the BindArticle
method by setting the Rating
Control's CurrentRating
property to the Rating
property of the Article
entity. Remember, the Rating
property is one of the immutable properties we added to the Article
entity in the custom partial class. The Rating
control has a Tag
property, which I use to hold the ArticleId
being rated.
Using Articlerpt As New ArticleRepository Dim lArticle As Article = Articlerpt.GetArticleById(ArticleId) ArticleRating.Tag = ArticleId ArticleRating.CurrentRating = lArticle.AverageRating End Using
When a user clicks the Rating
control to select a rating for the article, it is quietly posted back to the server using the magical AJAX features of the UpdatePanel
. The Rating
control's Change
event handler is handled by the page and passes the value of the vote to the database by calling the RateArticle
member of the ArticleRepository
.
Private Sub ArticleRating_Changed(ByVal sender As Object, ByVal e As AjaxControlToolkit.RatingEventArgs) Handles ArticleRating.Changed Using Articlerpt As New ArticleRepository Articlerpt.RateArticle(e.Tag, e.Value) End Using End Sub
Allowing visitors to comment on articles is a great way to increase community activity around the Beer House. Doing this with AJAX is even cooler. The comments for an article are displayed in a ListView
, wrapped in an UpdatePanel
. This is nothing new based on what I have already reviewed. Below the comment list is a form, so the visitor can make a comment. This entire form is wrapped in a DIV, called "dComment"
. There are actually three nested DIVs contained within the fieldset element that wraps around the form. The first DIV contains the Input
elements, including a button to submit the comment.
<div id="dComment"> <fieldset id="commentform"> <legend>Leave a Comment</legend> <div id="dMakeComment"> <div> <label for="txtTitle"> Title</label><em>(required)</em></div> <div> <input id="txtTitle" type="text" value="re: <% =GetTitle()%>" size="40" /></div> <div> <label for="txtEmail"> Your E-Mail</label> <em>(will be kept private)</em></div> <div> <input id="txtEmail" type="text" /></div> <div> <label for="txtURL"> Your URL</label> <em>(optional)</em></div> <div> <input id="txtURL" type="text" /></div> <div> <label for="txtComment"> Comments</label> <em>(required)</em><span id="sCommentReq" style="color: Red; visibility: hidden;">*</span></div> <div> <textarea rows="5" cols="25" id="txtComment"></textarea></div> <button name="btnStoreComment" id="btnStoreComment" onclick="StoreComment(); return false;"> Submit Me!</button> </div> <div id="dThinking"> Thinking....</div> <div id="dCommentPosted"> Your Comment has been submitted, check back later to see it listed....</div> </fieldset> </div>
The latter two DIVs are initially hidden from view because they will be displayed when the user submits their comment and when the response from the server indicating the comment has been posted is complete. These DIVs are hidden by a combination of CSS and JavaScript. When the page is loaded, it calls the SetPage
JavaScript function, defined in the TBHComment.js
file. This is added to the Body
tag's Onload
event in the Page Init
event handler. Since the page uses a set of nested master pages a little magic needs to be done by casting the Master
property to the correct type where the actual Body
tag is located. The Body
tag has to be declared as a server-side control by adding runat=server
and assigning it an ID, <body runat="server" id="pageBody">
.
Private Sub Page_PreInit(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.PreInit Dim mp As TBHMain = CType(CType(Me.Master, CRMaster).Master, TBHMain) mp.pageBodyTag.Attributes.Add("onload", "SetPage();") End Sub
In the ShowArticle
page a ScriptManagerProxy
has to be added along with a reference to each the JavaScript file with the client-side code to execute the storing of the comments and the web service:
<asp:ScriptManagerProxy ID="ScriptManagerProxy1" runat="server"> <Services> <asp:ServiceReference Path="~/CommentService.asmx" InlineScript="true" /> </Services> <Scripts> <asp:ScriptReference Path="~/TBHComments.js" /> </Scripts> </asp:ScriptManagerProxy>
The submit button calls a JavaScript function, StoreComment
, that makes an asynchronous call to a web service that stores the comment:
var dThinking = $get('dThinking'), var dCommentPosted = $get('dCommentPosted'), if (dCommentPosted != null) { HideElement('dCommentPosted'), HideElement('dThinking'), } function SetPage() { if ($get('dResults') != null) { HideElement('dResults'), } } function StoreComment() { HideElement('dMakeComment'), ShowElement('dThinking'), var ArticleId = $get('ArticleId'), var eMail = $get('txtEmail'), var Title = $get('txtTitle'),
var Comment = $get('txtComment'), var URL = $get('txtURL'), if (eMail.value.length > 0 && Comment.value.length > 0 && Title.value.length > 0) { var objCat = new TheBeerHouse.BLL.Articles.Comment(); objCat.ArticleId = ArticleId.value; objCat.addedByEmail = eMail.value; objCat.Title = Title.value objCat.Body = Comment.value; objCat.CommenterURL = URL.value; objCat.AddedByIP = UserIP.value; objCat.AddedBy = eMail.value; objCat.UpdatedBy = eMail.value; // alert('calling the Service.'), TBH_Web35.CommentService.PostComment(objCat, StoreCommentCompleteEvent, StoreCommentErrorEvent); // alert('Called the Service.'), } else { alert('Just not a valid form.'), } return false; } function StoreCommentCompleteEvent(result, context) { HideElement('dThinking'), ShowElement('dCommentPosted'), } function StoreCommentErrorEvent(result, context) { alert('It blew up!'), if (null != result) { alert(result.get_stackTrace()); } } function ShowElement(ElementName) { $get(ElementName).style.display = ''; } function ShowBlockElement(ElementName) { var a = $get(ElementName);
a.style.display = 'block'; a.style.overflow = 'auto'; a.style.height = 'auto'; } function HideElement(ElementName) { $get(ElementName).style.display = 'none'; } function calcTaxes() { HideElement('dIntro'), ShowBlockElement('dResults'), return false; }
The StoreComment
function hides the dMakeComment
DIV and displays the dThinking
DIV while the comment is being stored. Next, it grabs references to each of the Input
elements in the form and sets those values to JavaScript version of the Comment
class after it does some basic validation of the data. Remember, the comment has to have a title, the person's e-mail address, and of course the comment. The user's IP address along with the ArticleId
are held in Hidden
input fields. These fields are accessed by the JavaScript and added to the Comment
object before it is posted to the server. Next, the PostComment
method of the CommentService
is called, passing the Comment
and the names of the functions to be called when the operation is complete or an error occurs.
When the web service completes and the comment has been posted, the dCommentPosted
DIV is displayed and the dThinking
DIV is hidden. This does not completely end the comment process; the comment still needs to be approved by an administrator before it will be displayed to the world.
The CommentService
is a normal web service, except that it has a couple of AJAX-related attributes set, ScriptService
at the class level and ScriptMethod
at the method level. ASP.NET AJAX now knows how to communicate with the web service. The PostComment
service itself calls the AddComment
method of the CommentRepository
and passes the comment provided by the client-side code.
Imports System.Web Imports System.Web.Services Imports System.Web.Services.Protocols Imports System.Web.Script.Services Imports TheBeerHouse.BLL.Articles ' To allow this Web Service to be called from script, using ASP.NET AJAX, uncomment the following line. ' <System.Web.Script.Services.ScriptService()> _ <WebService(Namespace:="http://tempuri.org/")> _ <WebServiceBinding(ConformsTo:=WsiProfiles.BasicProfile1_1)> _ <ScriptService()> _ <Global.Microsoft.VisualBasic.CompilerServices.DesignerGenerated()> _ Public Class CommentService Inherits System.Web.Services.WebService <WebMethod()> _ <ScriptMethod()> _ Public Function PostComment(ByVal vComment As Comment) As Boolean Dim bRet As Boolean = False
vComment.AddedByIP = HttpContext.Current.Request.UserHostAddress Using Commentrps As New CommentRepository bRet = Commentrps.AddComment(vComment) End Using Return bRet End Function End Class
When the page loads, it reads the ArticleID
parameter from the querystring. This is managed in the ShowArticle.vb
code-behind file. The ArticleID
parameter must be specified; otherwise, the page will throw an exception because it doesn't know which article to load. Since we are using the site map infrastructure described in Chapter 3, the URLRewriting
module handles this for us by exchanging the friendly URL for the direct URL needed to display the article properly.
After an Article
object has been loaded for the specified article, you must also confirm that the article is currently published (it is not a future or retired article) and that the current user can read it (if the OnlyForMembers
property is true
, they have to be logged in). You must check these conditions because a cheating user might try to enter an ArticleID
in the URL manually, even if the article isn't listed in the BrowseArticles.aspx
page. Of course, this type of hacking is one of the main reasons we are using search engine friendly URLs. If everything is OK, the article's view count is incremented, and Labels
and other controls on the page are filled with the article's data:
Private Sub ShowArticle_PreLoad(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.PreLoad If Not IsPostBack Then BindArticle() End If End Sub Private Sub BindArticle() Using Articlerpt As New ArticleRepository Dim lArticle As Article = Articlerpt.GetArticleById(ArticleId) ArticleRating.Tag = ArticleId Title = lArticle.Title lblLocation.Visible = (lArticle.Location.Length > 0) lblLocation.Text = String.Format(lblLocation.Text, lArticle.Location) lblViews.Text = String.Format(lblViews.Text, lArticle.ViewCount) ArticleRating.CurrentRating = lArticle.AverageRating ArticleDetails = lArticle lArticle.ViewCount += 1 Articlerpt.UpdateArticle(lArticle) End Using End Sub
Some of the value are not bound directly to controls on the page. I did this to demonstrate how to call methods or properties defined in the page directly in the page's markup. The use of Literal
and Label
controls is not always necessary and can add to the page's overhead and weight. The thinner the better from a performance and search engine placement perspective. So, use this technique to keep your pages a little thinner.
As discussed in the "Design" section of this chapter, the RSSFeed
handler composes and returns a valid RSS XML document composed of articles in the Beer House site. In the "Design" section of this chapter, you saw the schema of a valid RSS 2.0 document. HttpHandlers
are composed of two members, IsReusable
and ProcessRequest
.
The IsReusable
property is a Boolean that indicates to the ASP.NET framework if the request can be used by more than one request at a time. Typically, you want to return false since the content of most handlers is dynamic and each request needs to be sent down a different path. For an RSS feed, it could be set to True
because the content being returned is the same and most likely does not change that often.
The ProcessRequest
method is automatically called by the framework and accepts a reference to the HttpContext
being used to process the request. The HttpContext
object provides access to the current HttpRequest
and HttpResponse
objects, needed to evaluate what was being requested and to send content to the client.
Public ReadOnly Property Response() As System.Web.HttpResponse Get Return BaseContext.Response End Get End Property Public ReadOnly Property Request() As System.Web.HttpRequest Get Return BaseContext.Request End Get End Property
Typically, I create properties to wrap the context, request, and response objects and refactor the worker code to a method that is called by the ProcessRequest
method. In the RSSFeed
handler The BaseContext
property is set to the context parameter. This is then used by the Request
and Response
properties to return the associated objects to read and write. Finally the CreateRSSFeed
method is called to actually produce the RSS feed.
Public Sub ProcessRequest(ByVal context As System.Web.HttpContext) Implements System.Web.IHttpHandler.ProcessRequest BaseContext = context CreateRSSFeed() End Sub
The CreateRSSFeed
member reads the list of published articles from the database and produces the RSS feed, an XML document. This is where one of the major differences between VB.NET and C# comes into play, XML Literals (http://msdn.microsoft.com/en-us/library/bb384629.aspx
). For the most part, there is little to no difference between the two dominant languages in the .NET world, and by that I mean capabilities that one has the other does not have. For the most part, the languages are differentiated by their syntax. VB has the Dim statement, and C# has a plethora of ;'s and case sensitivity. XML Literals
are one clear advantage that VB.NET has over other languages and, as is typical of the Visual Basic experience, it is designed to make development faster.
Simply put, XML Literals
allow you to integrate XML directly into your VB.NET code as a natural part of the syntax. For the RSS feed, an XML template can be added directly to the code and logic placed inside the XML to build an XML document.
Private Sub CreateRSSFeed() Response.ContentType = "application/xml" Using lArticlectx As New ArticleRepository Dim Settings As TheBeerHouseSection = Helpers.Settings Dim xRss As XDocument = <?xml version="1.0" encoding="utf-8"?> <rss version="2.0" xmlns:geo= "http://www.w3.org/2003/01/geo/wgs84_pos#"> <channel> <title>The Beer House Articles</title> <link>http://www.TheBeerHouseBook.com/</link> <description>RSS Feed containing The Beer House News Articles.</description> <docs>http://www.rssboard.org/rss-specification</docs> <image> <link>http://www.TheBeerHouseBook.com/</link> <title>The Beer House Articles</title> <url>http://www.TheBeerHouseBook.com /Images/tbh-logo.png</url> </image> <%= From lArticle In lArticlectx.GetRSSArticles .AsEnumerable _ Select <item> <title><%= lArticle.Title %></title> <link><%= SEOFriendlyURL( _ Path.Combine(Settings.Articles .URLIndicator, lArticle.Title)) %></link> <description><%= lArticle.Abstract %></description> </item> %> </channel>
</rss> Response.Write(xRss.ToString) End Using Response.Flush() Response.End() End Sub
The Response
's ContentType
property is not set to "text/xml"
. This is necessary for the browser to correctly recognize the output as an XML document. The XDocument
's ToString
method is called to get an XML string that is sent to the client by calling the Response.Write
method. Finally, the Response.Flush
and Response.End
methods are called to ensure that any content remaining in the buffer is flushed to the client and to close off any more content from being sent.
An XDocument
object, xRSS
, is created by setting it to the XML template. Embedded within the XML template is a LINQ query that retrieves Article
entities as an Enumerable
by calling the AsEnumerable
operator. The AsEnumerable
operator changes the list of objects to an IEnumerable(Of T)
, which the XML Literal
can traverse through. This is done with a special method in the ArticleRepository
class, GetRSSArticles
.
Public Function GetRSSArticles() As IEnumerable(Of Article) Dim key As String = String.Format(CacheKey) If EnableCaching AndAlso Not IsNothing(Cache(key)) Then Return CType(Cache(key), List(Of Article)) End If Dim lArticles As IEnumerable(Of Article) = (From lArticle In Articlesctx.Articles _ Where lArticle.Active = True And lArticle.Approved = True And _ lArticle.Listed = True And lArticle.ReleaseDate < Now() And _ lArticle.ExpireDate > Now() _ Order By lArticle.ReleaseDate Descending).AsEnumerable If EnableCaching Then CacheData(key, lArticles) End If Return lArticles End Function
The RSSFeed
handler uses the Title
and Abstract
fields to populate the RSS feed. The <link>
element of the feed is built with a helper method, SEOFriendlyURL
and the Path.Combine
method. Path.Combine
takes two strings related to a path, which can be a URL or a UNC file name, and combines them with the proper / or separation. I have found this method to be extremely helpful when composing file paths. The SEOFriendlyURL
method accepts a URLIndicator
and the article title. It then combines and massages these values to produce a search engine friendly URL that contains the article's title. The result can be seen in Figure 5-15, which shows FireFox consuming the feed.
Also notice the URLs are fully composed and not relative. This is because the RSS feed will be consumed outside the Beer House's website and, thus, need the full URL to be included in the <link>
elements.
The final piece of code for this chapter's module is the RssReader
user control. In the ascx
file, you define a ListView
that displays the title and description of the bound RSS items and makes the title a link pointing to the full article. It also has a header that will be set to the channel's title, a graphical link pointing to the source RSS file, and a link at the bottom that points to a page with more content:
<%@ Control Language="vb" AutoEventWireup="false" CodeBehind="RSSReader.ascx.vb" Inherits="TBH_Web35.RSSReader" %> <asp:ListView runat="server" ID="lvRSSReader" ItemPlaceholderID="itemPlaceHolder"> <LayoutTemplate> <dl runat="server" id="itemPlaceHolder"> </dl> </LayoutTemplate> <ItemTemplate> <dt><a href="<%#Eval("Url")%>"> <%#Eval("Title")%></a> </dt> <dd> <%#Eval("description")%> <a href="<%#Eval("Url")%>">...</a></dd> </ItemTemplate> </asp:ListView>
The first part of the RssReader.ascx.cs
code-behind file defines all the custom properties defined in the "Design" section that are used to make this user control a generic RSS reader, and not specific to our site's content and settings:
Public Partial Class RSSReader Inherits System.Web.UI.UserControl Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load If Not IsPostBack Then BindData() End If End Sub Private Sub BindData() Trace.Write(Helpers.FormatUrl("TheBeerHouse.rss")) Dim rssFeed As XDocument = XDocument.Load(Helpers.FormatUrl("TheBeerHouse.rss")) Dim rssItems = (From rss In rssFeed.<rss>.<channel>.<item> _ Select New With {.Title = rss.<title>.Value, _ .Url = rss.<link>.Value, _ .description = rss.<description>.Value}).Take(5).ToList lvRSSReader.DataSource = rssItems lvRSSReader.DataBind() End Sub End Class
You don't need to persist the property values in the ControlState
here as we did in previous controls because all properties wrap a property of some other server-side control, and it will be that other control's job to take care of persisting the values. All the real work of loading and binding the data is done in BindData
method. With XML literals, a simple LINQ statement can be used over the RSS source to retrieve the items and bind them to the ListView
.
<mb:RssReader id="RssReader1" runat="server" Title="Latest Articles" RssUrl="~/GetArticlesRss.aspx" MoreText="More articles..." MoreUrl="~/BrowseArticles.aspx" />
The output is shown in Figure 5-16.
Many security checks have been implemented programmatically from the page's code-behind. Now you need to edit the Admin
folder's web.config
file to add the Contributors role to the list of allowed roles for the AddEditArticle.aspx
page:
<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0"> <system.web> <authorization> <allow roles="Administrators,Editors" /> <deny users="*" /> </authorization> </system.web> <location path="AddEditArticle.aspx"> <system.web> <authorization> <allow roles="Administrators,Editors,Contributors" /> <deny users="*" /> </authorization> </system.web> </location> <!-- ManageUsers.aspx and EditUser.aspx pages... --> </configuration>
This chapter showed you how to build a complex and articulate module to completely manage the site's articles and announcements. It covered all of the following:
An administrative section for managing the data in the database.
Pages for browsing the published content.
Integration with the built-in membership system to secure the module and track the authors of the articles.
A syndication service that publishes a RSS feed of recent content for a specific category, or for every category, by means of an ASP.NET page.
A generic user control that consumes any RSS feed. It has been used in this chapter to list the new articles on the home page, but you could also use it in the forums module, and in other situations.
By following along in this chapter, you've seen some of the powerful things you can do with the ListView
control, the Entity Framework, and a properly designed business logic layer (BLL).
This system is flexible enough to be utilized in many real-world applications, but you can also consider making some of the following improvements:
Support multilevel categories (subcategories management).
A search engine could be added to the public section of the modules. Currently, when users want to find a particular article, they have to go through all the content (which could fill several pages in the article list). You could add a Search box that searches for the specified words in the selected category, or in all the categories, and with further options.
Extend the ShowArticle.aspx
page, or create a separate page that outputs a printer-friendly version of the article, that is, the article without the site's layout (header, menus, footer, left- and right-hand columns). This could be done easily by adding a new stylesheet to the page (when the page is loaded with a PrinterFriendly=1
parameter on the querystring) that hides some DIVs (use the visibility:hidden
style).
Create a web service that allows other applications to retrieve the list of articles as an alternative to the RSS feed. This could also be used by Contributors to submit new articles, or even by Administrators and Editors to perform their duties. You could use the Microsoft Web Service Extensions (WSE) to make authentication much easier and even to support encryption — otherwise, you'd have to pass credentials in the SOAP headers, and encryption would only be possible by using SSL.
In the next chapter, you'll work on a module for creating, managing, displaying, and archiving opinion polls, to implement a form of user-to-site communication.