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 %} + + {% 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.name }}

    +
    + +
    + {% if edit_link %} + + {% endif %} +

    Email

    +

    {{ profile.email }}

    + + {% if profile.secondary_contact %} +

    Secondary Contact

    +

    {{ profile.secondary_contact }}

    + {% endif %} + + {% if profile.biography %} +

    Developer History

    +

    {{ profile.biography }}

    + {% endif %} + + {% if profile.website %} +

    Github Profile

    +

    {{ profile.website }}

    + {% endif %} +
    +
    {# 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

    +
    +
    +
    + {# TODO(samking): This is repetitive. Use a for loop or something. #} +

    User Name

    +
    + +
    + + {# TODO(suciutwo): Let users edit email? This will require changing + project ownership and collaborations#} + + + + + + + +

    Secondary Contact Information

    +
    + +
    + +

    Biography

    +
    + +
    + +

    Github Link

    +
    + +
    + +
    + +
    +
    {# profile-form #} +
    + + {# 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 #}