from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied from django.db.models import Q from tracker.search_feeds import canonical_bool, apply_feed_filter from tracker.models import ( Bid, Donation, DonationBid, Donor, DonorCache, DonorPrizeEntry, Event, Prize, PrizeWinner, Runner, SpeedRun, ) _ModelMap = { # TODO: different kinds of bids should be a parameter, not a top level type 'allbids': Bid, 'bid': Bid, 'bidtarget': Bid, 'donationbid': DonationBid, 'donation': Donation, 'donor': Donor, 'donorcache': DonorCache, 'event': Event, 'prize': Prize, 'prizewinner': PrizeWinner, 'prizeentry': DonorPrizeEntry, 'run': SpeedRun, 'runner': Runner, } _ModelDefaultQuery = { 'bidtarget': Q(allowuseroptions=True) | Q(options__isnull=True, istarget=True), 'bid': Q(level=0), } _ModelReverseMap = {v: k for k, v in _ModelMap.items()} _GeneralFields = { # There was a really weird bug when doing the full recursion on speedrun, where it would double-select the related bids in aggregate queries # it seems to be related to selecting the donor table as part of the 'runners' recurse thing # it only applied to challenges too for some reason. I can't figure it out, and I don't really want to waste more time on it, so I'm just hard-coding it to do the specific speedrun fields only 'bid': ['event', 'speedrun', 'name', 'description', 'shortdescription'], 'allbids': [ 'event', 'speedrun', 'name', 'description', 'shortdescription', 'parent', ], 'bidtarget': [ 'event', 'speedrun', 'name', 'description', 'shortdescription', 'parent', ], 'bidsuggestion': ['name', 'bid'], 'donationbid': ['donation', 'bid'], 'donation': ['donor', 'comment', 'modcomment'], 'donor': ['email', 'alias', 'firstname', 'lastname', 'paypalemail'], 'event': ['short', 'name'], 'prize': ['name', 'description', 'shortdescription'], 'run': ['name', 'description'], 'runner': ['name', 'stream', 'twitter', 'youtube', 'platform', 'pronouns'], } BID_FIELDS = { 'event': 'event', 'eventshort': 'event__short__iexact', 'eventname': 'event__name__icontains', 'locked': 'event__locked', 'run': 'speedrun', 'runname': 'speedrun__name__icontains', 'parent': 'parent', 'parentname': 'parent__name__icontains', 'name': 'name__icontains', 'description': 'description__icontains', 'shortdescription': 'shortdescription__icontains', 'state': 'state__iexact', 'revealedtime_gte': 'revealedtime__gte', 'revealedtime_lte': 'revealedtime__lte', 'istarget': 'istarget', 'allowuseroptions': 'allowuseroptions', 'total_gte': 'total__gte', 'total_lte': 'total__lte', 'count_gte': 'count__gte', 'count_lte': 'count__lte', 'count': 'count', } # Some of these fields are used internally by the code or the `lookups` endpoints _SpecificFields = { 'bid': BID_FIELDS, 'allbids': BID_FIELDS, 'bidtarget': BID_FIELDS, 'bidsuggestion': { 'event': 'bid__event', 'eventshort': 'bid__event__short__iexact', 'eventname': 'bid__event__name__icontains', 'locked': 'bid__event__locked', 'run': 'bid__speedrun', 'runname': 'bid__speedrun__name__icontains', 'state': 'bid__state__iexact', 'name': 'name__icontains', }, 'donationbid': { 'event': 'donation__event', 'eventshort': 'donation__event__short__iexact', 'eventname': 'donation__event__name__icontains', 'locked': 'donation__event__locked', 'run': 'bid__speedrun', 'runname': 'bid__speedrun__name__icontains', 'bid': 'bid', 'bidname': 'bid__name__icontains', 'donation': 'donation', 'donor': 'donation__donor', 'amount': 'amount', 'amount_lte': 'amount__lte', 'amount_gte': 'amount__gte', }, 'donation': { 'event': 'event', 'eventshort': 'event__short__iexact', 'eventname': 'event__name__icontains', 'locked': 'event__locked', 'donor': 'donor', 'domain': 'domain', 'transactionstate': 'transactionstate', 'bidstate': 'bidstate', 'commentstate': 'commentstate', 'readstate': 'readstate', 'amount': 'amount', 'amount_lte': 'amount__lte', 'amount_gte': 'amount__gte', 'time_lte': 'timereceived__lte', 'time_gte': 'timereceived__gte', 'comment': 'comment__icontains', 'modcomment': 'modcomment__icontains', }, 'donor': { 'event': 'donation__event', 'eventshort': 'donation__event__short__iexact', 'eventname': 'donation__event__name__icontains', 'firstname': 'firstname__icontains', 'lastname': 'lastname__icontains', 'alias': 'alias__icontains', 'email': 'email__icontains', 'visibility': 'visibility__iexact', }, 'donorcache': { 'event': 'event', 'firstname': 'donor__firstname__icontains', 'lastname': 'donor__lastname__icontains', 'alias': 'donor__alias__icontains', 'email': 'donor__email__icontains', 'visibility': 'donor__visibility__iexact', }, 'event': { 'name': 'name__icontains', 'short': 'short__iexact', 'locked': 'locked', 'datetime_lte': 'datetime__lte', 'datetime_gte': 'datetime__gte', }, 'prize': { 'event': 'event', 'eventname': 'event__name__icontains', 'eventshort': 'event__short__iexact', 'locked': 'event__locked', 'category': 'category', 'categoryname': 'category__name__icontains', 'name': 'name__icontains', 'startrun': 'startrun', 'endrun': 'endrun', 'starttime_lte': ['starttime__lte', 'startrun__starttime__lte'], 'starttime_gte': ['starttime__gte', 'startrun__starttime__gte'], 'endtime_lte': ['endtime__lte', 'endrun__endtime__lte'], 'endtime_gte': ['endtime__gte', 'endrun__endtime__gte'], 'description': 'description__icontains', 'shortdescription': 'shortdescription__icontains', 'sumdonations': 'sumdonations', 'randomdraw': 'randomdraw', 'provider': 'provider__icontains', 'handler': 'handler', 'creator': 'creator', }, 'prizewinner': { 'event': 'prize__event', 'eventname': 'prize__event__name__icontains', 'eventshort': 'prize__event__short__iexact', 'prizename': 'prize__name__icontains', 'prize': 'prize', 'emailsent': 'emailsent', 'winner': 'winner', 'locked': 'prize__event__locked', }, 'prizeentry': { 'donor': 'donor', 'prize': 'prize', 'prizename': 'prize__name__icontains', 'event': 'prize__event', 'eventname': 'prize__event__name__icontains', 'eventshort': 'prize__event__short__iexact', 'weight': 'weight', 'weight_lte': 'weight__lte', 'weight_gte': 'weight__gte', 'locked': 'prize__event__locked', }, 'run': { 'event': 'event', 'eventname': 'event__name__icontains', 'eventshort': 'event__short__iexact', 'locked': 'event__locked', 'name': 'name__icontains', 'runner': 'runners', 'runnername': 'runners__name__icontains', 'description': 'description__icontains', 'starttime_lte': 'starttime__lte', 'starttime_gte': 'starttime__gte', 'endtime_lte': 'endtime__lte', 'endtime_gte': 'endtime__gte', }, 'runner': { 'name': 'name__iexact', 'stream': 'stream', 'twitter': 'twitter', 'youtube': 'youtube', 'event': 'speedrun__event', }, } _FKMap = { 'winner': 'donor', 'speedrun': 'run', 'startrun': 'run', 'endrun': 'run', 'option': 'bid', 'runners': 'donor', 'parent': 'bid', } _DonorEmailFields = ['email', 'paypalemail'] _DonorNameFields = ['firstname', 'lastname', 'alias'] _SpecialMarkers = ['icontains', 'contains', 'iexact', 'exact', 'lte', 'gte'] def single(query_dict, key, *fallback): if key not in query_dict: if len(fallback): return fallback[0] else: raise KeyError('Missing parameter: %s' % key) value = query_dict.pop(key) if type(value) != list: return value if len(value) != 1: raise KeyError('Parameter repeated: %s' % key) return value[0] # additional considerations for permission related visibility at the 'field' level def check_field_permissions(rootmodel, key, value, user=None): user = user or AnonymousUser() toks = key.split('__') if len(toks) >= 2: tail = toks[-2] rootmodel = _FKMap.get(tail, tail) field = toks[-1] # cannot search for locked=True unless you can view locked events if ( field == 'locked' and canonical_bool(value) and not user.has_perm('tracker.view_locked_events') ): raise PermissionDenied if rootmodel == 'donor': if (field in _DonorEmailFields) and not user.has_perm('tracker.view_emails'): raise PermissionDenied elif (field in _DonorNameFields) and not user.has_perm( 'tracker.view_usernames' ): raise PermissionDenied elif rootmodel == 'donation': if (field == 'testdonation') and not user.has_perm('tracker.view_test'): raise PermissionDenied if (field == 'comment') and not user.has_perm('tracker.view_comments'): raise PermissionDenied elif rootmodel in ['bid', 'allbids', 'bidtarget']: if ( (field == 'state') and not user.has_perm('tracker.view_hidden') and value not in ['OPENED', 'CLOSED'] ): raise PermissionDenied def recurse_keys(key, from_models=None): from_models = from_models or [] tail = key.split('__')[-1] ftail = _FKMap.get(tail, tail) if ftail in _GeneralFields: ret = [] for key in _GeneralFields[ftail]: if key not in from_models: from_models.append(key) for k in recurse_keys(key, from_models): ret.append(tail + '__' + k) return ret return [key] def build_general_query_piece(rootmodel, key, text, user): if text: # These can throw if somebody is trying to access, say, the lookups endpoints without being logged in check_field_permissions(rootmodel, key, text, user) return Q(**{key + '__icontains': text}) return Q() def normalize_model_param(model): if model == 'speedrun': model = 'run' # we should really just rename all instances of it already! if model not in _ModelMap: model = _ModelReverseMap[model] return model def model_general_filter(model, text, user): fields = set() model = normalize_model_param(model) from_models = [model] if model not in _GeneralFields: raise KeyError("Requested model does not support the 'q' parameter") for key in _GeneralFields[model]: fields |= set(recurse_keys(key, from_models=from_models)) fields = list(fields) query = Q() for field in fields: query |= build_general_query_piece(model, field, text, user) return query def model_specific_filter(model, params, user): query = Q() model = normalize_model_param(model) specifics = _SpecificFields[model] keys = list(params.keys()) filters = {k: single(params, k) for k in keys if k in specifics} if params: # anything leftover is unrecognized raise KeyError("Invalid search parameters: '%s'" % ','.join(params.keys())) for param, value in filters.items(): check_field_permissions(model, param, value, user) specific = specifics[param] field_query = Q() if isinstance(specific, str) or not hasattr(specific, '__iter__'): specific = [specific] for search_key in specific: field_query |= Q(**{search_key: value}) query &= field_query return query def run_model_query(model, params, user=None): user = user or AnonymousUser() params = params.copy() if params else {} model = normalize_model_param(model) filtered = _ModelMap[model].objects.all() filter_accumulator = Q() if model in _ModelDefaultQuery: filter_accumulator &= _ModelDefaultQuery[model] pk = single(params, 'id', None) pks = single(params, 'ids', None) # technically speaking it's a viable query but why would you do this if pk and pks: raise KeyError('Cannot combine `id` with `ids` query') if pk: filter_accumulator &= Q(pk=pk) if pks: filter_accumulator &= Q(pk__in=pks.split(',')) # arguably does not make sense if combined with id or ids, but I can think of some use cases, so just let it go for now q = params.pop('q', None) if q: filter_accumulator &= model_general_filter(model, q, user) feed = single(params, 'feed', None) feed_params = { k: single(params, k) for k in [ 'noslice', 'delta', 'time', 'maxDonations', 'minDonations', 'maxRuns', 'minRuns', ] if k in params } filter_accumulator &= model_specific_filter(model, params, user) filtered = filtered.filter(filter_accumulator) if model in ['bid', 'bidtarget', 'allbids']: filtered = filtered.order_by(*Bid._meta.ordering) filtered = apply_feed_filter(filtered, model, feed, feed_params, user) if 'maxRuns' in feed_params or 'minRuns' in feed_params: return filtered # stupid hack return filtered.distinct() _1ToManyDonationAggregateFilter = Q(donation__transactionstate='COMPLETED') DonationBidAggregateFilter = _1ToManyDonationAggregateFilter DonorAggregateFilter = _1ToManyDonationAggregateFilter EventAggregateFilter = _1ToManyDonationAggregateFilter PrizeWinnersFilter = Q(prizewinner__acceptcount__gt=0) | Q( prizewinner__pendingcount__gt=0 ) _1ToManyBidsAggregateFilter = Q(bids__donation__transactionstate='COMPLETED')