Permalink
Browse files

notifications system

implement delayed job to handle notifications asynchronously
new notifications are displayed to the user no more than 20 seconds after they happen
notification center is split into read and unread sections. unread being a popup in the top right, and general activity displayed on the homepage
first incarnation of notifications (from events) to be refactored into a ActiveRecord mixin
  • Loading branch information...
1 parent 9fd83c9 commit b6ca07e97c2974fc2b563ca5bb8c90f556a5ca83 @agrobbin committed Oct 22, 2012
View
@@ -1,5 +1,7 @@
source 'https://rubygems.org'
+gem 'daemons', '1.1.9'
+gem 'delayed_job_active_record', '0.3.3'
gem 'exception_notification', '3.0.0'
gem 'google-api', path: '/Users/alex/Rails/gems/google-api'
gem 'haml-rails', '0.3.5'
View
@@ -37,21 +37,27 @@ GEM
multi_json (~> 1.0)
addressable (2.3.2)
arel (3.0.2)
- builder (3.0.3)
+ builder (3.0.4)
capistrano (2.13.4)
highline
net-scp (>= 1.0.0)
net-sftp (>= 2.0.0)
net-ssh (>= 2.0.14)
net-ssh-gateway (>= 1.1.0)
- cocaine (0.4.0)
+ cocaine (0.4.2)
coffee-rails (3.2.2)
coffee-script (>= 2.2.0)
railties (~> 3.2.0)
coffee-script (2.2.0)
coffee-script-source
execjs
coffee-script-source (1.3.3)
+ daemons (1.1.9)
+ delayed_job (3.0.3)
+ activesupport (~> 3.0)
+ delayed_job_active_record (0.3.3)
+ activerecord (>= 2.1.0, < 4)
+ delayed_job (~> 3.0)
diff-lcs (1.1.3)
erubis (2.7.0)
eventmachine (1.0.0)
@@ -80,7 +86,6 @@ GEM
jquery-rails (2.1.3)
railties (>= 3.1.0, < 5.0)
thor (~> 0.14)
- jruby-pageant (1.1.1)
json (1.7.5)
jwt (0.1.5)
multi_json (>= 1.0)
@@ -105,8 +110,7 @@ GEM
net-ssh (>= 1.99.1)
net-sftp (2.0.5)
net-ssh (>= 2.0.9)
- net-ssh (2.6.0)
- jruby-pageant (>= 1.1.1)
+ net-ssh (2.6.1)
net-ssh-gateway (1.1.0)
net-ssh (>= 1.99.1)
oauth2 (0.8.0)
@@ -225,6 +229,8 @@ PLATFORMS
DEPENDENCIES
capistrano (= 2.13.4)
coffee-rails (= 3.2.2)
+ daemons (= 1.1.9)
+ delayed_job_active_record (= 0.3.3)
exception_notification (= 3.0.0)
google-api!
haml-rails (= 0.3.5)
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
@@ -0,0 +1,83 @@
+class Notification
+ @_refreshInterval: 20
+ @_displayFor: 5
+ @_items: []
+ @_timeout: 0
+ @_lastRequest: null
+
+ @get: ->
+ $.getJSON '/notifications',
+ since: Notification._lastRequest.toISOString()
+ , (data) ->
+ for item in data
+ Notification._items.push item
+ if Notification._items.length > 0
+ Notification.show()
+ Notification.update_count data.length
+ else
+ Notification.start()
+
+ @update_count: (count) ->
+ notification_number = $('a#notification-count span')
+ if count is 0
+ new_count = 0
+ else
+ new_count = +$('a#notification-count span').text() + count
+ notification_number.fadeOut ->
+ notification_number.text(new_count).fadeIn()
+ notification_number.parent()[(if new_count > 0 then 'addClass' else 'removeClass')]('new');
+
+ @show: ->
+ new Notification(@_items[0])
+
+ @start: ->
+ @_lastRequest = new Date()
+ setTimeout @get, @_refreshInterval * 1000
+
+ constructor: (@data) ->
+ @show()
+
+ object: ->
+ $("#notification-#{@data.id}")
+
+ top_offset: ->
+ @object().height() * -1
+
+ show: ->
+ item = @
+ notification = $("<a class='notification-item' />").addClass(@data.css_class.toLowerCase()).attr('id', "notification-#{@data.id}").attr('href', @data.url)
+ notification.addClass "modal" if @data.modal
+ notification.append($("<span/>").html(@data.text))
+ $('#notifications').prepend notification
+ @object().show().css('top', @top_offset()).animate
+ top: 0
+ opacity: 1
+ 'slow'
+ ->
+ Notification._timeout = setTimeout (->
+ item.hide()
+ ), Notification._displayFor * 1000
+ item.object().mouseenter ->
+ clearTimeout Notification._timeout
+ Notification.update_count(-1)
+ $.ajax
+ url: "/notifications/#{item.data.id}/read"
+ item.object().mouseleave ->
+ item.hide()
+
+ hide: ->
+ @object().animate
+ top: @top_offset()
+ opacity: 0
+ 'slow'
+ ->
+ $(@).remove()
+ Notification._items.splice(0, 1)
+ Notification[(if Notification._items.length > 0 then 'show' else 'start')]()
+
+# Open up the Notifications class to the global namespace
+(exports ? @).Notification = Notification
+
+$ ->
+ $('a#notification-count').click ->
+ Notification.update_count 0 if $('a#notification-count span').text() != '0'
@@ -0,0 +1,120 @@
+@import "mixins";
+
+$types: ("events", "assignments", "documents", "gradebook");
+
+#notifications {
+ position: relative;
+ margin-top: 15px;
+ a.notification-item {
+ @include gradient($start: #fff, $start_pct: 30%, $end: darken(#f5e7be, 25%));
+ @include css3-attribute(box-shadow, 0 0 3px #fff);
+ @include css3-attribute(border-radius, 5px);
+ z-index: 2;
+ position: absolute;
+ right: 0;
+ border-bottom: 1px solid #fff;
+ width: 300px;
+ padding: 4px 8px;
+ float: left;
+ color: #555;
+ margin-right: 50px;
+ text-shadow: 0 1px 1px #fff;
+ font-size: 11px;
+ line-height: 1.1em;
+ opacity: 0;
+ display: none;
+ &:hover {
+ @include gradient($start: #fff, $start_pct: 30%, $end: darken(#f5e7be, 40%));
+ text-decoration: none;
+ }
+ span {
+ display: block;
+ padding-left: 32px;
+ min-height: 24px;
+ background: {
+ position: top left;
+ repeat: no-repeat;
+ size: 24px;
+ }
+ > b {
+ color: #000;
+ }
+ }
+ @each $icon in $types {
+ &.#{$icon} span {
+ background-image: image-url('icons/notifications-#{$icon}.png');
+ }
+ }
+ }
+ a#notification-count {
+ @include css3-attribute(border-radius, 4px);
+ @include css3-attribute(transition, 0.2s);
+ float: right;
+ margin-left: 10px;
+ background-color: #65a9d7;
+ padding: 6px 10px;
+ text-shadow: 0 1px 2px #000;
+ color: #fff;
+ font: {
+ size: 16px;
+ weight: bold;
+ }
+ &:hover {
+ text-decoration: none;
+ background-color: #3c6b8a;
+ }
+ &.new {
+ background-color: #b00;
+ &:hover {
+ background-color: #900;
+ }
+ }
+ }
+}
+
+a.notification {
+ @include css3-attribute(transition, 0.2s);
+ display: block;
+ border-top: 1px solid #aaa;
+ padding: 10px 10px 10px 75px;
+ font-size: 15px;
+ letter-spacing: 0.05em;
+ min-height: 40px;
+ color: #555;
+ background: {
+ position: 10px 10px;
+ repeat: no-repeat;
+ }
+ @each $icon in $types {
+ &.#{$icon} {
+ background-image: image-url('icons/notifications-#{$icon}.png');
+ }
+ }
+ &:hover {
+ background-color: lighten(#f5e7be, 10%);
+ text-decoration: none;
+ }
+ > b {
+ color: #000;
+ }
+ > span {
+ display: block;
+ font: {
+ size: 11px;
+ style: italic;
+ }
+ }
+}
+
+#latest-activity {
+ overflow: auto;
+ max-height: 190px;
+ a.notification {
+ font-size: 12px;
+ letter-spacing: 0em;
+ min-height: 25px;
+ padding-left: 32px;
+ background-position: center left;
+ background-size: 24px;
+ }
+}
@@ -8,6 +8,7 @@ def index
@events = current_user.events.for(Time.now..7.days.from_now.end_of_day)
@events = @events.visible unless current_user.is_a?(Professor)
@events = @events.group_by(&:starts_on)
+ @notifications = current_user.notifications.since(10.days.ago)
end
# This class-level method is a convenience to render no layout when the request is AJAX-based
@@ -0,0 +1,28 @@
+class NotificationsController < InstitutionsController
+
+ ajax :index
+
+ def index
+ @notifications = current_user.notifications.unread
+ since = Time.parse(params[:since]) rescue Time.now
+ @notifications = @notifications.since(params[:since] ? since : 10.days.ago)
+
+ # only mark all notifications as read if we're viewing them via JS and they aren't all already marked as read
+ @notifications.update_all(read: true) unless params[:since] || @notifications.all?(&:read)
+
+ respond_to do |format|
+ format.js
+ format.json { render json: @notifications, only: [:id, :text, :css_class, :url, :modal, :created_at] }
+ end
+ end
+
+ def read
+ @notification = current_user.notifications.find(params[:id])
+ @notification.read!
+
+ respond_to do |format|
+ format.js { render nothing: true }
+ end
+ end
+
+end
View
@@ -9,6 +9,8 @@ class Event < ActiveRecord::Base
validates_presence_of :title, :starts_at, :ends_at
validate :check_times
+ after_create :notify!
+
def title
"#{assignment ? 'DUE: ' : nil}#{read_attribute(:title)}"
end
@@ -29,4 +31,24 @@ def check_times
errors.add(:ends_at, 'must be after start time') if ends_at && starts_at && ends_at < starts_at
end
+ def notify!
+ info = {
+ type: assignment ? 'assignment' : 'event',
+ title: title,
+ section: section.course.title,
+ starts_on: starts_on.strftime("%a., %b #{starts_on.day.ordinalize}"),
+ time: time
+ }
+ notification = {
+ resource: self,
+ text: 'A new %{type}, <b>%{title}</b>, was created for %{section} on <b>%{starts_on}</b> from <b>%{time}</b>.' % info,
+ url: [section, id],
+ css_class: assignment ? 'assignments' : 'events',
+ modal: true
+ }
+ section.student_ids.each {|student| Notification.create(notification.merge({user_type: 'Student', user_id: student}))}
+ end
+
+ handle_asynchronously :notify!, queue: 'notifications'
+
end
@@ -0,0 +1,25 @@
+class Notification < ActiveRecord::Base
+ include ActionDispatch::Routing::PolymorphicRoutes
+
+ default_scope order('created_at DESC')
+ scope :unread, where(read: false)
+ scope :since, lambda {|since| where('created_at > ?', since)}
+
+ belongs_to :user, polymorphic: true
+ belongs_to :resource, polymorphic: true
+
+ validates_presence_of :text, :url
+
+ before_create :build_url!
+
+ def read!
+ self.read = true
+ self.save
+ end
+
+ protected
+ def build_url!
+ self.url = polymorphic_path(url) unless url.is_a?(String)
+ end
+
+end
View
@@ -8,6 +8,7 @@ class User < ActiveRecord::Base
has_many :addresses, as: :user
has_many :phone_numbers, as: :user
has_many :documents, as: :resource
+ has_many :notifications, as: :user
validates_presence_of :first_name, :last_name #, :email_address
# validates_uniqueness_of :email_address,
@@ -3,3 +3,8 @@
%span.m Latest Activity
%span.r
%p The last 10 days of activity on uClass across all of your courses are shown below.
+#latest-activity
+ = render @notifications
+ - if @notifications.length == 0
+ .none
+ Nothing new has happened on #{current_institution.name}'s uClass in the last 10 days.
Oops, something went wrong.

0 comments on commit b6ca07e

Please sign in to comment.