Skip to content

Commit 6db18ba

Browse files
Merge pull request #70 from aaron-suarez/timestamps
Add create and update timestamps to Resource objects
2 parents 78d50d2 + b6dd327 commit 6db18ba

File tree

7 files changed

+271
-38
lines changed

7 files changed

+271
-38
lines changed

.env

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
FLASK_APP=run.py
2-
SQLALCHEMY_DATABASE_URI=postgresql://user_name:change_password@127.0.0.1:5432/resources
2+
SQLALCHEMY_DATABASE_URI=postgresql://aaron:getmein@127.0.0.1:5432/resources
33
FLASK_SKIP_DOTENV=1
4+
FLASK_ENV=development

app/api/routes.py

+37-22
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
from traceback import print_tb
22

33
from flask import request
4-
from sqlalchemy import and_, func
4+
from sqlalchemy import or_, func
55
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
66

77
from app.api import bp
88
from app.models import Language, Resource, Category
99
from app import Config, db
1010
from app.utils import Paginator, standardize_response
11+
from dateutil import parser
12+
from datetime import datetime
13+
import logging
14+
15+
logger = logging.getLogger()
1116

1217

1318
# Routes
@@ -72,43 +77,53 @@ def get_resources():
7277
# Fetch the filter params from the url, if they were provided.
7378
language = request.args.get('language')
7479
category = request.args.get('category')
80+
updated_after = request.args.get('updated_after')
81+
82+
q = Resource.query
7583

7684
# Filter on language
77-
if language and not category:
78-
query = Resource.query.filter(
85+
if language:
86+
q = q.filter(
7987
Resource.languages.any(
8088
Language.name.ilike(language)
8189
)
8290
)
8391

8492
# Filter on category
85-
elif category and not language:
86-
query = Resource.query.filter(
93+
if category:
94+
q = q.filter(
8795
Resource.category.has(
8896
func.lower(Category.name) == category.lower()
8997
)
9098
)
9199

92-
# Filter on both
93-
elif category and language:
94-
query = Resource.query.filter(
95-
and_(
96-
Resource.languages.any(
97-
Language.name.ilike(language)
98-
),
99-
Resource.category.has(
100-
func.lower(Category.name) == category.lower()
101-
)
100+
# Filter on updated_after
101+
if updated_after:
102+
try:
103+
uaDate = parser.parse(updated_after)
104+
if uaDate > datetime.now():
105+
raise Exception("updated_after greater than today's date")
106+
uaDate = uaDate.strftime("%Y-%m-%d")
107+
except Exception as e:
108+
logger.error(e)
109+
message = 'The value for "updated_after" is invalid'
110+
error = [{"code": "bad-value", "message": message}]
111+
return standardize_response(None, error, "unprocessable-entity", 422)
112+
113+
q = q.filter(
114+
or_(
115+
Resource.created_at >= uaDate,
116+
Resource.last_updated >= uaDate
102117
)
103118
)
104119

105-
# No filters
106-
else:
107-
query = Resource.query
108-
109-
resource_list = [
110-
resource.serialize for resource in resource_paginator.items(query)
111-
]
120+
try:
121+
resource_list = [
122+
resource.serialize for resource in resource_paginator.items(q)
123+
]
124+
except Exception as e:
125+
logger.error(e)
126+
return standardize_response(None, [{"code": "bad-request"}], "bad request", 400)
112127

113128
return standardize_response(resource_list, None, "ok")
114129

app/models.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from app import db
22
from sqlalchemy_utils import URLType
3+
from sqlalchemy import DateTime
4+
from sqlalchemy.sql import func
35

46

57
language_identifier = db.Table('language_identifier',
@@ -28,6 +30,8 @@ class Resource(db.Model):
2830
upvotes = db.Column(db.INTEGER, default=0)
2931
downvotes = db.Column(db.INTEGER, default=0)
3032
times_clicked = db.Column(db.INTEGER, default=0)
33+
created_at = db.Column(DateTime(timezone=True), server_default=func.now())
34+
last_updated = db.Column(DateTime(timezone=True), onupdate=func.now())
3135

3236
@property
3337
def serialize(self):

app/utils.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ def items(self, query):
1616
return query.paginate(self.page, self.page_size, False).items
1717

1818

19-
def standardize_response(data, errors, status):
19+
def standardize_response(data, errors, status, status_code=200):
2020
resp = {
2121
"status": status,
2222
"apiVersion": API_VERSION
2323
}
24-
if data:
24+
if data is not None:
2525
resp["data"] = data
2626
elif errors:
2727
resp["errors"] = errors
2828
else:
2929
resp["errors"] = [{"code": "something-went-wrong"}]
30-
return jsonify(resp)
30+
return jsonify(resp), status_code

pytest.ini

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
addopts = -p no:warnings

tests/conftest.py

+56-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,58 @@
11
import pytest
2-
from app import create_app
2+
from app import create_app, db as _db
3+
from configs import Config
34

4-
@pytest.fixture(scope='module')
5-
def app():
6-
app = create_app()
7-
return app
5+
TEST_DATABASE_URI = 'sqlite:///:memory:'
6+
7+
counter = 0
8+
9+
10+
@pytest.fixture(scope='session')
11+
def app(request):
12+
Config.SQLALCHEMY_DATABASE_URI = TEST_DATABASE_URI
13+
Config.TESTING = True
14+
app = create_app(Config)
15+
16+
17+
# Establish an application context before running the tests.
18+
ctx = app.app_context()
19+
ctx.push()
20+
21+
def teardown():
22+
ctx.pop()
23+
24+
request.addfinalizer(teardown)
25+
return app
26+
27+
28+
@pytest.fixture(scope='session')
29+
def db(app, request):
30+
"""Session-wide test database."""
31+
def teardown():
32+
_db.drop_all()
33+
34+
_db.app = app
35+
_db.create_all()
36+
37+
request.addfinalizer(teardown)
38+
return _db
39+
40+
41+
@pytest.fixture(scope='function')
42+
def session(db, request):
43+
"""Creates a new database session for a test."""
44+
connection = db.engine.connect()
45+
transaction = connection.begin()
46+
47+
options = dict(bind=connection, binds={})
48+
session = db.create_scoped_session(options=options)
49+
50+
db.session = session
51+
52+
def teardown():
53+
transaction.rollback()
54+
connection.close()
55+
session.remove()
56+
57+
request.addfinalizer(teardown)
58+
return session

tests/unit/test_routes.py

+167-7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,171 @@
11
import pytest
2+
from tests import conftest
23
from app.models import Resource, Language, Category
4+
from configs import PaginatorConfig
5+
from app.cli import import_resources
36

47

5-
def test_does_nothing():
6-
"""
7-
GIVEN a User model
8-
WHEN a new User is created
9-
THEN check the email, hashed_password, authenticated, and role fields are defined correctly
10-
"""
11-
assert(1 == 1)
8+
##########################################
9+
## Tests
10+
##########################################
11+
12+
# TODO: We need negative unit tests (what happens when bad data is sent)
13+
14+
def test_getters(app, session, db):
15+
# Importing the data takes a rather long time, so import it once
16+
# here for all the getters that require pages of resources
17+
import_resources(db)
18+
client = app.test_client()
19+
20+
# Actually conduct all the tests in helper functions
21+
get_resources_test(app, session, db, client)
22+
paginator_test(app, session, db, client)
23+
filters_test(app, session, db, client)
24+
languages_test(app, session, db, client)
25+
categories_test(app, session, db, client)
26+
27+
28+
def test_create_resource(app, session, db):
29+
client = app.test_client()
30+
31+
response = create_resource(client)
32+
assert(response.status_code == 200)
33+
assert(isinstance(response.json['data'].get('id'), int))
34+
assert(response.json['data'].get('name') == "Some Name")
35+
36+
37+
def test_update_resource(app, session, db):
38+
client = app.test_client()
39+
40+
response = create_resource(client)
41+
42+
id = response.json['data'].get('id')
43+
assert(isinstance(id, int))
44+
45+
response = client.put(f"/api/v1/resources/{id}", json={
46+
"name": "New name"
47+
})
48+
49+
assert(response.status_code == 200)
50+
assert(response.json['data'].get('name') == "New name")
51+
52+
53+
##########################################
54+
## Helpers
55+
##########################################
56+
57+
def get_resources_test(app, session, db, client):
58+
response = client.get('api/v1/resources')
59+
60+
# Status should be OK
61+
assert(response.status_code == 200)
62+
63+
resources = response.json
64+
65+
# Default page size shouold be specified in PaginatorConfig
66+
assert(len(resources['data']) == PaginatorConfig.per_page)
67+
check_resources(resources['data'])
68+
69+
70+
def get_single_resource_test(app, session, db, client):
71+
response = client.get('api/v1/resources/5')
72+
73+
# Status should be OK
74+
assert(response.status_code == 200)
75+
76+
resource = response.json
77+
78+
check_resources([resources['data']])
79+
assert(resources['data'].get('id') == 5)
80+
81+
82+
83+
def paginator_test(app, session, db, client):
84+
# Test page size
85+
response = client.get('api/v1/resources?page_size=1')
86+
assert(len(response.json['data']) == 1)
87+
response = client.get('api/v1/resources?page_size=5')
88+
assert(len(response.json['data']) == 5)
89+
response = client.get('api/v1/resources?page_size=10')
90+
assert(len(response.json['data']) == 10)
91+
response = client.get('api/v1/resources?page_size=100')
92+
assert(len(response.json['data']) == 100)
93+
94+
# Test pages different and sequential
95+
first_page_resource = response.json['data'][0]
96+
assert(first_page_resource.get('id') == 1)
97+
response = client.get('api/v1/resources?page_size=100&page=2')
98+
second_page_resource = response.json['data'][0]
99+
assert(second_page_resource.get('id') == 101)
100+
response = client.get('api/v1/resources?page_size=100&page=3')
101+
third_page_resource = response.json['data'][0]
102+
assert(third_page_resource.get('id') == 201)
103+
104+
# Test bigger than max page size
105+
too_long = PaginatorConfig.max_page_size + 1
106+
response = client.get(f"api/v1/resources?page_size={too_long}")
107+
assert(len(response.json['data']) == PaginatorConfig.max_page_size)
108+
109+
# Test farther than last page
110+
too_far = 99999999
111+
response = client.get(f"api/v1/resources?page_size=100&page={too_far}")
112+
assert(len(response.json['data']) == 0)
113+
114+
115+
def filters_test(app, session, db, client):
116+
# Filter by language
117+
response = client.get('api/v1/resources?language=python')
118+
119+
for resource in response.json['data']:
120+
assert(type(resource.get('languages')) is list)
121+
assert('Python' in resource.get('languages'))
122+
123+
# Filter by category
124+
response = client.get('api/v1/resources?category=Back%20End%20Dev')
125+
126+
for resource in response.json['data']:
127+
assert(resource.get('category') == "Back End Dev")
128+
129+
# TODO: Filter by updated_after
130+
# (Need to figure out how to manually set last_updated and created_at)
131+
132+
133+
def languages_test(app, session, db, client):
134+
response = client.get('api/v1/languages')
135+
136+
for language in response.json['data']:
137+
assert(isinstance(language.get('id'), int))
138+
assert(isinstance(language.get('name'), str))
139+
assert(len(language.get('name')) > 0)
140+
141+
142+
def categories_test(app, session, db, client):
143+
response = client.get('api/v1/categories')
144+
145+
for category in response.json['data']:
146+
assert(isinstance(category.get('id'), int))
147+
assert(isinstance(category.get('name'), str))
148+
assert(len(category.get('name')) > 0)
149+
150+
151+
def check_resources(resources):
152+
# Each resource should have a name, url, languages and category
153+
for resource in resources:
154+
assert(isinstance(resource.get('name'), str))
155+
assert(resource.get('name') != "")
156+
assert(isinstance(resource.get('url'), str))
157+
assert(resource.get('url') != "")
158+
assert(isinstance(resource.get('category'), str))
159+
assert(resource.get('category') != "")
160+
assert(type(resource.get('languages')) is list)
161+
162+
163+
def create_resource(client):
164+
return client.post('/api/v1/resources', json={
165+
"name": "Some Name",
166+
"url": "http://example.org/",
167+
"category": "New Category",
168+
"languages": ["Python", "New Language"],
169+
"paid": False,
170+
"notes": "Some notes"
171+
})

0 commit comments

Comments
 (0)