diff --git a/tests/apiv2/test_donors.py b/tests/apiv2/test_donors.py new file mode 100644 index 000000000..512117a99 --- /dev/null +++ b/tests/apiv2/test_donors.py @@ -0,0 +1,132 @@ +from django.db.models import Q + +from tests import randgen +from tests.util import APITestCase +from tracker.api.serializers import DonorSerializer + + +class TestDonor(APITestCase): + model_name = 'donor' + serializer_class = DonorSerializer + + def _format_donor(self, donor, *, include_totals=False, event=None): + return { + 'type': 'donor', + 'id': donor.id, + **({'alias': donor.full_alias} if donor.visibility == 'ALIAS' else {}), + **( + { + 'totals': [ + { + 'event': c.event_id, + 'total': c.donation_total, + 'count': c.donation_count, + 'avg': c.donation_avg, + 'max': c.donation_max, + } + for c in ( + donor.cache.filter(Q(event__isnull=True) | Q(event=event)) + if event + else donor.cache.all() + ) + ] + } + if include_totals + else {} + ), + } + + def setUp(self): + super().setUp() + randgen.generate_runs(self.rand, self.event, 1, ordered=True) + self.visible_donor = randgen.generate_donor(self.rand, visibility='ALIAS') + self.visible_donor.save() + randgen.generate_donations( + self.rand, self.event, num_donations=5, donors=[self.visible_donor] + ) + self.anonymous_donor = randgen.generate_donor(self.rand, visibility='ANON') + self.anonymous_donor.save() + randgen.generate_donations( + self.rand, self.event, num_donations=3, donors=[self.anonymous_donor] + ) + + def test_fetch(self): + with self.saveSnapshot(): + data = self.get_list(user=self.view_user) + self.assertExactV2Models( + [self.visible_donor, self.anonymous_donor], data['results'] + ) + + data = self.get_list(user=self.view_user, data={'include_totals': ''}) + self.assertExactV2Models( + [self.visible_donor, self.anonymous_donor], + data['results'], + serializer_kwargs={'include_totals': True}, + ) + + data = self.get_list( + user=self.view_user, + kwargs={'event_pk': self.event.id}, + data={'include_totals': ''}, + ) + self.assertExactV2Models( + [self.visible_donor, self.anonymous_donor], + data['results'], + serializer_kwargs={'include_totals': True, 'event_pk': self.event.id}, + ) + + data = self.get_detail(self.visible_donor, user=self.view_user) + self.assertV2ModelPresent(self.visible_donor, data) + + data = self.get_detail( + self.visible_donor, user=self.view_user, data={'include_totals': ''} + ) + self.assertV2ModelPresent( + self.visible_donor, data, serializer_kwargs={'include_totals': True} + ) + + data = self.get_detail( + self.visible_donor, + user=self.view_user, + kwargs={'event_pk': self.event.id}, + data={'include_totals': ''}, + ) + self.assertV2ModelPresent( + self.visible_donor, + data, + serializer_kwargs={'event_pk': self.event.id, 'include_totals': True}, + ) + + data = self.get_list( + user=self.view_user, kwargs={'event_pk': self.locked_event.id} + ) + self.assertEqual(len(data['results']), 0, msg='Donor list was not empty') + + with self.subTest('error cases'): + with self.subTest('no donations on event'): + self.get_detail( + self.visible_donor, + user=self.view_user, + kwargs={'event_pk': self.locked_event.id}, + status_code=404, + ) + + with self.subTest('anonymous'): + self.get_list(user=None, status_code=403) + self.get_detail(self.visible_donor, user=None, status_code=403) + + def test_serializer(self): + data = DonorSerializer(self.visible_donor, include_totals=True).data + formatted = self._format_donor(self.visible_donor, include_totals=True) + # FIXME + data['totals'] = sorted(data['totals'], key=lambda t: t['event'] or 0) + formatted['totals'] = sorted(formatted['totals'], key=lambda t: t['event'] or 0) + self.assertEqual(data, formatted) + self.assertEqual( + len(data['totals']), + self.visible_donor.cache.count(), + msg='Cache count did not match', + ) + + data = DonorSerializer(self.anonymous_donor).data + self.assertEqual(data, self._format_donor(self.anonymous_donor)) diff --git a/tests/apiv2/test_serializers.py b/tests/apiv2/test_serializers.py index 2946d71d8..092f8214b 100644 --- a/tests/apiv2/test_serializers.py +++ b/tests/apiv2/test_serializers.py @@ -78,7 +78,7 @@ def test_requestedalias_different_donor_says_requestedalias(self): # user entered, regardless of who we attribute it to internally. self.donation.requestedalias = 'requested by donation' self.donation.donor = generate_donor( - self.rand, alias='requested by donor', visibility='ALIAS' + self.rand, alias='requested_by_donor', visibility='ALIAS' ) serialized = DonationSerializer(self.donation).data diff --git a/tests/randgen.py b/tests/randgen.py index 805c853f9..00c488878 100644 --- a/tests/randgen.py +++ b/tests/randgen.py @@ -135,7 +135,7 @@ def generate_donor( donor.email = random_email(rand, alias) if rand.getrandbits(1): donor.paypalemail = random_paypal_email(rand, alias, donor.email) - donor.clean() + donor.full_clean() return donor @@ -163,7 +163,7 @@ def generate_run( if ordered: last = run.event.speedrun_set.last() run.order = last.order + 1 if last else 1 - run.clean() + run.full_clean() return run @@ -179,7 +179,7 @@ def generate_talent( youtube=youtube or random_name(rand, 'youtube'), donor=donor, ) - talent.clean() + talent.full_clean() return talent @@ -215,7 +215,10 @@ def generate_prize( random_draw=True, maxwinners=1, state='ACCEPTED', + handler=None, ): + from django.contrib.auth.models import User + prize = Prize() prize.name = random_prize_name(rand) prize.description = random_prize_description(rand, prize.name) @@ -250,7 +253,8 @@ def generate_prize( prize.maxwinners = rand.randrange(maxwinners) + 1 if state: prize.state = state - prize.clean() + prize.handler = handler or User.objects.get_or_create(username='prizehandler')[0] + prize.full_clean() return prize @@ -265,7 +269,7 @@ def generate_prize_key( if not prize_winner and winner: prize_winner = PrizeWinner.objects.create(prize=prize, winner=winner) prize_key.prize_winner = prize_winner - prize_key.clean() + prize_key.full_clean() return prize_key @@ -356,12 +360,12 @@ def generate_bid( bid.name = random_name(rand, 'challenge') else: bid.name = random_name(rand, 'choice') - bid.clean() + bid.full_clean() return bid, children def chain_insert_bid(bid, children): - bid.clean() + bid.full_clean() bid.save() for child in children: chain_insert_bid(child[0], child[1]) @@ -421,7 +425,7 @@ def generate_donation( assert Donor.objects.exists(), 'No donor provided and none exist' donor = rand.choice(Donor.objects.all()) donation.donor = donor - donation.clean() + donation.full_clean() return donation @@ -447,7 +451,8 @@ def generate_event(rand: random.Random, start_time=None): event.name = random_event_name(rand) event.short = event.name event.targetamount = Decimal('1000.00') - event.clean() + event.paypalemail = 'receiver@example.com' + event.full_clean() return event @@ -654,7 +659,7 @@ def generate_milestone( description=random_name(rand, 'description'), short_description=random_name(rand, 'short description'), ) - milestone.clean() + milestone.full_clean() return milestone @@ -676,11 +681,13 @@ def generate_ad( ad = Ad(event=event, suborder=suborder, ad_type='IMAGE') if run: ad.anchor = run + ad.order = run.order else: ad.order = order ad.filename = random_name(rand, 'filename') + '.jpg' ad.ad_name = random_name(rand, 'ad_name') - ad.clean() + ad.sponsor_name = random_name(rand, 'sponsor') + ad.full_clean() return ad @@ -704,7 +711,7 @@ def generate_interview( interview = Interview(event=event, order=run.order, suborder=suborder) interview.interviewers = random_name(rand, 'interviewer') interview.topic = random_name(rand, 'topic') - interview.clean() + interview.full_clean() return interview diff --git a/tracker/api/filters.py b/tracker/api/filters.py index d0f82ee61..d04aa12b4 100644 --- a/tracker/api/filters.py +++ b/tracker/api/filters.py @@ -18,18 +18,10 @@ class TrackerFilter(filters.BaseFilterBackend): def filter_queryset(self, request, queryset, view): if not view.detail: - if 'id' in request.query_params: - try: - queryset = queryset.filter( - id__in=request.query_params.getlist('id') - ) - except (TypeError, ValueError): - raise ParseError( - detail=messages.MALFORMED_SEARCH_PARAMETER_SPECIFIC % 'id', - code=messages.MALFORMED_SEARCH_PARAMETER_CODE, - ) filter_args = [] - filter_params = {} + filter_kwargs = {} + if 'id' in request.query_params: + filter_kwargs['id__in'] = request.query_params.getlist('id') for param, filter_param in self.filter_params.items(): if param in request.query_params: if isinstance(filter_param, str): @@ -43,7 +35,7 @@ def filter_queryset(self, request, queryset, view): detail=messages.UNAUTHORIZED_FILTER_PARAM, code=messages.UNAUTHORIZED_FILTER_PARAM_CODE, ) - filter_params[filter_param] = [ + filter_kwargs[filter_param] = [ self.normalize_value(param, value) for value in values ] else: @@ -53,7 +45,7 @@ def filter_queryset(self, request, queryset, view): detail=messages.UNAUTHORIZED_FILTER_PARAM, code=messages.UNAUTHORIZED_FILTER_PARAM_CODE, ) - filter_params[filter_param] = self.normalize_value( + filter_kwargs[filter_param] = self.normalize_value( param, value ) elif isinstance(filter_param, Q): @@ -61,7 +53,7 @@ def filter_queryset(self, request, queryset, view): elif callable(filter_param): filter_args.append(filter_param(request.query_params[param])) try: - queryset = queryset.filter(*filter_args, **filter_params) + queryset = queryset.filter(*filter_args, **filter_kwargs) except (ValueError, TypeError): raise ParseError( detail=messages.MALFORMED_SEARCH_PARAMETER, diff --git a/tracker/api/serializers.py b/tracker/api/serializers.py index 7101a9c34..5b5cede7d 100644 --- a/tracker/api/serializers.py +++ b/tracker/api/serializers.py @@ -343,61 +343,59 @@ class EventNestedSerializerMixin: event_move = False def __init__(self, *args, event_pk=None, **kwargs): - # FIXME: figure out a more elegant way to pass this in tests since they're the only - # ones that use it any more super().__init__(*args, **kwargs) - self.event_pk = event_pk + view = self.context.get('view', None) + assert ( + view is None or event_pk is None + ), 'event_pk should only be passed by tests when the view is not directly available' + self.event_pk = ( + (((pk := view.kwargs.get('event_pk', None)) and int(pk)) or None) + if view is not None + else event_pk + ) # without this check, patching by event url doesn't work - self.event_in_url = ( - view := self.context.get('view', None) - ) and 'event_pk' in view.kwargs + self.event_in_url = view and 'event_pk' in view.kwargs def get_fields(self): fields = super().get_fields() - if self.instance and not self.event_move: + if self.instance and not self.event_move and 'event' in 'fields': fields['event'].read_only = True return fields - def get_event_pk(self): - return self.event_pk or ( - (view := self.context.get('view', None)) - and ((pk := view.kwargs.get('event_pk', None)) is not None) - and int(pk) - ) - def get_event(self): - return (event_pk := self.get_event_pk()) and Event.objects.filter( - pk=event_pk - ).first() + return self.event_pk and Event.objects.filter(pk=self.event_pk).first() def to_representation(self, instance): ret = super().to_representation(instance) - if self.get_event_pk() and 'event' in ret: + if self.event_pk and 'event' in ret: del ret['event'] return ret def to_internal_value(self, data): - if ( - isinstance(data, dict) - and 'event' not in data - and (event_pk := self.get_event_pk()) - ): - data['event'] = event_pk + if isinstance(data, dict) and 'event' not in data and self.event_pk: + data['event'] = self.event_pk value = super().to_internal_value(data) return value def validate(self, data): - # TODO: validate_event would not be called because the field is read-only in this case, - # so this is how we make this error case more explicit for now - if ( - not self.event_move - and self.instance - and ('event' in getattr(self, 'initial_data', {}) and not self.event_in_url) - ): - raise ValidationError( - {'event': messages.EVENT_READ_ONLY}, code=messages.EVENT_READ_ONLY_CODE - ) - return super().validate(data) + def _check_event(): + # TODO: validate_event would not be called because the field is read-only in this case, + # so this is how we make this error case more explicit for now + if ( + not self.event_move + and self.instance + and ( + 'event' in getattr(self, 'initial_data', {}) + and not self.event_in_url + ) + ): + raise ValidationError( + {'event': messages.EVENT_READ_ONLY}, + code=messages.EVENT_READ_ONLY_CODE, + ) + + with _coalesce_validation_errors(_check_event): + return super().validate(data) class BidSerializer( @@ -946,3 +944,55 @@ class Meta: 'description', 'short_description', ) + + +class DonorSerializer(EventNestedSerializerMixin, TrackerModelSerializer): + type = ClassNameField() + alias = serializers.SerializerMethodField() + totals = serializers.SerializerMethodField() + + def __init__(self, *args, include_totals=False, **kwargs): + self.include_totals = include_totals + super().__init__(*args, **kwargs) + + class Meta: + model = Donor + fields = ( + 'type', + 'id', + 'alias', + 'totals', + ) + + def get_fields(self): + fields = super().get_fields() + if not self.include_totals: + fields.pop('totals', None) + return fields + + def get_alias(self, instance): + return instance.full_alias + + def get_totals(self, instance): + return sorted( + ( + { + 'event': c.event_id, + 'total': c.donation_total, + 'count': c.donation_count, + 'avg': c.donation_avg, + 'max': c.donation_max, + } + for c in instance.cache.all() + if self.event_pk is None + or c.event_id == self.event_pk + or c.event_id is None + ), + key=lambda c: c['event'] or -1, + ) + + def to_representation(self, instance): + value = super().to_representation(instance) + if instance.visibility == 'ANON': + value.pop('alias', None) + return value diff --git a/tracker/api/urls.py b/tracker/api/urls.py index dddf9dbf4..28df1c512 100644 --- a/tracker/api/urls.py +++ b/tracker/api/urls.py @@ -9,6 +9,7 @@ bids, country, donations, + donors, interview, me, milestone, @@ -42,6 +43,7 @@ def event_nested_route(path, viewset, *, basename=None, feed=False): event_nested_route(r'ads', ad.AdViewSet) event_nested_route(r'interviews', interview.InterviewViewSet) event_nested_route(r'milestones', milestone.MilestoneViewSet) +event_nested_route(r'donors', donors.DonorViewSet) router.register(r'donations', donations.DonationViewSet, basename='donations') router.register(r'me', me.MeViewSet, basename='me') router.register(r'countries', country.CountryViewSet) diff --git a/tracker/api/views/__init__.py b/tracker/api/views/__init__.py index 826e06b03..c892ad1c3 100644 --- a/tracker/api/views/__init__.py +++ b/tracker/api/views/__init__.py @@ -127,26 +127,23 @@ def get_permissions(self): return super().get_permissions() + [EventLockedPermission()] def get_queryset(self): - queryset = super().get_queryset() - event_pk = self.kwargs.get('event_pk', None) - if event_pk: - event = EventViewSet( - kwargs={'pk': event_pk, 'skip_annotations': True}, request=self.request - ).get_object() - queryset = self.get_event_filter(queryset, event) - return queryset + return self.get_event_filter( + super().get_queryset(), self.get_event_from_request() + ) def get_event_filter(self, queryset, event): - return queryset.filter(event=event) + if event: + queryset = queryset.filter(event=event) + return queryset def get_event_from_request(self): - if 'event_pk' in self.kwargs: - return models.Event.objects.filter(pk=self.kwargs['event_pk']).first() - if 'event' in self.request.data: + if event_pk := self.kwargs.get('event_pk', None): + return EventViewSet( + kwargs={'pk': event_pk, 'skip_annotations': True}, request=self.request + ).get_object() + if event := self.request.data.get('event', None): with contextlib.suppress(TypeError, ValueError): - return models.Event.objects.filter( - pk=self.request.data['event'] - ).first() + return models.Event.objects.filter(pk=event).first() return None def is_event_locked(self, obj=None): diff --git a/tracker/api/views/donors.py b/tracker/api/views/donors.py new file mode 100644 index 000000000..bda87422e --- /dev/null +++ b/tracker/api/views/donors.py @@ -0,0 +1,48 @@ +from django.db.models import Prefetch, Q + +from tracker.api.pagination import TrackerPagination +from tracker.api.permissions import tracker_permission +from tracker.api.serializers import DonorSerializer +from tracker.api.views import EventNestedMixin, TrackerReadViewSet +from tracker.models import Donor, DonorCache + + +class DonorViewSet(EventNestedMixin, TrackerReadViewSet): + queryset = Donor.objects.all() + pagination_class = TrackerPagination + permission_classes = [tracker_permission('tracker.view_donor')] + serializer_class = DonorSerializer + + def _include_totals(self): + return 'include_totals' in self.request.query_params + + def get_queryset(self): + queryset = super().get_queryset() + if self._include_totals(): + if event := self.get_event_from_request(): + dc_queryset = DonorCache.objects.filter( + Q(event__isnull=True) | Q(event=event) + ) + else: + dc_queryset = DonorCache.objects.all() + queryset = queryset.prefetch_related( + Prefetch('cache', queryset=dc_queryset) + ) + return queryset + + def get_serializer(self, *args, **kwargs): + if self._include_totals(): + kwargs['include_totals'] = True + return super().get_serializer(*args, **kwargs) + + def get_event_filter(self, queryset, event): + if event: + queryset = queryset.filter( + id__in=( + d['id'] + for d in queryset.filter(donation__event=event) + .distinct() + .values('id') + ) + ) + return queryset diff --git a/tracker/models/donation.py b/tracker/models/donation.py index 7a6d764d1..24a4fbad7 100644 --- a/tracker/models/donation.py +++ b/tracker/models/donation.py @@ -492,11 +492,12 @@ def full_alias(self): return f'{self.alias}#{self.alias_num}' return None - def get_absolute_url(self): - return reverse( - 'tracker:donor', - args=(self.id,), - ) + # disabled for now + # def get_absolute_url(self): + # return reverse( + # 'tracker:donor', + # args=(self.id,), + # ) def __repr__(self): return self.visible_name() @@ -578,7 +579,9 @@ def update(self): self.donation_avg = aggregate['avg'] def __str__(self): - return str(self.donor) + return ( + f'{str(self.donor)} -- {(str(self.event) if self.event else "All Events")}' + ) @property def donation_set(self):