diff options
author | Jesse Morgan <jesse@jesterpm.net> | 2013-08-04 16:09:29 -0700 |
---|---|---|
committer | Jesse Morgan <jesse@jesterpm.net> | 2013-08-04 16:09:29 -0700 |
commit | 52539d7aaba96b7997a3c5a07e4a1ad234af7d04 (patch) | |
tree | 2686f56bc37656c0824a05e28472f7334ed39028 /src | |
parent | 69e2512750dd75fce43a21226979996c3cd7da1d (diff) |
Committing everything since its long overdue.
Diffstat (limited to 'src')
24 files changed, 1534 insertions, 5 deletions
diff --git a/src/com/p4square/grow/backend/GrowBackend.java b/src/com/p4square/grow/backend/GrowBackend.java new file mode 100644 index 0000000..515cd1b --- /dev/null +++ b/src/com/p4square/grow/backend/GrowBackend.java @@ -0,0 +1,138 @@ +/* + * Copyright 2012 Jesse Morgan + */ + +package com.p4square.grow.backend; + +import org.apache.log4j.Logger; + +import org.restlet.Application; +import org.restlet.Component; +import org.restlet.data.Protocol; +import org.restlet.Restlet; +import org.restlet.routing.Router; + +import com.p4square.grow.config.Config; + +import com.p4square.grow.backend.db.CassandraDatabase; +import com.p4square.grow.backend.resources.SurveyResource; +import com.p4square.grow.backend.resources.SurveyResultsResource; +import com.p4square.grow.backend.resources.TrainingResource; +import com.p4square.grow.backend.resources.TrainingRecordResource; + +/** + * Main class for the backend application. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class GrowBackend extends Application { + private final static Logger cLog = Logger.getLogger(GrowBackend.class); + + private final Config mConfig; + private final CassandraDatabase mDatabase; + + public GrowBackend() { + mConfig = new Config(); + mDatabase = new CassandraDatabase(); + } + + @Override + public Restlet createInboundRoot() { + Router router = new Router(getContext()); + + // Survey API + router.attach("/assessment/question/{questionId}", SurveyResource.class); + + router.attach("/accounts/{userId}/assessment", SurveyResultsResource.class); + router.attach("/accounts/{userId}/assessment/answers/{questionId}", + SurveyResultsResource.class); + + // Training API + router.attach("/training/{level}", TrainingResource.class); + router.attach("/training/{level}/videos/{videoId}", TrainingResource.class); + + router.attach("/accounts/{userId}/training", TrainingRecordResource.class); + router.attach("/accounts/{userId}/training/videos/{videoId}", + TrainingRecordResource.class); + + + return router; + } + + /** + * Open the database. + */ + @Override + public void start() throws Exception { + super.start(); + + // Load config + final String configDomain = + getContext().getParameters().getFirstValue("configDomain"); + if (configDomain != null) { + mConfig.setDomain(configDomain); + } + + mConfig.updateConfig(this.getClass().getResourceAsStream("/grow.properties")); + + final String configFilename = + getContext().getParameters().getFirstValue("configFile"); + + if (configFilename != null) { + mConfig.updateConfig(configFilename); + } + + // Setup database + mDatabase.setClusterName(mConfig.getString("clusterName", "Dev Cluster")); + mDatabase.setKeyspaceName(mConfig.getString("keyspace", "GROW")); + mDatabase.init(); + } + + /** + * Close the database. + */ + @Override + public void stop() throws Exception { + cLog.info("Shutting down..."); + mDatabase.close(); + + super.stop(); + } + + /** + * @return the current database. + */ + public CassandraDatabase getDatabase() { + return mDatabase; + } + + /** + * Stand-alone main for testing. + */ + public static void main(String[] args) throws Exception { + // Start the HTTP Server + final Component component = new Component(); + component.getServers().add(Protocol.HTTP, 9095); + component.getClients().add(Protocol.HTTP); + component.getDefaultHost().attach(new GrowBackend()); + + // Setup shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread() { + public void run() { + try { + component.stop(); + } catch (Exception e) { + cLog.error("Exception during cleanup", e); + } + } + }); + + cLog.info("Starting server..."); + + try { + component.start(); + } catch (Exception e) { + cLog.fatal("Could not start: " + e.getMessage(), e); + } + } +} diff --git a/src/com/p4square/grow/backend/db/CassandraDatabase.java b/src/com/p4square/grow/backend/db/CassandraDatabase.java new file mode 100644 index 0000000..e40c251 --- /dev/null +++ b/src/com/p4square/grow/backend/db/CassandraDatabase.java @@ -0,0 +1,192 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.db; + +import com.netflix.astyanax.AstyanaxContext; +import com.netflix.astyanax.connectionpool.exceptions.ConnectionException; +import com.netflix.astyanax.connectionpool.impl.ConnectionPoolConfigurationImpl; +import com.netflix.astyanax.connectionpool.impl.CountingConnectionPoolMonitor; +import com.netflix.astyanax.connectionpool.NodeDiscoveryType; +import com.netflix.astyanax.connectionpool.OperationResult; +import com.netflix.astyanax.impl.AstyanaxConfigurationImpl; +import com.netflix.astyanax.Keyspace; +import com.netflix.astyanax.ColumnMutation; +import com.netflix.astyanax.model.Column; +import com.netflix.astyanax.model.ColumnFamily; +import com.netflix.astyanax.model.ColumnList; +import com.netflix.astyanax.MutationBatch; +import com.netflix.astyanax.serializers.StringSerializer; +import com.netflix.astyanax.thrift.ThriftFamilyFactory; + +import org.apache.log4j.Logger; + +/** + * Cassandra Database Abstraction for the Backend. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CassandraDatabase { + private static Logger cLog = Logger.getLogger(CassandraDatabase.class); + + // Configuration fields. + private String mClusterName; + private String mKeyspaceName; + private String mSeedEndpoint = "127.0.0.1:9160"; + private int mPort = 9160; + + private AstyanaxContext<Keyspace> mContext; + private Keyspace mKeyspace; + + /** + * Connect to Cassandra. + * + * Cluster and Keyspace must be set before calling init(). + */ + public void init() { + mContext = new AstyanaxContext.Builder() + .forCluster(mClusterName) + .forKeyspace(mKeyspaceName) + .withAstyanaxConfiguration(new AstyanaxConfigurationImpl() + .setDiscoveryType(NodeDiscoveryType.RING_DESCRIBE) + ) + .withConnectionPoolConfiguration(new ConnectionPoolConfigurationImpl("MyConnectionPool") + .setPort(mPort) + .setMaxConnsPerHost(1) + .setSeeds(mSeedEndpoint) + ) + .withConnectionPoolMonitor(new CountingConnectionPoolMonitor()) + .buildKeyspace(ThriftFamilyFactory.getInstance()); + + mContext.start(); + mKeyspace = mContext.getClient(); + } + + /** + * Close the database connection. + */ + public void close() { + mContext.shutdown(); + } + + /** + * Set the cluster name to connect to. + */ + public void setClusterName(final String cluster) { + mClusterName = cluster; + } + + /** + * Set the name of the keyspace to open. + */ + public void setKeyspaceName(final String keyspace) { + mKeyspaceName = keyspace; + } + + /** + * Change the seed endpoint. + * The default is 127.0.0.1:9160. + */ + public void setSeedEndpoint(final String endpoint) { + mSeedEndpoint = endpoint; + } + + /** + * Change the port to connect to. + * The default is 9160. + */ + public void setPort(final int port) { + mPort = port; + } + + /** + * @return The entire row associated with this key. + */ + public ColumnList<String> getRow(final String cfName, final String key) { + try { + ColumnFamily<String, String> cf = new ColumnFamily(cfName, + StringSerializer.get(), + StringSerializer.get()); + + OperationResult<ColumnList<String>> result = + mKeyspace.prepareQuery(cf) + .getKey(key) + .execute(); + + return result.getResult(); + + } catch (ConnectionException e) { + cLog.error("getRow failed due to Connection Exception", e); + throw new RuntimeException(e); + } + } + + /** + * @return The value associated with the given key. + */ + public String getKey(final String cfName, final String key) { + return getKey(cfName, key, "value"); + } + + /** + * @return The value associated with the given key, column pair. + */ + public String getKey(final String cfName, final String key, final String column) { + final ColumnList<String> row = getRow(cfName, key); + + if (row != null) { + final Column rowColumn = row.getColumnByName(column); + if (rowColumn != null) { + return rowColumn.getStringValue(); + } + } + + return null; + } + + /** + * Assign value to key. + */ + public void putKey(final String cfName, final String key, final String value) { + putKey(cfName, key, "value", value); + } + + /** + * Assign value to the key, column pair. + */ + public void putKey(final String cfName, final String key, + final String column, final String value) { + + ColumnFamily<String, String> cf = new ColumnFamily(cfName, + StringSerializer.get(), + StringSerializer.get()); + + MutationBatch m = mKeyspace.prepareMutationBatch(); + m.withRow(cf, key).putColumn(column, value); + + try { + m.execute(); + } catch (ConnectionException e) { + cLog.error("putKey failed due to Connection Exception", e); + throw new RuntimeException(e); + } + } + + /** + * Remove a key, column pair. + */ + public void deleteKey(final String cfName, final String key, final String column) { + ColumnFamily<String, String> cf = new ColumnFamily(cfName, + StringSerializer.get(), + StringSerializer.get()); + + try { + ColumnMutation m = mKeyspace.prepareColumnMutation(cf, key, column); + m.deleteColumn().execute(); + } catch (ConnectionException e) { + cLog.error("deleteKey failed due to Connection Exception", e); + throw new RuntimeException(e); + } + } +} diff --git a/src/com/p4square/grow/backend/resources/Answer.java b/src/com/p4square/grow/backend/resources/Answer.java new file mode 100644 index 0000000..5ba1bce --- /dev/null +++ b/src/com/p4square/grow/backend/resources/Answer.java @@ -0,0 +1,57 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.util.Map; + +/** + * This is the model of an assessment question's answer. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +class Answer { + public static enum ScoreType { + NONE, AVERAGE, TRUMP; + } + + private final String mAnswerId; + private final String mAnswerText; + private final ScoreType mType; + private final float mScoreFactor; + + public Answer(final String id, final Map<String, Object> answer) { + mAnswerId = id; + mAnswerText = (String) answer.get("text"); + final String typeStr = (String) answer.get("type"); + if (typeStr == null) { + mType = ScoreType.AVERAGE; + } else { + mType = ScoreType.valueOf(typeStr.toUpperCase()); + } + + if (mType != ScoreType.NONE) { + mScoreFactor = Float.valueOf((String) answer.get("score")); + } else { + mScoreFactor = 0; + } + + } + + public String getId() { + return mAnswerId; + } + + public String getText() { + return mAnswerText; + } + + public ScoreType getType() { + return mType; + } + + public float getScoreFactor() { + return mScoreFactor; + } +} diff --git a/src/com/p4square/grow/backend/resources/Point.java b/src/com/p4square/grow/backend/resources/Point.java new file mode 100644 index 0000000..e1b15a8 --- /dev/null +++ b/src/com/p4square/grow/backend/resources/Point.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +/** + * Simple double based point class. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +class Point { + public static Point valueOf(String str) { + final int comma = str.indexOf(','); + if (comma == -1) { + throw new IllegalArgumentException("Malformed point string"); + } + + final String sX = str.substring(0, comma); + final String sY = str.substring(comma + 1); + + return new Point(Double.valueOf(sX), Double.valueOf(sY)); + } + + private final double mX; + private final double mY; + + public Point(double x, double y) { + mX = x; + mY = y; + } + + public double distance(Point other) { + final double dx = mX - other.mX; + final double dy = mY - other.mY; + + return Math.sqrt(dx*dx + dy*dy); + } + + public double getX() { + return mX; + } + + public double getY() { + return mY; + } + + @Override + public String toString() { + return String.format("%.2f,%.2f", mX, mY); + } +} diff --git a/src/com/p4square/grow/backend/resources/Question.java b/src/com/p4square/grow/backend/resources/Question.java new file mode 100644 index 0000000..c53883c --- /dev/null +++ b/src/com/p4square/grow/backend/resources/Question.java @@ -0,0 +1,72 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Model of an assessment question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +class Question { + public static enum QuestionType { + TEXT, IMAGE, SLIDER, QUAD; + } + + private final String mQuestionId; + private final QuestionType mType; + private final String mQuestionText; + private Map<String, Answer> mAnswers; + + private final String mPreviousQuestionId; + private final String mNextQuestionId; + + public Question(final Map<String, Object> map) { + mQuestionId = (String) map.get("id"); + mType = QuestionType.valueOf(((String) map.get("type")).toUpperCase()); + + mQuestionText = (String) map.get("text"); + + mPreviousQuestionId = (String) map.get("previousQuestion"); + mNextQuestionId = (String) map.get("nextQuestion"); + + mAnswers = new HashMap<String, Answer>(); + for (Map.Entry<String, Object> answer : + ((Map<String, Object>) map.get("answers")).entrySet()) { + + final String id = answer.getKey(); + final Map<String, Object> answerMap = (Map<String, Object>) answer.getValue(); + final Answer answerObj = new Answer(id, answerMap); + mAnswers.put(id, answerObj); + } + } + + public String getId() { + return mQuestionId; + } + + public QuestionType getType() { + return mType; + } + + public String getText() { + return mQuestionText; + } + + public String getPrevious() { + return mPreviousQuestionId; + } + + public String getNext() { + return mNextQuestionId; + } + + public Map<String, Answer> getAnswers() { + return Collections.unmodifiableMap(mAnswers); + } +} diff --git a/src/com/p4square/grow/backend/resources/Score.java b/src/com/p4square/grow/backend/resources/Score.java new file mode 100644 index 0000000..c7e5ecc --- /dev/null +++ b/src/com/p4square/grow/backend/resources/Score.java @@ -0,0 +1,34 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +/** + * Simple structure containing a score's sum and count. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +class Score { + double sum; + int count; + + @Override + public String toString() { + final double score = sum / count; + + if (score >= 4) { + return "teacher"; + + } else if (score >= 3) { + return "disciple"; + + } else if (score >= 2) { + return "believer"; + + } else { + return "seeker"; + } + } + +} diff --git a/src/com/p4square/grow/backend/resources/SurveyResource.java b/src/com/p4square/grow/backend/resources/SurveyResource.java new file mode 100644 index 0000000..d22d763 --- /dev/null +++ b/src/com/p4square/grow/backend/resources/SurveyResource.java @@ -0,0 +1,70 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.util.Map; +import java.util.HashMap; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.backend.db.CassandraDatabase; + +/** + * This resource manages assessment questions. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SurveyResource extends ServerResource { + private final static Logger cLog = Logger.getLogger(SurveyResource.class); + + private CassandraDatabase mDb; + + private String mQuestionId; + + @Override + public void doInit() { + super.doInit(); + + final GrowBackend backend = (GrowBackend) getApplication(); + mDb = backend.getDatabase(); + + mQuestionId = getAttribute("questionId"); + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + String result = ""; + + if (mQuestionId == null) { + // TODO: List all question ids + + } else if (mQuestionId.equals("first")) { + // TODO: Get the first question id from db? + result = "1"; + + } else { + // Get a question by id + result = mDb.getKey("strings", "/questions/" + mQuestionId); + + if (result == null) { + // 404 + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + } + + return new StringRepresentation(result); + } +} diff --git a/src/com/p4square/grow/backend/resources/SurveyResultsResource.java b/src/com/p4square/grow/backend/resources/SurveyResultsResource.java new file mode 100644 index 0000000..db7dad0 --- /dev/null +++ b/src/com/p4square/grow/backend/resources/SurveyResultsResource.java @@ -0,0 +1,252 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.util.Map; +import java.util.HashMap; + +import com.netflix.astyanax.model.Column; +import com.netflix.astyanax.model.ColumnList; + +import org.codehaus.jackson.map.ObjectMapper; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.backend.db.CassandraDatabase; + +/** + * Store the user's answers to the assessment and generate their score. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SurveyResultsResource extends ServerResource { + private final static Logger cLog = Logger.getLogger(SurveyResultsResource.class); + + private final static ObjectMapper cMapper = new ObjectMapper(); + + static enum RequestType { + ASSESSMENT, ANSWER + } + + private CassandraDatabase mDb; + + private RequestType mRequestType; + private String mUserId; + private String mQuestionId; + + @Override + public void doInit() { + super.doInit(); + + final GrowBackend backend = (GrowBackend) getApplication(); + mDb = backend.getDatabase(); + + mUserId = getAttribute("userId"); + mQuestionId = getAttribute("questionId"); + + mRequestType = RequestType.ASSESSMENT; + if (mQuestionId != null) { + mRequestType = RequestType.ANSWER; + } + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + String result = null; + + switch (mRequestType) { + case ANSWER: + result = mDb.getKey("assessments", mUserId, mQuestionId); + break; + + case ASSESSMENT: + result = buildAssessment(); + break; + } + + if (result == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + return new StringRepresentation(result); + } + + /** + * Handle PUT requests + */ + @Override + protected Representation put(Representation entity) { + boolean success = false; + + switch (mRequestType) { + case ANSWER: + try { + mDb.putKey("assessments", mUserId, mQuestionId, entity.getText()); + mDb.putKey("assessments", mUserId, "lastAnswered", mQuestionId); + mDb.deleteKey("assessments", mUserId, "summary"); + success = true; + + } catch (Exception e) { + cLog.warn("Caught exception putting answer: " + e.getMessage(), e); + } + break; + + default: + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + } + + if (success) { + setStatus(Status.SUCCESS_NO_CONTENT); + + } else { + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + return null; + } + + /** + * This method compiles assessment results. + */ + private String buildAssessment() { + StringBuilder sb = new StringBuilder("{ "); + + // Last question answered + final String lastAnswered = mDb.getKey("assessments", mUserId, "lastAnswered"); + if (lastAnswered != null) { + sb.append("\"lastAnswered\": \"" + lastAnswered + "\""); + } + + // Compute score + ColumnList<String> row = mDb.getRow("assessments", mUserId); + if (!row.isEmpty()) { + Score score = new Score(); + for (Column<String> c : row) { + if (c.getName().equals("lastAnswered")) { + continue; + } + + final String questionId = c.getName(); + final String answerId = c.getStringValue(); + if (!scoreQuestion(score, questionId, answerId)) { + break; + } + } + + sb.append(", \"score\":" + score.sum / score.count); + sb.append(", \"sum\":" + score.sum); + sb.append(", \"count\":" + score.count); + sb.append(", \"result\":\"" + score.toString() + "\""); + } + + sb.append(" }"); + return sb.toString(); + } + + private boolean scoreQuestion(final Score score, final String questionId, + final String answerJson) { + + final String data = mDb.getKey("strings", "/questions/" + questionId); + + try { + final Map<?,?> questionMap = cMapper.readValue(data, Map.class); + final Map<?,?> answerMap = cMapper.readValue(answerJson, Map.class); + final Question question = new Question((Map<String, Object>) questionMap); + final String answerId = (String) answerMap.get("answerId"); + + switch (question.getType()) { + case TEXT: + case IMAGE: + final Answer answer = question.getAnswers().get(answerId); + if (answer == null) { + cLog.warn("Got unknown answer " + answerId + + " for question " + questionId); + } else { + if (!scoreAnswer(score, answer)) { + return false; // Quit scoring + } + } + break; + + case SLIDER: + score.sum += Double.valueOf(answerId) * 4 + 1; + score.count++; + break; + + case QUAD: + scoreQuad(score, question, answerId); + break; + } + + } catch (Exception e) { + cLog.error("Exception parsing question id " + questionId, e); + } + + return true; + } + + private boolean scoreAnswer(final Score score, final Answer answer) { + switch (answer.getType()) { + case TRUMP: + score.sum = answer.getScoreFactor(); + score.count = 1; + return false; // Quit scoring. + + case AVERAGE: + score.sum += answer.getScoreFactor(); + score.count++; + break; + + case NONE: + break; + } + + return true; // Continue scoring + } + + private boolean scoreQuad(final Score score, final Question question, + final String answerId) { + + Point[] answers = new Point[question.getAnswers().size()]; + { + int i = 0; + for (String answer : question.getAnswers().keySet()) { + answers[i++] = Point.valueOf(answer); + } + } + + Point userAnswer = Point.valueOf(answerId); + + double minDistance = Double.MAX_VALUE; + int answerIndex = 0; + for (int i = 0; i < answers.length; i++) { + final double distance = userAnswer.distance(answers[i]); + if (distance < minDistance) { + minDistance = distance; + answerIndex = i; + } + } + + cLog.debug("Quad " + question.getId() + ": Got answer " + + answers[answerIndex].toString() + " for user point " + answerId); + + final Answer answer = question.getAnswers().get(answers[answerIndex].toString()); + score.sum += answer.getScoreFactor(); + score.count++; + + return true; // Continue scoring + } +} diff --git a/src/com/p4square/grow/backend/resources/TrainingRecordResource.java b/src/com/p4square/grow/backend/resources/TrainingRecordResource.java new file mode 100644 index 0000000..93f4fbc --- /dev/null +++ b/src/com/p4square/grow/backend/resources/TrainingRecordResource.java @@ -0,0 +1,155 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.util.Map; +import java.util.HashMap; + +import com.netflix.astyanax.model.Column; +import com.netflix.astyanax.model.ColumnList; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.backend.db.CassandraDatabase; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TrainingRecordResource extends ServerResource { + private final static Logger cLog = Logger.getLogger(TrainingRecordResource.class); + + static enum RequestType { + SUMMARY, VIDEO + } + + private GrowBackend mBackend; + private CassandraDatabase mDb; + + private RequestType mRequestType; + private String mUserId; + private String mVideoId; + + @Override + public void doInit() { + super.doInit(); + + mBackend = (GrowBackend) getApplication(); + mDb = mBackend.getDatabase(); + + mUserId = getAttribute("userId"); + mVideoId = getAttribute("videoId"); + + mRequestType = RequestType.SUMMARY; + if (mVideoId != null) { + mRequestType = RequestType.VIDEO; + } + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + String result = null; + + switch (mRequestType) { + case VIDEO: + result = mDb.getKey("training", mUserId, mVideoId); + break; + + case SUMMARY: + result = buildSummary(); + break; + } + + if (result == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + return new StringRepresentation(result); + } + + /** + * Handle PUT requests + */ + @Override + protected Representation put(Representation entity) { + boolean success = false; + + switch (mRequestType) { + case VIDEO: + try { + mDb.putKey("training", mUserId, mVideoId, entity.getText()); + mDb.putKey("training", mUserId, "lastVideo", mVideoId); + success = true; + + } catch (Exception e) { + cLog.warn("Caught exception updating training record: " + e.getMessage(), e); + } + break; + + default: + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + } + + if (success) { + setStatus(Status.SUCCESS_NO_CONTENT); + + } else { + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + return null; + } + + /** + * This method compiles the summary of the training completed. + */ + private String buildSummary() { + StringBuilder sb = new StringBuilder("{ "); + + // Last question answered + final String lastVideo = mDb.getKey("training", mUserId, "lastVideo"); + if (lastVideo != null) { + sb.append("\"lastVideo\": \"" + lastVideo + "\", "); + } + + // List of videos watched + sb.append("\"videos\": { "); + ColumnList<String> row = mDb.getRow("training", mUserId); + if (!row.isEmpty()) { + boolean first = true; + for (Column<String> c : row) { + if ("lastVideo".equals(c.getName())) { + continue; + } + + if (first) { + sb.append("\"" + c.getName() + "\": "); + first = false; + } else { + sb.append(", \"" + c.getName() + "\": "); + } + + sb.append(c.getStringValue()); + } + } + sb.append(" }"); + + + sb.append(" }"); + return sb.toString(); + } + +} diff --git a/src/com/p4square/grow/backend/resources/TrainingResource.java b/src/com/p4square/grow/backend/resources/TrainingResource.java new file mode 100644 index 0000000..85d08c1 --- /dev/null +++ b/src/com/p4square/grow/backend/resources/TrainingResource.java @@ -0,0 +1,88 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import com.netflix.astyanax.model.Column; +import com.netflix.astyanax.model.ColumnList; + +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.backend.db.CassandraDatabase; + +/** + * This resource returns a listing of training items for a particular level. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TrainingResource extends ServerResource { + private final static Logger cLog = Logger.getLogger(TrainingResource.class); + + private GrowBackend mBackend; + private CassandraDatabase mDb; + + private String mLevel; + private String mVideoId; + + @Override + public void doInit() { + super.doInit(); + + mBackend = (GrowBackend) getApplication(); + mDb = mBackend.getDatabase(); + + mLevel = getAttribute("level"); + mVideoId = getAttribute("videoId"); + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + String result = null; + + if (mLevel == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + if (mVideoId == null) { + // Get all videos + ColumnList<String> row = mDb.getRow("strings", "/training/" + mLevel); + if (!row.isEmpty()) { + StringBuilder sb = new StringBuilder("{ \"level\": \"" + mLevel + "\""); + sb.append(", \"videos\": ["); + boolean first = true; + for (Column<String> c : row) { + if (!first) { + sb.append(", "); + } + sb.append(c.getStringValue()); + first = false; + } + sb.append("] }"); + result = sb.toString(); + } + + } else { + // Get single video + result = mDb.getKey("strings", "/training/" + mLevel, mVideoId); + } + + if (result == null) { + // 404 + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + return new StringRepresentation(result); + } +} diff --git a/src/com/p4square/grow/frontend/GrowFrontend.java b/src/com/p4square/grow/frontend/GrowFrontend.java index 226929d..74cd704 100644 --- a/src/com/p4square/grow/frontend/GrowFrontend.java +++ b/src/com/p4square/grow/frontend/GrowFrontend.java @@ -4,8 +4,14 @@ package com.p4square.grow.frontend; +import java.io.File; +import java.io.IOException; + +import org.restlet.Application; import org.restlet.Component; +import org.restlet.Restlet; import org.restlet.data.Protocol; +import org.restlet.resource.Directory; import org.restlet.routing.Router; import org.apache.log4j.Logger; @@ -71,6 +77,8 @@ public class GrowFrontend extends FMFacade { final Router accountRouter = new Router(getContext()); accountRouter.attach("/assessment/question/{questionId}", SurveyPageResource.class); accountRouter.attach("/assessment", SurveyPageResource.class); + accountRouter.attach("/training/{chapter}", TrainingPageResource.class); + accountRouter.attach("/training", TrainingPageResource.class); final LoginAuthenticator accountGuard = new LoginAuthenticator(getContext(), false, loginPage); @@ -88,6 +96,17 @@ public class GrowFrontend extends FMFacade { final Component component = new Component(); component.getServers().add(Protocol.HTTP, 8085); component.getClients().add(Protocol.HTTP); + component.getClients().add(Protocol.FILE); + + // Static content + try { + component.getDefaultHost().attach("/images/", new FileServingApp("./build/images/")); + component.getDefaultHost().attach("/scripts", new FileServingApp("./build/scripts")); + component.getDefaultHost().attach("/style.css", new FileServingApp("./build/style.css")); + } catch (IOException e) { + cLog.error("Could not create directory for static resources: " + + e.getMessage(), e); + } // Setup App GrowFrontend app = new GrowFrontend(); @@ -119,4 +138,17 @@ public class GrowFrontend extends FMFacade { cLog.fatal("Could not start: " + e.getMessage(), e); } } + + private static class FileServingApp extends Application { + private final String mPath; + + public FileServingApp(String path) throws IOException { + mPath = new File(path).getAbsolutePath(); + } + + @Override + public Restlet createInboundRoot() { + return new Directory(getContext(), "file://" + mPath); + } + } } diff --git a/src/com/p4square/grow/frontend/SurveyPageResource.java b/src/com/p4square/grow/frontend/SurveyPageResource.java index 280184b..8145c0d 100644 --- a/src/com/p4square/grow/frontend/SurveyPageResource.java +++ b/src/com/p4square/grow/frontend/SurveyPageResource.java @@ -115,6 +115,7 @@ public class SurveyPageResource extends FreeMarkerPageResource { protected Representation post(Representation entity) { final Form form = new Form(entity); final String answerId = form.getFirstValue("answer"); + final String direction = form.getFirstValue("direction"); if (mQuestionId == null || answerId == null || answerId.length() == 0) { // Something is wrong. @@ -154,9 +155,26 @@ public class SurveyPageResource extends FreeMarkerPageResource { // Find the next question or finish the assessment. String nextPage = mConfig.getString("dynamicRoot", ""); { - String nextQuestionId = (String) questionData.get("nextQuestion"); + String nextQuestionId = null; + if ("previous".equals(direction)) { + nextQuestionId = (String) questionData.get("previousQuestion"); + } else { + nextQuestionId = (String) questionData.get("nextQuestion"); + } + if (nextQuestionId == null) { - nextPage += "/account/assessment/results"; + //nextPage += "/account/assessment/results"; + // TODO: Remove this hack: + JsonResponse response = backendGet("/accounts/" + mUserId + "/assessment"); + if (!response.getStatus().isSuccess()) { + nextPage += "/account/assessment/results"; + } else { + final String score = (String) response.getMap().get("result"); + if (score != null) { + nextPage += "/account/training/" + score; + } + } + } else { nextPage += "/account/assessment/question/" + nextQuestionId; } diff --git a/src/com/p4square/grow/frontend/TrainingPageResource.java b/src/com/p4square/grow/frontend/TrainingPageResource.java new file mode 100644 index 0000000..6493638 --- /dev/null +++ b/src/com/p4square/grow/frontend/TrainingPageResource.java @@ -0,0 +1,228 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +import freemarker.template.Template; + +import org.restlet.data.Form; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.ext.freemarker.TemplateRepresentation; +import org.restlet.representation.Representation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import net.jesterpm.fmfacade.json.JsonRequestClient; +import net.jesterpm.fmfacade.json.JsonResponse; + +import net.jesterpm.fmfacade.FreeMarkerPageResource; + +import com.p4square.grow.config.Config; + +/** + * SurveyPageResource handles rendering the survey and processing user's answers. + * + * This resource expects the user to be authenticated and the ClientInfo User object + * to be populated. Each question is requested from the backend along with the + * user's previous answer. Each answer is sent to the backend and the user is redirected + * to the next question. After the last question the user is sent to his results. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TrainingPageResource extends FreeMarkerPageResource { + private static Logger cLog = Logger.getLogger(TrainingPageResource.class); + + private Config mConfig; + private Template mTrainingTemplate; + private JsonRequestClient mJsonClient; + + // Fields pertaining to this request. + private String mChapter; + private String mUserId; + + @Override + public void doInit() { + super.doInit(); + + GrowFrontend growFrontend = (GrowFrontend) getApplication(); + mConfig = growFrontend.getConfig(); + mTrainingTemplate = growFrontend.getTemplate("templates/training.ftl"); + if (mTrainingTemplate == null) { + cLog.fatal("Could not find training template."); + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + mJsonClient = new JsonRequestClient(getContext().getClientDispatcher()); + + mChapter = getAttribute("chapter"); + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + } + + /** + * Return a page with a survey question. + */ + @Override + protected Representation get() { + try { + // Get the current chapter. + if (mChapter == null) { + // TODO: Get user's current question + mChapter = "seeker"; + } + + // Get videos for the chapter. + List<Map<String, Object>> videos = null; + { + JsonResponse response = backendGet("/training/" + mChapter); + if (!response.getStatus().isSuccess()) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + videos = (List<Map<String, Object>>) response.getMap().get("videos"); + } + + // Get list of completed videos + Map<String, Object> trainingRecord = null; + Map<String, Object> completedVideos = new HashMap<String, Object>(); + { + JsonResponse response = backendGet("/accounts/" + mUserId + "/training"); + if (response.getStatus().isSuccess()) { + trainingRecord = response.getMap(); + completedVideos = (Map<String, Object>) trainingRecord.get("videos"); + } + } + + // Mark the completed videos as completed + int chapterProgress = 0; + for (Map<String, Object> video : videos) { + boolean completed = (null != completedVideos.get(video.get("id"))); + video.put("completed", completed); + + if (completed) { + chapterProgress++; + } + } + chapterProgress = chapterProgress * 100 / videos.size(); + + Map root = getRootObject(); + root.put("chapter", mChapter); + root.put("chapterProgress", chapterProgress); + root.put("videos", videos); + root.put("completedVideos", completedVideos); + + return new TemplateRepresentation(mTrainingTemplate, root, MediaType.TEXT_HTML); + + } catch (Exception e) { + cLog.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * Record a survey answer and redirect to the next question. + */ + @Override + protected Representation post(Representation entity) { + return null; + /*final Form form = new Form(entity); + final String answerId = form.getFirstValue("answer"); + + if (mQuestionId == null || answerId == null || answerId.length() == 0) { + // Something is wrong. + setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + return null; + } + + try { + // Find the question + Map questionData = null; + { + JsonResponse response = backendGet("/assessment/question/" + mQuestionId); + if (!response.getStatus().isSuccess()) { + // User is answering a question which doesn't exist + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + questionData = response.getMap(); + } + + // Store answer + { + Map<String, String> answer = new HashMap<String, String>(); + answer.put("answerId", answerId); + JsonResponse response = backendPut("/accounts/" + mUserId + + "/assessment/answers/" + mQuestionId, answer); + + if (!response.getStatus().isSuccess()) { + // Something went wrong talking to the backend, error out. + cLog.fatal("Error recording survey answer " + response.getStatus()); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + // Find the next question or finish the assessment. + String nextPage = mConfig.getString("dynamicRoot", ""); + { + String nextQuestionId = (String) questionData.get("nextQuestion"); + if (nextQuestionId == null) { + nextPage += "/account/assessment/results"; + } else { + nextPage += "/account/assessment/question/" + nextQuestionId; + } + } + + getResponse().redirectSeeOther(nextPage); + return null; + + } catch (Exception e) { + cLog.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + }*/ + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } + + /** + * Helper method to send a GET to the backend. + */ + private JsonResponse backendGet(final String uri) { + cLog.debug("Sending backend GET " + uri); + + final JsonResponse response = mJsonClient.get(getBackendEndpoint() + uri); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + cLog.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } + + private JsonResponse backendPut(final String uri, final Map data) { + cLog.debug("Sending backend PUT " + uri); + + final JsonResponse response = mJsonClient.put(getBackendEndpoint() + uri, data); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + cLog.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } +} diff --git a/src/grow.properties b/src/grow.properties index b1bd989..1f76237 100644 --- a/src/grow.properties +++ b/src/grow.properties @@ -1 +1,13 @@ +# Frontend Settings dev.backendUri = http://localhost:9095 +dev.staticRoot = +dev.dynamicRoot = + +*.backendUri = riap://component/backend +*.staticRoot = /grow-frontend +*.dynamicRoot = /grow-frontend + +# Backend Settings +dev.clusterName = Dev Cluster + +prod.clusterName = Prod Cluster diff --git a/src/log4j.properties b/src/log4j.properties new file mode 100644 index 0000000..c1e2ecb --- /dev/null +++ b/src/log4j.properties @@ -0,0 +1,15 @@ +# Set root logger level to DEBUG and its only appender to A1. +log4j.rootLogger=DEBUG, stdout, logfile + +log4j.loggger.org.eclipse = WARN + +# stdout appender +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%-4r [%t] %-5p %c %x - %m%n + +# service.log appender +log4j.appender.logfile=org.apache.log4j.FileAppender +log4j.appender.logfile.File=service.log +log4j.appender.logfile.layout=org.apache.log4j.PatternLayout +log4j.appender.logfile.layout.ConversionPattern=%-4r [%t] %-5p %c %x - %m%n diff --git a/src/templates/macros/common-page.ftl b/src/templates/macros/common-page.ftl index 5fa2740..9b4323d 100644 --- a/src/templates/macros/common-page.ftl +++ b/src/templates/macros/common-page.ftl @@ -6,6 +6,7 @@ <link rel="stylesheet" href="${staticRoot}/style.css" /> <script src="${staticRoot}/scripts/jquery.min.js"></script> + <script src="${staticRoot}/scripts/jquery-ui.js"></script> <script src="${staticRoot}/scripts/growth.js"></script> </head> <body> diff --git a/src/templates/macros/common.ftl b/src/templates/macros/common.ftl index ab0d769..513fc57 100644 --- a/src/templates/macros/common.ftl +++ b/src/templates/macros/common.ftl @@ -1,4 +1,5 @@ <#include "content.ftl"> <#include "noticebox.ftl"> -<#assign contentroot = "http://localhost/~jesterpm/growcontent"> +<#assign dynamicRoot = ""> +<#assign staticRoot = ""> diff --git a/src/templates/pages/contact.html.ftl b/src/templates/pages/contact.html.ftl new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/templates/pages/contact.html.ftl diff --git a/src/templates/pages/video.html.ftl b/src/templates/pages/video.html.ftl new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/templates/pages/video.html.ftl diff --git a/src/templates/templates/question-image.ftl b/src/templates/templates/question-image.ftl index f117256..0cf815c 100644 --- a/src/templates/templates/question-image.ftl +++ b/src/templates/templates/question-image.ftl @@ -1,9 +1,9 @@ <div class="imageQuestion question"> <#list question.answers?keys as answerid> <#if selectedAnswerId?? && answerid == selectedAnswerId> - <a href="#" class="answer" id="${answerid}" onclick="selectAnswer(this)" class="selected"><img src="${staticRoot}/images/${question.id}-${answerid}.png" alt="${question.answers[answerid]!}" /></a> + <img class="answer selected" id="${answerid}" onclick="selectAnswer(this)" src="${staticRoot}/images/${question.id}-${answerid}-hover.jpg" /> <#else> - <a href="#" class="answer" id="${answerid}" onclick="selectAnswer(this)" class="answer"><img src="${staticRoot}/images/${question.id}-${answerid}.png" alt="${question.answers[answerid]!}" /></a> + <img class="answer" id="${answerid}" onclick="selectAnswer(this)" src="${staticRoot}/images/${question.id}-${answerid}.jpg" /> </#if> </#list> </div> diff --git a/src/templates/templates/question-quad.ftl b/src/templates/templates/question-quad.ftl new file mode 100644 index 0000000..90a7381 --- /dev/null +++ b/src/templates/templates/question-quad.ftl @@ -0,0 +1,16 @@ +<div class="quadQuestion question"> + <div class="top">${question.top}</div> + <div class="middle"> + <div class="left">${question.left}</div> + <div class="quad"><img src="${staticRoot}/images/quadselector.png" class="selector" /></div> + <div class="right">${question.right}</div> + </div> + <div class="bottom">${question.bottom}</div> +</div> + +<h1>${question.question}</h1> +<#if question.description??> +<p> + ${question.description} +</p> +</#if> diff --git a/src/templates/templates/question-slider.ftl b/src/templates/templates/question-slider.ftl new file mode 100644 index 0000000..faded36 --- /dev/null +++ b/src/templates/templates/question-slider.ftl @@ -0,0 +1,16 @@ +<h1>${question.question}</h1> +<#if question.description??> +<p> + ${question.description} +</p> +</#if> + +<div class="sliderQuestion question"> + <div class="sliderbar"><img src="${staticRoot}/images/slider.png" class="slider" /></div> + <div class="answers"> + <#list question.answers?keys as answerid> + <div id="${answerid}" class="label">${question.answers[answerid].text}</div> + </#list> + <span class="stretch"></span> + </div> +</div> diff --git a/src/templates/templates/survey.ftl b/src/templates/templates/survey.ftl index 9ea47d5..da4e0b3 100644 --- a/src/templates/templates/survey.ftl +++ b/src/templates/templates/survey.ftl @@ -14,11 +14,14 @@ <div id="content"> <form id="questionForm" action="${dynamicRoot}/account/assessment/question/${question.id}" method="post"> + <input id="direction" type="hidden" name="direction" value="next" /> <input id="answerField" type="hidden" name="answer" value="${selectedAnswerId!}" /> <div id="previous"> + <#if question.previousQuestion??> <a href="#" onclick="previousQuestion();return false;"> <img src="${staticRoot}/images/previous.png" alt="Previous Question" /> </a> + </#if> </div> <div id="next"> <a href="#" onclick="nextQuestion();return false;"> @@ -33,6 +36,12 @@ <#case "image"> <#include "/templates/question-image.ftl"> <#break> + <#case "slider"> + <#include "/templates/question-slider.ftl"> + <#break> + <#case "quad"> + <#include "/templates/question-quad.ftl"> + <#break> </#switch> </article> </form> diff --git a/src/templates/templates/training.ftl b/src/templates/templates/training.ftl new file mode 100644 index 0000000..3207c33 --- /dev/null +++ b/src/templates/templates/training.ftl @@ -0,0 +1,71 @@ +<#include "/macros/common.ftl"> +<#include "/macros/common-page.ftl"> + +<@commonpage> + <@noticebox> + The Grow Process focuses on the topic that you want to learn + about. Our 'Assessment' test will give you the right courses + fit for your level. + </@noticebox> + + <div id="progressbar"> + <#switch chapter> + <#case "seeker"><#assign overallProgress = 0><#break> + <#case "believer"><#assign overallProgress = 25><#break> + <#case "disciple"><#assign overallProgress = 50><#break> + <#case "teacher"><#assign overallProgress = 75><#break> + </#switch> + <div class="progress" style="width: ${overallProgress}%"></div> + </div> + + <div id="content"> + <nav> + <#assign chapters = ["seeker", "believer", "disciple", "teacher"]> + <#list chapters as x> + <a href="${dynamicRoot}/account/training/${x}" <#if x == chapter>class="current"</#if>>${x?capitalize}</a> + <#if x_has_next> - </#if> + </#list> + </nav> + + <div id="chapterprogress"> + "${chapter?capitalize} Chapter Progress:" + <div class="progressbar"><div class="progress" style="width: ${chapterProgress}%"></div></div> + <div class="progresslabel" style="left:${chapterProgress}%">${chapterProgress}%</div> + </div> + + <div id="videos"> + <#list videos as video> + <article> + <div class="image <#if video.completed>completed</#if>"><a href="#"><img src="${video.image!staticRoot+"/images/videoimage.jpg"}" alt="${video.title}" /></a></div> + <h2>${video.title}</h2> <span class="duration">${video.length}</span> + </article> + </#list> + </div> + + <div id="thefeed"> + <h2>The Feed</h2> + <article> + <div class="question"> + Q: How has God worked in your life... + <div><a class="reply" href="#">Answer</a></div> + </div> + <div class="answer"> + A: God has worked in an amazing way in my own life. I constantly + <a class="readmore" href="#">(continue)</a> + </div> + </article> + <article> + <div class="question"> + Q: How has God worked in your life... + <div><a class="reply" href="#">Answer</a></div> + </div> + <div class="answer"> + A: God has worked in an amazing way in my own life. I constantly + <a class="readmore" href="#">(continue)</a> + </div> + </article> + </div> + </div> +</@commonpage> + + |