Building a Personalized Portfolio Experience in Drupal 7

Wireframes, content model, and sample code I used to create a personalized portfolio tour to offer potential employers or partners a quick view into relevant material.

Happy Holidays, folks!

I use my portfolio website as an archive of all projects so that high-resolution images and descriptions are organized and readily available. Much of this content is either not publicly listed or is specifically restricted from anonymous traffic. This means a lot of content is ready to present to special guests, but the experience would be more like digging through a card catalog than reviewing a portfolio in person. An in-person review usually involves an introduction and a short curated list of pieces to show with relevant talking points about them.

I wanted to prototype as much of that experience as possible in this little holiday side project.

The “Portfolio Tour” as a content type


I identified a few fields:

  • Title: Guest or company name, etc…
  • Teaser Description: Every node on my site has a short descriptive sentence under the title; continue that trend for consistency. In this case, I’ll use it like an objective line on a resume. Why am I showing you this / what I’m going to show you.
  • Masthead Image: Also a site staple, an image above the content area. Probably a relevant cityscape or building, or significant portfolio item.
  • Introduction: The node body, use as a short cover letter.
  • Portfolio Selections: A repeatable field collection containing:
    • Portfolio Item: Entity reference to project or article node
    • Description: To provide any relevant point about the project and its relevance to this tour
  • Conclusion: Another text field for a closing. Keep it short!

This format closely resembles my existing site components, so theming the front-end is simple. (Theming of Field Collections is a lot easier by using the “Fields Only” formatter now included.)

When considering access restriction, honestly content like this could simply be left unlisted. But where’s the fun in that? Also, since some of my content is already not accessible publicly, I forged ahead and extended that node access check to restrict this node type. See those permissions below.


User assignments

I added an Entity Reference field to user accounts to store a relationship to an assigned tour. This assignment lets me easily surface links and redirects to the specific tour. Also, multiple guests could be assigned to the same tour but log in separately.


My project-specific module provides a few extra permissions using hook_permission(), each is fairly self-explanatory.

  1. View private projects
  2. View assigned portfolio tour
  3. View any portfolio tours
  4. Administer portfolio tour assignments

1 and 2 are assigned to a “portfolio visitors” role. 3 and 4 are reserved for admin and/or future use, but are used to help keep user_access() checks easy to read.

Allowing users to access the tour easily

To remove any barriers to entry, I opted to allow guests to jump straight to their assigned tour using a personalized “welcome” link. Yes, that’s right, a URL-based authentication. That’s a big security concern in most situations. Set your permissions for “Portfolio Visitor” role appropriately as well as those for “Authenticated User” since each user qualifies for those permissions as well. This is a user experience decision, and is totally unnecessary. Giving users a username, password, and login URL isn’t difficult; this is to provide something special, if you’re willing to consider the security concerns.

Start by defining the URL in hook_menu() for the callback to handle visitor authentication. I’m using a URL structure like this:

 * Implements hook_menu()
function tsc_menu() {
  return array(
    'welcome/%/%' => array(
      'page callback' => '_tsc_user_welcome_login',
      'page arguments' => array(1, 2),
      'access callback' => TRUE, // Anonymous traffic needs access.

Then in the page callback function, I perform several checks and ultimately log the user in programmatically. At any point of failure, I use drupal_goto() to send the visitor to the home page. This also halts further execution, acting as a return or die() statement, so they don’t get logged in inadvertently.

function _tsc_user_welcome_login($username, $access_token) {
  // This menu hook shouldn't be called without these
  // components, but if we don't receive one, go home.
  if (empty($username) || empty($access_token)) {

  // Confirm account exists and load.
  $tentative_user = user_load_by_name(check_plain($username));

  // Does user exist?
  if (empty($tentative_user)) { drupal_goto(); }

  // Does user have tour assignment?
  $tour = field_get_items('user', $tentative_user, 'field_assigned_portfolio_tour');
  $tour = (!empty($tour)) ? reset($tour) : false;
  if ( !isset($tour['target_id']) ) { drupal_goto(); }

  // For the time being, the "access token" is just the NID
  // of the tour assignment. Check for that.
  // @TODO: Something better would be good.
  if ($access_token != $tour['target_id']) { drupal_goto(); }

  // Run full node_access check for the portfolio tour:
  if (!node_access('view', node_load($tour['target_id']), $tentative_user)) {

  // Now we "know" we have a valid $user, a valid $tour['target_id'], the
  // given user can view that tour, and we've checked our laughable
  // token. Officially log in the user and redirect to that tour.
  global $user;
  $user = $tentative_user;

  $edit = array(
    // This would be the $form_state of a submitted user_login form.
    // hook_user_login() recieves it, but a lot of it can't be faked.
    'name' => $user->name,
    'uid' => $user->uid,

  // Goto the assigned tour

Ultimately, there are a couple ways to log a user in programmatically. Using user_login_finalize() ensures that all hook_user_login() functions fire, and an auth message is written to watchdog. Simply setting the global $user value and using drupal_session_regenerate() also works, but skips those parts.

Returning to the tour easily

Once the user begins his or her exploration, I wanted an item in the main menu to return to the tour.

A very simple solution: move the My Account link in the Core-provided User Menu (which I don’t use) into the Main Menu. It is only shown to authenticated users and can be relabeled. “Welcome” seemed appropriate, and it is placed next to “Home.”


This link goes to user_view at /user, so next, I need to direct users from there to the appropriate tour. Panels makes this easy. Using a Panels display variant, these portfolio visitors can be redirected using a simple set of selection rules and contexts that lead to an “HTTP Status Code” variant:

Selection Rules

  • User: compare User being used is the Logged in user. Make sure the user is trying to reach his or her own profile, which happens when using the My Account link or if he or she ever visits /user manually.
  • User: permission User being viewed has “View assigned portfolio tour” Make sure this user can see a tour.
  • Node: accessible User being viewed can view Assigned Portfolio Tour This is a Relationship added in the Contexts page of the variant setup. It simply provides a full node object lifted from the field_assigned_portfolio_tour field.

The redirect rule is a simple 301 Redirect to node/%tour:nid. (The tour identifier defaults to node and is configured when the Relationship is established. Your pick.)

Lastly, I’ll add two custom Ctools content types (custom panes) on the tour node: one for yours truly to see the links to give to my guests, and one for guests so they know their way back (as well as inform them that they must log in that way). Building custom panes is easy; my friend/co-worker at Four Kitchens, Ian Carrico, explains all about it in Creating Custom Panels Panes on the 4K blog.

My admin pane:


Guest pane:


Both panes take in a node context (node being viewed); the guest pane also takes a user context (logged in user). These contexts are added by including the following in the $plugin array in the .inc file that Ian described:

'required context' => array(
  new ctools_context_required(t('Node'), 'node'),
  new ctools_context_required(t('User'), 'user'),

The admin pane uses an EntityFieldQuery to locate any users whose field_assigned_portfolio_tour match the node being viewed:

// Build an EntityFieldQuery to look for user(s) assigned to this node
$query = new EntityFieldQuery;
$result = $query
  ->entityCondition('entity_type', 'user')

// Did we see any?
if ( empty($result['user']) ) {
  $block->content =
    "<p>No users are assigned to this tour.</p>";
  return $block;

// Load full user objects
$users = user_load_multiple(array_keys($result['user']));

The function then builds the unordered list of usernames linked to their profiles and the customized login link each should use using the same code the guest pane uses.

The guest pane is more simple. It pulls the user’s name and the node’s id, and makes the link:

$tour = field_get_items('user', $user_context, 'field_assigned_portfolio_tour');
$tour = (!empty($tour)) ? reset($tour) : false;

// Check if the user's tour target is this node before printing.
// If not, bail invisibly.
if ($tour['target_id'] != $node_context->nid) return;

global $base_root; // Get our hostname/installation location

$link = preg_replace('/(http:\/\/|www\.)/', '', "{$base_root}/welcome/{$user_context->name}/{$node_context->nid}");

So that’s it

I declare Drupal Victory with my little prototype feature. The minimum requirement was met with the Portfolio Tour node type and the assignment field on users. The permissions work, the “welcome” link in the Main Menu, the easy-login URL, and the link display panes provide a more pleasant experience. In the future, additional user-activity tracking could be added to see if/when guests log in, what they look at, or other analytics that might be useful in keeping the conversation going.