Index: project.inc
===================================================================
RCS file: /cvs/drupal-contrib/contributions/modules/project/project.inc,v
retrieving revision 1.125
diff -u -p -u -p -r1.125 project.inc
--- project.inc	1 Oct 2008 23:24:05 -0000	1.125
+++ project.inc	23 Oct 2008 11:48:58 -0000
@@ -185,7 +185,7 @@ function project_project_validate(&$node
 
     // Make sure uri isn't already in use, or reserved.  Includes all X from
     // project/issues/X paths used in project_issues module
-    $reserved_names = array('user', 'issues', 'releases', 'rss', 'subscribe-mail', 'search', 'add', 'update_project', 'statistics', 'comments', 'autocomplete', 'cvs', 'developers');
+    $reserved_names = array('user', 'issues', 'releases', 'rss', 'subscribe-mail', 'search', 'add', 'update_project', 'statistics', 'comments', 'autocomplete', 'cvs', 'developers', 'usage');
     if (project_use_taxonomy()) {
       $terms = taxonomy_get_tree(_project_get_vid());
       foreach ($terms as $i => $term) {
Index: project.module
===================================================================
RCS file: /cvs/drupal-contrib/contributions/modules/project/project.module,v
retrieving revision 1.317
diff -u -p -u -p -r1.317 project.module
--- project.module	1 Aug 2008 22:36:51 -0000	1.317
+++ project.module	23 Oct 2008 11:48:59 -0000
@@ -1210,3 +1210,24 @@ function project_use_cvs($project) {
   }
 }
 
+/**
+ * Determines if the current site supports caching of project-related pages.
+ *
+ * The pages can be cached if:
+ *   1. Anonymous users have the 'access projects' permission.
+ *   2. No node access modules are installed.
+ *
+ * @return TRUE if the output can be cached, FALSE otherwise.
+ */
+function project_can_cache() {
+  $grants = module_implements('node_grants');
+  if (!empty($grants)) {
+    return FALSE;
+  }
+  $allowed_roles = user_roles(FALSE, 'access projects');
+  if (!isset($allowed_roles[DRUPAL_ANONYMOUS_RID])) {
+    return FALSE;
+  }
+
+  return TRUE;
+}
cvs diff: Diffing usage
Index: usage/README.txt
===================================================================
RCS file: /cvs/drupal-contrib/contributions/modules/project/usage/README.txt,v
retrieving revision 1.1
diff -u -p -u -p -r1.1 README.txt
--- usage/README.txt	7 Aug 2007 20:21:33 -0000	1.1
+++ usage/README.txt	23 Oct 2008 11:48:59 -0000
@@ -7,9 +7,11 @@ updates about available releases.
 
 For background, see http://drupal.org/node/128827
 
+Note that the module currently requires mysql 5.x or greater in order to
+display usage statistics.
+
 This module was written by:
 - Andrew Morton (http://drupal.org/user/34869) "drewish"
 - Derek Wright (http://drupal.org/user/46549) "dww"
 
 $Id: README.txt,v 1.1 2007/08/07 20:21:33 dww Exp $
-$Name:  $
Index: usage/project_usage.css
===================================================================
RCS file: /cvs/drupal-contrib/contributions/modules/project/usage/project_usage.css,v
retrieving revision 1.1
diff -u -p -u -p -r1.1 project_usage.css
--- usage/project_usage.css	14 Sep 2007 16:17:55 -0000	1.1
+++ usage/project_usage.css	23 Oct 2008 11:48:59 -0000
@@ -1,10 +1,7 @@
 /* $Id: project_usage.css,v 1.1 2007/09/14 16:17:55 dww Exp $ */
 
-th.project-usage-week {
+th.project-usage-numbers, th.project-usage-numbers a, 
+td.project-usage-numbers, td.project-usage-numbers a {
   text-align: right;
+  padding: 0 .1em 0 .5em;
 }
-
-td.project-usage-numbers {
-  text-align: right;
-}
-
Index: usage/project_usage.install
===================================================================
RCS file: /cvs/drupal-contrib/contributions/modules/project/usage/project_usage.install,v
retrieving revision 1.5
diff -u -p -u -p -r1.5 project_usage.install
--- usage/project_usage.install	10 Oct 2008 19:16:35 -0000	1.5
+++ usage/project_usage.install	23 Oct 2008 11:48:59 -0000
@@ -40,6 +40,15 @@ function project_usage_install() {
           count int unsigned NOT NULL default '0',
           PRIMARY KEY (nid, timestamp)
         ) /*!40100 DEFAULT CHARACTER SET utf8 */;");
+      db_query("CREATE TABLE IF NOT EXISTS {cache_project_usage} (
+          cid varchar(255) NOT NULL default '',
+          data longblob,
+          expire int NOT NULL default '0',
+          created int NOT NULL default '0',
+          headers text,
+          PRIMARY KEY (cid),
+          INDEX expire (expire)
+      ) /*!40100 DEFAULT CHARACTER SET UTF8 */;");
       break;
   }
 }
@@ -50,6 +59,7 @@ function project_usage_uninstall() {
     'project_usage_day',
     'project_usage_week_project',
     'project_usage_week_release',
+    'cache_project_usage',
   );
   foreach ($tables as $table) {
     if (db_table_exists($table)) {
@@ -63,6 +73,8 @@ function project_usage_uninstall() {
     'project_usage_life_daily',
     'project_usage_life_weekly_project',
     'project_usage_life_weekly_release',
+    'project_usage_date_long',
+    'project_usage_date_short',
   );
   foreach ($variables as $variable) {
     variable_del($variable);
@@ -116,3 +128,25 @@ function project_usage_update_5001() {
   }
   return $ret;
 }
+
+/**
+ * Add a cache table {cache_project_usage}.
+ */
+function project_usage_update_5002() {
+  $ret = array();
+  switch ($GLOBALS['db_type']) {
+    case 'mysql':
+    case 'mysqli':
+      $ret[] = update_sql("CREATE TABLE IF NOT EXISTS {cache_project_usage} (
+        cid varchar(255) NOT NULL default '',
+        data longblob,
+        expire int NOT NULL default '0',
+        created int NOT NULL default '0',
+        headers text,
+        PRIMARY KEY (cid),
+        INDEX expire (expire)
+      ) /*!40100 DEFAULT CHARACTER SET UTF8 */;");
+      break;
+  }
+  return $ret;
+}
Index: usage/project_usage.module
===================================================================
RCS file: /cvs/drupal-contrib/contributions/modules/project/usage/project_usage.module,v
retrieving revision 1.7
diff -u -p -u -p -r1.7 project_usage.module
--- usage/project_usage.module	11 Jun 2008 02:50:24 -0000	1.7
+++ usage/project_usage.module	23 Oct 2008 11:49:00 -0000
@@ -30,6 +30,18 @@ define('PROJECT_USAGE_WEEK', PROJECT_USA
 define('PROJECT_USAGE_YEAR', PROJECT_USAGE_DAY * 365);
 
 /**
+ * Date formats for month and day. We define our own rather than using core's
+ * 'date_format_short' and 'date_format_long' variables because our timestamps
+ * don't have hour or minute resolution so displaying that would be confusing
+ * and take up extra space.
+ */
+define('PROJECT_USAGE_DATE_LONG', 'F jS');
+define('PROJECT_USAGE_DATE_SHORT','M j');
+
+// How many weeks should be shown in the usage pages?
+define('PROJECT_USAGE_SHOW_WEEKS', 6);
+
+/**
  * Implementation of hook_menu().
  */
 function project_usage_menu($may_cache) {
@@ -43,11 +55,349 @@ function project_usage_menu($may_cache) 
       'description' => t('Configure how long usage data is retained.'),
       'weight' => 1,
     );
+    $items[] = array(
+      'path' => 'project/usage',
+      'title' => t('Project usage'),
+      'callback' => 'project_usage_dispatch',
+      'access' => user_access('view project usage'),
+    );
   }
   return $items;
 }
 
 /**
+ * Implementation of hook_help().
+ */
+function project_usage_help($section) {
+  switch ($section) {
+    case 'project/usage':
+      return t('The following is a summary of the usage information for the projects on this site. The count is the total number of sites using any version of the project. Only sites that have opted to allow usage information to be tracked are included.');
+  }
+}
+
+/**
+ * Implementation of hook_perm().
+ */
+function project_usage_perm() {
+  return array(
+    'view project usage',
+  );
+}
+
+/**
+ * Menu handler for project URLs.
+ *
+ * @param $key
+ *   Optional node id or project uri. NULL gets the overview page, project
+ *   nids and uris get the project usage page, release nids get the release
+ *   usage page, and everything else gets a not found. In addition, if a user
+ *   does not have permission to view the project or release node they've
+ *   requested, they get an access denied page.
+ */
+function project_usage_dispatch($key = NULL) {
+  if (!isset($key)) {
+    return project_usage_overview();
+  }
+
+  // Load the node the user has requested.  We want to only use
+  // project_project_retrieve() if the $key parameter is not numeric because
+  // project_project_retrieve() will only return a project_project node, and
+  // we want to allow $node to also be a project_release node.
+  if (is_numeric($key)) {
+    $node = node_load($key);
+  }
+  else {
+    $node = project_project_retrieve($key);
+  }
+
+  if (!empty($node->nid)) {
+    // Make sure that the user has the permission to view this project/
+    // project_release node.
+    if (node_access('view', $node)) {
+      if ($node->type == 'project_project') {
+        return project_usage_project_page($node);
+      }
+      if ($node->type == 'project_release') {
+        return project_usage_release_page($node);
+      }
+    }
+    else {
+      return drupal_access_denied();
+    }
+  }
+  return drupal_not_found();
+}
+
+/**
+ * Display an overview of usage for all modules.
+ */
+function project_usage_overview() {
+  drupal_add_css(drupal_get_path('module', 'project_usage') .'/project_usage.css');
+  drupal_set_title(t('Project usage overview'));
+
+  $week_count = PROJECT_USAGE_SHOW_WEEKS;
+
+  // In order to get the project usage data into a sortable table, we've gotta
+  // write a pretty evil query:
+  //
+  // - We need to create a separate column for each week to allow sorting by
+  //   usage in any week (the tablesort_sql() requires that anything you can
+  //   sort on has a distinct field in the underlying query). However, some
+  //   weeks may not have any usage data, forcing us to use a LEFT JOIN,
+  //   rather than the more efficient INNER JOIN.
+  // - The LEFT JOINs mean we have to limit the entries in {node} so that
+  //   we're not including things like forum posts, hence the WHERE IN below.
+  // - Each project may have multiple records in {project_usage_week_project}
+  //   to track usage for API version. We need to SUM() them to get a total
+  //   count forcing us to GROUP BY. Sadly, I can't explain why we need
+  //   SUM(DISTINCT)... it just works(TM).
+  $sql_elements = project_empty_query();
+  // Ignore the order_bys generated by project_empty_query(), and use
+  // the tablesort instead.
+  unset($sql_elements['order_bys']);
+
+  $where_args = array();
+  $sql_elements['fields']['pieces'] = array('n.nid', 'n.title');
+  $sql_elements['from']['pieces'][] = '{node} n ';
+  $sql_elements['wheres']['pieces'] = array('n.nid IN (SELECT nid FROM {project_usage_week_project}) AND n.status = %d');
+  $where_args[] = 1;  // n.status = 1
+  $sql_elements['group_bys']['pieces'] = array('n.nid', 'n.title');
+  $headers = array(array('field' => 'n.title', 'data' => t('Project')));
+
+  $joins_args = array();
+  foreach (project_usage_get_last_weeks($week_count) as $i => $week) {
+    // Note that "{$i}" in these query snippets are used to add a week integer
+    // to the table and field aliases so we can uniquely identify each column
+    // for sorting purposes.  These are not literals in the query, we need
+    // these aliases to be unique via this PHP string operation before we even
+    // build the query.
+    $sql_elements['fields']['pieces'][] = "SUM(DISTINCT p{$i}.count) AS week{$i}";
+    $sql_elements['joins']['pieces'][] = "LEFT JOIN {project_usage_week_project} p{$i} ON n.nid = p{$i}.nid AND p{$i}.timestamp = %d";
+    $joins_args[] = $week;
+
+    $header = array(
+      'field' => "week{$i}",
+      'data' => format_date($week, 'custom', variable_get('project_usage_date_short', PROJECT_USAGE_DATE_SHORT), 0),
+      'class' => 'project-usage-numbers'
+    );
+    if ($i == 0) {
+      $header['sort'] = 'desc';
+    }
+    $headers[] = $header;
+  }
+
+  // Check for a cached page. The cache id needs to take into account the sort
+  // column and order.
+  $sort = tablesort_init($headers);
+  $cid = 'overview:'. $sort['sql'] .':'. $sort['sort'];
+  if (project_can_cache() && $cached = cache_get($cid, 'cache_project_usage')) {
+    return $cached->data;
+  }
+
+  $args = array_merge($joins_args, $where_args);
+  $result = db_query(project_build_query($sql_elements) . tablesort_sql($headers), $args);
+
+  while ($line = db_fetch_array($result)) {
+    $row = array(array('data' => l($line['title'], 'project/usage/'. $line['nid'])));
+    for ($i = 0; $i < $week_count; $i++) {
+      $row[] = array('data' => number_format($line["week{$i}"]), 'class' => 'project-usage-numbers');
+    }
+    $rows[] = $row;
+  }
+  $output = theme('table', $headers, $rows);
+
+  // Cache the completed page.
+  if (project_can_cache()) {
+    cache_set($cid, 'cache_project_usage', $output, project_usage_cache_time());
+  }
+
+  return $output;
+}
+
+/**
+ * Display the usage history of a project node.
+ */
+function project_usage_project_page($node) {
+  drupal_add_css(drupal_get_path('module', 'project_usage') .'/project_usage.css');
+  $breadcrumb = array(
+    l(t('Usage'), 'project/usage'),
+  );
+  project_project_set_breadcrumb(NULL, $breadcrumb);
+  drupal_set_title(l($node->title, 'project/'. $node->uri));
+
+  // In order to keep the database load down we need to cache these pages.
+  // Because the release usage table is sortable, the cache id needs to take
+  // into account the sort parameters. The easiest way to ensure we have valid
+  // sorting parameters is to build the table headers and let the tablesort
+  // functions do it. This means we end up doing most of the work to build the
+  // page's second table early on. We might as well finish the job, then build
+  // the other table and output them in the correct order.
+
+  $week_count = PROJECT_USAGE_SHOW_WEEKS;
+  $releases = project_release_get_releases($node, FALSE);
+
+  // If there are no releases for this project, we can skip the rest
+  // of this function.
+  if (empty($releases)) {
+    return theme('project_usage_project_page_no_releases');
+  }
+
+  // Build a table showing this week's usage for each release. In order to get
+  // the release usage data into a sortable table, we need another evil query:
+  // - We need to create a separate column for each week to allow sorting by
+  //   usage in any week (the tablesort_sql() requires that anything you can
+  //   sort on has a distinct field in the underlying query). However, some
+  //   weeks may not have any usage data, forcing us to use a LEFT JOIN,
+  //   rather than the more efficient INNER JOIN.
+  // - We need to create a column for each week but some weeks may not have any
+  //   usage data, forcing us to use a LEFT JOIN, rather than the more
+  //   efficient INNER JOIN.
+  // - The LEFT JOINs mean we have to limit the entries in {node} so that we're
+  //   not including things like forum posts, hence the WHERE IN below.
+  $sql_elements = project_empty_query();
+  // Ignore the order_bys generated by project_empty_query(), and use
+  // the tablesort instead.
+  unset($sql_elements['order_bys']);
+
+  $sql_elements['fields']['pieces'] = array('n.nid');
+  $sql_elements['from']['pieces'][] = '{node} n ';
+  $sql_elements['wheres']['pieces'] = array('n.nid IN ('. implode(', ', array_fill(0, count($releases), '%d')) .') AND n.status = %d');
+  $where_args = array_keys($releases);
+  $where_args[] = 1;  // n.status = 1
+
+  $release_header = array(array('field' => 'n.title', 'data' => t('Release'), 'sort' => 'desc'));
+
+  $joins_args = array();
+  foreach (project_usage_get_last_weeks($week_count) as $i => $week) {
+    // Note that "{$i}" in these query snippets are used to add a week integer
+    // to the table and field aliases so we can uniquely identify each column
+    // for sorting purposes.  These are not literals in the query, we need
+    // these aliases to be unique via this PHP string operation before we even
+    // build the query.
+    $sql_elements['fields']['pieces'][] = "p{$i}.count AS week{$i}";
+    $sql_elements['joins']['pieces'][] = "LEFT JOIN {project_usage_week_release} p{$i} ON n.nid = p{$i}.nid AND p{$i}.timestamp = %d";
+    $joins_args[] = $week;
+
+    $release_header[] = array(
+      'field' => "week{$i}",
+      'data' => format_date($week, 'custom', variable_get('project_usage_date_short', PROJECT_USAGE_DATE_SHORT), 0),
+      'class' => 'project-usage-numbers',
+    );
+  }
+
+  // Check for a cached page. The cache id needs to take into account the sort
+  // column and order.
+  $sort = tablesort_init($release_header);
+  $cid = 'project:'. $node->nid .':'. $sort['sql'] .':'. $sort['sort'];
+  if ($cached = cache_get($cid, 'cache_project_usage')) {
+    return $cached->data;
+  }
+
+  $args = array_merge($joins_args, $where_args);
+  $result = db_query(project_build_query($sql_elements) . tablesort_sql($release_header), $args);
+
+  $release_rows = array();
+  while ($line = db_fetch_array($result)) {
+    $row = array(array('data' => l($releases[$line['nid']], 'project/usage/'. $line['nid'])));
+    for ($i = 0; $i < $week_count; $i++) {
+      $row[] = array('data' => number_format($line["week{$i}"]), 'class' => 'project-usage-numbers');
+    }
+    $release_rows[] = $row;
+  }
+
+  // Build a table of the weekly usage data with a column for each API version.
+  // Get an array of the weeks going back as far as we have data...
+  $oldest = db_result(db_query("SELECT MIN(puwp.timestamp) FROM {project_usage_week_project} puwp WHERE puwp.nid = %d", $node->nid));
+  if ($oldest === NULL) {
+    $weeks = array();
+  }
+  else {
+    $weeks = project_usage_get_weeks_since($oldest);
+    // ...ignore the current week, since we won't have usage data for that and
+    // reverse the order so it's newest to oldest.
+    array_pop($weeks);
+    $weeks = array_reverse($weeks);
+  }
+
+  // The number of columns varies depending on how many different API versions
+  // are in use. Set up the header and a blank, template row, based on the
+  // number of distinct terms in use. This *could* be done with LEFT JOINs,
+  // but it ends up being a more expensive query and harder to read.
+  $project_header = array(0 => array('data' => t('Week')));
+  $blank_row = array(0 => array('data' => ''));
+  $result = db_query("SELECT DISTINCT td.tid, td.name FROM {project_usage_week_project} p INNER JOIN {term_data} td ON p.tid = td.tid WHERE p.nid = %d ORDER BY td.weight, td.name", $node->nid);
+  while ($row = db_fetch_object($result)) {
+    $project_header[$row->tid] = array('data' => check_plain($row->name), 'class' => 'project-usage-numbers');
+    $blank_row[$row->tid] = array('data' => 0, 'class' => 'project-usage-numbers');
+  }
+
+  // Now create a blank table with a row for each week and formatted date in
+  // the first column...
+  $project_rows = array();
+  foreach ($weeks as $week) {
+    $project_rows[$week] = $blank_row;
+    $project_rows[$week][0]['data'] = format_date($week, 'custom', variable_get('project_usage_date_long', PROJECT_USAGE_DATE_LONG), 0);
+  }
+
+  // ...then fill it in with our data.
+  $result = db_query("SELECT timestamp, tid, count FROM {project_usage_week_project} WHERE nid = %d", $node->nid);
+  while ($row = db_fetch_object($result)) {
+    $project_rows[$row->timestamp][$row->tid]['data'] = number_format($row->count);
+  }
+
+  $output = theme('project_usage_project_page', $release_header, $release_rows, $project_header, $project_rows);
+
+  // Cache the completed page.
+  if (project_can_cache()) {
+    cache_set($cid, 'cache_project_usage', $output, project_usage_cache_time());
+  }
+
+  return $output;
+}
+
+/**
+ * Display the usage history of a release node.
+ */
+function project_usage_release_page($node) {
+  drupal_add_css(drupal_get_path('module', 'project_usage') .'/project_usage.css');
+  $project = node_load($node->pid);
+  $breadcrumb = array(
+    l(t('Usage'), 'project/usage'),
+    l($project->title, 'project/usage/'. $project->nid),
+  );
+  project_project_set_breadcrumb(NULL, $breadcrumb);
+  drupal_set_title(l($node->title, 'node/'. $node->nid));
+
+  // Check for a cached page.
+  $cid = "release:{$node->nid}";
+  if (project_can_cache() && $cached = cache_get($cid, 'cache_project_usage')) {
+    return $cached->data;
+  }
+
+  // Table displaying the usage back through time.
+  $header = array(
+    array('data' => t('Week starting')),
+    array('data' => t('Count'), 'class' => 'project-usage-numbers'),
+  );
+  $rows = array();
+  $query = db_query("SELECT timestamp, count FROM {project_usage_week_release} WHERE nid = %d ORDER BY timestamp DESC", $node->nid);
+  while ($row = db_fetch_object($query)) {
+    $rows[] = array(
+      array('data' => format_date($row->timestamp, 'custom', variable_get('project_usage_date_long', PROJECT_USAGE_DATE_LONG), 0)),
+      array('data' => number_format($row->count), 'class' => 'project-usage-numbers'),
+    );
+  }
+  $output = theme('project_usage_release_page', $header, $rows);
+
+  // Cache the completed page.
+  if (project_can_cache()) {
+    cache_set($cid, 'cache_project_usage', $output, project_usage_cache_time());
+  }
+
+  return $output;
+}
+
+/**
  * Module settings form.
  */
 function project_usage_settings_form() {
@@ -107,6 +457,9 @@ function project_usage_cron() {
     project_usage_process_weekly($last_weekly);
     variable_set('project_usage_last_weekly', $now);
   }
+
+  // Wipe the cache of all expired usage pages.
+  cache_clear_all(NULL, 'cache_project_usage');
 }
 
 /**
@@ -225,6 +578,7 @@ function project_usage_gmgetdate($timest
  * @param $days_ago
  *   An integer specifying a number of days previous. A value of 0 indicates
  *   the current day.
+ *
  * @return
  *   GMT UNIX timestamp.
  */
@@ -242,6 +596,7 @@ function project_usage_daily_timestamp($
  * @param $weeks_ago
  *   An integer specifying a number of weeks previous. A value of 0 indicates
  *   the current week.
+ *
  * @return
  *   GMT UNIX timestamp.
  */
@@ -280,6 +635,25 @@ function project_usage_get_weeks_since($
 }
 
 /**
+ * Return an array of the N previous weeks for which we'll have data.
+ *
+ * @param $count
+ *   Number of weeks to return.
+ *
+ * @return
+ *   An array of UNIX timestamps sorted newest to oldest. Will not include
+ *   the current week.
+ */
+function project_usage_get_last_weeks($count = PROJECT_USAGE_SHOW_WEEKS) {
+  $weeks = project_usage_get_weeks_since(time() - ($count * PROJECT_USAGE_WEEK));
+  // Use array_pop() to remove the current week as we won't have usage data
+  // for it.
+  array_pop($weeks);
+  // Reverse the order so it's newest to oldest.
+  return array_reverse($weeks);
+}
+
+/**
 * Implementation of hook_simpletest().
 */
 function project_usage_simpletest() {
@@ -287,3 +661,57 @@ function project_usage_simpletest() {
   $tests = file_scan_directory($dir, '\.test$');
   return array_keys($tests);
 }
+
+/**
+ * Implementation of hook_devel_caches().
+ *
+ * Lets the devel module know about our cache table so it can clear it.
+ */
+function project_usage_devel_caches() {
+  return array('cache_project_usage');
+}
+
+/**
+ * Sets the expiry timestamp for cached project usage pages.
+ *
+ * Default is 24 hours.
+ *
+ * @return The UNIX timestamp to expire the page at.
+ */
+function project_usage_cache_time() {
+  return time() + variable_get('project_usage_cache_length', 86400);
+}
+
+/**
+ * Theme the output of the project/usage/<project> page when there are no
+ * published releases for a project that the user has access to view.
+ */
+function theme_project_usage_project_page_no_releases() {
+  return t('There are no releases for this project.');
+}
+
+/**
+ * Theme the output of project/usage/<project> page.
+ */
+function theme_project_usage_project_page($release_header, $release_rows, $project_header, $project_rows) {
+  $output = '';
+  $output .= '<h3>'. t('Recent release usage') .'</h3>';
+  $output .= theme('table', $release_header, $release_rows);
+  $output .= '<h3>'. t('Weekly project usage') .'</h3>';
+  $output .= theme('table', $project_header, $project_rows);
+  return $output;
+}
+
+/**
+ * Theme the output of the project/usage/<release nid> page.
+ */
+function theme_project_usage_release_page($header, $rows) {
+  // If there is no usage information for a release, don't just
+  // display an empty usage table.
+  if (empty($rows)) {
+    return t('There is no usage information for this release.');
+  }
+  else {
+    return theme('table', $header, $rows);
+  }
+}
cvs diff: Diffing usage/po
cvs diff: Diffing usage/tests
