diff --git a/app/api/auth.py b/app/api/auth.py index 8a54e376..af75cc9d 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,6 +1,7 @@ import uuid import os from enum import Enum +import functools import requests from app import db @@ -127,7 +128,11 @@ def get_api_key_from_authenticated_email(email): return apikey -def authenticate(func): +def authenticate(func=None, allow_no_auth_key=False): + if not func: + return functools.partial(authenticate, allow_no_auth_key=allow_no_auth_key) + + @functools.wraps(func) def wrapper(*args, **kwargs): apikey = request.headers.get('x-apikey') try: @@ -136,10 +141,12 @@ def wrapper(*args, **kwargs): except Exception: return standardize_response(status_code=500) - if not key: + if not key and not allow_no_auth_key: return standardize_response(status_code=401) - log_request(request, key) + if key: + log_request(request, key) + g.auth_key = key return func(*args, **kwargs) diff --git a/app/api/routes/resource_creation.py b/app/api/routes/resource_creation.py index 5ecbbebd..84171c98 100644 --- a/app/api/routes/resource_creation.py +++ b/app/api/routes/resource_creation.py @@ -67,7 +67,7 @@ def create_resources(json, db): logger.exception(e) return utils.standardize_response(status_code=500) - created_resources.append(new_resource.serialize) + created_resources.append(new_resource.serialize()) # Take all the created resources and save them in Algolia with one API call try: diff --git a/app/api/routes/resource_modification.py b/app/api/routes/resource_modification.py index 0f2f7a59..763bdbd3 100644 --- a/app/api/routes/resource_modification.py +++ b/app/api/routes/resource_modification.py @@ -35,6 +35,7 @@ def put_resource(id): def update_resource(id, json, db): resource = Resource.query.get(id) + api_key = g.auth_key.apikey if not resource: return redirect('/404') @@ -54,11 +55,12 @@ def get_unique_resource_languages_as_strings(): try: logger.info( - f"Updating resource. Old data: {json_module.dumps(resource.serialize)}") + f"Updating resource. Old data: " + f"{json_module.dumps(resource.serialize(api_key))}") if json.get('languages') is not None: old_languages = resource.languages[:] resource.languages = langs - index_object['languages'] = resource.serialize['languages'] + index_object['languages'] = resource.serialize(api_key)['languages'] resource_languages = get_unique_resource_languages_as_strings() for language in old_languages: if language.name not in resource_languages: @@ -99,7 +101,9 @@ def get_unique_resource_languages_as_strings(): db.session.commit() return utils.standardize_response( - payload=dict(data=resource.serialize), + payload=dict( + data=resource.serialize(api_key) + ), datatype="resource" ) @@ -124,19 +128,24 @@ def change_votes(id, vote_direction): @latency_summary.time() @failures_counter.count_exceptions() @bp.route('/resources//click', methods=['PUT']) +@authenticate(allow_no_auth_key=True) def update_resource_click(id): return add_click(id) -def update_votes(id, vote_direction): +def update_votes(id, vote_direction_attribute): resource = Resource.query.get(id) if not resource: return redirect('/404') - initial_count = getattr(resource, vote_direction) - opposite_direction = 'downvotes' if vote_direction == 'upvotes' else 'upvotes' - opposite_count = getattr(resource, opposite_direction) + initial_count = getattr(resource, vote_direction_attribute) + vote_direction = vote_direction_attribute[:-1] + + opposite_direction_attribute = 'downvotes' \ + if vote_direction_attribute == 'upvotes' else 'upvotes' + opposite_direction = opposite_direction_attribute[:-1] + opposite_count = getattr(resource, opposite_direction_attribute) api_key = g.auth_key.apikey vote_info = VoteInformation.query.get( @@ -152,25 +161,27 @@ def update_votes(id, vote_direction): ) new_vote_info.voter = voter resource.voters.append(new_vote_info) - setattr(resource, vote_direction, initial_count + 1) + setattr(resource, vote_direction_attribute, initial_count + 1) else: if vote_info.current_direction == vote_direction: - setattr(resource, vote_direction, initial_count - 1) - setattr(vote_info, 'current_direction', 'None') + setattr(resource, vote_direction_attribute, initial_count - 1) + setattr(vote_info, 'current_direction', None) else: - setattr(resource, opposite_direction, opposite_count - 1) \ + setattr(resource, opposite_direction_attribute, opposite_count - 1) \ if vote_info.current_direction == opposite_direction else None - setattr(resource, vote_direction, initial_count + 1) + setattr(resource, vote_direction_attribute, initial_count + 1) setattr(vote_info, 'current_direction', vote_direction) db.session.commit() return utils.standardize_response( - payload=dict(data=resource.serialize), - datatype="resource") + payload=dict(data=resource.serialize(api_key)), + datatype="resource" + ) def add_click(id): resource = Resource.query.get(id) + api_key = g.auth_key.apikey if g.auth_key else None if not resource: return redirect('/404') @@ -180,5 +191,5 @@ def add_click(id): db.session.commit() return utils.standardize_response( - payload=dict(data=resource.serialize), + payload=dict(data=resource.serialize(api_key)), datatype="resource") diff --git a/app/api/routes/resource_retrieval.py b/app/api/routes/resource_retrieval.py index d6153ff2..ffb0c760 100644 --- a/app/api/routes/resource_retrieval.py +++ b/app/api/routes/resource_retrieval.py @@ -1,11 +1,12 @@ from datetime import datetime from dateutil import parser -from flask import redirect, request +from flask import redirect, request, g from sqlalchemy import func, or_, text from app import utils as utils from app.api import bp +from app.api.auth import authenticate from app.api.routes.helpers import failures_counter, latency_summary, logger from app.models import Category, Language, Resource from configs import Config @@ -25,6 +26,7 @@ def resource(id): return get_resource(id) +@authenticate(allow_no_auth_key=True) def get_resources(): """ Gets a paginated list of resources. @@ -41,6 +43,7 @@ def get_resources(): category = request.args.get('category') updated_after = request.args.get('updated_after') free = request.args.get('free') + api_key = g.auth_key.apikey if g.auth_key else None q = Resource.query @@ -102,25 +105,28 @@ def get_resources(): if not paginated_resources: return redirect('/404') resource_list = [ - resource.serialize for resource in paginated_resources.items + item.serialize(api_key) + for item in paginated_resources.items ] details = resource_paginator.details(paginated_resources) except Exception as e: logger.exception(e) return utils.standardize_response(status_code=500) - return utils.standardize_response(payload=dict( - data=resource_list, - **details), - datatype="resources") + return utils.standardize_response( + payload=dict(data=resource_list, **details), + datatype="resources" + ) +@authenticate(allow_no_auth_key=True) def get_resource(id): resource = Resource.query.get(id) + api_key = g.auth_key.apikey if g.auth_key else None if resource: return utils.standardize_response( - payload=dict(data=(resource.serialize)), + payload=dict(data=(resource.serialize(api_key))), datatype="resource") return redirect('/404') diff --git a/app/models.py b/app/models.py index 85e11bcb..21522506 100644 --- a/app/models.py +++ b/app/models.py @@ -36,8 +36,7 @@ class Resource(TimestampMixin, db.Model): times_clicked = db.Column(db.INTEGER, default=0) voters = db.relationship('VoteInformation', back_populates='resource') - @property - def serialize(self): + def serialize(self, apikey=None): """Return object data in easily serializeable format""" if self.created_at: created = self.created_at.strftime("%Y-%m-%d %H:%M:%S") @@ -60,12 +59,16 @@ def serialize(self): 'downvotes': self.downvotes, 'times_clicked': self.times_clicked, 'created_at': created, - 'last_updated': updated + 'last_updated': updated, + 'user_vote_direction': next( + (voter.current_direction for voter in self.voters + if voter.voter_apikey == apikey), None + ) if apikey else None } @property def serialize_algolia_search(self): - result = self.serialize + result = self.serialize() result['objectID'] = self.id return result @@ -200,6 +203,6 @@ def __repr__(self): class VoteInformation(db.Model): voter_apikey = db.Column(db.String, db.ForeignKey('key.apikey'), primary_key=True) resource_id = db.Column(db.Integer, db.ForeignKey('resource.id'), primary_key=True) - current_direction = db.Column(db.String, nullable=False) + current_direction = db.Column(db.String, nullable=True) resource = db.relationship('Resource', back_populates='voters') voter = db.relationship('Key', back_populates='voted_resources') diff --git a/app/static/openapi.yaml b/app/static/openapi.yaml index bde51a36..a59908dd 100644 --- a/app/static/openapi.yaml +++ b/app/static/openapi.yaml @@ -398,6 +398,7 @@ paths: times_clicked: 0 upvotes: 0 url: 'http://www.freetechbooks.com/' + user_vote_direction: null status: 'ok' status_code: 200 404: @@ -447,6 +448,7 @@ paths: times_clicked: 3 upvotes: 1 url: 'http://www.test.com' + user_vote_direction: null status: 'ok' status_code: 200 404: @@ -554,6 +556,7 @@ paths: times_clicked: 0 upvotes: 1 url: 'http://teachyourkidstocode.com/' + user_vote_direction: 'upvote' status: 'ok' status_code: 200 404: @@ -598,6 +601,7 @@ paths: times_clicked: 0 upvotes: 0 url: 'http://teachyourkidstocode.com/' + user_vote_direction: 'downvote' status: 'ok' status_code: 200 404: @@ -658,6 +662,7 @@ paths: times_clicked: 3 upvotes: 1 url: 'http://www.test.com' + user_vote_direction: null has_next: false has_prev: false number_of_pages: 1 @@ -722,6 +727,7 @@ paths: times_clicked: 3 upvotes: 1 url: 'https://www.w3schools.com/css/' + user_vote_direction: null status: 'ok' status_code: 200 400: @@ -1092,6 +1098,10 @@ components: url: type: string description: Resource url + user_vote_direction: + type: string + nullable: true + description: Vote direction of a logged in user ResourceList: type: array diff --git a/migrations/versions/e6ac83ef4570_include_vote_direction.py b/migrations/versions/e6ac83ef4570_include_vote_direction.py new file mode 100644 index 00000000..b5037dbc --- /dev/null +++ b/migrations/versions/e6ac83ef4570_include_vote_direction.py @@ -0,0 +1,33 @@ +"""include vote direction + +Revision ID: e6ac83ef4570 +Revises: fc34137ad3ba +Create Date: 2020-11-05 20:04:51.029258 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision = 'e6ac83ef4570' +down_revision = 'fc34137ad3ba' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('vote_information', 'current_direction', + existing_type=sa.VARCHAR(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('vote_information', 'current_direction', + existing_type=sa.VARCHAR(), + nullable=False) + # ### end Alembic commands ### diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 5c7ae9e5..f4394fc0 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -21,7 +21,7 @@ def test_resource(): "\tCategory: \n" "\tURL: https://resource.url\n>") - assert (resource.serialize == { + assert (resource.serialize() == { 'id': None, 'name': 'name', 'url': 'https://resource.url', @@ -33,7 +33,8 @@ def test_resource(): 'downvotes': None, 'times_clicked': None, 'created_at': '', - 'last_updated': '' + 'last_updated': '', + 'user_vote_direction': None }) # Test equality diff --git a/tests/unit/test_routes/test_resource_retreival.py b/tests/unit/test_routes/test_resource_retreival.py index 5b6b7ee3..1e1f62f6 100644 --- a/tests/unit/test_routes/test_resource_retreival.py +++ b/tests/unit/test_routes/test_resource_retreival.py @@ -20,6 +20,7 @@ def test_get_resources(module_client, module_db): assert (isinstance(resource.get('category'), str)) assert (resource.get('category')) assert (isinstance(resource.get('languages'), list)) + assert ('user_vote_direction' in resource) assert (response.json['number_of_pages'] is not None) @@ -62,6 +63,7 @@ def test_get_single_resource(module_client, module_db): assert (isinstance(resource.get('category'), str)) assert (resource.get('category')) assert (isinstance(resource.get('languages'), list)) + assert ('user_vote_direction' in resource) assert (resource.get('id') == 5) diff --git a/tests/unit/test_routes/test_resource_update.py b/tests/unit/test_routes/test_resource_update.py index ef980b06..c7ced481 100644 --- a/tests/unit/test_routes/test_resource_update.py +++ b/tests/unit/test_routes/test_resource_update.py @@ -10,6 +10,7 @@ def test_update_votes(module_client, module_db, fake_auth_from_oc, fake_algolia_ client = module_client UPVOTE = 'upvote' DOWNVOTE = 'downvote' + USER_VOTE_DIRECTION = 'user_vote_direction' id = 1 apikey = get_api_key(client) @@ -23,6 +24,7 @@ def test_update_votes(module_client, module_db, fake_auth_from_oc, fake_algolia_ assert (response.status_code == 200) assert (response.json['resource'].get(f"{UPVOTE}s") == initial_upvotes + 1) + assert (response.json['resource'].get(USER_VOTE_DIRECTION) == UPVOTE) response = client.put( f"/api/v1/resources/{id}/{DOWNVOTE}", @@ -32,6 +34,7 @@ def test_update_votes(module_client, module_db, fake_auth_from_oc, fake_algolia_ assert (response.status_code == 200) assert (response.json['resource'].get(f"{UPVOTE}s") == initial_upvotes) assert (response.json['resource'].get(f"{DOWNVOTE}s") == initial_downvotes + 1) + assert (response.json['resource'].get(USER_VOTE_DIRECTION) == DOWNVOTE) response = client.put( f"/api/v1/resources/{id}/{DOWNVOTE}", @@ -39,6 +42,7 @@ def test_update_votes(module_client, module_db, fake_auth_from_oc, fake_algolia_ headers={'x-apikey': apikey}) assert (response.status_code == 200) assert (response.json['resource'].get(f"{DOWNVOTE}s") == initial_downvotes) + assert (response.json['resource'].get(USER_VOTE_DIRECTION) is None) def test_update_votes_invalid( @@ -262,6 +266,7 @@ def test_update_votes_authorization_header( id = 1 UPVOTE = 'upvote' DOWNVOTE = 'downvote' + USER_VOTE_DIRECTION = 'user_vote_direction' data = client.get(f"api/v1/resources/{id}").json['resource'] response = client.put( @@ -273,6 +278,7 @@ def test_update_votes_authorization_header( assert (response.status_code == 200) assert (response.json['resource'].get(f"{UPVOTE}s") == initial_upvotes + 1) + assert (response.json['resource'].get(USER_VOTE_DIRECTION) == UPVOTE) response = client.put( f"/api/v1/resources/{id}/downvote", @@ -282,6 +288,7 @@ def test_update_votes_authorization_header( assert (response.status_code == 200) assert (response.json['resource'].get(f"{UPVOTE}s") == initial_upvotes) assert (response.json['resource'].get(f"{DOWNVOTE}s") == initial_downvotes + 1) + assert (response.json['resource'].get(USER_VOTE_DIRECTION) == DOWNVOTE) def test_delete_unused_languages(module_client, module_db,