diff --git a/functional_tests.py b/functional_tests.py
index 36095b4..4001adf 100644
--- a/functional_tests.py
+++ b/functional_tests.py
@@ -24,11 +24,12 @@ def setUp(self):
def login(self):
"""Creates a user, logs in, and returns the user."""
- user = models.user.User()
+ user = models.user.User(id='12345',
+ email='test@codethechange.org')
user.put()
self.testbed.setup_env(
USER_EMAIL='test@codethechange.org',
- USER_ID=str(user.key.id()),
+ USER_ID=user.key.id(),
overwrite=True)
return user
@@ -86,7 +87,7 @@ def test_post_edit_project(self):
def test_only_creator_can_edit_project(self):
self.login()
- other_user = models.user.User()
+ other_user = models.user.User(email='anotheruser@codethechange.org')
other_user.put()
project = model_helpers.create_project(owner_key=other_user.key)
project_id = project.key.id()
@@ -99,6 +100,43 @@ def test_display_project(self):
self.assertIn('hello', project_page.body)
self.assertIn('world', project_page.body)
+ def test_project_page_includes_counts(self):
+ user_key = self.login().key
+ project = model_helpers.create_project('hello', 'world')
+ project_id = project.key.id()
+ #Check to see that the count is present and 0.
+ project_page = self.testapp.get('/project/%d' % project_id, status=200)
+ self.assertRegexpMatches(
+ project_page.body,
+ 'id="numbers".*\n.*
0
.*\n.*People Involved')
+ #Add a collaborator and check to see the count increments.
+ collab = models.collaborator.Collaborator(
+ user_key=user_key, parent=project.key)
+ collab.put()
+ project_page = self.testapp.get('/project/%d' % project_id, status=200)
+ self.assertRegexpMatches(
+ project_page.body,
+ 'id="numbers".*\n.*1
.*\n.*People Involved')
+
+
+ def test_only_show_emails_of_collaborators_to_other_collaborators(self):
+ project = model_helpers.create_project('hello', 'world')
+ project_id = project.key.id()
+ project_page = self.testapp.get('/project/%d' % project_id, status=200)
+ self.assertNotIn('email', project_page.body)
+ self.assertNotIn('@', project_page.body)
+ self.assertIn('Login to', project_page.body)
+ #Now, make the user a collaborator and verify the emails are present.
+ user_key = self.login().key
+ collab = models.collaborator.Collaborator(
+ user_key=user_key, parent=project.key)
+ collab.put()
+ project_page = self.testapp.get('/project/%d' % project_id, status=200)
+ self.assertIn('email', project_page.body)
+ self.assertIn('@', project_page.body)
+
+
+
def test_get_main_page(self):
main_page = self.testapp.get('/', status=200)
self.assertIn('Code the Change', main_page.body)
@@ -126,5 +164,54 @@ def test_login_required(self):
self.assertIn('Login', page.location)
+
+
+ def test_display_user(self):
+ self.login()
+ profile = models.user.User(
+ id='123',
+ email='testprofile@codethechange.org',
+ biography='i am awesome',
+ website='github!'
+ )
+ profile.put()
+ profile_id = profile.key.id()
+ page = self.testapp.get('/user/%d' % int(profile_id), status=200)
+ self.assertIn('i am awesome', page.body)
+ self.assertIn('github!', page.body)
+ self.assertNotIn('Secondary Contact', page.body)
+ self.assertNotIn('Edit', page.body)
+
+ def test_edit_user(self):
+ user_profile = self.login()
+ profile_id = user_profile.key.id()
+ page = self.testapp.get('/user/%d' % int(profile_id), status=200)
+ self.assertIn('test@codethechange.org', page.body)
+ self.assertIn('Edit', page.body)
+
+ def test_post_edit_user(self):
+ user_profile = self.login()
+ user_id = user_profile.key.id()
+ response = self.testapp.post(
+ '/user/%d/edit' % int(user_id),
+ {'biography': 'i can edit'}
+ , status=302
+ )
+ self.assertTrue(response.location.endswith('/%d' % int(user_id)))
+ edited_profile = user_profile.key.get()
+ self.assertEqual(edited_profile.email, 'test@codethechange.org')
+ self.assertEqual(edited_profile.biography, 'i can edit')
+
+ def test_only_owner_can_edit_profile(self):
+ self.login()
+ other_profile = models.user.User(
+ id='222222',
+ email='testprofile@codethechange.org',
+ biography='i am awesome',
+ website='github!'
+ ).put()
+ other_id = other_profile.id()
+ self.testapp.post('/user/%d/edit' % int(other_id), status=403)
+
if __name__ == '__main__':
unittest.main()
diff --git a/handlers.py b/handlers.py
index 9d7962e..b82c39b 100644
--- a/handlers.py
+++ b/handlers.py
@@ -25,8 +25,9 @@ class MainPage(BaseHandler):
def get(self):
"""Renders the main landing page in response to a GET request."""
values = {
+ #TODO: initialize login and logout urls in base handler?
'login_url': users.create_login_url('/dashboard'),
- 'logout_url': users.create_logout_url('/')
+ 'logout_url': generate_logout_url()
}
self.response.write(templates.render('main.html', values))
@@ -41,34 +42,105 @@ def get(self):
owned_projects = models.project.get_by_owner(user_key)
contributing_projects = models.collaborator.get_projects(user_key)
values = {
- 'logout_url': users.create_logout_url('/'),
+ 'logout_url': generate_logout_url(),
'own': owned_projects,
'contributing': contributing_projects
}
self.response.write(templates.render('dashboard.html', values))
+class DisplayUser(BaseHandler):
+ """The handler for displaying a user page."""
+
+ def get(self, user_id):
+ """Renders the user page in response to a GET request."""
+ self.require_login()
+ requesting_user_id = models.user.get_current_user_key().id()
+ profile_object = models.user.User.get_by_id(user_id)
+ is_profile_owner = (user_id == requesting_user_id)
+ edit_link = None
+ if is_profile_owner:
+ edit_link = self.uri_for(EditUser, user_id=user_id)
+ values = {
+ 'profile': profile_object,
+ 'edit_link': edit_link,
+ 'logout_url': generate_logout_url()
+ }
+ self.response.write(templates.render('display_user.html', values))
+
+
+class EditUser(BaseHandler):
+ """The handler for editing a user profile."""
+
+ def require_owner(self, user_id):
+ """Aborts the current request if the user isn't the profile's owner."""
+ current_user_id = models.user.get_current_user_key().id()
+ if current_user_id != user_id:
+ self.abort(403)
+
+ def get(self, user_id):
+ """Renders the edit user page in response to a GET request."""
+ self.require_login()
+ self.require_owner(user_id)
+ profile_object = models.user.User.get_by_id(user_id)
+ edit_link = self.uri_for(EditUser, user_id=user_id)
+ values = {
+ 'profile': profile_object,
+ 'action_link': edit_link,
+ 'action': 'Update',
+ 'logout_url': generate_logout_url()}
+ self.response.write(templates.render('edit_user.html', values))
+
+ def post(self, user_id):
+ """Edits the provided project."""
+ self.require_login()
+ self.require_owner(user_id)
+ profile_object = models.user.User.get_by_id(user_id)
+ profile_object.populate(self.request).put()
+ self.redirect_to(DisplayUser, user_id=user_id)
+
+
class DisplayProject(BaseHandler):
"""The handler for displaying a project."""
def get(self, project_id):
"""Renders the project page in response to a GET request."""
project = ndb.Key(models.project.Project, int(project_id)).get()
- edit_link = self.uri_for(EditProject, project_id=project_id)
user_key = models.user.get_current_user_key()
+ edit_link = None
+ collaborator_emails = []
+ #Initialize some truthy objects for the following display logic.
+ is_logged_in = user_key
is_collaborating = models.collaborator.get_collaborator(
user_key, project.key)
- if user_key and is_collaborating:
+ is_project_owner = is_logged_in and project.owner_key == user_key
+ should_show_collaborator_emails = is_collaborating or is_project_owner
+ #Use the above as booleans to guide permissions.
+ if is_project_owner:
+ edit_link = self.uri_for(EditProject, project_id=project_id)
+ if should_show_collaborator_emails:
+ collaborator_emails = models.collaborator.get_collaborator_emails(
+ ndb.Key(models.project.Project, int(project_id))
+ )
+ if is_collaborating:
action = 'Leave'
action_link = self.uri_for(LeaveProject, project_id=project_id)
- else:
- # TODO(samking): This doesn't quite work for logged-out users.
+ if not is_collaborating and is_logged_in:
action = 'Join'
action_link = self.uri_for(JoinProject, project_id=project_id)
+ if not is_logged_in:
+ action = 'Login to Join'
+ action_link = users.create_login_url(self.request.uri)
values = {'project': project,
+ 'num_contributors':
+ models.collaborator.get_collaborator_count(
+ ndb.Key(models.project.Project, int(project_id))),
'edit_link': edit_link,
'action_link': action_link,
- 'action': action
+ 'action': action,
+ 'logout_url': generate_logout_url(),
+ 'collaborator_emails': collaborator_emails,
+ 'logged_out_user': user_key is None
}
self.response.write(templates.render('display_project.html', values))
@@ -90,7 +162,8 @@ def get(self, project_id):
edit_link = self.uri_for(EditProject, project_id=project_id)
values = {
'project': project, 'action_link': edit_link,
- 'action': 'Edit Your'}
+ 'action': 'Edit Your',
+ 'logout_url': generate_logout_url()}
self.response.write(templates.render('edit_project.html', values))
def post(self, project_id):
@@ -114,7 +187,8 @@ def get(self):
for curr_project in projects:
project_id = curr_project.key.id()
links.append(self.uri_for(DisplayProject, project_id=project_id))
- values = {'projects_and_links': zip(projects, links)}
+ values = {'projects_and_links': zip(projects, links),
+ 'logout_url': generate_logout_url()}
self.response.write(templates.render('list_projects.html', values))
@@ -125,7 +199,8 @@ def get(self):
"""Renders the new project page in response to a GET request."""
self.require_login()
values = {
- 'action': 'Create a New', 'action_link': self.uri_for(NewProject)}
+ 'action': 'Create a New', 'action_link': self.uri_for(NewProject),
+ 'logout_url': generate_logout_url()}
self.response.write(templates.render('edit_project.html', values))
def post(self):
@@ -145,11 +220,10 @@ def post(self, project_id):
"""Accepts a request to join a project."""
self.require_login()
current_user_key = models.user.get_current_user_key()
- # TODO(samking): ensure that there's at most one collaborator per user
- # and project.
models.collaborator.Collaborator(
user_key=current_user_key,
- project_key=ndb.Key(models.project.Project, int(project_id))).put()
+ parent=ndb.Key(models.project.Project, int(project_id))
+ ).get_or_insert()
self.redirect_to(DisplayProject, project_id=project_id)
@@ -165,3 +239,7 @@ def post(self, project_id):
if collaborator:
collaborator.key.delete()
self.redirect_to(DisplayProject, project_id=project_id)
+
+def generate_logout_url():
+ """Returns logout url if user is logged in; otherwise returns None."""
+ return users.create_logout_url('/') if users.get_current_user() else None
diff --git a/index.yaml b/index.yaml
index 611f2a5..76b6d3d 100644
--- a/index.yaml
+++ b/index.yaml
@@ -21,6 +21,17 @@ indexes:
- name: created_date
direction: desc
+- kind: Collaborator
+ ancestor: yes
+ properties:
+ - name: created_date
+
+- kind: Collaborator
+ ancestor: yes
+ properties:
+ - name: created_date
+ direction: desc
+
- kind: Project
properties:
- name: owner_key
diff --git a/models/collaborator.py b/models/collaborator.py
index 175029c..a14a595 100644
--- a/models/collaborator.py
+++ b/models/collaborator.py
@@ -1,27 +1,26 @@
"""A model for the relationship between a user and a project."""
from google.appengine.ext import ndb
-from models import project
from models import user
class Collaborator(ndb.Model):
"""A model for relationship between a user and a project."""
user_key = ndb.KeyProperty(required=True, kind=user.User)
- project_key = ndb.KeyProperty(required=True, kind=project.Project)
created_date = ndb.DateTimeProperty(required=True, auto_now_add=True)
+ def _pre_put_hook(self):
+ """Raises an exception if a new collaborator does not have a parent."""
+ assert self.key.parent(), "No parent project for this collaborator."
+
+
def get_collaborator(user_key, project_key):
"""Returns a collaboration if the user is collaborating on the project."""
- # TODO(samking): after refreshing the page, we get stale data. Make the
- # collaborator an ancestor query (on the user) to get strong
- # consistency.
- query = Collaborator.query().filter(
- Collaborator.user_key == user_key,
- Collaborator.project_key == project_key)
+ query = Collaborator.query(ancestor=project_key).filter(
+ Collaborator.user_key == user_key)
collaborator = query.fetch(limit=1)
- return collaborator[0] if collaborator else None
+ return collaborator[0] if collaborator else None
def get_projects(user_key):
@@ -29,8 +28,25 @@ def get_projects(user_key):
query = Collaborator.query(Collaborator.user_key == user_key)
query = query.order(-Collaborator.created_date)
collaborators = query.fetch()
- futures = []
- for collaborator in collaborators:
- futures.append(collaborator.project_key.get_async())
+ futures = [collaborator.key.parent().get_async()
+ for collaborator in collaborators]
ndb.Future.wait_all(futures)
return [future.get_result() for future in futures]
+
+
+def get_collaborator_count(project_key):
+ """Counts the number of collaborators for a given project."""
+ query = Collaborator.query(ancestor=project_key)
+ return query.count()
+
+
+def get_collaborator_emails(project_key):
+ """Returns the emails of all collaborating users."""
+ query = Collaborator.query(ancestor=project_key)
+ query = query.order(Collaborator.created_date)
+ collaborators = query.fetch()
+ futures = [collaborator.user_key.get_async()
+ for collaborator in collaborators]
+ ndb.Future.wait_all(futures)
+ return [future.get_result().email for future in futures]
+
diff --git a/models/collaborator_test.py b/models/collaborator_test.py
index 38080e9..e9bd8f6 100644
--- a/models/collaborator_test.py
+++ b/models/collaborator_test.py
@@ -12,25 +12,60 @@
class CollaboratorTests(testutil.CtcTestCase):
def test_get_collaborator(self):
- user_key = models.user.User().put()
+ user_key = models.user.User(email='user@codethechange.org').put()
project = model_helpers.create_project(owner_key=user_key)
self.assertEqual(
models.collaborator.get_collaborator(user_key, project.key), None)
collaborator = models.collaborator.Collaborator(
- user_key=user_key, project_key=project.key)
+ user_key=user_key, parent=project.key)
collaborator.put()
self.assertEqual(
models.collaborator.get_collaborator(user_key, project.key),
collaborator)
def test_get_projects(self):
- user_key = models.user.User().put()
+ user_key = models.user.User(email='getter@codethechange.org').put()
self.assertEqual(models.collaborator.get_projects(user_key), [])
project = model_helpers.create_project(owner_key=user_key)
models.collaborator.Collaborator(
- user_key=user_key, project_key=project.key).put()
+ user_key=user_key, parent=project.key).put()
self.assertEqual(models.collaborator.get_projects(user_key), [project])
+ def test_get_emails(self):
+ user_key = models.user.User(email='user@codethechange.org').put()
+ project = model_helpers.create_project(owner_key=user_key)
+ collaborator = models.collaborator.Collaborator(
+ user_key=user_key, parent=project.key)
+ collaborator.put()
+ another_user_key = models.user.User(
+ email='another@codethechange.org').put()
+ collaborator = models.collaborator.Collaborator(
+ user_key=another_user_key, parent=project.key)
+ collaborator.put()
+ self.assertEqual(
+ models.collaborator.get_collaborator_emails(project.key),
+ ['user@codethechange.org', 'another@codethechange.org'])
+
+
+ def test_get_collaborator_count(self):
+ user_key = models.user.User(email='user@codethechange.org').put()
+ project = model_helpers.create_project(owner_key=user_key)
+ collaborator = models.collaborator.Collaborator(
+ user_key=user_key, parent=project.key)
+ collaborator.put()
+ another_user_key = models.user.User(
+ email='another@codethechange.org').put()
+ collaborator = models.collaborator.Collaborator(
+ user_key=another_user_key, parent=project.key)
+ collaborator.put()
+ self.assertEqual(
+ models.collaborator.get_collaborator_count(project.key),
+ 2)
+ collaborator.key.delete()
+ self.assertEqual(
+ models.collaborator.get_collaborator_count(project.key),
+ 1)
+
if __name__ == '__main__':
unittest.main()
diff --git a/models/project.py b/models/project.py
index 588861b..c1d3f20 100644
--- a/models/project.py
+++ b/models/project.py
@@ -1,7 +1,7 @@
"""A model for one project."""
from google.appengine.ext import ndb
-from models import user
+from models import user as user_model
class Project(ndb.Model):
@@ -17,12 +17,9 @@ class Project(ndb.Model):
tech_objectives = ndb.TextProperty(required=True)
github = ndb.TextProperty(required=True)
- num_commits = ndb.IntegerProperty(required=False, default=0)
- num_contributors = ndb.IntegerProperty(required=False, default=0)
-
created_date = ndb.DateTimeProperty(required=True, auto_now_add=True)
updated_date = ndb.DateTimeProperty(required=True, auto_now=True)
- owner_key = ndb.KeyProperty(required=True, kind=user.User)
+ owner_key = ndb.KeyProperty(required=True, kind=user_model.User)
# TODO(samking): add these fields
# tag_keys = ndb.KeyProperty(repeated=True, kind=tag.Tag)
# is_completed = ndb.BooleanProperty(required=True, default=False)
diff --git a/models/project_test.py b/models/project_test.py
index 41a25a4..62a2c5a 100644
--- a/models/project_test.py
+++ b/models/project_test.py
@@ -25,11 +25,12 @@ def test_populate(self):
self.assertEqual(project.github, 'github')
def test_get_by_owner(self):
- user_key = models.user.User().put()
+ user_key = models.user.User(email='owner@codethechange.org').put()
self.assertEqual(models.project.get_by_owner(user_key), [])
project1 = model_helpers.create_project(owner_key=user_key)
project2 = model_helpers.create_project(owner_key=user_key)
- other_user = models.user.User().put()
+ other_user = models.user.User(
+ email='nottheowner@codethechange.org').put()
model_helpers.create_project(owner_key=other_user)
# Ordered by most recent. Doesn't include the other user's project.
expected_projects = [project2, project1]
diff --git a/models/user.py b/models/user.py
index fcb4b1d..b975a2b 100644
--- a/models/user.py
+++ b/models/user.py
@@ -6,23 +6,44 @@
class User(ndb.Model):
"""A model for one user."""
created_date = ndb.DateTimeProperty(required=True, auto_now_add=True)
+ email = ndb.StringProperty(required=True)
+ name = ndb.StringProperty(default="")
+ secondary_contact = ndb.StringProperty(default="")
+ biography = ndb.StringProperty(default="")
+ website = ndb.StringProperty(default="")
+ def populate(self, request):
+ """Populates the fields in a user's profile from a web request.
+
+ Args:
+ request: A WebOb.Request with string values for each settable
+ User parameter.
+
+ Returns:
+ self for the sake of chaining.
+ """
+ settable_fields = [
+ 'name', 'secondary_contact', 'biography', 'website']
+ for field in settable_fields:
+ setattr(self, field, request.get(field))
+ return self
def get_current_user_key():
"""Gets the ndb.Key for the current user, creating it if necessary.
Returns None if the user is not logged in.
"""
- user_object = None
+ local_user_object = None
user_id = None
- if users.get_current_user():
- user_id = users.get_current_user().user_id()
- user_object = User.get_by_id(user_id)
+ appengine_user = users.get_current_user()
+ if appengine_user:
+ user_id = appengine_user.user_id()
+ local_user_object = User.get_by_id(user_id)
# The user is not logged in.
if not user_id:
return None
# The user is logged in but isn't in the datastore.
- if not user_object:
- user_object = User(id=user_id)
- user_object.put()
- return user_object.key
+ if not local_user_object:
+ local_user_object = User(id=user_id, email=appengine_user.email())
+ local_user_object.put()
+ return local_user_object.key
diff --git a/server.py b/server.py
index d96dda9..3bc6b45 100644
--- a/server.py
+++ b/server.py
@@ -33,4 +33,6 @@ def named_route(path, handler):
named_route(r'/project//leave', handlers.LeaveProject),
named_route(r'/project/new', handlers.NewProject),
named_route(r'/dashboard', handlers.DisplayDashboard),
+ named_route(r'/user/', handlers.DisplayUser),
+ named_route(r'/user//edit', handlers.EditUser)
], debug=IS_DEV)
diff --git a/testing/model_helpers.py b/testing/model_helpers.py
index 98ad139..cd06e9a 100644
--- a/testing/model_helpers.py
+++ b/testing/model_helpers.py
@@ -13,7 +13,7 @@ def create_project(
owner_key = user.get_current_user_key()
# If the user is not logged in, an arbitrary user is the owner.
if owner_key is None:
- owner_key = user.User().put()
+ owner_key = user.User(email='arbitrary@codethechange.org').put()
new_project = project.Project(
title=title, description=description, lead=lead,
tech_objectives=tech_objectives, github=github, owner_key=owner_key)
diff --git a/testing/testutil.py b/testing/testutil.py
index 4184776..73e063e 100644
--- a/testing/testutil.py
+++ b/testing/testutil.py
@@ -12,9 +12,10 @@ def setUp(self):
super(CtcTestCase, self).setUp()
self.testbed = testbed.Testbed()
self.testbed.activate()
- # It's annoying to have to figure out the right stub while you're
- # writing tests, so initialize ALL the stubs!
- self.testbed.init_all_stubs()
+ # Only some stubs are initialized because we had trouble with some
+ # testing environments.
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_user_stub()
def tearDown(self):
super(CtcTestCase, self).tearDown()
diff --git a/views/dashboard.html b/views/dashboard.html
index 355229e..9f72da3 100644
--- a/views/dashboard.html
+++ b/views/dashboard.html
@@ -7,7 +7,7 @@ Projects I Own
{% endfor %}
- Project I'm Contributing To
+ Projects I Contribute To
{% for project in contributing %}
- {{ project.title }}
diff --git a/views/display_project.html b/views/display_project.html
index b76b2f6..00a8444 100644
--- a/views/display_project.html
+++ b/views/display_project.html
@@ -7,6 +7,9 @@ {{ project.title }}
+ {% if edit_link %}
+
+ {% endif %}
Project Objectives
{{ project.description }}
Technical Objectives
@@ -16,13 +19,19 @@
Technical Objectives
-
{{ project.num_commits }}
-
Git commits
-
{{ project.num_contributors }}
+
{{ num_contributors }}
People Involved
-
+ {% for email in collaborator_emails %}
+
{{ email }}
+ {% endfor %}
+
+ {% if logged_out_user %}
+
+ {% else %}
+
+ {% endif %}
Contribute Code
diff --git a/views/display_user.html b/views/display_user.html
new file mode 100644
index 0000000..e0fb027
--- /dev/null
+++ b/views/display_user.html
@@ -0,0 +1,31 @@
+{% extends "base.html" %}
+{% block body %}
+ {# profile-header #}
+{% endblock body %}
diff --git a/views/edit_user.html b/views/edit_user.html
new file mode 100644
index 0000000..97e0bb5
--- /dev/null
+++ b/views/edit_user.html
@@ -0,0 +1,62 @@
+{% extends "base.html" %}
+{% block body %}
+
{{ action }} User
+
+
+
+ {# Sidebar #}
+
+
+ {# TODO(samking): ditch the
tags and use CSS to make this visually
+ line up with the form. #}
+
+
+
Feel free to update your profile so other users can get in touch with you.
+
+
+{% endblock body %}
diff --git a/views/header.html b/views/header.html
index 64917bc..f5d5cb7 100644
--- a/views/header.html
+++ b/views/header.html
@@ -26,7 +26,9 @@
{# navbar-collapse #}
{# container-fluid #}