From 8128d5b872b7d6486b13e577bb60c72fa2deca7e Mon Sep 17 00:00:00 2001 From: Amitosh Swain Mahapatra Date: Sun, 10 Jan 2021 23:53:52 +0530 Subject: [PATCH] Add one on one API --- api/openapi.yaml | 126 +++++++++++++- snowflake/acl/__init__.py | 1 + snowflake/acl/one_on_one.py | 11 ++ snowflake/app.py | 3 + snowflake/controllers/api/__init__.py | 3 +- snowflake/controllers/api/one_on_ones.py | 192 +++++++++++++++++++++ snowflake/controllers/api/response.py | 11 ++ snowflake/models/one_on_one_action_item.py | 4 +- snowflake/schemas/fields.py | 20 +++ snowflake/schemas/one_on_one.py | 47 +++++ 10 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 snowflake/acl/__init__.py create mode 100644 snowflake/acl/one_on_one.py create mode 100644 snowflake/controllers/api/one_on_ones.py create mode 100644 snowflake/schemas/fields.py create mode 100644 snowflake/schemas/one_on_one.py diff --git a/api/openapi.yaml b/api/openapi.yaml index 5cea2b7..58319ea 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -27,6 +27,15 @@ components: example: message: 'Bad request' + PermissionDenied: + description: 'Permission denied' + content: + 'application/json': + schema: + $ref: '#/components/schemas/Error' + example: + message: 'Permission denied' + schemas: Error: type: object @@ -40,21 +49,73 @@ components: name: type: string maxLength: 255 + example: 'John Doe' designation: type: string + example: 'Manager' team_name: type: string + example: 'Sales' email: type: string format: email + example: 'john.doe@example.com' profile_pic: type: string format: url + example: 'https://cdn.example.com/images/dcd1674d3ddf42a59817202f.jpg' username: type: string + example: 'john.doe' + + OneOnOneActionItem: + type: object + properties: + id: + type: integer + example: 995296 + state: + type: boolean + example: false + content: + type: string + example: 'This is an action item' + created_by: + $ref: '#/components/schemas/User' + + OneOnOne: + type: object + properties: + id: + type: integer + example: 261993 + created_at: + type: string + format: date-time + example: '2021-01-11T22:40:08+05:30Z' + created_by: + $ref: '#/components/schemas/User' + user: + $ref: '#/components/schemas/User' + + OneOnOneDetail: + allOf: + - $ref: '#/components/schemas/OneOnOne' + - type: object + properties: + action_items: + type: array + items: + - $ref: '#/components/schemas/OneOnOneActionItem' + + CreateOneOnOne: + type: object + properties: + user: + type: string paths: - '/v1/users/_autocomplete': + '/users/_autocomplete': get: summary: 'Autocomplete' responses: @@ -66,3 +127,66 @@ paths: $ref: '#/components/schemas/User' '400': $ref: '#/components/responses/BadRequest' + + '/one_on_ones': + get: + summary: 'Get One on Ones' + responses: + '200': + description: 'OK' + content: + 'application/json': + schema: + type: array + items: + $ref: '#/components/schemas/OneOnOne' + put: + summary: 'Create one on one' + requestBody: + content: + 'application/json': + schema: + $ref: '#/components/schemas/CreateOneOnOne' + responses: + '201': + description: 'Created' + content: + 'application/json': + schema: + $ref: '#/components/schemas/OneOnOneDetail' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' + + + '/one_on_ones/{id}': + parameters: + - in: path + name: id + schema: + type: integer + required: true + get: + summary: 'Get one on one by ID' + responses: + '200': + description: 'OK' + content: + 'application/json': + schema: + $ref: '#/components/schemas/OneOnOneDetail' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' + + delete: + summary: 'Delete one on one by ID' + responses: + '204': + description: 'OK' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' diff --git a/snowflake/acl/__init__.py b/snowflake/acl/__init__.py new file mode 100644 index 0000000..3638fb1 --- /dev/null +++ b/snowflake/acl/__init__.py @@ -0,0 +1 @@ +from .one_on_one import * diff --git a/snowflake/acl/one_on_one.py b/snowflake/acl/one_on_one.py new file mode 100644 index 0000000..e3fc5f5 --- /dev/null +++ b/snowflake/acl/one_on_one.py @@ -0,0 +1,11 @@ +from flask_login import current_user + +from snowflake.models import OneOnOne, User + + +def can_view_one_on_one(one_on_one: OneOnOne, user: User = current_user): + return user.id == one_on_one.user_id or user.id == one_on_one.created_by_id + + +def can_delete_one_on_one(one_on_one: OneOnOne, user: User = current_user): + return can_view_one_on_one(one_on_one, user) diff --git a/snowflake/app.py b/snowflake/app.py index 4f384e1..28a17ed 100644 --- a/snowflake/app.py +++ b/snowflake/app.py @@ -30,8 +30,11 @@ def load_user(user_id): app.register_blueprint(index.blueprint) + app.register_blueprint(api.users.blueprint, url_prefix="/api/users") app.register_blueprint(api.notifications.blueprint, url_prefix="/api/notifications") +app.register_blueprint(api.one_on_ones.blueprint, url_prefix="/api/one_on_ones") + app.register_blueprint(login.blueprint, url_prefix="/login") app.register_blueprint(register.blueprint, url_prefix="/register") app.register_blueprint(profile.blueprint, url_prefix="/profile") diff --git a/snowflake/controllers/api/__init__.py b/snowflake/controllers/api/__init__.py index a0c20ae..7ac9c8b 100644 --- a/snowflake/controllers/api/__init__.py +++ b/snowflake/controllers/api/__init__.py @@ -1,2 +1,3 @@ -from . import users from . import notifications +from . import one_on_ones +from . import users diff --git a/snowflake/controllers/api/one_on_ones.py b/snowflake/controllers/api/one_on_ones.py new file mode 100644 index 0000000..0b75272 --- /dev/null +++ b/snowflake/controllers/api/one_on_ones.py @@ -0,0 +1,192 @@ +from datetime import datetime + +from flask import Blueprint, request +from flask_login import login_required, current_user +from marshmallow import ValidationError + +from .response import not_found, unauthorized, no_content, bad_request, validation_error +from ... import acl +from ...db import transaction, db +from ...models import OneOnOne, OneOnOneActionItem +from ...schemas.one_on_one import OneOnOneSchema, GetOneOnOneSchema, CreateOneOnOneSchema, OneOnOneActionItemSchema, \ + CreateOrEditOneOnOneActionItemSchema + +blueprint = Blueprint('api.one_on_ones', __name__) + +one_on_one_schema = OneOnOneSchema() +one_on_one_action_item_schema = OneOnOneActionItemSchema() +get_one_on_one_schema = GetOneOnOneSchema() +create_one_on_one_schema = CreateOneOnOneSchema() +create_or_edit_one_on_one_action_item_schema = CreateOrEditOneOnOneActionItemSchema() + + +@login_required +@blueprint.route('', methods=['GET']) +def list_all(): + return one_on_one_schema.jsonify(OneOnOne.get_by_user(current_user), many=True) + + +@login_required +@blueprint.route('', methods=['PUT']) +def create(): + if not request.is_json(): + return bad_request() + + try: + one_on_one: OneOnOne = create_one_on_one_schema.load(request.json) + one_on_one.created_by = current_user + one_on_one.created_at = datetime.now() + + with transaction(): + OneOnOne.create(one_on_one) + + return get_one_on_one_schema.jsonify(one_on_one) + except ValidationError as e: + return validation_error(e.messages) + + +@login_required +@blueprint.route('/<_id>', methods=['GET']) +def get(_id: int): + one_on_one = OneOnOne.get(_id) + + if not one_on_one: + return not_found() + + if not acl.can_view_one_on_one(one_on_one): + return unauthorized() + + return get_one_on_one_schema.jsonify(one_on_one) + + +@login_required +@blueprint.route('//action_items', methods=['GET']) +def get_action_items(one_on_one_id: int): + one_on_one = OneOnOne.get(one_on_one_id) + + if not one_on_one: + return not_found() + + if not acl.can_view_one_on_one(one_on_one): + return unauthorized() + + return one_on_one_action_item_schema.jsonify(one_on_one.action_items, many=True) + + +@login_required +@blueprint.route('//action_items/', methods=['GET']) +def get_action_item(one_on_one_id: int, action_item_id: int): + one_on_one = OneOnOne.get(one_on_one_id) + + if not one_on_one: + return not_found() + + if not acl.can_view_one_on_one(one_on_one): + return unauthorized() + + action_item = OneOnOneActionItem.get(action_item_id) + + if not action_item: + return not_found() + + if not action_item.one_on_one_id == one_on_one.id: + return not_found() + + return one_on_one_action_item_schema.jsonify(action_item) + + +@login_required +@blueprint.route('//action_items/', methods=['DELETE']) +def delete_action_item(one_on_one_id: int, action_item_id: int): + one_on_one = OneOnOne.get(one_on_one_id) + + if not one_on_one: + return not_found() + + if not acl.can_view_one_on_one(one_on_one): + return unauthorized() + + action_item = OneOnOneActionItem.get(action_item_id) + + if not action_item: + return not_found() + + if not action_item.one_on_one_id == one_on_one.id: + return not_found() + + with transaction(): + db.session.remove(one_on_one) + + return no_content() + + +@login_required +@blueprint.route('//action_items', methods=['PUT']) +def create_action_item(one_on_one_id: int): + if not request.is_json(): + return bad_request() + + one_on_one = OneOnOne.get(one_on_one_id) + + if not one_on_one: + return not_found() + + if not acl.can_view_one_on_one(one_on_one): + return unauthorized() + + try: + action_item: OneOnOneActionItem = create_or_edit_one_on_one_action_item_schema.load(request.json) + action_item.one_on_one = one_on_one + action_item.created_by = current_user + action_item.created_at = datetime.now() + + with transaction(): + OneOnOneActionItem.create(action_item) + + return one_on_one_action_item_schema.jsonify(action_item) + except ValidationError as e: + return validation_error(e.messages) + + +@login_required +@blueprint.route('//action_items/', methods=['PATCH']) +def edit_action_item(one_on_one_id: int, action_item_id: int): + if not request.is_json(): + return bad_request() + + one_on_one = OneOnOne.get(one_on_one_id) + + if not one_on_one: + return not_found() + + if not acl.can_view_one_on_one(one_on_one): + return unauthorized() + + action_item = OneOnOneActionItem.get(action_item_id) + + try: + action_item: OneOnOneActionItem = create_or_edit_one_on_one_action_item_schema.load(request.json, action_item) + + with transaction(): + db.session.add(action_item) + + return one_on_one_action_item_schema.jsonify(action_item) + except ValidationError as e: + return validation_error(e.messages) + + +@login_required +@blueprint.route('/<_id>', methods=['DELETE']) +def delete_one_on_one(_id: int): + one_on_one = OneOnOne.get(_id) + + if not one_on_one: + return not_found() + + if not acl.can_delete_one_on_one(one_on_one): + return unauthorized() + + with transaction(): + db.session.remove(one_on_one) + + return no_content() diff --git a/snowflake/controllers/api/response.py b/snowflake/controllers/api/response.py index 07a5c44..f09e455 100644 --- a/snowflake/controllers/api/response.py +++ b/snowflake/controllers/api/response.py @@ -9,9 +9,20 @@ def bad_request(message="Bad request"): return error_body(message), 400 +def validation_error(messages): + return jsonify({ + 'message': 'Validation error', + 'errors': messages + }), 400 + + def not_found(message="Not found"): return error_body(message), 404 def unauthorized(message="Unauthorized"): return jsonify(message), 403 + + +def no_content(): + return '', 204 diff --git a/snowflake/models/one_on_one_action_item.py b/snowflake/models/one_on_one_action_item.py index 3bd11b2..707069a 100644 --- a/snowflake/models/one_on_one_action_item.py +++ b/snowflake/models/one_on_one_action_item.py @@ -3,7 +3,7 @@ class OneOnOneActionItem(db.Model): id = db.Column(db.BigInteger, primary_key=True) - state = db.Column(db.Boolean) + state = db.Column(db.Boolean, default=False) content = db.Column(db.String) one_on_one_id = db.Column(db.BigInteger, db.ForeignKey('one_on_one.id'), nullable=False) @@ -22,5 +22,5 @@ def update(self): db.session.commit() @staticmethod - def get(_id): + def get(_id) -> 'OneOnOneActionItem': return OneOnOneActionItem.query.get(_id) diff --git a/snowflake/schemas/fields.py b/snowflake/schemas/fields.py new file mode 100644 index 0000000..4907bf6 --- /dev/null +++ b/snowflake/schemas/fields.py @@ -0,0 +1,20 @@ +from marshmallow import ValidationError +from marshmallow.fields import Field + +from ..models import User + + +class UserByUsername(Field): + def _deserialize(self, value: str, attr, data, **kwargs): + user = User.get_by_username(value) + + if not user: + raise ValidationError(f"User {value} not found") + + return user + + def _serialize(self, value: User, attr, obj, **kwargs): + if value is None: + return None + + return value.username diff --git a/snowflake/schemas/one_on_one.py b/snowflake/schemas/one_on_one.py new file mode 100644 index 0000000..456b3dd --- /dev/null +++ b/snowflake/schemas/one_on_one.py @@ -0,0 +1,47 @@ +from marshmallow.fields import List +from marshmallow_sqlalchemy.fields import Nested + +from .fields import UserByUsername +from .user import UserSchema +from ..marshmallow import marshmallow +from ..models import OneOnOne, OneOnOneActionItem + + +class OneOnOneActionItemSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = OneOnOneActionItem + + id = marshmallow.auto_field() + state = marshmallow.auto_field() + content = marshmallow.auto_field() + + created_by = Nested(UserSchema) + + +class CreateOrEditOneOnOneActionItemSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = OneOnOneActionItem + + state = marshmallow.auto_field() + content = marshmallow.auto_field() + + +class OneOnOneSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = OneOnOne + + id = marshmallow.auto_field() + created_at = marshmallow.auto_field() + created_by = Nested(UserSchema) + user = Nested(UserSchema) + + +class GetOneOnOneSchema(OneOnOneSchema): + action_items = List(Nested(OneOnOneActionItemSchema)) + + +class CreateOneOnOneSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = OneOnOne + + user = UserByUsername()