diff core/whos_online.py @ 581:ee87ea74d46b

For Django 1.4, rearranged project structure for new manage.py.
author Brian Neal <bgneal@gmail.com>
date Sat, 05 May 2012 17:10:48 -0500
parents gpp/core/whos_online.py@f72ace06658a
children 2f5779e9d8f8
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/core/whos_online.py	Sat May 05 17:10:48 2012 -0500
@@ -0,0 +1,239 @@
+"""
+This module keeps track of who is online. We maintain records for both
+authenticated users ("users") and non-authenticated visitors ("visitors").
+"""
+import datetime
+import logging
+import time
+
+import redis
+
+from core.services import get_redis_connection
+from core.models import Statistic
+
+
+# Users and visitors each have a sorted set in a Redis database. When a user or
+# visitor is seen, the respective set is updated with the score of the current
+# time. Periodically we remove elements by score (time) to stale out members.
+
+# Redis key names:
+USER_SET_KEY = "whos_online:users"
+VISITOR_SET_KEY = "whos_online:visitors"
+
+CORE_STATS_KEY = "core:stats"
+
+# the period over which we collect who's online stats:
+MAX_AGE = datetime.timedelta(minutes=15)
+
+
+# Logging: we don't want a Redis malfunction to bring down the site. So we
+# catch all Redis exceptions, log them, and press on.
+logger = logging.getLogger(__name__)
+
+
+def _get_connection():
+    """
+    Create and return a Redis connection. Returns None on failure.
+    """
+    try:
+        conn = get_redis_connection()
+        return conn
+    except redis.RedisError, e:
+        logger.error(e)
+
+    return None
+
+
+def to_timestamp(dt):
+    """
+    Turn the supplied datetime object into a UNIX timestamp integer.
+
+    """
+    return int(time.mktime(dt.timetuple()))
+
+
+def _zadd(key, member):
+    """
+    Adds the member to the given set key, using the current time as the score.
+
+    """
+    conn = _get_connection()
+    if conn:
+        ts = to_timestamp(datetime.datetime.now())
+        try:
+            conn.zadd(key, ts, member)
+        except redis.RedisError, e:
+            logger.error(e)
+
+
+def _zrangebyscore(key):
+    """
+    Performs a zrangebyscore operation on the set given by key.
+    The minimum score will be a timestap equal to the current time
+    minus MAX_AGE. The maximum score will be a timestap equal to the
+    current time.
+
+    """
+    conn = _get_connection()
+    if conn:
+        now = datetime.datetime.now()
+        min = to_timestamp(now - MAX_AGE)
+        max = to_timestamp(now)
+        try:
+            return conn.zrangebyscore(key, min, max)
+        except redis.RedisError, e:
+            logger.error(e)
+
+    return []
+
+
+def report_user(username):
+    """
+    Call this function when a user has been seen. The username will be added to
+    the set of users online.
+
+    """
+    _zadd(USER_SET_KEY, username)
+
+
+def report_visitor(ip):
+    """
+    Call this function when a visitor has been seen. The IP address will be
+    added to the set of visitors online.
+
+    """
+    _zadd(VISITOR_SET_KEY, ip)
+
+
+def get_users_online():
+    """
+    Returns a list of user names from the user set.
+    sets.
+    """
+    return _zrangebyscore(USER_SET_KEY)
+
+
+def get_visitors_online():
+    """
+    Returns a list of visitor IP addresses from the visitor set.
+    """
+    return _zrangebyscore(VISITOR_SET_KEY)
+
+
+def _tick(conn):
+    """
+    Call this function to "age out" the sets by removing old users/visitors.
+    It then returns a tuple of the form:
+        (zcard users, zcard visitors)
+
+    """
+    cutoff = to_timestamp(datetime.datetime.now() - MAX_AGE)
+
+    try:
+        pipeline = conn.pipeline(transaction=False)
+        pipeline.zremrangebyscore(USER_SET_KEY, 0, cutoff)
+        pipeline.zremrangebyscore(VISITOR_SET_KEY, 0, cutoff)
+        pipeline.zcard(USER_SET_KEY)
+        pipeline.zcard(VISITOR_SET_KEY)
+        result = pipeline.execute()
+    except redis.RedisError, e:
+        logger.error(e)
+        return 0, 0
+
+    return result[2], result[3]
+
+
+def max_users():
+    """
+    Run this function periodically to clean out the sets and to compute our max
+    users and max visitors statistics.
+
+    """
+    conn = _get_connection()
+    if not conn:
+        return
+
+    num_users, num_visitors = _tick(conn)
+    now = datetime.datetime.now()
+
+    stats = get_stats(conn)
+    update = False
+
+    if stats is None:
+        stats = Statistic(id=1,
+                max_users=num_users,
+                max_users_date=now,
+                max_anon_users=num_visitors,
+                max_anon_users_date=now)
+        update = True
+    else:
+        if num_users > stats.max_users:
+            stats.max_users = num_users
+            stats.max_users_date = now
+            update = True
+
+        if num_visitors > stats.max_anon_users:
+            stats.max_anon_users = num_visitors
+            stats.max_anon_users_date = now
+            update = True
+
+    if update:
+        _save_stats_to_redis(conn, stats)
+        stats.save()
+
+
+def get_stats(conn=None):
+    """
+    This function retrieves the who's online max user stats out of Redis. If
+    the keys do not exist in Redis, we fall back to the database. If the stats
+    are not available, None is returned.
+    Note that if we can find stats data, it will be returned as a Statistic
+    object.
+
+    """
+    if conn is None:
+        conn = _get_connection()
+
+    stats = None
+    if conn:
+        try:
+            stats = conn.hgetall(CORE_STATS_KEY)
+        except redis.RedisError, e:
+            logger.error(e)
+
+        if stats:
+            return Statistic(
+                id=1,
+                max_users=stats['max_users'],
+                max_users_date=datetime.datetime.fromtimestamp(
+                    float(stats['max_users_date'])),
+                max_anon_users=stats['max_anon_users'],
+                max_anon_users_date=datetime.datetime.fromtimestamp(
+                    float(stats['max_anon_users_date'])))
+
+    try:
+        stats = Statistic.objects.get(pk=1)
+    except Statistic.DoesNotExist:
+        return None
+    else:
+        _save_stats_to_redis(conn, stats)
+        return stats
+
+
+def _save_stats_to_redis(conn, stats):
+    """
+    Saves the statistics to Redis. A TTL is put on the key to prevent Redis and
+    the database from becoming out of sync.
+
+    """
+    fields = dict(
+        max_users=stats.max_users,
+        max_users_date=to_timestamp(stats.max_users_date),
+        max_anon_users=stats.max_anon_users,
+        max_anon_users_date=to_timestamp(stats.max_anon_users_date))
+
+    try:
+        conn.hmset(CORE_STATS_KEY, fields)
+        conn.expire(CORE_STATS_KEY, 4 * 60 * 60)
+    except redis.RedisError, e:
+        logger.error(e)