This repository has been archived by the owner on Apr 11, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 11
/
lane.py
2985 lines (2523 loc) · 108 KB
/
lane.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# encoding: utf-8
from collections import defaultdict
from nose.tools import set_trace
import datetime
import logging
import time
import urllib
from psycopg2.extras import NumericRange
from sqlalchemy.sql import select
from sqlalchemy.sql.expression import Select
from sqlalchemy.dialects.postgresql import JSON
from config import Configuration
from flask_babel import lazy_gettext as _
import classifier
from classifier import (
Classifier,
GenreData,
)
from sqlalchemy import (
and_,
case,
or_,
not_,
Integer,
Table,
Unicode,
text,
)
from sqlalchemy.ext.associationproxy import (
association_proxy,
)
from sqlalchemy.ext.hybrid import (
hybrid_property,
)
from sqlalchemy.orm import (
aliased,
backref,
contains_eager,
defer,
joinedload,
lazyload,
relationship,
)
from sqlalchemy.sql.expression import literal
from entrypoint import (
EntryPoint,
EverythingEntryPoint,
)
from model import (
directly_modified,
get_one_or_create,
numericrange_to_tuple,
site_configuration_has_changed,
tuple_to_numericrange,
Base,
CachedFeed,
Collection,
CustomList,
CustomListEntry,
DataSource,
DeliveryMechanism,
Edition,
Genre,
get_one,
Library,
LicensePool,
LicensePoolDeliveryMechanism,
Session,
Work,
WorkGenre,
)
from model.constants import EditionConstants
from facets import FacetConstants
from problem_details import *
from util import (
fast_query_count,
LanguageCodes,
)
from util.problem_detail import ProblemDetail
from util.accept_language import parse_accept_language
from util.opds_writer import OPDSFeed
import elasticsearch
from sqlalchemy import (
event,
Boolean,
Column,
ForeignKey,
Integer,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import (
ARRAY,
INT4RANGE,
)
class BaseFacets(FacetConstants):
"""Basic faceting class that doesn't modify a search filter at all.
This is intended solely for use as a base class.
"""
# If the use of a certain faceting object has implications for the
# type of feed (the way FeaturedFacets always implies a 'groups' feed),
# set the type of feed here. This will override any CACHED_FEED_TYPE
# associated with the WorkList.
CACHED_FEED_TYPE = None
# By default, faceting objects have no opinion on how long the feeds
# generated using them should be cached.
max_cache_age = None
def items(self):
"""Yields a 2-tuple for every active facet setting.
These tuples are used to generate URLs that can identify
specific facet settings, and to distinguish between CachedFeed
objects that represent the same feed with different facet
settings.
"""
return []
@property
def cached(self):
"""This faceting object's opinion on whether feeds should be cached.
:return: A boolean, or None for 'no opinion'.
"""
if self.max_cache_age is None:
return None
return (self.max_cache_age != 0)
@property
def query_string(self):
"""A query string fragment that propagates all active facet
settings.
"""
return "&".join("=".join(x) for x in sorted(self.items()))
@property
def facet_groups(self):
"""Yield a list of 4-tuples
(facet group, facet value, new Facets object, selected)
for use in building OPDS facets.
This does not include the 'entry point' facet group,
which must be handled separately.
"""
return []
@classmethod
def selectable_entrypoints(cls, worklist):
"""Ignore all entry points, even if the WorkList supports them."""
return []
def modify_search_filter(self, filter):
"""Modify an external_search.Filter object to filter out works
excluded by the business logic of this faceting class.
"""
return filter
def modify_database_query(cls, _db, qu):
"""If necessary, modify a database query so that resulting
items conform the constraints of this faceting object.
The default behavior is to not modify the query.
"""
return qu
def scoring_functions(self, filter):
"""Create a list of ScoringFunction objects that modify how
works in the given WorkList should be ordered.
Most subclasses will not use this because they order
works using the 'order' feature.
"""
return []
class FacetsWithEntryPoint(BaseFacets):
"""Basic Facets class that knows how to filter a query based on a
selected EntryPoint.
"""
def __init__(self, entrypoint=None, entrypoint_is_default=False,
max_cache_age=None, **kwargs):
"""Constructor.
:param entrypoint: An EntryPoint (optional).
:param entrypoint_is_default: If this is True, then `entrypoint`
is a default value and was not determined by a user's
explicit choice.
:param max_cache_age: Any feeds generated by this faceting object
will be cached for this amount of time. The default is to have
no opinion and let the Worklist manage this.
:param kwargs: Other arguments may be supplied based on user
input, but the default implementation is to ignore them.
"""
self.entrypoint = entrypoint
self.entrypoint_is_default = entrypoint_is_default
self.max_cache_age = max_cache_age
self.constructor_kwargs = kwargs
@classmethod
def selectable_entrypoints(cls, worklist):
"""Which EntryPoints can be selected for these facets on this
WorkList?
In most cases, there are no selectable EntryPoints; this generally
happens only at the top level.
By default, this is completely determined by the WorkList.
See SearchFacets for an example that changes this.
"""
if not worklist:
return []
return worklist.entrypoints
def navigate(self, entrypoint):
"""Create a very similar FacetsWithEntryPoint that points to
a different EntryPoint.
"""
return self.__class__(
entrypoint=entrypoint, entrypoint_is_default=False,
max_cache_age=self.max_cache_age,
**self.constructor_kwargs
)
@classmethod
def from_request(
cls, library, facet_config, get_argument, get_header, worklist,
default_entrypoint=None, **extra_kwargs
):
"""Load a faceting object from an HTTP request.
:param facet_config: A Library (or mock of one) that knows
which subset of the available facets are configured.
:param get_argument: A callable that takes one argument and
retrieves (or pretends to retrieve) a query string
parameter of that name from an incoming HTTP request.
:param get_header: A callable that takes one argument and
retrieves (or pretends to retrieve) an HTTP header
of that name from an incoming HTTP request.
:param worklist: A WorkList associated with the current request,
if any.
:param default_entrypoint: Select this EntryPoint if the
incoming request does not specify an enabled EntryPoint.
If this is None, the first enabled EntryPoint will be used
as the default.
:param extra_kwargs: A dictionary of keyword arguments to pass
into the constructor when a faceting object is instantiated.
:return: A FacetsWithEntryPoint, or a ProblemDetail if there's
a problem with the input from the request.
"""
return cls._from_request(
facet_config, get_argument, get_header, worklist,
default_entrypoint, **extra_kwargs
)
@classmethod
def _from_request(
cls, facet_config, get_argument, get_header, worklist,
default_entrypoint=None, **extra_kwargs
):
"""Load a faceting object from an HTTP request.
Subclasses of FacetsWithEntryPoint can override `from_request`,
but call this method to load the EntryPoint and actually
instantiate the faceting class.
"""
entrypoint_name = get_argument(
Facets.ENTRY_POINT_FACET_GROUP_NAME, None
)
valid_entrypoints = list(cls.selectable_entrypoints(facet_config))
entrypoint = cls.load_entrypoint(
entrypoint_name, valid_entrypoints, default=default_entrypoint
)
if isinstance(entrypoint, ProblemDetail):
return entrypoint
entrypoint, is_default = entrypoint
max_cache_age = get_argument(
Facets.MAX_CACHE_AGE_NAME, None
)
max_cache_age = cls.load_max_cache_age(max_cache_age)
if isinstance(max_cache_age, ProblemDetail):
return max_cache_age
return cls(
entrypoint=entrypoint, entrypoint_is_default=is_default,
max_cache_age=max_cache_age, **extra_kwargs
)
@classmethod
def load_entrypoint(cls, name, valid_entrypoints, default=None):
"""Look up an EntryPoint by name, assuming it's valid in the
given WorkList.
:param valid_entrypoints: The EntryPoints that might be
valid. This is probably not the value of
WorkList.selectable_entrypoints, because an EntryPoint
selected in a WorkList remains valid (but not selectable) for
all of its children.
:param default: A class to use as the default EntryPoint if
none is specified. If no default is specified, the first
enabled EntryPoint will be used.
:return: A 2-tuple (EntryPoint class, is_default).
"""
if not valid_entrypoints:
return None, True
if default is None:
default = valid_entrypoints[0]
ep = EntryPoint.BY_INTERNAL_NAME.get(name)
if not ep or ep not in valid_entrypoints:
return default, True
return ep, False
@classmethod
def load_max_cache_age(cls, value):
"""Convert a value for the MAX_CACHE_AGE_NAME parameter to a value
that CachedFeed will understand.
:param value: A string.
:return: For now, either CachedFeed.IGNORE_CACHE or None.
"""
if value is None:
return value
try:
value = int(value)
except ValueError, e:
value = None
# At the moment, the only acceptable value that can be set
# through the web is zero -- i.e. don't use the cache at
# all. We can't give web clients fine-grained control over
# the internal workings of our cache; the most we can do
# is give them the opportunity to opt out.
#
# Thus, any nonzero value will be ignored.
if value == 0:
value = CachedFeed.IGNORE_CACHE
else:
value = None
return value
def items(self):
"""Yields a 2-tuple for every active facet setting.
In this class that just means the entrypoint and any max_cache_age.
"""
if self.entrypoint:
yield (self.ENTRY_POINT_FACET_GROUP_NAME,
self.entrypoint.INTERNAL_NAME)
if self.max_cache_age not in (None, CachedFeed.CACHE_FOREVER):
if self.max_cache_age == CachedFeed.IGNORE_CACHE:
value = 0
else:
value = self.max_cache_age
yield (self.MAX_CACHE_AGE_NAME, unicode(value))
def modify_search_filter(self, filter):
"""Modify the given external_search.Filter object
so that it reflects this set of facets.
"""
if self.entrypoint:
self.entrypoint.modify_search_filter(filter)
return filter
def modify_database_query(self, _db, qu):
"""Modify the given database query so that it reflects this set of
facets.
"""
if self.entrypoint:
qu = self.entrypoint.modify_database_query(_db, qu)
return qu
class Facets(FacetsWithEntryPoint):
"""A full-fledged facet class that supports complex navigation between
multiple facet groups.
Despite the generic name, this is only used in 'page' type OPDS
feeds that list all the works in some WorkList.
"""
ORDER_BY_RELEVANCE = "relevance"
@classmethod
def default(cls, library, collection=None, availability=None, order=None,
entrypoint=None):
return cls(library, collection=collection, availability=availability,
order=order, entrypoint=entrypoint)
@classmethod
def available_facets(cls, config, facet_group_name):
"""Which facets are enabled for the given facet group?
You can override this to forcible enable or disable facets
that might not be enabled in library configuration, but you
can't make up totally new facets.
TODO: This sytem would make more sense if you _could_ make up
totally new facets, maybe because each facet was represented
as a policy object rather than a key to code implemented
elsewhere in this class. Right now this method implies more
flexibility than actually exists.
"""
available = config.enabled_facets(facet_group_name)
# "The default facet isn't available" makes no sense. If the
# default facet is not in the available list for any reason,
# add it to the beginning of the list. This makes other code
# elsewhere easier to write.
default = cls.default_facet(config, facet_group_name)
if default not in available:
available = [default] + available
return available
@classmethod
def default_facet(cls, config, facet_group_name):
"""The default value for the given facet group.
The default value must be one of the values returned by available_facets() above.
"""
return config.default_facet(facet_group_name)
@classmethod
def _values_from_request(cls, config, get_argument, get_header):
g = Facets.ORDER_FACET_GROUP_NAME
order = get_argument(g, cls.default_facet(config, g))
order_facets = cls.available_facets(config, g)
if order and not order in order_facets:
return INVALID_INPUT.detailed(
_("I don't know how to order a feed by '%(order)s'", order=order),
400
)
g = Facets.AVAILABILITY_FACET_GROUP_NAME
availability = get_argument(g, cls.default_facet(config, g))
availability_facets = cls.available_facets(config, g)
if availability and not availability in availability_facets:
return INVALID_INPUT.detailed(
_("I don't understand the availability term '%(availability)s'", availability=availability),
400
)
g = Facets.COLLECTION_FACET_GROUP_NAME
collection = get_argument(g, cls.default_facet(config, g))
collection_facets = cls.available_facets(config, g)
if collection and not collection in collection_facets:
return INVALID_INPUT.detailed(
_("I don't understand what '%(collection)s' refers to.", collection=collection),
400
)
enabled = {
Facets.ORDER_FACET_GROUP_NAME : order_facets,
Facets.AVAILABILITY_FACET_GROUP_NAME : availability_facets,
Facets.COLLECTION_FACET_GROUP_NAME : collection_facets,
}
return dict(
order=order, availability=availability, collection=collection,
enabled_facets=enabled
)
@classmethod
def from_request(cls, library, config, get_argument, get_header, worklist,
default_entrypoint=None, **extra):
"""Load a faceting object from an HTTP request."""
values = cls._values_from_request(config, get_argument, get_header)
if isinstance(values, ProblemDetail):
return values
extra.update(values)
extra['library'] = library
return cls._from_request(config, get_argument, get_header, worklist,
default_entrypoint, **extra)
def __init__(self, library, collection, availability, order,
order_ascending=None, enabled_facets=None, entrypoint=None,
entrypoint_is_default=False, **constructor_kwargs):
"""Constructor.
:param collection: This is not a Collection object; it's a value for
the 'collection' facet, e.g. 'main' or 'featured'.
:param entrypoint: An EntryPoint class. The 'entry point'
facet group is configured on a per-WorkList basis rather than
a per-library basis.
"""
super(Facets, self).__init__(
entrypoint, entrypoint_is_default, **constructor_kwargs
)
collection = collection or self.default_facet(
library, self.COLLECTION_FACET_GROUP_NAME
)
availability = availability or self.default_facet(
library, self.AVAILABILITY_FACET_GROUP_NAME
)
order = order or self.default_facet(library, self.ORDER_FACET_GROUP_NAME)
if order_ascending is None:
if order in Facets.ORDER_DESCENDING_BY_DEFAULT:
order_ascending = self.ORDER_DESCENDING
else:
order_ascending = self.ORDER_ASCENDING
if (availability == self.AVAILABLE_ALL and (library and not library.allow_holds)
and (self.AVAILABLE_NOW in self.available_facets(library, self.AVAILABILITY_FACET_GROUP_NAME))):
# Under normal circumstances we would show all works, but
# library configuration says to hide books that aren't
# available.
availability = self.AVAILABLE_NOW
self.library = library
self.collection = collection
self.availability = availability
self.order = order
if order_ascending == self.ORDER_ASCENDING:
order_ascending = True
elif order_ascending == self.ORDER_DESCENDING:
order_ascending = False
self.order_ascending = order_ascending
self.facets_enabled_at_init = enabled_facets
def navigate(self, collection=None, availability=None, order=None,
entrypoint=None):
"""Create a slightly different Facets object from this one."""
return self.__class__(
library=self.library,
collection=collection or self.collection,
availability=availability or self.availability,
order=order or self.order,
enabled_facets=self.facets_enabled_at_init,
entrypoint=(entrypoint or self.entrypoint),
entrypoint_is_default=False,
max_cache_age=self.max_cache_age
)
def items(self):
for k,v in super(Facets, self).items():
yield k, v
if self.order:
yield (self.ORDER_FACET_GROUP_NAME, self.order)
if self.availability:
yield (self.AVAILABILITY_FACET_GROUP_NAME, self.availability)
if self.collection:
yield (self.COLLECTION_FACET_GROUP_NAME, self.collection)
@property
def enabled_facets(self):
"""Yield a 3-tuple of lists (order, availability, collection)
representing facet values enabled via initialization or configuration
The 'entry point' facet group is handled separately, since it
is not always used.
"""
if self.facets_enabled_at_init:
# When this Facets object was initialized, a list of enabled
# facets was passed. We'll only work with those facets.
facet_types = [
self.ORDER_FACET_GROUP_NAME,
self.AVAILABILITY_FACET_GROUP_NAME,
self.COLLECTION_FACET_GROUP_NAME
]
for facet_type in facet_types:
yield self.facets_enabled_at_init.get(facet_type, [])
else:
library = self.library
for group_name in (
Facets.ORDER_FACET_GROUP_NAME,
Facets.AVAILABILITY_FACET_GROUP_NAME,
Facets.COLLECTION_FACET_GROUP_NAME
):
yield self.available_facets(self.library, group_name)
@property
def facet_groups(self):
"""Yield a list of 4-tuples
(facet group, facet value, new Facets object, selected)
for use in building OPDS facets.
This does not yield anything for the 'entry point' facet group,
which must be handled separately.
"""
order_facets, availability_facets, collection_facets = self.enabled_facets
def dy(new_value):
group = self.ORDER_FACET_GROUP_NAME
current_value = self.order
facets = self.navigate(order=new_value)
return (group, new_value, facets, current_value==new_value)
# First, the order facets.
if len(order_facets) > 1:
for facet in order_facets:
yield dy(facet)
# Next, the availability facets.
def dy(new_value):
group = self.AVAILABILITY_FACET_GROUP_NAME
current_value = self.availability
facets = self.navigate(availability=new_value)
return (group, new_value, facets, new_value==current_value)
if len(availability_facets) > 1:
for facet in availability_facets:
yield dy(facet)
# Next, the collection facets.
def dy(new_value):
group = self.COLLECTION_FACET_GROUP_NAME
current_value = self.collection
facets = self.navigate(collection=new_value)
return (group, new_value, facets, new_value==current_value)
if len(collection_facets) > 1:
for facet in collection_facets:
yield dy(facet)
def modify_search_filter(self, filter):
"""Modify the given external_search.Filter object
so that it reflects the settings of this Facets object.
This is the Elasticsearch equivalent of apply(). However, the
Elasticsearch implementation of (e.g.) the meaning of the
different availabilty statuses is kept in Filter.build().
"""
super(Facets, self).modify_search_filter(filter)
if self.library:
filter.minimum_featured_quality = self.library.minimum_featured_quality
filter.availability = self.availability
filter.subcollection = self.collection
# No order and relevance order both signify the default and,
# thus, either should leave `filter.order` unset.
if self.order and self.order != self.ORDER_BY_RELEVANCE:
order = self.SORT_ORDER_TO_ELASTICSEARCH_FIELD_NAME.get(self.order)
if order:
filter.order = order
filter.order_ascending = self.order_ascending
else:
logging.error("Unrecognized sort order: %s", self.order)
def modify_database_query(self, _db, qu):
"""Restrict a query against Work+LicensePool+Edition so that it
matches only works that fit the criteria of this Faceting object.
Sort order facet cannot be handled in this method, but can be
handled in subclasses that override this method.
"""
# Apply any superclass criteria
qu = super(Facets, self).modify_database_query(_db, qu)
if self.availability == self.AVAILABLE_NOW:
availability_clause = or_(
LicensePool.open_access == True,
LicensePool.licenses_available > 0
)
elif self.availability == self.AVAILABLE_ALL:
availability_clause = or_(
LicensePool.open_access == True,
LicensePool.licenses_owned > 0
)
elif self.availability == self.AVAILABLE_OPEN_ACCESS:
availability_clause = LicensePool.open_access == True
qu = qu.filter(availability_clause)
if self.collection == self.COLLECTION_FULL:
# Include everything.
pass
elif self.collection == self.COLLECTION_FEATURED:
# Exclude books with a quality of less than the library's
# minimum featured quality.
qu = qu.filter(
Work.quality >= self.library.minimum_featured_quality
)
return qu
class DefaultSortOrderFacets(Facets):
"""A faceting object that changes the default sort order.
Subclasses must set DEFAULT_SORT_ORDER
"""
@classmethod
def available_facets(cls, config, facet_group_name):
"""Make sure the default sort order is the first item
in the list of available sort orders.
"""
if facet_group_name != cls.ORDER_FACET_GROUP_NAME:
return super(DefaultSortOrderFacets, cls).available_facets(
config, facet_group_name
)
default = config.enabled_facets(facet_group_name)
# Promote the default sort order to the front of the list,
# adding it if necessary.
order = cls.DEFAULT_SORT_ORDER
if order in default:
default = filter(lambda x: x!=order, default)
return [order] + default
@classmethod
def default_facet(cls, config, facet_group_name):
if facet_group_name == cls.ORDER_FACET_GROUP_NAME:
return cls.DEFAULT_SORT_ORDER
return super(DefaultSortOrderFacets, cls).default_facet(
config, facet_group_name
)
class DatabaseBackedFacets(Facets):
"""A generic faceting object designed for managing queries against the
database. (Other faceting objects are designed for managing
Elasticsearch searches.)
"""
# Of the sort orders in Facets, these are the only available ones
# -- they map directly onto a field of one of the tables we're
# querying.
ORDER_FACET_TO_DATABASE_FIELD = {
FacetConstants.ORDER_WORK_ID : Work.id,
FacetConstants.ORDER_TITLE : Edition.sort_title,
FacetConstants.ORDER_AUTHOR : Edition.sort_author,
FacetConstants.ORDER_LAST_UPDATE : Work.last_update_time,
}
@classmethod
def available_facets(cls, config, facet_group_name):
"""Exclude search orders not available through database queries."""
standard = config.enabled_facets(facet_group_name)
if facet_group_name != cls.ORDER_FACET_GROUP_NAME:
return standard
return [order for order in standard
if order in cls.ORDER_FACET_TO_DATABASE_FIELD]
@classmethod
def default_facet(cls, config, facet_group_name):
"""Exclude search orders not available through database queries."""
standard_default = super(DatabaseBackedFacets, cls).default_facet(
config, facet_group_name
)
if facet_group_name != cls.ORDER_FACET_GROUP_NAME:
return standard_default
if standard_default in cls.ORDER_FACET_TO_DATABASE_FIELD:
# This default sort order is supported.
return standard_default
# The default sort order is not supported. Just pick the first
# enabled sort order.
enabled = config.enabled_facets(facet_group_name)
for i in enabled:
if i in cls.ORDER_FACET_TO_DATABASE_FIELD:
return i
# None of the enabled sort orders are usable. Order by work ID.
return cls.ORDER_WORK_ID
def order_by(self):
"""Given these Facets, create a complete ORDER BY clause for queries
against WorkModelWithGenre.
"""
default_sort_order = [
Edition.sort_author, Edition.sort_title, Work.id
]
primary_order_by = self.ORDER_FACET_TO_DATABASE_FIELD.get(self.order)
if primary_order_by is not None:
# Promote the field designated by the sort facet to the top of
# the order-by list.
order_by = [primary_order_by]
for i in default_sort_order:
if i not in order_by:
order_by.append(i)
else:
# Use the default sort order
order_by = default_sort_order
# order_ascending applies only to the first field in the sort order.
# Everything else is ordered ascending.
if self.order_ascending:
order_by_sorted = [x.asc() for x in order_by]
else:
order_by_sorted = [order_by[0].desc()] + [x.asc() for x in order_by[1:]]
return order_by_sorted, order_by
def modify_database_query(self, _db, qu):
"""Restrict a query so that it matches only works
that fit the criteria of this faceting object. Ensure
query is appropriately ordered and made distinct.
"""
# Filter by facet criteria
qu = super(DatabaseBackedFacets, self).modify_database_query(_db, qu)
# Set the ORDER BY clause.
order_by, order_distinct = self.order_by()
qu = qu.order_by(*order_by)
qu = qu.distinct(*order_distinct)
return qu
class FeaturedFacets(FacetsWithEntryPoint):
"""A simple faceting object that configures a query so that the 'most
featurable' items are at the front.
This is mainly a convenient thing to pass into
AcquisitionFeed.groups().
"""
# This Facets class is used exclusively for grouped feeds.
CACHED_FEED_TYPE = CachedFeed.GROUPS_TYPE
def __init__(self, minimum_featured_quality, entrypoint=None,
random_seed=None, **kwargs):
"""Set up an object that finds featured books in a given
WorkList.
:param kwargs: Other arguments may be supplied based on user
input, but the default implementation is to ignore them.
"""
super(FeaturedFacets, self).__init__(entrypoint=entrypoint, **kwargs)
self.minimum_featured_quality = minimum_featured_quality
self.random_seed=random_seed
@classmethod
def default(cls, lane, **kwargs):
library = None
if lane:
if isinstance(lane, Library):
library = lane
else:
library = lane.library
if library:
quality = library.minimum_featured_quality
else:
quality = Configuration.DEFAULT_MINIMUM_FEATURED_QUALITY
return cls(quality, **kwargs)
def navigate(self, minimum_featured_quality=None, entrypoint=None):
"""Create a slightly different FeaturedFacets object based on this
one.
"""
minimum_featured_quality = minimum_featured_quality or self.minimum_featured_quality
entrypoint = entrypoint or self.entrypoint
return self.__class__(minimum_featured_quality, entrypoint, max_cache_age=self.max_cache_age)
def modify_search_filter(self, filter):
super(FeaturedFacets, self).modify_search_filter(filter)
filter.minimum_featured_quality = self.minimum_featured_quality
def scoring_functions(self, filter):
"""Generate scoring functions that weight works randomly, but
with 'more featurable' works tending to be at the top.
"""
return filter.featurability_scoring_functions(self.random_seed)
class SearchFacets(Facets):
"""A Facets object designed to filter search results.
Most search result filtering is handled by WorkList, but this
allows someone to, e.g., search a multi-lingual WorkList in their
preferred language.
"""
# If search results are to be ordered by some field other than
# score, we need a cutoff point so that marginal matches don't get
# top billing just because they're first alphabetically. This is
# the default cutoff point, determined empirically.
DEFAULT_MIN_SCORE = 500
def __init__(self, **kwargs):
languages = kwargs.pop('languages', None)
media = kwargs.pop('media', None)
# Our default_facets implementation will fill in values for
# the facet groups defined by the Facets class. This
# eliminates the need to explicitly specify a library, since
# the library is mainly used to determine these defaults --
# SearchFacets itself doesn't need one. However, in real
# usage, a Library will be provided via
# SearchFacets.from_request.
kwargs.setdefault('library', None)
kwargs.setdefault('collection', None)
kwargs.setdefault('availability', None)
order = kwargs.setdefault('order', None)
if order in (None, self.ORDER_BY_RELEVANCE):
# Search results are ordered by score, so there is no
# need for a score cutoff.
default_min_score = None
else:
default_min_score = self.DEFAULT_MIN_SCORE
self.min_score = kwargs.pop('min_score', default_min_score)
super(SearchFacets, self).__init__(**kwargs)
if media == Edition.ALL_MEDIUM:
self.media = media
else:
self.media = self._ensure_list(media)
self.media_argument = media
self.languages = self._ensure_list(languages)
@classmethod
def default_facet(cls, ignore, group_name):
"""The default facet settings for SearchFacets are hard-coded.
By default, we will search the full collection and all
availabilities, and order by match quality rather than any
bibliographic field.
"""
if group_name == cls.COLLECTION_FACET_GROUP_NAME:
return cls.COLLECTION_FULL
if group_name == cls.AVAILABILITY_FACET_GROUP_NAME:
return cls.AVAILABLE_ALL
if group_name == cls.ORDER_FACET_GROUP_NAME:
return cls.ORDER_BY_RELEVANCE
return None
def _ensure_list(self, x):
"""Make sure x is a list of values, if there is a value at all."""
if x is None:
return None
if isinstance(x, list):
return x
return [x]
@classmethod
def from_request(cls, library, config, get_argument, get_header, worklist,
default_entrypoint=EverythingEntryPoint, **extra):
values = cls._values_from_request(config, get_argument, get_header)
if isinstance(values, ProblemDetail):
return values
extra.update(values)
extra['library'] = library
# Searches against a WorkList will use the union of the
# languages allowed by the WorkList and the languages found in
# the client's Accept-Language header.
language_header = get_header("Accept-Language")
languages = get_argument("language") or None
if not languages:
if language_header:
languages = parse_accept_language(language_header)
languages = [l[0] for l in languages]
languages = map(LanguageCodes.iso_639_2_for_locale, languages)
languages = [l for l in languages if l]
languages = languages or None
# The client can request a minimum score for search results.
min_score = get_argument("min_score", None)
if min_score is not None:
try:
min_score = int(min_score)
except ValueError, e:
min_score = None
if min_score is not None:
extra['min_score'] = min_score
# The client can request an additional restriction on
# the media types to be returned by searches.
media = get_argument("media", None)
if media not in EditionConstants.KNOWN_MEDIA:
media = None
extra['media'] = media
languageQuery = get_argument("language", None)
# Currently, the only value passed to the language query from the client is
# `all`. This will remove the default browser's Accept-Language header value
# in the search request.