Skip to content

Commit 367bac4

Browse files
authored
Options improvements (#159)
* improvements to company options * tweak CompanyOptionsModel * allow tc domain * correct thumb var names * fix image saving * add support for tiff images * fix contractor sorting
1 parent e96f987 commit 367bac4

9 files changed

+116
-60
lines changed

tcsocket/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
FROM python:3.6-alpine
22

3-
RUN apk --update --no-cache add gcc g++ musl-dev zlib-dev libuv libffi-dev make postgresql-dev jpeg-dev \
3+
RUN apk --update --no-cache add gcc g++ musl-dev zlib-dev libuv libffi-dev make postgresql-dev jpeg-dev tiff-dev \
44
&& rm -rf /var/cache/apk/*
55

66
ADD ./requirements.txt /home/root/requirements.txt

tcsocket/app/middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ async def pg_conn_middleware(request, handler):
118118

119119

120120
def domain_allowed(allow_domains, current_domain):
121-
return any(
121+
return current_domain.endswith('tutorcruncher.com') or any(
122122
allow_domain == current_domain or (allow_domain.startswith('*') and current_domain.endswith(allow_domain[1:]))
123123
for allow_domain in allow_domains
124124
)

tcsocket/app/validation.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,17 @@ class RouterMode(str, Enum):
3131
history = 'history'
3232

3333

34+
@unique
35+
class SortOn(str, Enum):
36+
name = 'name'
37+
review_rating = 'review_rating'
38+
last_updated = 'last_updated'
39+
40+
3441
class CompanyCreateModal(BaseModel):
3542
name: constr(min_length=3, max_length=63)
43+
domains: Optional[List[constr(max_length=255)]] = []
3644
name_display: NameOptions = NameOptions.first_name_initial
37-
url: NoneStr = None
3845
public_key: constr(min_length=18, max_length=20) = None
3946
private_key: constr(min_length=20, max_length=50) = None
4047

@@ -53,13 +60,35 @@ class CompanyUpdateModel(BaseModel):
5360
private_key: constr(min_length=20, max_length=50) = None
5461

5562
domains: Optional[List[constr(max_length=255)]] = 'UNCHANGED'
56-
name_display: NameOptions = None
5763

64+
name_display: NameOptions = None
5865
show_stars: bool = None
5966
display_mode: DisplayMode = None
6067
router_mode: RouterMode = None
6168
show_hours_reviewed: bool = None
6269
show_labels: bool = None
70+
show_location_search: bool = None
71+
show_subject_filter: bool = None
72+
sort_on: SortOn = None
73+
pagination: int = None
74+
75+
76+
class CompanyOptionsModel(BaseModel):
77+
"""
78+
Used for options views, this is the definitive set of defaults for company options
79+
"""
80+
name: str
81+
name_display: NameOptions
82+
83+
show_stars: bool = True
84+
display_mode: DisplayMode = DisplayMode.grid
85+
router_mode: RouterMode = RouterMode.hash
86+
show_hours_reviewed: bool = True
87+
show_labels: bool = True
88+
show_location_search: bool = True
89+
show_subject_filter: bool = True
90+
sort_on: SortOn = SortOn.name
91+
pagination: int = 100
6392

6493

6594
class ContractorModel(BaseModel):

tcsocket/app/views.py

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,13 @@
1717
from sqlalchemy.dialects.postgresql import insert as pg_insert
1818
from sqlalchemy.sql import and_, distinct, or_
1919
from sqlalchemy.sql.functions import count as count_func
20-
from yarl import URL
2120

2221
from .geo import geocode, get_ip
2322
from .models import (Action, NameOptions, sa_companies, sa_con_skills, sa_contractors, sa_labels, sa_qual_levels,
2423
sa_subjects)
2524
from .processing import contractor_set as _contractor_set
2625
from .utils import HTTPBadRequestJson, json_response
27-
from .validation import CompanyCreateModal, CompanyUpdateModel, ContractorModel, DisplayMode, RouterMode
26+
from .validation import CompanyCreateModal, CompanyOptionsModel, CompanyUpdateModel, ContractorModel
2827

2928
EXTRA_ATTR_TYPES = 'checkbox', 'text_short', 'text_extended', 'integer', 'stars', 'dropdown', 'datetime', 'date'
3029
MISSING = object()
@@ -58,8 +57,7 @@ async def company_create(request):
5857
"""
5958
company: CompanyCreateModal = request['model']
6059
existing_company = bool(company.private_key)
61-
data = company.dict(exclude={'url'})
62-
data['domains'] = company.url and [URL(company.url).host] # TODO here for backwards compatibility, to be removed
60+
data = company.dict()
6361

6462
conn = await request['conn_manager'].get_connection()
6563
v = await conn.execute((
@@ -91,6 +89,12 @@ async def company_create(request):
9189
)
9290

9391

92+
OPTIONS_FIELDS = {
93+
'show_stars', 'display_mode', 'router_mode', 'show_hours_reviewed', 'show_labels', 'show_location_search',
94+
'show_subject_filter', 'sort_on', 'pagination'
95+
}
96+
97+
9498
async def company_update(request):
9599
"""
96100
Modify a company.
@@ -101,7 +105,7 @@ async def company_update(request):
101105
if company.domains != 'UNCHANGED':
102106
data['domains'] = company.domains
103107

104-
options = company.dict(include={'show_stars', 'display_mode', 'router_mode', 'show_hours_reviewed', 'show_labels'})
108+
options = company.dict(include=OPTIONS_FIELDS)
105109
options = {k: v for k, v in options.items() if v is not None}
106110
if options:
107111
data['options'] = options
@@ -148,17 +152,12 @@ async def company_options(request):
148152
"""
149153
Get a companies options
150154
"""
151-
options = request['company'].options or {}
152-
return json_response(
153-
request,
155+
opts = CompanyOptionsModel(
154156
name=request['company'].name,
155-
name_display=request['company'].name_display or NameOptions.first_name_initial,
156-
show_stars=options.get('show_stars') or False,
157-
display_mode=options.get('display_mode') or DisplayMode.grid,
158-
router_mode=options.get('router_mode') or RouterMode.hash,
159-
show_hours_reviewed=options.get('show_hours_reviewed') or False,
160-
show_labels=options.get('show_labels') or False,
157+
name_display=request['company'].name_display,
158+
**(request['company'].options or {})
161159
)
160+
return json_response(request, **opts.dict())
162161

163162

164163
async def contractor_set(request):
@@ -200,12 +199,10 @@ async def clear_enquiry(request):
200199
)
201200

202201

203-
DISTANCE_SORT = '__distance__'
204202
SORT_OPTIONS = {
205-
'update': sa_contractors.c.last_updated,
203+
'last_updated': sa_contractors.c.last_updated,
204+
'review_rating': sa_contractors.c.review_rating,
206205
'name': sa_contractors.c.first_name,
207-
'distance': DISTANCE_SORT,
208-
# TODO some configurable sort index
209206
}
210207

211208

@@ -247,7 +244,7 @@ def _get_arg(request, field, *, decoder: Callable[[str], Any]=int, default: Any=
247244

248245
async def contractor_list(request): # noqa: C901 (ignore complexity)
249246
sort_val = request.GET.get('sort')
250-
sort_col = SORT_OPTIONS.get(sort_val, SORT_OPTIONS['update'])
247+
sort_col = SORT_OPTIONS.get(sort_val, SORT_OPTIONS['last_updated'])
251248
page = _get_arg(request, 'page', default=1)
252249
pagination = min(_get_arg(request, 'pagination', default=100), 100)
253250
offset = (page - 1) * pagination
@@ -302,17 +299,12 @@ async def contractor_list(request): # noqa: C901 (ignore complexity)
302299
distance_func = func.earth_distance(request_loc, con_loc)
303300
where += distance_func < max_distance,
304301
fields += distance_func.label('distance'),
305-
if not sort_val:
306-
sort_col = DISTANCE_SORT
307-
if sort_col == DISTANCE_SORT:
308-
sort_col = distance_func
309-
elif sort_col == DISTANCE_SORT:
310-
raise HTTPBadRequestJson(
311-
status='invalid_argument',
312-
details=f'distance sorting not available if latitude and longitude are not provided',
313-
)
302+
sort_col = distance_func
303+
304+
sort_on = sort_col.asc()
305+
if sort_col in {sa_contractors.c.last_updated, sa_contractors.c.review_rating}:
306+
sort_on = sort_col.desc()
314307

315-
sort_on = sort_col.desc() if sort_col == sa_contractors.c.last_updated else sort_col.asc()
316308
q_iter = (
317309
select(fields)
318310
.where(and_(*where))

tcsocket/app/worker.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,12 @@ async def get_image(self, company, contractor_id, url):
7171
f.write(chunk)
7272
f.seek(0)
7373
with Image.open(f) as img:
74-
if img.mode == 'RGBA':
75-
img = img.convert('RGB')
76-
img_thumb = ImageOps.fit(img, SIZE_LARGE, Image.LANCZOS)
77-
img_thumb.save(path_str + '.jpg', 'JPEG')
74+
img = img.convert('RGB')
75+
img_large = ImageOps.fit(img, SIZE_LARGE, Image.LANCZOS)
76+
img_large.save(path_str + '.jpg', 'JPEG')
7877

79-
img_large = ImageOps.fit(img, SIZE_SMALL, Image.LANCZOS)
80-
img_large.save(path_str + '.thumb.jpg', 'JPEG')
78+
img_thumb = ImageOps.fit(img, SIZE_SMALL, Image.LANCZOS)
79+
img_thumb.save(path_str + '.thumb.jpg', 'JPEG')
8180
return 200
8281

8382
def request_headers(self, company):

tests/conftest.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pytest
1111
from aiohttp.web import Application, Response, json_response
1212
from aiopg.sa import create_engine as aio_create_engine
13-
from PIL import Image
13+
from PIL import Image, ImageDraw
1414
from sqlalchemy import create_engine as sa_create_engine
1515
from sqlalchemy import select
1616
from sqlalchemy.sql.functions import count as count_func
@@ -25,11 +25,21 @@
2525

2626

2727
async def test_image_view(request):
28-
image = Image.new('RGB', (2000, 1200), (50, 100, 150))
28+
image_format = request.GET.get('format')
2929
stream = BytesIO()
30-
image.save(stream, format='JPEG', optimize=True)
31-
request.app['request_log'].append('test_image')
32-
return Response(body=stream.getvalue(), content_type='image/jpeg')
30+
request.app['request_log'].append(('test_image', image_format))
31+
32+
if image_format == 'RGBA':
33+
create_as, save_as = 'RGBA', 'PNG'
34+
elif image_format == 'P':
35+
create_as, save_as = 'RGBA', 'GIF'
36+
else:
37+
create_as, save_as = 'RGB', 'JPEG'
38+
39+
image = Image.new(create_as, (2000, 1200), (50, 100, 150))
40+
ImageDraw.Draw(image).line((0, 0) + image.size, fill=128)
41+
image.save(stream, format=save_as, optimize=True)
42+
return Response(body=stream.getvalue(), content_type=f'image/{save_as.lower()}')
3343

3444

3545
async def contractor_list_view(request):

tests/test_public_filtering_views.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,6 @@ async def test_qual_level_list(cli, db_conn, company):
171171

172172
@pytest.mark.parametrize('params, con_distances', [
173173
({'location': 'SW1W 0EN'}, [('1-bcon-t', 3129), ('2-acon-t', 10054)]),
174-
({'location': 'SW1W 0EN', 'sort': 'name'}, [('2-acon-t', 10054), ('1-bcon-t', 3129)]),
175174
({'location': 'SW1W 0EN', 'max_distance': 4000}, [('1-bcon-t', 3129)]),
176175
({'location': 'SW1W 0ENx', 'max_distance': 4000}, [('2-acon-t', None), ('1-bcon-t', None)]),
177176
])
@@ -193,13 +192,6 @@ async def test_distance_filter(cli, db_conn, company, params, con_distances):
193192
obj = await r.json()
194193
assert list(map(itemgetter('link', 'distance'), obj['results'])) == con_distances
195194

196-
r = await cli.get(url, params={'sort': 'distance'})
197-
assert r.status == 400, await r.text()
198-
assert (await r.json()) == {
199-
'details': 'distance sorting not available if latitude and longitude are not provided',
200-
'status': 'invalid_argument'
201-
}
202-
203195

204196
async def test_geocode_cache(cli, other_server, company):
205197
url = str(cli.server.app.router['contractor-list'].url_for(company=company.public_key))

tests/test_set_company.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async def test_create(cli, db_conn):
3939
async def test_create_with_url_public_key(cli, db_conn):
4040
payload = json.dumps({
4141
'name': 'foobar',
42-
'url': 'https://www.example.com',
42+
'domains': ['www.example.com'],
4343
'public_key': 'X' * 20,
4444
'_request_time': int(time()),
4545
})
@@ -212,6 +212,24 @@ async def test_list_invalid_time(cli, company, payload_func, name):
212212
assert r.status == 403, await r.text()
213213

214214

215+
async def test_default_options(cli, db_conn, company):
216+
r = await cli.get(f'/{company.public_key}/options')
217+
assert r.status == 200, await r.text()
218+
assert {
219+
'display_mode': 'grid',
220+
'name': 'foobar',
221+
'name_display': 'first_name_initial',
222+
'pagination': 100,
223+
'router_mode': 'hash',
224+
'show_hours_reviewed': True,
225+
'show_labels': True,
226+
'show_location_search': True,
227+
'show_stars': True,
228+
'show_subject_filter': True,
229+
'sort_on': 'name',
230+
} == await r.json()
231+
232+
215233
async def test_update_company(cli, db_conn, company, other_server):
216234
curr = await db_conn.execute(sa_companies.select())
217235
result = await curr.first()
@@ -224,14 +242,21 @@ async def test_update_company(cli, db_conn, company, other_server):
224242
signing_key_='this is the master key',
225243
domains=['changed.com'],
226244
display_mode='enquiry-modal',
227-
show_hours_reviewed=True,
245+
show_location_search=False,
246+
pagination=20,
247+
sort_on='review_rating',
228248
)
229249
assert r.status == 200, await r.text()
230250
response_data = await r.json()
231251
assert response_data == {
232252
'details': {
233253
'domains': ['changed.com'],
234-
'options': {'display_mode': 'enquiry-modal', 'show_hours_reviewed': True},
254+
'options': {
255+
'display_mode': 'enquiry-modal',
256+
'show_location_search': False,
257+
'pagination': 20,
258+
'sort_on': 'review_rating',
259+
}
235260
},
236261
'company_domains': ['changed.com'],
237262
'status': 'success',
@@ -241,18 +266,23 @@ async def test_update_company(cli, db_conn, company, other_server):
241266
curr = await db_conn.execute(sa_companies.select())
242267
result = await curr.first()
243268
assert result.domains == ['changed.com']
244-
assert result.options == {'display_mode': 'enquiry-modal', 'show_hours_reviewed': True}
269+
assert result.options == {'display_mode': 'enquiry-modal', 'pagination': 20, 'show_location_search': False,
270+
'sort_on': 'review_rating'}
245271

246272
r = await cli.get(f'/{company.public_key}/options')
247273
assert r.status == 200, await r.text()
248274
assert {
249275
'display_mode': 'enquiry-modal',
250276
'name': 'foobar',
251277
'name_display': 'first_name_initial',
278+
'pagination': 20,
252279
'router_mode': 'hash',
253280
'show_hours_reviewed': True,
254-
'show_labels': False,
255-
'show_stars': False,
281+
'show_labels': True,
282+
'show_location_search': False,
283+
'show_stars': True,
284+
'show_subject_filter': True,
285+
'sort_on': 'review_rating',
256286
} == await r.json()
257287

258288

tests/test_set_contractor.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
from time import time
66

7+
import pytest
78
from PIL import Image
89

910
from tcsocket.app.models import sa_con_skills, sa_contractors, sa_labels, sa_qual_levels, sa_subjects
@@ -278,15 +279,18 @@ async def test_extra_attributes_null(cli, db_conn, company):
278279
assert result.primary_description is None
279280

280281

281-
async def test_photo(cli, db_conn, company, image_download_url, tmpdir):
282+
@pytest.mark.parametrize('image_format', ['JPEG', 'RGBA', 'P'])
283+
async def test_photo(cli, db_conn, company, image_download_url, tmpdir, other_server, image_format):
282284
r = await signed_post(
283285
cli,
284286
f'/{company.public_key}/webhook/contractor',
285287
id=123,
286288
first_name='Fred',
287-
photo=image_download_url
289+
photo=f'{image_download_url}?format={image_format}'
288290
)
289291
assert r.status == 201, await r.text()
292+
assert other_server.app['request_log'] == [('test_image', image_format)]
293+
290294
assert [cs.first_name async for cs in await db_conn.execute(sa_contractors.select())] == ['Fred']
291295
path = Path(tmpdir / 'media' / company.public_key / '123.jpg')
292296
assert path.exists()

0 commit comments

Comments
 (0)