summaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorJesse Morgan <jesse@jesterpm.net>2016-04-09 14:22:20 -0700
committerJesse Morgan <jesse@jesterpm.net>2016-04-09 15:48:01 -0700
commit3102d8bce3426d9cf41aeaf201c360d342677770 (patch)
tree38c4f1e8828f9af9c4b77a173bee0d312b321698 /src/main/java
parentbbf907e51dfcf157bdee24dead1d531122aa25db (diff)
Switching from Ivy+Ant to Maven.
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/com/p4square/f1oauth/Attribute.java90
-rw-r--r--src/main/java/com/p4square/f1oauth/F1API.java56
-rw-r--r--src/main/java/com/p4square/f1oauth/F1Access.java594
-rw-r--r--src/main/java/com/p4square/f1oauth/F1Exception.java15
-rw-r--r--src/main/java/com/p4square/f1oauth/F1ProgressReporter.java57
-rw-r--r--src/main/java/com/p4square/f1oauth/F1User.java70
-rw-r--r--src/main/java/com/p4square/f1oauth/FellowshipOneIntegrationDriver.java55
-rw-r--r--src/main/java/com/p4square/f1oauth/SecondPartyAuthenticator.java52
-rw-r--r--src/main/java/com/p4square/f1oauth/SecondPartyVerifier.java72
-rw-r--r--src/main/java/com/p4square/fmfacade/FMFacade.java107
-rw-r--r--src/main/java/com/p4square/fmfacade/FreeMarkerPageResource.java98
-rw-r--r--src/main/java/com/p4square/fmfacade/ftl/GetMethod.java94
-rw-r--r--src/main/java/com/p4square/fmfacade/json/ClientException.java20
-rw-r--r--src/main/java/com/p4square/fmfacade/json/JsonRequestClient.java109
-rw-r--r--src/main/java/com/p4square/fmfacade/json/JsonResponse.java87
-rw-r--r--src/main/java/com/p4square/grow/GrowProcessComponent.java166
-rw-r--r--src/main/java/com/p4square/grow/backend/BackendVerifier.java92
-rw-r--r--src/main/java/com/p4square/grow/backend/CassandraGrowData.java172
-rw-r--r--src/main/java/com/p4square/grow/backend/DynamoGrowData.java180
-rw-r--r--src/main/java/com/p4square/grow/backend/GrowBackend.java211
-rw-r--r--src/main/java/com/p4square/grow/backend/GrowData.java36
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java109
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java212
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraKey.java34
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java37
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java71
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DbTool.java481
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java109
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java307
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java56
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java37
-rw-r--r--src/main/java/com/p4square/grow/backend/feed/FeedDataProvider.java33
-rw-r--r--src/main/java/com/p4square/grow/backend/feed/ThreadResource.java106
-rw-r--r--src/main/java/com/p4square/grow/backend/feed/TopicResource.java117
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/AccountResource.java87
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/BannerResource.java85
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/SurveyResource.java115
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java253
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java235
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/TrainingResource.java97
-rw-r--r--src/main/java/com/p4square/grow/ccb/CCBProgressReporter.java104
-rw-r--r--src/main/java/com/p4square/grow/ccb/CCBUser.java37
-rw-r--r--src/main/java/com/p4square/grow/ccb/CCBUserVerifier.java50
-rw-r--r--src/main/java/com/p4square/grow/ccb/ChurchCommunityBuilderIntegrationDriver.java61
-rw-r--r--src/main/java/com/p4square/grow/ccb/CustomFieldCache.java126
-rw-r--r--src/main/java/com/p4square/grow/ccb/MonitoredCCBAPI.java96
-rw-r--r--src/main/java/com/p4square/grow/config/Config.java203
-rw-r--r--src/main/java/com/p4square/grow/frontend/AccountRedirectResource.java113
-rw-r--r--src/main/java/com/p4square/grow/frontend/AssessmentResetPage.java99
-rw-r--r--src/main/java/com/p4square/grow/frontend/AssessmentResultsPage.java145
-rw-r--r--src/main/java/com/p4square/grow/frontend/AuthenticatedResource.java18
-rw-r--r--src/main/java/com/p4square/grow/frontend/ChapterCompletePage.java209
-rw-r--r--src/main/java/com/p4square/grow/frontend/ErrorPage.java77
-rw-r--r--src/main/java/com/p4square/grow/frontend/FeedData.java105
-rw-r--r--src/main/java/com/p4square/grow/frontend/FeedResource.java101
-rw-r--r--src/main/java/com/p4square/grow/frontend/GroupLeaderTrainingPageResource.java26
-rw-r--r--src/main/java/com/p4square/grow/frontend/GrowFrontend.java230
-rw-r--r--src/main/java/com/p4square/grow/frontend/IntegrationDriver.java26
-rw-r--r--src/main/java/com/p4square/grow/frontend/JsonRequestProvider.java96
-rw-r--r--src/main/java/com/p4square/grow/frontend/LoginFormAuthenticator.java146
-rw-r--r--src/main/java/com/p4square/grow/frontend/LoginPageResource.java77
-rw-r--r--src/main/java/com/p4square/grow/frontend/LogoutResource.java40
-rw-r--r--src/main/java/com/p4square/grow/frontend/NewAccountResource.java135
-rw-r--r--src/main/java/com/p4square/grow/frontend/NewBelieverResource.java72
-rw-r--r--src/main/java/com/p4square/grow/frontend/NotFoundException.java13
-rw-r--r--src/main/java/com/p4square/grow/frontend/ProgressReporter.java30
-rw-r--r--src/main/java/com/p4square/grow/frontend/SurveyPageResource.java343
-rw-r--r--src/main/java/com/p4square/grow/frontend/TrainingPageResource.java268
-rw-r--r--src/main/java/com/p4square/grow/frontend/VideosResource.java133
-rw-r--r--src/main/java/com/p4square/grow/model/Answer.java142
-rw-r--r--src/main/java/com/p4square/grow/model/Banner.java20
-rw-r--r--src/main/java/com/p4square/grow/model/Chapter.java112
-rw-r--r--src/main/java/com/p4square/grow/model/CircleQuestion.java89
-rw-r--r--src/main/java/com/p4square/grow/model/ImageQuestion.java24
-rw-r--r--src/main/java/com/p4square/grow/model/Message.java103
-rw-r--r--src/main/java/com/p4square/grow/model/MessageThread.java60
-rw-r--r--src/main/java/com/p4square/grow/model/Playlist.java192
-rw-r--r--src/main/java/com/p4square/grow/model/Point.java79
-rw-r--r--src/main/java/com/p4square/grow/model/QuadQuestion.java89
-rw-r--r--src/main/java/com/p4square/grow/model/QuadScoringEngine.java49
-rw-r--r--src/main/java/com/p4square/grow/model/Question.java165
-rw-r--r--src/main/java/com/p4square/grow/model/RecordedAnswer.java34
-rw-r--r--src/main/java/com/p4square/grow/model/Score.java119
-rw-r--r--src/main/java/com/p4square/grow/model/ScoringEngine.java26
-rw-r--r--src/main/java/com/p4square/grow/model/SimpleScoringEngine.java26
-rw-r--r--src/main/java/com/p4square/grow/model/SliderQuestion.java24
-rw-r--r--src/main/java/com/p4square/grow/model/SliderScoringEngine.java35
-rw-r--r--src/main/java/com/p4square/grow/model/TextQuestion.java24
-rw-r--r--src/main/java/com/p4square/grow/model/TrainingRecord.java49
-rw-r--r--src/main/java/com/p4square/grow/model/UserRecord.java183
-rw-r--r--src/main/java/com/p4square/grow/model/VideoRecord.java85
-rw-r--r--src/main/java/com/p4square/grow/provider/CollectionProvider.java59
-rw-r--r--src/main/java/com/p4square/grow/provider/DelegateCollectionProvider.java69
-rw-r--r--src/main/java/com/p4square/grow/provider/DelegateProvider.java40
-rw-r--r--src/main/java/com/p4square/grow/provider/JsonEncodedProvider.java83
-rw-r--r--src/main/java/com/p4square/grow/provider/MapCollectionProvider.java74
-rw-r--r--src/main/java/com/p4square/grow/provider/MapProvider.java28
-rw-r--r--src/main/java/com/p4square/grow/provider/Provider.java31
-rw-r--r--src/main/java/com/p4square/grow/provider/ProvidesAssessments.java20
-rw-r--r--src/main/java/com/p4square/grow/provider/ProvidesQuestions.java19
-rw-r--r--src/main/java/com/p4square/grow/provider/ProvidesStrings.java19
-rw-r--r--src/main/java/com/p4square/grow/provider/ProvidesTrainingRecords.java27
-rw-r--r--src/main/java/com/p4square/grow/provider/ProvidesUserRecords.java19
-rw-r--r--src/main/java/com/p4square/grow/provider/ProvidesVideos.java16
-rw-r--r--src/main/java/com/p4square/grow/provider/TrainingRecordProvider.java41
-rw-r--r--src/main/java/com/p4square/grow/tools/AssessmentStats.java218
-rw-r--r--src/main/java/com/p4square/grow/tools/AttributeBackfillTool.java268
-rw-r--r--src/main/java/com/p4square/grow/tools/AttributeTool.java184
-rw-r--r--src/main/java/com/p4square/restlet/metrics/MetricRouter.java61
-rw-r--r--src/main/java/com/p4square/restlet/metrics/MetricsApplication.java43
-rw-r--r--src/main/java/com/p4square/restlet/metrics/MetricsResource.java32
-rw-r--r--src/main/java/com/p4square/restlet/oauth/OAuthAuthenticator.java95
-rw-r--r--src/main/java/com/p4square/restlet/oauth/OAuthAuthenticatorHelper.java177
-rw-r--r--src/main/java/com/p4square/restlet/oauth/OAuthException.java25
-rw-r--r--src/main/java/com/p4square/restlet/oauth/OAuthHelper.java149
-rw-r--r--src/main/java/com/p4square/restlet/oauth/OAuthUser.java50
-rw-r--r--src/main/java/com/p4square/restlet/oauth/Token.java52
-rw-r--r--src/main/java/com/p4square/session/Session.java59
-rw-r--r--src/main/java/com/p4square/session/SessionAuthenticator.java36
-rw-r--r--src/main/java/com/p4square/session/SessionCheckingAuthenticator.java39
-rw-r--r--src/main/java/com/p4square/session/SessionCookieAuthenticator.java59
-rw-r--r--src/main/java/com/p4square/session/SessionCreatingAuthenticator.java46
-rw-r--r--src/main/java/com/p4square/session/Sessions.java155
123 files changed, 12215 insertions, 0 deletions
diff --git a/src/main/java/com/p4square/f1oauth/Attribute.java b/src/main/java/com/p4square/f1oauth/Attribute.java
new file mode 100644
index 0000000..64f2507
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/Attribute.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.f1oauth;
+
+import java.util.Date;
+
+/**
+ * F1 Attribute Data.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Attribute {
+ private final String mAttributeName;
+ private String mId;
+ private Date mStartDate;
+ private Date mEndDate;
+ private String mComment;
+
+ /**
+ * @param name The attribute name.
+ */
+ public Attribute(final String name) {
+ mAttributeName = name;
+ }
+
+ /**
+ * @return the Attribute name.
+ */
+ public String getAttributeName() {
+ return mAttributeName;
+ }
+
+ /**
+ * @return the id of this specific attribute instance.
+ */
+ public String getId() {
+ return mId;
+ }
+
+ /**
+ * Set the attribute id to id.
+ */
+ public void setId(final String id) {
+ mId = id;
+ }
+
+ /**
+ * @return the start date for the attribute.
+ */
+ public Date getStartDate() {
+ return mStartDate;
+ }
+
+ /**
+ * Set the start date for the attribute.
+ */
+ public void setStartDate(final Date date) {
+ mStartDate = date;
+ }
+
+ /**
+ * @return the end date for the attribute.
+ */
+ public Date getEndDate() {
+ return mEndDate;
+ }
+
+ /**
+ * Set the end date for the attribute.
+ */
+ public void setEndDate(final Date date) {
+ mEndDate = date;
+ }
+
+ /**
+ * @return The comment on the Attribute.
+ */
+ public String getComment() {
+ return mComment;
+ }
+
+ /**
+ * Set the comment on the attribute.
+ */
+ public void setComment(final String comment) {
+ mComment = comment;
+ }
+}
diff --git a/src/main/java/com/p4square/f1oauth/F1API.java b/src/main/java/com/p4square/f1oauth/F1API.java
new file mode 100644
index 0000000..a525c3f
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/F1API.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.f1oauth;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import com.p4square.restlet.oauth.OAuthException;
+import com.p4square.restlet.oauth.OAuthUser;
+
+/**
+ * F1 API methods which require an authenticated user.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface F1API {
+ /**
+ * Fetch information about a user.
+ *
+ * @param user The user to fetch information about.
+ * @return An F1User object.
+ */
+ F1User getF1User(OAuthUser user) throws OAuthException, IOException;
+
+ /**
+ * Fetch a list of all attributes ids and names.
+ *
+ * @return A Map of attribute name to attribute id.
+ */
+ Map<String, String> getAttributeList() throws F1Exception;
+
+ /**
+ * Add an attribute to the user.
+ *
+ * @param user The user to add the attribute to.
+ * @param attributeName The attribute to add.
+ * @param attribute The attribute to add.
+ */
+ boolean addAttribute(String userId, Attribute attribute) throws F1Exception;
+
+ /**
+ * Return attributes assigned to user.
+ *
+ * A user may be assigned multiple attributes with the same name, thus even if
+ * attributeName is specified, multiple attributes may be returned.
+ *
+ * @param userId The user to query.
+ * @param attributeName A specific attribute to return, null for all.
+ * @return A list of Attributes
+ */
+ List<Attribute> getAttribute(String userId, String attributeName) throws F1Exception;
+
+}
diff --git a/src/main/java/com/p4square/f1oauth/F1Access.java b/src/main/java/com/p4square/f1oauth/F1Access.java
new file mode 100644
index 0000000..c3307f1
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/F1Access.java
@@ -0,0 +1,594 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.f1oauth;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.data.ChallengeResponse;
+import org.restlet.data.ChallengeScheme;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Status;
+import org.restlet.engine.util.Base64;
+import org.restlet.ext.jackson.JacksonRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+
+import com.p4square.restlet.oauth.OAuthException;
+import com.p4square.restlet.oauth.OAuthHelper;
+import com.p4square.restlet.oauth.OAuthUser;
+import com.p4square.restlet.oauth.Token;
+
+/**
+ * F1 API Access.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class F1Access {
+ public enum UserType {
+ WEBLINK, PORTAL;
+ }
+
+ private static final Logger LOG = Logger.getLogger(F1Access.class);
+
+ private static final String VERSION_STRING = "/v1/";
+ private static final String REQUESTTOKEN_URL = "Tokens/RequestToken";
+ private static final String AUTHORIZATION_URL = "Login";
+ private static final String ACCESSTOKEN_URL= "Tokens/AccessToken";
+ private static final String TRUSTED_ACCESSTOKEN_URL = "/AccessToken";
+
+ private static final SimpleDateFormat DATE_FORMAT =
+ new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
+
+ private final String mBaseUrl;
+ private final String mMethod;
+
+ private final OAuthHelper mOAuthHelper;
+
+ private final Map<String, String> mAttributeIdByName;
+
+ private MetricRegistry mMetricRegistry;
+
+ /**
+ */
+ public F1Access(Context context, String consumerKey, String consumerSecret,
+ String baseUrl, String churchCode, UserType userType) {
+
+ switch (userType) {
+ case WEBLINK:
+ mMethod = "WeblinkUser";
+ break;
+ case PORTAL:
+ mMethod = "PortalUser";
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown UserType");
+ }
+
+ mBaseUrl = "https://" + churchCode + "." + baseUrl + VERSION_STRING;
+
+ // Create the OAuthHelper. This implicitly registers the helper to
+ // handle outgoing requests which need OAuth authentication.
+ mOAuthHelper = new OAuthHelper(context, consumerKey, consumerSecret) {
+ @Override
+ protected String getRequestTokenUrl() {
+ return mBaseUrl + REQUESTTOKEN_URL;
+ }
+
+ @Override
+ public String getLoginUrl(Token requestToken, String callback) {
+ String loginUrl = mBaseUrl + mMethod + AUTHORIZATION_URL
+ + "?oauth_token=" + URLEncoder.encode(requestToken.getToken());
+
+ if (callback != null) {
+ loginUrl += "&oauth_callback=" + URLEncoder.encode(callback);
+ }
+
+ return loginUrl;
+ }
+
+ @Override
+ protected String getAccessTokenUrl() {
+ return mBaseUrl + ACCESSTOKEN_URL;
+ }
+ };
+
+ mAttributeIdByName = new HashMap<>();
+ }
+
+ /**
+ * Set the MetricRegistry to get metrics recorded.
+ */
+ public void setMetricRegistry(MetricRegistry metrics) {
+ mMetricRegistry = metrics;
+ }
+
+ /**
+ * Request an AccessToken for a particular username and password.
+ *
+ * This is an F1 extension to OAuth:
+ * http://developer.fellowshipone.com/docs/v1/Util/AuthDocs.help#2creds
+ */
+ public OAuthUser getAccessToken(String username, String password) throws OAuthException {
+ Timer.Context timer = getTimer("F1Access.getAccessToken.time");
+ boolean success = true;
+
+ try {
+ Request request = new Request(Method.POST,
+ mBaseUrl + mMethod + TRUSTED_ACCESSTOKEN_URL);
+ request.setChallengeResponse(new ChallengeResponse(ChallengeScheme.HTTP_OAUTH));
+
+ String base64String = Base64.encode((username + " " + password).getBytes(), false);
+ request.setEntity(new StringRepresentation(base64String));
+
+ return mOAuthHelper.processAccessTokenRequest(request);
+
+ } catch (Exception e) {
+ success = false;
+ throw e;
+
+ } finally {
+ if (timer != null) {
+ timer.stop();
+ }
+ if (success) {
+ incrementCounter("F1Access.getAccessToken.success");
+ } else {
+ incrementCounter("F1Access.getAccessToken.failure");
+ }
+ }
+ }
+
+ /**
+ * Create a new Account.
+ *
+ * @param firstname The user's first name.
+ * @param lastname The user's last name.
+ * @param email The user's email address.
+ * @param redirect The URL to send the user to after confirming his address.
+ *
+ * @return true if created, false if the account already exists.
+ */
+ public boolean createAccount(String firstname, String lastname, String email, String redirect)
+ throws OAuthException {
+ Timer.Context timer = getTimer("F1Access.createAccount.time");
+ boolean success = true;
+
+ try {
+ String req = String.format("{\n\"account\":{\n\"firstName\":\"%s\",\n"
+ + "\"lastName\":\"%s\",\n\"email\":\"%s\",\n"
+ + "\"urlRedirect\":\"%s\"\n}\n}",
+ firstname, lastname, email, redirect);
+
+ Request request = new Request(Method.POST, mBaseUrl + "Accounts");
+ request.setChallengeResponse(new ChallengeResponse(ChallengeScheme.HTTP_OAUTH));
+ request.setEntity(new StringRepresentation(req, MediaType.APPLICATION_JSON));
+
+ Response response = mOAuthHelper.getResponse(request);
+
+ Status status = response.getStatus();
+ if (Status.SUCCESS_NO_CONTENT.equals(status)) {
+ return true;
+
+ } else if (Status.CLIENT_ERROR_CONFLICT.equals(status)) {
+ return false;
+
+ } else {
+ throw new OAuthException(status);
+ }
+
+ } catch (Exception e) {
+ success = false;
+ throw e;
+
+ } finally {
+ if (timer != null) {
+ timer.stop();
+ }
+ if (success) {
+ incrementCounter("F1Access.createAccount.success");
+ } else {
+ incrementCounter("F1Access.createAccount.failure");
+ }
+ }
+ }
+
+ /**
+ * @return An F1API authenticated by the given user.
+ */
+ public F1API getAuthenticatedApi(OAuthUser user) {
+ return new AuthenticatedApi(user);
+ }
+
+ private class AuthenticatedApi implements F1API {
+ private final OAuthUser mUser;
+
+ public AuthenticatedApi(OAuthUser user) {
+ mUser = user;
+ }
+
+ /**
+ * Fetch information about a user.
+ *
+ * @param user The user to fetch information about.
+ * @return An F1User object.
+ */
+ @Override
+ public F1User getF1User(OAuthUser user) throws OAuthException, IOException {
+ Timer.Context timer = getTimer("F1Access.getF1User.time");
+ boolean success = true;
+
+ try {
+ Request request = new Request(Method.GET, user.getLocation() + ".json");
+ request.setChallengeResponse(mUser.getChallengeResponse());
+ Response response = mOAuthHelper.getResponse(request);
+
+ try {
+ Status status = response.getStatus();
+ if (status.isSuccess()) {
+ JacksonRepresentation<Map> entity =
+ new JacksonRepresentation<Map>(response.getEntity(), Map.class);
+ Map data = entity.getObject();
+ return new F1User(user, data);
+
+ } else {
+ throw new OAuthException(status);
+ }
+
+ } finally {
+ if (response.getEntity() != null) {
+ response.release();
+ }
+ }
+
+ } catch (Exception e) {
+ success = false;
+ throw e;
+
+ } finally {
+ if (timer != null) {
+ timer.stop();
+ }
+ if (success) {
+ incrementCounter("F1Access.getF1User.success");
+ } else {
+ incrementCounter("F1Access.getF1User.failure");
+ }
+ }
+ }
+
+ @Override
+ public Map<String, String> getAttributeList() throws F1Exception {
+ // Note: this list is shared by all F1 users.
+ synchronized (mAttributeIdByName) {
+ if (mAttributeIdByName.size() == 0) {
+ Timer.Context timer = getTimer("F1Access.getAttributeList.time");
+ boolean success = true;
+
+ try {
+ // Reload attributes. Maybe it will be there now...
+ Request request = new Request(Method.GET,
+ mBaseUrl + "People/AttributeGroups.json");
+ request.setChallengeResponse(mUser.getChallengeResponse());
+ Response response = mOAuthHelper.getResponse(request);
+
+ Representation representation = response.getEntity();
+ try {
+ Status status = response.getStatus();
+ if (status.isSuccess()) {
+ JacksonRepresentation<Map> entity =
+ new JacksonRepresentation<Map>(response.getEntity(), Map.class);
+
+ Map attributeGroups = (Map) entity.getObject().get("attributeGroups");
+ List<Map> groups = (List<Map>) attributeGroups.get("attributeGroup");
+
+ for (Map group : groups) {
+ List<Map> attributes = (List<Map>) group.get("attribute");
+ if (attributes != null) {
+ for (Map attribute : attributes) {
+ String id = (String) attribute.get("@id");
+ String name = ((String) attribute.get("name"));
+ mAttributeIdByName.put(name.toLowerCase(), id);
+ LOG.debug("Caching attribute '" + name
+ + "' with id '" + id + "'");
+ }
+ }
+ }
+ }
+
+ } catch (IOException e) {
+ throw new F1Exception("Could not parse AttributeGroups.", e);
+
+ } finally {
+ if (representation != null) {
+ representation.release();
+ }
+ }
+
+ } catch (Exception e) {
+ success = false;
+ throw e;
+
+ } finally {
+ if (timer != null) {
+ timer.stop();
+ }
+ if (success) {
+ incrementCounter("F1Access.getAttributeList.success");
+ } else {
+ incrementCounter("F1Access.getAttributeList.failure");
+ }
+ }
+ }
+
+ return mAttributeIdByName;
+ }
+ }
+
+ /**
+ * Add an attribute to the user.
+ *
+ * @param user The user to add the attribute to.
+ * @param attributeName The attribute to add.
+ * @param attribute The attribute to add.
+ */
+ public boolean addAttribute(String userId, Attribute attribute)
+ throws F1Exception {
+
+ // Get the attribute id.
+ String attributeId = getAttributeId(attribute.getAttributeName());
+ if (attributeId == null) {
+ throw new F1Exception("Could not find id for " + attribute.getAttributeName());
+ }
+
+ // Get Attribute Template
+ Map attributeTemplate = null;
+
+ Timer.Context timer = getTimer("F1Access.addAttribute.GET.time");
+ boolean success = true;
+
+ try {
+ Request request = new Request(Method.GET,
+ mBaseUrl + "People/" + userId + "/Attributes/new.json");
+ request.setChallengeResponse(mUser.getChallengeResponse());
+ Response response = mOAuthHelper.getResponse(request);
+
+ Representation representation = response.getEntity();
+ try {
+ Status status = response.getStatus();
+ if (status.isSuccess()) {
+ JacksonRepresentation<Map> entity =
+ new JacksonRepresentation<Map>(response.getEntity(), Map.class);
+ attributeTemplate = entity.getObject();
+
+ } else {
+ throw new F1Exception("Failed to retrieve attribute template: "
+ + status);
+ }
+
+ } catch (IOException e) {
+ throw new F1Exception("Could not parse attribute template.", e);
+
+ } finally {
+ if (representation != null) {
+ representation.release();
+ }
+ }
+ } catch (Exception e) {
+ success = false;
+ throw e;
+
+ } finally {
+ if (timer != null) {
+ timer.stop();
+ }
+ if (success) {
+ incrementCounter("F1Access.addAttribute.GET.success");
+ } else {
+ incrementCounter("F1Access.addAttribute.GET.failure");
+ }
+ }
+
+ if (attributeTemplate == null) {
+ throw new F1Exception("Could not retrieve attribute template.");
+ }
+
+ // Populate Attribute Template
+ Map attributeMap = (Map) attributeTemplate.get("attribute");
+ Map attributeGroup = (Map) attributeMap.get("attributeGroup");
+
+ Map<String, String> attributeIdMap = new HashMap<>();
+ attributeIdMap.put("@id", attributeId);
+ attributeGroup.put("attribute", attributeIdMap);
+
+ if (attribute.getStartDate() != null) {
+ attributeMap.put("startDate", DATE_FORMAT.format(attribute.getStartDate()));
+ }
+
+ if (attribute.getStartDate() != null) {
+ attributeMap.put("endDate", DATE_FORMAT.format(attribute.getStartDate()));
+ }
+
+ attributeMap.put("comment", attribute.getComment());
+
+ // POST new attribute
+ Status status;
+ timer = getTimer("F1Access.addAttribute.POST.time");
+ success = true;
+
+ try {
+ Request request = new Request(Method.POST,
+ mBaseUrl + "People/" + userId + "/Attributes.json");
+ request.setChallengeResponse(mUser.getChallengeResponse());
+ request.setEntity(new JacksonRepresentation<Map>(attributeTemplate));
+ Response response = mOAuthHelper.getResponse(request);
+
+ Representation representation = response.getEntity();
+ try {
+ status = response.getStatus();
+
+ if (status.isSuccess()) {
+ return true;
+ }
+
+ } finally {
+ if (representation != null) {
+ representation.release();
+ }
+ }
+ } catch (Exception e) {
+ success = false;
+ throw e;
+
+ } finally {
+ if (timer != null) {
+ timer.stop();
+ }
+ if (success) {
+ incrementCounter("F1Access.addAttribute.POST.success");
+ } else {
+ incrementCounter("F1Access.getAccessToken.POST.failure");
+ }
+ }
+
+ LOG.debug("addAttribute failed POST: " + status);
+ return false;
+ }
+
+ @Override
+ public List<Attribute> getAttribute(String userId, String attributeNameFilter)
+ throws F1Exception {
+
+ Map attributesResponse;
+
+ // Get Attributes
+ Timer.Context timer = getTimer("F1Access.getAttribute.time");
+ boolean success = true;
+
+ try {
+ Request request = new Request(Method.GET,
+ mBaseUrl + "People/" + userId + "/Attributes.json");
+ request.setChallengeResponse(mUser.getChallengeResponse());
+ Response response = mOAuthHelper.getResponse(request);
+
+ Representation representation = response.getEntity();
+ try {
+ Status status = response.getStatus();
+ if (status.isSuccess()) {
+ JacksonRepresentation<Map> entity =
+ new JacksonRepresentation<Map>(response.getEntity(), Map.class);
+ attributesResponse = entity.getObject();
+
+ } else {
+ throw new F1Exception("Failed to retrieve attributes: "
+ + status);
+ }
+
+ } catch (IOException e) {
+ throw new F1Exception("Could not parse attributes.", e);
+
+ } finally {
+ if (representation != null) {
+ representation.release();
+ }
+ }
+ } catch (Exception e) {
+ success = false;
+ throw e;
+
+ } finally {
+ if (timer != null) {
+ timer.stop();
+ }
+ if (success) {
+ incrementCounter("F1Access.getAttribute.success");
+ } else {
+ incrementCounter("F1Access.getAttribute.failure");
+ }
+ }
+
+ // Parse Response
+ List<Attribute> result = new ArrayList<>();
+
+ try {
+ // I feel like I'm writing lisp here...
+ Map attributesMap = (Map) attributesResponse.get("attributes");
+ if (attributesMap == null) {
+ return result;
+ }
+
+ List<Map> attributes = (List<Map>) (attributesMap).get("attribute");
+ for (Map attributeMap : attributes) {
+ String id = (String) attributeMap.get("@id");
+ String startDate = (String) attributeMap.get("startDate");
+ String endDate = (String) attributeMap.get("endDate");
+ String comment = (String) attributeMap.get("comment");
+
+ Map attributeIdMap = (Map) ((Map) attributeMap.get("attributeGroup"))
+ .get("attribute");
+ String attributeName = (String) attributeIdMap.get("name");
+
+ if (attributeNameFilter == null
+ || attributeNameFilter.equalsIgnoreCase(attributeName)) {
+
+ Attribute attribute = new Attribute(attributeName);
+ attribute.setId(id);
+ if (startDate != null) {
+ attribute.setStartDate(DATE_FORMAT.parse(startDate));
+ }
+ if (endDate != null) {
+ attribute.setEndDate(DATE_FORMAT.parse(endDate));
+ }
+ attribute.setComment(comment);
+ result.add(attribute);
+ }
+ }
+ } catch (Exception e) {
+ throw new F1Exception("Failed to parse attributes response.", e);
+ }
+
+ return result;
+ }
+
+ /**
+ * @return an attribute id for the given attribute name.
+ */
+ private String getAttributeId(String attributeName) throws F1Exception {
+ Map<String, String> attributeMap = getAttributeList();
+
+ return attributeMap.get(attributeName.toLowerCase());
+ }
+
+ }
+
+ private Timer.Context getTimer(String name) {
+ if (mMetricRegistry != null) {
+ return mMetricRegistry.timer(name).time();
+ } else {
+ return null;
+ }
+ }
+
+ private void incrementCounter(String name) {
+ if (mMetricRegistry != null) {
+ mMetricRegistry.counter(name).inc();
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/f1oauth/F1Exception.java b/src/main/java/com/p4square/f1oauth/F1Exception.java
new file mode 100644
index 0000000..54c1a77
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/F1Exception.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.f1oauth;
+
+public class F1Exception extends Exception {
+ public F1Exception(String message) {
+ super(message);
+ }
+
+ public F1Exception(String message, Exception cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/p4square/f1oauth/F1ProgressReporter.java b/src/main/java/com/p4square/f1oauth/F1ProgressReporter.java
new file mode 100644
index 0000000..8382020
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/F1ProgressReporter.java
@@ -0,0 +1,57 @@
+package com.p4square.f1oauth;
+
+import com.p4square.grow.frontend.ProgressReporter;
+import org.apache.log4j.Logger;
+import org.restlet.security.User;
+
+import java.util.Date;
+
+/**
+ * A ProgressReporter implementation to record progress in F1.
+ */
+public class F1ProgressReporter implements ProgressReporter {
+
+ private static final Logger LOG = Logger.getLogger(F1ProgressReporter.class);
+
+ private F1Access mF1Access;
+
+ public F1ProgressReporter(final F1Access f1access) {
+ mF1Access = f1access;
+ }
+
+ @Override
+ public void reportAssessmentComplete(final User user, final String level, final Date date, final String results) {
+ String attributeName = "Assessment Complete - " + level;
+ Attribute attribute = new Attribute(attributeName);
+ attribute.setStartDate(date);
+ attribute.setComment(results);
+ addAttribute(user, attribute);
+ }
+
+ @Override
+ public void reportChapterComplete(final User user, final String chapter, final Date date) {
+ final String attributeName = "Training Complete - " + chapter;
+ final Attribute attribute = new Attribute(attributeName);
+ attribute.setStartDate(date);
+ addAttribute(user, attribute);
+ }
+
+ private void addAttribute(final User user, final Attribute attribute) {
+ if (!(user instanceof F1User)) {
+ throw new IllegalArgumentException("User must be an F1User, but got " + user.getClass().getName());
+ }
+
+ try {
+ final F1User f1User = (F1User) user;
+ final F1API f1 = mF1Access.getAuthenticatedApi(f1User);
+
+ if (!f1.addAttribute(user.getIdentifier(), attribute)) {
+ LOG.error("addAttribute failed for " + user.getIdentifier() + " with attribute "
+ + attribute.getAttributeName());
+ }
+ } catch (Exception e) {
+ LOG.error("addAttribute failed for " + user.getIdentifier() + " with attribute "
+ + attribute.getAttributeName(), e);
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/f1oauth/F1User.java b/src/main/java/com/p4square/f1oauth/F1User.java
new file mode 100644
index 0000000..e5ab487
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/F1User.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.f1oauth;
+
+import java.util.Map;
+
+import com.p4square.restlet.oauth.OAuthException;
+import com.p4square.restlet.oauth.OAuthUser;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class F1User extends OAuthUser {
+ public static final String ID = "@id";
+ public static final String FIRST_NAME = "firstName";
+ public static final String LAST_NAME = "lastName";
+ public static final String ICODE = "@iCode";
+
+ private final Map mData;
+
+ /**
+ * Copy the user information from user into a new F1User.
+ *
+ * @param user Original user.
+ * @param data F1 Person Record.
+ * @throws IllegalStateException if data.get("person") is null.
+ */
+ public F1User(OAuthUser user, Map data) {
+ super(user.getLocation(), user.getToken());
+
+ mData = (Map) data.get("person");
+ if (mData == null) {
+ throw new IllegalStateException("Bad data");
+ }
+
+ setIdentifier(getString(ID));
+ setFirstName(getString(FIRST_NAME));
+ setLastName(getString(LAST_NAME));
+ }
+
+ /**
+ * Get a String from the map.
+ *
+ * @param key The map key.
+ * @return The value associated with the key, or null.
+ */
+ public String getString(String key) {
+ Object blob = get(key);
+
+ if (blob instanceof String) {
+ return (String) blob;
+
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Fetch an object from the F1 record.
+ *
+ * @param key The map key
+ * @return The object in the map or null.
+ */
+ public Object get(String key) {
+ return mData.get(key);
+ }
+}
diff --git a/src/main/java/com/p4square/f1oauth/FellowshipOneIntegrationDriver.java b/src/main/java/com/p4square/f1oauth/FellowshipOneIntegrationDriver.java
new file mode 100644
index 0000000..865f5d6
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/FellowshipOneIntegrationDriver.java
@@ -0,0 +1,55 @@
+package com.p4square.f1oauth;
+
+import com.codahale.metrics.MetricRegistry;
+import com.p4square.grow.config.Config;
+import com.p4square.grow.frontend.IntegrationDriver;
+import com.p4square.grow.frontend.ProgressReporter;
+import org.restlet.Context;
+import org.restlet.security.Verifier;
+
+/**
+ * The FellowshipOneIntegrationDriver creates implementations of various
+ * objects to support integration with Fellowship One.
+ */
+public class FellowshipOneIntegrationDriver implements IntegrationDriver {
+
+ private final Context mContext;
+ private final MetricRegistry mMetricRegistry;
+ private final Config mConfig;
+ private final F1Access mAPI;
+
+ private final ProgressReporter mProgressReporter;
+
+ public FellowshipOneIntegrationDriver(final Context context) {
+ mContext = context;
+ mConfig = (Config) context.getAttributes().get("com.p4square.grow.config");
+ mMetricRegistry = (MetricRegistry) context.getAttributes().get("com.p4square.grow.metrics");
+
+ mAPI = new F1Access(context,
+ mConfig.getString("f1ConsumerKey", ""),
+ mConfig.getString("f1ConsumerSecret", ""),
+ mConfig.getString("f1BaseUrl", "staging.fellowshiponeapi.com"),
+ mConfig.getString("f1ChurchCode", "pfseawa"),
+ F1Access.UserType.WEBLINK);
+ mAPI.setMetricRegistry(mMetricRegistry);
+
+ mProgressReporter = new F1ProgressReporter(mAPI);
+ }
+
+ /**
+ * @return An F1Access instance.
+ */
+ public F1Access getF1Access() {
+ return mAPI;
+ }
+
+ @Override
+ public Verifier newUserAuthenticationVerifier() {
+ return new SecondPartyVerifier(mContext, mAPI);
+ }
+
+ @Override
+ public ProgressReporter getProgressReporter() {
+ return mProgressReporter;
+ }
+}
diff --git a/src/main/java/com/p4square/f1oauth/SecondPartyAuthenticator.java b/src/main/java/com/p4square/f1oauth/SecondPartyAuthenticator.java
new file mode 100644
index 0000000..8deefec
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/SecondPartyAuthenticator.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.f1oauth;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.restlet.oauth.OAuthException;
+import com.p4square.restlet.oauth.OAuthUser;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.security.Authenticator;
+
+/**
+ * Restlet Authenticator for 2nd
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SecondPartyAuthenticator extends Authenticator {
+ private static final Logger LOG = Logger.getLogger(SecondPartyAuthenticator.class);
+
+ private final F1Access mHelper;
+
+ public SecondPartyAuthenticator(Context context, boolean optional, F1Access helper) {
+ super(context, optional);
+
+ mHelper = helper;
+ }
+
+ protected boolean authenticate(Request request, Response response) {
+ if (request.getChallengeResponse() == null) {
+ return false; // no credentials
+ }
+
+ String username = request.getChallengeResponse().getIdentifier();
+ String password = new String(request.getChallengeResponse().getSecret());
+
+ try {
+ OAuthUser user = mHelper.getAccessToken(username, password);
+ request.getClientInfo().setUser(user);
+
+ return true;
+
+ } catch (OAuthException e) {
+ LOG.info("OAuth Exception: " + e);
+ }
+
+ return false; // Invalid credentials
+ }
+}
diff --git a/src/main/java/com/p4square/f1oauth/SecondPartyVerifier.java b/src/main/java/com/p4square/f1oauth/SecondPartyVerifier.java
new file mode 100644
index 0000000..882c7e7
--- /dev/null
+++ b/src/main/java/com/p4square/f1oauth/SecondPartyVerifier.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.f1oauth;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.restlet.oauth.OAuthException;
+import com.p4square.restlet.oauth.OAuthUser;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.data.Method;
+import org.restlet.data.Status;
+import org.restlet.ext.jackson.JacksonRepresentation;
+import org.restlet.security.Verifier;
+
+/**
+ * Restlet Verifier for F1 2nd Party Authentication
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SecondPartyVerifier implements Verifier {
+ private static final Logger LOG = Logger.getLogger(SecondPartyVerifier.class);
+
+ private final Restlet mDispatcher;
+ private final F1Access mHelper;
+
+ public SecondPartyVerifier(Context context, F1Access helper) {
+ if (helper == null) {
+ throw new IllegalArgumentException("Helper can not be null.");
+ }
+
+ mDispatcher = context.getClientDispatcher();
+ mHelper = helper;
+ }
+
+ @Override
+ public int verify(Request request, Response response) {
+ if (request.getChallengeResponse() == null) {
+ return RESULT_MISSING; // no credentials
+ }
+
+ String username = request.getChallengeResponse().getIdentifier();
+ String password = new String(request.getChallengeResponse().getSecret());
+
+ try {
+ OAuthUser ouser = mHelper.getAccessToken(username, password);
+
+ // Once we have a user, fetch the people record to get the user id.
+ F1User user = mHelper.getAuthenticatedApi(ouser).getF1User(ouser);
+ user.setEmail(username);
+
+ // This seems like a hack... but it'll work
+ request.getClientInfo().setUser(user);
+
+ return RESULT_VALID;
+
+ } catch (Exception e) {
+ LOG.info("OAuth Exception: " + e, e);
+ }
+
+ return RESULT_INVALID; // Invalid credentials
+ }
+
+}
diff --git a/src/main/java/com/p4square/fmfacade/FMFacade.java b/src/main/java/com/p4square/fmfacade/FMFacade.java
new file mode 100644
index 0000000..0e552b0
--- /dev/null
+++ b/src/main/java/com/p4square/fmfacade/FMFacade.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2013 Jesse Morgan <jesse@jesterpm.net>
+ */
+
+package com.p4square.fmfacade;
+
+import java.io.IOException;
+
+import org.restlet.Application;
+import org.restlet.Component;
+import org.restlet.data.Protocol;
+import org.restlet.Restlet;
+import org.restlet.routing.Router;
+
+import freemarker.template.Configuration;
+import freemarker.template.DefaultObjectWrapper;
+import freemarker.template.Template;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.grow.config.Config;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class FMFacade extends Application {
+ private static final Logger cLog = Logger.getLogger(FMFacade.class);
+ private final Configuration mFMConfig;
+
+ public FMFacade() {
+ mFMConfig = new Configuration();
+ mFMConfig.setClassForTemplateLoading(getClass(), "/templates");
+ mFMConfig.setObjectWrapper(new DefaultObjectWrapper());
+ }
+
+ /**
+ * @return a Config object.
+ */
+ public Config getConfig() {
+ return null;
+ }
+
+ @Override
+ public synchronized Restlet createInboundRoot() {
+ return createRouter();
+ }
+
+ /**
+ * Retrieve a template.
+ *
+ * @param name The template name.
+ * @return A FreeMarker template or null on error.
+ */
+ public Template getTemplate(String name) {
+ try {
+ return mFMConfig.getTemplate(name);
+
+ } catch (IOException e) {
+ cLog.error("Could not load template \"" + name + "\"", e);
+ return null;
+ }
+ }
+
+ /**
+ * Create the router to be used by this application. This can be overriden
+ * by sub-classes to add additional routes.
+ *
+ * @return The router.
+ */
+ protected Router createRouter() {
+ Router router = new Router(getContext());
+ router.attachDefault(FreeMarkerPageResource.class);
+
+ return router;
+ }
+
+ /**
+ * Stand-alone main for testing.
+ */
+ public static void main(String[] args) {
+ // Start the HTTP Server
+ final Component component = new Component();
+ component.getServers().add(Protocol.HTTP, 8085);
+ component.getClients().add(Protocol.HTTP);
+ component.getDefaultHost().attach(new FMFacade());
+
+ // 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/main/java/com/p4square/fmfacade/FreeMarkerPageResource.java b/src/main/java/com/p4square/fmfacade/FreeMarkerPageResource.java
new file mode 100644
index 0000000..8c8948a
--- /dev/null
+++ b/src/main/java/com/p4square/fmfacade/FreeMarkerPageResource.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.fmfacade;
+
+import java.util.Map;
+import java.util.HashMap;
+
+import freemarker.template.Template;
+
+import org.restlet.Context;
+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.restlet.security.User;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.ftl.GetMethod;
+
+import com.p4square.session.Session;
+import com.p4square.session.Sessions;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class FreeMarkerPageResource extends ServerResource {
+ private static Logger cLog = Logger.getLogger(FreeMarkerPageResource.class);
+
+ public static Map<String, Object> baseRootObject(final Context context, final FMFacade fmf) {
+ Map<String, Object> root = new HashMap<String, Object>();
+
+ root.put("get", new GetMethod(context.getClientDispatcher()));
+ root.put("config", fmf.getConfig());
+
+ return root;
+ }
+
+ private FMFacade mFMF;
+ private String mCurrentPage;
+
+ @Override
+ public void doInit() {
+ mFMF = (FMFacade) getApplication();
+ mCurrentPage = getReference().getRemainingPart(false, false);
+ }
+
+ protected Representation get() {
+ try {
+ Template t = mFMF.getTemplate("pages" + mCurrentPage + ".ftl");
+
+ if (t == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return null;
+ }
+
+ return new TemplateRepresentation(t, getRootObject(),
+ MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ cLog.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+ }
+
+ /**
+ * Build and return the root object to pass to the FTL Template.
+ * @return A map of objects and methods for the template to access.
+ */
+ protected Map<String, Object> getRootObject() {
+ Map<String, Object> root = baseRootObject(getContext(), mFMF);
+
+ root.put("attributes", getRequestAttributes());
+ root.put("query", getQuery().getValuesMap());
+
+ if (getClientInfo().isAuthenticated()) {
+ final User user = getClientInfo().getUser();
+ final Map<String, String> userMap = new HashMap<String, String>();
+ userMap.put("id", user.getIdentifier());
+ userMap.put("firstName", user.getFirstName());
+ userMap.put("lastName", user.getLastName());
+ userMap.put("email", user.getEmail());
+ root.put("user", userMap);
+ }
+
+ Session s = Sessions.getInstance().get(getRequest());
+ if (s != null) {
+ root.put("session", s.getMap());
+ }
+
+ return root;
+ }
+}
diff --git a/src/main/java/com/p4square/fmfacade/ftl/GetMethod.java b/src/main/java/com/p4square/fmfacade/ftl/GetMethod.java
new file mode 100644
index 0000000..a47c4b0
--- /dev/null
+++ b/src/main/java/com/p4square/fmfacade/ftl/GetMethod.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.fmfacade.ftl;
+
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+
+import java.io.IOException;
+
+import freemarker.core.Environment;
+import freemarker.template.SimpleScalar;
+import freemarker.template.TemplateMethodModel;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.data.Status;
+import org.restlet.data.Method;
+import org.restlet.representation.Representation;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+
+import org.restlet.ext.jackson.JacksonRepresentation;
+
+/**
+ * This method allows templates to make GET requests.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class GetMethod implements TemplateMethodModel {
+ private static final Logger cLog = Logger.getLogger(GetMethod.class);
+
+ private final Restlet mDispatcher;
+
+ public GetMethod(Restlet dispatcher) {
+ mDispatcher = dispatcher;
+ }
+
+ /**
+ * @param args List with exactly two arguments:
+ * * The variable in which to put the result.
+ * * The URI to GET.
+ */
+ public TemplateModel exec(List args) throws TemplateModelException {
+ final Environment env = Environment.getCurrentEnvironment();
+
+ if (args.size() != 2) {
+ throw new TemplateModelException(
+ "Expecting exactly one argument containing the URI");
+ }
+
+ Request request = new Request(Method.GET, (String) args.get(1));
+ Response response = mDispatcher.handle(request);
+ Status status = response.getStatus();
+ Representation representation = response.getEntity();
+
+ try {
+ if (response.getStatus().isSuccess()) {
+ JacksonRepresentation<Map> mapRepresentation;
+ if (representation instanceof JacksonRepresentation) {
+ mapRepresentation = (JacksonRepresentation<Map>) representation;
+ } else {
+ mapRepresentation = new JacksonRepresentation<Map>(
+ representation, Map.class);
+ }
+ try {
+ TemplateModel mapModel = env.getObjectWrapper().wrap(mapRepresentation.getObject());
+
+ env.setVariable((String) args.get(0), mapModel);
+
+ } catch (IOException e) {
+ cLog.warn("Exception occurred when calling getObject(): "
+ + e.getMessage(), e);
+ status = Status.SERVER_ERROR_INTERNAL;
+ }
+ }
+
+ Map statusMap = new HashMap();
+ statusMap.put("code", status.getCode());
+ statusMap.put("reason", status.getReasonPhrase());
+ statusMap.put("succeeded", status.isSuccess());
+ return env.getObjectWrapper().wrap(statusMap);
+ } finally {
+ if (representation != null) {
+ representation.release();
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/fmfacade/json/ClientException.java b/src/main/java/com/p4square/fmfacade/json/ClientException.java
new file mode 100644
index 0000000..c233193
--- /dev/null
+++ b/src/main/java/com/p4square/fmfacade/json/ClientException.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.fmfacade.json;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class ClientException extends Exception {
+
+ public ClientException(final String msg) {
+ super(msg);
+ }
+
+ public ClientException(final String msg, final Exception cause) {
+ super(msg, cause);
+ }
+}
diff --git a/src/main/java/com/p4square/fmfacade/json/JsonRequestClient.java b/src/main/java/com/p4square/fmfacade/json/JsonRequestClient.java
new file mode 100644
index 0000000..19a394f
--- /dev/null
+++ b/src/main/java/com/p4square/fmfacade/json/JsonRequestClient.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.fmfacade.json;
+
+import java.util.Map;
+
+import java.io.IOException;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.data.Status;
+import org.restlet.data.Method;
+import org.restlet.representation.Representation;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+
+import org.restlet.ext.jackson.JacksonRepresentation;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class JsonRequestClient {
+ private final Restlet mDispatcher;
+
+ public JsonRequestClient(Restlet dispatcher) {
+ mDispatcher = dispatcher;
+ }
+
+ /**
+ * Perform a GET request for the given URI and parse the response as a
+ * JSON map.
+ *
+ * @return A JsonResponse object which can be used to retrieve the
+ * response as a JSON map.
+ */
+ public JsonResponse get(final String uri) {
+ final Request request = new Request(Method.GET, uri);
+ final Response response = mDispatcher.handle(request);
+
+ return new JsonResponse(response);
+ }
+
+ /**
+ * Perform a PUT request for the given URI and parse the response as a
+ * JSON map.
+ *
+ * @return A JsonResponse object which can be used to retrieve the
+ * response as a JSON map.
+ */
+ public JsonResponse put(final String uri, Representation entity) {
+ final Request request = new Request(Method.PUT, uri);
+ request.setEntity(entity);
+
+ final Response response = mDispatcher.handle(request);
+ return new JsonResponse(response);
+ }
+
+ /**
+ * Perform a PUT request for the given URI and parse the response as a
+ * JSON map.
+ *
+ * @return A JsonResponse object which can be used to retrieve the
+ * response as a JSON map.
+ */
+ public JsonResponse put(final String uri, Map map) {
+ return put(uri, new JacksonRepresentation<Map>(map));
+ }
+
+ /**
+ * Perform a POST request for the given URI and parse the response as a
+ * JSON map.
+ *
+ * @return A JsonResponse object which can be used to retrieve the
+ * response as a JSON map.
+ */
+ public JsonResponse post(final String uri, Representation entity) {
+ final Request request = new Request(Method.POST, uri);
+ request.setEntity(entity);
+
+ final Response response = mDispatcher.handle(request);
+ return new JsonResponse(response);
+ }
+
+ /**
+ * Perform a POST request for the given URI and parse the response as a
+ * JSON map.
+ *
+ * @return A JsonResponse object which can be used to retrieve the
+ * response as a JSON map.
+ */
+ public JsonResponse post(final String uri, Map map) {
+ return post(uri, new JacksonRepresentation<Map>(map));
+ }
+
+ /**
+ * Perform a DELETE request for the given URI.
+ *
+ * @return A JsonResponse object with the status of the request.
+ */
+ public JsonResponse delete(final String uri) {
+ final Request request = new Request(Method.DELETE, uri);
+ final Response response = mDispatcher.handle(request);
+ return new JsonResponse(response);
+ }
+}
diff --git a/src/main/java/com/p4square/fmfacade/json/JsonResponse.java b/src/main/java/com/p4square/fmfacade/json/JsonResponse.java
new file mode 100644
index 0000000..b9cb587
--- /dev/null
+++ b/src/main/java/com/p4square/fmfacade/json/JsonResponse.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.fmfacade.json;
+
+import java.util.Map;
+
+import java.io.IOException;
+
+import org.restlet.data.Status;
+import org.restlet.data.Reference;
+import org.restlet.representation.Representation;
+import org.restlet.Response;
+
+import org.restlet.ext.jackson.JacksonRepresentation;
+
+/**
+ * JsonResponse wraps a Restlet Response object and parses the entity, if any,
+ * as a JSON map.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class JsonResponse {
+ private final Response mResponse;
+ private final Representation mRepresentation;
+
+ private Map<String, Object> mMap;
+
+ JsonResponse(Response response) {
+ mResponse = response;
+ mRepresentation = response.getEntity();
+ mMap = null;
+
+ if (!response.getStatus().isSuccess()) {
+ if (mRepresentation != null) {
+ mRepresentation.release();
+ }
+ }
+ }
+
+ /**
+ * @return the Status info from the response.
+ */
+ public Status getStatus() {
+ return mResponse.getStatus();
+ }
+
+ /**
+ * @return the Reference for a redirect.
+ */
+ public Reference getRedirectLocation() {
+ return mResponse.getLocationRef();
+ }
+
+ /**
+ * Return the parsed json map from the response.
+ */
+ public Map<String, Object> getMap() throws ClientException {
+ if (mMap == null) {
+ Representation representation = mRepresentation;
+
+ // Parse response
+ if (representation == null) {
+ return null;
+ }
+
+ JacksonRepresentation<Map> mapRepresentation;
+ if (representation instanceof JacksonRepresentation) {
+ mapRepresentation = (JacksonRepresentation<Map>) representation;
+ } else {
+ mapRepresentation = new JacksonRepresentation<Map>(
+ representation, Map.class);
+ }
+
+ try {
+ mMap = (Map<String, Object>) mapRepresentation.getObject();
+
+ } catch (IOException e) {
+ throw new ClientException("Failed to parse response: " + e.getMessage(), e);
+ }
+ }
+
+ return mMap;
+ }
+
+}
diff --git a/src/main/java/com/p4square/grow/GrowProcessComponent.java b/src/main/java/com/p4square/grow/GrowProcessComponent.java
new file mode 100644
index 0000000..608ec0d
--- /dev/null
+++ b/src/main/java/com/p4square/grow/GrowProcessComponent.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import com.codahale.metrics.ConsoleReporter;
+import com.codahale.metrics.MetricRegistry;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Application;
+import org.restlet.Component;
+import org.restlet.Restlet;
+import org.restlet.data.ChallengeScheme;
+import org.restlet.data.Protocol;
+import org.restlet.resource.Directory;
+import org.restlet.security.ChallengeAuthenticator;
+
+import com.p4square.grow.backend.BackendVerifier;
+import com.p4square.grow.backend.GrowBackend;
+import com.p4square.grow.config.Config;
+import com.p4square.grow.frontend.GrowFrontend;
+import com.p4square.restlet.metrics.MetricsApplication;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class GrowProcessComponent extends Component {
+ private static Logger LOG = Logger.getLogger(GrowProcessComponent.class);
+
+ private static final String BACKEND_REALM = "Grow Backend";
+
+ private final Config mConfig;
+ private final MetricRegistry mMetricRegistry;
+
+ /**
+ * Create a new Grow Process website component combining a frontend and backend.
+ */
+ public GrowProcessComponent() throws Exception {
+ this(new Config());
+ }
+
+ public GrowProcessComponent(Config config) throws Exception {
+ // Clients
+ getClients().add(Protocol.FILE);
+ getClients().add(Protocol.HTTP);
+ getClients().add(Protocol.HTTPS);
+
+ // Prepare mConfig
+ mConfig = config;
+ mConfig.updateConfig(this.getClass().getResourceAsStream("/grow.properties"));
+
+ // Prepare Metrics
+ mMetricRegistry = new MetricRegistry();
+
+ // Frontend
+ GrowFrontend frontend = new GrowFrontend(mConfig, mMetricRegistry);
+ getDefaultHost().attach(frontend);
+
+ // Backend
+ GrowBackend backend = new GrowBackend(mConfig, mMetricRegistry);
+ getInternalRouter().attach("/backend", backend);
+
+ // Authenticated access to the backend
+ BackendVerifier verifier = new BackendVerifier(backend.getUserRecordProvider());
+ ChallengeAuthenticator auth = new ChallengeAuthenticator(getContext().createChildContext(),
+ false, ChallengeScheme.HTTP_BASIC, BACKEND_REALM, verifier);
+ auth.setNext(backend);
+ getDefaultHost().attach("/backend", auth);
+
+ // Authenticated access to metrics
+ ChallengeAuthenticator metricAuth = new ChallengeAuthenticator(
+ getContext().createChildContext(), false,
+ ChallengeScheme.HTTP_BASIC, BACKEND_REALM, verifier);
+ metricAuth.setNext(new MetricsApplication(mMetricRegistry));
+ getDefaultHost().attach("/metrics", metricAuth);
+ }
+
+
+ @Override
+ public void start() throws Exception {
+ String configDomain = getContext().getParameters().getFirstValue("com.p4square.grow.configDomain");
+ if (configDomain != null) {
+ mConfig.setDomain(configDomain);
+ }
+
+ String configFilename = getContext().getParameters().getFirstValue("com.p4square.grow.configFile");
+ if (configFilename != null) {
+ mConfig.updateConfig(configFilename);
+ }
+
+ super.start();
+ }
+
+ /**
+ * Stand-alone main for testing.
+ */
+ public static void main(String[] args) throws Exception {
+ // Load an optional config file from the first argument.
+ Config config = new Config();
+ config.setDomain("dev");
+ if (args.length >= 1) {
+ config.updateConfig(args[0]);
+ }
+
+ // Override domain
+ if (args.length == 2) {
+ config.setDomain(args[1]);
+ }
+
+ // Start the HTTP Server
+ final GrowProcessComponent component = new GrowProcessComponent(config);
+ component.getServers().add(Protocol.HTTP, 8085);
+
+ // Static content
+ try {
+ component.getDefaultHost().attach("/images/", new FileServingApp("./src/main/webapp/images/"));
+ component.getDefaultHost().attach("/scripts", new FileServingApp("./src/main/webapp/scripts"));
+ component.getDefaultHost().attach("/style.css", new FileServingApp("./src/main/webapp/style.css"));
+ component.getDefaultHost().attach("/favicon.ico", new FileServingApp("./src/main/webapp/favicon.ico"));
+ component.getDefaultHost().attach("/notfound.html", new FileServingApp("./src/main/webapp/notfound.html"));
+ component.getDefaultHost().attach("/error.html", new FileServingApp("./src/main/webapp/error.html"));
+ } catch (IOException e) {
+ LOG.error("Could not create directory for static resources: "
+ + e.getMessage(), e);
+ }
+
+ // Setup shutdown hook
+ Runtime.getRuntime().addShutdownHook(new Thread() {
+ public void run() {
+ try {
+ component.stop();
+ } catch (Exception e) {
+ LOG.error("Exception during cleanup", e);
+ }
+ }
+ });
+
+ LOG.info("Starting server...");
+
+ try {
+ component.start();
+ } catch (Exception e) {
+ LOG.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/main/java/com/p4square/grow/backend/BackendVerifier.java b/src/main/java/com/p4square/grow/backend/BackendVerifier.java
new file mode 100644
index 0000000..83160a9
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/BackendVerifier.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend;
+
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import org.apache.commons.codec.binary.Hex;
+
+import org.restlet.security.SecretVerifier;
+
+import com.p4square.grow.model.UserRecord;
+import com.p4square.grow.provider.Provider;
+
+/**
+ * Verify the given credentials against the users with backend access.
+ */
+public class BackendVerifier extends SecretVerifier {
+
+ private final Provider<String, UserRecord> mUserProvider;
+
+ public BackendVerifier(Provider<String, UserRecord> userProvider) {
+ mUserProvider = userProvider;
+ }
+
+ @Override
+ public int verify(String identifier, char[] secret) {
+ if (identifier == null) {
+ throw new IllegalArgumentException("Null identifier");
+ }
+
+ if (secret == null) {
+ throw new IllegalArgumentException("Null secret");
+ }
+
+ // Does the user exist?
+ UserRecord user;
+ try {
+ user = mUserProvider.get(identifier);
+ if (user == null) {
+ return RESULT_UNKNOWN;
+ }
+
+ } catch (IOException e) {
+ return RESULT_UNKNOWN;
+ }
+
+ // Does the user have a backend password?
+ String storedHash = user.getBackendPasswordHash();
+ if (storedHash == null) {
+ // This user doesn't have access
+ return RESULT_INVALID;
+ }
+
+ // Validate the password.
+ try {
+ String hashedInput = hashPassword(secret);
+ if (hashedInput.equals(storedHash)) {
+ return RESULT_VALID;
+ }
+
+ } catch (NoSuchAlgorithmException e) {
+ return RESULT_UNSUPPORTED;
+ }
+
+ // If all else fails, fail.
+ return RESULT_INVALID;
+ }
+
+ /**
+ * Hash the given secret.
+ */
+ public static String hashPassword(char[] secret) throws NoSuchAlgorithmException {
+ MessageDigest md = MessageDigest.getInstance("SHA-1");
+
+ // Convert the char[] to byte[]
+ // FIXME This approach is incorrectly truncating multibyte
+ // characters.
+ byte[] b = new byte[secret.length];
+ for (int i = 0; i < secret.length; i++) {
+ b[i] = (byte) secret[i];
+ }
+
+ md.update(b);
+
+ byte[] hash = md.digest();
+ return new String(Hex.encodeHex(hash));
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/CassandraGrowData.java b/src/main/java/com/p4square/grow/backend/CassandraGrowData.java
new file mode 100644
index 0000000..22a7716
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/CassandraGrowData.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend;
+
+import java.io.IOException;
+
+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.Message;
+import com.p4square.grow.model.MessageThread;
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.model.Question;
+import com.p4square.grow.model.TrainingRecord;
+import com.p4square.grow.model.UserRecord;
+
+import com.p4square.grow.provider.CollectionProvider;
+import com.p4square.grow.provider.DelegateCollectionProvider;
+import com.p4square.grow.provider.DelegateProvider;
+import com.p4square.grow.provider.Provider;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+class CassandraGrowData implements GrowData {
+ private static final String DEFAULT_COLUMN = "value";
+
+ private final Config mConfig;
+ private final CassandraDatabase mDatabase;
+
+ private final Provider<String, UserRecord> mUserRecordProvider;
+
+ private final Provider<String, Question> mQuestionProvider;
+ private final CassandraTrainingRecordProvider mTrainingRecordProvider;
+ private final CollectionProvider<String, String, String> mVideoProvider;
+
+ private final CollectionProvider<String, String, MessageThread> mFeedThreadProvider;
+ private final CollectionProvider<String, String, Message> mFeedMessageProvider;
+
+ private final Provider<String, String> mStringProvider;
+
+ private final CollectionProvider<String, String, String> mAnswerProvider;
+
+ public CassandraGrowData(final Config config) {
+ mConfig = config;
+ mDatabase = new CassandraDatabase();
+
+ mUserRecordProvider = new DelegateProvider<String, CassandraKey, UserRecord>(
+ new CassandraProviderImpl<UserRecord>(mDatabase, UserRecord.class)) {
+ @Override
+ public CassandraKey makeKey(String userid) {
+ return new CassandraKey("accounts", userid, DEFAULT_COLUMN);
+ }
+ };
+
+ mQuestionProvider = new DelegateProvider<String, CassandraKey, Question>(
+ new CassandraProviderImpl<Question>(mDatabase, Question.class)) {
+ @Override
+ public CassandraKey makeKey(String questionId) {
+ return new CassandraKey("strings", "/questions/" + questionId, DEFAULT_COLUMN);
+ }
+ };
+
+ mFeedThreadProvider = new CassandraCollectionProvider<MessageThread>(mDatabase,
+ "feedthreads", MessageThread.class);
+ mFeedMessageProvider = new CassandraCollectionProvider<Message>(mDatabase,
+ "feedmessages", Message.class);
+
+ mTrainingRecordProvider = new CassandraTrainingRecordProvider(mDatabase);
+
+ mVideoProvider = new DelegateCollectionProvider<String, String, String, String, String>(
+ new CassandraCollectionProvider<String>(mDatabase, "strings", String.class)) {
+ @Override
+ public String makeCollectionKey(String key) {
+ return "/training/" + key;
+ }
+
+ @Override
+ public String makeKey(String key) {
+ return key;
+ }
+
+ @Override
+ public String unmakeKey(String key) {
+ return key;
+ }
+ };
+
+ mStringProvider = new DelegateProvider<String, CassandraKey, String>(
+ new CassandraProviderImpl<String>(mDatabase, String.class)) {
+ @Override
+ public CassandraKey makeKey(String id) {
+ return new CassandraKey("strings", id, DEFAULT_COLUMN);
+ }
+ };
+
+ mAnswerProvider = new CassandraCollectionProvider<String>(
+ mDatabase, "assessments", String.class);
+ }
+
+ @Override
+ public void start() throws Exception {
+ mDatabase.setClusterName(mConfig.getString("clusterName", "Dev Cluster"));
+ mDatabase.setKeyspaceName(mConfig.getString("keyspace", "GROW"));
+ mDatabase.init();
+ }
+
+ @Override
+ public void stop() throws Exception {
+ mDatabase.close();
+ }
+
+ /**
+ * @return the current database.
+ */
+ public CassandraDatabase getDatabase() {
+ return mDatabase;
+ }
+
+ @Override
+ public Provider<String, UserRecord> getUserRecordProvider() {
+ return mUserRecordProvider;
+ }
+
+ @Override
+ public Provider<String, Question> getQuestionProvider() {
+ return mQuestionProvider;
+ }
+
+ @Override
+ public Provider<String, TrainingRecord> getTrainingRecordProvider() {
+ return mTrainingRecordProvider;
+ }
+
+ @Override
+ public CollectionProvider<String, String, String> getVideoProvider() {
+ return mVideoProvider;
+ }
+
+ @Override
+ public Playlist getDefaultPlaylist() throws IOException {
+ return mTrainingRecordProvider.getDefaultPlaylist();
+ }
+
+ @Override
+ public CollectionProvider<String, String, MessageThread> getThreadProvider() {
+ return mFeedThreadProvider;
+ }
+
+ @Override
+ public CollectionProvider<String, String, Message> getMessageProvider() {
+ return mFeedMessageProvider;
+ }
+
+ @Override
+ public Provider<String, String> getStringProvider() {
+ return mStringProvider;
+ }
+
+ @Override
+ public CollectionProvider<String, String, String> getAnswerProvider() {
+ return mAnswerProvider;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/DynamoGrowData.java b/src/main/java/com/p4square/grow/backend/DynamoGrowData.java
new file mode 100644
index 0000000..3b38eac
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/DynamoGrowData.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend;
+
+import java.io.IOException;
+
+import com.amazonaws.auth.AWSCredentials;
+
+import com.p4square.grow.backend.dynamo.DynamoDatabase;
+import com.p4square.grow.backend.dynamo.DynamoKey;
+import com.p4square.grow.backend.dynamo.DynamoProviderImpl;
+import com.p4square.grow.backend.dynamo.DynamoCollectionProviderImpl;
+
+import com.p4square.grow.config.Config;
+
+import com.p4square.grow.model.Message;
+import com.p4square.grow.model.MessageThread;
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.model.Question;
+import com.p4square.grow.model.TrainingRecord;
+import com.p4square.grow.model.UserRecord;
+
+import com.p4square.grow.provider.CollectionProvider;
+import com.p4square.grow.provider.DelegateCollectionProvider;
+import com.p4square.grow.provider.DelegateProvider;
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+class DynamoGrowData implements GrowData {
+ private static final String DEFAULT_COLUMN = "value";
+ private static final String DEFAULT_PLAYLIST_KEY = "/training/defaultplaylist";
+
+ private final Config mConfig;
+ private final DynamoDatabase mDatabase;
+
+ private final Provider<String, UserRecord> mUserRecordProvider;
+
+ private final Provider<String, Question> mQuestionProvider;
+ private final Provider<String, TrainingRecord> mTrainingRecordProvider;
+ private final CollectionProvider<String, String, String> mVideoProvider;
+
+ private final CollectionProvider<String, String, MessageThread> mFeedThreadProvider;
+ private final CollectionProvider<String, String, Message> mFeedMessageProvider;
+
+ private final Provider<String, String> mStringProvider;
+
+ private final CollectionProvider<String, String, String> mAnswerProvider;
+
+ public DynamoGrowData(final Config config) {
+ mConfig = config;
+
+ mDatabase = new DynamoDatabase(config);
+
+ mUserRecordProvider = new DelegateProvider<String, DynamoKey, UserRecord>(
+ new DynamoProviderImpl<UserRecord>(mDatabase, UserRecord.class)) {
+ @Override
+ public DynamoKey makeKey(String userid) {
+ return DynamoKey.newAttributeKey("accounts", userid, DEFAULT_COLUMN);
+ }
+ };
+
+ mQuestionProvider = new DelegateProvider<String, DynamoKey, Question>(
+ new DynamoProviderImpl<Question>(mDatabase, Question.class)) {
+ @Override
+ public DynamoKey makeKey(String questionId) {
+ return DynamoKey.newAttributeKey("strings",
+ "/questions/" + questionId,
+ DEFAULT_COLUMN);
+ }
+ };
+
+ mFeedThreadProvider = new DynamoCollectionProviderImpl<MessageThread>(
+ mDatabase, "feedthreads", MessageThread.class);
+ mFeedMessageProvider = new DynamoCollectionProviderImpl<Message>(
+ mDatabase, "feedmessages", Message.class);
+
+ mTrainingRecordProvider = new DelegateProvider<String, DynamoKey, TrainingRecord>(
+ new DynamoProviderImpl<TrainingRecord>(mDatabase, TrainingRecord.class)) {
+ @Override
+ public DynamoKey makeKey(String userId) {
+ return DynamoKey.newAttributeKey("training",
+ userId,
+ DEFAULT_COLUMN);
+ }
+ };
+
+ mVideoProvider = new DelegateCollectionProvider<String, String, String, String, String>(
+ new DynamoCollectionProviderImpl<String>(mDatabase, "strings", String.class)) {
+ @Override
+ public String makeCollectionKey(String key) {
+ return "/training/" + key;
+ }
+
+ @Override
+ public String makeKey(String key) {
+ return key;
+ }
+
+ @Override
+ public String unmakeKey(String key) {
+ return key;
+ }
+ };
+
+ mStringProvider = new DelegateProvider<String, DynamoKey, String>(
+ new DynamoProviderImpl<String>(mDatabase, String.class)) {
+ @Override
+ public DynamoKey makeKey(String id) {
+ return DynamoKey.newAttributeKey("strings", id, DEFAULT_COLUMN);
+ }
+ };
+
+ mAnswerProvider = new DynamoCollectionProviderImpl<String>(
+ mDatabase, "assessments", String.class);
+ }
+
+ @Override
+ public void start() throws Exception {
+ }
+
+ @Override
+ public void stop() throws Exception {
+ }
+
+ @Override
+ public Provider<String, UserRecord> getUserRecordProvider() {
+ return mUserRecordProvider;
+ }
+
+ @Override
+ public Provider<String, Question> getQuestionProvider() {
+ return mQuestionProvider;
+ }
+
+ @Override
+ public Provider<String, TrainingRecord> getTrainingRecordProvider() {
+ return mTrainingRecordProvider;
+ }
+
+ @Override
+ public CollectionProvider<String, String, String> getVideoProvider() {
+ return mVideoProvider;
+ }
+
+ @Override
+ public Playlist getDefaultPlaylist() throws IOException {
+ String blob = mStringProvider.get(DEFAULT_PLAYLIST_KEY);
+ if (blob == null) {
+ return null;
+ }
+
+ return JsonEncodedProvider.MAPPER.readValue(blob, Playlist.class);
+ }
+
+ @Override
+ public CollectionProvider<String, String, MessageThread> getThreadProvider() {
+ return mFeedThreadProvider;
+ }
+
+ @Override
+ public CollectionProvider<String, String, Message> getMessageProvider() {
+ return mFeedMessageProvider;
+ }
+
+ @Override
+ public Provider<String, String> getStringProvider() {
+ return mStringProvider;
+ }
+
+ @Override
+ public CollectionProvider<String, String, String> getAnswerProvider() {
+ return mAnswerProvider;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/GrowBackend.java b/src/main/java/com/p4square/grow/backend/GrowBackend.java
new file mode 100644
index 0000000..4091138
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/GrowBackend.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2012 Jesse Morgan
+ */
+
+package com.p4square.grow.backend;
+
+import java.io.IOException;
+
+import com.codahale.metrics.MetricRegistry;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Application;
+import org.restlet.Component;
+import org.restlet.Restlet;
+import org.restlet.data.Protocol;
+import org.restlet.data.Reference;
+import org.restlet.resource.Directory;
+import org.restlet.routing.Router;
+
+import com.p4square.grow.config.Config;
+
+import com.p4square.grow.model.Message;
+import com.p4square.grow.model.MessageThread;
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.model.Question;
+import com.p4square.grow.model.TrainingRecord;
+import com.p4square.grow.model.UserRecord;
+
+import com.p4square.grow.provider.CollectionProvider;
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.ProvidesQuestions;
+import com.p4square.grow.provider.ProvidesTrainingRecords;
+import com.p4square.grow.provider.ProvidesUserRecords;
+
+import com.p4square.grow.backend.resources.AccountResource;
+import com.p4square.grow.backend.resources.BannerResource;
+import com.p4square.grow.backend.resources.SurveyResource;
+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;
+
+import com.p4square.restlet.metrics.MetricRouter;
+
+/**
+ * Main class for the backend application.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class GrowBackend extends Application implements GrowData {
+
+ private final static Logger LOG = Logger.getLogger(GrowBackend.class);
+
+ private final MetricRegistry mMetricRegistry;
+
+ private final Config mConfig;
+ private final GrowData mGrowData;
+
+ public GrowBackend() {
+ this(new Config(), new MetricRegistry());
+ }
+
+ public GrowBackend(Config config, MetricRegistry metricRegistry) {
+ mConfig = config;
+
+ mMetricRegistry = metricRegistry;
+
+ mGrowData = new DynamoGrowData(config);
+ }
+
+ public MetricRegistry getMetrics() {
+ return mMetricRegistry;
+ }
+
+ @Override
+ public Restlet createInboundRoot() {
+ Router router = new MetricRouter(getContext(), mMetricRegistry);
+
+ // Account API
+ router.attach("/accounts/{userId}", AccountResource.class);
+
+ // 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);
+
+ // 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);
+
+ router.attachDefault(new Directory(getContext(), new Reference(getClass().getResource("apiinfo.html"))));
+
+ return router;
+ }
+
+ /**
+ * Open the database.
+ */
+ @Override
+ public void start() throws Exception {
+ super.start();
+
+ mGrowData.start();
+ }
+
+ /**
+ * Close the database.
+ */
+ @Override
+ public void stop() throws Exception {
+ LOG.info("Shutting down...");
+ mGrowData.stop();
+
+ super.stop();
+ }
+
+ @Override
+ public Provider<String, UserRecord> getUserRecordProvider() {
+ return mGrowData.getUserRecordProvider();
+ }
+
+ @Override
+ public Provider<String, Question> getQuestionProvider() {
+ return mGrowData.getQuestionProvider();
+ }
+
+ @Override
+ public CollectionProvider<String, String, String> getVideoProvider() {
+ return mGrowData.getVideoProvider();
+ }
+
+ @Override
+ public Provider<String, TrainingRecord> getTrainingRecordProvider() {
+ return mGrowData.getTrainingRecordProvider();
+ }
+
+ /**
+ * @return the Default Playlist.
+ */
+ public Playlist getDefaultPlaylist() throws IOException {
+ return mGrowData.getDefaultPlaylist();
+ }
+
+ @Override
+ public CollectionProvider<String, String, MessageThread> getThreadProvider() {
+ return mGrowData.getThreadProvider();
+ }
+
+ @Override
+ public CollectionProvider<String, String, Message> getMessageProvider() {
+ return mGrowData.getMessageProvider();
+ }
+
+ @Override
+ public Provider<String, String> getStringProvider() {
+ return mGrowData.getStringProvider();
+ }
+
+ @Override
+ public CollectionProvider<String, String, String> getAnswerProvider() {
+ return mGrowData.getAnswerProvider();
+ }
+
+ /**
+ * 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) {
+ LOG.error("Exception during cleanup", e);
+ }
+ }
+ });
+
+ LOG.info("Starting server...");
+
+ try {
+ component.start();
+ } catch (Exception e) {
+ LOG.fatal("Could not start: " + e.getMessage(), e);
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/GrowData.java b/src/main/java/com/p4square/grow/backend/GrowData.java
new file mode 100644
index 0000000..293bb88
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/GrowData.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend;
+
+import com.p4square.grow.backend.feed.FeedDataProvider;
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.provider.ProvidesAssessments;
+import com.p4square.grow.provider.ProvidesQuestions;
+import com.p4square.grow.provider.ProvidesStrings;
+import com.p4square.grow.provider.ProvidesTrainingRecords;
+import com.p4square.grow.provider.ProvidesUserRecords;
+import com.p4square.grow.provider.ProvidesVideos;
+
+/**
+ * Aggregate of the data provider interfaces.
+ *
+ * Used by GrowBackend to swap out implementations of the providers.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+interface GrowData extends ProvidesQuestions, ProvidesTrainingRecords, ProvidesVideos,
+ FeedDataProvider, ProvidesUserRecords, ProvidesStrings,
+ ProvidesAssessments {
+
+ /**
+ * Start the data provider.
+ */
+ void start() throws Exception;
+
+ /**
+ * Stop the data provider.
+ */
+ void stop() throws Exception;
+}
diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java b/src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java
new file mode 100644
index 0000000..bfcb48d
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.db;
+
+import java.io.IOException;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+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 LinkedHashMap<>();
+
+ ColumnList<String> row = mDb.getRow(mCF, collection);
+ if (!row.isEmpty()) {
+ int count = 0;
+ for (Column<String> c : row) {
+ if (limit >= 0 && ++count > limit) {
+ break; // Limit reached.
+ }
+
+ String key = c.getName();
+ String blob = c.getStringValue();
+ V obj = decode(blob);
+
+ result.put(key, obj);
+ }
+ }
+
+ 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 {
+ if (mClazz == String.class) {
+ return (String) obj;
+ } else {
+ 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;
+ }
+
+ if (mClazz == String.class) {
+ return (V) blob;
+ }
+
+ V obj = JsonEncodedProvider.MAPPER.readValue(blob, mClazz);
+ return obj;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java b/src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java
new file mode 100644
index 0000000..b8cb6df
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java
@@ -0,0 +1,212 @@
+/*
+ * 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.ColumnListMutation;
+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);
+ }
+ }
+
+ /**
+ * Remove a row
+ */
+ public void deleteRow(final String cfName, final String key) {
+ ColumnFamily<String, String> cf = new ColumnFamily(cfName,
+ StringSerializer.get(),
+ StringSerializer.get());
+
+ try {
+ MutationBatch batch = mKeyspace.prepareMutationBatch();
+ ColumnListMutation<String> cfm = batch.withRow(cf, key).delete();
+ batch.execute();
+
+ } catch (ConnectionException e) {
+ cLog.error("deleteRow failed due to Connection Exception", e);
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraKey.java b/src/main/java/com/p4square/grow/backend/db/CassandraKey.java
new file mode 100644
index 0000000..853fe96
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/db/CassandraKey.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.db;
+
+/**
+ * CassandraKey represents a Cassandra key / column pair.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class CassandraKey {
+ private final String mColumnFamily;
+ private final String mId;
+ private final String mColumn;
+
+ public CassandraKey(String columnFamily, String id, String column) {
+ mColumnFamily = columnFamily;
+ mId = id;
+ mColumn = column;
+ }
+
+ public String getColumnFamily() {
+ return mColumnFamily;
+ }
+
+ public String getId() {
+ return mId;
+ }
+
+ public String getColumn() {
+ return mColumn;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java b/src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java
new file mode 100644
index 0000000..da5a9f2
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.db;
+
+import java.io.IOException;
+
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ * Provider implementation backed by a Cassandra ColumnFamily.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class CassandraProviderImpl<V> extends JsonEncodedProvider<V> implements Provider<CassandraKey, V> {
+ private final CassandraDatabase mDb;
+
+ public CassandraProviderImpl(CassandraDatabase db, Class<V> clazz) {
+ super(clazz);
+
+ mDb = db;
+ }
+
+ @Override
+ public V get(CassandraKey key) throws IOException {
+ String blob = mDb.getKey(key.getColumnFamily(), key.getId(), key.getColumn());
+ return decode(blob);
+ }
+
+ @Override
+ public void put(CassandraKey key, V obj) throws IOException {
+ String blob = encode(obj);
+ mDb.putKey(key.getColumnFamily(), key.getId(), key.getColumn(), blob);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java b/src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java
new file mode 100644
index 0000000..4face52
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.db;
+
+import java.io.IOException;
+
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.model.TrainingRecord;
+
+import com.p4square.grow.provider.JsonEncodedProvider;
+import com.p4square.grow.provider.Provider;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class CassandraTrainingRecordProvider implements Provider<String, TrainingRecord> {
+ private static final CassandraKey DEFAULT_PLAYLIST_KEY = new CassandraKey("strings", "defaultPlaylist", "value");
+
+ private static final String COLUMN_FAMILY = "training";
+ private static final String PLAYLIST_KEY = "playlist";
+ private static final String LAST_VIDEO_KEY = "lastVideo";
+
+ private final CassandraDatabase mDb;
+ private final Provider<CassandraKey, Playlist> mPlaylistProvider;
+
+ public CassandraTrainingRecordProvider(CassandraDatabase db) {
+ mDb = db;
+ mPlaylistProvider = new CassandraProviderImpl<>(db, Playlist.class);
+ }
+
+ @Override
+ public TrainingRecord get(String userid) throws IOException {
+ Playlist playlist = mPlaylistProvider.get(new CassandraKey(COLUMN_FAMILY, userid, PLAYLIST_KEY));
+
+ if (playlist == null) {
+ // We consider no playlist to mean no record whatsoever.
+ return null;
+ }
+
+ TrainingRecord r = new TrainingRecord();
+ r.setPlaylist(playlist);
+ r.setLastVideo(mDb.getKey(COLUMN_FAMILY, userid, LAST_VIDEO_KEY));
+
+ return r;
+ }
+
+ @Override
+ public void put(String userid, TrainingRecord record) throws IOException {
+ String lastVideo = record.getLastVideo();
+ Playlist playlist = record.getPlaylist();
+
+ mDb.putKey(COLUMN_FAMILY, userid, LAST_VIDEO_KEY, lastVideo);
+ mPlaylistProvider.put(new CassandraKey(COLUMN_FAMILY, userid, PLAYLIST_KEY), playlist);
+ }
+
+ /**
+ * @return the default playlist stored in the database.
+ */
+ public Playlist getDefaultPlaylist() throws IOException {
+ Playlist playlist = mPlaylistProvider.get(DEFAULT_PLAYLIST_KEY);
+
+ if (playlist == null) {
+ playlist = new Playlist();
+ }
+
+ return playlist;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DbTool.java b/src/main/java/com/p4square/grow/backend/dynamo/DbTool.java
new file mode 100644
index 0000000..374fa83
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/dynamo/DbTool.java
@@ -0,0 +1,481 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.dynamo;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.io.File;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import com.p4square.grow.backend.dynamo.DynamoDatabase;
+import com.p4square.grow.backend.dynamo.DynamoKey;
+import com.p4square.grow.config.Config;
+import com.p4square.grow.model.UserRecord;
+import com.p4square.grow.provider.Provider;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class DbTool {
+ private static final FilenameFilter JSON_FILTER = new JsonFilter();
+
+ private static Config mConfig;
+ private static DynamoDatabase mDatabase;
+
+ public static void usage() {
+ System.out.println("java com.p4square.grow.backend.dynamo.DbTool <command>...\n");
+ System.out.println("Commands:");
+ System.out.println("\t--domain <domain> Set config domain");
+ System.out.println("\t--dev Set config domain to dev");
+ System.out.println("\t--config <file> Merge in config file");
+ System.out.println("\t--list List all tables");
+ System.out.println("\t--create <table> <reads> <writes> Create a table");
+ System.out.println("\t--update <table> <reads> <writes> Update table throughput");
+ System.out.println("\t--drop <table> Delete a table");
+ System.out.println("\t--get <table> <key> <attribute> Get a value");
+ System.out.println("\t--put <table> <key> <attribute> <value> Put a value");
+ System.out.println("\t--delete <table> <key> <attribute> Delete a value");
+ System.out.println("\t--scan <table> List all rows");
+ System.out.println("\t--scanf <table> <attribute> List all rows");
+ System.out.println();
+ System.out.println("Bootstrap Commands:");
+ System.out.println("\t--bootstrap <data> Create all tables and import all data");
+ System.out.println("\t--loadStrings <data> Load all videos and questions");
+ System.out.println("\t--destroy Drop all tables");
+ System.out.println("\t--addadmin <user> <pass> Add a backend account");
+ System.out.println("\t--import <table> <file> Backfill a table");
+ }
+
+ public static void main(String... args) {
+ if (args.length == 0) {
+ usage();
+ System.exit(1);
+ }
+
+ mConfig = new Config();
+
+ try {
+ mConfig.updateConfig(DbTool.class.getResourceAsStream("/grow.properties"));
+
+ int offset = 0;
+ while (offset < args.length) {
+ if ("--domain".equals(args[offset])) {
+ mConfig.setDomain(args[offset + 1]);
+ mDatabase = null;
+ offset += 2;
+
+ } else if ("--dev".equals(args[offset])) {
+ mConfig.setDomain("dev");
+ mDatabase = null;
+ offset += 1;
+
+ } else if ("--config".equals(args[offset])) {
+ mConfig.updateConfig(args[offset + 1]);
+ mDatabase = null;
+ offset += 2;
+
+ } else if ("--list".equals(args[offset])) {
+ //offset = list(args, ++offset);
+
+ } else if ("--create".equals(args[offset])) {
+ offset = create(args, ++offset);
+
+ } else if ("--update".equals(args[offset])) {
+ offset = update(args, ++offset);
+
+ } else if ("--drop".equals(args[offset])) {
+ offset = drop(args, ++offset);
+
+ } else if ("--get".equals(args[offset])) {
+ offset = get(args, ++offset);
+
+ } else if ("--put".equals(args[offset])) {
+ offset = put(args, ++offset);
+
+ } else if ("--delete".equals(args[offset])) {
+ offset = delete(args, ++offset);
+
+ } else if ("--scan".equals(args[offset])) {
+ offset = scan(args, ++offset);
+
+ } else if ("--scanf".equals(args[offset])) {
+ offset = scanf(args, ++offset);
+
+ /* Bootstrap Commands */
+ } else if ("--bootstrap".equals(args[offset])) {
+ if ("dev".equals(mConfig.getDomain())) {
+ offset = bootstrapDevTables(args, ++offset);
+ } else {
+ offset = bootstrapTables(args, ++offset);
+ }
+ offset = loadStrings(args, offset);
+
+ } else if ("--loadStrings".equals(args[offset])) {
+ offset = loadStrings(args, ++offset);
+
+ } else if ("--destroy".equals(args[offset])) {
+ offset = destroy(args, ++offset);
+
+ } else if ("--addadmin".equals(args[offset])) {
+ offset = addAdmin(args, ++offset);
+
+ } else if ("--import".equals(args[offset])) {
+ offset = importTable(args, ++offset);
+
+ } else {
+ throw new IllegalArgumentException("Unknown command " + args[offset]);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ System.exit(2);
+ }
+ }
+
+ private static DynamoDatabase getDatabase() {
+ if (mDatabase == null) {
+ mDatabase = new DynamoDatabase(mConfig);
+ }
+
+ return mDatabase;
+ }
+
+ private static int create(String[] args, int offset) {
+ String name = args[offset++];
+ long reads = Long.parseLong(args[offset++]);
+ long writes = Long.parseLong(args[offset++]);
+
+ DynamoDatabase db = getDatabase();
+
+ db.createTable(name, reads, writes);
+
+ return offset;
+ }
+
+ private static int update(String[] args, int offset) {
+ String name = args[offset++];
+ long reads = Long.parseLong(args[offset++]);
+ long writes = Long.parseLong(args[offset++]);
+
+ DynamoDatabase db = getDatabase();
+
+ db.updateTable(name, reads, writes);
+
+ return offset;
+ }
+
+ private static int drop(String[] args, int offset) {
+ String name = args[offset++];
+
+ DynamoDatabase db = getDatabase();
+
+ db.deleteTable(name);
+
+ return offset;
+ }
+
+ private static int get(String[] args, int offset) {
+ String table = args[offset++];
+ String key = args[offset++];
+ String attribute = args[offset++];
+
+ DynamoDatabase db = getDatabase();
+
+ String value = db.getAttribute(DynamoKey.newAttributeKey(table, key, attribute));
+
+ if (value == null) {
+ value = "<null>";
+ }
+
+ System.out.printf("%s %s:%s\n%s\n\n", table, key, attribute, value);
+
+ return offset;
+ }
+
+ private static int put(String[] args, int offset) {
+ String table = args[offset++];
+ String key = args[offset++];
+ String attribute = args[offset++];
+ String value = args[offset++];
+
+ DynamoDatabase db = getDatabase();
+
+ db.putAttribute(DynamoKey.newAttributeKey(table, key, attribute), value);
+
+ return offset;
+ }
+
+ private static int delete(String[] args, int offset) {
+ String table = args[offset++];
+ String key = args[offset++];
+ String attribute = args[offset++];
+
+ DynamoDatabase db = getDatabase();
+
+ db.deleteAttribute(DynamoKey.newAttributeKey(table, key, attribute));
+
+ System.out.printf("Deleted %s %s:%s\n\n", table, key, attribute);
+
+ return offset;
+ }
+
+ private static int scan(String[] args, int offset) {
+ String table = args[offset++];
+
+ DynamoKey key = DynamoKey.newKey(table, null);
+
+ doScan(key);
+
+ return offset;
+ }
+
+ private static int scanf(String[] args, int offset) {
+ String table = args[offset++];
+ String attribute = args[offset++];
+
+ DynamoKey key = DynamoKey.newAttributeKey(table, null, attribute);
+
+ doScan(key);
+
+ return offset;
+ }
+
+ private static void doScan(DynamoKey key) {
+ DynamoDatabase db = getDatabase();
+
+ String attributeFilter = key.getAttribute();
+
+ while (key != null) {
+ Map<DynamoKey, Map<String, String>> result = db.getAll(key);
+
+ key = null; // If there are no results, exit
+
+ for (Map.Entry<DynamoKey, Map<String, String>> entry : result.entrySet()) {
+ key = entry.getKey(); // Save the last key
+
+ for (Map.Entry<String, String> attribute : entry.getValue().entrySet()) {
+ if (attributeFilter == null || attributeFilter.equals(attribute.getKey())) {
+ String keyString = key.getHashKey();
+ if (key.getRangeKey() != null) {
+ keyString += "(" + key.getRangeKey() + ")";
+ }
+ System.out.printf("%s %s:%s\n%s\n\n",
+ key.getTable(), keyString, attribute.getKey(),
+ attribute.getValue());
+ }
+ }
+ }
+ }
+ }
+
+
+ private static int bootstrapTables(String[] args, int offset) {
+ DynamoDatabase db = getDatabase();
+
+ db.createTable("strings", 5, 1);
+ db.createTable("accounts", 5, 1);
+ db.createTable("assessments", 5, 5);
+ db.createTable("training", 5, 5);
+ db.createTable("feedthreads", 5, 1);
+ db.createTable("feedmessages", 5, 1);
+
+ return offset;
+ }
+
+ private static int bootstrapDevTables(String[] args, int offset) {
+ DynamoDatabase db = getDatabase();
+
+ db.createTable("strings", 1, 1);
+ db.createTable("accounts", 1, 1);
+ db.createTable("assessments", 1, 1);
+ db.createTable("training", 1, 1);
+ db.createTable("feedthreads", 1, 1);
+ db.createTable("feedmessages", 1, 1);
+
+ return offset;
+ }
+
+ private static int loadStrings(String[] args, int offset) throws IOException {
+ String data = args[offset++];
+ File baseDir = new File(data);
+
+ DynamoDatabase db = getDatabase();
+
+ insertQuestions(baseDir);
+ insertVideos(baseDir);
+ insertDefaultPlaylist(baseDir);
+
+ return offset;
+ }
+
+ private static int destroy(String[] args, int offset) {
+ DynamoDatabase db = getDatabase();
+
+ final String[] tables = { "strings",
+ "accounts",
+ "assessments",
+ "training",
+ "feedthreads",
+ "feedmessages"
+ };
+
+ for (String table : tables) {
+ try {
+ db.deleteTable(table);
+ } catch (Exception e) {
+ System.err.println("Deleting " + table + ": " + e.getMessage());
+ }
+ }
+
+ return offset;
+ }
+
+ private static int addAdmin(String[] args, int offset) throws IOException {
+ String user = args[offset++];
+ String pass = args[offset++];
+
+ DynamoDatabase db = getDatabase();
+
+ UserRecord record = new UserRecord();
+ record.setId(user);
+ record.setBackendPassword(pass);
+
+ Provider<DynamoKey, UserRecord> provider = new DynamoProviderImpl(db, UserRecord.class);
+ provider.put(DynamoKey.newAttributeKey("accounts", user, "value"), record);
+
+ return offset;
+ }
+
+ private static int importTable(String[] args, int offset) throws IOException {
+ String table = args[offset++];
+ String filename = args[offset++];
+
+ DynamoDatabase db = getDatabase();
+
+ List<String> lines = Files.readAllLines(new File(filename).toPath(),
+ StandardCharsets.UTF_8);
+
+ int count = 0;
+
+ String key = null;
+ Map<String, String> attributes = new HashMap<>();
+ for (String line : lines) {
+ if (line.length() == 0) {
+ if (attributes.size() > 0) {
+ db.putKey(DynamoKey.newKey(table, key), attributes);
+ count++;
+
+ if (count % 50 == 0) {
+ System.out.printf("Imported %d records into %s...\n", count, table);
+ }
+ }
+ key = null;
+ attributes = new HashMap<>();
+ continue;
+ }
+
+ if (key == null) {
+ key = line;
+ continue;
+ }
+
+ int space = line.indexOf(' ');
+ String attribute = line.substring(0, space);
+ String value = line.substring(space + 1);
+
+ attributes.put(attribute, value);
+ }
+
+ // Finish up the remaining attributes.
+ if (key != null && attributes.size() > 0) {
+ db.putKey(DynamoKey.newKey(table, key), attributes);
+ count++;
+ }
+
+ System.out.printf("Imported %d records into %s.\n", count, table);
+
+ return offset;
+ }
+
+ private static void insertQuestions(File baseDir) throws IOException {
+ DynamoDatabase db = getDatabase();
+ File questions = new File(baseDir, "questions");
+
+ File[] files = questions.listFiles(JSON_FILTER);
+ Arrays.sort(files);
+
+ for (File file : files) {
+ String filename = file.getName();
+ String questionId = filename.substring(0, filename.lastIndexOf('.'));
+
+ byte[] encoded = Files.readAllBytes(file.toPath());
+ String value = new String(encoded, StandardCharsets.UTF_8);
+ db.putAttribute(DynamoKey.newAttributeKey("strings",
+ "/questions/" + questionId, "value"), value);
+ System.out.println("Inserted /questions/" + questionId);
+ }
+
+ String filename = files[0].getName();
+ String first = filename.substring(0, filename.lastIndexOf('.'));
+ int count = files.length;
+ String summary = "{\"first\": \"" + first + "\", \"count\": " + count + "}";
+ db.putAttribute(DynamoKey.newAttributeKey("strings", "/questions", "value"), summary);
+ System.out.println("Inserted /questions");
+ }
+
+ private static void insertVideos(File baseDir) throws IOException {
+ DynamoDatabase db = getDatabase();
+ File videos = new File(baseDir, "videos");
+
+ for (File topic : videos.listFiles()) {
+ if (!topic.isDirectory()) {
+ continue;
+ }
+
+ String topicName = topic.getName();
+
+ Map<String, String> attributes = new HashMap<>();
+ File[] files = topic.listFiles(JSON_FILTER);
+ for (File file : files) {
+ String filename = file.getName();
+ String videoId = filename.substring(0, filename.lastIndexOf('.'));
+
+ byte[] encoded = Files.readAllBytes(file.toPath());
+ String value = new String(encoded, StandardCharsets.UTF_8);
+
+ attributes.put(videoId, value);
+ System.out.println("Found /training/" + topicName + ":" + videoId);
+ }
+
+ db.putKey(DynamoKey.newKey("strings",
+ "/training/" + topicName), attributes);
+ System.out.println("Inserted /training/" + topicName);
+ }
+ }
+
+ private static void insertDefaultPlaylist(File baseDir) throws IOException {
+ DynamoDatabase db = getDatabase();
+ File file = new File(baseDir, "videos/playlist.json");
+
+ byte[] encoded = Files.readAllBytes(file.toPath());
+ String value = new String(encoded, StandardCharsets.UTF_8);
+ db.putAttribute(DynamoKey.newAttributeKey("strings",
+ "/training/defaultplaylist", "value"), value);
+ System.out.println("Inserted /training/defaultplaylist");
+ }
+
+ private static class JsonFilter implements FilenameFilter {
+ @Override
+ public boolean accept(File dir, String name) {
+ return name.endsWith(".json");
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java
new file mode 100644
index 0000000..b53e9f7
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.dynamo;
+
+import java.io.IOException;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.p4square.grow.provider.CollectionProvider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class DynamoCollectionProviderImpl<V> implements CollectionProvider<String, String, V> {
+ private final DynamoDatabase mDb;
+ private final String mTable;
+ private final Class<V> mClazz;
+
+ public DynamoCollectionProviderImpl(DynamoDatabase db, String table, Class<V> clazz) {
+ mDb = db;
+ mTable = table;
+ mClazz = clazz;
+ }
+
+ @Override
+ public V get(String collection, String key) throws IOException {
+ String blob = mDb.getAttribute(DynamoKey.newAttributeKey(mTable, 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 LinkedHashMap<>();
+
+ Map<String, String> row = mDb.getKey(DynamoKey.newKey(mTable, collection));
+ if (row.size() > 0) {
+ int count = 0;
+ for (Map.Entry<String, String> c : row.entrySet()) {
+ if (limit >= 0 && ++count > limit) {
+ break; // Limit reached.
+ }
+
+ String key = c.getKey();
+ String blob = c.getValue();
+ V obj = decode(blob);
+
+ result.put(key, obj);
+ }
+ }
+
+ return Collections.unmodifiableMap(result);
+ }
+
+ @Override
+ public void put(String collection, String key, V obj) throws IOException {
+ if (obj == null) {
+ mDb.deleteAttribute(DynamoKey.newAttributeKey(mTable, collection, key));
+ } else {
+ String blob = encode(obj);
+ mDb.putAttribute(DynamoKey.newAttributeKey(mTable, 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 {
+ if (mClazz == String.class) {
+ return (String) obj;
+ } else {
+ 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;
+ }
+
+ if (mClazz == String.class) {
+ return (V) blob;
+ }
+
+ V obj = JsonEncodedProvider.MAPPER.readValue(blob, mClazz);
+ return obj;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java
new file mode 100644
index 0000000..68a165d
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java
@@ -0,0 +1,307 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.dynamo;
+
+import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.amazonaws.auth.AWSCredentials;
+import com.amazonaws.auth.DefaultAWSCredentialsProviderChain;
+import com.amazonaws.regions.Region;
+import com.amazonaws.regions.Regions;
+import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient;
+import com.amazonaws.services.dynamodbv2.model.AttributeAction;
+import com.amazonaws.services.dynamodbv2.model.AttributeDefinition;
+import com.amazonaws.services.dynamodbv2.model.AttributeValue;
+import com.amazonaws.services.dynamodbv2.model.AttributeValueUpdate;
+import com.amazonaws.services.dynamodbv2.model.CreateTableRequest;
+import com.amazonaws.services.dynamodbv2.model.CreateTableResult;
+import com.amazonaws.services.dynamodbv2.model.DeleteItemRequest;
+import com.amazonaws.services.dynamodbv2.model.DeleteItemResult;
+import com.amazonaws.services.dynamodbv2.model.DeleteTableRequest;
+import com.amazonaws.services.dynamodbv2.model.DeleteTableResult;
+import com.amazonaws.services.dynamodbv2.model.GetItemRequest;
+import com.amazonaws.services.dynamodbv2.model.GetItemResult;
+import com.amazonaws.services.dynamodbv2.model.KeySchemaElement;
+import com.amazonaws.services.dynamodbv2.model.KeyType;
+import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput;
+import com.amazonaws.services.dynamodbv2.model.PutItemRequest;
+import com.amazonaws.services.dynamodbv2.model.PutItemResult;
+import com.amazonaws.services.dynamodbv2.model.ScanRequest;
+import com.amazonaws.services.dynamodbv2.model.ScanResult;
+import com.amazonaws.services.dynamodbv2.model.UpdateItemRequest;
+import com.amazonaws.services.dynamodbv2.model.UpdateItemResult;
+import com.amazonaws.services.dynamodbv2.model.UpdateTableRequest;
+import com.amazonaws.services.dynamodbv2.model.UpdateTableResult;
+
+import com.p4square.grow.config.Config;
+
+/**
+ * A wrapper around the Dynamo API.
+ */
+public class DynamoDatabase {
+ private final AmazonDynamoDBClient mClient;
+ private final String mTablePrefix;
+
+ public DynamoDatabase(final Config config) {
+ AWSCredentials creds;
+
+ String awsAccessKey = config.getString("awsAccessKey");
+ if (awsAccessKey != null) {
+ creds = new AWSCredentials() {
+ @Override
+ public String getAWSAccessKeyId() {
+ return config.getString("awsAccessKey");
+ }
+ @Override
+ public String getAWSSecretKey() {
+ return config.getString("awsSecretKey");
+ }
+ };
+ } else {
+ creds = new DefaultAWSCredentialsProviderChain().getCredentials();
+ }
+
+ mClient = new AmazonDynamoDBClient(creds);
+
+ String endpoint = config.getString("dynamoEndpoint");
+ if (endpoint != null) {
+ mClient.setEndpoint(endpoint);
+ }
+
+ String region = config.getString("awsRegion");
+ if (region != null) {
+ mClient.setRegion(Region.getRegion(Regions.fromName(region)));
+ }
+
+ mTablePrefix = config.getString("dynamoTablePrefix", "");
+ }
+
+ public void createTable(String name, long reads, long writes) {
+ ArrayList<AttributeDefinition> attributeDefinitions = new ArrayList<>();
+ attributeDefinitions.add(new AttributeDefinition()
+ .withAttributeName("id")
+ .withAttributeType("S"));
+
+ ArrayList<KeySchemaElement> ks = new ArrayList<>();
+ ks.add(new KeySchemaElement().withAttributeName("id").withKeyType(KeyType.HASH));
+
+ ProvisionedThroughput provisionedThroughput = new ProvisionedThroughput()
+ .withReadCapacityUnits(reads)
+ .withWriteCapacityUnits(writes);
+
+ CreateTableRequest request = new CreateTableRequest()
+ .withTableName(mTablePrefix + name)
+ .withAttributeDefinitions(attributeDefinitions)
+ .withKeySchema(ks)
+ .withProvisionedThroughput(provisionedThroughput);
+
+ CreateTableResult result = mClient.createTable(request);
+ }
+
+ public void updateTable(String name, long reads, long writes) {
+ ProvisionedThroughput provisionedThroughput = new ProvisionedThroughput()
+ .withReadCapacityUnits(reads)
+ .withWriteCapacityUnits(writes);
+
+ UpdateTableRequest request = new UpdateTableRequest()
+ .withTableName(mTablePrefix + name)
+ .withProvisionedThroughput(provisionedThroughput);
+
+ UpdateTableResult result = mClient.updateTable(request);
+ }
+
+ public void deleteTable(String name) {
+ DeleteTableRequest deleteTableRequest = new DeleteTableRequest()
+ .withTableName(mTablePrefix + name);
+
+ DeleteTableResult result = mClient.deleteTable(deleteTableRequest);
+ }
+
+ /**
+ * Get all rows from a table.
+ *
+ * The key parameter must specify a table. If hash/range key is specified,
+ * the scan will begin after that key.
+ *
+ * @param key Previous key to start with.
+ * @return An ordered map of all results.
+ */
+ public Map<DynamoKey, Map<String, String>> getAll(final DynamoKey key) {
+ ScanRequest scanRequest = new ScanRequest().withTableName(mTablePrefix + key.getTable());
+
+ if (key.getHashKey() != null) {
+ scanRequest.setExclusiveStartKey(generateKey(key));
+ }
+
+ ScanResult scanResult = mClient.scan(scanRequest);
+
+ Map<DynamoKey, Map<String, String>> result = new LinkedHashMap<>();
+ for (Map<String, AttributeValue> map : scanResult.getItems()) {
+ String id = null;
+ String range = null;
+ Map<String, String> row = new LinkedHashMap<>();
+ for (Map.Entry<String, AttributeValue> entry : map.entrySet()) {
+ if ("id".equals(entry.getKey())) {
+ id = entry.getValue().getS();
+ } else if ("range".equals(entry.getKey())) {
+ range = entry.getValue().getS();
+ } else {
+ row.put(entry.getKey(), entry.getValue().getS());
+ }
+ }
+ result.put(DynamoKey.newRangeKey(key.getTable(), id, range), row);
+ }
+
+ return result;
+ }
+
+ public Map<String, String> getKey(final DynamoKey key) {
+ GetItemRequest getItemRequest = new GetItemRequest()
+ .withTableName(mTablePrefix + key.getTable())
+ .withKey(generateKey(key));
+
+ GetItemResult getItemResult = mClient.getItem(getItemRequest);
+ Map<String, AttributeValue> map = getItemResult.getItem();
+
+ Map<String, String> result = new LinkedHashMap<>();
+ if (map != null) {
+ for (Map.Entry<String, AttributeValue> entry : map.entrySet()) {
+ if (!"id".equals(entry.getKey())) {
+ result.put(entry.getKey(), entry.getValue().getS());
+ }
+ }
+ }
+
+ return result;
+ }
+
+ public String getAttribute(final DynamoKey key) {
+ checkAttributeKey(key);
+
+ GetItemRequest getItemRequest = new GetItemRequest()
+ .withTableName(mTablePrefix + key.getTable())
+ .withKey(generateKey(key))
+ .withAttributesToGet(key.getAttribute());
+
+ GetItemResult result = mClient.getItem(getItemRequest);
+ Map<String, AttributeValue> map = result.getItem();
+
+ if (map == null) {
+ return null;
+ }
+
+ AttributeValue value = map.get(key.getAttribute());
+ if (value != null) {
+ return value.getS();
+
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Set all attributes for the given key.
+ *
+ * @param key The key.
+ * @param values Map of attributes to values.
+ */
+ public void putKey(final DynamoKey key, final Map<String, String> values) {
+ Map<String, AttributeValue> item = new HashMap<>();
+ for (Map.Entry<String, String> entry : values.entrySet()) {
+ item.put(entry.getKey(), new AttributeValue().withS(entry.getValue()));
+ }
+
+ // Set the Key
+ item.putAll(generateKey(key));
+
+ PutItemRequest putItemRequest = new PutItemRequest()
+ .withTableName(mTablePrefix + key.getTable())
+ .withItem(item);
+
+ PutItemResult result = mClient.putItem(putItemRequest);
+ }
+
+ /**
+ * Set the particular attributes of the given key.
+ *
+ * @param key The key.
+ * @param value The new value.
+ */
+ public void putAttribute(final DynamoKey key, final String value) {
+ checkAttributeKey(key);
+
+ Map<String, AttributeValueUpdate> updateItem = new HashMap<>();
+ updateItem.put(key.getAttribute(),
+ new AttributeValueUpdate()
+ .withAction(AttributeAction.PUT)
+ .withValue(new AttributeValue().withS(value)));
+
+ UpdateItemRequest updateItemRequest = new UpdateItemRequest()
+ .withTableName(mTablePrefix + key.getTable())
+ .withKey(generateKey(key))
+ .withAttributeUpdates(updateItem);
+ // TODO: Check conditions.
+
+ UpdateItemResult result = mClient.updateItem(updateItemRequest);
+ }
+
+ /**
+ * Delete the given key.
+ *
+ * @param key The key.
+ */
+ public void deleteKey(final DynamoKey key) {
+ DeleteItemRequest deleteItemRequest = new DeleteItemRequest()
+ .withTableName(mTablePrefix + key.getTable())
+ .withKey(generateKey(key));
+
+ DeleteItemResult result = mClient.deleteItem(deleteItemRequest);
+ }
+
+ /**
+ * Delete an attribute from the given key.
+ *
+ * @param key The key.
+ */
+ public void deleteAttribute(final DynamoKey key) {
+ checkAttributeKey(key);
+
+ Map<String, AttributeValueUpdate> updateItem = new HashMap<>();
+ updateItem.put(key.getAttribute(),
+ new AttributeValueUpdate().withAction(AttributeAction.DELETE));
+
+ UpdateItemRequest updateItemRequest = new UpdateItemRequest()
+ .withTableName(mTablePrefix + key.getTable())
+ .withKey(generateKey(key))
+ .withAttributeUpdates(updateItem);
+
+ UpdateItemResult result = mClient.updateItem(updateItemRequest);
+ }
+
+ /**
+ * Generate a DynamoDB Key Map from the DynamoKey.
+ */
+ private Map<String, AttributeValue> generateKey(final DynamoKey key) {
+ HashMap<String, AttributeValue> keyMap = new HashMap<>();
+ keyMap.put("id", new AttributeValue().withS(key.getHashKey()));
+
+ String range = key.getRangeKey();
+ if (range != null) {
+ keyMap.put("range", new AttributeValue().withS(range));
+ }
+
+ return keyMap;
+ }
+
+ private void checkAttributeKey(DynamoKey key) {
+ if (null == key.getAttribute()) {
+ throw new IllegalArgumentException("Attribute must be non-null");
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java
new file mode 100644
index 0000000..5cdbacd
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.dynamo;
+
+/**
+ * DynamoKey represents a table, hash key, and range key tupl.
+ */
+public class DynamoKey {
+ private final String mTable;
+ private final String mHashKey;
+ private final String mRangeKey;
+ private final String mAttribute;
+
+ public static DynamoKey newKey(final String table, final String hashKey) {
+ return new DynamoKey(table, hashKey, null, null);
+ }
+
+ public static DynamoKey newRangeKey(final String table, final String hashKey,
+ final String rangeKey) {
+
+ return new DynamoKey(table, hashKey, rangeKey, null);
+ }
+
+ public static DynamoKey newAttributeKey(final String table, final String hashKey,
+ final String attribute) {
+
+ return new DynamoKey(table, hashKey, null, attribute);
+ }
+
+ public DynamoKey(final String table, final String hashKey, final String rangeKey,
+ final String attribute) {
+
+ mTable = table;
+ mHashKey = hashKey;
+ mRangeKey = rangeKey;
+ mAttribute = attribute;
+ }
+
+ public String getTable() {
+ return mTable;
+ }
+
+ public String getHashKey() {
+ return mHashKey;
+ }
+
+ public String getRangeKey() {
+ return mRangeKey;
+ }
+
+ public String getAttribute() {
+ return mAttribute;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java
new file mode 100644
index 0000000..93a535f
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.dynamo;
+
+import java.io.IOException;
+
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ * Provider implementation backed by a DynamoDB Table.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class DynamoProviderImpl<V> extends JsonEncodedProvider<V> implements Provider<DynamoKey, V> {
+ private final DynamoDatabase mDb;
+
+ public DynamoProviderImpl(DynamoDatabase db, Class<V> clazz) {
+ super(clazz);
+
+ mDb = db;
+ }
+
+ @Override
+ public V get(DynamoKey key) throws IOException {
+ String blob = mDb.getAttribute(key);
+ return decode(blob);
+ }
+
+ @Override
+ public void put(DynamoKey key, V obj) throws IOException {
+ String blob = encode(obj);
+ mDb.putAttribute(key, blob);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/feed/FeedDataProvider.java b/src/main/java/com/p4square/grow/backend/feed/FeedDataProvider.java
new file mode 100644
index 0000000..6f090c0
--- /dev/null
+++ b/src/main/java/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", "leader" }));
+
+ /**
+ * @return a CollectionProvider of Threads.
+ */
+ CollectionProvider<String, String, MessageThread> getThreadProvider();
+
+ /**
+ * @return a CollectionProvider of Messages.
+ */
+ CollectionProvider<String, String, Message> getMessageProvider();
+}
diff --git a/src/main/java/com/p4square/grow/backend/feed/ThreadResource.java b/src/main/java/com/p4square/grow/backend/feed/ThreadResource.java
new file mode 100644
index 0000000..e8f46c2
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/feed/ThreadResource.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.feed;
+
+import java.io.IOException;
+
+import java.util.Date;
+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.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 message to the thread.
+ */
+ @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(Message.generateId());
+
+ if (message.getCreated() == null) {
+ message.setCreated(new Date());
+ }
+
+ 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/main/java/com/p4square/grow/backend/feed/TopicResource.java b/src/main/java/com/p4square/grow/backend/feed/TopicResource.java
new file mode 100644
index 0000000..24b6a92
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/feed/TopicResource.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.feed;
+
+import java.io.IOException;
+
+import java.util.Date;
+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.Message;
+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);
+ }
+
+ // Parse limit query parameter.
+ int limit = -1;
+ String limitString = getQueryValue("limit");
+ if (limitString != null) {
+ try {
+ limit = Integer.parseInt(limitString);
+ } catch (NumberFormatException e) {
+ setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
+ return null;
+ }
+ }
+
+ try {
+ Map<String, MessageThread> threads = mBackend.getThreadProvider().query(mTopic, limit);
+ 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 {
+ // Deserialize the incoming message.
+ JacksonRepresentation<MessageThread> jsonRep =
+ new JacksonRepresentation<MessageThread>(entity, MessageThread.class);
+
+ // Get the message from the request.
+ // Throw away the wrapping MessageThread because we'll create our own later.
+ Message message = jsonRep.getObject().getMessage();
+ if (message.getCreated() == null) {
+ message.setCreated(new Date());
+ }
+
+ // Create the new thread.
+ MessageThread newThread = MessageThread.createNew();
+
+ // Force the thread id and message to be what we expect.
+ message.setId(Message.generateId());
+ message.setThreadId(newThread.getId());
+ newThread.setMessage(message);
+
+ mBackend.getThreadProvider().put(mTopic, newThread.getId(), newThread);
+
+ setLocationRef(mTopic + "/" + newThread.getId());
+ return new JacksonRepresentation(newThread);
+
+ } catch (IOException e) {
+ LOG.error("Unexpected exception: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/resources/AccountResource.java b/src/main/java/com/p4square/grow/backend/resources/AccountResource.java
new file mode 100644
index 0000000..2ac7061
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/resources/AccountResource.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.resources;
+
+import java.io.IOException;
+
+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.UserRecord;
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.ProvidesUserRecords;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ * Stores a document about a user.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AccountResource extends ServerResource {
+ private static final Logger LOG = Logger.getLogger(AccountResource.class);
+
+ private Provider<String, UserRecord> mUserRecordProvider;
+
+ private String mUserId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ final ProvidesUserRecords backend = (ProvidesUserRecords) getApplication();
+ mUserRecordProvider = backend.getUserRecordProvider();
+
+ mUserId = getAttribute("userId");
+ }
+
+ /**
+ * Handle GET Requests.
+ */
+ @Override
+ protected Representation get() {
+ try {
+ UserRecord result = mUserRecordProvider.get(mUserId);
+
+ if (result == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return null;
+ }
+
+ JacksonRepresentation<UserRecord> rep = new JacksonRepresentation<UserRecord>(result);
+ rep.setObjectMapper(JsonEncodedProvider.MAPPER);
+ return rep;
+
+ } catch (IOException e) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+ }
+
+ /**
+ * Handle PUT requests
+ */
+ @Override
+ protected Representation put(Representation entity) {
+ try {
+ JacksonRepresentation<UserRecord> representation =
+ new JacksonRepresentation<>(entity, UserRecord.class);
+ representation.setObjectMapper(JsonEncodedProvider.MAPPER);
+ UserRecord record = representation.getObject();
+
+ mUserRecordProvider.put(mUserId, record);
+ setStatus(Status.SUCCESS_NO_CONTENT);
+
+ } catch (IOException e) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ }
+
+ return null;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/resources/BannerResource.java b/src/main/java/com/p4square/grow/backend/resources/BannerResource.java
new file mode 100644
index 0000000..2b9c8e6
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/resources/BannerResource.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.resources;
+
+import java.io.IOException;
+
+import org.restlet.data.Status;
+import org.restlet.ext.jackson.JacksonRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.resource.ServerResource;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.grow.backend.GrowBackend;
+import com.p4square.grow.model.Banner;
+import com.p4square.grow.provider.JsonEncodedProvider;
+import com.p4square.grow.provider.Provider;
+
+/**
+ * Fetches or sets the banner string.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class BannerResource extends ServerResource {
+ private static final Logger LOG = Logger.getLogger(BannerResource.class);
+
+ public static final ObjectMapper MAPPER = JsonEncodedProvider.MAPPER;
+
+ private Provider<String, String> mStringProvider;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ final GrowBackend backend = (GrowBackend) getApplication();
+ mStringProvider = backend.getStringProvider();
+ }
+
+ /**
+ * Handle GET Requests.
+ */
+ @Override
+ protected Representation get() {
+ String result = null;
+ try {
+ result = mStringProvider.get("banner");
+
+ } catch (IOException e) {
+ LOG.warn("Exception loading banner: " + e);
+ }
+
+ if (result == null || result.length() == 0) {
+ result = "{\"html\":null}";
+ }
+
+ return new StringRepresentation(result);
+ }
+
+ /**
+ * Handle PUT requests
+ */
+ @Override
+ protected Representation put(Representation entity) {
+ try {
+ JacksonRepresentation<Banner> representation =
+ new JacksonRepresentation<>(entity, Banner.class);
+ representation.setObjectMapper(MAPPER);
+
+ Banner banner = representation.getObject();
+
+ mStringProvider.put("banner", MAPPER.writeValueAsString(banner));
+ setStatus(Status.SUCCESS_NO_CONTENT);
+
+ } catch (IOException e) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ }
+
+ return null;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/resources/SurveyResource.java b/src/main/java/com/p4square/grow/backend/resources/SurveyResource.java
new file mode 100644
index 0000000..8723ee2
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/resources/SurveyResource.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.resources;
+
+import java.io.IOException;
+
+import java.util.Map;
+import java.util.HashMap;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.ext.jackson.JacksonRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.resource.ServerResource;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.grow.backend.GrowBackend;
+import com.p4square.grow.model.Question;
+import com.p4square.grow.provider.JsonEncodedProvider;
+import com.p4square.grow.provider.Provider;
+
+/**
+ * This resource manages assessment questions.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SurveyResource extends ServerResource {
+ private static final Logger LOG = Logger.getLogger(SurveyResource.class);
+
+ private static final ObjectMapper MAPPER = JsonEncodedProvider.MAPPER;
+
+ private Provider<String, Question> mQuestionProvider;
+ private Provider<String, String> mStringProvider;
+
+ private String mQuestionId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ final GrowBackend backend = (GrowBackend) getApplication();
+ mQuestionProvider = backend.getQuestionProvider();
+ mStringProvider = backend.getStringProvider();
+
+ 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")) {
+ // Get the first question id from db?
+ Map<?, ?> questionSummary = getQuestionsSummary();
+ mQuestionId = (String) questionSummary.get("first");
+
+ } else if (mQuestionId.equals("count")) {
+ // Get the first question id from db?
+ Map<?, ?> questionSummary = getQuestionsSummary();
+
+ return new StringRepresentation("{\"count\":" +
+ String.valueOf((Integer) questionSummary.get("count")) + "}");
+ }
+
+ if (mQuestionId != null) {
+ // Get a question by id
+ Question question = null;
+ try {
+ question = mQuestionProvider.get(mQuestionId);
+ } catch (IOException e) {
+ LOG.error("IOException loading question: " + e);
+ }
+
+ if (question == null) {
+ // 404
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return null;
+ }
+
+ JacksonRepresentation<Question> rep = new JacksonRepresentation<>(question);
+ rep.setObjectMapper(MAPPER);
+ return rep;
+ }
+
+ return new StringRepresentation(result);
+ }
+
+ private Map<?, ?> getQuestionsSummary() {
+ try {
+ // TODO: This could be better. Quick fix for provider support.
+ String json = mStringProvider.get("/questions");
+
+ if (json != null) {
+ return MAPPER.readValue(json, Map.class);
+ }
+
+ } catch (IOException e) {
+ LOG.info("Exception reading questions summary.", e);
+ }
+
+ return null;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java b/src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java
new file mode 100644
index 0000000..7c15cfd
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java
@@ -0,0 +1,253 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.resources;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.HashMap;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.resource.ServerResource;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.grow.backend.GrowBackend;
+import com.p4square.grow.model.Answer;
+import com.p4square.grow.model.Question;
+import com.p4square.grow.model.RecordedAnswer;
+import com.p4square.grow.model.Score;
+import com.p4square.grow.model.UserRecord;
+import com.p4square.grow.provider.CollectionProvider;
+import com.p4square.grow.provider.Provider;
+
+
+/**
+ * Store the user's answers to the assessment and generate their score.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SurveyResultsResource extends ServerResource {
+ private static final Logger LOG = Logger.getLogger(SurveyResultsResource.class);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ static enum RequestType {
+ ASSESSMENT, ANSWER
+ }
+
+ private CollectionProvider<String, String, String> mAnswerProvider;
+ private Provider<String, Question> mQuestionProvider;
+ private Provider<String, UserRecord> mUserRecordProvider;
+
+ private RequestType mRequestType;
+ private String mUserId;
+ private String mQuestionId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ final GrowBackend backend = (GrowBackend) getApplication();
+ mAnswerProvider = backend.getAnswerProvider();
+ mQuestionProvider = backend.getQuestionProvider();
+ mUserRecordProvider = backend.getUserRecordProvider();
+
+ mUserId = getAttribute("userId");
+ mQuestionId = getAttribute("questionId");
+
+ mRequestType = RequestType.ASSESSMENT;
+ if (mQuestionId != null) {
+ mRequestType = RequestType.ANSWER;
+ }
+ }
+
+ /**
+ * Handle GET Requests.
+ */
+ @Override
+ protected Representation get() {
+ try {
+ String result = null;
+
+ switch (mRequestType) {
+ case ANSWER:
+ result = mAnswerProvider.get(mUserId, mQuestionId);
+ break;
+
+ case ASSESSMENT:
+ result = mAnswerProvider.get(mUserId, "summary");
+ if (result == null || result.length() == 0) {
+ result = buildAssessment();
+ }
+ break;
+ }
+
+ if (result == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return null;
+ }
+
+ return new StringRepresentation(result);
+ } catch (IOException e) {
+ LOG.error("IOException getting answer: ", e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+ }
+
+ /**
+ * Handle PUT requests
+ */
+ @Override
+ protected Representation put(Representation entity) {
+ boolean success = false;
+
+ switch (mRequestType) {
+ case ANSWER:
+ try {
+ mAnswerProvider.put(mUserId, mQuestionId, entity.getText());
+ mAnswerProvider.put(mUserId, "lastAnswered", mQuestionId);
+ mAnswerProvider.put(mUserId, "summary", null);
+ success = true;
+
+ } catch (Exception e) {
+ LOG.warn("Caught exception putting answer: " + e.getMessage(), e);
+ }
+ break;
+
+ default:
+ setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED);
+ return null;
+ }
+
+ if (success) {
+ setStatus(Status.SUCCESS_NO_CONTENT);
+
+ } else {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ }
+
+ return null;
+ }
+
+ /**
+ * Clear assessment results.
+ */
+ @Override
+ protected Representation delete() {
+ boolean success = false;
+
+ switch (mRequestType) {
+ case ANSWER:
+ try {
+ mAnswerProvider.put(mUserId, mQuestionId, null);
+ mAnswerProvider.put(mUserId, "summary", null);
+ success = true;
+
+ } catch (Exception e) {
+ LOG.warn("Caught exception putting answer: " + e.getMessage(), e);
+ }
+ break;
+
+ case ASSESSMENT:
+ try {
+ mAnswerProvider.put(mUserId, "summary", null);
+ mAnswerProvider.put(mUserId, "lastAnswered", null);
+ // TODO Delete answers
+
+ UserRecord record = mUserRecordProvider.get(mUserId);
+ if (record != null) {
+ record.setLanding("assessment");
+ mUserRecordProvider.put(mUserId, record);
+ }
+
+ success = true;
+
+ } catch (Exception e) {
+ LOG.warn("Caught exception putting answer: " + e.getMessage(), e);
+ }
+ break;
+
+ default:
+ setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED);
+ return null;
+ }
+
+ if (success) {
+ setStatus(Status.SUCCESS_NO_CONTENT);
+
+ } else {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ }
+
+ return null;
+
+ }
+
+ /**
+ * This method compiles assessment results.
+ */
+ private String buildAssessment() throws IOException {
+ StringBuilder sb = new StringBuilder("{ ");
+
+ // Last question answered
+ final String lastAnswered = mAnswerProvider.get(mUserId, "lastAnswered");
+ if (lastAnswered != null && lastAnswered.length() > 0) {
+ sb.append("\"lastAnswered\": \"" + lastAnswered + "\", ");
+ }
+
+ // Compute score
+ Map<String, String> row = mAnswerProvider.query(mUserId);
+ if (row.size() > 0) {
+ Score score = new Score();
+ boolean scoringDone = false;
+ int totalAnswers = 0;
+ for (Map.Entry<String, String> c : row.entrySet()) {
+ if (c.getKey().equals("lastAnswered") || c.getKey().equals("summary")) {
+ continue;
+ }
+
+ try {
+ Question question = mQuestionProvider.get(c.getKey());
+ RecordedAnswer userAnswer = MAPPER.readValue(c.getValue(), RecordedAnswer.class);
+
+ if (question == null) {
+ LOG.warn("Answer for unknown question: " + c.getKey());
+ continue;
+ }
+
+ LOG.debug("Scoring questionId: " + c.getKey());
+ scoringDone = !question.scoreAnswer(score, userAnswer);
+
+ } catch (Exception e) {
+ LOG.error("Failed to score question: {userid: \"" + mUserId +
+ "\", questionid:\"" + c.getKey() +
+ "\", userAnswer:\"" + c.getValue() + "\"}", e);
+ }
+
+ totalAnswers++;
+ }
+
+ sb.append("\"score\":" + score.getScore());
+ sb.append(", \"sum\":" + score.getSum());
+ sb.append(", \"count\":" + score.getCount());
+ sb.append(", \"totalAnswers\":" + totalAnswers);
+ sb.append(", \"result\":\"" + score.toString() + "\"");
+ }
+
+ sb.append(" }");
+ String summary = sb.toString();
+
+ // Persist summary
+ mAnswerProvider.put(mUserId, "summary", summary);
+
+ return summary;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java b/src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java
new file mode 100644
index 0000000..51ba56a
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.resources;
+
+import java.io.IOException;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+
+import com.fasterxml.jackson.databind.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.restlet.ext.jackson.JacksonRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.grow.backend.GrowBackend;
+
+import com.p4square.grow.model.Chapter;
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.model.VideoRecord;
+import com.p4square.grow.model.TrainingRecord;
+
+import com.p4square.grow.provider.CollectionProvider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.ProvidesAssessments;
+import com.p4square.grow.provider.ProvidesTrainingRecords;
+
+import com.p4square.grow.model.Score;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class TrainingRecordResource extends ServerResource {
+ private static final Logger LOG = Logger.getLogger(TrainingRecordResource.class);
+ private static final ObjectMapper MAPPER = JsonEncodedProvider.MAPPER;
+
+ static enum RequestType {
+ SUMMARY, VIDEO
+ }
+
+ private Provider<String, TrainingRecord> mTrainingRecordProvider;
+ private CollectionProvider<String, String, String> mAnswerProvider;
+
+ private RequestType mRequestType;
+ private String mUserId;
+ private String mVideoId;
+ private TrainingRecord mRecord;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mTrainingRecordProvider = ((ProvidesTrainingRecords) getApplication()).getTrainingRecordProvider();
+ mAnswerProvider = ((ProvidesAssessments) getApplication()).getAnswerProvider();
+
+ mUserId = getAttribute("userId");
+ mVideoId = getAttribute("videoId");
+
+ try {
+ Playlist defaultPlaylist = ((ProvidesTrainingRecords) getApplication()).getDefaultPlaylist();
+
+ mRecord = mTrainingRecordProvider.get(mUserId);
+ if (mRecord == null) {
+ mRecord = new TrainingRecord();
+ mRecord.setPlaylist(defaultPlaylist);
+ skipAssessedChapters(mUserId, mRecord);
+ } else {
+ // Merge the playlist with the most recent version.
+ mRecord.getPlaylist().merge(defaultPlaylist);
+ }
+
+ } catch (IOException e) {
+ LOG.error("IOException loading TrainingRecord: " + e.getMessage(), e);
+ mRecord = null;
+ }
+
+ mRequestType = RequestType.SUMMARY;
+ if (mVideoId != null) {
+ mRequestType = RequestType.VIDEO;
+ }
+ }
+
+ /**
+ * Handle GET Requests.
+ */
+ @Override
+ protected Representation get() {
+ JacksonRepresentation<?> rep = null;
+
+ if (mRecord == null) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+
+ switch (mRequestType) {
+ case VIDEO:
+ VideoRecord video = mRecord.getPlaylist().find(mVideoId);
+ if (video == null) {
+ break; // Fall through and return 404
+ }
+ rep = new JacksonRepresentation<VideoRecord>(video);
+ break;
+
+ case SUMMARY:
+ rep = new JacksonRepresentation<TrainingRecord>(mRecord);
+ break;
+ }
+
+ if (rep == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return null;
+
+ } else {
+ rep.setObjectMapper(JsonEncodedProvider.MAPPER);
+ return rep;
+ }
+ }
+
+ /**
+ * Handle PUT requests
+ */
+ @Override
+ protected Representation put(Representation entity) {
+ if (mRecord == null) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+
+ switch (mRequestType) {
+ case VIDEO:
+ try {
+ JacksonRepresentation<VideoRecord> representation =
+ new JacksonRepresentation<>(entity, VideoRecord.class);
+ representation.setObjectMapper(JsonEncodedProvider.MAPPER);
+ VideoRecord update = representation.getObject();
+ VideoRecord video = mRecord.getPlaylist().find(mVideoId);
+
+ if (video == null) {
+ // TODO: Video isn't on their playlist...
+ LOG.warn("Skipping video completion for video missing from playlist.");
+
+ } else if (update.getComplete() && !video.getComplete()) {
+ // Video was newly completed
+ video.complete();
+ mRecord.setLastVideo(mVideoId);
+
+ mTrainingRecordProvider.put(mUserId, mRecord);
+ }
+
+ setStatus(Status.SUCCESS_NO_CONTENT);
+
+ } catch (Exception e) {
+ LOG.warn("Caught exception updating training record: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ }
+ break;
+
+ default:
+ setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED);
+ }
+
+ return null;
+ }
+
+ private Score getAssessedScore(String userId) throws IOException {
+ // Get the user's score.
+ Score assessedScore = new Score(0, 0);
+
+ String summaryString = mAnswerProvider.get(userId, "summary");
+ if (summaryString == null) {
+ throw new IOException("Asked to create training record for unassessed user " + userId);
+ }
+
+ Map<?,?> summary = MAPPER.readValue(summaryString, Map.class);
+
+ if (summary.containsKey("sum") && summary.containsKey("count")) {
+ double sum = (Double) summary.get("sum");
+ int count = (Integer) summary.get("count");
+ assessedScore = new Score(sum, count);
+ }
+
+ return assessedScore;
+ }
+
+ /**
+ * Mark the chapters which the user assessed through as not required.
+ */
+ private void skipAssessedChapters(String userId, TrainingRecord record) {
+ // Get the user's score.
+ Score assessedScore = new Score(0, 0);
+
+ try {
+ assessedScore = getAssessedScore(userId);
+ } catch (IOException e) {
+ LOG.error("IOException fetching assessment record for " + userId, e);
+ return;
+ }
+
+ // Mark the correct videos as not required.
+ Playlist playlist = record.getPlaylist();
+
+ for (Map.Entry<String, Chapter> entry : playlist.getChaptersMap().entrySet()) {
+ String chapterId = entry.getKey();
+ Chapter chapter = entry.getValue();
+ boolean required;
+
+ if ("introduction".equals(chapter)) {
+ // Introduction chapter is always required
+ required = true;
+
+ } else {
+ // Chapter required if the floor of the score is <= the chapter's numeric value.
+ required = assessedScore.floor() <= Score.numericScore(chapterId);
+ }
+
+ if (!required) {
+ for (VideoRecord video : chapter.getVideos().values()) {
+ video.setRequired(required);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/backend/resources/TrainingResource.java b/src/main/java/com/p4square/grow/backend/resources/TrainingResource.java
new file mode 100644
index 0000000..6efdfab
--- /dev/null
+++ b/src/main/java/com/p4square/grow/backend/resources/TrainingResource.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.backend.resources;
+
+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.representation.StringRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.grow.backend.GrowBackend;
+import com.p4square.grow.backend.db.CassandraDatabase;
+
+import com.p4square.grow.provider.CollectionProvider;
+/**
+ * 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 LOG = Logger.getLogger(TrainingResource.class);
+
+ private CollectionProvider<String, String, String> mVideoProvider;
+
+ private String mLevel;
+ private String mVideoId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ GrowBackend backend = (GrowBackend) getApplication();
+ mVideoProvider = backend.getVideoProvider();
+
+ 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;
+ }
+
+ try {
+ if (mVideoId == null) {
+ // Get all videos
+ // TODO: This could be improved, but this is the quickest way to get
+ // providers working.
+ Map<String, String> videos = mVideoProvider.query(mLevel);
+ if (videos.size() > 0) {
+ StringBuilder sb = new StringBuilder("{ \"level\": \"" + mLevel + "\"");
+ sb.append(", \"videos\": [");
+ boolean first = true;
+ for (String value : videos.values()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(value);
+ first = false;
+ }
+ sb.append("] }");
+ result = sb.toString();
+ }
+
+ } else {
+ // Get single video
+ result = mVideoProvider.get(mLevel, mVideoId);
+ }
+
+ if (result == null) {
+ // 404
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return null;
+ }
+
+ return new StringRepresentation(result);
+
+ } catch (IOException e) {
+ LOG.error("IOException fetch video: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/ccb/CCBProgressReporter.java b/src/main/java/com/p4square/grow/ccb/CCBProgressReporter.java
new file mode 100644
index 0000000..d2826eb
--- /dev/null
+++ b/src/main/java/com/p4square/grow/ccb/CCBProgressReporter.java
@@ -0,0 +1,104 @@
+package com.p4square.grow.ccb;
+
+import com.p4square.ccbapi.CCBAPI;
+import com.p4square.ccbapi.model.*;
+import com.p4square.grow.frontend.ProgressReporter;
+import com.p4square.grow.model.Score;
+import org.apache.log4j.Logger;
+import org.restlet.security.User;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
+
+/**
+ * A ProgressReporter which records progress in CCB.
+ *
+ * Except not really, because it's not implemented yet.
+ * This is just a placeholder until ccb-api-client-java has support for updating an individual.
+ */
+public class CCBProgressReporter implements ProgressReporter {
+
+ private static final Logger LOG = Logger.getLogger(CCBProgressReporter.class);
+
+ private static final String GROW_LEVEL = "GrowLevelTrain";
+ private static final String GROW_ASSESSMENT = "GrowLevelAsmnt";
+
+ private final CCBAPI mAPI;
+ private final CustomFieldCache mCache;
+
+ public CCBProgressReporter(final CCBAPI api, final CustomFieldCache cache) {
+ mAPI = api;
+ mCache = cache;
+ }
+
+ @Override
+ public void reportAssessmentComplete(final User user, final String level, final Date date, final String results) {
+ if (!(user instanceof CCBUser)) {
+ throw new IllegalArgumentException("Expected CCBUser but got " + user.getClass().getCanonicalName());
+ }
+ final CCBUser ccbuser = (CCBUser) user;
+
+ updateLevelAndDate(ccbuser, GROW_ASSESSMENT, level, date);
+ }
+
+ @Override
+ public void reportChapterComplete(final User user, final String chapter, final Date date) {
+ if (!(user instanceof CCBUser)) {
+ throw new IllegalArgumentException("Expected CCBUser but got " + user.getClass().getCanonicalName());
+ }
+ final CCBUser ccbuser = (CCBUser) user;
+
+ // Only update the level if it is increasing.
+ final CustomPulldownFieldValue currentLevel = ccbuser.getProfile()
+ .getCustomPulldownFields().getByLabel(GROW_LEVEL);
+
+ if (currentLevel != null) {
+ if (Score.numericScore(chapter) <= Score.numericScore(currentLevel.getSelection().getLabel())) {
+ LOG.info("Not updating level for " + user.getIdentifier()
+ + " because current level (" + currentLevel.getSelection().getLabel()
+ + ") is greater than new level (" + chapter + ")");
+ return;
+ }
+ }
+
+ updateLevelAndDate(ccbuser, GROW_LEVEL, chapter, date);
+ }
+
+ private void updateLevelAndDate(final CCBUser user, final String field, final String level, final Date date) {
+ boolean modified = false;
+
+ final UpdateIndividualProfileRequest req = new UpdateIndividualProfileRequest()
+ .withIndividualId(user.getProfile().getId());
+
+ final CustomField pulldownField = mCache.getIndividualPulldownByLabel(field);
+ if (pulldownField != null) {
+ final LookupTableType type = LookupTableType.valueOf(pulldownField.getName().toUpperCase());
+ final LookupTableItem item = mCache.getPulldownItemByName(type, level);
+ if (item != null) {
+ req.withCustomPulldownField(pulldownField.getName(), item.getId());
+ modified = true;
+ }
+ }
+
+ final CustomField dateField = mCache.getDateFieldByLabel(field);
+ if (dateField != null) {
+ req.withCustomDateField(dateField.getName(), date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
+ modified = true;
+ }
+
+ try {
+ // Only update if a field exists.
+ if (modified) {
+ mAPI.updateIndividualProfile(req);
+ }
+
+ } catch (IOException e) {
+ LOG.error("updateIndividual failed for " + user.getIdentifier()
+ + ", field " + field
+ + ", level " + level
+ + ", date " + date.toString());
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/ccb/CCBUser.java b/src/main/java/com/p4square/grow/ccb/CCBUser.java
new file mode 100644
index 0000000..7313172
--- /dev/null
+++ b/src/main/java/com/p4square/grow/ccb/CCBUser.java
@@ -0,0 +1,37 @@
+package com.p4square.grow.ccb;
+
+import com.p4square.ccbapi.model.IndividualProfile;
+import org.restlet.security.User;
+
+/**
+ * CCBUser is an adapter between a CCB IndividualProfile and a Restlet User.
+ *
+ * Note: CCBUser prefixes the user's identifier with "CCB-". This is done to
+ * ensure the identifier does not collide with identifiers from other
+ * systems.
+ */
+public class CCBUser extends User {
+
+ private final IndividualProfile mProfile;
+
+ /**
+ * Wrap an IndividualProfile inside a User object.
+ *
+ * @param profile The CCB IndividualProfile for the user.
+ */
+ public CCBUser(final IndividualProfile profile) {
+ mProfile = profile;
+
+ setIdentifier("CCB-" + mProfile.getId());
+ setFirstName(mProfile.getFirstName());
+ setLastName(mProfile.getLastName());
+ setEmail(mProfile.getEmail());
+ }
+
+ /**
+ * @return The IndividualProfile of the user.
+ */
+ public IndividualProfile getProfile() {
+ return mProfile;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/ccb/CCBUserVerifier.java b/src/main/java/com/p4square/grow/ccb/CCBUserVerifier.java
new file mode 100644
index 0000000..db10b75
--- /dev/null
+++ b/src/main/java/com/p4square/grow/ccb/CCBUserVerifier.java
@@ -0,0 +1,50 @@
+package com.p4square.grow.ccb;
+
+import com.p4square.ccbapi.CCBAPI;
+import com.p4square.ccbapi.model.GetIndividualProfilesRequest;
+import com.p4square.ccbapi.model.GetIndividualProfilesResponse;
+import org.apache.log4j.Logger;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.security.Verifier;
+
+/**
+ * CCBUserVerifier authenticates a user through the CCB individual_profile_from_login_password API.
+ */
+public class CCBUserVerifier implements Verifier {
+ private static final Logger LOG = Logger.getLogger(CCBUserVerifier.class);
+
+ private final CCBAPI mAPI;
+
+ public CCBUserVerifier(final CCBAPI api) {
+ mAPI = api;
+ }
+
+ @Override
+ public int verify(Request request, Response response) {
+ if (request.getChallengeResponse() == null) {
+ return RESULT_MISSING; // no credentials
+ }
+
+ final String username = request.getChallengeResponse().getIdentifier();
+ final char[] password = request.getChallengeResponse().getSecret();
+
+ try {
+ GetIndividualProfilesResponse resp = mAPI.getIndividualProfiles(
+ new GetIndividualProfilesRequest().withLoginPassword(username, password));
+
+ if (resp.getIndividuals().size() == 1) {
+ // Wrap the IndividualProfile up in an User and update the user on the request.
+ final CCBUser user = new CCBUser(resp.getIndividuals().get(0));
+ LOG.info("Successfully authenticated " + user.getIdentifier());
+ request.getClientInfo().setUser(user);
+ return RESULT_VALID;
+ }
+
+ } catch (Exception e) {
+ LOG.error("CCB API Exception: " + e, e);
+ }
+
+ return RESULT_INVALID; // Invalid credentials
+ }
+}
diff --git a/src/main/java/com/p4square/grow/ccb/ChurchCommunityBuilderIntegrationDriver.java b/src/main/java/com/p4square/grow/ccb/ChurchCommunityBuilderIntegrationDriver.java
new file mode 100644
index 0000000..fc6148f
--- /dev/null
+++ b/src/main/java/com/p4square/grow/ccb/ChurchCommunityBuilderIntegrationDriver.java
@@ -0,0 +1,61 @@
+package com.p4square.grow.ccb;
+
+import com.codahale.metrics.MetricRegistry;
+import com.p4square.ccbapi.CCBAPI;
+import com.p4square.ccbapi.CCBAPIClient;
+import com.p4square.grow.config.Config;
+import com.p4square.grow.frontend.IntegrationDriver;
+import com.p4square.grow.frontend.ProgressReporter;
+import org.restlet.Context;
+import org.restlet.security.Verifier;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * The ChurchCommunityBuilderIntegrationDriver is used to integrate Grow with Church Community Builder.
+ */
+public class ChurchCommunityBuilderIntegrationDriver implements IntegrationDriver {
+
+ private final Context mContext;
+ private final MetricRegistry mMetricRegistry;
+ private final Config mConfig;
+
+ private final CCBAPI mAPI;
+
+ private final CCBProgressReporter mProgressReporter;
+
+ public ChurchCommunityBuilderIntegrationDriver(final Context context) {
+ mContext = context;
+ mConfig = (Config) context.getAttributes().get("com.p4square.grow.config");
+ mMetricRegistry = (MetricRegistry) context.getAttributes().get("com.p4square.grow.metrics");
+
+ try {
+ CCBAPI api = new CCBAPIClient(new URI(mConfig.getString("CCBAPIURL", "")),
+ mConfig.getString("CCBAPIUser", ""),
+ mConfig.getString("CCBAPIPassword", ""));
+
+ if (mMetricRegistry != null) {
+ api = new MonitoredCCBAPI(api, mMetricRegistry);
+ }
+
+ mAPI = api;
+
+ final CustomFieldCache cache = new CustomFieldCache(mAPI);
+ mProgressReporter = new CCBProgressReporter(mAPI, cache);
+
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public Verifier newUserAuthenticationVerifier() {
+ return new CCBUserVerifier(mAPI);
+ }
+
+ @Override
+ public ProgressReporter getProgressReporter() {
+ return mProgressReporter;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/ccb/CustomFieldCache.java b/src/main/java/com/p4square/grow/ccb/CustomFieldCache.java
new file mode 100644
index 0000000..d93e6d9
--- /dev/null
+++ b/src/main/java/com/p4square/grow/ccb/CustomFieldCache.java
@@ -0,0 +1,126 @@
+package com.p4square.grow.ccb;
+
+import com.p4square.ccbapi.CCBAPI;
+import com.p4square.ccbapi.model.*;
+import org.apache.log4j.Logger;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * CustomFieldCache maintains an index from custom field labels to names.
+ */
+public class CustomFieldCache {
+
+ private static final Logger LOG = Logger.getLogger(CustomFieldCache.class);
+
+ private final CCBAPI mAPI;
+
+ private CustomFieldCollection<CustomField> mTextFields;
+ private CustomFieldCollection<CustomField> mDateFields;
+ private CustomFieldCollection<CustomField> mIndividualPulldownFields;
+ private CustomFieldCollection<CustomField> mGroupPulldownFields;
+
+ private final Map<LookupTableType, Map<String, LookupTableItem>> mItemByNameTable;
+
+ public CustomFieldCache(final CCBAPI api) {
+ mAPI = api;
+ mTextFields = new CustomFieldCollection<>();
+ mDateFields = new CustomFieldCollection<>();
+ mIndividualPulldownFields = new CustomFieldCollection<>();
+ mGroupPulldownFields = new CustomFieldCollection<>();
+ mItemByNameTable = new HashMap<>();
+ }
+
+ public CustomField getTextFieldByLabel(final String label) {
+ if (mTextFields.size() == 0) {
+ refresh();
+ }
+ return mTextFields.getByLabel(label);
+ }
+
+ public CustomField getDateFieldByLabel(final String label) {
+ if (mDateFields.size() == 0) {
+ refresh();
+ }
+ return mDateFields.getByLabel(label);
+ }
+
+ public CustomField getIndividualPulldownByLabel(final String label) {
+ if (mIndividualPulldownFields.size() == 0) {
+ refresh();
+ }
+ return mIndividualPulldownFields.getByLabel(label);
+ }
+
+ public CustomField getGroupPulldownByLabel(final String label) {
+ if (mGroupPulldownFields.size() == 0) {
+ refresh();
+ }
+ return mGroupPulldownFields.getByLabel(label);
+ }
+
+ public LookupTableItem getPulldownItemByName(final LookupTableType type, final String name) {
+ Map<String, LookupTableItem> items = mItemByNameTable.get(type);
+ if (items == null) {
+ if (!cacheLookupTable(type)) {
+ return null;
+ }
+ items = mItemByNameTable.get(type);
+ }
+
+ return items.get(name.toLowerCase());
+ }
+
+ private synchronized void refresh() {
+ try {
+ // Get all of the custom fields.
+ final GetCustomFieldLabelsResponse resp = mAPI.getCustomFieldLabels();
+
+ final CustomFieldCollection<CustomField> newTextFields = new CustomFieldCollection<>();
+ final CustomFieldCollection<CustomField> newDateFields = new CustomFieldCollection<>();
+ final CustomFieldCollection<CustomField> newIndPulldownFields = new CustomFieldCollection<>();
+ final CustomFieldCollection<CustomField> newGrpPulldownFields = new CustomFieldCollection<>();
+
+ for (final CustomField field : resp.getCustomFields()) {
+ if (field.getName().startsWith("udf_ind_text_")) {
+ newTextFields.add(field);
+ } else if (field.getName().startsWith("udf_ind_date_")) {
+ newDateFields.add(field);
+ } else if (field.getName().startsWith("udf_ind_pulldown_")) {
+ newIndPulldownFields.add(field);
+ } else if (field.getName().startsWith("udf_grp_pulldown_")) {
+ newGrpPulldownFields.add(field);
+ } else {
+ LOG.warn("Unknown custom field type " + field.getName());
+ }
+ }
+
+ this.mTextFields = newTextFields;
+ this.mDateFields = newDateFields;
+ this.mIndividualPulldownFields = newIndPulldownFields;
+ this.mGroupPulldownFields = newGrpPulldownFields;
+
+ } catch (IOException e) {
+ // Error fetching labels.
+ LOG.error("Error fetching custom fields: " + e.getMessage(), e);
+ }
+ }
+
+ private synchronized boolean cacheLookupTable(final LookupTableType type) {
+ try {
+ final GetLookupTableResponse resp = mAPI.getLookupTable(new GetLookupTableRequest().withType(type));
+ mItemByNameTable.put(type, resp.getItems().stream().collect(
+ Collectors.toMap(item -> item.getName().toLowerCase(), Function.identity())));
+ return true;
+
+ } catch (IOException e) {
+ LOG.error("Exception caching lookup table of type " + type, e);
+ }
+
+ return false;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/ccb/MonitoredCCBAPI.java b/src/main/java/com/p4square/grow/ccb/MonitoredCCBAPI.java
new file mode 100644
index 0000000..43b6433
--- /dev/null
+++ b/src/main/java/com/p4square/grow/ccb/MonitoredCCBAPI.java
@@ -0,0 +1,96 @@
+package com.p4square.grow.ccb;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+import com.p4square.ccbapi.CCBAPI;
+import com.p4square.ccbapi.model.*;
+
+import java.io.IOException;
+
+/**
+ * MonitoredCCBAPI is a CCBAPI decorator which records metrics for each API call.
+ */
+public class MonitoredCCBAPI implements CCBAPI {
+
+ private final CCBAPI mAPI;
+ private final MetricRegistry mMetricRegistry;
+
+ public MonitoredCCBAPI(final CCBAPI api, final MetricRegistry metricRegistry) {
+ if (api == null) {
+ throw new IllegalArgumentException("api must not be null.");
+ }
+ mAPI = api;
+
+ if (metricRegistry == null) {
+ throw new IllegalArgumentException("metricRegistry must not be null.");
+ }
+ mMetricRegistry = metricRegistry;
+ }
+
+ @Override
+ public GetCustomFieldLabelsResponse getCustomFieldLabels() throws IOException {
+ final Timer.Context timer = mMetricRegistry.timer("CCBAPI.getCustomFieldLabels.time").time();
+ boolean success = false;
+ try {
+ final GetCustomFieldLabelsResponse resp = mAPI.getCustomFieldLabels();
+ success = true;
+ return resp;
+ } finally {
+ timer.stop();
+ mMetricRegistry.counter("CCBAPI.getCustomFieldLabels.success").inc(success ? 1 : 0);
+ mMetricRegistry.counter("CCBAPI.getCustomFieldLabels.failure").inc(!success ? 1 : 0);
+ }
+ }
+
+ @Override
+ public GetLookupTableResponse getLookupTable(final GetLookupTableRequest request) throws IOException {
+ final Timer.Context timer = mMetricRegistry.timer("CCBAPI.getLookupTable.time").time();
+ boolean success = false;
+ try {
+ final GetLookupTableResponse resp = mAPI.getLookupTable(request);
+ success = true;
+ return resp;
+ } finally {
+ timer.stop();
+ mMetricRegistry.counter("CCBAPI.getLookupTable.success").inc(success ? 1 : 0);
+ mMetricRegistry.counter("CCBAPI.getLookupTable.failure").inc(!success ? 1 : 0);
+ }
+ }
+
+ @Override
+ public GetIndividualProfilesResponse getIndividualProfiles(GetIndividualProfilesRequest request)
+ throws IOException {
+ final Timer.Context timer = mMetricRegistry.timer("CCBAPI.getIndividualProfiles").time();
+ boolean success = false;
+ try {
+ final GetIndividualProfilesResponse resp = mAPI.getIndividualProfiles(request);
+ mMetricRegistry.counter("CCBAPI.getIndividualProfiles.count").inc(resp.getIndividuals().size());
+ success = true;
+ return resp;
+ } finally {
+ timer.stop();
+ mMetricRegistry.counter("CCBAPI.getIndividualProfiles.success").inc(success ? 1 : 0);
+ mMetricRegistry.counter("CCBAPI.getIndividualProfiles.failure").inc(!success ? 1 : 0);
+ }
+ }
+
+ @Override
+ public UpdateIndividualProfileResponse updateIndividualProfile(UpdateIndividualProfileRequest request) throws IOException {
+ final Timer.Context timer = mMetricRegistry.timer("CCBAPI.updateIndividualProfile").time();
+ boolean success = false;
+ try {
+ final UpdateIndividualProfileResponse resp = mAPI.updateIndividualProfile(request);
+ success = true;
+ return resp;
+ } finally {
+ timer.stop();
+ mMetricRegistry.counter("CCBAPI.updateIndividualProfile.success").inc(success ? 1 : 0);
+ mMetricRegistry.counter("CCBAPI.updateIndividualProfile.failure").inc(!success ? 1 : 0);
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ mAPI.close();
+ }
+}
diff --git a/src/main/java/com/p4square/grow/config/Config.java b/src/main/java/com/p4square/grow/config/Config.java
new file mode 100644
index 0000000..2fc2ea3
--- /dev/null
+++ b/src/main/java/com/p4square/grow/config/Config.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.config;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.IOException;
+import java.util.Properties;
+
+import org.apache.log4j.Logger;
+
+/**
+ * Manage configuration for an application.
+ *
+ * Config reads one or more property files as the application config. Duplicate
+ * properties loaded later override properties loaded earlier. Config has the
+ * concept of a domain to distinguish settings for development and production.
+ * The default domain is prod for production. Domain can be any String such as
+ * dev for development or test for testing.
+ *
+ * The property files are processed like java.util.Properties except that the
+ * keys are specified as DOMAIN.KEY. An asterisk (*) can be used in place of a
+ * domain to indicate it should apply to all domains. If a domain specific entry
+ * exists for the current domain, it will override any global config.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Config {
+ private static final Logger LOG = Logger.getLogger(Config.class);
+
+ private String mDomain;
+ private Properties mProperties;
+
+ /**
+ * Construct a new Config object.
+ *
+ * Sets the domain to the value of the system property CONFIG_DOMAIN, if present.
+ * If the system property is not set then the environment variable CONFIG_DOMAIN is checked.
+ * If neither are set the domain defaults to prod.
+ */
+ public Config() {
+ // Check the command line for a domain property.
+ mDomain = System.getProperty("CONFIG_DOMAIN");
+
+ // If the domain was not set with a property, check for an environment variable.
+ if (mDomain == null) {
+ mDomain = System.getenv("CONFIG_DOMAIN");
+ }
+
+ // If neither were set, default to prod
+ if (mDomain == null) {
+ mDomain = "prod";
+ }
+
+ mProperties = new Properties();
+ }
+
+ /**
+ * Change the domain from the default string "prod".
+ *
+ * @param domain The new domain.
+ */
+ public void setDomain(String domain) {
+ LOG.info("Setting Config domain to " + domain);
+ mDomain = domain;
+ }
+
+ /**
+ * @return the current domain.
+ */
+ public String getDomain() {
+ return mDomain;
+ }
+
+ /**
+ * Load properties from a file.
+ * Any exception are logged and suppressed.
+ */
+ public void updateConfig(String propertyFilename) {
+ final File propFile = new File(propertyFilename);
+
+ LOG.info("Loading properties from " + propFile);
+
+ try {
+ InputStream in = new FileInputStream(propFile);
+ updateConfig(in);
+
+ } catch (IOException e) {
+ LOG.error("Could not load properties file: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Load properties from an InputStream.
+ * This method closes the InputStream when it completes.
+ *
+ * @param in The InputStream
+ */
+ public void updateConfig(InputStream in) throws IOException {
+ LOG.info("Loading properties from InputStream");
+ mProperties.load(in);
+ in.close();
+ }
+
+ /**
+ * Get a String from the config.
+ *
+ * @return The config value or null if it is not found.
+ */
+ public String getString(String key) {
+ return getString(key, null);
+ }
+
+ /**
+ * Get a String from the config.
+ *
+ * @return The config value or defaultValue if it can not be found.
+ */
+ public String getString(final String key, final String defaultValue) {
+ String result;
+
+ // Command line properties trump all.
+ result = System.getProperty(key);
+ if (result != null) {
+ LOG.debug("Reading System.getProperty(" + key + "). Got result = { " + result + " }");
+ return result;
+ }
+
+ // Environment variables can also override configs
+ result = System.getenv(key);
+ if (result != null) {
+ LOG.debug("Reading System.getenv(" + key + "). Got result = { " + result + " }");
+ return result;
+ }
+
+ final String domainKey = mDomain + "." + key;
+ result = mProperties.getProperty(domainKey);
+ if (result != null) {
+ LOG.debug("Reading config for key = { " + key + " }. Got result = { " + result + " }");
+ return result;
+ }
+
+ final String globalKey = "*." + key;
+ result = mProperties.getProperty(globalKey);
+ if (result != null) {
+ LOG.debug("Reading config for key = { " + key + " }. Got result = { " + result + " }");
+ return result;
+ }
+
+ LOG.debug("Reading config for key = { " + key + " }. Got default value = { " + defaultValue + " }");
+ return defaultValue;
+ }
+
+ /**
+ * Get an integer from the config.
+ *
+ * @return The config value or Integer.MIN_VALUE if the key is not present or the
+ * config can not be parsed.
+ */
+ public int getInt(String key) {
+ return getInt(key, Integer.MIN_VALUE);
+ }
+
+ /**
+ * Get an integer from the config.
+ *
+ * @return The config value or defaultValue if the key is not present or the
+ * config can not be parsed.
+ */
+ public int getInt(String key, int defaultValue) {
+ final String propertyValue = getString(key);
+
+ if (propertyValue != null) {
+ try {
+ final int result = Integer.valueOf(propertyValue);
+ return result;
+
+ } catch (NumberFormatException e) {
+ LOG.warn("Expected property to be an integer: "
+ + key + " = { " + propertyValue + " }");
+ }
+ }
+
+ return defaultValue;
+ }
+
+ public boolean getBoolean(String key) {
+ return getBoolean(key, false);
+ }
+
+ public boolean getBoolean(String key, boolean defaultValue) {
+ final String propertyValue = getString(key);
+
+ if (propertyValue != null) {
+ return (propertyValue.charAt(0) & 0xDF) == 'T';
+ }
+
+ return defaultValue;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/AccountRedirectResource.java b/src/main/java/com/p4square/grow/frontend/AccountRedirectResource.java
new file mode 100644
index 0000000..be2ae65
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/AccountRedirectResource.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.resource.ServerResource;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+import com.p4square.grow.config.Config;
+import com.p4square.grow.model.UserRecord;
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.DelegateProvider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ * This resource simply redirects the user to either the assessment
+ * or the training page.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AccountRedirectResource extends ServerResource {
+ private static final Logger LOG = Logger.getLogger(AccountRedirectResource.class);
+
+ private Config mConfig;
+ private Provider<String, UserRecord> mUserRecordProvider;
+
+ // Fields pertaining to this request.
+ private String mUserId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ GrowFrontend growFrontend = (GrowFrontend) getApplication();
+ mConfig = growFrontend.getConfig();
+
+ mUserRecordProvider = new DelegateProvider<String, String, UserRecord>(
+ new JsonRequestProvider<UserRecord>(getContext().getClientDispatcher(),
+ UserRecord.class)) {
+ @Override
+ public String makeKey(String userid) {
+ return getBackendEndpoint() + "/accounts/" + userid;
+ }
+ };
+
+ mUserId = getRequest().getClientInfo().getUser().getIdentifier();
+ }
+
+ /**
+ * Redirect to the correct landing.
+ */
+ @Override
+ protected Representation get() {
+ if (mUserId == null || mUserId.length() == 0) {
+ // This shouldn't happen, but I want to be safe because of the DB insert below.
+ setStatus(Status.CLIENT_ERROR_FORBIDDEN);
+ return new ErrorPage("Not Authenticated!");
+ }
+
+ try {
+ // Fetch account Map.
+ UserRecord user = null;
+ try {
+ user = mUserRecordProvider.get(mUserId);
+ } catch (NotFoundException e) {
+ // User record doesn't exist, so create a new one.
+ user = new UserRecord(getRequest().getClientInfo().getUser());
+ mUserRecordProvider.put(mUserId, user);
+ }
+
+ // Check for the new believers cookie
+ String cookie = getRequest().getCookies().getFirstValue(NewBelieverResource.COOKIE_NAME);
+ if (cookie != null && cookie.length() != 0) {
+ user.setLanding("training");
+ user.setNewBeliever(true);
+ mUserRecordProvider.put(mUserId, user);
+ }
+
+ String landing = user.getLanding();
+ if (landing == null) {
+ landing = "assessment";
+ }
+
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/account/" + landing;
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ /**
+ * @return The backend endpoint URI
+ */
+ private String getBackendEndpoint() {
+ return mConfig.getString("backendUri", "riap://component/backend");
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/AssessmentResetPage.java b/src/main/java/com/p4square/grow/frontend/AssessmentResetPage.java
new file mode 100644
index 0000000..519b135
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/AssessmentResetPage.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.Map;
+
+import freemarker.template.Template;
+
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.ext.freemarker.TemplateRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+import com.p4square.fmfacade.json.JsonRequestClient;
+import com.p4square.fmfacade.json.JsonResponse;
+import com.p4square.fmfacade.json.ClientException;
+
+import com.p4square.grow.config.Config;
+
+/**
+ * This page delete's the current user's assessment.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AssessmentResetPage extends FreeMarkerPageResource {
+ private static final Logger LOG = Logger.getLogger(AssessmentResetPage.class);
+
+ private GrowFrontend mGrowFrontend;
+ private Config mConfig;
+ private JsonRequestClient mJsonClient;
+
+ private String mUserId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mGrowFrontend = (GrowFrontend) getApplication();
+ mConfig = mGrowFrontend.getConfig();
+
+ mJsonClient = new JsonRequestClient(getContext().getClientDispatcher());
+
+ mUserId = getRequest().getClientInfo().getUser().getIdentifier();
+ }
+
+ /**
+ * Return the login page.
+ */
+ @Override
+ protected Representation get() {
+ try {
+ // Get the assessment results
+ JsonResponse response = backendDelete("/accounts/" + mUserId + "/assessment");
+ if (!response.getStatus().isSuccess()) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.BACKEND_ERROR;
+ }
+
+ String nextPage = mConfig.getString("dynamicRoot", "")
+ + "/account/assessment/question/first";
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ /**
+ * @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 backendDelete(final String uri) {
+ LOG.debug("Sending backend GET " + uri);
+
+ final JsonResponse response = mJsonClient.delete(getBackendEndpoint() + uri);
+ final Status status = response.getStatus();
+ if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) {
+ LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString());
+ }
+
+ return response;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/AssessmentResultsPage.java b/src/main/java/com/p4square/grow/frontend/AssessmentResultsPage.java
new file mode 100644
index 0000000..f1c924b
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/AssessmentResultsPage.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.Date;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.p4square.f1oauth.FellowshipOneIntegrationDriver;
+import freemarker.template.Template;
+
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.ext.freemarker.TemplateRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+import com.p4square.fmfacade.json.JsonRequestClient;
+import com.p4square.fmfacade.json.JsonResponse;
+import com.p4square.fmfacade.json.ClientException;
+
+import com.p4square.f1oauth.Attribute;
+import com.p4square.f1oauth.F1API;
+import com.p4square.f1oauth.F1User;
+
+import com.p4square.grow.config.Config;
+import com.p4square.grow.provider.JsonEncodedProvider;
+import org.restlet.security.User;
+
+/**
+ * This page fetches the user's final score and displays the transitional page between
+ * the assessment and the videos.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AssessmentResultsPage extends FreeMarkerPageResource {
+ private static final Logger LOG = Logger.getLogger(AssessmentResultsPage.class);
+
+ private GrowFrontend mGrowFrontend;
+ private Config mConfig;
+ private JsonRequestClient mJsonClient;
+
+ private String mUserId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mGrowFrontend = (GrowFrontend) getApplication();
+ mConfig = mGrowFrontend.getConfig();
+
+ mJsonClient = new JsonRequestClient(getContext().getClientDispatcher());
+
+ mUserId = getRequest().getClientInfo().getUser().getIdentifier();
+ }
+
+ /**
+ * Return the login page.
+ */
+ @Override
+ protected Representation get() {
+ Template t = mGrowFrontend.getTemplate("templates/assessment-results.ftl");
+
+ try {
+ if (t == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return ErrorPage.TEMPLATE_NOT_FOUND;
+ }
+
+ Map<String, Object> root = getRootObject();
+
+ // Get the assessment results
+ JsonResponse response = backendGet("/accounts/" + mUserId + "/assessment");
+ if (!response.getStatus().isSuccess()) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.BACKEND_ERROR;
+ }
+
+ final String score = (String) response.getMap().get("result");
+ if (score == null) {
+ // Odd... send them to the first questions
+ String nextPage = mConfig.getString("dynamicRoot", "")
+ + "/account/assessment/question/first";
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+ }
+
+ // Publish results in F1
+ publishScoreInF1(response.getMap());
+
+ root.put("stage", score);
+ return new TemplateRepresentation(t, root, MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ private void publishScoreInF1(Map results) {
+ final ProgressReporter reporter = mGrowFrontend.getThirdPartyIntegrationFactory().getProgressReporter();
+
+ try {
+ final User user = getRequest().getClientInfo().getUser();
+ final String level = results.get("result").toString();
+ final Date completionDate = new Date();
+ final String data = JsonEncodedProvider.MAPPER.writeValueAsString(results);
+
+ reporter.reportAssessmentComplete(user, level, completionDate, data);
+
+ } catch (JsonProcessingException e) {
+ LOG.error("Failed to generate json " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * @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) {
+ LOG.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)) {
+ LOG.warn("Error making backend request for '" + uri + "'. status = "
+ + response.getStatus().toString());
+ }
+
+ return response;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/AuthenticatedResource.java b/src/main/java/com/p4square/grow/frontend/AuthenticatedResource.java
new file mode 100644
index 0000000..800eb83
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/AuthenticatedResource.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import org.restlet.resource.ServerResource;
+import org.restlet.representation.Representation;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AuthenticatedResource extends ServerResource {
+ protected Representation post() {
+ return null;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/ChapterCompletePage.java b/src/main/java/com/p4square/grow/frontend/ChapterCompletePage.java
new file mode 100644
index 0000000..35abc43
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/ChapterCompletePage.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.Date;
+import java.util.Map;
+
+import com.p4square.f1oauth.FellowshipOneIntegrationDriver;
+import freemarker.template.Template;
+
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.ext.freemarker.TemplateRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+import com.p4square.fmfacade.json.JsonRequestClient;
+import com.p4square.fmfacade.json.JsonResponse;
+import com.p4square.fmfacade.json.ClientException;
+
+import com.p4square.f1oauth.Attribute;
+import com.p4square.f1oauth.F1API;
+import com.p4square.f1oauth.F1User;
+
+import com.p4square.grow.config.Config;
+import com.p4square.grow.model.TrainingRecord;
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.TrainingRecordProvider;
+import org.restlet.security.User;
+
+/**
+ * This resource displays the transitional page between chapters.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class ChapterCompletePage extends FreeMarkerPageResource {
+ private static final Logger LOG = Logger.getLogger(ChapterCompletePage.class);
+
+ private GrowFrontend mGrowFrontend;
+ private Config mConfig;
+ private JsonRequestClient mJsonClient;
+ private Provider<String, TrainingRecord> mTrainingRecordProvider;
+
+ private String mUserId;
+ private String mChapter;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mGrowFrontend = (GrowFrontend) getApplication();
+ mConfig = mGrowFrontend.getConfig();
+
+ mJsonClient = new JsonRequestClient(getContext().getClientDispatcher());
+ mTrainingRecordProvider = new TrainingRecordProvider<String>(
+ new JsonRequestProvider<TrainingRecord>(
+ getContext().getClientDispatcher(),
+ TrainingRecord.class)) {
+ @Override
+ public String makeKey(String userid) {
+ return getBackendEndpoint() + "/accounts/" + userid + "/training";
+ }
+ };
+
+ mUserId = getRequest().getClientInfo().getUser().getIdentifier();
+
+ mChapter = getAttribute("chapter");
+ }
+
+ /**
+ * Return the login page.
+ */
+ @Override
+ protected Representation get() {
+ try {
+ Map<String, Object> root = getRootObject();
+
+ // Get the training summary
+ TrainingRecord trainingRecord = mTrainingRecordProvider.get(mUserId);
+ if (trainingRecord == null) {
+ // Wait. What? Everyone has a training record...
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return new ErrorPage("Could not retrieve your training record.");
+ }
+
+ // Verify they completed the chapter.
+ Map<String, Boolean> chapters = trainingRecord.getPlaylist().getChapterStatuses();
+ Boolean completed = chapters.get(mChapter);
+ if (completed == null || !completed) {
+ // Redirect back to training page...
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/account/training/" + mChapter;
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+ }
+
+ // Publish the training chapter complete attribute.
+ assignAttribute();
+
+ // Find the next chapter
+ String nextChapter = null;
+ {
+ int min = Integer.MAX_VALUE;
+ for (Map.Entry<String, Boolean> chapter : chapters.entrySet()) {
+ int index = chapterIndex(chapter.getKey());
+ if (!chapter.getValue() && index < min) {
+ min = index;
+ nextChapter = chapter.getKey();
+ }
+ }
+ }
+
+ String nextOverride = getQueryValue("next");
+ if (nextOverride != null) {
+ nextChapter = nextOverride;
+ }
+
+ root.put("stage", mChapter);
+ root.put("nextstage", nextChapter);
+
+ /*
+ * We will display one of two transitional pages:
+ *
+ * If the next chapter has a forward page, display the forward page.
+ * Else, if this chapter is not "Introduction", display the chapter
+ * complete message.
+ */
+ Template t = mGrowFrontend.getTemplate("templates/stage-"
+ + nextChapter + "-forward.ftl");
+
+ if (t == null) {
+ // Skip the chapter complete message for "Introduction"
+ if ("introduction".equals(mChapter)) {
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/account/training/" + nextChapter;
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+ }
+
+ t = mGrowFrontend.getTemplate("templates/stage-complete.ftl");
+ if (t == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return ErrorPage.TEMPLATE_NOT_FOUND;
+ }
+ }
+
+ return new TemplateRepresentation(t, root, MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ private void assignAttribute() {
+ final ProgressReporter reporter = mGrowFrontend.getThirdPartyIntegrationFactory().getProgressReporter();
+
+ final User user = getRequest().getClientInfo().getUser();
+ final Date completionDate = new Date();
+
+ reporter.reportChapterComplete(user, mChapter, completionDate);
+ }
+
+ /**
+ * @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) {
+ LOG.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)) {
+ LOG.warn("Error making backend request for '" + uri
+ + "'. status = " + response.getStatus().toString());
+ }
+
+ return response;
+ }
+
+ int chapterIndex(String chapter) {
+ if ("leader".equals(chapter)) {
+ return 5;
+ } else if ("teacher".equals(chapter)) {
+ return 4;
+ } else if ("disciple".equals(chapter)) {
+ return 3;
+ } else if ("believer".equals(chapter)) {
+ return 2;
+ } else if ("seeker".equals(chapter)) {
+ return 1;
+ } else {
+ return Integer.MAX_VALUE;
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/ErrorPage.java b/src/main/java/com/p4square/grow/frontend/ErrorPage.java
new file mode 100644
index 0000000..81abe74
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/ErrorPage.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import freemarker.template.Template;
+
+import org.restlet.data.MediaType;
+import org.restlet.ext.freemarker.TemplateRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.representation.WriterRepresentation;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+/**
+ * ErrorPage wraps a String or Template Representation and displays the given
+ * error message.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class ErrorPage extends WriterRepresentation {
+ public static final ErrorPage TEMPLATE_NOT_FOUND =
+ new ErrorPage("Could not find the requested page template.");
+
+ public static final ErrorPage RENDER_ERROR =
+ new ErrorPage("Error rendering page.");
+
+ public static final ErrorPage BACKEND_ERROR =
+ new ErrorPage("Error communicating with backend.");
+
+ public static final ErrorPage NOT_FOUND =
+ new ErrorPage("The requested URL could not be found.");
+
+ private static Template cTemplate = null;
+ private static Map<String, Object> cRoot = null;
+
+ private final String mMessage;
+
+ public ErrorPage(String msg) {
+ this(msg, MediaType.TEXT_HTML);
+ }
+
+ public ErrorPage(String msg, MediaType mediaType) {
+ super(mediaType);
+
+ mMessage = msg;
+ }
+
+ public static synchronized void setTemplate(Template template, Map<String, Object> root) {
+ cTemplate = template;
+ cRoot = root;
+ }
+
+ protected Representation getRepresentation() {
+ if (cTemplate == null) {
+ return new StringRepresentation(mMessage);
+
+ } else {
+ Map<String, Object> root = new HashMap<String, Object>(cRoot);
+ root.put("errorMessage", mMessage);
+ return new TemplateRepresentation(cTemplate, root, MediaType.TEXT_HTML);
+ }
+ }
+
+ @Override
+ public void write(Writer writer) throws IOException {
+ getRepresentation().write(writer);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/FeedData.java b/src/main/java/com/p4square/grow/frontend/FeedData.java
new file mode 100644
index 0000000..feb03a1
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/FeedData.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.io.IOException;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+
+import org.restlet.Context;
+import org.restlet.Restlet;
+
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.type.TypeFactory;
+
+import com.p4square.grow.config.Config;
+import com.p4square.grow.frontend.JsonRequestProvider;
+import com.p4square.grow.model.Message;
+import com.p4square.grow.model.MessageThread;
+import com.p4square.grow.provider.JsonEncodedProvider;
+import com.p4square.grow.provider.Provider;
+
+/**
+ * Fetch feed data for a topic.
+ */
+public class FeedData {
+
+ /**
+ * Allowed Topics.
+ */
+ public static final HashSet<String> TOPICS = new HashSet(Arrays.asList("seeker", "believer",
+ "disciple", "teacher", "leader"));
+
+
+ private final Config mConfig;
+ private final String mBackendURI;
+
+ // TODO: Elegantly merge the List and individual providers.
+ private final JsonRequestProvider<List<MessageThread>> mThreadsProvider;
+ private final JsonRequestProvider<MessageThread> mThreadProvider;
+
+ private final JsonRequestProvider<List<Message>> mMessagesProvider;
+ private final JsonRequestProvider<Message> mMessageProvider;
+
+ public FeedData(final Context context, final Config config) {
+ mConfig = config;
+ mBackendURI = mConfig.getString("backendUri", "riap://component/backend") + "/feed";
+
+ Restlet clientDispatcher = context.getClientDispatcher();
+
+ TypeFactory factory = JsonEncodedProvider.MAPPER.getTypeFactory();
+
+ JavaType threadType = factory.constructCollectionType(List.class, MessageThread.class);
+ mThreadsProvider = new JsonRequestProvider<List<MessageThread>>(clientDispatcher, threadType);
+ mThreadProvider = new JsonRequestProvider<MessageThread>(clientDispatcher, MessageThread.class);
+
+ JavaType messageType = factory.constructCollectionType(List.class, Message.class);
+ mMessagesProvider = new JsonRequestProvider<List<Message>>(clientDispatcher, messageType);
+ mMessageProvider = new JsonRequestProvider<Message>(clientDispatcher, Message.class);
+ }
+
+ /**
+ * Get the threads for a topic.
+ *
+ * @param topic The topic to request threads for.
+ * @param limit The maximum number of threads.
+ * @return A list of MessageThread objects.
+ */
+ public List<MessageThread> getThreads(final String topic, final int limit) throws IOException {
+ return mThreadsProvider.get(makeUrl(limit, topic));
+ }
+
+ public List<Message> getMessages(final String topic, final String threadId) throws IOException {
+ return mMessagesProvider.get(makeUrl(topic, threadId));
+ }
+
+ public void createThread(final String topic, final Message message) throws IOException {
+ MessageThread thread = new MessageThread();
+ thread.setMessage(message);
+
+ mThreadProvider.post(makeUrl(topic), thread);
+ }
+
+ public void createResponse(final String topic, final String thread, final Message message)
+ throws IOException {
+
+ mMessageProvider.post(makeUrl(topic, thread), message);
+ }
+
+ private String makeUrl(String... parts) {
+ String url = mBackendURI;
+ for (String part : parts) {
+ url += "/" + part;
+ }
+
+ return url;
+ }
+
+ private String makeUrl(int limit, String... parts) {
+ return makeUrl(parts) + "?limit=" + limit;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/FeedResource.java b/src/main/java/com/p4square/grow/frontend/FeedResource.java
new file mode 100644
index 0000000..13d0fa0
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/FeedResource.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.io.IOException;
+
+import org.restlet.data.Form;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.resource.ServerResource;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.grow.config.Config;
+import com.p4square.grow.model.Message;
+import com.p4square.grow.model.UserRecord;
+
+/**
+ * This resource handles user interactions with the feed.
+ */
+public class FeedResource extends ServerResource {
+ private static final Logger LOG = Logger.getLogger(FeedResource.class);
+
+ private Config mConfig;
+
+ private FeedData mFeedData;
+
+ // Fields pertaining to this request.
+ protected String mTopic;
+ protected String mThread;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ GrowFrontend growFrontend = (GrowFrontend) getApplication();
+ mConfig = growFrontend.getConfig();
+
+ mFeedData = new FeedData(getContext(), mConfig);
+
+ mTopic = getAttribute("topic");
+ if (mTopic != null) {
+ mTopic = mTopic.trim();
+ }
+
+ mThread = getAttribute("thread");
+ if (mThread != null) {
+ mThread = mThread.trim();
+ }
+ }
+
+ /**
+ * Create a new MessageThread.
+ */
+ @Override
+ protected Representation post(Representation entity) {
+ try {
+ if (mTopic == null || mTopic.length() == 0 || !FeedData.TOPICS.contains(mTopic)) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return ErrorPage.NOT_FOUND;
+ }
+
+ Form form = new Form(entity);
+
+ String question = form.getFirstValue("question");
+
+ Message message = new Message();
+ message.setMessage(question);
+
+ UserRecord user = new UserRecord(getRequest().getClientInfo().getUser());
+ message.setAuthor(user);
+
+ if (mThread != null && mThread.length() != 0) {
+ // Post a response
+ mFeedData.createResponse(mTopic, mThread, message);
+
+ } else {
+ // Post a new thread
+ mFeedData.createThread(mTopic, message);
+ }
+
+ /*
+ * Can't trust the referrer, so we'll send them to the
+ * appropriate part of the training page
+ * TODO: This could be better done.
+ */
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/account/training/" + mTopic;
+ getResponse().redirectSeeOther(nextPage);
+ return null;
+
+ } catch (IOException e) {
+ LOG.fatal("Could not save message: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.BACKEND_ERROR;
+
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/GroupLeaderTrainingPageResource.java b/src/main/java/com/p4square/grow/frontend/GroupLeaderTrainingPageResource.java
new file mode 100644
index 0000000..3ab140e
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/GroupLeaderTrainingPageResource.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+/**
+ * Display the Group Leader training videos.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class GroupLeaderTrainingPageResource extends TrainingPageResource {
+ private static final String[] CHAPTERS = { "leader" };
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mChapter = "leader";
+ }
+
+ @Override
+ public String[] getChaptersInOrder() {
+ return CHAPTERS;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/GrowFrontend.java b/src/main/java/com/p4square/grow/frontend/GrowFrontend.java
new file mode 100644
index 0000000..b5f62fb
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/GrowFrontend.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2013 Jesse Morgan <jesse@jesterpm.net>
+ */
+
+package com.p4square.grow.frontend;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+
+import freemarker.template.Template;
+
+import org.restlet.Application;
+import org.restlet.Component;
+import org.restlet.Context;
+import org.restlet.Restlet;
+import org.restlet.data.Protocol;
+import org.restlet.resource.Directory;
+import org.restlet.routing.Redirector;
+import org.restlet.routing.Router;
+import org.restlet.security.Authenticator;
+
+import com.codahale.metrics.MetricRegistry;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.FMFacade;
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+import com.p4square.grow.config.Config;
+
+import com.p4square.restlet.metrics.MetricRouter;
+
+import com.p4square.session.SessionCheckingAuthenticator;
+import com.p4square.session.SessionCreatingAuthenticator;
+import org.restlet.security.Verifier;
+
+/**
+ * This is the Restlet Application implementing the Grow project front-end.
+ * It's implemented as an extension of FMFacade that connects interactive pages
+ * with various ServerResources. This class provides a main method to start a
+ * Jetty instance for testing.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class GrowFrontend extends FMFacade {
+ private static Logger LOG = Logger.getLogger(GrowFrontend.class);
+
+ private final Config mConfig;
+ private final MetricRegistry mMetricRegistry;
+
+ private IntegrationDriver mIntegrationFactory;
+
+ public GrowFrontend() {
+ this(new Config(), new MetricRegistry());
+ }
+
+ public GrowFrontend(Config config, MetricRegistry metricRegistry) {
+ mConfig = config;
+ mMetricRegistry = metricRegistry;
+ }
+
+ public Config getConfig() {
+ return mConfig;
+ }
+
+ public MetricRegistry getMetrics() {
+ return mMetricRegistry;
+ }
+
+ @Override
+ public synchronized void start() throws Exception {
+ Template errorTemplate = getTemplate("templates/error.ftl");
+ if (errorTemplate != null) {
+ ErrorPage.setTemplate(errorTemplate,
+ FreeMarkerPageResource.baseRootObject(getContext(), this));
+ }
+
+ getContext().getAttributes().put("com.p4square.grow.config", mConfig);
+ getContext().getAttributes().put("com.p4square.grow.metrics", mMetricRegistry);
+
+ super.start();
+ }
+
+ public synchronized IntegrationDriver getThirdPartyIntegrationFactory() {
+ if (mIntegrationFactory == null) {
+ final String driverClassName = getConfig().getString("integrationDriver",
+ "com.p4square.f1oauth.FellowshipOneIntegrationDriver");
+ try {
+ Class<?> clazz = Class.forName(driverClassName);
+ Constructor<?> constructor = clazz.getConstructor(Context.class);
+ mIntegrationFactory = (IntegrationDriver) constructor.newInstance(getContext());
+ } catch (Exception e) {
+ LOG.error("Failed to instantiate IntegrationDriver " + driverClassName);
+ }
+ }
+
+ return mIntegrationFactory;
+ }
+
+ @Override
+ protected Router createRouter() {
+ Router router = new MetricRouter(getContext(), mMetricRegistry);
+
+ final Authenticator defaultGuard = new SessionCheckingAuthenticator(getContext(), true);
+ defaultGuard.setNext(FreeMarkerPageResource.class);
+ router.attachDefault(defaultGuard);
+ router.attach("/", new Redirector(getContext(), "index.html", Redirector.MODE_CLIENT_PERMANENT));
+ router.attach("/login.html", LoginPageResource.class);
+ router.attach("/newaccount.html", NewAccountResource.class);
+ router.attach("/newbeliever", NewBelieverResource.class);
+
+ final Router accountRouter = new MetricRouter(getContext(), mMetricRegistry);
+ accountRouter.attach("/authenticate", AuthenticatedResource.class);
+ accountRouter.attach("/logout", LogoutResource.class);
+
+ accountRouter.attach("", AccountRedirectResource.class);
+ accountRouter.attach("/assessment/question/{questionId}", SurveyPageResource.class);
+ accountRouter.attach("/assessment/results", AssessmentResultsPage.class);
+ accountRouter.attach("/assessment/reset", AssessmentResetPage.class);
+ accountRouter.attach("/assessment", SurveyPageResource.class);
+ accountRouter.attach("/training/{chapter}/completed", ChapterCompletePage.class);
+ accountRouter.attach("/training/{chapter}/videos/{videoId}.json", VideosResource.class);
+ accountRouter.attach("/training/{chapter}", TrainingPageResource.class);
+ accountRouter.attach("/training", TrainingPageResource.class);
+ accountRouter.attach("/feed/{topic}", FeedResource.class);
+ accountRouter.attach("/feed/{topic}/{thread}", FeedResource.class);
+
+ final Authenticator accountGuard = createAuthenticatorChain(accountRouter);
+ router.attach("/account", accountGuard);
+
+ return router;
+ }
+
+ private Authenticator createAuthenticatorChain(Restlet last) {
+ final Context context = getContext();
+ final String loginPage = getConfig().getString("dynamicRoot", "") + "/login.html";
+ final String loginPost = getConfig().getString("dynamicRoot", "") + "/account/authenticate";
+ final String defaultPage = getConfig().getString("dynamicRoot", "") + "/account";
+
+ // This is used to check for an existing session
+ SessionCheckingAuthenticator sessionChk = new SessionCheckingAuthenticator(context, true);
+
+ // This is used to authenticate the user
+ Verifier verifier = getThirdPartyIntegrationFactory().newUserAuthenticationVerifier();
+ LoginFormAuthenticator loginAuth = new LoginFormAuthenticator(context, false, verifier);
+ loginAuth.setLoginFormUrl(loginPage);
+ loginAuth.setLoginPostUrl(loginPost);
+ loginAuth.setDefaultPage(defaultPage);
+
+ // This is used to create a new session for a newly authenticated user.
+ SessionCreatingAuthenticator sessionCreate = new SessionCreatingAuthenticator(context);
+
+ sessionChk.setNext(loginAuth);
+ loginAuth.setNext(sessionCreate);
+
+ sessionCreate.setNext(last);
+
+ return sessionChk;
+ }
+
+ /**
+ * Stand-alone main for testing.
+ */
+ public static void main(String[] args) {
+ // Start the HTTP Server
+ final Component component = new Component();
+ component.getServers().add(Protocol.HTTP, 8085);
+ component.getClients().add(Protocol.HTTP);
+ component.getClients().add(Protocol.HTTPS);
+ component.getClients().add(Protocol.FILE);
+ //component.getClients().add(new Client(null, Arrays.asList(Protocol.HTTPS), "org.restlet.ext.httpclient.HttpClientHelper"));
+
+ // Static content
+ try {
+ component.getDefaultHost().attach("/images/", new FileServingApp("./build/root/images/"));
+ component.getDefaultHost().attach("/scripts", new FileServingApp("./build/root/scripts"));
+ component.getDefaultHost().attach("/style.css", new FileServingApp("./build/root/style.css"));
+ component.getDefaultHost().attach("/favicon.ico", new FileServingApp("./build/root/favicon.ico"));
+ component.getDefaultHost().attach("/notfound.html", new FileServingApp("./build/root/notfound.html"));
+ component.getDefaultHost().attach("/error.html", new FileServingApp("./build/root/error.html"));
+ } catch (IOException e) {
+ LOG.error("Could not create directory for static resources: "
+ + e.getMessage(), e);
+ }
+
+ // Setup App
+ GrowFrontend app = new GrowFrontend();
+
+ // Load an optional config file from the first argument.
+ app.getConfig().setDomain("dev");
+ if (args.length == 1) {
+ app.getConfig().updateConfig(args[0]);
+ }
+
+ component.getDefaultHost().attach(app);
+
+ // Setup shutdown hook
+ Runtime.getRuntime().addShutdownHook(new Thread() {
+ public void run() {
+ try {
+ component.stop();
+ } catch (Exception e) {
+ LOG.error("Exception during cleanup", e);
+ }
+ }
+ });
+
+ LOG.info("Starting server...");
+
+ try {
+ component.start();
+ } catch (Exception e) {
+ LOG.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/main/java/com/p4square/grow/frontend/IntegrationDriver.java b/src/main/java/com/p4square/grow/frontend/IntegrationDriver.java
new file mode 100644
index 0000000..b9c3508
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/IntegrationDriver.java
@@ -0,0 +1,26 @@
+package com.p4square.grow.frontend;
+
+import org.restlet.security.Verifier;
+
+/**
+ * An IntegrationDriver is used to create implementations of various objects
+ * used to integration Grow with a particular Church Management System.
+ */
+public interface IntegrationDriver {
+
+ /**
+ * Create a new Restlet Verifier to authenticate users when they login to the site.
+ *
+ * @return A Verifier.
+ */
+ Verifier newUserAuthenticationVerifier();
+
+ /**
+ * Return a ProgressReporter for this Church Management System.
+ *
+ * The ProgressReporter should be thread-safe.
+ *
+ * @return The ProgressReporter.
+ */
+ ProgressReporter getProgressReporter();
+}
diff --git a/src/main/java/com/p4square/grow/frontend/JsonRequestProvider.java b/src/main/java/com/p4square/grow/frontend/JsonRequestProvider.java
new file mode 100644
index 0000000..bf3b2b3
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/JsonRequestProvider.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.databind.JavaType;
+
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.data.Method;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ * Fetch a JSON object via a Request.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class JsonRequestProvider<V> extends JsonEncodedProvider<V> implements Provider<String, V> {
+
+ private final Restlet mDispatcher;
+
+ public JsonRequestProvider(Restlet dispatcher, Class<V> clazz) {
+ super(clazz);
+
+ mDispatcher = dispatcher;
+ }
+
+ public JsonRequestProvider(Restlet dispatcher, JavaType type) {
+ super(type);
+
+ mDispatcher = dispatcher;
+ }
+
+ @Override
+ public V get(String url) throws IOException {
+ Request request = new Request(Method.GET, url);
+ Response response = mDispatcher.handle(request);
+ Representation representation = response.getEntity();
+
+ if (!response.getStatus().isSuccess()) {
+ if (representation != null) {
+ representation.release();
+ }
+
+ if (Status.CLIENT_ERROR_NOT_FOUND.equals(response.getStatus())) {
+ throw new NotFoundException("Could not get object. " + response.getStatus());
+ } else {
+ throw new IOException("Could not get object. " + response.getStatus());
+ }
+ }
+
+ return decode(representation.getText());
+ }
+
+ @Override
+ public void put(String url, V obj) throws IOException {
+ final Request request = new Request(Method.PUT, url);
+ request.setEntity(new StringRepresentation(encode(obj)));
+
+ final Response response = mDispatcher.handle(request);
+
+ if (!response.getStatus().isSuccess()) {
+ throw new IOException("Could not put object. " + response.getStatus());
+ }
+ }
+
+ /**
+ * Variant of put() which makes a POST request to the url.
+ *
+ * This method may eventually be incorporated into Provider for
+ * creating new objects with auto-generated IDs.
+ *
+ * @param url The url to make the request to.
+ * @param obj The post to post.
+ * @throws IOException on failure.
+ */
+ public void post(String url, V obj) throws IOException {
+ final Request request = new Request(Method.POST, url);
+ request.setEntity(new StringRepresentation(encode(obj)));
+
+ final Response response = mDispatcher.handle(request);
+
+ if (!response.getStatus().isSuccess()) {
+ throw new IOException("Could not put object. " + response.getStatus());
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/LoginFormAuthenticator.java b/src/main/java/com/p4square/grow/frontend/LoginFormAuthenticator.java
new file mode 100644
index 0000000..21c9097
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/LoginFormAuthenticator.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.data.ChallengeResponse;
+import org.restlet.data.ChallengeScheme;
+import org.restlet.data.Form;
+import org.restlet.data.Method;
+import org.restlet.data.Reference;
+import org.restlet.security.Authenticator;
+import org.restlet.security.Verifier;
+
+/**
+ * LoginFormAuthenticator changes
+ *
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class LoginFormAuthenticator extends Authenticator {
+ private static final Logger LOG = Logger.getLogger(LoginFormAuthenticator.class);
+
+ private final Verifier mVerifier;
+
+ private String mLoginPage = "/login.html";
+ private String mLoginPostUrl = "/authenticate";
+ private String mDefaultRedirect = "/index.html";
+
+ public LoginFormAuthenticator(Context context, boolean optional, Verifier verifier) {
+ super(context, false, optional, null);
+
+ mVerifier = verifier;
+ }
+
+ public void setLoginFormUrl(String url) {
+ mLoginPage = url;
+ }
+
+ public void setLoginPostUrl(String url) {
+ mLoginPostUrl = url;
+ }
+
+ public void setDefaultPage(String url) {
+ mDefaultRedirect = url;
+ }
+
+ @Override
+ protected int beforeHandle(Request request, Response response) {
+ if (!isLoginAttempt(request) && request.getClientInfo().isAuthenticated()) {
+ // TODO: Logout
+ LOG.debug("Already authenticated. Skipping");
+ return CONTINUE;
+
+ } else {
+ return super.beforeHandle(request, response);
+ }
+ }
+
+
+ @Override
+ protected boolean authenticate(Request request, Response response) {
+ boolean isLoginAttempt = isLoginAttempt(request);
+
+ Form query = request.getOriginalRef().getQueryAsForm();
+ String redirect = query.getFirstValue("redirect");
+ if (redirect == null || redirect.length() == 0) {
+ if (isLoginAttempt) {
+ redirect = mDefaultRedirect;
+ } else {
+ redirect = request.getResourceRef().getPath();
+ }
+ }
+
+ boolean authenticationFailed = false;
+
+ if (isLoginAttempt) {
+ LOG.debug("Attempting authentication");
+
+ // Process login form
+ final Form form = new Form(request.getEntity());
+ final String email = form.getFirstValue("email");
+ final String password = form.getFirstValue("password");
+
+ boolean authenticated = false;
+
+ if (email != null && !"".equals(email) &&
+ password != null && !"".equals(password)) {
+
+ LOG.debug("Got login request from " + email);
+
+ request.setChallengeResponse(
+ new ChallengeResponse(ChallengeScheme.HTTP_BASIC, email, password.toCharArray()));
+
+ // We expect the verifier to setup the User object.
+ int result = mVerifier.verify(request, response);
+ if (result == Verifier.RESULT_VALID) {
+ return true;
+ }
+ }
+
+ authenticationFailed = true;
+ }
+
+ if (!isOptional() || authenticationFailed) {
+ Reference ref = new Reference(mLoginPage);
+ ref.addQueryParameter("redirect", redirect);
+
+ if (authenticationFailed) {
+ ref.addQueryParameter("retry", "t");
+ }
+
+ LOG.debug("Redirecting to " + ref);
+ response.redirectSeeOther(ref.toString());
+ }
+ LOG.debug("Failing authentication.");
+ return false;
+ }
+
+ @Override
+ protected int authenticated(Request request, Response response) {
+ super.authenticated(request, response);
+
+ Form query = request.getOriginalRef().getQueryAsForm();
+ String redirect = query.getFirstValue("redirect");
+ if (redirect == null || redirect.length() == 0) {
+ redirect = mDefaultRedirect;
+ }
+
+ // TODO: Ensure redirect is a relative url.
+ LOG.debug("Redirecting to " + redirect);
+ response.redirectSeeOther(redirect);
+
+ return CONTINUE;
+ }
+
+ private boolean isLoginAttempt(Request request) {
+ String requestPath = request.getResourceRef().getPath();
+ return request.getMethod() == Method.POST && mLoginPostUrl.equals(requestPath);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/LoginPageResource.java b/src/main/java/com/p4square/grow/frontend/LoginPageResource.java
new file mode 100644
index 0000000..38eba07
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/LoginPageResource.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.Map;
+
+import freemarker.template.Template;
+
+import org.restlet.data.Form;
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.resource.ServerResource;
+import org.restlet.representation.Representation;
+import org.restlet.ext.freemarker.TemplateRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+/**
+ * LoginPageResource presents a login page template and processes the response.
+ * Upon successful authentication, the user is redirected to another page and
+ * a cookie is set.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class LoginPageResource extends FreeMarkerPageResource {
+ private static Logger cLog = Logger.getLogger(LoginPageResource.class);
+
+ private GrowFrontend mGrowFrontend;
+
+ private String mErrorMessage;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mGrowFrontend = (GrowFrontend) getApplication();
+
+ mErrorMessage = null;
+ }
+
+ /**
+ * Return the login page.
+ */
+ @Override
+ protected Representation get() {
+ Template t = mGrowFrontend.getTemplate("pages/login.html.ftl");
+
+ try {
+ if (t == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return null;
+ }
+
+ Map<String, Object> root = getRootObject();
+
+ Form query = getRequest().getOriginalRef().getQueryAsForm();
+ String redirect = query.getFirstValue("redirect");
+ root.put("redirect", redirect);
+ String retry = query.getFirstValue("retry");
+ if ("t".equals(retry)) {
+ root.put("errorMessage", "Invalid email or password.");
+ }
+
+ return new TemplateRepresentation(t, root, MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ cLog.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+ }
+
+}
diff --git a/src/main/java/com/p4square/grow/frontend/LogoutResource.java b/src/main/java/com/p4square/grow/frontend/LogoutResource.java
new file mode 100644
index 0000000..e26dcb7
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/LogoutResource.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.resource.ServerResource;
+
+import com.p4square.session.Sessions;
+
+import com.p4square.grow.config.Config;
+
+/**
+ * This Resource removes a user's session and session cookies.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class LogoutResource extends ServerResource {
+ private Config mConfig;
+
+ @Override
+ protected void doInit() {
+ super.doInit();
+
+ GrowFrontend growFrontend = (GrowFrontend) getApplication();
+ mConfig = growFrontend.getConfig();
+ }
+
+ @Override
+ protected Representation get() {
+ Sessions.getInstance().delete(getRequest(), getResponse());
+
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/index.html";
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/NewAccountResource.java b/src/main/java/com/p4square/grow/frontend/NewAccountResource.java
new file mode 100644
index 0000000..5c13017
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/NewAccountResource.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.Map;
+
+import com.p4square.f1oauth.FellowshipOneIntegrationDriver;
+import freemarker.template.Template;
+
+import org.restlet.data.Form;
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.ext.freemarker.TemplateRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.f1oauth.F1Access;
+import com.p4square.restlet.oauth.OAuthException;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+/**
+ * This resource creates a new InFellowship account.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class NewAccountResource extends FreeMarkerPageResource {
+ private static Logger LOG = Logger.getLogger(NewAccountResource.class);
+
+ private GrowFrontend mGrowFrontend;
+ private F1Access mHelper;
+
+ private String mErrorMessage;
+
+ private String mLoginPageUrl;
+ private String mVerificationPage;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mGrowFrontend = (GrowFrontend) getApplication();
+
+ final IntegrationDriver driver = mGrowFrontend.getThirdPartyIntegrationFactory();
+ if (driver instanceof FellowshipOneIntegrationDriver) {
+ mHelper = ((FellowshipOneIntegrationDriver) driver).getF1Access();
+ } else {
+ LOG.error("NewAccountResource only works with F1!");
+ mHelper = null;
+ }
+
+ mErrorMessage = "";
+
+ mLoginPageUrl = mGrowFrontend.getConfig().getString("postAccountCreationPage",
+ getRequest().getRootRef().toString());
+ mVerificationPage = mGrowFrontend.getConfig().getString("dynamicRoot", "")
+ + "/verification.html";
+ }
+
+ /**
+ * Return the login page.
+ */
+ @Override
+ protected Representation get() {
+ Template t = mGrowFrontend.getTemplate("pages/newaccount.html.ftl");
+
+ try {
+ if (t == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return ErrorPage.TEMPLATE_NOT_FOUND;
+ }
+
+ Map<String, Object> root = getRootObject();
+ if (mErrorMessage.length() > 0) {
+ root.put("errorMessage", mErrorMessage);
+ }
+
+ return new TemplateRepresentation(t, root, MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ @Override
+ protected Representation post(Representation rep) {
+ if (mHelper == null) {
+ mErrorMessage += "F1 support is not enabled! ";
+ return get();
+ }
+
+ Form form = new Form(rep);
+
+ String firstname = form.getFirstValue("firstname");
+ String lastname = form.getFirstValue("lastname");
+ String email = form.getFirstValue("email");
+
+ if (isEmpty(firstname)) {
+ mErrorMessage += "First Name is a required field. ";
+ }
+ if (isEmpty(lastname)) {
+ mErrorMessage += "Last Name is a required field. ";
+ }
+ if (isEmpty(email)) {
+ mErrorMessage += "Email is a required field. ";
+ }
+
+ if (mErrorMessage.length() > 0) {
+ return get();
+ }
+
+ try {
+ if (!mHelper.createAccount(firstname, lastname, email, mLoginPageUrl)) {
+ mErrorMessage = "An account with that address already exists.";
+ return get();
+ }
+
+ getResponse().redirectSeeOther(mVerificationPage);
+ return new StringRepresentation("Redirecting to " + mVerificationPage);
+
+ } catch (OAuthException e) {
+ return new ErrorPage(e.getStatus().getDescription());
+ }
+ }
+
+ private boolean isEmpty(String s) {
+ return s == null || s.trim().length() == 0;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/NewBelieverResource.java b/src/main/java/com/p4square/grow/frontend/NewBelieverResource.java
new file mode 100644
index 0000000..8fe078a
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/NewBelieverResource.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import freemarker.template.Template;
+
+import org.restlet.data.CookieSetting;
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.representation.Representation;
+import org.restlet.ext.freemarker.TemplateRepresentation;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+/**
+ * This resource displays the transitional page between chapters.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class NewBelieverResource extends FreeMarkerPageResource {
+ private static final Logger LOG = Logger.getLogger(NewBelieverResource.class);
+
+ public static final String COOKIE_NAME = "seeker";
+
+ private GrowFrontend mGrowFrontend;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ mGrowFrontend = (GrowFrontend) getApplication();
+ }
+
+ /**
+ * Display the New Believer page.
+ *
+ * The New Believer page creates a cookie to remember the user,
+ * explains what's going on, and then asks the user to go to the login
+ * page.
+ *
+ * When the user hits the {@link AccountRedirectResource} the cookie
+ * is read and the user is moved ahead to the training section.
+ */
+ @Override
+ protected Representation get() {
+ Template t = mGrowFrontend.getTemplate("templates/newbeliever.ftl");
+
+ try {
+ if (t == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return ErrorPage.TEMPLATE_NOT_FOUND;
+ }
+
+ // Set the new believer cookie
+ CookieSetting cookie = new CookieSetting(COOKIE_NAME, "true");
+ cookie.setPath("/");
+ getRequest().getCookies().add(cookie);
+ getResponse().getCookieSettings().add(cookie);
+
+ return new TemplateRepresentation(t, getRootObject(), MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/NotFoundException.java b/src/main/java/com/p4square/grow/frontend/NotFoundException.java
new file mode 100644
index 0000000..dfa2a4c
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/NotFoundException.java
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.io.IOException;
+
+public class NotFoundException extends IOException {
+ public NotFoundException(final String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/ProgressReporter.java b/src/main/java/com/p4square/grow/frontend/ProgressReporter.java
new file mode 100644
index 0000000..2f36832
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/ProgressReporter.java
@@ -0,0 +1,30 @@
+package com.p4square.grow.frontend;
+
+import org.restlet.security.User;
+
+import java.util.Date;
+
+/**
+ * A ProgressReporter is used to record a User's progress in a Church Management System.
+ */
+public interface ProgressReporter {
+
+ /**
+ * Report that the User completed the assessment.
+ *
+ * @param user The user who completed the assessment.
+ * @param level The assessment level.
+ * @param date The completion date.
+ * @param results Result information (e.g. json of the results).
+ */
+ void reportAssessmentComplete(User user, String level, Date date, String results);
+
+ /**
+ * Report that the User completed the chapter.
+ *
+ * @param user The user who completed the chapter.
+ * @param chapter The chapter completed.
+ * @param date The completion date.
+ */
+ void reportChapterComplete(User user, String chapter, Date date);
+}
diff --git a/src/main/java/com/p4square/grow/frontend/SurveyPageResource.java b/src/main/java/com/p4square/grow/frontend/SurveyPageResource.java
new file mode 100644
index 0000000..3575fe3
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/SurveyPageResource.java
@@ -0,0 +1,343 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.io.IOException;
+
+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.representation.StringRepresentation;
+import org.restlet.resource.ServerResource;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.json.JsonRequestClient;
+import com.p4square.fmfacade.json.JsonResponse;
+import com.p4square.fmfacade.json.ClientException;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+import com.p4square.grow.config.Config;
+import com.p4square.grow.model.Question;
+import com.p4square.grow.model.UserRecord;
+import com.p4square.grow.provider.DelegateProvider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+import com.p4square.grow.provider.Provider;
+
+/**
+ * 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 SurveyPageResource extends FreeMarkerPageResource {
+ private static final Logger LOG = Logger.getLogger(SurveyPageResource.class);
+
+ private Config mConfig;
+ private Template mSurveyTemplate;
+ private JsonRequestClient mJsonClient;
+ private Provider<String, Question> mQuestionProvider;
+ private Provider<String, UserRecord> mUserRecordProvider;
+
+ // Fields pertaining to this request.
+ private String mQuestionId;
+ private String mUserId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ GrowFrontend growFrontend = (GrowFrontend) getApplication();
+ mConfig = growFrontend.getConfig();
+ mSurveyTemplate = growFrontend.getTemplate("templates/survey.ftl");
+ if (mSurveyTemplate == null) {
+ LOG.fatal("Could not find survey template.");
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ }
+
+ mJsonClient = new JsonRequestClient(getContext().getClientDispatcher());
+ mQuestionProvider = new DelegateProvider<String, String, Question>(
+ new JsonRequestProvider<Question>(getContext().getClientDispatcher(),
+ Question.class)) {
+ @Override
+ public String makeKey(String questionId) {
+ return getBackendEndpoint() + "/assessment/question/" + questionId;
+ }
+ };
+
+ mUserRecordProvider = new DelegateProvider<String, String, UserRecord>(
+ new JsonRequestProvider<UserRecord>(getContext().getClientDispatcher(),
+ UserRecord.class)) {
+ @Override
+ public String makeKey(String userid) {
+ return getBackendEndpoint() + "/accounts/" + userid;
+ }
+ };
+
+ mQuestionId = getAttribute("questionId");
+ mUserId = getRequest().getClientInfo().getUser().getIdentifier();
+ }
+
+ /**
+ * Return a page with a survey question.
+ */
+ @Override
+ protected Representation get() {
+ try {
+ // Get the current question.
+ if (mQuestionId == null) {
+ // Get user's current question
+ mQuestionId = getCurrentQuestionId();
+
+ if (mQuestionId != null) {
+ Question lastQuestion = getQuestion(mQuestionId);
+ return redirectToNextQuestion(lastQuestion, getAnswer(mQuestionId));
+ }
+ }
+
+ // If we don't have a current question, get the first one.
+ if (mQuestionId == null) {
+ mQuestionId = "first";
+ }
+
+ Question question = getQuestion(mQuestionId);
+ if (question == null) {
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return new ErrorPage("Could not find the question.");
+ }
+
+ // Set the real question id if a meta-id was used (i.e. first)
+ mQuestionId = question.getId();
+
+ // Get any previous answer to the question
+ String selectedAnswer = getAnswer(mQuestionId);
+
+ Map root = getRootObject();
+ root.put("question", question);
+ root.put("selectedAnswerId", selectedAnswer);
+
+ // Get the question count and compute progress
+ {
+ JsonResponse response = backendGet("/assessment/question/count");
+ if (response.getStatus().isSuccess()) {
+ Map countData = response.getMap();
+ if (countData != null) {
+ response = backendGet("/accounts/" + mUserId + "/assessment");
+ if (response.getStatus().isSuccess()) {
+ Integer completed = (Integer) response.getMap().get("totalAnswers");
+ Integer total = (Integer) countData.get("count");
+
+ if (completed != null && total != null && total != 0) {
+ root.put("percentComplete", String.valueOf((int) (100.0 * completed) / total));
+ }
+ }
+ }
+ }
+ }
+
+ return new TemplateRepresentation(mSurveyTemplate, root, MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ /**
+ * Record a survey answer and redirect to the next question.
+ */
+ @Override
+ protected Representation post(Representation entity) {
+ final Form form = new Form(entity);
+ final String answerId = form.getFirstValue("answer");
+ final String direction = form.getFirstValue("direction");
+ boolean justGoBack = false; // FIXME: Ugly hack
+
+ if (mQuestionId == null || answerId == null || answerId.length() == 0) {
+ if ("previous".equals(direction)) {
+ // Just go back
+ justGoBack = true;
+
+ } else {
+ // Something is wrong.
+ setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
+ return new ErrorPage("Question or answer messing.");
+ }
+ }
+
+ try {
+ // Find the question
+ Question question = getQuestion(mQuestionId);
+ if (question == null) {
+ // User is answering a question which doesn't exist
+ setStatus(Status.CLIENT_ERROR_NOT_FOUND);
+ return new ErrorPage("Question not found.");
+ }
+
+ // Store answer
+ if (!justGoBack) {
+ 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.
+ LOG.fatal("Error recording survey answer " + response.getStatus());
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.BACKEND_ERROR;
+ }
+ }
+
+ // Find the next question or finish the assessment.
+ if ("previous".equals(direction)) {
+ return redirectToPreviousQuestion(question);
+
+ } else {
+ return redirectToNextQuestion(question, answerId);
+ }
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ private Question getQuestion(String id) {
+ try {
+ return mQuestionProvider.get(id);
+
+ } catch (IOException e) {
+ LOG.warn("Error fetching question.", e);
+ return null;
+ }
+ }
+
+ private String getAnswer(String questionId) {
+ try {
+ JsonResponse response = backendGet("/accounts/" + mUserId + "/assessment/answers/" + questionId);
+ if (response.getStatus().isSuccess()) {
+ return (String) response.getMap().get("answerId");
+ }
+
+ } catch (ClientException e) {
+ LOG.warn("Error fetching answer to question " + questionId, e);
+ }
+
+ return null;
+ }
+
+ private Representation redirectToNextQuestion(Question question, String answerid) {
+ String nextQuestionId = question.getNextQuestion(answerid);
+
+ if (nextQuestionId == null) {
+ // Just finished the last question. Update the user's account
+ try {
+ UserRecord account = null;
+ try {
+ account = mUserRecordProvider.get(mUserId);
+ } catch (NotFoundException e) {
+ // User record doesn't exist, so create a new one.
+ account = new UserRecord(getRequest().getClientInfo().getUser());
+ }
+ account.setLanding("training");
+ mUserRecordProvider.put(mUserId, account);
+ } catch (IOException e) {
+ LOG.warn("IOException updating landing for " + mUserId, e);
+ }
+
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/account/assessment/results";
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+ }
+
+ return redirectToQuestion(nextQuestionId);
+ }
+
+ private Representation redirectToPreviousQuestion(Question question) {
+ String nextQuestionId = question.getPreviousQuestion();
+
+ if (nextQuestionId == null) {
+ nextQuestionId = (String) question.getId();
+ }
+
+ return redirectToQuestion(nextQuestionId);
+ }
+
+ private Representation redirectToQuestion(String id) {
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/account/assessment/question/" + id;
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+ }
+
+ private String getCurrentQuestionId() {
+ String id = null;
+ try {
+ JsonResponse response = backendGet("/accounts/" + mUserId + "/assessment");
+
+ if (response.getStatus().isSuccess()) {
+ return (String) response.getMap().get("lastAnswered");
+
+ } else {
+ LOG.warn("Failed to get assessment results: " + response.getStatus());
+ }
+
+ } catch (ClientException e) {
+ LOG.error("Exception getting assessment results.", e);
+ }
+
+ 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) {
+ LOG.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)) {
+ LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString());
+ }
+
+ return response;
+ }
+
+ protected JsonResponse backendPut(final String uri, final Map data) {
+ LOG.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)) {
+ LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString());
+ }
+
+ return response;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/frontend/TrainingPageResource.java b/src/main/java/com/p4square/grow/frontend/TrainingPageResource.java
new file mode 100644
index 0000000..a1e7789
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/TrainingPageResource.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import freemarker.template.Template;
+
+import org.restlet.data.CookieSetting;
+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.representation.StringRepresentation;
+import org.restlet.resource.ServerResource;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.json.JsonRequestClient;
+import com.p4square.fmfacade.json.JsonResponse;
+
+import com.p4square.fmfacade.FreeMarkerPageResource;
+
+import com.p4square.grow.config.Config;
+import com.p4square.grow.model.TrainingRecord;
+import com.p4square.grow.model.VideoRecord;
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.provider.TrainingRecordProvider;
+import com.p4square.grow.provider.Provider;
+
+/**
+ * TrainingPageResource handles rendering the training page.
+ *
+ * This resource expects the user to be authenticated and the ClientInfo User object
+ * to be populated.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class TrainingPageResource extends FreeMarkerPageResource {
+ private static final Logger LOG = Logger.getLogger(TrainingPageResource.class);
+
+ private static final String[] CHAPTERS = { "introduction", "seeker", "believer", "disciple", "teacher", "leader" };
+ private static final Comparator<Map<String, Object>> VIDEO_COMPARATOR = new Comparator<Map<String, Object>>() {
+ @Override
+ public int compare(Map<String, Object> left, Map<String, Object> right) {
+ String leftNumberStr = (String) left.get("number");
+ String rightNumberStr = (String) right.get("number");
+
+ if (leftNumberStr == null || rightNumberStr == null) {
+ return -1;
+ }
+
+ double leftNumber = Double.valueOf(leftNumberStr);
+ double rightNumber = Double.valueOf(rightNumberStr);
+
+ return Double.compare(leftNumber, rightNumber);
+ }
+ };
+
+ private Config mConfig;
+ private Template mTrainingTemplate;
+ private JsonRequestClient mJsonClient;
+
+ private Provider<String, TrainingRecord> mTrainingRecordProvider;
+ private FeedData mFeedData;
+
+ // Fields pertaining to this request.
+ protected String mChapter;
+ protected String mUserId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ GrowFrontend growFrontend = (GrowFrontend) getApplication();
+ mConfig = growFrontend.getConfig();
+ mTrainingTemplate = growFrontend.getTemplate("templates/training.ftl");
+ if (mTrainingTemplate == null) {
+ LOG.fatal("Could not find training template.");
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ }
+
+ mJsonClient = new JsonRequestClient(getContext().getClientDispatcher());
+ mTrainingRecordProvider = new TrainingRecordProvider<String>(new JsonRequestProvider<TrainingRecord>(getContext().getClientDispatcher(), TrainingRecord.class)) {
+ @Override
+ public String makeKey(String userid) {
+ return getBackendEndpoint() + "/accounts/" + userid + "/training";
+ }
+ };
+
+ mFeedData = new FeedData(getContext(), mConfig);
+
+ mChapter = getAttribute("chapter");
+ mUserId = getRequest().getClientInfo().getUser().getIdentifier();
+ }
+
+ /**
+ * Return a page of videos.
+ */
+ @Override
+ protected Representation get() {
+ try {
+ // Get the training summary
+ TrainingRecord trainingRecord = mTrainingRecordProvider.get(mUserId);
+ if (trainingRecord == null) {
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return new ErrorPage("Could not retrieve TrainingRecord.");
+ }
+
+ Playlist playlist = trainingRecord.getPlaylist();
+ Map<String, Boolean> chapters = playlist.getChapterStatuses();
+ Map<String, Boolean> allowedChapters = new LinkedHashMap<String, Boolean>();
+
+ // The user is not allowed to view chapters after his highest completed chapter.
+ // In this loop we find which chapters are allowed and check if the user tried
+ // to skip ahead.
+ boolean allowUserToSkip = mConfig.getBoolean("allowUserToSkip", false) || getQueryValue("magicskip") != null;
+ String defaultChapter = null;
+ boolean userTriedToSkip = false;
+ int overallProgress = 0;
+
+ boolean foundRequired = false;
+ for (String chapterId : getChaptersInOrder()) {
+ boolean allowed = true;
+
+ Boolean completed = chapters.get(chapterId);
+ if (completed != null) {
+ if (!foundRequired) {
+ if (!completed) {
+ // The first incomplete chapter is the highest allowed chapter.
+ foundRequired = true;
+ defaultChapter = chapterId;
+ }
+
+ } else {
+ allowed = allowUserToSkip;
+
+ if (!allowUserToSkip && chapterId.equals(mChapter)) {
+ userTriedToSkip = true;
+ }
+ }
+
+ allowedChapters.put(chapterId, allowed);
+
+ if (completed) {
+ overallProgress++;
+ }
+ }
+ }
+
+ // Overall progress is the percentage of chapters complete
+ overallProgress = (int) ((double) overallProgress / getChaptersInOrder().length * 100);
+
+ if (defaultChapter == null) {
+ // Everything is completed... send them back to introduction.
+ defaultChapter = "introduction";
+ }
+
+ if (mChapter == null || userTriedToSkip) {
+ // No chapter was specified or the user tried to skip ahead.
+ // Either case, redirect.
+ String nextPage = mConfig.getString("dynamicRoot", "");
+ nextPage += "/account/training/" + defaultChapter;
+ getResponse().redirectSeeOther(nextPage);
+ return new StringRepresentation("Redirecting to " + nextPage);
+ }
+
+
+ // 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");
+ Collections.sort(videos, VIDEO_COMPARATOR);
+ }
+
+ // Mark the completed videos as completed
+ int chapterProgress = 0;
+ for (Map<String, Object> video : videos) {
+ boolean completed = false;
+ VideoRecord record = playlist.find((String) video.get("id"));
+ LOG.info("VideoId: " + video.get("id"));
+ if (record != null) {
+ LOG.info("VideoRecord: " + record.getComplete());
+ completed = record.getComplete();
+ }
+ video.put("completed", completed);
+
+ if (completed) {
+ chapterProgress++;
+ }
+ }
+ chapterProgress = chapterProgress * 100 / videos.size();
+
+ Map root = getRootObject();
+ root.put("chapter", mChapter);
+ root.put("chapters", allowedChapters.keySet());
+ root.put("isChapterAllowed", allowedChapters);
+ root.put("chapterProgress", chapterProgress);
+ root.put("overallProgress", overallProgress);
+ root.put("videos", videos);
+ root.put("allowUserToSkip", allowUserToSkip);
+
+ // Determine if we should show the feed.
+ boolean showfeed = true;
+
+ // Don't show the feed if the topic isn't allowed.
+ if (!FeedData.TOPICS.contains(mChapter)) {
+ showfeed = false;
+ }
+
+ root.put("showfeed", showfeed);
+ if (showfeed) {
+ root.put("feeddata", mFeedData);
+ }
+
+ return new TemplateRepresentation(mTrainingTemplate, root, MediaType.TEXT_HTML);
+
+ } catch (Exception e) {
+ LOG.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.RENDER_ERROR;
+ }
+ }
+
+ /**
+ * This method returns a list of chapters in the correct order.
+ */
+ protected String[] getChaptersInOrder() {
+ return CHAPTERS;
+ }
+
+ /**
+ * @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) {
+ LOG.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)) {
+ LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString());
+ }
+
+ return response;
+ }
+
+}
diff --git a/src/main/java/com/p4square/grow/frontend/VideosResource.java b/src/main/java/com/p4square/grow/frontend/VideosResource.java
new file mode 100644
index 0000000..2099a77
--- /dev/null
+++ b/src/main/java/com/p4square/grow/frontend/VideosResource.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.frontend;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import freemarker.template.Template;
+
+import org.restlet.data.Form;
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
+import org.restlet.ext.jackson.JacksonRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.resource.ServerResource;
+
+import org.apache.log4j.Logger;
+
+import com.p4square.fmfacade.json.JsonRequestClient;
+import com.p4square.fmfacade.json.JsonResponse;
+
+import com.p4square.grow.config.Config;
+
+/**
+ * VideosResource returns JSON blobs with video information and records watched
+ * videos.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class VideosResource extends ServerResource {
+ private static Logger cLog = Logger.getLogger(VideosResource.class);
+
+ private Config mConfig;
+ private JsonRequestClient mJsonClient;
+
+ // Fields pertaining to this request.
+ private String mChapter;
+ private String mVideoId;
+ private String mUserId;
+
+ @Override
+ public void doInit() {
+ super.doInit();
+
+ GrowFrontend growFrontend = (GrowFrontend) getApplication();
+ mConfig = growFrontend.getConfig();
+
+ mJsonClient = new JsonRequestClient(getContext().getClientDispatcher());
+
+ mChapter = getAttribute("chapter");
+ mVideoId = getAttribute("videoId");
+ mUserId = getRequest().getClientInfo().getUser().getIdentifier();
+ }
+
+ /**
+ * Fetch a video record from the backend.
+ */
+ @Override
+ protected Representation get() {
+ try {
+ JsonResponse response = backendGet("/training/" + mChapter + "/videos/" + mVideoId);
+
+ if (response.getStatus().isSuccess()) {
+ return new JacksonRepresentation<Map>(response.getMap());
+
+ } else {
+ setStatus(response.getStatus());
+ return null;
+ }
+
+ } catch (Exception e) {
+ cLog.fatal("Could not render page: " + e.getMessage(), e);
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return null;
+ }
+ }
+
+ /**
+ * Mark a video as completed.
+ */
+ @Override
+ protected Representation post(Representation entity) {
+ Map<String, Object> data = new HashMap<String, Object>();
+ data.put("complete", "true");
+ JsonResponse response = backendPut("/accounts/" + mUserId + "/training/videos/" + mVideoId, data);
+
+ if (!response.getStatus().isSuccess()) {
+ // Something went wrong talking to the backend, error out.
+ cLog.fatal("Error recording completed video " + response.getStatus());
+ setStatus(Status.SERVER_ERROR_INTERNAL);
+ return ErrorPage.BACKEND_ERROR;
+ }
+
+ setStatus(Status.SUCCESS_NO_CONTENT);
+ 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/main/java/com/p4square/grow/model/Answer.java b/src/main/java/com/p4square/grow/model/Answer.java
new file mode 100644
index 0000000..a818365
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Answer.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import org.apache.log4j.Logger;
+
+/**
+ * This is the model of an assessment question's answer.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Answer {
+ private static final Logger LOG = Logger.getLogger(Answer.class);
+
+ /**
+ * ScoreType determines how the answer will be scored.
+ *
+ */
+ public static enum ScoreType {
+ /**
+ * This question has no effect on the score.
+ */
+ NONE,
+
+ /**
+ * The score of this question is part of the average.
+ */
+ AVERAGE,
+
+ /**
+ * The score of this question is the total score, no other questions
+ * matter after this point.
+ */
+ TRUMP;
+
+ @Override
+ public String toString() {
+ return name().toLowerCase();
+ }
+ }
+
+ private String mAnswerText;
+ private ScoreType mType;
+ private float mScoreFactor;
+ private String mNextQuestionId;
+
+ public Answer() {
+ mType = ScoreType.AVERAGE;
+ }
+
+ /**
+ * @return The text associated with the answer.
+ */
+ public String getText() {
+ return mAnswerText;
+ }
+
+ /**
+ * Set the text associated with the answer.
+ * @param text The new text.
+ */
+ public void setText(String text) {
+ mAnswerText = text;
+ }
+
+ /**
+ * @return the ScoreType for the Answer.
+ */
+ public ScoreType getType() {
+ return mType;
+ }
+
+ /**
+ * Set the ScoreType for the answer.
+ * @param type The new ScoreType.
+ */
+ public void setType(ScoreType type) {
+ mType = type;
+ }
+
+ /**
+ * @return the delta of the score if this answer is selected.
+ */
+ public float getScore() {
+ if (mType == ScoreType.NONE) {
+ return 0;
+ }
+
+ return mScoreFactor;
+ }
+
+ /**
+ * Set the score delta for this answer.
+ * @param score The new delta.
+ */
+ public void setScore(float score) {
+ mScoreFactor = score;
+ }
+
+ /**
+ * @return the id of the next question if this answer is selected, or null
+ * if selecting this answer has no effect.
+ */
+ public String getNextQuestion() {
+ return mNextQuestionId;
+ }
+
+ /**
+ * Set the id of the next question when this answer is selected.
+ * @param id The next question id or null to proceed as usual.
+ */
+ public void setNextQuestion(String id) {
+ mNextQuestionId = id;
+ }
+
+ /**
+ * Adjust the running score for the selection of this answer.
+ * @param score The running score to adjust.
+ * @return true if scoring should continue, false if this answer trumps all.
+ */
+ public boolean score(final Score score) {
+ switch (getType()) {
+ case TRUMP:
+ score.sum = getScore();
+ score.count = 1;
+ return false; // Quit scoring.
+
+ case AVERAGE:
+ LOG.debug("ScoreType.AVERAGE: { delta: \"" + getScore() + "\" }");
+ score.sum += getScore();
+ score.count++;
+ break;
+
+ case NONE:
+ break;
+ }
+
+ return true; // Continue scoring
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/Banner.java b/src/main/java/com/p4square/grow/model/Banner.java
new file mode 100644
index 0000000..b786b36
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Banner.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Page Banner Data.
+ */
+public class Banner {
+ private String mHtml;
+
+ public String getHtml() {
+ return mHtml;
+ }
+
+ public void setHtml(final String html) {
+ mHtml = html;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/Chapter.java b/src/main/java/com/p4square/grow/model/Chapter.java
new file mode 100644
index 0000000..3a08e4c
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Chapter.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+/**
+ * Chapter is a list of VideoRecords in a Playlist.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Chapter implements Cloneable {
+ private String mName;
+ private Map<String, VideoRecord> mVideos;
+
+ public Chapter(String name) {
+ mName = name;
+ mVideos = new HashMap<String, VideoRecord>();
+ }
+
+ /**
+ * Private constructor for JSON decoding.
+ */
+ private Chapter() {
+ this(null);
+ }
+
+ /**
+ * @return The Chapter name.
+ */
+ public String getName() {
+ return mName;
+ }
+
+ /**
+ * Set the chapter name.
+ *
+ * @param name The name of the chapter.
+ */
+ public void setName(final String name) {
+ mName = name;
+ }
+
+ /**
+ * @return The VideoRecord for videoid or null if videoid is not in the chapter.
+ */
+ public VideoRecord getVideoRecord(String videoid) {
+ return mVideos.get(videoid);
+ }
+
+ /**
+ * @return A map of video ids to VideoRecords.
+ */
+ @JsonAnyGetter
+ public Map<String, VideoRecord> getVideos() {
+ return mVideos;
+ }
+
+ /**
+ * Set the VideoRecord for a video id.
+ * @param videoId the video id.
+ * @param video the VideoRecord.
+ */
+ @JsonAnySetter
+ public void setVideoRecord(String videoId, VideoRecord video) {
+ mVideos.put(videoId, video);
+ }
+
+ /**
+ * Remove the VideoRecord for a video id.
+ * @param videoId The id to remove.
+ */
+ public void removeVideoRecord(String videoId) {
+ mVideos.remove(videoId);
+ }
+
+ /**
+ * @return true if every required video has been completed.
+ */
+ @JsonIgnore
+ public boolean isComplete() {
+ boolean complete = true;
+
+ for (VideoRecord r : mVideos.values()) {
+ if (r.getRequired() && !r.getComplete()) {
+ return false;
+ }
+ }
+
+ return complete;
+ }
+
+ /**
+ * Deeply clone a chapter.
+ *
+ * @return a new Chapter object identical but independent of this one.
+ */
+ public Chapter clone() throws CloneNotSupportedException {
+ Chapter c = new Chapter(mName);
+ for (Map.Entry<String, VideoRecord> videoEntry : mVideos.entrySet()) {
+ c.setVideoRecord(videoEntry.getKey(), videoEntry.getValue().clone());
+ }
+ return c;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/CircleQuestion.java b/src/main/java/com/p4square/grow/model/CircleQuestion.java
new file mode 100644
index 0000000..71acc14
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/CircleQuestion.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Circle Question.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class CircleQuestion extends Question {
+ private static final ScoringEngine ENGINE = new QuadScoringEngine();
+
+ private String mTopLeft;
+ private String mTopRight;
+ private String mBottomLeft;
+ private String mBottomRight;
+
+ /**
+ * @return the Top Left label.
+ */
+ public String getTopLeft() {
+ return mTopLeft;
+ }
+
+ /**
+ * Set the Top Left label.
+ * @param s The new top left label.
+ */
+ public void setTopLeft(String s) {
+ mTopLeft = s;
+ }
+
+ /**
+ * @return the Top Right label.
+ */
+ public String getTopRight() {
+ return mTopRight;
+ }
+
+ /**
+ * Set the Top Right label.
+ * @param s The new top left label.
+ */
+ public void setTopRight(String s) {
+ mTopRight = s;
+ }
+
+ /**
+ * @return the Bottom Left label.
+ */
+ public String getBottomLeft() {
+ return mBottomLeft;
+ }
+
+ /**
+ * Set the Bottom Left label.
+ * @param s The new top left label.
+ */
+ public void setBottomLeft(String s) {
+ mBottomLeft = s;
+ }
+
+ /**
+ * @return the Bottom Right label.
+ */
+ public String getBottomRight() {
+ return mBottomRight;
+ }
+
+ /**
+ * Set the Bottom Right label.
+ * @param s The new top left label.
+ */
+ public void setBottomRight(String s) {
+ mBottomRight = s;
+ }
+
+ @Override
+ public boolean scoreAnswer(Score score, RecordedAnswer answer) {
+ return ENGINE.scoreAnswer(score, this, answer);
+ }
+
+ @Override
+ public QuestionType getType() {
+ return QuestionType.CIRCLE;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/ImageQuestion.java b/src/main/java/com/p4square/grow/model/ImageQuestion.java
new file mode 100644
index 0000000..d94c32c
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/ImageQuestion.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Image Question.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class ImageQuestion extends Question {
+ private static final ScoringEngine ENGINE = new SimpleScoringEngine();
+
+ @Override
+ public boolean scoreAnswer(Score score, RecordedAnswer answer) {
+ return ENGINE.scoreAnswer(score, this, answer);
+ }
+
+ @Override
+ public QuestionType getType() {
+ return QuestionType.IMAGE;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/Message.java b/src/main/java/com/p4square/grow/model/Message.java
new file mode 100644
index 0000000..9d33320
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Message.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import java.util.Date;
+import java.util.UUID;
+
+/**
+ * A feed message.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Message {
+ private String mThreadId;
+ private String mId;
+ private UserRecord mAuthor;
+ private Date mCreated;
+ private String mMessage;
+
+ /**
+ * @return a new message id.
+ */
+ public static String generateId() {
+ return String.format("%x-%s", System.currentTimeMillis(), UUID.randomUUID().toString());
+ }
+
+ /**
+ * @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 UserRecord getAuthor() {
+ return mAuthor;
+ }
+
+ /**
+ * Set the author of the message.
+ * @param author The new author.
+ */
+ public void setAuthor(UserRecord 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/main/java/com/p4square/grow/model/MessageThread.java b/src/main/java/com/p4square/grow/model/MessageThread.java
new file mode 100644
index 0000000..9542a18
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/MessageThread.java
@@ -0,0 +1,60 @@
+/*
+ * 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;
+ private Message mMessage;
+
+ /**
+ * Create a new thread with a probably unique id.
+ *
+ * @return the new thread.
+ */
+ public static MessageThread createNew() {
+ MessageThread t = new MessageThread();
+ // IDs are keyed to sort lexicographically from latest to oldest.
+ t.setId(String.format("%016x-%s", Long.MAX_VALUE - 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;
+ }
+
+ /**
+ * @return The original message.
+ */
+ public Message getMessage() {
+ return mMessage;
+ }
+
+ /**
+ * Set the original message.
+ * @param id The new message.
+ */
+ public void setMessage(Message message) {
+ mMessage = message;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/Playlist.java b/src/main/java/com/p4square/grow/model/Playlist.java
new file mode 100644
index 0000000..3e77ada
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Playlist.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+/**
+ * Representation of a user's playlist.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Playlist {
+ /**
+ * Map of Chapter ID to map of Video ID to VideoRecord.
+ */
+ private Map<String, Chapter> mPlaylist;
+
+ private Date mLastUpdated;
+
+ /**
+ * Construct an empty playlist.
+ */
+ public Playlist() {
+ mPlaylist = new HashMap<String, Chapter>();
+ mLastUpdated = new Date(0); // Default to a prehistoric date if we don't have one.
+ }
+
+ /**
+ * Find the VideoRecord for a video id.
+ */
+ public VideoRecord find(String videoId) {
+ for (Chapter chapter : mPlaylist.values()) {
+ VideoRecord r = chapter.getVideoRecord(videoId);
+
+ if (r != null) {
+ return r;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param videoId The video to search for.
+ * @return the Chapter containing videoId.
+ */
+ private Chapter findChapter(String videoId) {
+ for (Chapter chapter : mPlaylist.values()) {
+ VideoRecord r = chapter.getVideoRecord(videoId);
+
+ if (r != null) {
+ return chapter;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return The last modified date of the source playlist.
+ */
+ public Date getLastUpdated() {
+ return mLastUpdated;
+ }
+
+ /**
+ * Set the last updated date.
+ * @param date the new last updated date.
+ */
+ public void setLastUpdated(Date date) {
+ mLastUpdated = date;
+ }
+
+ /**
+ * Add a video to the playlist.
+ */
+ public VideoRecord add(String chapterId, String videoId) {
+ Chapter chapter = mPlaylist.get(chapterId);
+
+ if (chapter == null) {
+ chapter = new Chapter(chapterId);
+ mPlaylist.put(chapterId, chapter);
+ }
+
+ VideoRecord r = new VideoRecord();
+ chapter.setVideoRecord(videoId, r);
+ return r;
+ }
+
+ /**
+ * Add a Chapter to the Playlist.
+ * @param chapterId The name of the chapter.
+ * @param chapter The Chapter object to add.
+ */
+ @JsonAnySetter
+ public void addChapter(String chapterId, Chapter chapter) {
+ chapter.setName(chapterId);
+ mPlaylist.put(chapterId, chapter);
+ }
+
+ /**
+ * @return a map of chapter id to chapter.
+ */
+ @JsonAnyGetter
+ public Map<String, Chapter> getChaptersMap() {
+ return mPlaylist;
+ }
+
+ /**
+ * @return The last chapter to be completed.
+ */
+ @JsonIgnore
+ public Map<String, Boolean> getChapterStatuses() {
+ Map<String, Boolean> completed = new HashMap<String, Boolean>();
+
+ for (Map.Entry<String, Chapter> entry : mPlaylist.entrySet()) {
+ completed.put(entry.getKey(), entry.getValue().isComplete());
+ }
+
+ return completed;
+ }
+
+ /**
+ * @return true if all required videos in the chapter have been watched.
+ */
+ public boolean isChapterComplete(String chapterId) {
+ Chapter chapter = mPlaylist.get(chapterId);
+ if (chapter != null) {
+ return chapter.isComplete();
+ }
+
+ return false;
+ }
+
+ /**
+ * Merge a playlist into this playlist.
+ *
+ * Merge is accomplished by adding all missing Chapters and VideoRecords to
+ * this playlist.
+ */
+ public void merge(Playlist source) {
+ if (source.getLastUpdated().before(mLastUpdated)) {
+ // Already up to date.
+ return;
+ }
+
+ for (Map.Entry<String, Chapter> entry : source.getChaptersMap().entrySet()) {
+ String chapterName = entry.getKey();
+ Chapter theirChapter = entry.getValue();
+ Chapter myChapter = mPlaylist.get(entry.getKey());
+
+ if (myChapter == null) {
+ // Add new chapter
+ myChapter = new Chapter(chapterName);
+ addChapter(chapterName, myChapter);
+ }
+
+ // Check chapter for missing videos
+ for (Map.Entry<String, VideoRecord> videoEntry : theirChapter.getVideos().entrySet()) {
+ String videoId = videoEntry.getKey();
+ VideoRecord myVideo = myChapter.getVideoRecord(videoId);
+
+ if (myVideo == null) {
+ myVideo = find(videoId);
+ if (myVideo == null) {
+ // New Video
+ try {
+ myVideo = videoEntry.getValue().clone();
+ myChapter.setVideoRecord(videoId, myVideo);
+ } catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e); // Unexpected...
+ }
+ } else {
+ // Video moved
+ findChapter(videoId).removeVideoRecord(videoId);
+ myChapter.setVideoRecord(videoId, myVideo);
+ }
+ }
+ }
+ }
+
+ mLastUpdated = source.getLastUpdated();
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/Point.java b/src/main/java/com/p4square/grow/model/Point.java
new file mode 100644
index 0000000..e9fc0ca
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Point.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Simple double based point class.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Point {
+ /**
+ * Parse a comma separated x,y pair into a point.
+ *
+ * @return The point represented by the string.
+ * @throws IllegalArgumentException if the input is malformed.
+ */
+ public static Point valueOf(String str) {
+ final int comma = str.indexOf(',');
+ if (comma == -1 || comma == 0 || comma == str.length() - 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;
+
+ /**
+ * Create a new point with the given coordinates.
+ *
+ * @param x The x coordinate.
+ * @param y The y coordinate.
+ */
+ public Point(double x, double y) {
+ mX = x;
+ mY = y;
+ }
+
+ /**
+ * Compute the distance between this point and another.
+ *
+ * @param other The other point.
+ * @return The distance between this point and other.
+ */
+ public double distance(Point other) {
+ final double dx = mX - other.mX;
+ final double dy = mY - other.mY;
+
+ return Math.sqrt(dx*dx + dy*dy);
+ }
+
+ /**
+ * @return The x coordinate.
+ */
+ public double getX() {
+ return mX;
+ }
+
+ /**
+ * @return The y coordinate.
+ */
+ public double getY() {
+ return mY;
+ }
+
+ /**
+ * @return The point represented as a comma separated pair.
+ */
+ @Override
+ public String toString() {
+ return String.format("%.2f,%.2f", mX, mY);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/QuadQuestion.java b/src/main/java/com/p4square/grow/model/QuadQuestion.java
new file mode 100644
index 0000000..a7b4179
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/QuadQuestion.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Two-dimensional Question.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class QuadQuestion extends Question {
+ private static final ScoringEngine ENGINE = new QuadScoringEngine();
+
+ private String mTop;
+ private String mRight;
+ private String mBottom;
+ private String mLeft;
+
+ /**
+ * @return the top label.
+ */
+ public String getTop() {
+ return mTop;
+ }
+
+ /**
+ * Set the top label.
+ * @param s The new top label.
+ */
+ public void setTop(String s) {
+ mTop = s;
+ }
+
+ /**
+ * @return the right label.
+ */
+ public String getRight() {
+ return mRight;
+ }
+
+ /**
+ * Set the right label.
+ * @param s The new right label.
+ */
+ public void setRight(String s) {
+ mRight = s;
+ }
+
+ /**
+ * @return the bottom label.
+ */
+ public String getBottom() {
+ return mBottom;
+ }
+
+ /**
+ * Set the bottom label.
+ * @param s The new bottom label.
+ */
+ public void setBottom(String s) {
+ mBottom = s;
+ }
+
+ /**
+ * @return the left label.
+ */
+ public String getLeft() {
+ return mLeft;
+ }
+
+ /**
+ * Set the left label.
+ * @param s The new left label.
+ */
+ public void setLeft(String s) {
+ mLeft = s;
+ }
+
+ @Override
+ public boolean scoreAnswer(Score score, RecordedAnswer answer) {
+ return ENGINE.scoreAnswer(score, this, answer);
+ }
+
+ @Override
+ public QuestionType getType() {
+ return QuestionType.QUAD;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/QuadScoringEngine.java b/src/main/java/com/p4square/grow/model/QuadScoringEngine.java
new file mode 100644
index 0000000..33403b5
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/QuadScoringEngine.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import com.p4square.grow.model.Point;
+
+/**
+ * QuadScoringEngine expects the user's answer to be a Point string. We find
+ * the closest answer Point to the user's answer and treat that as the answer.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class QuadScoringEngine extends ScoringEngine {
+
+ @Override
+ public boolean scoreAnswer(Score score, Question question, RecordedAnswer userAnswer) {
+ // Find all of the answer points.
+ Point[] answers = new Point[question.getAnswers().size()];
+ {
+ int i = 0;
+ for (String answerStr : question.getAnswers().keySet()) {
+ answers[i++] = Point.valueOf(answerStr);
+ }
+ }
+
+ // Parse the user's answer.
+ Point userPoint = Point.valueOf(userAnswer.getAnswerId());
+
+ // Find the closest answer point to the user's answer.
+ double minDistance = Double.MAX_VALUE;
+ int answerIndex = 0;
+ for (int i = 0; i < answers.length; i++) {
+ final double distance = userPoint.distance(answers[i]);
+ if (distance < minDistance) {
+ minDistance = distance;
+ answerIndex = i;
+ }
+ }
+
+ LOG.debug("Quad " + question.getId() + ": Got answer "
+ + answers[answerIndex].toString() + " for user point " + userAnswer);
+
+ // Get the answer and update the score.
+ final Answer answer = question.getAnswers().get(answers[answerIndex].toString());
+ return answer.score(score);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/Question.java b/src/main/java/com/p4square/grow/model/Question.java
new file mode 100644
index 0000000..f4b9458
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Question.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+/**
+ * Model of an assessment question.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "type")
+@JsonSubTypes({
+ @Type(value = TextQuestion.class, name = "text"),
+ @Type(value = ImageQuestion.class, name = "image"),
+ @Type(value = SliderQuestion.class, name = "slider"),
+ @Type(value = QuadQuestion.class, name = "quad"),
+ @Type(value = CircleQuestion.class, name = "circle"),
+})
+public abstract class Question {
+ /**
+ * QuestionType indicates the type of Question.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+ public enum QuestionType {
+ TEXT,
+ IMAGE,
+ SLIDER,
+ QUAD,
+ CIRCLE;
+
+ @Override
+ public String toString() {
+ return name().toLowerCase();
+ }
+ }
+
+ private String mQuestionId;
+ private QuestionType mType;
+ private String mQuestionText;
+ private Map<String, Answer> mAnswers;
+
+ private String mPreviousQuestionId;
+ private String mNextQuestionId;
+
+ public Question() {
+ mAnswers = new HashMap<String, Answer>();
+ }
+
+ /**
+ * @return the id String for this question.
+ */
+ public String getId() {
+ return mQuestionId;
+ }
+
+ /**
+ * Set the id String for this question.
+ * @param id New id
+ */
+ public void setId(String id) {
+ mQuestionId = id;
+ }
+
+ /**
+ * @return The Question text.
+ */
+ public String getQuestion() {
+ return mQuestionText;
+ }
+
+ /**
+ * Set the question text.
+ * @param value The new question text.
+ */
+ public void setQuestion(String value) {
+ mQuestionText = value;
+ }
+
+ /**
+ * @return The id String of the previous question or null if no previous question exists.
+ */
+ public String getPreviousQuestion() {
+ return mPreviousQuestionId;
+ }
+
+ /**
+ * Set the id string of the previous question.
+ * @param id Previous question id or null if there is no previous question.
+ */
+ public void setPreviousQuestion(String id) {
+ mPreviousQuestionId = id;
+ }
+
+ /**
+ * @return The id String of the next question or null if no next question exists.
+ */
+ public String getNextQuestion() {
+ return mNextQuestionId;
+ }
+
+ /**
+ * Set the id string of the next question.
+ * @param id next question id or null if there is no next question.
+ */
+ public void setNextQuestion(String id) {
+ mNextQuestionId = id;
+ }
+
+ /**
+ * @return a map of Answer id Strings to Answer objects.
+ */
+ public Map<String, Answer> getAnswers() {
+ return mAnswers;
+ }
+
+ /**
+ * Determine the id of the next question based on the answer to this
+ * question.
+ *
+ * @param answerid
+ * The id of the selected answer.
+ * @return a question id or null if this is the last question.
+ */
+ public String getNextQuestion(String answerid) {
+ String nextQuestion = null;
+
+ Answer a = mAnswers.get(answerid);
+ if (a != null) {
+ nextQuestion = a.getNextQuestion();
+ }
+
+ if (nextQuestion == null) {
+ nextQuestion = mNextQuestionId;
+ }
+
+ return nextQuestion;
+ }
+
+ /**
+ * Update the score based on the answer to this question.
+ *
+ * @param score The running score to update.
+ * @param answer The answer give to this question.
+ * @return true if scoring should continue, false if this answer trumps everything else.
+ */
+ public abstract boolean scoreAnswer(Score score, RecordedAnswer answer);
+
+ /**
+ * @return the QuestionType of this question.
+ */
+ public abstract QuestionType getType();
+
+}
diff --git a/src/main/java/com/p4square/grow/model/RecordedAnswer.java b/src/main/java/com/p4square/grow/model/RecordedAnswer.java
new file mode 100644
index 0000000..7d9905d
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/RecordedAnswer.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Simple model for a user's assessment answer.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class RecordedAnswer {
+ private String mAnswerId;
+
+ /**
+ * @return The user's answer.
+ */
+ public String getAnswerId() {
+ return mAnswerId;
+ }
+
+ /**
+ * Set the answer id field.
+ * @param id The new id.
+ */
+ public void setAnswerId(String id) {
+ mAnswerId = id;
+ }
+
+ @Override
+ public String toString() {
+ return mAnswerId;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/Score.java b/src/main/java/com/p4square/grow/model/Score.java
new file mode 100644
index 0000000..031c309
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/Score.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Simple structure containing a score's sum and count.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Score {
+ /**
+ * Return the decimal value for the given Score String.
+ *
+ * This method satisfies the invariant for Score x:
+ * numericScore(x.toString()) <= x.getScore()
+ */
+ public static double numericScore(String score) {
+ score = score.toLowerCase();
+
+ if ("teacher".equals(score)) {
+ return 3.5;
+ } else if ("disciple".equals(score)) {
+ return 2.5;
+ } else if ("believer".equals(score)) {
+ return 1.5;
+ } else if ("seeker".equals(score)) {
+ return 0;
+ } else {
+ return Integer.MAX_VALUE;
+ }
+ }
+
+ double sum;
+ int count;
+
+ public Score() {
+ sum = 0;
+ count = 0;
+ }
+
+ public Score(double sum, int count) {
+ this.sum = sum;
+ this.count = count;
+ }
+
+ /**
+ * Copy Constructor.
+ */
+ public Score(Score other) {
+ sum = other.sum;
+ count = other.count;
+ }
+
+ /**
+ * @return The sum of all the points.
+ */
+ public double getSum() {
+ return sum;
+ }
+
+ /**
+ * @return The number of questions included in the score.
+ */
+ public int getCount() {
+ return count;
+ }
+
+ /**
+ * @return The final score.
+ */
+ public double getScore() {
+ if (count == 0) {
+ return 0;
+ }
+
+ return sum / count;
+ }
+
+ /**
+ * @return the lowest score in the same category as this score.
+ */
+ public double floor() {
+ final double score = getScore();
+
+ if (score >= 3.5) {
+ return 3.5; // teacher
+
+ } else if (score >= 2.5) {
+ return 2.5; // disciple
+
+ } else if (score >= 1.5) {
+ return 1.5; // believer
+
+ } else {
+ return 0; // seeker
+ }
+ }
+
+ @Override
+ public String toString() {
+ final double score = getScore();
+
+ if (score >= 3.5) {
+ return "teacher";
+
+ } else if (score >= 2.5) {
+ return "disciple";
+
+ } else if (score >= 1.5) {
+ return "believer";
+
+ } else {
+ return "seeker";
+ }
+ }
+
+}
diff --git a/src/main/java/com/p4square/grow/model/ScoringEngine.java b/src/main/java/com/p4square/grow/model/ScoringEngine.java
new file mode 100644
index 0000000..8ff18b3
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/ScoringEngine.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import org.apache.log4j.Logger;
+
+/**
+ * ScoringEngine computes the score for a question and a given answer.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public abstract class ScoringEngine {
+ protected static final Logger LOG = Logger.getLogger(ScoringEngine.class);
+
+ /**
+ * Update the score based on the given question and answer.
+ *
+ * @param score The running score to update.
+ * @param question The question to compute the score for.
+ * @param answer The answer give to this question.
+ * @return true if scoring should continue, false if this answer trumps everything else.
+ */
+ public abstract boolean scoreAnswer(Score score, Question question, RecordedAnswer answer);
+}
diff --git a/src/main/java/com/p4square/grow/model/SimpleScoringEngine.java b/src/main/java/com/p4square/grow/model/SimpleScoringEngine.java
new file mode 100644
index 0000000..6ef2dbb
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/SimpleScoringEngine.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * SimpleScoringEngine expects the user's answer to a valid answer id and
+ * scores accordingly.
+ *
+ * If the answer id is not valid an Exception is thrown.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SimpleScoringEngine extends ScoringEngine {
+
+ @Override
+ public boolean scoreAnswer(Score score, Question question, RecordedAnswer userAnswer) {
+ final Answer answer = question.getAnswers().get(userAnswer.getAnswerId());
+ if (answer == null) {
+ throw new IllegalArgumentException("Not a valid answer.");
+ }
+
+ return answer.score(score);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/SliderQuestion.java b/src/main/java/com/p4square/grow/model/SliderQuestion.java
new file mode 100644
index 0000000..f0861e3
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/SliderQuestion.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Slider Question.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SliderQuestion extends Question {
+ private static final ScoringEngine ENGINE = new SliderScoringEngine();
+
+ @Override
+ public boolean scoreAnswer(Score score, RecordedAnswer answer) {
+ return ENGINE.scoreAnswer(score, this, answer);
+ }
+
+ @Override
+ public QuestionType getType() {
+ return QuestionType.SLIDER;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/SliderScoringEngine.java b/src/main/java/com/p4square/grow/model/SliderScoringEngine.java
new file mode 100644
index 0000000..2961e95
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/SliderScoringEngine.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * SliderScoringEngine expects the user's answer to be a decimal value in the
+ * range [0, 1]. The value is scaled to the range [1, 4] and added to the
+ * score.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SliderScoringEngine extends ScoringEngine {
+
+ @Override
+ public boolean scoreAnswer(Score score, Question question, RecordedAnswer userAnswer) {
+ int numberOfAnswers = question.getAnswers().size();
+ if (numberOfAnswers == 0) {
+ throw new IllegalArgumentException("Question has no answers.");
+ }
+
+ double answer = Double.valueOf(userAnswer.getAnswerId());
+ if (answer < 0 || answer > 1) {
+ throw new IllegalArgumentException("Answer out of bounds.");
+ }
+
+ double delta = Math.max(1, Math.ceil(answer * numberOfAnswers) / numberOfAnswers * 4);
+
+ score.sum += delta;
+ score.count++;
+
+ return true;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/TextQuestion.java b/src/main/java/com/p4square/grow/model/TextQuestion.java
new file mode 100644
index 0000000..88c2a34
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/TextQuestion.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Text Question.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class TextQuestion extends Question {
+ private static final ScoringEngine ENGINE = new SimpleScoringEngine();
+
+ @Override
+ public boolean scoreAnswer(Score score, RecordedAnswer answer) {
+ return ENGINE.scoreAnswer(score, this, answer);
+ }
+
+ @Override
+ public QuestionType getType() {
+ return QuestionType.TEXT;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/TrainingRecord.java b/src/main/java/com/p4square/grow/model/TrainingRecord.java
new file mode 100644
index 0000000..bc3ffa9
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/TrainingRecord.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+/**
+ * Representation of a user's training record.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class TrainingRecord {
+ private String mLastVideo;
+ private Playlist mPlaylist;
+
+ public TrainingRecord() {
+ mPlaylist = new Playlist();
+ }
+
+ /**
+ * @return Video id of the last video watched.
+ */
+ public String getLastVideo() {
+ return mLastVideo;
+ }
+
+ /**
+ * Set the video id for the last video watched.
+ * @param video The new video id.
+ */
+ public void setLastVideo(String video) {
+ mLastVideo = video;
+ }
+
+ /**
+ * @return the user's Playlist.
+ */
+ public Playlist getPlaylist() {
+ return mPlaylist;
+ }
+
+ /**
+ * Set the user's playlist.
+ * @param playlist The new playlist.
+ */
+ public void setPlaylist(Playlist playlist) {
+ mPlaylist = playlist;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/UserRecord.java b/src/main/java/com/p4square/grow/model/UserRecord.java
new file mode 100644
index 0000000..4399282
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/UserRecord.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import org.apache.commons.codec.binary.Hex;
+
+import org.restlet.security.User;
+
+/**
+ * A simple user representation without any secrets.
+ */
+public class UserRecord {
+ private String mId;
+ private String mFirstName;
+ private String mLastName;
+ private String mEmail;
+ private String mLanding;
+ private boolean mNewBeliever;
+
+ // Backend Access
+ private String mBackendPasswordHash;
+
+ /**
+ * Create an empty UserRecord.
+ */
+ public UserRecord() {
+ }
+
+ /**
+ * Create a new UserRecord with the information from a User.
+ */
+ public UserRecord(final User user) {
+ mId = user.getIdentifier();
+ mFirstName = user.getFirstName();
+ mLastName = user.getLastName();
+ mEmail = user.getEmail();
+ }
+
+ /**
+ * @return The user's identifier.
+ */
+ public String getId() {
+ return mId;
+ }
+
+ /**
+ * Set the user's identifier.
+ * @param value The new id.
+ */
+ public void setId(final String value) {
+ mId = value;
+ }
+
+ /**
+ * @return The user's email.
+ */
+ public String getEmail() {
+ return mEmail;
+ }
+
+ /**
+ * Set the user's email.
+ * @param value The new email.
+ */
+ public void setEmail(final String value) {
+ mEmail = value;
+ }
+
+ /**
+ * @return The user's first name.
+ */
+ public String getFirstName() {
+ return mFirstName;
+ }
+
+ /**
+ * Set the user's first name.
+ * @param value The new first name.
+ */
+ public void setFirstName(final String value) {
+ mFirstName = value;
+ }
+
+ /**
+ * @return The user's last name.
+ */
+ public String getLastName() {
+ return mLastName;
+ }
+
+ /**
+ * Set the user's last name.
+ * @param value The new last name.
+ */
+ public void setLastName(final String value) {
+ mLastName = value;
+ }
+
+ /**
+ * @return The user's landing page.
+ */
+ public String getLanding() {
+ return mLanding;
+ }
+
+ /**
+ * Set the user's landing page.
+ * @param value The new landing page.
+ */
+ public void setLanding(final String value) {
+ mLanding = value;
+ }
+
+ /**
+ * @return true if the user came from the New Believer's landing.
+ */
+ public boolean getNewBeliever() {
+ return mNewBeliever;
+ }
+
+ /**
+ * Set the user's new believer flag.
+ * @param value The new flag.
+ */
+ public void setNewBeliever(final boolean value) {
+ mNewBeliever = value;
+ }
+
+ /**
+ * @return The user's backend password hash, null if he doesn't have
+ * access.
+ */
+ public String getBackendPasswordHash() {
+ return mBackendPasswordHash;
+ }
+
+ /**
+ * Set the user's backend password hash.
+ * @param value The new backend password hash or null to remove
+ * access.
+ */
+ public void setBackendPasswordHash(final String value) {
+ mBackendPasswordHash = value;
+ }
+
+ /**
+ * Set the user's backend password to the clear-text value given.
+ * @param value The new backend password.
+ */
+ public void setBackendPassword(final String value) {
+ try {
+ mBackendPasswordHash = hashPassword(value);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Hash the given secret.
+ */
+ public static String hashPassword(final String secret) throws NoSuchAlgorithmException {
+ MessageDigest md = MessageDigest.getInstance("SHA-1");
+
+ // Convert the char[] to byte[]
+ // FIXME This approach is incorrectly truncating multibyte
+ // characters.
+ byte[] b = new byte[secret.length()];
+ for (int i = 0; i < secret.length(); i++) {
+ b[i] = (byte) secret.charAt(i);
+ }
+
+ md.update(b);
+
+ byte[] hash = md.digest();
+ return new String(Hex.encodeHex(hash));
+ }
+}
diff --git a/src/main/java/com/p4square/grow/model/VideoRecord.java b/src/main/java/com/p4square/grow/model/VideoRecord.java
new file mode 100644
index 0000000..ec99d0d
--- /dev/null
+++ b/src/main/java/com/p4square/grow/model/VideoRecord.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.model;
+
+import java.util.Date;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+/**
+ * Simple bean containing video completion data.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class VideoRecord implements Cloneable {
+ private Boolean mComplete;
+ private Boolean mRequired;
+ private Date mCompletionDate;
+
+ public VideoRecord() {
+ mComplete = null;
+ mRequired = null;
+ mCompletionDate = null;
+ }
+
+ public boolean getComplete() {
+ if (mComplete == null) {
+ return false;
+ }
+ return mComplete;
+ }
+
+ public void setComplete(boolean complete) {
+ mComplete = complete;
+ }
+
+ @JsonIgnore
+ public boolean isCompleteSet() {
+ return mComplete != null;
+ }
+
+ public boolean getRequired() {
+ if (mRequired == null) {
+ return true;
+ }
+ return mRequired;
+ }
+
+ public void setRequired(boolean complete) {
+ mRequired = complete;
+ }
+
+ @JsonIgnore
+ public boolean isRequiredSet() {
+ return mRequired != null;
+ }
+
+ public Date getCompletionDate() {
+ return mCompletionDate;
+ }
+
+ public void setCompletionDate(Date date) {
+ mCompletionDate = date;
+ }
+
+ /**
+ * Convenience method to mark a video complete.
+ */
+ public void complete() {
+ mComplete = true;
+ mCompletionDate = new Date();
+ }
+
+ /**
+ * @return an identical clone of this record.
+ */
+ public VideoRecord clone() throws CloneNotSupportedException {
+ VideoRecord r = (VideoRecord) super.clone();
+ r.mComplete = mComplete;
+ r.mRequired = mRequired;
+ r.mCompletionDate = mCompletionDate;
+ return r;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/provider/CollectionProvider.java b/src/main/java/com/p4square/grow/provider/CollectionProvider.java
new file mode 100644
index 0000000..e4e9040
--- /dev/null
+++ b/src/main/java/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/main/java/com/p4square/grow/provider/DelegateCollectionProvider.java b/src/main/java/com/p4square/grow/provider/DelegateCollectionProvider.java
new file mode 100644
index 0000000..cf697ba
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/DelegateCollectionProvider.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public abstract class DelegateCollectionProvider<C, DC, K, DK, V>
+ implements CollectionProvider<C, K, V> {
+
+ private CollectionProvider<DC, DK, V> mProvider;
+
+ public DelegateCollectionProvider(final CollectionProvider<DC, DK, V> provider) {
+ mProvider = provider;
+ }
+
+ public V get(C collection, K key) throws IOException {
+ return mProvider.get(makeCollectionKey(collection), makeKey(key));
+ }
+
+ public Map<K, V> query(C collection) throws IOException {
+ return query(collection, -1);
+ }
+
+ public Map<K, V> query(C collection, int limit) throws IOException {
+ Map<DK, V> delegateResult = mProvider.query(makeCollectionKey(collection), limit);
+ Map<K, V> result = new LinkedHashMap<>();
+ for (Map.Entry<DK, V> entry : delegateResult.entrySet()) {
+ result.put(unmakeKey(entry.getKey()), entry.getValue());
+ }
+
+ return result;
+ }
+
+ public void put(C collection, K key, V obj) throws IOException {
+ mProvider.put(makeCollectionKey(collection), makeKey(key), obj);
+ }
+
+ /**
+ * Make a collection key for the delegated provider.
+ *
+ * @param input The pre-transform key.
+ * @return the post-transform key.
+ */
+ protected abstract DC makeCollectionKey(final C input);
+
+ /**
+ * Make a key for the delegated provider.
+ *
+ * @param input The pre-transform key.
+ * @return the post-transform key.
+ */
+ protected abstract DK makeKey(final K input);
+
+ /**
+ * Transform a key for the delegated provider to an input key.
+ *
+ * @param input The post-transform key.
+ * @return the pre-transform key.
+ */
+ protected abstract K unmakeKey(final DK input);
+}
diff --git a/src/main/java/com/p4square/grow/provider/DelegateProvider.java b/src/main/java/com/p4square/grow/provider/DelegateProvider.java
new file mode 100644
index 0000000..42dcc63
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/DelegateProvider.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+
+/**
+ * DelegateProvider wraps an existing Provider an transforms the key from
+ * type K to type D.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public abstract class DelegateProvider<K, D, V> implements Provider<K, V> {
+
+ private Provider<D, V> mProvider;
+
+ public DelegateProvider(final Provider<D, V> provider) {
+ mProvider = provider;
+ }
+
+ @Override
+ public V get(final K key) throws IOException {
+ return mProvider.get(makeKey(key));
+ }
+
+ @Override
+ public void put(final K key, final V obj) throws IOException {
+ mProvider.put(makeKey(key), obj);
+ }
+
+ /**
+ * Make a Key for the delegated provider.
+ *
+ * @param input The pre-transform key.
+ * @return the post-transform key.
+ */
+ protected abstract D makeKey(final K input);
+}
diff --git a/src/main/java/com/p4square/grow/provider/JsonEncodedProvider.java b/src/main/java/com/p4square/grow/provider/JsonEncodedProvider.java
new file mode 100644
index 0000000..500f761
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/JsonEncodedProvider.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+/**
+ * Provider provides a simple interface for loading and persisting
+ * objects.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public abstract class JsonEncodedProvider<V> {
+ public static final ObjectMapper MAPPER = new ObjectMapper();
+ static {
+ MAPPER.configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true);
+ MAPPER.configure(DeserializationFeature.READ_ENUMS_USING_TO_STRING, true);
+ MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ }
+
+ protected final Class<V> mClazz;
+ protected final JavaType mType;
+
+ public JsonEncodedProvider(Class<V> clazz) {
+ mClazz = clazz;
+ mType = null;
+ }
+
+ public JsonEncodedProvider(JavaType type) {
+ mType = type;
+ mClazz = null;
+ }
+
+ /**
+ * 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 {
+ if (mClazz == String.class) {
+ return (String) obj;
+ }
+
+ return 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;
+ }
+
+ if (mClazz == String.class) {
+ return (V) blob;
+ }
+
+ V obj;
+ if (mClazz != null) {
+ obj = MAPPER.readValue(blob, mClazz);
+
+ } else {
+ obj = MAPPER.readValue(blob, mType);
+ }
+
+ return obj;
+ }
+}
+
diff --git a/src/main/java/com/p4square/grow/provider/MapCollectionProvider.java b/src/main/java/com/p4square/grow/provider/MapCollectionProvider.java
new file mode 100644
index 0000000..4c5cef6
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/MapCollectionProvider.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2015 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.HashMap;
+
+/**
+ * In-memory CollectionProvider implementation, useful for tests.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class MapCollectionProvider<C, K, V> implements CollectionProvider<C, K, V> {
+ private final Map<C, Map<K, V>> mMap;
+
+ public MapCollectionProvider() {
+ mMap = new HashMap<>();
+ }
+
+ @Override
+ public synchronized V get(C collection, K key) throws IOException {
+ Map<K, V> map = mMap.get(collection);
+ if (map != null) {
+ return map.get(key);
+ }
+
+ return null;
+ }
+
+ @Override
+ public synchronized Map<K, V> query(C collection) throws IOException {
+ Map<K, V> map = mMap.get(collection);
+ if (map == null) {
+ map = new HashMap<K, V>();
+ }
+
+ return map;
+ }
+
+ @Override
+ public synchronized Map<K, V> query(C collection, int limit) throws IOException {
+ Map<K, V> map = query(collection);
+
+ if (map.size() > limit) {
+ Map<K, V> smallMap = new HashMap<>();
+
+ Iterator<Map.Entry<K, V>> iterator = map.entrySet().iterator();
+ for (int i = 0; i < limit; i++) {
+ Map.Entry<K, V> entry = iterator.next();
+ smallMap.put(entry.getKey(), entry.getValue());
+ }
+
+ return smallMap;
+
+ } else {
+ return map;
+ }
+ }
+
+ @Override
+ public synchronized void put(C collection, K key, V obj) throws IOException {
+ Map<K, V> map = mMap.get(collection);
+ if (map == null) {
+ map = new HashMap<K, V>();
+ mMap.put(collection, map);
+ }
+
+ map.put(key, obj);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/provider/MapProvider.java b/src/main/java/com/p4square/grow/provider/MapProvider.java
new file mode 100644
index 0000000..40f8107
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/MapProvider.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2015 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.HashMap;
+
+/**
+ * In-memory Provider implementation, useful for tests.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class MapProvider<K, V> implements Provider<K, V> {
+ private final Map<K, V> mMap = new HashMap<K, V>();
+
+ @Override
+ public V get(K key) throws IOException {
+ return mMap.get(key);
+ }
+
+ @Override
+ public void put(K key, V obj) throws IOException {
+ mMap.put(key, obj);
+ }
+}
diff --git a/src/main/java/com/p4square/grow/provider/Provider.java b/src/main/java/com/p4square/grow/provider/Provider.java
new file mode 100644
index 0000000..ca6af25
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/Provider.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+
+/**
+ * Provider provides a simple interface for loading and persisting
+ * objects.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface Provider<K, V> {
+ /**
+ * Retrieve the object with the given key.
+ *
+ * @param key The key for the object.
+ * @return The object or null if not found.
+ */
+ V get(K key) throws IOException;
+
+ /**
+ * Persist the object with the given key.
+ *
+ * @param key The key for the object.
+ * @param obj The object to persist.
+ */
+ void put(K key, V obj) throws IOException;
+}
diff --git a/src/main/java/com/p4square/grow/provider/ProvidesAssessments.java b/src/main/java/com/p4square/grow/provider/ProvidesAssessments.java
new file mode 100644
index 0000000..62ba8f6
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/ProvidesAssessments.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import com.p4square.grow.model.RecordedAnswer;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface ProvidesAssessments {
+ /**
+ * Provides a collection of user assessments.
+ * The collection key is the user id.
+ * The key is the question id.
+ */
+ CollectionProvider<String, String, String> getAnswerProvider();
+}
diff --git a/src/main/java/com/p4square/grow/provider/ProvidesQuestions.java b/src/main/java/com/p4square/grow/provider/ProvidesQuestions.java
new file mode 100644
index 0000000..b43f649
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/ProvidesQuestions.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import com.p4square.grow.model.Question;
+
+/**
+ * Indicates the ability to provide a Question Provider.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface ProvidesQuestions {
+ /**
+ * @return A Provider of Questions keyed by question id.
+ */
+ Provider<String, Question> getQuestionProvider();
+}
diff --git a/src/main/java/com/p4square/grow/provider/ProvidesStrings.java b/src/main/java/com/p4square/grow/provider/ProvidesStrings.java
new file mode 100644
index 0000000..5d9976e
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/ProvidesStrings.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+/**
+ * Indicates the ability to provide a String provider.
+ *
+ * Strings are typically configuration settings stored as a String.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface ProvidesStrings {
+ /**
+ * @return A Provider of Questions keyed by question id.
+ */
+ Provider<String, String> getStringProvider();
+} \ No newline at end of file
diff --git a/src/main/java/com/p4square/grow/provider/ProvidesTrainingRecords.java b/src/main/java/com/p4square/grow/provider/ProvidesTrainingRecords.java
new file mode 100644
index 0000000..586e649
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/ProvidesTrainingRecords.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+
+import com.p4square.grow.model.TrainingRecord;
+import com.p4square.grow.model.Playlist;
+
+/**
+ * Indicates the ability to provide a TrainingRecord Provider.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface ProvidesTrainingRecords {
+ /**
+ * @return A Provider of Questions keyed by question id.
+ */
+ Provider<String, TrainingRecord> getTrainingRecordProvider();
+
+ /**
+ * @return the Default Playlist.
+ */
+ Playlist getDefaultPlaylist() throws IOException;
+}
diff --git a/src/main/java/com/p4square/grow/provider/ProvidesUserRecords.java b/src/main/java/com/p4square/grow/provider/ProvidesUserRecords.java
new file mode 100644
index 0000000..d77c878
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/ProvidesUserRecords.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import com.p4square.grow.model.UserRecord;
+
+/**
+ * Indicates the ability to provide a UserRecord Provider.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface ProvidesUserRecords {
+ /**
+ * @return A Provider of Questions keyed by question id.
+ */
+ Provider<String, UserRecord> getUserRecordProvider();
+}
diff --git a/src/main/java/com/p4square/grow/provider/ProvidesVideos.java b/src/main/java/com/p4square/grow/provider/ProvidesVideos.java
new file mode 100644
index 0000000..3d055d3
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/ProvidesVideos.java
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public interface ProvidesVideos {
+ /**
+ * @return A Provider of Questions keyed by question id.
+ */
+ CollectionProvider<String, String, String> getVideoProvider();
+}
diff --git a/src/main/java/com/p4square/grow/provider/TrainingRecordProvider.java b/src/main/java/com/p4square/grow/provider/TrainingRecordProvider.java
new file mode 100644
index 0000000..44dba87
--- /dev/null
+++ b/src/main/java/com/p4square/grow/provider/TrainingRecordProvider.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.provider;
+
+import java.io.IOException;
+
+import com.p4square.grow.model.TrainingRecord;
+
+/**
+ * TrainingRecordProvider wraps an existing Provider to get and put TrainingRecords.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public abstract class TrainingRecordProvider<K> implements Provider<String, TrainingRecord> {
+
+ private Provider<K, TrainingRecord> mProvider;
+
+ public TrainingRecordProvider(Provider<K, TrainingRecord> provider) {
+ mProvider = provider;
+ }
+
+ @Override
+ public TrainingRecord get(String key) throws IOException {
+ return mProvider.get(makeKey(key));
+ }
+
+ @Override
+ public void put(String key, TrainingRecord obj) throws IOException {
+ mProvider.put(makeKey(key), obj);
+ }
+
+ /**
+ * Make a Key for a TrainingRecord..
+ *
+ * @param userId The user id.
+ * @return a key for the TrainingRecord of userid.
+ */
+ protected abstract K makeKey(String userId);
+}
diff --git a/src/main/java/com/p4square/grow/tools/AssessmentStats.java b/src/main/java/com/p4square/grow/tools/AssessmentStats.java
new file mode 100644
index 0000000..ca83411
--- /dev/null
+++ b/src/main/java/com/p4square/grow/tools/AssessmentStats.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.grow.tools;
+
+
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Queue;
+import java.util.List;
+import java.util.LinkedList;
+import java.io.IOException;
+
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.p4square.grow.model.Answer;
+import com.p4square.grow.model.Question;
+import com.p4square.grow.model.RecordedAnswer;
+import com.p4square.grow.model.Score;
+import com.p4square.grow.provider.Provider;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AssessmentStats {
+ public static void main(String... args) throws Exception {
+ if (args.length == 0) {
+ System.out.println("Usage: AssessmentStats directory firstQuestionId");
+ System.exit(1);
+ }
+
+ Map<String, Question> questions;
+ questions = loadQuestions(args[0], args[1]);
+
+ // Find the highest possible score
+ List<AnswerPath> scores = findHighestFromId(questions, args[1]);
+
+ // Print Results
+ System.out.printf("Found %d different paths.\n", scores.size());
+ int i = 0;
+ for (AnswerPath path : scores) {
+ Score s = path.mScore;
+ System.out.printf("Path %d: %f points, %d questions. Score: %f (%s)\n",
+ i++, s.getSum(), s.getCount(), s.getScore(), s.toString());
+ System.out.println(" " + path.mPath);
+ System.out.println(" " + path.mScores);
+ }
+ }
+
+ private static Map<String, Question> loadQuestions(String baseDir, String firstId) throws IOException {
+ FileQuestionProvider provider = new FileQuestionProvider(baseDir);
+
+ // Questions to find...
+ Queue<String> queue = new LinkedList<>();
+ queue.offer(firstId);
+
+ Map<String, Question> questions = new HashMap<>();
+
+
+ while (!queue.isEmpty()) {
+ Question q = provider.get(queue.poll());
+ questions.put(q.getId(), q);
+
+ if (q.getNextQuestion() != null) {
+ queue.offer(q.getNextQuestion());
+
+ }
+
+ for (Answer a : q.getAnswers().values()) {
+ if (a.getNextQuestion() != null) {
+ queue.offer(a.getNextQuestion());
+ }
+ }
+
+ // Quick Sanity check
+ if (q.getPreviousQuestion() != null) {
+ if (questions.get(q.getPreviousQuestion()) == null) {
+ throw new IllegalStateException("Haven't seen previous question??");
+ }
+ }
+ }
+
+ return questions;
+ }
+
+ private static List<AnswerPath> findHighestFromId(Map<String, Question> questions, String id) {
+ List<AnswerPath> scores = new LinkedList<>();
+ doFindHighestFromId(questions, id, scores, new AnswerPath());
+ return scores;
+ }
+
+ private static void doFindHighestFromId(Map<String, Question> questions, String id, List<AnswerPath> scores, AnswerPath path) {
+ if (id == null) {
+ // End of the road! Save the score and return.
+ scores.add(path);
+ return;
+ }
+
+ Question q = questions.get(id);
+
+ // Find the best answer following this path and find other paths.
+ Score maxScore = path.mScore;
+ double max = 0;
+
+ int answerCount = 1;
+ for (Map.Entry<String, Answer> entry : q.getAnswers().entrySet()) {
+ Answer a = entry.getValue();
+ RecordedAnswer userAnswer = new RecordedAnswer();
+
+ if (q.getType() == Question.QuestionType.SLIDER) {
+ // Special Case
+ userAnswer.setAnswerId(String.valueOf((float) answerCount / q.getAnswers().size()));
+
+ } else {
+ userAnswer.setAnswerId(entry.getKey());
+ }
+
+ Score tempScore = new Score(path.mScore); // Always start with the initial score.
+ boolean endOfRoad = !q.scoreAnswer(tempScore, userAnswer);
+ double thisScore = tempScore.getSum() - path.mScore.getSum();
+
+ if (endOfRoad) {
+ // End of Road is a fork too. Record and pick another answer.
+ AnswerPath fork = new AnswerPath(path);
+ fork.update(id, tempScore);
+ scores.add(fork);
+
+ } else if (a.getNextQuestion() != null) {
+ // Found a new path, follow it.
+ // Remember to count this answer in the score.
+ AnswerPath fork = new AnswerPath(path);
+ fork.update(id, tempScore);
+ doFindHighestFromId(questions, a.getNextQuestion(), scores, fork);
+
+ } else if (thisScore > max) {
+ // Found a higher option that isn't a new path.
+ maxScore = tempScore;
+ max = thisScore;
+ }
+
+ answerCount++;
+ }
+
+ path.update(id, maxScore);
+ doFindHighestFromId(questions, q.getNextQuestion(), scores, path);
+ }
+
+ private static class FileQuestionProvider extends JsonEncodedProvider<Question> implements Provider<String, Question> {
+ private String mBaseDir;
+
+ public FileQuestionProvider(String directory) {
+ super(Question.class);
+ mBaseDir = directory;
+ }
+
+ @Override
+ public Question get(String key) throws IOException {
+ Path qfile = FileSystems.getDefault().getPath(mBaseDir, key + ".json");
+ byte[] blob = Files.readAllBytes(qfile);
+ return decode(new String(blob));
+ }
+
+ @Override
+ public void put(String key, Question obj) throws IOException {
+ throw new UnsupportedOperationException("Not Implemented");
+ }
+ }
+
+ private static class AnswerPath {
+ String mPath;
+ String mScores;
+ Score mScore;
+
+ public AnswerPath() {
+ mPath = null;
+ mScores = null;
+ mScore = new Score();
+ }
+
+ public AnswerPath(AnswerPath other) {
+ mPath = other.mPath;
+ mScores = other.mScores;
+ mScore = other.mScore;
+ }
+
+ public void update(String questionId, Score newScore) {
+ String value;
+
+ if (mScore.getCount() == newScore.getCount()) {
+ value = "n/a";
+
+ } else {
+ double delta = newScore.getSum() - mScore.getSum();
+ if (delta < 0) {
+ value = "TRUMP";
+ } else {
+ value = String.valueOf(delta);
+ }
+ }
+
+ if (mPath == null) {
+ mPath = questionId;
+ mScores = value;
+
+ } else {
+ mPath += ", " + questionId;
+ mScores += " + " + value;
+ }
+
+ mScore = newScore;
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/grow/tools/AttributeBackfillTool.java b/src/main/java/com/p4square/grow/tools/AttributeBackfillTool.java
new file mode 100644
index 0000000..d7fd2ff
--- /dev/null
+++ b/src/main/java/com/p4square/grow/tools/AttributeBackfillTool.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.tools;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.data.Protocol;
+
+import com.p4square.f1oauth.Attribute;
+import com.p4square.f1oauth.F1API;
+import com.p4square.f1oauth.F1Access;
+import com.p4square.f1oauth.F1Exception;
+import com.p4square.restlet.oauth.OAuthUser;
+
+import com.p4square.grow.backend.dynamo.DynamoDatabase;
+import com.p4square.grow.backend.dynamo.DynamoKey;
+
+import com.p4square.grow.config.Config;
+
+import com.p4square.grow.model.Chapter;
+import com.p4square.grow.model.Playlist;
+import com.p4square.grow.model.TrainingRecord;
+import com.p4square.grow.model.VideoRecord;
+import com.p4square.grow.provider.JsonEncodedProvider;
+
+/**
+ * This utility is used to backfill F1 Attributes from the GROW database into F1.
+ *
+ * This tool currently reads from Dynamo directly. It should probably access the
+ * backend or use the {@link com.p4square.grow.backend.GrowData} abstraction instead.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AttributeBackfillTool {
+
+ private static Config mConfig;
+ private static F1API mF1API;
+ private static DynamoDatabase mDatabase;
+
+ public static void usage() {
+ System.out.println("java com.p4square.grow.tools.AttributeBackfillTool <command>...\n");
+ System.out.println("Commands:");
+ System.out.println("\t--domain <domain> Set config domain");
+ System.out.println("\t--dev Set config domain to dev");
+ System.out.println("\t--config <file> Merge in config file");
+ System.out.println("\t--assessments Backfill All Assessments");
+ System.out.println("\t--training Backfill All Training Records");
+ }
+
+ public static void main(String... args) {
+ if (args.length == 0) {
+ usage();
+ System.exit(1);
+ }
+
+ mConfig = new Config();
+
+ try {
+ mConfig.updateConfig(AttributeTool.class.getResourceAsStream("/grow.properties"));
+
+ int offset = 0;
+ while (offset < args.length) {
+ if ("--domain".equals(args[offset])) {
+ mConfig.setDomain(args[offset + 1]);
+ mF1API = null;
+ mDatabase = null;
+ offset += 2;
+
+ } else if ("--dev".equals(args[offset])) {
+ mConfig.setDomain("dev");
+ mF1API = null;
+ mDatabase = null;
+ offset += 1;
+
+ } else if ("--config".equals(args[offset])) {
+ mConfig.updateConfig(args[offset + 1]);
+ mF1API = null;
+ mDatabase = null;
+ offset += 2;
+
+ } else if ("--assessments".equals(args[offset])) {
+ offset = assessments(args, ++offset);
+
+ } else if ("--training".equals(args[offset])) {
+ offset = training(args, ++offset);
+
+ } else {
+ throw new IllegalArgumentException("Unknown command " + args[offset]);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ System.exit(2);
+ }
+ }
+
+ private static F1API getF1API() throws Exception {
+ if (mF1API == null) {
+ Context context = new Context();
+ Client client = new Client(context, Arrays.asList(Protocol.HTTP, Protocol.HTTPS));
+ context.setClientDispatcher(client);
+
+ F1Access f1Access = new F1Access(context,
+ mConfig.getString("f1ConsumerKey"),
+ mConfig.getString("f1ConsumerSecret"),
+ mConfig.getString("f1BaseUrl"),
+ mConfig.getString("f1ChurchCode"),
+ F1Access.UserType.WEBLINK);
+
+ // Gather Username and Password
+ String username = System.console().readLine("F1 Username: ");
+ char[] password = System.console().readPassword("F1 Password: ");
+
+ OAuthUser user = f1Access.getAccessToken(username, new String(password));
+ Arrays.fill(password, ' '); // Lost cause, but I'll still try.
+
+ mF1API = f1Access.getAuthenticatedApi(user);
+ }
+
+ return mF1API;
+ }
+
+ private static DynamoDatabase getDatabase() {
+ if (mDatabase == null) {
+ mDatabase = new DynamoDatabase(mConfig);
+ }
+
+ return mDatabase;
+ }
+
+ private static int assessments(String[] args, int offset) throws Exception {
+ final F1API f1 = getF1API();
+ final DynamoDatabase db = getDatabase();
+
+ DynamoKey key = DynamoKey.newKey("assessments", null);
+
+ while (key != null) {
+ Map<DynamoKey, Map<String, String>> rows = db.getAll(key);
+
+ key = null;
+
+ for (Map.Entry<DynamoKey, Map<String, String>> row : rows.entrySet()) {
+ key = row.getKey();
+
+ String userId = key.getHashKey();
+
+ String summaryString = row.getValue().get("summary");
+ if (summaryString == null || summaryString.length() == 0) {
+ System.out.printf("%s assessment incomplete\n", userId);
+ continue;
+ }
+
+ try {
+ Map summary = JsonEncodedProvider.MAPPER.readValue(summaryString, Map.class);
+
+ String result = (String) summary.get("result");
+ if (result == null) {
+ System.out.printf("%s assessment incomplete\n", userId);
+ continue;
+ }
+
+ String attributeName = "Assessment Complete - " + result;
+
+ // Check if the user already has the attribute.
+ List<Attribute> attributes = f1.getAttribute(userId, attributeName);
+
+ if (attributes.size() == 0) {
+ Attribute attribute = new Attribute(attributeName);
+ attribute.setStartDate(new Date());
+ attribute.setComment(summaryString);
+
+ if (f1.addAttribute(userId, attribute)) {
+ System.out.printf("%s attribute added\n", userId);
+ } else {
+ System.out.printf("%s failed to add attribute\n", userId);
+ }
+ } else {
+ System.out.printf("%s already has attribute\n", userId);
+ }
+ } catch (Exception e) {
+ System.out.printf("%s exception: %s\n", userId, e.getMessage());
+ }
+ }
+ }
+
+ return offset;
+ }
+
+ private static int training(String[] args, int offset) throws Exception {
+ final F1API f1 = getF1API();
+ final DynamoDatabase db = getDatabase();
+
+ DynamoKey key = DynamoKey.newKey("training", null);
+
+ while (key != null) {
+ Map<DynamoKey, Map<String, String>> rows = db.getAll(key);
+
+ key = null;
+
+ for (Map.Entry<DynamoKey, Map<String, String>> row : rows.entrySet()) {
+ key = row.getKey();
+
+ String userId = key.getHashKey();
+
+ String valueString = row.getValue().get("value");
+ if (valueString == null || valueString.length() == 0) {
+ System.out.printf("%s empty training record\n", userId);
+ continue;
+ }
+
+ try {
+ TrainingRecord record =
+ JsonEncodedProvider.MAPPER.readValue(valueString, TrainingRecord.class);
+ Playlist playlist = record.getPlaylist();
+
+chapters:
+ for (Map.Entry<String, Chapter> entry : playlist.getChaptersMap().entrySet()) {
+ Chapter chapter = entry.getValue();
+
+ // Find completion date
+ Date complete = new Date(0);
+ for (VideoRecord vr : chapter.getVideos().values()) {
+ if (!vr.getComplete()) {
+ continue chapters;
+ }
+
+ Date recordCompletion = vr.getCompletionDate();
+ if (recordCompletion != null && complete.before(recordCompletion)) {
+ complete = vr.getCompletionDate();
+ }
+ }
+
+ String attributeName = "Training Complete - " + entry.getKey();
+
+ // Check if the user already has the attribute.
+ List<Attribute> attributes = f1.getAttribute(userId, attributeName);
+
+ if (attributes.size() == 0) {
+ Attribute attribute = new Attribute(attributeName);
+ attribute.setStartDate(complete);
+
+ if (f1.addAttribute(userId, attribute)) {
+ System.out.printf("%s added %s\n", userId, attributeName);
+ } else {
+ System.out.printf("%s failed to add %s\n", userId, attributeName);
+ }
+ } else {
+ System.out.printf("%s already has %s\n", userId, attributeName);
+ }
+ }
+
+ } catch (Exception e) {
+ System.out.printf("%s exception: %s\n", userId, e.getMessage());
+ e.printStackTrace();
+ }
+ }
+ }
+
+ return offset;
+ }
+}
diff --git a/src/main/java/com/p4square/grow/tools/AttributeTool.java b/src/main/java/com/p4square/grow/tools/AttributeTool.java
new file mode 100644
index 0000000..8e0540a
--- /dev/null
+++ b/src/main/java/com/p4square/grow/tools/AttributeTool.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.grow.tools;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.data.Protocol;
+
+import com.p4square.grow.config.Config;
+import com.p4square.f1oauth.Attribute;
+import com.p4square.f1oauth.F1Access;
+import com.p4square.f1oauth.F1API;
+import com.p4square.f1oauth.F1Exception;
+import com.p4square.restlet.oauth.OAuthUser;
+
+/**
+ * Tool for manipulating F1 Attributes.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class AttributeTool {
+
+ private static Config mConfig;
+ private static F1API mF1API;
+
+ public static void usage() {
+ System.out.println("java com.p4square.grow.tools.AttributeTool <command>...\n");
+ System.out.println("Commands:");
+ System.out.println("\t--domain <domain> Set config domain");
+ System.out.println("\t--dev Set config domain to dev");
+ System.out.println("\t--config <file> Merge in config file");
+ System.out.println("\t--list List all attributes");
+ System.out.println("\t--assign <userId> <attribute> <comment> Assign an attribute");
+ System.out.println("\t--getall <userId> Get an attribute");
+ System.out.println("\t--get <userId> <attribute> Get an attribute");
+ }
+
+ public static void main(String... args) {
+ if (args.length == 0) {
+ usage();
+ System.exit(1);
+ }
+
+ mConfig = new Config();
+
+ try {
+ mConfig.updateConfig(AttributeTool.class.getResourceAsStream("/grow.properties"));
+
+ int offset = 0;
+ while (offset < args.length) {
+ if ("--domain".equals(args[offset])) {
+ mConfig.setDomain(args[offset + 1]);
+ mF1API = null;
+ offset += 2;
+
+ } else if ("--dev".equals(args[offset])) {
+ mConfig.setDomain("dev");
+ mF1API = null;
+ offset += 1;
+
+ } else if ("--config".equals(args[offset])) {
+ mConfig.updateConfig(args[offset + 1]);
+ mF1API = null;
+ offset += 2;
+
+ } else if ("--list".equals(args[offset])) {
+ offset = list(args, ++offset);
+
+ } else if ("--assign".equals(args[offset])) {
+ offset = assign(args, ++offset);
+
+ } else if ("--getall".equals(args[offset])) {
+ offset = getall(args, ++offset);
+
+ } else if ("--get".equals(args[offset])) {
+ offset = get(args, ++offset);
+
+ } else {
+ throw new IllegalArgumentException("Unknown command " + args[offset]);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ System.exit(2);
+ }
+ }
+
+ private static F1API getF1API() throws Exception {
+ if (mF1API == null) {
+ Context context = new Context();
+ Client client = new Client(context, Arrays.asList(Protocol.HTTP, Protocol.HTTPS));
+ context.setClientDispatcher(client);
+
+ F1Access f1Access = new F1Access(context,
+ mConfig.getString("f1ConsumerKey"),
+ mConfig.getString("f1ConsumerSecret"),
+ mConfig.getString("f1BaseUrl"),
+ mConfig.getString("f1ChurchCode"),
+ F1Access.UserType.WEBLINK);
+
+ // Gather Username and Password
+ String username = System.console().readLine("F1 Username: ");
+ char[] password = System.console().readPassword("F1 Password: ");
+
+ OAuthUser user = f1Access.getAccessToken(username, new String(password));
+ Arrays.fill(password, ' '); // Lost cause, but I'll still try.
+
+ mF1API = f1Access.getAuthenticatedApi(user);
+ }
+
+ return mF1API;
+ }
+
+ private static int list(String[] args, int offset) throws Exception {
+ final F1API f1 = getF1API();
+
+ final Map<String, String> attributes = f1.getAttributeList();
+ System.out.printf("%7s %s\n", "ID", "Name");
+ for (Map.Entry<String, String> entry : attributes.entrySet()) {
+ System.out.printf("%7s %s\n", entry.getValue(), entry.getKey());
+ }
+
+ return offset;
+ }
+
+ private static int assign(String[] args, int offset) throws Exception {
+ final String userId = args[offset++];
+ final String attributeName = args[offset++];
+ final String comment = args[offset++];
+
+ final F1API f1 = getF1API();
+
+ Attribute attribute = new Attribute(attributeName);
+ attribute.setStartDate(new Date());
+ attribute.setComment(comment);
+
+ if (f1.addAttribute(userId, attribute)) {
+ System.out.println("Added attribute " + attributeName + " for " + userId);
+ } else {
+ System.out.println("Failed to add attribute " + attributeName + " for " + userId);
+ }
+
+ return offset;
+ }
+
+ private static int getall(String[] args, int offset) throws Exception {
+ final String userId = args[offset++];
+
+ doGet(userId, null);
+
+ return offset;
+ }
+
+ private static int get(String[] args, int offset) throws Exception {
+ final String userId = args[offset++];
+ final String attributeName = args[offset++];
+
+ doGet(userId, attributeName);
+
+ return offset;
+ }
+
+ private static void doGet(final String userId, final String attributeName) throws Exception {
+ final F1API f1 = getF1API();
+
+ List<Attribute> attributes = f1.getAttribute(userId, attributeName);
+ for (Attribute attribute : attributes) {
+ System.out.printf("%s %s %s %s %s\n%s\n\n",
+ userId,
+ attribute.getAttributeName(),
+ attribute.getId(),
+ attribute.getStartDate(),
+ attribute.getEndDate(),
+ attribute.getComment());
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/metrics/MetricRouter.java b/src/main/java/com/p4square/restlet/metrics/MetricRouter.java
new file mode 100644
index 0000000..d4da270
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/metrics/MetricRouter.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.restlet.metrics;
+
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.routing.TemplateRoute;
+import org.restlet.routing.Router;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class MetricRouter extends Router {
+
+ private final MetricRegistry mMetricRegistry;
+
+ public MetricRouter(Context context, MetricRegistry metrics) {
+ super(context);
+ mMetricRegistry = metrics;
+ }
+
+ @Override
+ protected void doHandle(Restlet next, Request request, Response response) {
+ String baseName;
+ if (next instanceof TemplateRoute) {
+ TemplateRoute temp = (TemplateRoute) next;
+ baseName = MetricRegistry.name("MetricRouter", temp.getTemplate().getPattern());
+ } else {
+ baseName = MetricRegistry.name("MetricRouter", "unknown");
+ }
+
+ final Timer.Context aggTimer = mMetricRegistry.timer("MetricRouter.time").time();
+ final Timer.Context timer = mMetricRegistry.timer(baseName + ".time").time();
+
+ try {
+ super.doHandle(next, request, response);
+ } finally {
+ timer.stop();
+ aggTimer.stop();
+
+ // Record status code
+ boolean success = !response.getStatus().isError();
+ if (success) {
+ mMetricRegistry.counter("MetricRouter.success").inc();
+ mMetricRegistry.counter(baseName + ".response.success").inc();
+ } else {
+ mMetricRegistry.counter("MetricRouter.failure").inc();
+ mMetricRegistry.counter(baseName + ".response.failure").inc();
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/metrics/MetricsApplication.java b/src/main/java/com/p4square/restlet/metrics/MetricsApplication.java
new file mode 100644
index 0000000..6caf742
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/metrics/MetricsApplication.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.restlet.metrics;
+
+import java.util.concurrent.TimeUnit;
+
+import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.json.MetricsModule;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.restlet.Application;
+import org.restlet.Restlet;
+import org.restlet.resource.Finder;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class MetricsApplication extends Application {
+ static final ObjectMapper MAPPER;
+ static {
+ MAPPER = new ObjectMapper();
+ MAPPER.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.MILLISECONDS, true));
+ }
+
+ private final MetricRegistry mMetricRegistry;
+
+ public MetricsApplication(MetricRegistry metrics) {
+ mMetricRegistry = metrics;
+ }
+
+ public MetricRegistry getMetricRegistry() {
+ return mMetricRegistry;
+ }
+
+ @Override
+ public Restlet createInboundRoot() {
+ return new Finder(getContext(), MetricsResource.class);
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/metrics/MetricsResource.java b/src/main/java/com/p4square/restlet/metrics/MetricsResource.java
new file mode 100644
index 0000000..e2ab14d
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/metrics/MetricsResource.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2014 Jesse Morgan
+ */
+
+package com.p4square.restlet.metrics;
+
+import com.codahale.metrics.MetricRegistry;
+
+import org.restlet.ext.jackson.JacksonRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.resource.ServerResource;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class MetricsResource extends ServerResource {
+
+ private MetricRegistry mMetricRegistry;
+
+ @Override
+ public void doInit() {
+ mMetricRegistry = ((MetricsApplication) getApplication()).getMetricRegistry();
+ }
+
+ @Override
+ protected Representation get() {
+ JacksonRepresentation<MetricRegistry> rep = new JacksonRepresentation<>(mMetricRegistry);
+ rep.setObjectMapper(MetricsApplication.MAPPER);
+ return rep;
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticator.java b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticator.java
new file mode 100644
index 0000000..c33bb5a
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticator.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.restlet.oauth;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.security.Authenticator;
+import org.restlet.security.User;
+
+/**
+ * Authenticator which makes an OAuth request to authenticate the user.
+ *
+ * If this Authenticator is made optional than no requests are made to the
+ * service provider.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class OAuthAuthenticator extends Authenticator {
+ private static Logger LOG = Logger.getLogger(OAuthAuthenticator.class);
+
+ private static final String OAUTH_TOKEN = "oauth_token";
+ private static final String COOKIE_NAME = "oauth_secret";
+
+ private final OAuthHelper mHelper;
+
+ /**
+ * Create a new Authenticator.
+ *
+ * @param Context the current context.
+ * @param optional If true, unauthenticated users are allowed to continue.
+ * @param helper The OAuthHelper which will help with the requests.
+ */
+ public OAuthAuthenticator(Context context, boolean optional, OAuthHelper helper) {
+ super(context, false, optional, null);
+
+ mHelper = helper;
+ }
+
+ protected boolean authenticate(Request request, Response response) {
+ /*
+ * The authentication workflow has three steps:
+ * 1. Get RequestToken
+ * 2. Authenticate the user
+ * 3. Get AccessToken
+ *
+ * The authentication workflow is broken into two stages. In the first,
+ * we generate the RequestToken (step 1) and redirect the user to the
+ * authentication page. When the user comes back, we will request the
+ * AccessToken (step 2).
+ *
+ * We determine which half we are in by the presence of the oauth_token
+ * parameter in the query string.
+ */
+
+ final String token = request.getResourceRef().getQueryAsForm().getFirstValue(OAUTH_TOKEN);
+ final String secret = request.getCookies().getFirstValue(COOKIE_NAME);
+
+ try {
+ if (token == null) {
+ if (isOptional()) {
+ return false;
+ }
+
+ // 1. Get RequestToken
+ Token requestToken = mHelper.getRequestToken();
+
+ if (requestToken == null) {
+ return false;
+ }
+
+ // 2. Redirect user
+ // TODO Encrypt cookie
+ response.getCookieSettings().add(COOKIE_NAME, requestToken.getSecret());
+ response.redirectSeeOther(mHelper.getLoginUrl(requestToken, request.getResourceRef().toString()));
+ return false;
+
+ } else {
+ // 3. Get AccessToken
+ Token requestToken = new Token(token, secret);
+ User user = mHelper.getAccessToken(requestToken);
+ request.getClientInfo().setUser(user);
+ return true;
+ }
+
+ } catch (OAuthException e) {
+ LOG.debug("Authentication failed: " + e);
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticatorHelper.java b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticatorHelper.java
new file mode 100644
index 0000000..76ff044
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticatorHelper.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.restlet.oauth;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+
+import java.net.URLEncoder;
+
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import java.util.Collections;
+import java.util.Random;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.data.ChallengeRequest;
+import org.restlet.data.ChallengeResponse;
+import org.restlet.data.ChallengeScheme;
+import org.restlet.data.CharacterSet;
+import org.restlet.data.Form;
+import org.restlet.data.Method;
+import org.restlet.data.Parameter;
+import org.restlet.data.Reference;
+import org.restlet.engine.header.ChallengeWriter;
+import org.restlet.engine.header.Header;
+import org.restlet.engine.security.AuthenticatorHelper;
+import org.restlet.engine.util.Base64;
+import org.restlet.util.Series;
+
+/**
+ * Authentication helper for signing OAuth Requests.
+ *
+ * This implementation is limited to one consumer token/secret per restlet
+ * engine. In practice this means you will only be able to interact with one
+ * service provider unless you loaded/unloaded the AuthenticationHelper for
+ * each request.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class OAuthAuthenticatorHelper extends AuthenticatorHelper {
+ private static final String SIGNATURE_METHOD = "HMAC-SHA1";
+ private static final String JAVA_SIGNATURE_METHOD = "HmacSHA1";
+ private static final String ENCODING = "UTF-8";
+
+ private final Random mRandom;
+ private final Token mConsumerToken;
+
+ /**
+ * Package-private constructor.
+ *
+ * This class should only be instantiated by OAuthHelper.
+ */
+ OAuthAuthenticatorHelper(Token consumerToken) {
+ super(ChallengeScheme.HTTP_OAUTH, true, false);
+
+ mRandom = new Random();
+ mConsumerToken = consumerToken;
+ }
+
+ @Override
+ public void formatRequest(ChallengeWriter cw, ChallengeRequest cr,
+ Response response, Series<Header> httpHeaders) throws IOException {
+
+ throw new UnsupportedOperationException("OAuth Requests are not implemented");
+ }
+
+ @Override
+ public void formatResponse(ChallengeWriter cw, ChallengeResponse response,
+ Request request, Series<Header> httpHeaders) {
+
+ try {
+ Series<Parameter> authParams = new Series<Parameter>(Parameter.class);
+
+ String nonce = String.valueOf(mRandom.nextInt());
+ String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+
+ authParams.add(new Parameter("oauth_consumer_key", mConsumerToken.getToken()));
+ authParams.add(new Parameter("oauth_nonce", nonce));
+ authParams.add(new Parameter("oauth_signature_method", SIGNATURE_METHOD));
+ authParams.add(new Parameter("oauth_timestamp", timestamp));
+ authParams.add(new Parameter("oauth_version", "1.0"));
+
+ String accessToken = response.getIdentifier();
+ if (accessToken != null) {
+ authParams.add(new Parameter("oauth_token", accessToken));
+ }
+
+ // Generate Signature
+ String signature = generateSignature(response, request, authParams);
+ authParams.add(new Parameter("oauth_signature", signature));
+
+ // Write Header
+ for (Parameter p : authParams) {
+ cw.appendQuotedChallengeParameter(encode(p.getName()), encode(p.getValue()));
+ }
+
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+
+ } catch (InvalidKeyException e) {
+ throw new RuntimeException(e);
+
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Helper method to generate an OAuth Signature.
+ */
+ private String generateSignature(ChallengeResponse response, Request request,
+ Series<Parameter> authParams)
+ throws NoSuchAlgorithmException, InvalidKeyException, IOException,
+ UnsupportedEncodingException {
+
+ // HTTP Request Method
+ String httpMethod = request.getMethod().getName();
+
+ // Request Url
+ Reference url = request.getResourceRef();
+ String requestUrl = encode(url.getScheme() + ":" + url.getHierarchicalPart());
+
+ // Normalized parameters
+ Series<Parameter> params = new Series<Parameter>(Parameter.class);
+
+ // OAUTH Params
+ params.addAll(authParams);
+
+ // Query Params
+ Form query = url.getQueryAsForm();
+ params.addAll(query);
+
+ // Sort it
+ Collections.sort(params);
+
+ StringBuilder normalizedParamsBuilder = new StringBuilder();
+ for (Parameter p : params) {
+ normalizedParamsBuilder.append('&');
+ normalizedParamsBuilder.append(p.encode(CharacterSet.UTF_8));
+ }
+ String normalizedParams = encode(normalizedParamsBuilder.substring(1)); // remove the first &
+
+ // Generate signature base
+ String sigBase = httpMethod + "&" + requestUrl + "&" + normalizedParams.toString();
+
+ // Sign the signature base
+ Mac mac = Mac.getInstance(JAVA_SIGNATURE_METHOD);
+
+ String accessTokenSecret = "";
+ if (response.getIdentifier() != null) {
+ accessTokenSecret = new String(response.getSecret());
+ }
+
+ byte[] keyBytes = (encode(mConsumerToken.getSecret()) + "&" + encode(accessTokenSecret)).getBytes(ENCODING);
+ SecretKey key = new SecretKeySpec(keyBytes, JAVA_SIGNATURE_METHOD);
+ mac.init(key);
+
+ byte[] signature = mac.doFinal(sigBase.getBytes(ENCODING));
+
+ return Base64.encode(signature, false).trim();
+ }
+
+ /**
+ * Helper method to URL Encode Strings.
+ */
+ private String encode(String input) throws UnsupportedEncodingException {
+ return URLEncoder.encode(input, ENCODING);
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthException.java b/src/main/java/com/p4square/restlet/oauth/OAuthException.java
new file mode 100644
index 0000000..dd326d3
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/oauth/OAuthException.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.restlet.oauth;
+
+import org.restlet.data.Status;
+
+/**
+ * Exception throw when the service provider returns an error.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class OAuthException extends Exception {
+ private final Status mStatus;
+
+ public OAuthException(Status status) {
+ super("Service provider failed request: " + status.getDescription());
+ mStatus = status;
+ }
+
+ public Status getStatus() {
+ return mStatus;
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthHelper.java b/src/main/java/com/p4square/restlet/oauth/OAuthHelper.java
new file mode 100644
index 0000000..67dd238
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/oauth/OAuthHelper.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.restlet.oauth;
+
+import java.net.URLEncoder;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.data.ChallengeResponse;
+import org.restlet.data.ChallengeScheme;
+import org.restlet.data.Form;
+import org.restlet.data.Method;
+import org.restlet.data.Reference;
+import org.restlet.data.Status;
+import org.restlet.engine.Engine;
+import org.restlet.representation.Representation;
+
+/**
+ * Helper Class for OAuth 1.0 Authentication.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public abstract class OAuthHelper {
+ private final Restlet mDispatcher;
+ private final Token mConsumerToken;
+
+ /**
+ * Create a new OAuth Helper.
+ * As currently implemented, there can only be one OAuthHelper per Restlet
+ * Engine since this class registers its own provider for the OAuth
+ * authentication protocol.
+ *
+ * FIXME: This could be improved by making OAuthAuthenticationHelper and
+ * maybe Token aware of multiple service providers.
+ *
+ * @param context The restlet context which provides a ClientDispatcher.
+ * @param consumerKey The OAuth consumer key for this application.
+ * @param consumerSecret the OAuth consumer secret for this application.
+ */
+ public OAuthHelper(Context context, String consumerKey, String consumerSecret) {
+ mDispatcher = context.getClientDispatcher();
+ mConsumerToken = new Token(consumerKey, consumerSecret);
+
+ Engine.getInstance().getRegisteredAuthenticators().add(new OAuthAuthenticatorHelper(mConsumerToken));
+ }
+
+ /**
+ * @return the URL for the initial RequestToken request.
+ */
+ protected abstract String getRequestTokenUrl();
+
+ /**
+ * Request a RequestToken.
+ *
+ * @return a Token containing the RequestToken.
+ * @throws OAuthException if the request fails.
+ */
+ public Token getRequestToken() throws OAuthException {
+ Request request = new Request(Method.GET, getRequestTokenUrl());
+ request.setChallengeResponse(new ChallengeResponse(ChallengeScheme.HTTP_OAUTH));
+
+ Response response = mDispatcher.handle(request);
+
+ return processTokenRequest(response);
+ }
+
+ /**
+ * @return the URL to redirect the user to for Authentication.
+ */
+ public abstract String getLoginUrl(Token requestToken, String callback);
+
+ /**
+ * @return the URL for the AccessToken request.
+ */
+ protected abstract String getAccessTokenUrl();
+
+ /**
+ * Request an AccessToken for a previously authenticated RequestToken.
+ *
+ * @return an OAuthUser object containing the AccessToken.
+ * @throws OAuthException if the request fails.
+ */
+ public OAuthUser getAccessToken(Token requestToken) throws OAuthException {
+ Request request = new Request(Method.GET, getAccessTokenUrl());
+ request.setChallengeResponse(requestToken.getChallengeResponse());
+
+ return processAccessTokenRequest(request);
+ }
+
+ /**
+ * Helper method to decode the token returned from an OAuth Request.
+ *
+ * @param response The Response object from the Request.
+ * @return the Token from the oauth_token and oauth_token_secret parameters.
+ * @throws OAuthException is the server reported an error.
+ */
+ protected Token processTokenRequest(Response response) throws OAuthException {
+ Status status = response.getStatus();
+ Representation entity = response.getEntity();
+
+ try {
+ if (status.isSuccess()) {
+ Form form = new Form(entity);
+ String token = form.getFirstValue("oauth_token");
+ String secret = form.getFirstValue("oauth_token_secret");
+
+ return new Token(token, secret);
+
+ } else {
+ throw new OAuthException(status);
+ }
+ } finally {
+ entity.release();
+ }
+ }
+
+ /**
+ * Helper method to create an OAuthUser from the AccessToken request.
+ *
+ * The User's identifier is set to the Content-Location header, if present.
+ *
+ * @param response The Response to the AccessToken Request.
+ * @return An OAuthUser object wrapping the AccessToken.
+ * @throws OAuthException if the request failed.
+ */
+ public OAuthUser processAccessTokenRequest(Request request) throws OAuthException {
+ Response response = getResponse(request);
+ Token accessToken = processTokenRequest(response);
+
+ Reference ref = response.getEntity().getLocationRef();
+ if (ref != null) {
+ return new OAuthUser(ref.toString(), accessToken);
+
+ } else {
+ return new OAuthUser(accessToken);
+ }
+ }
+
+ /**
+ * Helper method to get a Response for a Request.
+ */
+ public Response getResponse(Request request) {
+ return mDispatcher.handle(request);
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthUser.java b/src/main/java/com/p4square/restlet/oauth/OAuthUser.java
new file mode 100644
index 0000000..11dbac1
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/oauth/OAuthUser.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.restlet.oauth;
+
+import org.restlet.data.ChallengeResponse;
+import org.restlet.security.User;
+
+/**
+ * Simple User object which also contains an OAuth AccessToken.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class OAuthUser extends User {
+ private final Token mToken;
+ private final String mContentLocation;
+
+ public OAuthUser(Token token) {
+ this(null, token);
+ }
+
+ public OAuthUser(String location, Token token) {
+ super();
+ mToken = token;
+ mContentLocation = location;
+ }
+
+ /**
+ * @return the Location associated with the user.
+ */
+ public String getLocation() {
+ return mContentLocation;
+ }
+
+ /**
+ * @return The AccessToken.
+ */
+ public Token getToken() {
+ return mToken;
+ }
+
+ /**
+ * Convenience method for getToken().getChallengeResponse().
+ * @return A ChallengeResponse based upon the access token.
+ */
+ public ChallengeResponse getChallengeResponse() {
+ return mToken.getChallengeResponse();
+ }
+}
diff --git a/src/main/java/com/p4square/restlet/oauth/Token.java b/src/main/java/com/p4square/restlet/oauth/Token.java
new file mode 100644
index 0000000..51a9087
--- /dev/null
+++ b/src/main/java/com/p4square/restlet/oauth/Token.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.restlet.oauth;
+
+import org.restlet.data.ChallengeResponse;
+import org.restlet.data.ChallengeScheme;
+
+/**
+ * Token wraps the two Strings which make up an OAuth Token: the public
+ * component and the private component.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Token {
+ private final String mToken;
+ private final String mSecret;
+
+ public Token(String token, String secret) {
+ mToken = token;
+ mSecret = secret;
+ }
+
+ /**
+ * @return the public component.
+ */
+ public String getToken() {
+ return mToken;
+ }
+
+ /**
+ * @return the secret component.
+ */
+ public String getSecret() {
+ return mSecret;
+ }
+
+ @Override
+ public String toString() {
+ return mToken + "&" + mSecret;
+ }
+
+ /**
+ * Generate a ChallengeResponse based on this Token.
+ *
+ * @return a ChallengeResponse object using the OAUTH ChallengeScheme.
+ */
+ public ChallengeResponse getChallengeResponse() {
+ return new ChallengeResponse(ChallengeScheme.HTTP_OAUTH, mToken, mSecret);
+ }
+}
diff --git a/src/main/java/com/p4square/session/Session.java b/src/main/java/com/p4square/session/Session.java
new file mode 100644
index 0000000..1bb65f5
--- /dev/null
+++ b/src/main/java/com/p4square/session/Session.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.session;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import org.restlet.security.User;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Session {
+ static final long LIFETIME = 86400000;
+
+ private final String mSessionId;
+ private final User mUser;
+ private final Map<String, String> mData;
+ private long mExpires;
+
+ Session(User user) {
+ mUser = user;
+ mSessionId = UUID.randomUUID().toString();
+ mExpires = System.currentTimeMillis() + LIFETIME;
+ mData = new HashMap<String, String>();
+ }
+
+ void touch() {
+ mExpires = System.currentTimeMillis() + LIFETIME;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() > mExpires;
+ }
+
+ public String getId() {
+ return mSessionId;
+ }
+
+ public Object get(String key) {
+ return mData.get(key);
+ }
+
+ public void put(String key, String value) {
+ mData.put(key, value);
+ }
+
+ public User getUser() {
+ return mUser;
+ }
+
+ public Map<String, String> getMap() {
+ return mData;
+ }
+}
diff --git a/src/main/java/com/p4square/session/SessionAuthenticator.java b/src/main/java/com/p4square/session/SessionAuthenticator.java
new file mode 100644
index 0000000..794e1a8
--- /dev/null
+++ b/src/main/java/com/p4square/session/SessionAuthenticator.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.session;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.security.Authenticator;
+import org.restlet.security.User;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SessionAuthenticator /*extends Authenticator*/ {
+ /*
+ @Override
+ protected boolean authenticate(Request request, Response response) {
+ // Check for authentication cookie
+ final String cookie = request.getCookies().getFirstValue(COOKIE_NAME);
+ if (cookie != null) {
+ cLog.debug("Got cookie: " + cookie);
+ // TODO Decrypt user info
+ User user = new User(cookie);
+ request.getClientInfo().setUser(user);
+ return true;
+ }
+
+ // Challenge the user if not authenticated
+ response.redirectSeeOther(mLoginPage);
+ return false;
+ }
+ */
+}
diff --git a/src/main/java/com/p4square/session/SessionCheckingAuthenticator.java b/src/main/java/com/p4square/session/SessionCheckingAuthenticator.java
new file mode 100644
index 0000000..489d6a0
--- /dev/null
+++ b/src/main/java/com/p4square/session/SessionCheckingAuthenticator.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.session;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.security.Authenticator;
+
+/**
+ * Authenticator which succeeds if a valid Session exists.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SessionCheckingAuthenticator extends Authenticator {
+ private static final Logger LOG = Logger.getLogger(SessionCheckingAuthenticator.class);
+
+ public SessionCheckingAuthenticator(Context context, boolean optional) {
+ super(context, optional);
+ }
+
+ protected boolean authenticate(Request request, Response response) {
+ Session s = Sessions.getInstance().get(request);
+
+ if (s != null) {
+ LOG.debug("Found session for user " + s.getUser());
+ request.getClientInfo().setUser(s.getUser());
+ return true;
+
+ } else {
+ return false;
+ }
+ }
+
+}
diff --git a/src/main/java/com/p4square/session/SessionCookieAuthenticator.java b/src/main/java/com/p4square/session/SessionCookieAuthenticator.java
new file mode 100644
index 0000000..0074b77
--- /dev/null
+++ b/src/main/java/com/p4square/session/SessionCookieAuthenticator.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.session;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.security.Authenticator;
+
+/**
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SessionCookieAuthenticator extends Authenticator {
+ private static final Logger LOG = Logger.getLogger(SessionCookieAuthenticator.class);
+
+ private static final String COOKIE_NAME = "S";
+
+ private final Sessions mSessions;
+
+ public SessionCookieAuthenticator(Context context, boolean optional, Sessions sessions) {
+ super(context, optional);
+
+ mSessions = sessions;
+ }
+
+ protected boolean authenticate(Request request, Response response) {
+ final String cookie = request.getCookies().getFirstValue(COOKIE_NAME);
+
+ if (request.getClientInfo().isAuthenticated()) {
+ // Request is already authenticated... create session if it doesn't exist.
+ if (cookie == null) {
+ Session s = mSessions.create(request.getClientInfo().getUser());
+ response.getCookieSettings().add(COOKIE_NAME, s.getId());
+ }
+
+ return true;
+
+ } else {
+ // Check for authentication cookie
+ if (cookie != null) {
+ LOG.debug("Got cookie: " + cookie);
+
+ Session s = mSessions.get(cookie);
+ if (s != null) {
+ request.getClientInfo().setUser(s.getUser());
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+}
diff --git a/src/main/java/com/p4square/session/SessionCreatingAuthenticator.java b/src/main/java/com/p4square/session/SessionCreatingAuthenticator.java
new file mode 100644
index 0000000..3ec14b4
--- /dev/null
+++ b/src/main/java/com/p4square/session/SessionCreatingAuthenticator.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.session;
+
+import org.apache.log4j.Logger;
+
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.security.Authenticator;
+import org.restlet.security.User;
+
+/**
+ * Authenticator which creates a Session for the request and adds a cookie
+ * to the response.
+ *
+ * The Request MUST be Authenticated and MUST have a User object associated.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class SessionCreatingAuthenticator extends Authenticator {
+ private static final Logger LOG = Logger.getLogger(SessionCreatingAuthenticator.class);
+
+ public SessionCreatingAuthenticator(Context context) {
+ super(context, true);
+ }
+
+ protected boolean authenticate(Request request, Response response) {
+ if (Sessions.getInstance().get(request) != null) {
+ return true;
+ }
+
+ User user = request.getClientInfo().getUser();
+
+ if (request.getClientInfo().isAuthenticated() && user != null) {
+ Sessions.getInstance().create(request, response);
+ LOG.debug(response);
+ return true;
+ }
+
+ return false;
+ }
+
+}
diff --git a/src/main/java/com/p4square/session/Sessions.java b/src/main/java/com/p4square/session/Sessions.java
new file mode 100644
index 0000000..9f9dda0
--- /dev/null
+++ b/src/main/java/com/p4square/session/Sessions.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2013 Jesse Morgan
+ */
+
+package com.p4square.session;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
+import java.util.Timer;
+import java.util.TimerTask;
+
+import org.restlet.Response;
+import org.restlet.Request;
+import org.restlet.data.CookieSetting;
+import org.restlet.security.User;
+
+/**
+ * Singleton Session Manager.
+ *
+ * @author Jesse Morgan <jesse@jesterpm.net>
+ */
+public class Sessions {
+ private static final String COOKIE_NAME = "S";
+ private static final int DELETE = 0;
+
+ private static final Sessions THE = new Sessions();
+ public static Sessions getInstance() {
+ return THE;
+ }
+
+ private final Map<String, Session> mSessions;
+ private final Timer mCleanupTimer;
+
+ private Sessions() {
+ mSessions = new ConcurrentHashMap<String, Session>();
+
+ mCleanupTimer = new Timer("sessionCleaner", true);
+ mCleanupTimer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ for (Session s : mSessions.values()) {
+ if (s.isExpired()) {
+ mSessions.remove(s.getId());
+ }
+ }
+ }
+ }, Session.LIFETIME, Session.LIFETIME);
+ }
+
+ /**
+ * Get a session by ID.
+ *
+ * @param sessionid
+ * The Session id
+ * @return The Session if found and not expired, null otherwise.
+ */
+ public Session get(String sessionid) {
+ Session s = mSessions.get(sessionid);
+
+ if (s != null && !s.isExpired()) {
+ s.touch();
+ return s;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the Session associated with the Request.
+ *
+ * @param request
+ * The request to fetch a session for.
+ * @return A session or null if no session is found.
+ */
+ public Session get(Request request) {
+ final String cookie = request.getCookies().getFirstValue(COOKIE_NAME);
+
+ if (cookie != null) {
+ return get(cookie);
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a new Session for the given User object.
+ *
+ * @param user
+ * The User to associate with the Session.
+ * @return The new Session object.
+ */
+ public Session create(User user) {
+ if (user == null) {
+ throw new IllegalArgumentException("Can not create session for null user.");
+ }
+
+ Session s = new Session(user);
+ mSessions.put(s.getId(), s);
+
+ return s;
+ }
+
+ /**
+ * Delete a Session.
+ *
+ * @param sessionid
+ * The id of the Session to remove.
+ */
+ public void delete(String sessionid) {
+ mSessions.remove(sessionid);
+ }
+
+ /**
+ * Create a new Session and add the Session cookie to the response.
+ *
+ * @param request
+ * The request to create the Session for.
+ * @param response
+ * The response to add the session cookie to.
+ * @return The new Session.
+ */
+ public Session create(Request request, Response response) {
+ Session s = create(request.getClientInfo().getUser());
+
+ CookieSetting cookie = new CookieSetting(COOKIE_NAME, s.getId());
+ cookie.setPath("/");
+
+ request.getCookies().add(cookie);
+ response.getCookieSettings().add(cookie);
+
+ return s;
+ }
+
+ /**
+ * Remove a Session and delete the cookies.
+ *
+ * @param request
+ * The request with the session cookie to remove
+ * @param response
+ * The response to remove the session cookie from.
+ */
+ public void delete(Request request, Response response) {
+ final String sessionid = request.getCookies().getFirstValue(COOKIE_NAME);
+
+ delete(sessionid);
+
+ CookieSetting cookie = new CookieSetting(COOKIE_NAME, "");
+ cookie.setPath("/");
+ cookie.setMaxAge(DELETE);
+
+ request.getCookies().add(cookie);
+ response.getCookieSettings().add(cookie);
+ }
+
+}