diff options
-rw-r--r-- | devfiles/scripts/cassandra-bootstrap.cql | 10 | ||||
-rw-r--r-- | src/com/p4square/grow/backend/GrowBackend.java | 33 | ||||
-rw-r--r-- | src/com/p4square/grow/backend/db/CassandraCollectionProvider.java | 101 | ||||
-rw-r--r-- | src/com/p4square/grow/backend/feed/FeedDataProvider.java | 33 | ||||
-rw-r--r-- | src/com/p4square/grow/backend/feed/ThreadResource.java | 102 | ||||
-rw-r--r-- | src/com/p4square/grow/backend/feed/TopicResource.java | 88 | ||||
-rw-r--r-- | src/com/p4square/grow/frontend/TrainingPageResource.java | 1 | ||||
-rw-r--r-- | src/com/p4square/grow/model/Message.java | 95 | ||||
-rw-r--r-- | src/com/p4square/grow/model/MessageThread.java | 43 | ||||
-rw-r--r-- | src/com/p4square/grow/provider/CollectionProvider.java | 59 | ||||
-rw-r--r-- | src/templates/templates/training.ftl | 10 |
11 files changed, 572 insertions, 3 deletions
diff --git a/devfiles/scripts/cassandra-bootstrap.cql b/devfiles/scripts/cassandra-bootstrap.cql index db2e949..9348fcc 100644 --- a/devfiles/scripts/cassandra-bootstrap.cql +++ b/devfiles/scripts/cassandra-bootstrap.cql @@ -25,3 +25,13 @@ create column family training with key_validation_class = 'UTF8Type' and comparator = 'UTF8Type' and default_validation_class = 'UTF8Type'; + +create column family feedthreads + with key_validation_class = 'UTF8Type' + and comparator = 'UTF8Type' + and default_validation_class = 'UTF8Type'; + +create column family feedmessages + with key_validation_class = 'UTF8Type' + and comparator = 'UTF8Type' + and default_validation_class = 'UTF8Type'; diff --git a/src/com/p4square/grow/backend/GrowBackend.java b/src/com/p4square/grow/backend/GrowBackend.java index 195554e..f844feb 100644 --- a/src/com/p4square/grow/backend/GrowBackend.java +++ b/src/com/p4square/grow/backend/GrowBackend.java @@ -19,12 +19,16 @@ import com.p4square.grow.config.Config; import com.p4square.grow.backend.db.CassandraDatabase; import com.p4square.grow.backend.db.CassandraKey; import com.p4square.grow.backend.db.CassandraProviderImpl; +import com.p4square.grow.backend.db.CassandraCollectionProvider; import com.p4square.grow.backend.db.CassandraTrainingRecordProvider; import com.p4square.grow.model.Question; import com.p4square.grow.model.TrainingRecord; import com.p4square.grow.model.Playlist; +import com.p4square.grow.model.MessageThread; +import com.p4square.grow.model.Message; +import com.p4square.grow.provider.CollectionProvider; import com.p4square.grow.provider.Provider; import com.p4square.grow.provider.ProvidesQuestions; import com.p4square.grow.provider.ProvidesTrainingRecords; @@ -37,13 +41,17 @@ import com.p4square.grow.backend.resources.SurveyResultsResource; import com.p4square.grow.backend.resources.TrainingRecordResource; import com.p4square.grow.backend.resources.TrainingResource; +import com.p4square.grow.backend.feed.FeedDataProvider; +import com.p4square.grow.backend.feed.ThreadResource; +import com.p4square.grow.backend.feed.TopicResource; + /** * Main class for the backend application. * * @author Jesse Morgan <jesse@jesterpm.net> */ public class GrowBackend extends Application - implements ProvidesQuestions, ProvidesTrainingRecords { + implements ProvidesQuestions, ProvidesTrainingRecords, FeedDataProvider { private static final String DEFAULT_COLUMN = "value"; private final static Logger LOG = Logger.getLogger(GrowBackend.class); @@ -54,6 +62,9 @@ public class GrowBackend extends Application private final Provider<String, Question> mQuestionProvider; private final CassandraTrainingRecordProvider mTrainingRecordProvider; + private final CollectionProvider<String, String, MessageThread> mFeedThreadProvider; + private final CollectionProvider<String, String, Message> mFeedMessageProvider; + public GrowBackend() { this(new Config()); } @@ -69,6 +80,11 @@ public class GrowBackend extends Application } }; + mFeedThreadProvider = new CassandraCollectionProvider<MessageThread>(mDatabase, + "feedthreads", MessageThread.class); + mFeedMessageProvider = new CassandraCollectionProvider<Message>(mDatabase, + "feedmessages", Message.class); + mTrainingRecordProvider = new CassandraTrainingRecordProvider(mDatabase); } @@ -97,6 +113,11 @@ public class GrowBackend extends Application // Misc. router.attach("/banner", BannerResource.class); + // Feed + router.attach("/feed/{topic}", TopicResource.class); + router.attach("/feed/{topic}/{thread}", ThreadResource.class); + //router.attach("/feed/{topic/{thread}/{message}", MessageResource.class); + return router; } @@ -148,6 +169,16 @@ public class GrowBackend extends Application return mTrainingRecordProvider.getDefaultPlaylist(); } + @Override + public CollectionProvider<String, String, MessageThread> getThreadProvider() { + return mFeedThreadProvider; + } + + @Override + public CollectionProvider<String, String, Message> getMessageProvider() { + return mFeedMessageProvider; + } + /** * Stand-alone main for testing. */ diff --git a/src/com/p4square/grow/backend/db/CassandraCollectionProvider.java b/src/com/p4square/grow/backend/db/CassandraCollectionProvider.java new file mode 100644 index 0000000..cc11828 --- /dev/null +++ b/src/com/p4square/grow/backend/db/CassandraCollectionProvider.java @@ -0,0 +1,101 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.db; + +import java.io.IOException; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.netflix.astyanax.model.Column; +import com.netflix.astyanax.model.ColumnList; + +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * CollectionProvider implementation backed by a Cassandra ColumnFamily. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CassandraCollectionProvider<V> implements CollectionProvider<String, String, V> { + private final CassandraDatabase mDb; + private final String mCF; + private final Class<V> mClazz; + + public CassandraCollectionProvider(CassandraDatabase db, String columnFamily, Class<V> clazz) { + mDb = db; + mCF = columnFamily; + mClazz = clazz; + } + + @Override + public V get(String collection, String key) throws IOException { + String blob = mDb.getKey(mCF, collection, key); + return decode(blob); + } + + @Override + public Map<String, V> query(String collection) throws IOException { + return query(collection, -1); + } + + @Override + public Map<String, V> query(String collection, int limit) throws IOException { + Map<String, V> result = new HashMap<>(); + + ColumnList<String> row = mDb.getRow(mCF, collection); + if (!row.isEmpty()) { + int count = 0; + for (Column<String> c : row) { + String key = c.getName(); + String blob = c.getStringValue(); + V obj = decode(blob); + + result.put(key, obj); + + if (limit >= 0 && ++count > limit) { + break; // Limit reached. + } + } + } + + return Collections.unmodifiableMap(result); + } + + @Override + public void put(String collection, String key, V obj) throws IOException { + String blob = encode(obj); + mDb.putKey(mCF, collection, key, blob); + } + + /** + * Encode the object as JSON. + * + * @param obj The object to encode. + * @return The JSON encoding of obj. + * @throws IOException if the object cannot be encoded. + */ + protected String encode(V obj) throws IOException { + return JsonEncodedProvider.MAPPER.writeValueAsString(obj); + } + + /** + * Decode the JSON string as an object. + * + * @param blob The JSON data to decode. + * @return The decoded object or null if blob is null. + * @throws IOException If an object cannot be decoded. + */ + protected V decode(String blob) throws IOException { + if (blob == null) { + return null; + } + + V obj = JsonEncodedProvider.MAPPER.readValue(blob, mClazz); + return obj; + } +} diff --git a/src/com/p4square/grow/backend/feed/FeedDataProvider.java b/src/com/p4square/grow/backend/feed/FeedDataProvider.java new file mode 100644 index 0000000..41b2dfa --- /dev/null +++ b/src/com/p4square/grow/backend/feed/FeedDataProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.feed; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; + +import com.p4square.grow.model.MessageThread; +import com.p4square.grow.model.Message; +import com.p4square.grow.provider.CollectionProvider; + +/** + * Implementing this interface indicates you can provide a data source for the Feed. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface FeedDataProvider { + public static final Collection<String> TOPICS = Collections.unmodifiableCollection( + Arrays.asList(new String[] { "seeker", "believer", "disciple", "teacher" })); + + /** + * @return a CollectionProvider of Threads. + */ + CollectionProvider<String, String, MessageThread> getThreadProvider(); + + /** + * @return a CollectionProvider of Messages. + */ + CollectionProvider<String, String, Message> getMessageProvider(); +} diff --git a/src/com/p4square/grow/backend/feed/ThreadResource.java b/src/com/p4square/grow/backend/feed/ThreadResource.java new file mode 100644 index 0000000..32a2f64 --- /dev/null +++ b/src/com/p4square/grow/backend/feed/ThreadResource.java @@ -0,0 +1,102 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.feed; + +import java.io.IOException; + +import java.util.Map; +import java.util.UUID; + +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; + +import org.restlet.ext.jackson.JacksonRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.model.Message; + +/** + * ThreadResource manages the messages that make up a thread. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class ThreadResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(ThreadResource.class); + + private FeedDataProvider mBackend; + private String mTopic; + private String mThreadId; + + @Override + public void doInit() { + super.doInit(); + + mBackend = (FeedDataProvider) getApplication(); + mTopic = getAttribute("topic"); + mThreadId = getAttribute("thread"); + } + + /** + * GET a list of messages in a thread. + */ + @Override + protected Representation get() { + // If the topic or threadId are missing, return a 404. + if (mTopic == null || mTopic.length() == 0 || + mThreadId == null || mThreadId.length() == 0) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + // TODO: Support limit query parameter. + + try { + String collectionKey = mTopic + "/" + mThreadId; + Map<String, Message> messages = mBackend.getMessageProvider().query(collectionKey); + return new JacksonRepresentation(messages.values()); + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * POST a new thread to the topic. + */ + @Override + protected Representation post(Representation entity) { + // If the topic and thread are not provided, respond with not allowed. + // TODO: Check if the thread exists. + if (mTopic == null || !mBackend.TOPICS.contains(mTopic) || + mThreadId == null || mThreadId.length() == 0) { + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + return null; + } + + try { + JacksonRepresentation<Message> jsonRep = new JacksonRepresentation<Message>(entity, Message.class); + Message message = jsonRep.getObject(); + + // Force the thread id and message to be what we expect. + message.setThreadId(mThreadId); + message.setId(String.format("%x-%s", System.currentTimeMillis(), UUID.randomUUID().toString())); + + String collectionKey = mTopic + "/" + mThreadId; + mBackend.getMessageProvider().put(collectionKey, message.getId(), message); + + setLocationRef(mThreadId + "/" + message.getId()); + return new JacksonRepresentation(message); + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } +} diff --git a/src/com/p4square/grow/backend/feed/TopicResource.java b/src/com/p4square/grow/backend/feed/TopicResource.java new file mode 100644 index 0000000..0904baa --- /dev/null +++ b/src/com/p4square/grow/backend/feed/TopicResource.java @@ -0,0 +1,88 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.feed; + +import java.io.IOException; + +import java.util.Map; + +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; + +import org.restlet.ext.jackson.JacksonRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.model.MessageThread; + +/** + * TopicResource manages the threads contained in a topic. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TopicResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(TopicResource.class); + + private FeedDataProvider mBackend; + private String mTopic; + + @Override + public void doInit() { + super.doInit(); + + mBackend = (FeedDataProvider) getApplication(); + mTopic = getAttribute("topic"); + } + + /** + * GET a list of threads in the topic. + */ + @Override + protected Representation get() { + // If no topic is provided, return a list of topics. + if (mTopic == null || mTopic.length() == 0) { + return new JacksonRepresentation(FeedDataProvider.TOPICS); + } + + // TODO: Support limit query parameter. + + try { + Map<String, MessageThread> threads = mBackend.getThreadProvider().query(mTopic); + return new JacksonRepresentation(threads.values()); + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * POST a new thread to the topic. + */ + @Override + protected Representation post(Representation entity) { + // If no topic is provided, respond with not allowed. + if (mTopic == null || !mBackend.TOPICS.contains(mTopic)) { + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + return null; + } + + try { + MessageThread newThread = MessageThread.createNew(); + mBackend.getThreadProvider().put(mTopic, newThread.getId(), newThread); + + setStatus(Status.SUCCESS_NO_CONTENT); + setLocationRef(mTopic + "/" + newThread.getId()); + return null; + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } +} diff --git a/src/com/p4square/grow/frontend/TrainingPageResource.java b/src/com/p4square/grow/frontend/TrainingPageResource.java index adad68c..0f2b284 100644 --- a/src/com/p4square/grow/frontend/TrainingPageResource.java +++ b/src/com/p4square/grow/frontend/TrainingPageResource.java @@ -209,6 +209,7 @@ public class TrainingPageResource extends FreeMarkerPageResource { root.put("overallProgress", overallProgress); root.put("videos", videos); root.put("allowUserToSkip", allowUserToSkip); + root.put("showfeed", getQueryValue("showfeed") != null); return new TemplateRepresentation(mTrainingTemplate, root, MediaType.TEXT_HTML); diff --git a/src/com/p4square/grow/model/Message.java b/src/com/p4square/grow/model/Message.java new file mode 100644 index 0000000..6e07150 --- /dev/null +++ b/src/com/p4square/grow/model/Message.java @@ -0,0 +1,95 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.Date; + +/** + * A feed message. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Message { + private String mThreadId; + private String mId; + private String mAuthor; + private Date mCreated; + private String mMessage; + + /** + * @return The id of the thread that the message belongs to. + */ + public String getThreadId() { + return mThreadId; + } + + /** + * Set the id of the thread that the message belongs to. + * @param id The new thread id. + */ + public void setThreadId(String id) { + mThreadId = id; + } + + /** + * @return The id the message. + */ + public String getId() { + return mId; + } + + /** + * Set the id of the message. + * @param id The new message id. + */ + public void setId(String id) { + mId = id; + } + + /** + * @return The author of the message. + */ + public String getAuthor() { + return mAuthor; + } + + /** + * Set the author of the message. + * @param author The new author. + */ + public void setAuthor(String author) { + mAuthor = author; + } + + /** + * @return The Date the message was created. + */ + public Date getCreated() { + return mCreated; + } + + /** + * Set the Date the message was created. + * @param date The new creation date. + */ + public void setCreated(Date date) { + mCreated = date; + } + + /** + * @return The message text. + */ + public String getMessage() { + return mMessage; + } + + /** + * Set the message text. + * @param text The message text. + */ + public void setMessage(String text) { + mMessage = text; + } +} diff --git a/src/com/p4square/grow/model/MessageThread.java b/src/com/p4square/grow/model/MessageThread.java new file mode 100644 index 0000000..f93ec13 --- /dev/null +++ b/src/com/p4square/grow/model/MessageThread.java @@ -0,0 +1,43 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.UUID; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class MessageThread { + private String mId; + + /** + * Create a new thread with a probably unique id. + * + * @return the new thread. + */ + public static MessageThread createNew() { + MessageThread t = new MessageThread(); + t.setId(String.format("%x-%s", System.currentTimeMillis(), UUID.randomUUID().toString())); + + return t; + } + + /** + * @return The id the message. + */ + public String getId() { + return mId; + } + + /** + * Set the id of the message. + * @param id The new message id. + */ + public void setId(String id) { + mId = id; + } + +} diff --git a/src/com/p4square/grow/provider/CollectionProvider.java b/src/com/p4square/grow/provider/CollectionProvider.java new file mode 100644 index 0000000..e4e9040 --- /dev/null +++ b/src/com/p4square/grow/provider/CollectionProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; +import java.util.Map; + +/** + * ListProvider is the logical extension of Provider for dealing with lists of + * items. + * + * @param C The type of the collection key. + * @param K The type of the item key. + * @param V The type of the value. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface CollectionProvider<C, K, V> { + /** + * Retrieve a specific object from the collection. + * + * @param collection The collection key. + * @param key The key for the object in the collection. + * @return The object or null if not found. + */ + V get(C collection, K key) throws IOException; + + /** + * Retrieve a collection. + * + * The returned map will never be null. + * + * @param collection The collection key. + * @return A Map of keys to values. + */ + Map<K, V> query(C collection) throws IOException; + + /** + * Retrieve a portion of a collection. + * + * The returned map will never be null. + * + * @param collection The collection key. + * @param limit Max number of items to return. + * @return A Map of keys to values. + */ + Map<K, V> query(C collection, int limit) throws IOException; + + /** + * Persist the object with the given key. + * + * @param collection The collection key. + * @param key The key for the object in the collection. + * @param obj The object to persist. + */ + void put(C collection, K key, V obj) throws IOException; +} diff --git a/src/templates/templates/training.ftl b/src/templates/templates/training.ftl index 3f5331e..7989d15 100644 --- a/src/templates/templates/training.ftl +++ b/src/templates/templates/training.ftl @@ -32,10 +32,12 @@ <div class="progresslabel" style="left:${chapterProgress}%">${chapterProgress}%</div> </div> - <div id="videos"> + <#assign sidebar=showfeed> + + <div id="videos" <#if sidebar>style="width: 70%"</#if>> <#assign allowed = true> <#list videos as video> - <article> + <article <#if sidebar>style="margin-right: 30px"</#if>> <div class="image <#if video.completed>completed</#if> <#if allowed>allowed</#if>" id="${video.id}"><a href="#" onclick="playVideo('${video.id}'); return false"><img src="${video.image!staticRoot+"/images/videoimage.jpg"}" alt="${video.title}" /></a></div> <h2><#if video.number != "0">${video.number}. </#if>${video.title}</h2> <span class="duration"><@hms seconds=video.length /></span> @@ -48,6 +50,10 @@ </article> </#list> </div> + + <#if showfeed> + <#include "/templates/communityfeed.ftl"> + </#if> </div> <div id="videoplayer"> |