Repository: fossasia/open-event-server

Issue:
Implement Profile API
- [x] Define Profile API with fields and methods
- [x] Implement Profile API in Orga Server and ensure all operations are be possible using API
- [x] To POST, PUT, DELETE methods check permissions
- [ ] Some Get methods also might need permissions
- [x] Document all API fields, methods and access in API.md



Discussion:
Hi @mariobehling 
I have doubts with this issue. 
First of all, Profile API means User API right. So do we need to have DELETE API (to delete a user account) ? In other words, will it be possible for a user to delete their account ?
Thanks

@aviaryan Please discuss the details with @rafalkowalski

@rafalkowalski @shivamMg 
This is what I have thought so far.. 
1. POST
   To keep it simple, post email and password to `/users` to create a user.
2. GET
   GET on `/users` returns all users. 
   Apart from that, GET on `/users/events/<id>` returns all users part of event with id <id>
   GET on `/users/<id>` returns full user profile
3. PUT
   PUT on `/users/<id>` is to update a profile
4. DELETE
   DELETE on `/users/<id>` deletes a profile.

Apart from that, there will be endpoints for managing roles but I think they come under Event API. We can add it after permission and access system is set up. 

In order to make it easier for us to understand please list all fields and make a sample JSON that would be exported using this API.

@mariobehling 
Here is the sample output 

``` json
{
    "id": 1,
    "email": "email@gmail.com",
    "user_detail": {
        "fullname": "John Doe",
        "details": "bio",
        "avatar": "http://link.to/avatar",
        "contact": "+91823234433",
        "facebook": "http://fb.com/link",
        "twitter": "http://twitter.com/link"
    }
}
```

This is consistent with how user is stored internally but if needed I can take out that `user_detail` envelope. 

What about the picture, github etc.?

How do we restrict access to certain fields? E.g. we want people to be able to access certain data like link to the speakers website, but we dont want to make the phone number public.

> What about the picture, github etc.?

picture = avatar in the above json .. Link to user's photo

github, linkedin, website etc have been defined in speaker's model and not in user's. But now that I think of it, it doesn't make sense to have it in speaker's model as a user could be speaker in many events. It makes no sense to copy same values multiple times. This is the current list of fields in a speaker model.

name, photo, biography, email, web, twitter, facebook, github, linkedin, organisation, position, country, event_id

I think we don't need name, photo, bio, email, twitter, facebook in speaker's fields as they are already in user model.
Also github, linkedin, country (possibly organisation and position) can be moved to user model.
Therefore speaker model should only store a link between user and event and link to sessions they take. (But of courese Speaker API will show the full user data)

@mariobehling @rafalkowalski @niranjan94 @shivamMg  What do you think ? 

> How do we restrict access to certain fields? E.g. we want people to be able to access certain data like link to the speakers website, but we dont want to make the phone number public.

Supposing that only track organizers, event organizers and the speaker himself can see the phone number, we can do it once permissions is implemented. It will be done by changing the model that the data is marshalled with a/c the access level. This can be easily done in flask-restplus framework .

@aviaryan There could be different kind of events, where a speaker wants to provide a different photo and bio. Also, we dont want to change former event data with updated information possibly. So, keeping some of the data separate for each event could make sense. Maybe we can offer the option "copy data from your profile to this event".

> Maybe we can offer the option "copy data from your profile to this event".

:+1:
I will do this tomorrow. 

working on this now. 


Resolving patch:
diff --git a/open_event/api/__init__.py b/open_event/api/__init__.py
--- a/open_event/api/__init__.py
+++ b/open_event/api/__init__.py
@@ -14,6 +14,7 @@
 from .login import api as login_api
 from .exports import api as exports_api
 from .imports import api as imports_api
+from .users import api as users_api
 
 
 from helpers.errors import (
@@ -41,6 +42,7 @@
 api.add_namespace(login_api)
 api.add_namespace(exports_api)
 api.add_namespace(imports_api)
+api.add_namespace(users_api)
 
 
 @api.documentation
diff --git a/open_event/api/helpers/utils.py b/open_event/api/helpers/utils.py
--- a/open_event/api/helpers/utils.py
+++ b/open_event/api/helpers/utils.py
@@ -59,9 +59,10 @@ class BaseDAO:
     """
     DAO for a basic independent model
     """
-    def __init__(self, model, post_api_model=None):
+    def __init__(self, model, post_api_model=None, put_api_model=None):
         self.model = model
         self.post_api_model = post_api_model
+        self.put_api_model = put_api_model if put_api_model else post_api_model
 
     def get(self, id_):
         return get_object_or_404(self.model, id_)
@@ -71,13 +72,13 @@ def list(self):
 
     def create(self, data, validate=True):
         if validate:
-            self.validate(data)
+            self.validate(data, self.post_api_model)
         item = create_model(self.model, data)
         return item
 
     def update(self, id_, data, validate=True):
         if validate:
-            self.validate(data)
+            self.validate(data, self.put_api_model)
         item = update_model(self.model, id_, data)
         return item
 
@@ -85,9 +86,11 @@ def delete(self, id_):
         item = delete_model(self.model, id_)
         return item
 
-    def validate(self, data):
-        if self.post_api_model:
-            validate_payload(data, self.post_api_model)
+    def validate(self, data, model=None):
+        if not model:
+            model = self.post_api_model
+        if model:
+            validate_payload(data, model)
 
 
 # DAO for Service Models
diff --git a/open_event/api/users.py b/open_event/api/users.py
new file mode 100644
--- /dev/null
+++ b/open_event/api/users.py
@@ -0,0 +1,118 @@
+from flask.ext.restplus import Resource, Namespace
+
+from open_event.models.user import User as UserModel
+from open_event.models.user_detail import UserDetail as UserDetailModel
+from open_event.helpers.data import DataManager
+
+from .helpers.helpers import get_paginated_list, requires_auth
+from .helpers.utils import PAGINATED_MODEL, PaginatedResourceBase, BaseDAO, \
+    PAGE_PARAMS, POST_RESPONSES, PUT_RESPONSES
+from .helpers import custom_fields as fields
+
+api = Namespace('users', description='Users', path='/')
+
+
+USER_DETAIL = api.model('UserDetail', {
+    'fullname': fields.String(),
+    'details': fields.String(),
+    'avatar': fields.ImageUri(),
+    'contact': fields.String(),
+    'facebook': fields.String(),
+    'twitter': fields.String()
+})
+
+USER = api.model('User', {
+    'id': fields.Integer(),
+    'email': fields.Email(required=True),
+    'user_detail': fields.Nested(USER_DETAIL)
+})
+
+USER_PAGINATED = api.clone('UserPaginated', PAGINATED_MODEL, {
+    'results': fields.List(fields.Nested(USER))
+})
+
+USER_PUT = api.clone('UserPut', USER)
+del USER_PUT['id']
+
+USER_POST = api.model('UserPost', {
+    'email': fields.Email(required=True),
+    'password': fields.String(required=True)
+})
+
+
+class UserDetailDAO(BaseDAO):
+    pass
+
+
+class UserDAO(BaseDAO):
+    def create(self, data):
+        self.validate(data)
+        user = DataManager.create_user([data['email'], data['password']])
+        return user
+
+    def update(self, id_, data):
+        self.validate(data, self.put_api_model)
+        user_detail = data.get('user_detail', {})
+        del data['user_detail']
+        item = BaseDAO.update(self, id_, data, validate=False)
+        DetailDAO.update(item.user_detail.id, user_detail)
+        return item
+
+
+DAO = UserDAO(UserModel, USER_POST, USER_PUT)
+DetailDAO = UserDetailDAO(UserDetailModel, USER_DETAIL)
+
+
+@api.route('/users/<int:user_id>')
+@api.response(404, 'User not found')
+class User(Resource):
+    @api.doc('get_user')
+    @api.marshal_with(USER)
+    def get(self, user_id):
+        """Fetch a user given its id"""
+        return DAO.get(user_id)
+
+    @requires_auth
+    @api.doc('delete_user')
+    @api.marshal_with(USER)
+    def delete(self, user_id):
+        """Delete a user given its id"""
+        return DAO.delete(user_id)
+
+    @requires_auth
+    @api.doc('update_user', responses=PUT_RESPONSES)
+    @api.marshal_with(USER)
+    @api.expect(USER_PUT)
+    def put(self, user_id):
+        """Update a user given its id"""
+        return DAO.update(user_id, self.api.payload)
+
+
+@api.route('/users')
+class TrackList(Resource):
+    @api.doc('list_users')
+    @api.marshal_list_with(USER)
+    def get(self):
+        """List all users"""
+        return DAO.list()
+
+    # @requires_auth
+    @api.doc('create_user', responses=POST_RESPONSES)
+    @api.marshal_with(USER)
+    @api.expect(USER_POST)
+    def post(self):
+        """Create a user"""
+        return DAO.create(self.api.payload)
+
+
+@api.route('/users/page')
+class TrackListPaginated(Resource, PaginatedResourceBase):
+    @api.doc('list_users_paginated', params=PAGE_PARAMS)
+    @api.marshal_with(USER_PAGINATED)
+    def get(self):
+        """List users in a paginated manner"""
+        return get_paginated_list(
+            UserModel,
+            self.api.url_for(self),
+            args=self.parser.parse_args()
+        )


Test patch:
diff --git a/tests/api/test_put_api.py b/tests/api/test_put_api.py
--- a/tests/api/test_put_api.py
+++ b/tests/api/test_put_api.py
@@ -8,6 +8,7 @@
 from tests.auth_helper import register
 from open_event import current_app as app
 
+
 class TestPutApiBase(OpenEventTestCase):
     """
     Base class for help testing PUT APIs
diff --git a/tests/api/test_user_api.py b/tests/api/test_user_api.py
new file mode 100644
--- /dev/null
+++ b/tests/api/test_user_api.py
@@ -0,0 +1,99 @@
+import unittest
+import json
+
+from open_event import current_app as app
+
+from tests.setup_database import Setup
+from tests.utils import OpenEventTestCase
+from tests.auth_helper import register, create_user
+from utils_post_data import POST_USER_DATA, PUT_USER_DATA
+
+
+def get_path(*args):
+    url = '/api/v2/users'
+    if args:
+        url += '/' + '/'.join(map(str, args))
+    return url
+
+
+class TestUserApi(OpenEventTestCase):
+    def setUp(self):
+        self.app = Setup.create_app()
+        with app.test_request_context():
+            create_user('em1@gmail.com', 'test')
+            create_user('em2@gmail.com', 'test')
+
+    def _login_user(self):
+        with app.test_request_context():
+            register(self.app, u'em3@gmail.com', u'test')
+
+
+class TestUserApiGet(TestUserApi):
+    def test_get_single(self):
+        path = get_path(1)
+        resp = self.app.get(get_path(3))
+        self.assertEqual(resp.status_code, 404)
+        resp = self.app.get(path)
+        self.assertEqual(resp.status_code, 200)
+        self.assertIn('gmail', resp.data)
+
+    def test_get_list(self):
+        path = get_path()
+        resp = self.app.get(path)
+        self.assertEqual(resp.status_code, 200)
+        self.assertIn('gmail', resp.data)
+
+    def test_get_list_paginated(self):
+        path = get_path('page')
+        resp = self.app.get(path)
+        self.assertEqual(resp.status_code, 200)
+        self.assertIn('gmail', resp.data)
+        resp = self.app.get(path + '?start=3')
+        self.assertEqual(resp.status_code, 404)
+
+
+class TestUserApiWritable(TestUserApi):
+    def _post(self, path, data):
+        return self.app.post(
+            path,
+            data=json.dumps(data),
+            headers={'content-type': 'application/json'}
+        )
+
+    def _put(self, path, data):
+        return self.app.put(
+            path,
+            data=json.dumps(data),
+            headers={'content-type': 'application/json'}
+        )
+
+    def test_post_api(self):
+        path = get_path()
+        resp = self._post(path, POST_USER_DATA)
+        self.assertEqual(resp.status_code, 200)
+        self.assertIn('gmail', resp.data)
+
+    def test_put_api(self):
+        path = get_path(1)
+        resp = self._put(path, PUT_USER_DATA)
+        self.assertEqual(resp.status_code, 401)
+        self._login_user()
+        resp = self._put(path, PUT_USER_DATA)
+        self.assertEqual(resp.status_code, 200)
+        self.assertIn('email@domain.com', resp.data)
+        self.assertNotIn('gmail', resp.data)
+        self.assertIn('TestUser', resp.data)
+
+    def test_delete_api(self):
+        path = get_path(1)
+        resp = self.app.delete(path)
+        self.assertEqual(resp.status_code, 401)
+        self._login_user()
+        resp = self.app.delete(path)
+        self.assertIn('em1', resp.data)
+        resp = self.app.get(path)
+        self.assertEqual(resp.status_code, 404)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/utils_post_data.py b/tests/api/utils_post_data.py
--- a/tests/api/utils_post_data.py
+++ b/tests/api/utils_post_data.py
@@ -94,3 +94,20 @@
     "track_image_url": "http://imgur.com/image.png",
     "location": "Some Street, Some City",
 }
+
+POST_USER_DATA = {
+    "email": "test@gmail.com",
+    "password": "test"
+}
+
+PUT_USER_DATA = {
+    "email": "email@domain.com",
+    "user_detail": {
+        "avatar": "http://website.com/image.ext",
+        "contact": "string",
+        "details": "TestUser",
+        "facebook": "string",
+        "fullname": "TestUser",
+        "twitter": "string"
+    }
+}
diff --git a/tests/auth_helper.py b/tests/auth_helper.py
--- a/tests/auth_helper.py
+++ b/tests/auth_helper.py
@@ -2,6 +2,7 @@
 from flask import url_for
 
 from open_event.helpers.helpers import get_serializer
+from open_event.helpers.data import DataManager
 
 
 def login(app, email, password):
@@ -21,3 +22,7 @@ def register(app, email, password):
     data = [email, password]
     data_hash = s.dumps(data)
     return app.get(url_for('admin.create_account_after_confirmation_view', hash=data_hash), follow_redirects=True)
+
+
+def create_user(email, password):
+    DataManager.create_user([email, password])