From 56e6f0c77d95f4928a3b3e85238f8d27451ddc6b Mon Sep 17 00:00:00 2001 From: Aurelien Lourot Date: Mon, 1 Jan 2018 23:25:23 +0100 Subject: [PATCH] Utility for resetting a term's stats. --- .coveragerc | 2 +- quizler/lib.py | 22 ++++++--- quizler/models.py | 49 ++++++++++++++++++- quizler/utils.py | 44 +++++++++++++++-- tests/factories.py | 11 ++++- tests/quizler/test_lib.py | 33 ++++++------- tests/quizler/test_models.py | 70 ++++++++++++++++++++++----- tests/quizler/test_utils.py | 92 ++++++++++++++++++++++++++++++++++-- 8 files changed, 275 insertions(+), 48 deletions(-) diff --git a/.coveragerc b/.coveragerc index 554158b..3cea068 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,2 @@ [report] -fail_under = 96 +fail_under = 97 diff --git a/quizler/lib.py b/quizler/lib.py index de6d8c0..8573230 100644 --- a/quizler/lib.py +++ b/quizler/lib.py @@ -1,7 +1,8 @@ """Additional none-user-visible utilities.""" +from typing import Dict +import json import os - import requests @@ -14,14 +15,21 @@ def get_api_envs(): return client_id, user_id -def api_call(end_point, client_id, user_id): +def api_call(method: str, end_point: str, params: Dict[str, str], client_id: str): """Call given API end_point with API keys.""" - url = 'https://api.quizlet.com/2.0/users/{}/{}'.format(user_id, end_point) - params = {'client_id': client_id} - response = requests.get(url, params) - if response.status_code != 200: + url = 'https://api.quizlet.com/2.0/{}'.format(end_point) + params['client_id'] = client_id + # pylint: disable=too-many-function-args + response = requests.request(method, url, params=params) + # pylint: enable=too-many-function-args + # pylint: disable=no-member + if int(response.status_code / 100) != 2: + # pylint: enable=no-member raise ValueError( 'Unknown end point, server returns {}'.format(response.status_code) ) - else: + + try: return response.json() + except json.decoder.JSONDecodeError: + pass diff --git a/quizler/models.py b/quizler/models.py index 7c38aaf..b7231b9 100644 --- a/quizler/models.py +++ b/quizler/models.py @@ -1,6 +1,51 @@ """OOP models for Quizlet terms abstractions.""" +class Image: + """Quizlet image abstraction.""" + + def __init__(self, url, width, height): + self.url = url + self.width = width + self.height = height + + @staticmethod + def from_dict(raw_data): + """Create Image from raw dictionary data.""" + url = None + width = None + height = None + try: + url = raw_data['url'] + width = raw_data['width'] + height = raw_data['height'] + except KeyError: + raise ValueError('Unexpected image json structure') + except TypeError: + # Happens when raw_data is None, i.e. when a term has no image: + pass + return Image(url, width, height) + + def to_dict(self): + """Convert Image into raw dictionary data.""" + if not self.url: + return None + return { + 'url': self.url, + 'width': self.width, + 'height': self.height + } + + def __eq__(self, other): + if not isinstance(other, Image): + raise ValueError + return all(( + self.url == other.url, + self.width == other.width, + self.height == other.height + )) + + class Term: """Quizlet term abstraction.""" @@ -17,7 +62,7 @@ def from_dict(raw_data): try: definition = raw_data['definition'] term_id = raw_data['id'] - image = raw_data['image'] + image = Image.from_dict(raw_data['image']) rank = raw_data['rank'] term = raw_data['term'] return Term(definition, term_id, image, rank, term) @@ -29,7 +74,7 @@ def to_dict(self): return { 'definition': self.definition, 'id': self.term_id, - 'image': self.image, + 'image': self.image.to_dict(), 'rank': self.rank, 'term': self.term } diff --git a/quizler/utils.py b/quizler/utils.py index 50340e0..66248ff 100644 --- a/quizler/utils.py +++ b/quizler/utils.py @@ -5,14 +5,12 @@ from typing import List, Tuple, Set from quizler.lib import api_call -from quizler.models import WordSet +from quizler.models import Term, WordSet -def get_user_sets(*api_envs): +def get_user_sets(client_id, user_id): """Find all user sets.""" - # pylint: disable=no-value-for-parameter - data = api_call('sets', *api_envs) - # pylint: enable=no-value-for-parameter + data = api_call('get', 'users/{}/sets'.format(user_id), {}, client_id) return [WordSet.from_dict(wordset) for wordset in data] @@ -32,7 +30,9 @@ def print_user_sets(wordsets: List[WordSet], print_terms: bool): def get_common_terms(*api_envs) -> List[Tuple[str, str, Set[str]]]: """Get all term duplicates across all user word sets.""" common_terms = [] + # pylint: disable=no-value-for-parameter wordsets = get_user_sets(*api_envs) + # pylint: enable=no-value-for-parameter for wordset1, wordset2 in combinations(wordsets, 2): common = wordset1.has_common(wordset2) @@ -53,6 +53,40 @@ def print_common_terms(common_terms: List[Tuple[str, str, Set[str]]]): print(' {}'.format(term)) +def delete_term(set_id, term_id, client_id): + """Delete the given term.""" + api_call('delete', 'sets/{}/terms/{}'.format(set_id, term_id), {}, client_id) + + +def add_term(set_id, term: Term, client_id): + """Add the given term to the given set.""" + api_call('post', 'sets/{}/terms'.format(set_id), term.to_dict(), client_id) + + +def reset_term_stats(set_id, term_id, client_id, user_id): + """Reset the stats of a term by deleting and re-creating it.""" + found_sets = [user_set for user_set in get_user_sets(client_id, user_id) + if user_set.set_id == set_id] + if len(found_sets) != 1: + raise ValueError('{} set(s) found with id {}'.format(len(found_sets), set_id)) + found_terms = [term for term in found_sets[0].terms if term.term_id == term_id] + if len(found_terms) != 1: + raise ValueError('{} term(s) found with id {}'.format(len(found_terms), term_id)) + term = found_terms[0] + + if term.image.url: + # Creating a term with an image requires an "image identifier", which you get by uploading + # an image via https://quizlet.com/api/2.0/docs/images , which can only be used by Quizlet + # PLUS members. + raise NotImplementedError('"{}" has an image and is thus not supported'.format(term)) + + print('Deleting "{}"...'.format(term)) + delete_term(set_id, term_id, client_id) + print('Re-creating "{}"...'.format(term)) + add_term(set_id, term, client_id) + print('Done') + + def apply_regex(pattern, repl, set_name, *api_envs): """Apply regex replace to all terms in word set.""" print('{}, {}, {}, {}'.format(pattern, repl, set_name, api_envs)) diff --git a/tests/factories.py b/tests/factories.py index 8480ab3..c7d1e17 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -7,6 +7,15 @@ from quizler import models +class ImageFactory(factory.Factory): + class Meta: + model = models.Image + + url = None + width = None + height = None + + class TermFactory(factory.Factory): class Meta: model = models.Term @@ -14,7 +23,7 @@ class Meta: definition = factory.LazyAttribute( lambda obj: 'definition{}'.format(obj.term_id)) term_id = factory.Sequence(lambda n: n) - image = None + image = ImageFactory() rank = 0 term = factory.LazyAttribute(lambda obj: 'term{}'.format(obj.term_id)) diff --git a/tests/quizler/test_lib.py b/tests/quizler/test_lib.py index e26a0e2..2f8868a 100644 --- a/tests/quizler/test_lib.py +++ b/tests/quizler/test_lib.py @@ -33,25 +33,26 @@ def test_all_envs_are_set(self): class TestApiCall(unittest.TestCase): - @mock.patch('requests.get') - def test_unknown_endpoint(self, mock_get): - mock_get.return_value.status_code = 404 + @mock.patch('requests.request') + def test_unknown_endpoint(self, mock_request): + mock_request.return_value.status_code = 404 with self.assertRaises(ValueError): - api_call('unknown_end_point', 'client_id', 'user_id') - - @mock.patch('requests.get') - def test_correct_url_was_called(self, mock_get): - mock_get.return_value.status_code = 200 - api_call('end_point', 'client_id', 'user_id') - mock_get.assert_called_once_with( - 'https://api.quizlet.com/2.0/users/user_id/end_point', - {'client_id': 'client_id'} + api_call('get', 'unknown_end_point', {}, 'client_id') + + @mock.patch('requests.request') + def test_correct_url_was_called(self, mock_request): + mock_request.return_value.status_code = 200 + api_call('get', 'end_point', {}, 'client_id') + mock_request.assert_called_once_with( + 'get', + 'https://api.quizlet.com/2.0/end_point', + params={'client_id': 'client_id'} ) - @mock.patch('requests.get') - def test_correct_output_was_returned(self, mock_get): + @mock.patch('requests.request') + def test_correct_output_was_returned(self, mock_request): response = mock.Mock() response.status_code = 200 - mock_get.return_value = response - data = api_call('end_point', 'client_id', 'user_id') + mock_request.return_value = response + data = api_call('get', 'end_point', {}, 'client_id') self.assertEqual(data, response.json()) diff --git a/tests/quizler/test_models.py b/tests/quizler/test_models.py index ee3d56f..673ff45 100644 --- a/tests/quizler/test_models.py +++ b/tests/quizler/test_models.py @@ -3,8 +3,54 @@ import unittest import pytest -from quizler.models import Term, WordSet -from tests.factories import TermFactory, WordSetFactory +from quizler.models import Image, Term, WordSet +from tests.factories import ImageFactory, TermFactory, WordSetFactory + + +class TestImage(unittest.TestCase): + + def test_from_dict(self): + raw_data = { + 'url': 'http://domain.com/myimage.png', + 'width': 200, + 'height': 100 + } + image = Image.from_dict(raw_data) + assert image.url == 'http://domain.com/myimage.png' + assert image.width == 200 + assert image.height == 100 + + def test_wrong_image_json_structure(self): + with self.assertRaises(ValueError): + Image.from_dict({'some data': '142% unexpected'}) + + def test_correct_init(self): + image = Image('http://domain.com/myimage.png', 200, 100) + assert image.url == 'http://domain.com/myimage.png' + assert image.width == 200 + assert image.height == 100 + + def test_to_dict(self): + image = Image('http://domain.com/myimage.png', 200, 100) + assert image.to_dict() == { + 'url': 'http://domain.com/myimage.png', + 'width': 200, + 'height': 100 + } + + def test_equal_images(self): + image0 = Image('http://domain.com/myimage.png', 200, 100) + image1 = Image('http://domain.com/myimage.png', 200, 100) + assert image0 == image1 + + def test_unequal_images(self): + image0 = Image('http://domain.com/myimage.png', 200, 100) + image1 = Image('http://domain.com/myimage.png', 201, 100) + assert image0 != image1 + + def test_wrong_equality_type(self): + with pytest.raises(ValueError): + assert ImageFactory() == 1 class TestTerm(unittest.TestCase): @@ -20,7 +66,7 @@ def test_from_dict(self): term = Term.from_dict(raw_data) assert term.definition == 'term definition' assert term.term_id == 12345 - assert term.image is None + assert term.image.url is None assert term.rank == 0 assert term.term == 'term' @@ -29,14 +75,14 @@ def test_wrong_term_json_structure(self): Term.from_dict({'some data': '142% unexpected'}) def test_correct_init(self): - term = Term('definition', 1, None, 0, 'term') + term = Term('definition', 1, ImageFactory(), 0, 'term') assert term.definition == 'definition' assert term.term_id == 1 - assert term.image is None + assert term.image.url is None assert term.term == 'term' def test_to_dict(self): - term = Term('definition1', 1, None, 1, 'term1') + term = Term('definition1', 1, ImageFactory(), 1, 'term1') assert term.to_dict() == { 'definition': 'definition1', 'id': 1, @@ -46,13 +92,13 @@ def test_to_dict(self): } def test_equal_terms(self): - term0 = Term('definition', 0, None, 0, 'term') - term1 = Term('definition', 0, None, 0, 'term') + term0 = Term('definition', 0, ImageFactory(), 0, 'term') + term1 = Term('definition', 0, ImageFactory(), 0, 'term') assert term0 == term1 def test_unequal_terms(self): - term0 = Term('definition0', 0, None, 0, 'term0') - term1 = Term('definition1', 1, None, 0, 'term1') + term0 = Term('definition0', 0, ImageFactory(), 0, 'term0') + term1 = Term('definition1', 1, ImageFactory(), 0, 'term1') assert term0 != term1 def test_wrong_equality_type(self): @@ -98,14 +144,14 @@ def test_from_dict(self): wordset = WordSet.from_dict(raw_data) assert wordset.set_id == 0 assert wordset.title == 'title0' - assert wordset.terms == [Term('definition0', 0, None, 0, 'term0')] + assert wordset.terms == [Term('definition0', 0, Image(None, None, None), 0, 'term0')] def test_from_bad_dict(self): with pytest.raises(ValueError): WordSet.from_dict({}) def test_to_dict(self): - wordset = WordSet(0, 'title0', [Term('def0', 0, None, 0, 'term0')]) + wordset = WordSet(0, 'title0', [Term('def0', 0, ImageFactory(), 0, 'term0')]) assert wordset.to_dict() == { 'id': 0, 'title': 'title0', diff --git a/tests/quizler/test_utils.py b/tests/quizler/test_utils.py index dabe0f6..fd7d834 100644 --- a/tests/quizler/test_utils.py +++ b/tests/quizler/test_utils.py @@ -5,8 +5,8 @@ from quizler.models import WordSet from quizler.utils import print_common_terms, get_common_terms, get_user_sets, \ - print_user_sets -from tests.factories import TermFactory, WordSetFactory + print_user_sets, delete_term, add_term, reset_term_stats +from tests.factories import ImageFactory, TermFactory, WordSetFactory from tests.utils import MockStdoutTestCase @@ -68,11 +68,11 @@ def test_there_are_sets(self, mock_api_call): mock_data = [wordset0.to_dict(), wordset1.to_dict()] wordsets = [WordSet.from_dict(wordset) for wordset in mock_data] mock_api_call.return_value = mock_data - assert get_user_sets() == wordsets + assert get_user_sets('client_id', 'user_id') == wordsets def test_there_are_no_sets(self, mock_api_call): mock_api_call.return_value = [] - self.assertEqual(get_user_sets(), []) + self.assertEqual(get_user_sets('client_id', 'user_id'), []) class TestPrintUserSets(MockStdoutTestCase): @@ -110,3 +110,87 @@ def test_two_sets_with_terms(self): ' {} = {}' .format(wordset0.title, term0.term, term0.definition, term1.term, term1.definition, wordset1.title, term2.term, term2.definition)) + + +@mock.patch('quizler.utils.api_call') +class TestDeleteTerm(unittest.TestCase): + + def test_one_term(self, mock_api_call): + set_id = 1 + term_id = 2 + client_id = 3 + delete_term(set_id, term_id, client_id) + mock_api_call.assert_called_once_with( + 'delete', + 'sets/{}/terms/{}'.format(set_id, term_id), + {}, + client_id + ) + + +@mock.patch('quizler.utils.api_call') +class TestAddTerm(unittest.TestCase): + + def test_one_term(self, mock_api_call): + set_id = 1 + term = TermFactory() + client_id = 3 + add_term(set_id, term, client_id) + mock_api_call.assert_called_once_with( + 'post', + 'sets/{}/terms'.format(set_id), + term.to_dict(), + client_id + ) + + +@mock.patch('quizler.utils.get_user_sets') +class TestResetTermStats(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.term0 = TermFactory(image=ImageFactory(url='http://domain.com/myimage.png')) + cls.term1 = TermFactory() + cls.term2 = TermFactory() + cls.wordset0 = WordSetFactory(terms=[cls.term0, cls.term1]) + cls.wordset1 = WordSetFactory(terms=[cls.term2]) + cls.wordsets = [cls.wordset0, cls.wordset1] + + def test_set_not_found(self, mock_get_user_sets): + mock_get_user_sets.return_value = self.wordsets + unknown_set_id = -1 + term_id = self.term0.term_id + client_id = 1 + user_id = 2 + with self.assertRaises(ValueError): + reset_term_stats(unknown_set_id, term_id, client_id, user_id) + + def test_term_not_found(self, mock_get_user_sets): + mock_get_user_sets.return_value = self.wordsets + set_id = self.wordset0.set_id + unknown_term_id = -1 + client_id = 1 + user_id = 2 + with self.assertRaises(ValueError): + reset_term_stats(set_id, unknown_term_id, client_id, user_id) + + def test_term_has_image(self, mock_get_user_sets): + mock_get_user_sets.return_value = self.wordsets + set_id = self.wordset0.set_id + term_id = self.term0.term_id + client_id = 1 + user_id = 2 + with self.assertRaises(NotImplementedError): + reset_term_stats(set_id, term_id, client_id, user_id) + + @mock.patch('quizler.utils.add_term') + @mock.patch('quizler.utils.delete_term') + def test_one_term(self, mock_delete_term, mock_add_term, mock_get_user_sets): + mock_get_user_sets.return_value = self.wordsets + set_id = self.wordset0.set_id + term_id = self.term1.term_id + client_id = 1 + user_id = 2 + reset_term_stats(set_id, term_id, client_id, user_id) + mock_delete_term.assert_called_once_with(set_id, term_id, client_id) + mock_add_term.assert_called_once_with(set_id, self.term1, client_id)