From 15e45c81c52cf765986ae58288a8e45d30844094 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 16 Sep 2024 23:16:14 +0000 Subject: [PATCH 01/11] fix #159 --- .env.example | 2 + auctions/consumers.py | 58 +- auctions/forms.py | 16 +- auctions/management/commands/endauctions.py | 122 +--- .../webpush_notifications_deduplicate.py | 19 + ...n_message_users_when_lots_sell_and_more.py | 56 ++ ...rdata_push_notifications_when_lots_sell.py | 21 + auctions/models.py | 99 ++- auctions/routing.py | 13 +- auctions/static/webpush/new.json | 1 + auctions/static/webpush/webpush.js | 212 ++++++ .../static/webpush/webpush_serviceworker.js | 20 + auctions/templates/all_auctions.html | 1 + .../auctions/dynamic_set_lot_winner.html | 191 ++++++ auctions/templates/base.html | 45 +- auctions/templates/user_map.html | 6 + auctions/templates/user_preferences.html | 29 +- auctions/templates/view_lot_images.html | 76 +- auctions/templates/view_lot_simple.html | 34 +- auctions/tests.py | 373 +++++----- auctions/urls.py | 34 +- auctions/views.py | 648 ++++++++++++------ crontab | 2 + fishauctions/asgi.py | 3 +- fishauctions/settings.py | 7 + requirements.in | 1 + requirements.txt | 177 +++-- 27 files changed, 1565 insertions(+), 701 deletions(-) create mode 100644 auctions/management/commands/webpush_notifications_deduplicate.py create mode 100644 auctions/migrations/0147_auction_message_users_when_lots_sell_and_more.py create mode 100644 auctions/migrations/0148_userdata_push_notifications_when_lots_sell.py create mode 100644 auctions/static/webpush/new.json create mode 100644 auctions/static/webpush/webpush.js create mode 100644 auctions/static/webpush/webpush_serviceworker.js create mode 100755 auctions/templates/auctions/dynamic_set_lot_winner.html diff --git a/.env.example b/.env.example index 5889c24..fd80edc 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ NGINX_CONF_LOCATION='/config/nginx/site-confs/default.conf' # leave unset for de SECRET_KEY='secret' DATABASE_PASSWORD='secret' DATABASE_ROOT_PASSWORD="secret" +VAPID_PUBLIC_KEY="public-key" +VAPID_PRIVATE_KEY="private-key" POST_OFFICE_EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" # if POST_OFFICE_EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', set the following EMAIL_USE_TLS='True' diff --git a/auctions/consumers.py b/auctions/consumers.py index 169e449..37861f8 100755 --- a/auctions/consumers.py +++ b/auctions/consumers.py @@ -17,6 +17,7 @@ Invoice, Lot, LotHistory, + User, UserBan, UserData, UserInterestCategory, @@ -191,12 +192,7 @@ def bid_on_lot(lot, user, amount): auctiontos_winner = AuctionTOS.objects.filter(auction=lot.auction, user=user).first() if auctiontos_winner: lot.auctiontos_winner = auctiontos_winner - invoice, created = Invoice.objects.get_or_create( - auctiontos_user=lot.auctiontos_winner, - auction=lot.auction, - defaults={}, - ) - invoice.recalculate + lot.create_update_invoices lot.winning_price = lot.buy_now_price lot.buy_now_used = True # this next line makes the lot end immediately after buy now is used @@ -526,3 +522,53 @@ def error_message(self, event): # Receive message from room group def chat_message(self, event): self.send(text_data=json.dumps(event)) + + +class UserConsumer(WebsocketConsumer): + """This is ready to use and corresponding code to connect added (commented out) to base.html + You can use userdata.send_websocket_message to message the user, like this: + result = { + "type": "toast", + "message": "Hello world!", + } + user.userdata.send_websocket_message(result) + It would make a good messaging system for some stuff like chat messages, + but at this time it does not seem like a good idea + """ + + def connect(self): + try: + self.pk = self.scope["url_route"]["kwargs"]["user_pk"] + user_for = User.objects.filter(pk=self.pk).first() + self.user = self.scope["user"] + self.user_notification_channel = f"user_{self.pk}" + if not user_for or user_for != self.user: + self.close() + else: + self.accept() + # Add to the group after accepting the connection + async_to_sync(self.channel_layer.group_add)(self.user_notification_channel, self.channel_name) + + # Send a message after accepting the connection + # async_to_sync(self.channel_layer.group_send)( + # self.user_notification_channel, + # {"type": "toast", "message": 'Welcome!', 'bg': 'success'}, + # ) + except Exception as e: + print(e) + self.close() + + def disconnect(self, close_code): + # Leave room group + async_to_sync(self.channel_layer.group_discard)(self.user_notification_channel, self.channel_name) + print("disconnected") + + # Receive message from WebSocket + def receive(self, text_data): + text_data_json = json.loads(text_data) + print(text_data_json) + + def toast(self, event): + message = event["message"] + bg = event.get("bg", "info") + self.send(text_data=json.dumps({"type": "toast", "message": message, "bg": bg})) diff --git a/auctions/forms.py b/auctions/forms.py index 405ad15..ae43e71 100755 --- a/auctions/forms.py +++ b/auctions/forms.py @@ -1548,6 +1548,7 @@ class Meta: "date_online_bidding_starts", "allow_deleting_bids", "auto_add_images", + "message_users_when_lots_sell", ] widgets = { "date_start": DateTimePickerInput(), @@ -1581,6 +1582,8 @@ def __init__(self, *args, **kwargs): ].help_text = "This should be 1-24 hours before the end of your auction" self.fields["allow_bidding_on_lots"].help_text = "Leave this checked or people won't be able to bid!" self.fields["allow_bidding_on_lots"].widget = forms.HiddenInput() + self.fields["message_users_when_lots_sell"].widget = forms.HiddenInput() + self.fields["advanced_lot_adding"].widget = forms.HiddenInput() # self.fields['pre_register_lot_entry_fee_discount'].widget=forms.HiddenInput() self.fields["pre_register_lot_discount_percent"].widget = forms.HiddenInput() # self.fields['set_lot_winners_url'].widget=forms.HiddenInput() @@ -1777,17 +1780,21 @@ def __init__(self, *args, **kwargs): "buy_now", css_class="col-md-3", ), - # Div('set_lot_winners_url', css_class='col-md-3',), Div( - "promote_this_auction", + "message_users_when_lots_sell", css_class="col-md-3", ), + # Div('set_lot_winners_url', css_class='col-md-3',), PrependedAppendedText( "tax", "", "%", wrapper_class="col-md-3", ), + Div( + "promote_this_auction", + css_class="col-md-3", + ), css_class="row", ), Submit("submit", "Save", css_class="create-update-auction btn-success"), @@ -2473,6 +2480,7 @@ class Meta: "username_visible", "share_lot_images", "auto_add_images", + "push_notifications_when_lots_sell", ) def __init__(self, user, *args, **kwargs): @@ -2510,6 +2518,10 @@ def __init__(self, user, *args, **kwargs): # Div('use_list_view',css_class='col-md-4',), # Div('use_dark_theme',css_class='col-md-4',), # Div('show_ads',css_class='col-md-3',), + Div( + "push_notifications_when_lots_sell", + css_class="col-md-6", + ), css_class="row", ), HTML("

Notifications


"), diff --git a/auctions/management/commands/endauctions.py b/auctions/management/commands/endauctions.py index e9375b2..af37e07 100644 --- a/auctions/management/commands/endauctions.py +++ b/auctions/management/commands/endauctions.py @@ -1,57 +1,21 @@ import datetime -import channels.layers -from asgiref.sync import async_to_sync from django.contrib.sites.models import Site from django.core.management.base import BaseCommand -from django.db.models import F from django.utils import timezone from easy_thumbnails.files import get_thumbnailer from post_office import mail -from auctions.models import AuctionTOS, Invoice, Lot, LotHistory, LotImage - - -def sendWarning(lot_number, result): - channel_layer = channels.layers.get_channel_layer() - async_to_sync(channel_layer.group_send)(f"lot_{lot_number}", result) - - -def fix_seller_info(part_of_auction): - """This function is really not needed - it's been run once to update all existing lots. - Fill out the user if auctiontos_seller is set, or auctiontos_seller if the user is set.""" - # first, let's fill out the seller info (1 of 2) - needs_auctiontos_seller_filled_out = part_of_auction.filter( - auctiontos_seller__isnull=True, auction__auctiontos__user=F("user") - ) - for lot in needs_auctiontos_seller_filled_out: - auctionTOS = AuctionTOS.objects.filter(user=lot.user, auction=lot.auction).first() - lot.auctiontos_seller = auctionTOS - lot.save() # presave receiver will make an invoice for this lot/seller - - # next, let's fill out the user info (2 of 2) - # user when auctiontos filled out - handy for when users are part of an auction (auctiontos__user__isnull=True), but then later sign up - # User is set for auctiontos on user sign in event - see models.user_logged_in_callback() - needs_user_filled_out = part_of_auction.filter( - auctiontos_seller__isnull=False, - user__isnull=True, - auctiontos_seller__user__isnull=False, - auction__auctiontos=F("auctiontos_seller"), - ) - for lot in needs_user_filled_out: - user = lot.auctiontos_seller.user - print("setting user based on auction tos:") - print(lot, lot.auctiontos_seller, user) - lot.user = user - lot.save() +from auctions.models import Lot, LotHistory, LotImage def declare_winners_on_lots(lots): """Set the winner and winning price on all lots""" for lot in lots: if lot.ended: - # note - lots that are part of in-person auctions will only get here if a winner and price is set - # they always have lot.ended = False + # note - lots that are part of in-person auctions will not get here + # if they are active, they always have lot.ended = False, and if they are sold, + # the method that sells them should set active=False, so they won't be filtered here # But, see https://github.com/iragm/fishauctions/issues/116 try: lot.active = False @@ -60,21 +24,18 @@ def declare_winners_on_lots(lots): # lots will be bought via buy now will get here # as well as in-person auction lots whose winner has been set manually # We still need to make those active=False, done above, and then save, below + # I don't think the above is true anymore, lots bought with buy now should also be made inactive pass else: info = None - if ( - lot.high_bidder - ): # and not lot.sold: # not sure what that was here, we already filter this in the if above - lot.winner = lot.high_bidder - lot.winning_price = lot.high_bid + if lot.high_bidder: + lot.sell_to_online_high_bidder info = "LOT_END_WINNER" bidder = lot.high_bidder high_bidder_pk = lot.high_bidder.pk high_bidder_name = str(lot.high_bidder_display) current_high_bid = lot.high_bid message = f"Won by {lot.high_bidder_display}" - lot.save() # at this point, the lot should have a winner filled out if it's sold. If it still doesn't: if not lot.sold: high_bidder_pk = None @@ -92,7 +53,7 @@ def declare_winners_on_lots(lots): "current_high_bid": current_high_bid, } if info: - sendWarning(lot.lot_number, result) + lot.send_websocket_message(result) LotHistory.objects.create( lot=lot, user=bidder, @@ -100,30 +61,7 @@ def declare_winners_on_lots(lots): changed_price=True, current_price=lot.high_bid, ) - # if this is part of an auction, update invoices - if lot.sold and lot.auction: - if lot.auctiontos_winner: - auctiontos_winner = lot.auctiontos_winner - else: - # look for the TOS and create the invoice - auctiontos_winner = AuctionTOS.objects.filter(auction=lot.auction, user=lot.high_bidder).first() - if auctiontos_winner: - lot.auctiontos_winner = auctiontos_winner - lot.save() - if lot.auctiontos_winner: - invoice, created = Invoice.objects.get_or_create( - auctiontos_user=lot.auctiontos_winner, - auction=lot.auction, - defaults={}, - ) - invoice.recalculate - if lot.auctiontos_seller: - invoice, created = Invoice.objects.get_or_create( - auctiontos_user=lot.auctiontos_seller, - auction=lot.auction, - defaults={}, - ) - invoice.recalculate + lot.create_update_invoices # logic to email winner and buyer for lots not in an auction if lot.winner and not lot.auction: current_site = Site.objects.get_current() @@ -207,48 +145,12 @@ def declare_winners_on_lots(lots): "pk": -1, "username": "System", } - sendWarning(lot.lot_number, result) - - -def fix_winner_info(part_of_auction): - """set winner if auctiontos_winner is set, and auctiontos_winner if winner is set. It's a good idea to run this AFTER declaring winners""" - # set auctiontos_winner if only winner is set - needs_auctiontos_winner_filled_out = part_of_auction.filter( - winner_isnull=False, - auctiontos_winner__isnull=True, - auction__auctiontos__user=F("winner"), - ) - for lot in needs_auctiontos_winner_filled_out: - auctionTOS = AuctionTOS.objects.filter(user=lot.winner, auction=lot.auction).first() - lot.auctiontos_winner = auctionTOS - lot.save() - invoice, created = Invoice.objects.get_or_create( - auctiontos_user=lot.auctiontos_winner, auction=lot.auction, defaults={} - ) - - # declate winners where there is already an auctiontos_winner (hopefully rare - but see models.user_logged_in_callback() - needs_winner_filled_out = part_of_auction.filter( - auctiontos_winner__isnull=False, - winner__isnull=True, - auctiontos_winner__user__isnull=False, - auction__auctiontos=F("auctiontos_winner"), - ) - for lot in needs_winner_filled_out: - winner = lot.auctiontos_seller.user - print("setting winner based on auction tos:") - print(lot, lot.auctiontos_winner, winner) - # fixme - this code is not yet fully tested, no saving yet!! - # lot.winner = winner - # lot.save() + lot.send_websocket_message(result) class Command(BaseCommand): help = "Sets the winner, and winning price on all ended lots. Send lot ending soon and lot ended messages to websocket connected users. Sets active to false on lots" def handle(self, *args, **options): - lots = Lot.objects.filter(is_deleted=False, banned=False, deactivated=False) - # part_of_auction = lots.filter(auction__isnull=False) - - # fix_seller_info(part_of_auction) - declare_winners_on_lots(lots.filter(active=True)) - # fix_winner_info(part_of_auction) + lots = Lot.objects.filter(active=True, is_deleted=False, banned=False, deactivated=False) + declare_winners_on_lots(lots) diff --git a/auctions/management/commands/webpush_notifications_deduplicate.py b/auctions/management/commands/webpush_notifications_deduplicate.py new file mode 100644 index 0000000..185b540 --- /dev/null +++ b/auctions/management/commands/webpush_notifications_deduplicate.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from django.db.models import Count, Min +from webpush.models import SubscriptionInfo + + +class Command(BaseCommand): + help = "Duplicate subscriptions can be created, this removes them. See https://github.com/safwanrahman/django-webpush/issues/135" + + def handle(self, *args, **options): + # Step 1: Annotate each WebpushSubscriptionInfo with the minimum id for each group of auth and p256dh. + duplicates = ( + SubscriptionInfo.objects.values("auth", "p256dh") + .annotate(min_id=Min("id"), count_id=Count("id")) + .filter(count_id__gt=1) + ) + # Step 2: Collect the IDs of the entries with the lowest id in each group. + min_ids_to_delete = [entry["min_id"] for entry in duplicates] + # Step 3: Delete these entries. + SubscriptionInfo.objects.filter(id__in=min_ids_to_delete).delete() diff --git a/auctions/migrations/0147_auction_message_users_when_lots_sell_and_more.py b/auctions/migrations/0147_auction_message_users_when_lots_sell_and_more.py new file mode 100644 index 0000000..7a47616 --- /dev/null +++ b/auctions/migrations/0147_auction_message_users_when_lots_sell_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 5.0.8 on 2024-09-13 20:34 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auctions", "0146_auction_auto_add_images_userdata_auto_add_images_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="auction", + name="message_users_when_lots_sell", + field=models.BooleanField( + blank=True, + default=True, + help_text="When you enter a lot number on the set lot winners screen, send a notification to any users watching that lot", + ), + ), + migrations.AlterField( + model_name="auction", + name="auto_add_images", + field=models.BooleanField( + blank=True, + default=True, + help_text="Images taken from older lots with the same name in any auctions created by you or other admins", + verbose_name="Automatically add images to lots", + ), + ), + migrations.AlterField( + model_name="lot", + name="buy_now_price", + field=models.PositiveIntegerField( + blank=True, + default=None, + help_text="This lot will be sold with no bidding for this price, if someone is willing to pay this much", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1000), + ], + ), + ), + migrations.AlterField( + model_name="userdata", + name="auto_add_images", + field=models.BooleanField( + blank=True, + default=True, + help_text="If another lot with the same name has been added previously. Images are only added to lots that are part of an auction.", + verbose_name="Automatically add images to my lots", + ), + ), + ] diff --git a/auctions/migrations/0148_userdata_push_notifications_when_lots_sell.py b/auctions/migrations/0148_userdata_push_notifications_when_lots_sell.py new file mode 100644 index 0000000..5bc5660 --- /dev/null +++ b/auctions/migrations/0148_userdata_push_notifications_when_lots_sell.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.8 on 2024-09-14 00:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auctions", "0147_auction_message_users_when_lots_sell_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="userdata", + name="push_notifications_when_lots_sell", + field=models.BooleanField( + blank=True, + default=False, + help_text="For in-person auctions, get a notification when bidding starts on a lot that you've watched", + ), + ), + ] diff --git a/auctions/models.py b/auctions/models.py index 05a0487..e3db2b2 100755 --- a/auctions/models.py +++ b/auctions/models.py @@ -4,6 +4,8 @@ from datetime import time from random import randint +import channels.layers +from asgiref.sync import async_to_sync from autoslug import AutoSlugField from django.conf import settings from django.contrib.auth.models import User @@ -648,6 +650,10 @@ class Auction(models.Model): auto_add_images.help_text = ( "Images taken from older lots with the same name in any auctions created by you or other admins" ) + message_users_when_lots_sell = models.BooleanField(default=True, blank=True) + message_users_when_lots_sell.help_text = ( + "When you enter a lot number on the set lot winners screen, send a notification to any users watching that lot" + ) def __str__(self): result = self.title @@ -785,7 +791,8 @@ def user_admin_link(self): @property def set_lot_winners_link(self): - return f"{self.get_absolute_url()}lots/set-winners/{self.set_lot_winners_url}" + # return f"{self.get_absolute_url()}lots/set-winners/{self.set_lot_winners_url}" + return f"{self.get_absolute_url()}lots/set-winners/" def permission_check(self, user): """See if `user` can make changes to this auction""" @@ -1925,7 +1932,9 @@ class Lot(models.Model): blank=True, null=True, ) - buy_now_price.help_text = "This lot will be sold instantly for this price if someone is willing to pay this much. Leave blank unless you know exactly what you're doing" + buy_now_price.help_text = ( + "This lot will be sold with no bidding for this price, if someone is willing to pay this much" + ) species = models.ForeignKey(Product, null=True, blank=True, on_delete=models.SET_NULL) species_category = models.ForeignKey( Category, @@ -2102,10 +2111,11 @@ def __str__(self): def add_winner_message(self, user, tos, winning_price): """Create a lot history message when a winner is declared (or changed) It's critical that this function is called every time the winner is changed so that invoices get recalculated""" + message = f"{user.username} has set bidder {tos} as the winner of this lot (${winning_price})" LotHistory.objects.create( lot=self, user=user, - message=f"{user.username} has set bidder {tos} as the winner of this lot (${winning_price}).", + message=message, notification_sent=True, bid_amount=winning_price, changed_price=True, @@ -2113,6 +2123,20 @@ def add_winner_message(self, user, tos, winning_price): ) invoice, created = Invoice.objects.get_or_create(auctiontos_user=tos, auction=self.auction, defaults={}) invoice.recalculate + self.send_websocket_message( + { + "type": "chat_message", + "info": "LOT_END_WINNER", + "message": message, + "high_bidder_pk": tos.user.pk if tos.user else -1, + "high_bidder_name": tos.display_name_for_admins, + "current_high_bid": winning_price, + } + ) + + def send_websocket_message(self, message): + channel_layer = channels.layers.get_channel_layer() + async_to_sync(channel_layer.group_send)(f"lot_{self.pk}", message) def refund(self, amount, user, message=None): """Call this to add a message when refunding a lot""" @@ -2277,20 +2301,6 @@ def winner_email(self): return self.winner.email return "" - @property - def table_class(self): - """We need to color rows red if a winner is set without a price, or a price is set without a winner""" - if self.auctiontos_winner and not self.winning_price: - return "table-danger" - if not self.auctiontos_winner and self.winning_price: - return "table-danger" - return "" - - # @property - # def user_as_str(self): - # """String value of the seller of this lot""" - # return str(self.user) - @property def seller_as_str(self): """String of the seller name or number, for use on lot pages""" @@ -2369,6 +2379,20 @@ def winner_as_str(self): return "Anonymous" return "" + @property + def sell_to_online_high_bidder(self): + if self.high_bidder: + self.winner = self.high_bidder + self.winning_price = self.high_bid + self.active = False + tos = AuctionTOS.objects.filter(auction=self.auction, user=self.high_bidder).order_by("-createdon").first() + if tos: + self.auctiontos_winner = tos + self.save() + return f"{self.high_bidder_for_admins} is now the winner of lot {self.custom_lot_number} for ${self.winning_price}" + else: + return "No high bidder" + @property def sold(self): if self.winner or self.auctiontos_winner: @@ -2801,11 +2825,10 @@ def auto_image(self): @property def thumbnail(self): - try: - return LotImage.objects.get(lot_number=self.lot_number, is_primary=True) - except: - pass - return None + default = LotImage.objects.filter(lot_number=self.lot_number, is_primary=True).first() + if default: + return default + return self.auto_image def get_absolute_url(self): return f"/lots/{self.lot_number}/{self.slug}/" @@ -2929,6 +2952,28 @@ def video_link(self): return match.group(1) return None + @property + def create_update_invoices(self): + """Call whenever ending this lot, or when creating it""" + if self.auction and self.winner and not self.auctiontos_winner: + tos = AuctionTOS.objects.filter(auction=self.auction, user=self.winner).first() + self.auctiontos_winner = tos + self.save() + if self.auction and self.auctiontos_winner: + invoice, created = Invoice.objects.get_or_create( + auctiontos_user=self.auctiontos_winner, + auction=self.auction, + defaults={}, + ) + invoice.recalculate + if self.auction and self.auctiontos_seller: + invoice, created = Invoice.objects.get_or_create( + auctiontos_user=self.auctiontos_seller, + auction=self.auction, + defaults={}, + ) + invoice.recalculate + class Invoice(models.Model): """ @@ -3497,9 +3542,7 @@ class UserData(models.Model): ) last_ip_address = models.CharField(max_length=100, blank=True, null=True) email_me_when_people_comment_on_my_lots = models.BooleanField(default=True, blank=True) - email_me_when_people_comment_on_my_lots.help_text = ( - "Notifications will be sent once a day, only for messages you haven't seen" - ) + email_me_when_people_comment_on_my_lots.help_text = "Notifications will be sent once a day, only for messages you haven't seen. If you'd like to get a notification right away, leave a comment here" email_me_about_new_auctions = models.BooleanField( default=True, blank=True, verbose_name="Email me about new online auctions" ) @@ -3573,6 +3616,8 @@ class UserData(models.Model): share_lot_images.help_text = "Images will be added to other lots without an image that have the same name" auto_add_images = models.BooleanField("Automatically add images to my lots", default=True, blank=True) auto_add_images.help_text = "If another lot with the same name has been added previously. Images are only added to lots that are part of an auction." + push_notifications_when_lots_sell = models.BooleanField(default=False, blank=True) + push_notifications_when_lots_sell.help_text = "For in-person auctions, get a notification when bidding starts on a lot that you've watched" # breederboard info rank_unique_species = models.PositiveIntegerField(null=True, blank=True) @@ -3596,6 +3641,10 @@ class UserData(models.Model): def __str__(self): return f"{self.user.username}'s data" + def send_websocket_message(self, message): + channel_layer = channels.layers.get_channel_layer() + async_to_sync(channel_layer.group_send)(f"user_{self.user.pk}", message) + @property def my_lots_qs(self): """All lots this user submitted, whether in an auction, or independently""" diff --git a/auctions/routing.py b/auctions/routing.py index c53fc6e..31cf00a 100755 --- a/auctions/routing.py +++ b/auctions/routing.py @@ -1,10 +1,9 @@ -# chat/routing.py -from django.urls import re_path +# # chat/routing.py +# from django.urls import re_path -from . import consumers +# from . import consumers -print("routing") -websocket_urlpatterns = [ - re_path(r"ws/lots/(?P\w+)/$", consumers.LotConsumer.as_asgi()), -] +# websocket_urlpatterns = [ +# re_path(r"ws/lots/(?P\w+)/$", consumers.LotConsumer.as_asgi()), +# ] diff --git a/auctions/static/webpush/new.json b/auctions/static/webpush/new.json new file mode 100644 index 0000000..7e8c553 --- /dev/null +++ b/auctions/static/webpush/new.json @@ -0,0 +1 @@ +{"1": 2} diff --git a/auctions/static/webpush/webpush.js b/auctions/static/webpush/webpush.js new file mode 100644 index 0000000..9656021 --- /dev/null +++ b/auctions/static/webpush/webpush.js @@ -0,0 +1,212 @@ +// Based On https://github.com/chrisdavidmills/push-api-demo/blob/283df97baf49a9e67705ed08354238b83ba7e9d3/main.js + +var isPushEnabled = false, + registration, + subBtn; + +window.addEventListener('load', function() { + subBtn = document.getElementById('webpush-subscribe-button'); + + subBtn.textContent = gettext('Subscribe to Push Messaging'); + + subBtn.addEventListener('click', + function() { + subBtn.disabled = true; + if (isPushEnabled) { + return unsubscribe(registration); + } + return subscribe(registration); + } + ); + + // Do everything if the Browser Supports Service Worker + if ('serviceWorker' in navigator) { + const serviceWorker = document.querySelector('meta[name="service-worker-js"]').content; + navigator.serviceWorker.register(serviceWorker).then( + function(reg) { + registration = reg; + initialiseState(reg); + }); + } + // If service worker not supported, show warning to the message box + else { + showMessage(gettext('Service workers are not supported in your browser.')); + } + + // Once the service worker is registered set the initial state + function initialiseState(reg) { + // Are Notifications supported in the service worker? + if (!(reg.showNotification)) { + // Show a message and activate the button + showMessage(gettext('Showing notifications are not supported in your browser.')); + return; + } + + // Check the current Notification permission. + // If its denied, it's a permanent block until the + // user changes the permission + if (Notification.permission === 'denied') { + // Show a message and activate the button + subBtn.disabled = false; + showMessage(gettext('Push notifications are blocked by your browser.')); + return; + } + + // Check if push messaging is supported + if (!('PushManager' in window)) { + // Show a message and activate the button + subBtn.disabled = false; + showMessage(gettext('Push notifications are not available in your browser.')); + return; + } + + // We need to get subscription state for push notifications and send the information to server + reg.pushManager.getSubscription().then( + function(subscription) { + if (subscription){ + postSubscribeObj('subscribe', subscription, + function(response) { + // Check the information is saved successfully into server + if (response.status === 201) { + // Show unsubscribe button instead + subBtn.textContent = gettext('Unsubscribe from Push Messaging'); + subBtn.disabled = false; + isPushEnabled = true; + showMessage(gettext('Successfully subscribed to push notifications.')); + } + }); + } + }); + } +} +); + +function showMessage(message) { + const messageBox = document.getElementById('webpush-message'); + if (messageBox) { + messageBox.textContent = message; + messageBox.style.display = 'block'; + } +} + +function subscribe(reg) { + // Get the Subscription or register one + reg.pushManager.getSubscription().then( + function(subscription) { + var metaObj, applicationServerKey, options; + // Check if Subscription is available + if (subscription) { + return subscription; + } + + metaObj = document.querySelector('meta[name="django-webpush-vapid-key"]'); + applicationServerKey = metaObj.content; + options = { + userVisibleOnly: true + }; + if (applicationServerKey){ + options.applicationServerKey = urlB64ToUint8Array(applicationServerKey) + } + // If not, register one + reg.pushManager.subscribe(options) + .then( + function(subscription) { + postSubscribeObj('subscribe', subscription, + function(response) { + // Check the information is saved successfully into server + if (response.status === 201) { + // Show unsubscribe button instead + subBtn.textContent = gettext('Unsubscribe from Push Messaging'); + subBtn.disabled = false; + isPushEnabled = true; + showMessage(gettext('Successfully subscribed to push notifications.')); + } + }); + }) + .catch( + function() { + console.log(gettext('Error while subscribing to push notifications.'), arguments) + }) + } + ); +} + +function urlB64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (var i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +function unsubscribe(reg) { + // Get the Subscription to unregister + reg.pushManager.getSubscription() + .then( + function(subscription) { + + // Check we have a subscription to unsubscribe + if (!subscription) { + // No subscription object, so set the state + // to allow the user to subscribe to push + subBtn.disabled = false; + showMessage(gettext('Subscription is not available.')); + return; + } + postSubscribeObj('unsubscribe', subscription, + function(response) { + // Check if the information is deleted from server + if (response.status === 202) { + // Get the Subscription + // Remove the subscription + subscription.unsubscribe() + .then( + function(successful) { + subBtn.textContent = gettext('Subscribe to Push Messaging'); + showMessage(gettext('Successfully unsubscribed from push notifications.')); + isPushEnabled = false; + subBtn.disabled = false; + } + ) + .catch( + function(error) { + subBtn.textContent = gettext('Unsubscribe from Push Messaging'); + showMessage(gettext('Error while unsubscribing from push notifications.')); + subBtn.disabled = false; + } + ); + } + }); + } + ) +} + +function postSubscribeObj(statusType, subscription, callback) { + // Send the information to the server with fetch API. + // the type of the request, the name of the user subscribing, + // and the push subscription endpoint + key the server needs + // to send push messages + + var browser = navigator.userAgent.match(/(firefox|msie|chrome|safari|trident)/ig)[0].toLowerCase(), + user_agent = navigator.userAgent, + data = { status_type: statusType, + subscription: subscription.toJSON(), + browser: browser, + user_agent: user_agent, + group: subBtn.dataset.group + }; + + fetch(subBtn.dataset.url, { + method: 'post', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data), + credentials: 'include' + }).then(callback); +} diff --git a/auctions/static/webpush/webpush_serviceworker.js b/auctions/static/webpush/webpush_serviceworker.js new file mode 100644 index 0000000..2eaeaba --- /dev/null +++ b/auctions/static/webpush/webpush_serviceworker.js @@ -0,0 +1,20 @@ + +// Register event listener for the 'push' event. +self.addEventListener('push', function(event) { + // Retrieve the textual payload from event.data (a PushMessageData object). + // Other formats are supported (ArrayBuffer, Blob, JSON), check out the documentation + // on https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData. + var payload = event.data ? event.data.text() : {"head": "No Content", "Body": "No Content"}, + data = JSON.parse(payload), + head = data.head, + body = data.body; + + // Keep the service worker alive until the notification is created. + event.waitUntil( + // Show a notification with title 'ServiceWorker Cookbook' and use the payload + // as the body. + self.registration.showNotification(head, { + body: body + }) + ); +}); diff --git a/auctions/templates/all_auctions.html b/auctions/templates/all_auctions.html index a87fcee..998718d 100755 --- a/auctions/templates/all_auctions.html +++ b/auctions/templates/all_auctions.html @@ -22,6 +22,7 @@

Auctions

{% if last_auction_used %} {{ last_auction_used.title }}
Your last auction + {% if not last_auction_used.promote_this_auction %}Not promoted{% endif %} {{ last_auction_used.template_lot_link_first_column }} {{ last_auction_used.template_promo_info }} diff --git a/auctions/templates/auctions/dynamic_set_lot_winner.html b/auctions/templates/auctions/dynamic_set_lot_winner.html new file mode 100755 index 0000000..ce48e54 --- /dev/null +++ b/auctions/templates/auctions/dynamic_set_lot_winner.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} +{% load i18n %} +{% block title %}Set winners for {{ auction }}{% endblock %} + +{% block extra_js %} + +{% endblock %} +{% block content %} + +{% include 'auction_ribbon.html' %} + +Use this page to mark lots as sold. Press tab to move the to next field. Leave feedback about this page here.
+ + +
+{% endblock %} +{% block undiv_content %} +
+ + +
+

+ Undo +

+
+ +
+
+
+ +
+
+
+
+ +
+ $ + + .00 +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+{% endblock %} diff --git a/auctions/templates/base.html b/auctions/templates/base.html index e425e06..b20e4bc 100755 --- a/auctions/templates/base.html +++ b/auctions/templates/base.html @@ -134,6 +134,12 @@ -webkit-transition: all .1s ease-out; -moz-transition: all .1s ease-out; -ms-transition: all .1s ease-out; +} +#toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 1055; } {% block extra_css %} @@ -200,12 +206,37 @@ return false; }); }); - - - + var $ = window.jQuery; + // toast + (function(b){b.toast=function(a,h,g,l,k){b("#toast-container").length||(b("body").prepend('
'),b("#toast-container").append('
'));var c="",d="",e="text-muted",f="",m="object"===typeof a?a.title||"":a||"Notice!";h="object"===typeof a?a.subtitle||"":h||"";g="object"===typeof a?a.content||"":g||"";k="object"===typeof a?a.delay||3E3:k||3E3;switch("object"===typeof a?a.type||"":l||"info"){case "info":c="bg-info"; + f=e=d="text-white";break;case "success":c="bg-success";f=e=d="text-white";break;case "warning":case "warn":c="bg-warning";f=e=d="text-white";break;case "error":case "danger":c="bg-danger",f=e=d="text-white"}a=' - {% if hide_navbar %}Show/Hide menu. {{ menu_tip }}{% endif %} + {% if not hide_tos_banner %} + {% endblock %} {% block content %} {% include "preferences_ribbon.html" %} diff --git a/auctions/templates/view_lot_images.html b/auctions/templates/view_lot_images.html index 1d4b181..59b76be 100755 --- a/auctions/templates/view_lot_images.html +++ b/auctions/templates/view_lot_images.html @@ -1,7 +1,10 @@ {% extends "base.html" %} {% block title %}{{ lot.lot_name }}{% if lot.ended %} (Ended){% endif %}{% endblock %} {% load static %} - +{% load webpush_notifications %} +{% block extra_head %} + {% webpush_header %} +{% endblock %} {% block extra_css %} {% endblock %} @@ -76,10 +79,10 @@ success : function(result) { if (data.watch) { $("#watch").addClass("watched text-warning"); - $("#watch").html("★ Watching{% if not lot.auction or lot.auction.is_online %} - you'll get an email 2 hours before bidding ends{% endif %}"); + $("#watch").html(" Watching{% if not lot.auction or lot.auction.is_online %} - you'll get an email 2 hours before bidding ends{% endif %}"); } else { $("#watch").removeClass("watched text-warning"); - $("#watch").html('☆ Watch'); + $("#watch").html(' Watch'); } } }); @@ -120,9 +123,6 @@ } }); } - (function(b){b.toast=function(a,h,g,l,k){b("#toast-container").length||(b("body").prepend('
'),b("#toast-container").append('
'));var c="",d="",e="text-muted",f="",m="object"===typeof a?a.title||"":a||"Notice!";h="object"===typeof a?a.subtitle||"":h||"";g="object"===typeof a?a.content||"":g||"";k="object"===typeof a?a.delay||3E3:k||3E3;switch("object"===typeof a?a.type||"":l||"info"){case "info":c="bg-info"; -f=e=d="text-white";break;case "success":c="bg-success";f=e=d="text-white";break;case "warning":case "warn":c="bg-warning";f=e=d="text-white";break;case "error":case "danger":c="bg-danger",f=e=d="text-white"}a=' -{% if lot.high_bidder %} - -{% else %} - -{% endif %} {% endif %} diff --git a/auctions/tests.py b/auctions/tests.py index 2ce5723..211c4f9 100644 --- a/auctions/tests.py +++ b/auctions/tests.py @@ -16,6 +16,7 @@ Lot, LotHistory, PickupLocation, + UserData, UserLabelPrefs, add_price_info, ) @@ -39,6 +40,9 @@ def setUp(self): time = timezone.now() - datetime.timedelta(days=2) timeStart = timezone.now() - datetime.timedelta(days=3) theFuture = timezone.now() + datetime.timedelta(days=3) + self.admin_user = User.objects.create_user( + username="admin_user", password="testpassword", email="test@example.com" + ) self.user = User.objects.create_user(username="my_lot", password="testpassword", email="test@example.com") self.user_with_no_lots = User.objects.create_user( username="no_lots", password="testpassword", email="asdf@example.com" @@ -67,22 +71,44 @@ def setUp(self): lot_entry_fee=2, unsold_lot_fee=10, tax=25, + buy_now="allow", + reserve_price="allow", ) self.location = PickupLocation.objects.create( name="location", auction=self.online_auction, pickup_time=theFuture ) + self.in_person_location = PickupLocation.objects.create( + name="location", auction=self.in_person_auction, pickup_time=theFuture + ) self.userB = User.objects.create_user(username="no_tos", password="testpassword") - self.tos = AuctionTOS.objects.create(user=self.user, auction=self.online_auction, pickup_location=self.location) + self.admin_online_tos = AuctionTOS.objects.create( + user=self.admin_user, auction=self.online_auction, pickup_location=self.location, is_admin=True + ) + self.admin_in_person_tos = AuctionTOS.objects.create( + user=self.admin_user, auction=self.in_person_auction, pickup_location=self.in_person_location, is_admin=True + ) + self.online_tos = AuctionTOS.objects.create( + user=self.user, auction=self.online_auction, pickup_location=self.location + ) + self.in_person_tos = AuctionTOS.objects.create( + user=self.user, auction=self.in_person_auction, pickup_location=self.location + ) self.tosB = AuctionTOS.objects.create( user=self.userB, auction=self.online_auction, pickup_location=self.location ) self.tosC = AuctionTOS.objects.create( user=self.user_with_no_lots, auction=self.online_auction, pickup_location=self.location ) + self.in_person_buyer = AuctionTOS.objects.create( + user=self.user_with_no_lots, + auction=self.in_person_auction, + pickup_location=self.in_person_location, + bidder_number="555", + ) self.lot = Lot.objects.create( lot_name="A test lot", auction=self.online_auction, - auctiontos_seller=self.tos, + auctiontos_seller=self.online_tos, quantity=1, description="", winning_price=10, @@ -92,7 +118,7 @@ def setUp(self): self.lotB = Lot.objects.create( lot_name="B test lot", auction=self.online_auction, - auctiontos_seller=self.tos, + auctiontos_seller=self.online_tos, quantity=1, description="", winning_price=10, @@ -102,7 +128,7 @@ def setUp(self): self.lotC = Lot.objects.create( lot_name="C test lot", auction=self.online_auction, - auctiontos_seller=self.tos, + auctiontos_seller=self.online_tos, quantity=1, description="", winning_price=10, @@ -115,10 +141,10 @@ def setUp(self): description="", auction=self.online_auction, quantity=1, - auctiontos_seller=self.tos, + auctiontos_seller=self.online_tos, active=False, ) - self.invoice, c = Invoice.objects.get_or_create(auctiontos_user=self.tos) + self.invoice, c = Invoice.objects.get_or_create(auctiontos_user=self.online_tos) self.invoiceB, c = Invoice.objects.get_or_create(auctiontos_user=self.tosB) self.adjustment_add = InvoiceAdjustment.objects.create( adjustment_type="ADD", amount=10, notes="test", invoice=self.invoiceB @@ -138,11 +164,15 @@ def setUp(self): notes="test", invoice=self.invoiceB, ) + self.in_person_lot = Lot.objects.create( + lot_name="another test lot", + auction=self.in_person_auction, + auctiontos_seller=self.admin_in_person_tos, + quantity=1, + description="", + custom_lot_number="101-1", + ) # TODO: stuff to add here: - # a normal user that has joined no auctions - # a user that has joined self.online_auction - # a user that is an admin for both auctions (tos.is_admin=True) - # lots in the in-person auction # a few more users and a userban or two # an online auction that hasn't started yet # an in-person auction that hasn't started yet @@ -609,7 +639,7 @@ def test_invoices(self): assert self.invoiceB.contact_email == "test@example.com" assert self.invoiceB.is_online assert self.invoiceB.unsold_lot_warning == "" - assert str(self.invoice) == f"{self.tos.name}'s invoice for {self.tos.auction}" + assert str(self.invoice) == f"{self.online_tos.name}'s invoice for {self.online_tos.auction}" # adjustments self.adjustment_add.amount = 0 @@ -747,196 +777,6 @@ def test_lot_prices(self): assert lot.your_cut == 0 -class SetLotWinnerViewTest(TestCase): - def setUp(self): - time = timezone.now() - datetime.timedelta(days=2) - timeStart = timezone.now() - datetime.timedelta(days=3) - theFuture = timezone.now() + datetime.timedelta(days=3) - self.user = User.objects.create_user(username="testuser", password="testpassword") - self.user2 = User.objects.create_user(username="testuser2", password="password") - self.auction = Auction.objects.create( - created_by=self.user, - title="A test auction", - date_end=time, - date_start=timeStart, - winning_bid_percent_to_club=25, - lot_entry_fee=2, - unsold_lot_fee=10, - tax=25, - ) - self.location = PickupLocation.objects.create(name="location", auction=self.auction, pickup_time=theFuture) - self.seller = AuctionTOS.objects.create( - user=self.user, - auction=self.auction, - pickup_location=self.location, - bidder_number="145", - ) - self.bidder = AuctionTOS.objects.create( - user=self.user2, - auction=self.auction, - pickup_location=self.location, - bidder_number="225", - ) - self.lot = Lot.objects.create( - custom_lot_number="123", - lot_name="A test lot", - auction=self.auction, - auctiontos_seller=self.seller, - quantity=1, - description="", - ) - self.lot2 = Lot.objects.create( - custom_lot_number="124", - lot_name="Another test lot", - auction=self.auction, - auctiontos_seller=self.seller, - quantity=1, - description="", - ) - # self.invoice = Invoice.objects.create(auctiontos_user=self.seller) - # self.invoiceB = Invoice.objects.create(auctiontos_user=self.tosB) - - # Create test client - self.client = Client() - - def test_valid_form_submission_and_undo(self): - self.client.login(username="testuser", password="testpassword") - url = reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) - response = self.client.post( - url, - { - "lot": self.lot.custom_lot_number, - "winner": self.bidder.bidder_number, - "winning_price": 100, - "invoice": True, - "auction": 5, # this is not used anywhere, but still required - }, - ) - assert response.status_code == 302 # Should redirect after successful form submission - updated_lot = Lot.objects.get(pk=self.lot.pk) - assert updated_lot.auctiontos_winner == self.bidder - assert updated_lot.winning_price == 100 - response = self.client.get(url, {"undo": self.lot.custom_lot_number}) - assert response.status_code == 200 - updated_lot = Lot.objects.get(pk=self.lot.pk) - assert updated_lot.auctiontos_winner is None - assert updated_lot.winning_price is None - - def test_invalid_form_submission(self): - url = reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) - self.client.login(username="testuser", password="testpassword") - response = self.client.post( - url, - { - "lot": self.lot.custom_lot_number, - "winner": self.bidder.bidder_number, - "winning_price": -10, # Invalid winning price - "invoice": True, - "auction": 5, - }, - ) - assert response.status_code == 200 # Form should not be submitted successfully - updated_lot = Lot.objects.get(pk=self.lot.pk) - assert updated_lot.auctiontos_winner is None - assert updated_lot.winning_price is None - - def test_seller_invoice_closed(self): - self.invoice = Invoice.objects.get(auctiontos_user=self.seller) - self.invoice.status = "READY" - self.invoice.save() - self.client.login(username="testuser", password="testpassword") - url = reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) - response = self.client.post( - url, - { - "lot": self.lot.custom_lot_number, - "winner": self.bidder.bidder_number, - "winning_price": 100, - "invoice": True, - "auction": 5, - }, - ) - assert response.status_code == 200 # Form should not be submitted successfully - updated_lot = Lot.objects.get(pk=self.lot.pk) - assert updated_lot.auctiontos_winner is None - assert updated_lot.winning_price is None - - def test_winner_invoice_closed(self): - self.invoice, c = Invoice.objects.get_or_create(auctiontos_user=self.bidder) - self.invoice.status = "READY" - self.invoice.save() - self.client.login(username="testuser", password="testpassword") - url = reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) - response = self.client.post( - url, - { - "lot": self.lot.custom_lot_number, - "winner": self.bidder.bidder_number, - "winning_price": 100, - "invoice": True, - "auction": 5, - }, - ) - assert response.status_code == 200 # Form should not be submitted successfully - updated_lot = Lot.objects.get(pk=self.lot.pk) - assert updated_lot.auctiontos_winner is None - assert updated_lot.winning_price is None - - def test_winner_not_found(self): - self.client.login(username="testuser", password="testpassword") - url = reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) - response = self.client.post( - url, - { - "lot": self.lot.custom_lot_number, - "winner": "55665", # Invalid winner number - "winning_price": 100, - "invoice": True, - "auction": 5, - }, - ) - assert response.status_code == 200 # Form should not be submitted successfully - updated_lot = Lot.objects.get(pk=self.lot.pk) - assert updated_lot.auctiontos_winner is None - assert updated_lot.winning_price is None - - def test_lot_not_found(self): - self.client.login(username="testuser", password="testpassword") - url = reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) - response = self.client.post( - url, - { - "lot": "invalid_lot_number", # Invalid lot number - "winner": self.bidder.bidder_number, - "winning_price": 100, - "invoice": True, - "auction": 5, - }, - ) - assert response.status_code == 200 # Form should not be submitted successfully - - def test_lot_already_sold(self): - self.lot.auctiontos_winner = self.bidder - self.lot.winning_price = 100 - self.lot.save() - self.client.login(username="testuser", password="testpassword") - url = reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) - response = self.client.post( - url, - { - "lot": self.lot.custom_lot_number, - "winner": self.bidder.bidder_number, - "winning_price": 200, # Attempting to set winner for already sold lot with a different price - "invoice": True, - "auction": 5, - }, - ) - assert response.status_code == 200 # Form should not be submitted successfully - updated_lot = Lot.objects.get(pk=self.lot.pk) - assert updated_lot.auctiontos_winner == self.bidder # Lot winner should remain unchanged - assert updated_lot.winning_price == 100 # Winning price should remain unchanged - - class LotRefundDialogTests(TestCase): def setUp(self): time = timezone.now() - datetime.timedelta(days=2) @@ -1030,6 +870,7 @@ def test_user_can_print_own_labels(self): self.client.login(username=self.user, password="testpassword") self.endAuction() response = self.client.get(self.url) + # messages = list(response.wsgi_request._messages) assert response.status_code == 200 assert "attachment; filename=" in response.headers["Content-Disposition"] @@ -1062,9 +903,7 @@ def test_small_labels(self): def test_non_admin_cannot_print_others_labels(self): """Test that a non-admin user cannot print labels for other users.""" self.client.login(username="no_tos", password="testpassword") - response = self.client.get(self.url) - assert response.status_code == 302 messages = list(response.wsgi_request._messages) assert str(messages[0]) == "Your account doesn't have permission to view this page." @@ -1086,3 +925,131 @@ def test_no_printable_lots(self): self.client.login(username=self.user_with_no_lots.username, password="testpassword") response = self.client.get(self.url) assert response.status_code == 302 + + +class UpdateLotPushNotificationsViewTestCase(StandardTestCase): + def get_url(self): + return reverse("enable_notifications") + + def test_anonymous_user(self): + response = self.client.get(self.get_url()) + assert response.status_code == 302 + response = self.client.post(self.get_url()) + assert response.status_code == 302 + + def test_logged_in_user(self): + self.client.login(username=self.user_who_does_not_join.username, password="testpassword") + response = self.client.get(self.get_url()) + assert response.status_code == 405 + response = self.client.post(self.get_url()) + assert response.status_code == 200 + userdata = UserData.objects.get(user=self.user_who_does_not_join) + assert userdata.push_notifications_when_lots_sell is True + + +class DynamicSetLotWinnerViewTestCase(StandardTestCase): + def get_url(self): + return reverse("auction_lot_winners_dynamic", kwargs={"slug": self.in_person_auction.slug}) + + def test_anonymous_user(self): + response = self.client.get(self.get_url()) + assert response.status_code == 403 + response = self.client.post(self.get_url()) + assert response.status_code == 403 + + def test_non_admin_user(self): + self.client.login(username=self.user_who_does_not_join.username, password="testpassword") + response = self.client.get(self.get_url()) + assert response.status_code == 403 + response = self.client.post(self.get_url()) + assert response.status_code == 403 + + def test_admin_user(self): + self.client.login(username=self.admin_user.username, password="testpassword") + response = self.client.get(self.get_url()) + assert response.status_code == 200 + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "5", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("price") == "valid" + assert data.get("winner") == "valid" + assert data.get("lot") == "valid" + + self.in_person_lot.reserve_price = 10 + self.in_person_lot.save() + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "5", "winner": "556", "action": "validate"} + ) + data = response.json() + assert data.get("price") != "valid" + assert data.get("winner") != "valid" + assert data.get("lot") == "valid" + + response = self.client.post(self.get_url(), data={"lot": "102-1", "action": "validate"}) + data = response.json() + assert data.get("lot") != "valid" + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} + ) + data = response.json() + assert data.get("price") == "valid" + assert data.get("winner") == "valid" + assert data.get("lot") == "valid" + assert data.get("last_sold_lot_number") == "101-1" + assert data.get("success_message") is not None + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + assert lot.winning_price == 10 + assert lot.auctiontos_winner is not None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("lot") != "valid" + + invoice, created = Invoice.objects.get_or_create(auctiontos_user=self.in_person_lot.auctiontos_seller) + invoice.status = "UNPAID" + invoice.save() + + self.in_person_lot.auctiontos_winner = None + self.in_person_lot.winning_price = None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} + ) + data = response.json() + assert data.get("lot") != "valid" + assert self.in_person_lot.auctiontos_winner is None + assert self.in_person_lot.winning_price is None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "7", "winner": "555", "action": "force_save"} + ) + data = response.json() + assert data.get("lot") == "valid" + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + assert lot.winning_price == 7 + assert lot.auctiontos_winner is not None + + Bid.objects.create(user=self.admin_user, lot_number=self.in_person_lot, amount=100) + self.in_person_auction.allow_bidding_on_lots = True + self.in_person_auction.save() + invoice.status = "OPEN" + invoice.save() + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + lot.winning_price = None + lot.auctiontos_winner = None + lot.winner = None + lot.save() + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("price") != "valid" + assert data.get("winner") != "valid" diff --git a/auctions/urls.py b/auctions/urls.py index 37e6b32..7d47551 100755 --- a/auctions/urls.py +++ b/auctions/urls.py @@ -1,5 +1,5 @@ from django.contrib.auth.decorators import login_required -from django.urls import path, re_path +from django.urls import include, path, re_path from django.views.generic.base import TemplateView from django_ses.views import SESEventWebhookView @@ -30,6 +30,7 @@ path("api/users/ban//", views.userBan), path("api/users/unban//", views.userUnban), path("api/users/location/", views.setCoordinates), + path("api/users/enable-notifications", views.UpdateLotPushNotificationsView.as_view(), name="enable_notifications"), path("api/users/lot_notifications/", views.lotNotifications), path("api/users/auction_notifications/", views.auctionNotifications), path("api/lots/new_lot_last_auction/", views.no_lot_auctions), @@ -188,20 +189,30 @@ login_required(views.BulkAddLots.as_view()), name="bulk_add_lots_for_myself", ), + # path( + # "auctions//lots/set-winners/old", + # login_required(views.SetLotWinner.as_view()), + # name="auction_lot_winners", + # ), + # path( + # "auctions//lots/set-winners/presentation", + # login_required(views.SetLotWinnerImage.as_view()), + # name="auction_lot_winners_images", + # ), + # path( + # "auctions//lots/set-winners/autocomplete", + # views.QuickSetLotWinner.as_view(), + # name="auction_lot_winners_autocomplete", + # ), path( "auctions//lots/set-winners/", - login_required(views.SetLotWinner.as_view()), - name="auction_lot_winners", + views.DynamicSetLotWinner.as_view(), + name="auction_lot_winners_dynamic", ), path( - "auctions//lots/set-winners/presentation", - login_required(views.SetLotWinnerImage.as_view()), - name="auction_lot_winners_images", - ), - path( - "auctions//lots/set-winners/autocomplete", - views.QuickSetLotWinner.as_view(), - name="auction_lot_winners_autocomplete", + "auctions//lots/set-winners/undo/", + views.AuctionUnsellLot.as_view(), + name="auction_unsell_lot", ), path("auctions//lots//", views.ViewLot.as_view()), path( @@ -408,4 +419,5 @@ SESEventWebhookView.as_view(), name="handle-event-webhook", ), + re_path(r"^webpush/", include("webpush.urls")), ] diff --git a/auctions/views.py b/auctions/views.py index 4de7cd7..3babff5 100755 --- a/auctions/views.py +++ b/auctions/views.py @@ -42,6 +42,7 @@ from django.http import ( Http404, HttpResponse, + HttpResponseNotAllowed, HttpResponseRedirect, JsonResponse, ) @@ -77,6 +78,8 @@ Table, ) from user_agents import parse +from webpush import send_user_notification +from webpush.models import PushInformation from .filters import ( AuctionTOSFilter, @@ -112,9 +115,6 @@ TOSFormSetHelper, UserLabelPrefsForm, UserLocation, - WinnerLot, - WinnerLotSimple, - WinnerLotSimpleImages, ) from .models import ( FAQ, @@ -136,7 +136,6 @@ LotImage, PageView, PickupLocation, - Product, SearchHistory, UserBan, UserData, @@ -285,6 +284,15 @@ def is_auction_admin(self): return result +class AuctionViewMixin(AuctionPermissionsMixin): + """Subclass this when you need auction permissions, it's easier than using AuctionPermissionsMixin""" + + def dispatch(self, request, *args, **kwargs): + self.auction = get_object_or_404(Auction, slug=kwargs.pop("slug"), is_deleted=False) + self.is_auction_admin + return super().dispatch(request, *args, **kwargs) + + class AuctionStatsPermissionsMixin: """For graph classes""" @@ -878,7 +886,7 @@ def setCoordinates(request): def userBan(request, pk): - if request.method == "POST": + if request.method == "POST" and request.user.is_authenticated: user = request.user bannedUser = User.objects.get(pk=pk) obj, created = UserBan.objects.update_or_create( @@ -907,7 +915,7 @@ def userBan(request, pk): if not lot.ended: # print(f"User {str(user)} has banned lot {lot}") lot.banned = True - lot.ban_reason = "This user has been banned from this auction" + lot.ban_reason = "The seller of this lot has been banned from this auction" lot.save() # return #redirect('/users/' + str(pk)) return redirect(reverse("userpage", kwargs={"slug": bannedUser.username})) @@ -939,38 +947,9 @@ def lotDeactivate(request, pk): return redirect("/") -# def lotBan(request, pk): -# if request.method == 'POST': -# lot = Lot.objects.get(pk=pk) -# try: -# ban_reason = request.POST['banned'] -# except: -# return HttpResponse("specify banned in post data") -# checksPass = False -# if request.user.is_superuser: -# checksPass = True -# if lot.auction: -# if lot.auction.created_by.pk == request.user.pk: -# checksPass = True -# if checksPass: -# if not ban_reason: -# lot.banned = False -# else: -# lot.banned = True -# lot.ban_reason = ban_reason -# lot.save() -# # I am debating whether or not to add a LotHistory here -# # A similar one would need to be added when banning a user, -# # so it may make more sense to do this with a reciever on save of lot -# # for now, I'm going to leave this alone -# return HttpResponse("success") -# messages.error(request, "Your account doesn't have permission to view this page") -# return redirect('/') - - def userUnban(request, pk): """Delete the UserBan""" - if request.method == "POST": + if request.method == "POST" and request.user.is_authenticated: user = request.user bannedUser = User.objects.get(pk=pk) obj, created = UserBan.objects.update_or_create( @@ -1261,6 +1240,24 @@ def invoicePaid(request, pk, **kwargs): return redirect("/") +class APIPostView(LoginRequiredMixin, View): + """POST only method to do stuff, logged in users only""" + + def get(self, request, *args, **kwargs): + return HttpResponseNotAllowed(["POST"]) + + def post(self, request, *args, **kwargs): + raise NotImplementedError() + + +class UpdateLotPushNotificationsView(APIPostView): + def post(self, request, *args, **kwargs): + userdata = request.user.userdata + userdata.push_notifications_when_lots_sell = True + userdata.save() + return JsonResponse({"result": "success"}) + + @login_required def my_lot_report(request): """CSV file showing my lots""" @@ -1445,7 +1442,7 @@ def auctionReport(request, slug): def userReport(request): """Get a CSV file showing all users from all auctions you're an admin for""" response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename=all_auction_contacts.csv"' + response["Content-Disposition"] = "attachment; filename=all_auction_contacts.csv" writer = csv.writer(response) found = [] writer.writerow(["Name", "Email", "Phone"]) @@ -2006,174 +2003,368 @@ def get_context_data(self, **kwargs): return context -class QuickSetLotWinner(FormView, AuctionPermissionsMixin): - """A form to let people record the winners of lots (really just for in-person auctions). Just 3 fields: - lot number - winner - winning price - """ +# The following lines of code can most likely be removed unless some club complains about the current system to set winners +# class QuickSetLotWinner(FormView, AuctionPermissionsMixin): +# """A form to let people record the winners of lots (really just for in-person auctions). Just 3 fields: +# lot number +# winner +# winning price +# """ - template_name = "auctions/quick_set_winner.html" - form_class = WinnerLot - model = Lot +# template_name = "auctions/quick_set_winner.html" +# form_class = WinnerLot +# model = Lot - def get_success_url(self): - return reverse("auction_lot_winners_autocomplete", kwargs={"slug": self.auction.slug}) +# def get_success_url(self): +# return reverse("auction_lot_winners_autocomplete", kwargs={"slug": self.auction.slug}) - def get_queryset(self): - return self.auction.lots_qs +# def get_queryset(self): +# return self.auction.lots_qs - def dispatch(self, request, *args, **kwargs): - self.auction = Auction.objects.get(slug=kwargs.pop("slug"), is_deleted=False) - self.is_auction_admin - undo = self.request.GET.get("undo") - if undo and request.method == "GET": - undo_lot = Lot.objects.filter(custom_lot_number=undo, auction=self.auction).first() - if undo_lot: - undo_lot.winner = None - undo_lot.auctiontos_winner = None - undo_lot.winning_price = None - if not self.auction.is_online: - undo_lot.date_end = None - undo_lot.active = False - undo_lot.save() - messages.info( - request, - f"{undo_lot.custom_lot_number} {undo_lot.lot_name} now has no winner and can be sold", - ) - return super().dispatch(request, *args, **kwargs) - - def get_form_kwargs(self): - form_kwargs = super().get_form_kwargs() - form_kwargs["auction"] = self.auction - return form_kwargs +# def dispatch(self, request, *args, **kwargs): +# self.auction = Auction.objects.get(slug=kwargs.pop("slug"), is_deleted=False) +# self.is_auction_admin +# undo = self.request.GET.get("undo") +# if undo and request.method == "GET": +# undo_lot = Lot.objects.filter(custom_lot_number=undo, auction=self.auction).first() +# if undo_lot: +# undo_lot.winner = None +# undo_lot.auctiontos_winner = None +# undo_lot.winning_price = None +# if not self.auction.is_online: +# undo_lot.date_end = None +# undo_lot.active = False +# undo_lot.save() +# undo_lot.create_update_invoices +# messages.info( +# request, +# f"{undo_lot.custom_lot_number} {undo_lot.lot_name} now has no winner and can be sold", +# ) +# return super().dispatch(request, *args, **kwargs) + +# def get_form_kwargs(self): +# form_kwargs = super().get_form_kwargs() +# form_kwargs["auction"] = self.auction +# return form_kwargs + +# def get_context_data(self, **kwargs): +# context = super().get_context_data(**kwargs) +# context["auction"] = self.auction +# return context + +# def form_valid(self, form, **kwargs): +# """A bit of cleanup""" +# lot = form.cleaned_data.get("lot") +# winner = form.cleaned_data.get("winner") +# winning_price = form.cleaned_data.get("winning_price") +# lot = Lot.objects.get(pk=lot, is_deleted=False) +# tos = AuctionTOS.objects.get(pk=winner) +# # check auction, find a lot that matches this one, confirm it belongs to this auction +# if lot.auction and lot.auction == self.auction: +# if (not tos and not winning_price) or (tos.auction and tos.auction == self.auction): +# return self.set_winner(lot, tos, winning_price) +# return self.form_invalid(form) + +# def set_winner(self, lot, winning_tos, winning_price): +# """Set the winner (or mark lot unsold), +# add a success message +# This does not do permissions or validation checks, do those first +# Call this once the form is valid""" +# if not winning_price: +# lot.date_end = timezone.now() +# lot.save() +# messages.success( +# self.request, +# f"Lot {lot.custom_lot_number} has been ended with no winner", +# ) +# else: +# lot.auctiontos_winner = winning_tos +# lot.winning_price = winning_price +# lot.date_end = timezone.now() +# lot.save() +# lot.create_update_invoices +# lot.add_winner_message(self.request.user, winning_tos, winning_price) +# undo_url = self.get_success_url() + f"?undo={lot.custom_lot_number}" +# messages.success( +# self.request, +# f"Bidder {winning_tos.bidder_number} is now the winner of lot {lot.custom_lot_number}. Undo", +# ) +# return HttpResponseRedirect(self.get_success_url()) + + +# class SetLotWinner(QuickSetLotWinner): +# """Same as QuickSetLotWinner but without the autocomplete, per user requests""" + +# form_class = WinnerLotSimple + +# def get_success_url(self): +# return reverse("auction_lot_winners", kwargs={"slug": self.auction.slug}) + +# def form_valid(self, form, **kwargs): +# """A bit of cleanup""" +# lot = form.cleaned_data.get("lot") +# winner = form.cleaned_data.get("winner") +# winning_price = form.cleaned_data.get("winning_price") +# if winning_price is None or winning_price < 0: +# form.add_error("winning_price", "How much did the lot sell for?") +# qs = self.auction.lots_qs +# lot = qs.filter(custom_lot_number=lot).first() +# tos = None +# if not lot: +# form.add_error("lot", "No lot found") +# if winning_price is not None and winning_price > 0: +# tos = AuctionTOS.objects.filter(auction=self.auction, bidder_number=winner) +# if len(tos) > 1: +# form.add_error("winner", f"{len(tos)} bidders found with this number!") +# else: +# tos = tos.first() +# if not tos: +# form.add_error("winner", "No bidder found") +# else: +# if tos.invoice and tos.invoice.status != "DRAFT": +# form.add_error("winner", "This user's invoice is not open") +# if lot: +# if ( +# lot.auctiontos_seller +# and lot.auctiontos_seller.invoice +# and lot.auctiontos_seller.invoice.status != "DRAFT" +# ): +# form.add_error("lot", "The seller's invoice is not open") +# else: +# # right now we allow you to mark unsold lots that have already been sold, bad idea but it's what people want +# if lot.auctiontos_winner and lot.winning_price and winning_price != 0: +# # if lot.auctiontos_winner and lot.winning_price: # this would be better, but would confuse +# error = f"Lot {lot.lot_number_display} has already been sold" +# try: +# if ( +# tos.invoice.status == "DRAFT" +# and lot.auctiontos_seller +# and lot.auctiontos_seller.invoice.status == "DRAFT" +# ): +# undo_url = self.get_success_url() + f"?undo={lot.custom_lot_number}" +# form.add_error( +# "lot", +# mark_safe(f"{error}. Click here to mark unsold."), +# ) +# except: +# # one invoice or the other doesn't exist, this only happens when the selling the first lot to a given tos +# form.add_error("lot", mark_safe(f"{error}")) +# if lot.high_bidder and lot.auction.allow_bidding_on_lots: +# if winning_price <= lot.max_bid and winner != lot.high_bidder_for_admins: +# form.add_error("winning_price", "Lower than an online bid") +# form.add_error("winner", f"Bidder {lot.high_bidder_for_admins} has bid more than this") +# if form.is_valid(): +# return self.set_winner(lot, tos, winning_price) +# return self.form_invalid(form) + + +# class SetLotWinnerImage(SetLotWinner): +# """Same as QuickSetLotWinner but without the autocomplete, and with images, per user requests""" + +# template_name = "auctions/quick_set_winner_images.html" +# form_class = WinnerLotSimpleImages + +# def get_success_url(self): +# return reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) + +# def get_context_data(self, **kwargs): +# context = super().get_context_data(**kwargs) +# context["hide_navbar"] = True +# context["menu_tip"] = mark_safe( +# f"Press Tab to move the to next field, F11 for full screen, control + or control - to zoom. {self.auction} home." +# ) +# return context + + +class DynamicSetLotWinner(AuctionViewMixin, TemplateView): + """A form to set lot winners. Totally async with no page loads, just POST. + Eventually this will be the only way to set winners in an in-person auction""" + + template_name = "auctions/dynamic_set_lot_winner.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["auction"] = self.auction + # Don't want notifications to show up on the projector + # context['disable_websocket'] = True return context - def form_valid(self, form, **kwargs): - """A bit of cleanup""" - lot = form.cleaned_data.get("lot") - winner = form.cleaned_data.get("winner") - winning_price = form.cleaned_data.get("winning_price") - lot = Lot.objects.get(pk=lot, is_deleted=False) - tos = AuctionTOS.objects.get(pk=winner) - # check auction, find a lot that matches this one, confirm it belongs to this auction - if lot.auction and lot.auction == self.auction: - if (not tos and not winning_price) or (tos.auction and tos.auction == self.auction): - return self.set_winner(lot, tos, winning_price) - return self.form_invalid(form) - - def set_winner(self, lot, winning_tos, winning_price): - """Set the winner (or mark lot unsold), - add a success message - This does not do permissions or validation checks, do those first - Call this once the form is valid""" - if not winning_price: - lot.date_end = timezone.now() - lot.save() - messages.success( - self.request, - f"Lot {lot.custom_lot_number} has been ended with no winner", - ) + def validate_lot(self, lot, action): + """Returns (Lot or None, error or None)""" + error = None + result_lot = None + if not lot and action != "validate": + error = "Enter a lot number" else: - lot.auctiontos_winner = winning_tos - lot.winning_price = winning_price - lot.date_end = timezone.now() - lot.save() - lot.add_winner_message(self.request.user, winning_tos, winning_price) - undo_url = self.get_success_url() + f"?undo={lot.custom_lot_number}" - messages.success( - self.request, - f"Bidder {winning_tos.bidder_number} is now the winner of lot {lot.custom_lot_number}. Undo", - ) - return HttpResponseRedirect(self.get_success_url()) - - -class SetLotWinner(QuickSetLotWinner): - """Same as QuickSetLotWinner but without the autocomplete, per user requests""" - - form_class = WinnerLotSimple - - def get_success_url(self): - return reverse("auction_lot_winners", kwargs={"slug": self.auction.slug}) - - def form_valid(self, form, **kwargs): - """A bit of cleanup""" - lot = form.cleaned_data.get("lot") - winner = form.cleaned_data.get("winner") - winning_price = form.cleaned_data.get("winning_price") - if winning_price is None or winning_price < 0: - form.add_error("winning_price", "How much did the lot sell for?") - qs = self.auction.lots_qs - lot = qs.filter(custom_lot_number=lot).first() + result_lot = self.auction.lots_qs.filter(custom_lot_number=lot).first() + if not result_lot and lot: + error = "No lot found" + if ( + result_lot + and result_lot.auctiontos_seller + and result_lot.auctiontos_seller.invoice + and result_lot.auctiontos_seller.invoice.status != "DRAFT" + ): + if action != "force_save": + error = "The seller's invoice is not open" + if result_lot and result_lot.auctiontos_winner and result_lot.winning_price and action != "force_save": + error = "This lot has already been sold" + return result_lot, error + + def validate_price(self, price, action): + """Returns (Int or None, error or None)""" + result_price = None + error = None + try: + result_price = int(price) + except (ValueError, TypeError): + if action == "save": + error = "Enter the winning price" + if action == "force_save": + error = "You can skip some errors, but you still need to enter a price" + return result_price, error + + def validate_winner(self, winner, action): + """Returns (AuctionTOS or None, error or None)""" + error = None tos = None - if not lot: - form.add_error("lot", "No lot found") - if winning_price is not None and winning_price > 0: - tos = AuctionTOS.objects.filter(auction=self.auction, bidder_number=winner) - if len(tos) > 1: - form.add_error("winner", f"{len(tos)} bidders found with this number!") - else: - tos = tos.first() - if not tos: - form.add_error("winner", "No bidder found") - else: - if tos.invoice and tos.invoice.status != "DRAFT": - form.add_error("winner", "This user's invoice is not open") - if lot: - if ( - lot.auctiontos_seller - and lot.auctiontos_seller.invoice - and lot.auctiontos_seller.invoice.status != "DRAFT" - ): - form.add_error("lot", "The seller's invoice is not open") + if not winner and (action == "force_save" or action == "save"): + error = "Enter the winning bidder's number" + else: + tos = AuctionTOS.objects.filter(auction=self.auction, bidder_number=winner).order_by("-createdon").first() + if not tos and winner: + error = "No bidder found" else: - # right now we allow you to mark unsold lots that have already been sold, bad idea but it's what people want - if lot.auctiontos_winner and lot.winning_price and winning_price != 0: - # if lot.auctiontos_winner and lot.winning_price: # this would be better, but would confuse - error = f"Lot {lot.lot_number_display} has already been sold" - try: - if ( - tos.invoice.status == "DRAFT" - and lot.auctiontos_seller - and lot.auctiontos_seller.invoice.status == "DRAFT" - ): - undo_url = self.get_success_url() + f"?undo={lot.custom_lot_number}" - form.add_error( - "lot", - mark_safe(f"{error}. Click here to mark unsold."), - ) - except: - # one invoice or the other doesn't exist, this only happens when the selling the first lot to a given tos - form.add_error("lot", mark_safe(f"{error}")) - if lot.high_bidder and lot.auction.allow_bidding_on_lots: - if winning_price <= lot.max_bid and winner != lot.high_bidder_for_admins: - form.add_error("winning_price", "Lower than an online bid") - form.add_error("winner", f"Bidder {lot.high_bidder_for_admins} has bid more than this") - if form.is_valid(): - return self.set_winner(lot, tos, winning_price) - return self.form_invalid(form) + if tos and tos.invoice and tos.invoice.status != "DRAFT" and action != "force_save": + error = "This user's invoice is not open" + return tos, error + + def end_unsold(self, lot): + """Mark lot unsold""" + lot.date_end = timezone.now() + lot.winner = None + lot.auctiontos_winner = None + lot.winning_price = None + lot.active = False + lot.save() + message = f"{self.request.user} has marked lot {lot.custom_lot_number} as not sold" + LotHistory.objects.create( + lot=lot, + user=self.request.user, + message=message, + changed_price=True, + ) + lot.send_websocket_message( + { + "type": "chat_message", + "info": "ENDED_NO_WINNER", + "message": message, + "high_bidder_pk": None, + "high_bidder_name": None, + "current_high_bid": None, + } + ) + return message + def set_winner(self, lot, winning_tos, winning_price): + lot.auctiontos_winner = winning_tos + lot.winning_price = winning_price + lot.date_end = timezone.now() + lot.active = False + lot.save() + lot.add_winner_message(self.request.user, winning_tos, winning_price) + return f"Bidder {winning_tos.bidder_number} is now the winner of lot {lot.custom_lot_number}" -class SetLotWinnerImage(SetLotWinner): - """Same as QuickSetLotWinner but without the autocomplete, and with images, per user requests""" + def post(self, request, *args, **kwargs): + """All lot validation checks called from here""" + lot = request.POST.get("lot", None) + price = request.POST.get("price", None) + winner = request.POST.get("winner", None) + action = request.POST.get("action", "validate") + + result = { + "price": None, + "winner": None, + "lot": None, + "last_sold_lot_number": None, + "success_message": None, + "online_high_bidder_message": None, + } + lot, lot_error = self.validate_lot(lot, action) + if lot and not lot_error and action == "to_online_high_bidder": + result["success_message"] = lot.sell_to_online_high_bidder + result["last_sold_lot_number"] = lot.custom_lot_number + lot.add_winner_message(self.request.user, lot.auctiontos_winner, lot.winning_price) + return JsonResponse(result) + price, price_error = self.validate_price(price, action) + winner, winner_error = self.validate_winner(winner, action) + if lot and not lot_error and action == "end_unsold": + result["success_message"] = self.end_unsold(lot) + result["last_sold_lot_number"] = lot.custom_lot_number + return JsonResponse(result) + if ( + not price_error + and lot + and winner + and lot.high_bidder + and lot.auction.allow_bidding_on_lots + and action != "force_save" + ): + if price and price <= lot.max_bid and f"{winner}" != f"{lot.high_bidder_for_admins}": + price_error = "Lower than an online bid" + winner_error = f"Bidder {lot.high_bidder_for_admins} has bid more than this" + if not price_error and price and lot and not lot_error and action != "force_save": + if lot.reserve_price and price < lot.reserve_price: + price_error = f"This lot's minimum bid is ${lot.reserve_price}" + if price < self.auction.minimum_bid: + price_error = f"Minimum bid is ${self.auction.minimum_bid}" + # I think this makes more sense: + if not lot_error and not price_error and not winner_error: + if action != "validate": + result["last_sold_lot_number"] = lot.custom_lot_number + if action == "force_save" or action == "save": + result["success_message"] = self.set_winner(lot, winner, price) + if lot and (action == "validate" or not result["success_message"]) and lot.high_bidder: + result["online_high_bidder_message"] = f"Sell to {lot.high_bidder_for_admins} for ${lot.high_bid}" + # js code is not in place for this, also remove code from view_lot_simple + if lot and not lot_error: + lot = "valid" + if price and not price_error: + price = "valid" + if winner and not winner_error: + winner = "valid" + result["lot"] = lot_error or lot + result["price"] = price_error or price + result["winner"] = winner_error or winner + return JsonResponse(result) - template_name = "auctions/quick_set_winner_images.html" - form_class = WinnerLotSimpleImages - def get_success_url(self): - return reverse("auction_lot_winners_images", kwargs={"slug": self.auction.slug}) +class AuctionUnsellLot(AuctionViewMixin, View): + def post(self, request, *args, **kwargs): + undo_lot = request.POST.get("lot_number", None) + if undo_lot: + undo_lot = Lot.objects.filter(custom_lot_number=undo_lot, auction=self.auction).first() + if undo_lot: + result = { + "hide_undo_button": "true", + "last_sold_lot_number": "", + "success_message": f"{undo_lot.custom_lot_number} {undo_lot.lot_name} now has no winner and can be sold", + } + undo_lot.winner = None + undo_lot.auctiontos_winner = None + undo_lot.winning_price = None + if not self.auction.is_online: + undo_lot.date_end = None + # this might need changing for online auctions + # but as it is now, this view is only ever called for in-person auctions + undo_lot.active = True + undo_lot.save() + else: + result = {"message": "No lot found"} + return JsonResponse(result) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["hide_navbar"] = True - context["menu_tip"] = mark_safe( - f"Press Tab to move the to next field, F11 for full screen, control + or control - to zoom. {self.auction} home." - ) - return context + def get(self, request, *args, **kwargs): + return self.http_method_not_allowed class BulkAddUsers(TemplateView, ContextMixin, AuctionPermissionsMixin): @@ -2683,6 +2874,8 @@ def get_context_data(self, **kwargs): context["user_specific_bidding_error"] = ( f"This lot is part of {lot.auction}. Please read the auction's rules and join the auction to bid
" ) + if not lot.auction.is_online and lot.auction.message_users_when_lots_sell: + context["push_notifications_possible"] = True if lot.within_dynamic_end_time and lot.minutes_to_end > 0 and not lot.sealed_bid: messages.info( self.request, @@ -2759,7 +2952,7 @@ def get_context_data(self, **kwargs): return context -class ViewLotSimple(ViewLot): +class ViewLotSimple(ViewLot, AuctionPermissionsMixin): """Minimalist view of a lot, just image and description. For htmx calls""" template_name = "view_lot_simple.html" @@ -2767,31 +2960,39 @@ class ViewLotSimple(ViewLot): def get_context_data(self, **kwargs): context = DetailView.get_context_data(self, **kwargs) - context["lot"] = self.get_object() + lot = self.get_object() + context["lot"] = lot + if lot and lot.auction: + self.auction = lot.auction + if self.is_auction_admin and self.auction.message_users_when_lots_sell and not lot.sold: + result = { + "type": "chat_message", + "info": "CHAT", + "message": "This lot is about to be sold!", + "pk": -1, + "username": "System", + } + lot.send_websocket_message(result) + watchers = Watch.objects.filter( + lot_number=lot.pk, user__userdata__push_notifications_when_lots_sell=True + ).exclude( + # it would be awkward to have notifications pop up when you're projecting an image of the lot + user=self.request.user + ) + for watch in watchers: + # does the user actually have a subscription? + if PushInformation.objects.filter(user=watch.user).first(): + payload = { + "head": lot.lot_name + " is about to be sold", + "body": f"Lot {lot.custom_lot_number} Don't miss out, bid now! You're getting this notification because you watched this lot.", + "url": "https://" + lot.full_lot_link, + } + if lot.thumbnail: + payload["icon"] = lot.thumbnail.image.url + send_user_notification(user=watch.user, payload=payload, ttl=10000) return context -def createSpecies(name, scientific_name, category=False): - """ - Create a new product/species - This is really only called by the LotValidation class - """ - if not category: - # uncategorized - category = Category.objects.get(id=21) - if category.pk == 18 or category.pk == 19 or category.pk == 20 or category.pk == 21: - # breeder points off for some things - breeder_points = False - else: - breeder_points = True - return Product.objects.create( - common_name=name, - scientific_name=scientific_name, - breeder_points=breeder_points, - category=category, - ) - - class ImageCreateView(LoginRequiredMixin, CreateView): """Add an image to a lot""" @@ -2906,20 +3107,6 @@ def form_valid(self, form, **kwargs): lot = form.save(commit=False) lot.user = self.request.user lot.date_of_last_user_edit = timezone.now() - # # just in case someone is messing with the hidden fields - # if form.cleaned_data['create_new_species']: - # lot.species = createSpecies( - # form.cleaned_data['new_species_name'], - # form.cleaned_data['new_species_scientific_name'], - # form.cleaned_data['new_species_category']) - # if lot.species: - # lot.species_category = lot.species.category - # # if this is not breedable, remove the breeder points - # # they can still be added back in by editing the lot - # if not lot.species.breeder_points: - # lot.i_bred_this_fish = False - # if lot.image and not lot.image_source: - # lot.image_source = 'RANDOM' # default to this pic is from the internet if lot.buy_now_price: if lot.buy_now_price < lot.reserve_price: lot.buy_now_price = lot.reserve_price @@ -3590,6 +3777,7 @@ def form_valid(self, form, **kwargs): "date_online_bidding_ends", "allow_deleting_bids", "auto_add_images", + "message_users_when_lots_sell", ] for field in fields_to_clone: setattr(auction, field, getattr(original_auction, field)) @@ -5881,6 +6069,7 @@ def get_labels(self): "An account", "Search", "Watch", + "Push notifications as lots sell", "Proxy bidding", "Chat", "Buy now", @@ -5898,8 +6087,16 @@ def get_data(self): SearchHistory.objects.filter(user__isnull=False, auction=self.auction).values("user").distinct().count() ) seach_percent = int(searches / auctiontos_with_account.count() * 100) - watches = Watch.objects.filter(lot_number__auction=self.auction).values("user").distinct().count() + watch_qs = Watch.objects.filter(lot_number__auction=self.auction).values("user").distinct() + watches = watch_qs.count() watch_percent = int(watches / auctiontos_with_account.count() * 100) + notifications = ( + PushInformation.objects.filter(user__in=watch_qs, user__userdata__push_notifications_when_lots_sell=True) + .values("user") + .distinct() + .count() + ) + notification_percent = int(notifications / auctiontos_with_account.count() * 100) has_used_proxy_bidding = UserData.objects.filter( has_used_proxy_bidding=True, user__in=auctiontos_with_account.values_list("user"), @@ -5954,6 +6151,7 @@ def get_data(self): account_percent, seach_percent, watch_percent, + notification_percent, has_used_proxy_bidding_percent, chat_percent, lot_with_buy_now_percent, diff --git a/crontab b/crontab index c9d9466..dc342cb 100644 --- a/crontab +++ b/crontab @@ -30,3 +30,5 @@ */15 * * * * /home/app/web/task.sh remove_duplicate_views 0 5 * * * /home/app/web/task.sh get_ses_statistics + +0 10 * * * /home/app/web/task.sh webpush_notifications_deduplicate diff --git a/fishauctions/asgi.py b/fishauctions/asgi.py index 87e584b..85b1682 100755 --- a/fishauctions/asgi.py +++ b/fishauctions/asgi.py @@ -16,7 +16,7 @@ from channels.routing import ProtocolTypeRouter, URLRouter from channels.security.websocket import AllowedHostsOriginValidator -from auctions.consumers import LotConsumer +from auctions.consumers import LotConsumer, UserConsumer application = ProtocolTypeRouter( { @@ -28,6 +28,7 @@ URLRouter( [ re_path(r"ws/lots/(?P\w+)/$", LotConsumer.as_asgi()), + re_path(r"ws/users/(?P\w+)/$", UserConsumer.as_asgi()), ] ) ) diff --git a/fishauctions/settings.py b/fishauctions/settings.py index 4b7f632..c7d573e 100755 --- a/fishauctions/settings.py +++ b/fishauctions/settings.py @@ -108,6 +108,7 @@ "django_recaptcha", "chartjs", "django_ses", + "webpush", ] ASGI_APPLICATION = "fishauctions.asgi.application" MIDDLEWARE = [ @@ -193,6 +194,12 @@ "allauth.account.auth_backends.AuthenticationBackend", ] +# for https://pypi.org/project/django-webpush/ +WEBPUSH_SETTINGS = { + "VAPID_PUBLIC_KEY": os.environ.get("VAPID_PUBLIC_KEY", "abcde"), + "VAPID_PRIVATE_KEY": os.environ.get("VAPID_PRIVATE_KEY", "abc"), + "VAPID_ADMIN_EMAIL": os.environ.get("ADMIN_EMAIL", "admin@example.com"), +} # Use sqlite for testing if "test" in sys.argv: diff --git a/requirements.in b/requirements.in index 18c0b92..94a6fdc 100644 --- a/requirements.in +++ b/requirements.in @@ -72,3 +72,4 @@ user-agents heif-image-plugin gunicorn django-ses[events] +django-webpush diff --git a/requirements.txt b/requirements.txt index b163f88..f5d94ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,13 +4,19 @@ # # pip-compile ./requirements.in # +aiohappyeyeballs==2.4.0 + # via aiohttp +aiohttp==3.10.5 + # via pywebpush +aiosignal==1.3.1 + # via aiohttp annotated-types==0.7.0 # via pydantic anyio==4.4.0 # via watchfiles asgiref==3.8.1 # via - # -r requirements.in + # -r ./requirements.in # channels # channels-redis # daphne @@ -18,21 +24,22 @@ asgiref==3.8.1 # django-htmx attrs==23.2.0 # via - # -r requirements.in + # -r ./requirements.in + # aiohttp # automat # service-identity # twisted autobahn==23.6.2 # via - # -r requirements.in + # -r ./requirements.in # daphne automat==22.10.0 # via - # -r requirements.in + # -r ./requirements.in # twisted bleach[css]==6.1.0 # via - # -r requirements.in + # -r ./requirements.in # django-markdownfield # django-post-office boto3==1.35.0 @@ -43,22 +50,22 @@ botocore==1.35.10 # s3transfer certifi==2024.7.4 # via - # -r requirements.in + # -r ./requirements.in # requests cffi==1.16.0 # via - # -r requirements.in + # -r ./requirements.in # cryptography # pyheif channels==4.1.0 # via - # -r requirements.in + # -r ./requirements.in # channels-redis channels-redis==4.2.0 - # via -r requirements.in + # via -r ./requirements.in chardet==5.2.0 # via - # -r requirements.in + # -r ./requirements.in # reportlab charset-normalizer==3.3.2 # via requests @@ -66,26 +73,29 @@ click==8.1.7 # via uvicorn constantly==23.10.4 # via - # -r requirements.in + # -r ./requirements.in # twisted crispy-bootstrap5==2024.2 - # via -r requirements.in + # via -r ./requirements.in cryptography==43.0.1 # via - # -r requirements.in + # -r ./requirements.in # autobahn # django-ses + # http-ece + # py-vapid # pyopenssl + # pywebpush # service-identity daphne==4.1.2 - # via -r requirements.in + # via -r ./requirements.in defusedxml==0.7.1 # via - # -r requirements.in + # -r ./requirements.in # python3-openid django==5.1 # via - # -r requirements.in + # -r ./requirements.in # channels # crispy-bootstrap5 # django-allauth @@ -106,113 +116,128 @@ django==5.1 # easy-thumbnails # jsonfield django-allauth==0.63.3 - # via -r requirements.in + # via -r ./requirements.in django-autocomplete-light==3.11.0 - # via -r requirements.in + # via -r ./requirements.in django-autoslug==1.9.9 - # via -r requirements.in + # via -r ./requirements.in django-bootstrap-datepicker-plus==5.0.5 - # via -r requirements.in + # via -r ./requirements.in django-chartjs==2.3.0 - # via -r requirements.in + # via -r ./requirements.in django-crispy-forms==2.2 # via - # -r requirements.in + # -r ./requirements.in # crispy-bootstrap5 django-debug-toolbar==4.4.6 - # via -r requirements.in + # via -r ./requirements.in django-el-pagination==4.0.0 - # via -r requirements.in + # via -r ./requirements.in django-extensions==3.2.3 - # via -r requirements.in + # via -r ./requirements.in django-filter==24.2 - # via -r requirements.in + # via -r ./requirements.in django-htmx==1.18.0 - # via -r requirements.in + # via -r ./requirements.in django-location-field==2.7.3 - # via -r requirements.in + # via -r ./requirements.in django-markdownfield==0.11.0 - # via -r requirements.in + # via -r ./requirements.in django-post-office==3.9.0 - # via -r requirements.in + # via -r ./requirements.in django-qr-code==4.1.0 - # via -r requirements.in + # via -r ./requirements.in django-recaptcha==4.0.0 - # via -r requirements.in + # via -r ./requirements.in django-ses[events]==4.1.0 - # via -r requirements.in + # via -r ./requirements.in django-tables2==2.7.0 - # via -r requirements.in + # via -r ./requirements.in +django-webpush==0.3.6 + # via -r ./requirements.in easy-thumbnails==2.9 - # via -r requirements.in + # via -r ./requirements.in +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal gunicorn==22.0.0 - # via -r requirements.in + # via -r ./requirements.in h11==0.14.0 # via uvicorn heif-image-plugin==0.6.1 - # via -r requirements.in + # via -r ./requirements.in +http-ece==1.2.1 + # via pywebpush httptools==0.6.1 # via uvicorn hyperlink==21.0.0 # via - # -r requirements.in + # -r ./requirements.in # autobahn # twisted idna==3.7 # via - # -r requirements.in + # -r ./requirements.in # anyio # hyperlink # requests # twisted + # yarl incremental==22.10.0 # via - # -r requirements.in + # -r ./requirements.in # twisted jmespath==1.0.1 # via # boto3 # botocore jsonfield==3.1.0 - # via -r requirements.in + # via -r ./requirements.in markdown==3.6 # via - # -r requirements.in + # -r ./requirements.in # django-markdownfield msgpack==1.0.8 # via channels-redis +multidict==6.1.0 + # via + # aiohttp + # yarl mysqlclient==2.2.4 - # via -r requirements.in + # via -r ./requirements.in oauthlib==3.2.2 # via - # -r requirements.in + # -r ./requirements.in # requests-oauthlib packaging==24.1 # via - # -r requirements.in + # -r ./requirements.in # gunicorn # pip-review piexif==1.1.3 # via heif-image-plugin pillow==10.4.0 # via - # -r requirements.in + # -r ./requirements.in # easy-thumbnails # reportlab pip-review==1.3.0 - # via -r requirements.in + # via -r ./requirements.in +py-vapid==1.9.1 + # via pywebpush pyasn1==0.6.0 # via - # -r requirements.in + # -r ./requirements.in # pyasn1-modules # service-identity pyasn1-modules==0.4.0 # via - # -r requirements.in + # -r ./requirements.in # service-identity pycparser==2.22 # via - # -r requirements.in + # -r ./requirements.in # cffi pydantic==2.8.2 # via @@ -221,78 +246,82 @@ pydantic==2.8.2 pydantic-core==2.20.1 # via pydantic pyhamcrest==2.1.0 - # via -r requirements.in + # via -r ./requirements.in pyheif==0.7.1 # via heif-image-plugin pyjwt==2.8.0 - # via -r requirements.in + # via -r ./requirements.in pymysql==1.1.1 - # via -r requirements.in + # via -r ./requirements.in pyopenssl==24.2.1 # via - # -r requirements.in + # -r ./requirements.in # twisted pyparsing==3.1.2 - # via -r requirements.in + # via -r ./requirements.in python-dateutil==2.9.0.post0 # via botocore python-dotenv==1.0.1 # via uvicorn python3-openid==3.2.0 - # via -r requirements.in + # via -r ./requirements.in pytz==2024.1 # via - # -r requirements.in + # -r ./requirements.in # django-ses +pywebpush==2.0.0 + # via django-webpush pyyaml==6.0.1 # via - # -r requirements.in + # -r ./requirements.in # uvicorn redis==5.0.7 # via channels-redis reportlab==4.2.2 - # via -r requirements.in + # via -r ./requirements.in requests==2.32.3 # via - # -r requirements.in + # -r ./requirements.in # django-ses + # pywebpush # requests-oauthlib requests-oauthlib==2.0.0 - # via -r requirements.in + # via -r ./requirements.in s3transfer==0.10.2 # via boto3 segno==1.6.1 # via django-qr-code service-identity==24.1.0 # via - # -r requirements.in + # -r ./requirements.in # twisted shortuuid==1.0.13 # via - # -r requirements.in + # -r ./requirements.in # django-markdownfield six==1.16.0 # via - # -r requirements.in + # -r ./requirements.in # automat # bleach # python-dateutil + # pywebpush sniffio==1.3.1 # via anyio sqlparse==0.5.1 # via - # -r requirements.in + # -r ./requirements.in # django # django-debug-toolbar tinycss2==1.2.1 # via bleach twisted[tls]==24.3.0 # via - # -r requirements.in + # -r ./requirements.in # daphne txaio==23.1.1 # via - # -r requirements.in + # -r ./requirements.in # autobahn typing-extensions==4.12.2 # via @@ -302,31 +331,33 @@ typing-extensions==4.12.2 # twisted ua-parser==0.18.0 # via - # -r requirements.in + # -r ./requirements.in # user-agents urllib3==2.2.2 # via - # -r requirements.in + # -r ./requirements.in # botocore # requests user-agents==2.2.0 - # via -r requirements.in + # via -r ./requirements.in uvicorn[standard]==0.30.3 - # via -r requirements.in + # via -r ./requirements.in uvloop==0.19.0 # via uvicorn watchfiles==0.22.0 # via uvicorn webencodings==0.5.1 # via - # -r requirements.in + # -r ./requirements.in # bleach # tinycss2 websockets==12.0 # via uvicorn +yarl==1.11.1 + # via aiohttp zope-interface==6.4.post2 # via - # -r requirements.in + # -r ./requirements.in # twisted # The following packages are considered to be unsafe in a requirements file: From d52e59e7e127e045fcbdf2765c5f1f10341b0083 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 16 Sep 2024 23:24:34 +0000 Subject: [PATCH 02/11] fix tests --- auctions/tests.py | 181 ++++++++++++++++++++++++---------------------- 1 file changed, 93 insertions(+), 88 deletions(-) diff --git a/auctions/tests.py b/auctions/tests.py index 211c4f9..aaad493 100644 --- a/auctions/tests.py +++ b/auctions/tests.py @@ -1,4 +1,5 @@ import datetime +import socket from django.contrib.auth.models import User from django.test import TestCase @@ -965,91 +966,95 @@ def test_non_admin_user(self): assert response.status_code == 403 def test_admin_user(self): - self.client.login(username=self.admin_user.username, password="testpassword") - response = self.client.get(self.get_url()) - assert response.status_code == 200 - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "5", "winner": "555", "action": "validate"} - ) - data = response.json() - assert data.get("price") == "valid" - assert data.get("winner") == "valid" - assert data.get("lot") == "valid" - - self.in_person_lot.reserve_price = 10 - self.in_person_lot.save() - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "5", "winner": "556", "action": "validate"} - ) - data = response.json() - assert data.get("price") != "valid" - assert data.get("winner") != "valid" - assert data.get("lot") == "valid" - - response = self.client.post(self.get_url(), data={"lot": "102-1", "action": "validate"}) - data = response.json() - assert data.get("lot") != "valid" - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} - ) - data = response.json() - assert data.get("price") == "valid" - assert data.get("winner") == "valid" - assert data.get("lot") == "valid" - assert data.get("last_sold_lot_number") == "101-1" - assert data.get("success_message") is not None - - lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() - assert lot.winning_price == 10 - assert lot.auctiontos_winner is not None - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} - ) - data = response.json() - assert data.get("lot") != "valid" - - invoice, created = Invoice.objects.get_or_create(auctiontos_user=self.in_person_lot.auctiontos_seller) - invoice.status = "UNPAID" - invoice.save() - - self.in_person_lot.auctiontos_winner = None - self.in_person_lot.winning_price = None - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} - ) - data = response.json() - assert data.get("lot") != "valid" - assert self.in_person_lot.auctiontos_winner is None - assert self.in_person_lot.winning_price is None - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "7", "winner": "555", "action": "force_save"} - ) - data = response.json() - assert data.get("lot") == "valid" - - lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() - assert lot.winning_price == 7 - assert lot.auctiontos_winner is not None - - Bid.objects.create(user=self.admin_user, lot_number=self.in_person_lot, amount=100) - self.in_person_auction.allow_bidding_on_lots = True - self.in_person_auction.save() - invoice.status = "OPEN" - invoice.save() - - lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() - lot.winning_price = None - lot.auctiontos_winner = None - lot.winner = None - lot.save() - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} - ) - data = response.json() - assert data.get("price") != "valid" - assert data.get("winner") != "valid" + try: + self.client.login(username=self.admin_user.username, password="testpassword") + response = self.client.get(self.get_url()) + assert response.status_code == 200 + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "5", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("price") == "valid" + assert data.get("winner") == "valid" + assert data.get("lot") == "valid" + + self.in_person_lot.reserve_price = 10 + self.in_person_lot.save() + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "5", "winner": "556", "action": "validate"} + ) + data = response.json() + assert data.get("price") != "valid" + assert data.get("winner") != "valid" + assert data.get("lot") == "valid" + + response = self.client.post(self.get_url(), data={"lot": "102-1", "action": "validate"}) + data = response.json() + assert data.get("lot") != "valid" + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} + ) + data = response.json() + assert data.get("price") == "valid" + assert data.get("winner") == "valid" + assert data.get("lot") == "valid" + assert data.get("last_sold_lot_number") == "101-1" + assert data.get("success_message") is not None + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + assert lot.winning_price == 10 + assert lot.auctiontos_winner is not None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("lot") != "valid" + + invoice, created = Invoice.objects.get_or_create(auctiontos_user=self.in_person_lot.auctiontos_seller) + invoice.status = "UNPAID" + invoice.save() + + self.in_person_lot.auctiontos_winner = None + self.in_person_lot.winning_price = None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} + ) + data = response.json() + assert data.get("lot") != "valid" + assert self.in_person_lot.auctiontos_winner is None + assert self.in_person_lot.winning_price is None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "7", "winner": "555", "action": "force_save"} + ) + data = response.json() + assert data.get("lot") == "valid" + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + assert lot.winning_price == 7 + assert lot.auctiontos_winner is not None + + Bid.objects.create(user=self.admin_user, lot_number=self.in_person_lot, amount=100) + self.in_person_auction.allow_bidding_on_lots = True + self.in_person_auction.save() + invoice.status = "OPEN" + invoice.save() + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + lot.winning_price = None + lot.auctiontos_winner = None + lot.winner = None + lot.save() + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("price") != "valid" + assert data.get("winner") != "valid" + except socket.gaierror: + # this happens because channels cannot connect to redis -- tests aren't running in a container + pass From 33174d1659ee54ef6465a928874b168da5a1b75e Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 16 Sep 2024 23:35:34 +0000 Subject: [PATCH 03/11] add a failing test to confirm that CI tests are being run twice --- auctions/tests.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/auctions/tests.py b/auctions/tests.py index aaad493..81c3cc3 100644 --- a/auctions/tests.py +++ b/auctions/tests.py @@ -1058,3 +1058,8 @@ def test_admin_user(self): except socket.gaierror: # this happens because channels cannot connect to redis -- tests aren't running in a container pass + + +class TestThatDoesntWorkTestCase(StandardTestCase): + def test_this_test_should_fail(self): + assert 1 == False From 1cfbebaa3387be05c05db049792c21e4bb85c564 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 16 Sep 2024 23:40:33 +0000 Subject: [PATCH 04/11] hopefully add testing to docker env --- .github/workflows/image-builds.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/image-builds.yml b/.github/workflows/image-builds.yml index 9bf35a0..c64f68f 100644 --- a/.github/workflows/image-builds.yml +++ b/.github/workflows/image-builds.yml @@ -1,4 +1,4 @@ -name: Docker images build +name: Docker images build and test on: push: pull_request: @@ -12,3 +12,4 @@ jobs: - uses: actions/checkout@v4 - run: ./.github/scripts/prepare-ci.sh - run: docker compose build + - run: docker exec -it django python3 manage.py test From c843fe594d20159588e984c84e5b9c6403cb854e Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 16 Sep 2024 23:47:16 +0000 Subject: [PATCH 05/11] ... --- .github/workflows/image-builds.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/image-builds.yml b/.github/workflows/image-builds.yml index c64f68f..991a394 100644 --- a/.github/workflows/image-builds.yml +++ b/.github/workflows/image-builds.yml @@ -12,4 +12,6 @@ jobs: - uses: actions/checkout@v4 - run: ./.github/scripts/prepare-ci.sh - run: docker compose build - - run: docker exec -it django python3 manage.py test + - run: docker compose up -d + - run: docker compose exec django python3 manage.py test + - run: docker compose down From 5058641723481d3c54b2dfb9c2463ddc60eca8c7 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 16 Sep 2024 23:51:35 +0000 Subject: [PATCH 06/11] docker run command for testing --- .github/workflows/image-builds.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/image-builds.yml b/.github/workflows/image-builds.yml index 991a394..006d818 100644 --- a/.github/workflows/image-builds.yml +++ b/.github/workflows/image-builds.yml @@ -12,6 +12,4 @@ jobs: - uses: actions/checkout@v4 - run: ./.github/scripts/prepare-ci.sh - run: docker compose build - - run: docker compose up -d - - run: docker compose exec django python3 manage.py test - - run: docker compose down + - run: docker compose run django python3 manage.py test From fc9d3a5a46e0da3269d892e3320f906b6780cf7c Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 16 Sep 2024 23:56:53 +0000 Subject: [PATCH 07/11] add diagnostic step to ci --- .github/workflows/image-builds.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/image-builds.yml b/.github/workflows/image-builds.yml index 006d818..481ea38 100644 --- a/.github/workflows/image-builds.yml +++ b/.github/workflows/image-builds.yml @@ -12,4 +12,5 @@ jobs: - uses: actions/checkout@v4 - run: ./.github/scripts/prepare-ci.sh - run: docker compose build + - run: docker compose config - run: docker compose run django python3 manage.py test From fe2fe353627e8eb0323f4fc4aabb64c07e6622c4 Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 17 Sep 2024 00:00:16 +0000 Subject: [PATCH 08/11] service vs. container name --- .github/workflows/image-builds.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/image-builds.yml b/.github/workflows/image-builds.yml index 481ea38..2608dba 100644 --- a/.github/workflows/image-builds.yml +++ b/.github/workflows/image-builds.yml @@ -12,5 +12,4 @@ jobs: - uses: actions/checkout@v4 - run: ./.github/scripts/prepare-ci.sh - run: docker compose build - - run: docker compose config - - run: docker compose run django python3 manage.py test + - run: docker compose run web python3 manage.py test From d711ee86dda04e83ecf05512c15c98a98ea1e15e Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 17 Sep 2024 00:06:30 +0000 Subject: [PATCH 09/11] different approach, build, up, run tests, down --- .github/workflows/image-builds.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/image-builds.yml b/.github/workflows/image-builds.yml index 2608dba..65bd81d 100644 --- a/.github/workflows/image-builds.yml +++ b/.github/workflows/image-builds.yml @@ -12,4 +12,7 @@ jobs: - uses: actions/checkout@v4 - run: ./.github/scripts/prepare-ci.sh - run: docker compose build - - run: docker compose run web python3 manage.py test + - run: docker compose up -d + - run: sleep 60 # Wait for the container to be up + - run: docker compose exec django python3 manage.py test + - run: docker compose down From c3d07d8d54bc8ab62ef68208d9a93d8d1a4a6c3c Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 17 Sep 2024 00:14:07 +0000 Subject: [PATCH 10/11] change service name again --- .github/workflows/image-builds.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/image-builds.yml b/.github/workflows/image-builds.yml index 65bd81d..da31b14 100644 --- a/.github/workflows/image-builds.yml +++ b/.github/workflows/image-builds.yml @@ -14,5 +14,5 @@ jobs: - run: docker compose build - run: docker compose up -d - run: sleep 60 # Wait for the container to be up - - run: docker compose exec django python3 manage.py test + - run: docker compose exec web python3 manage.py test - run: docker compose down From c2f39b03a3f6f184ac538f07f043379fe5d391dc Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 17 Sep 2024 00:31:02 +0000 Subject: [PATCH 11/11] tests should now run in the container in CI --- .github/workflows/django.yml | 63 ------ ...when_people_comment_on_my_lots_and_more.py | 30 +++ auctions/tests.py | 213 +++++++++--------- 3 files changed, 131 insertions(+), 175 deletions(-) delete mode 100644 .github/workflows/django.yml create mode 100644 auctions/migrations/0149_alter_userdata_email_me_when_people_comment_on_my_lots_and_more.py diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml deleted file mode 100644 index c07ad86..0000000 --- a/.github/workflows/django.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Django CI - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - -env: - DEBUG: True - SECRET_KEY: abcde - ALLOWED_HOST_1: localhost - ALLOWED_HOST_2: - ALLOWED_HOST_3: 127.0.0.1 - DATABASE_ENGINE: django.db.backends.mysql - DATABASE_NAME: auctions - DATABASE_USER: mysqluser - DATABASE_PASSWORD: 123456 - DATABASE_HOST: 127.0.0.1 - DATABASE_PORT: 3306 - BASE_URL: https://auction.fish - EMAIL_USE_TLS: True - EMAIL_HOST: smtp.example.com - EMAIL_PORT: - EMAIL_HOST_USER: user@example.com - EMAIL_HOST_PASSWORD: 123456 - DEFAULT_FROM_EMAIL: Notifications - TIME_ZONE: America/New_York - SITE_DOMAIN: auction.fish - SITE_URL: https://auction.fish - GOOGLE_MEASUREMENT_ID: None - GOOGLE_TAG_ID: None - GOOGLE_MAPS_API_KEY: abcde - ADMIN_EMAIL: admin@example.com - REDIS_PASS: 123456 - GOOGLE_ADSENSE_ID: None - STATIC_ROOT: /static - RECAPTCHA_PUBLIC_KEY: abcde - RECAPTCHA_PRIVATE_KEY: abcde - GOOGLE_OAUTH_LINK: key123456.apps.googleusercontent.com - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - max-parallel: 4 - matrix: - python-version: [3.11] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Tests - run: | - python manage.py test diff --git a/auctions/migrations/0149_alter_userdata_email_me_when_people_comment_on_my_lots_and_more.py b/auctions/migrations/0149_alter_userdata_email_me_when_people_comment_on_my_lots_and_more.py new file mode 100644 index 0000000..64c5fde --- /dev/null +++ b/auctions/migrations/0149_alter_userdata_email_me_when_people_comment_on_my_lots_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1 on 2024-09-17 00:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auctions", "0148_userdata_push_notifications_when_lots_sell"), + ] + + operations = [ + migrations.AlterField( + model_name="userdata", + name="email_me_when_people_comment_on_my_lots", + field=models.BooleanField( + blank=True, + default=True, + help_text="Notifications will be sent once a day, only for messages you haven't seen. If you'd like to get a notification right away, leave a comment here", + ), + ), + migrations.AlterField( + model_name="userdata", + name="push_notifications_when_lots_sell", + field=models.BooleanField( + blank=True, + default=False, + help_text="For in-person auctions, get a notification when bidding starts on a lot that you've watched", + ), + ), + ] diff --git a/auctions/tests.py b/auctions/tests.py index 81c3cc3..8fbf9c0 100644 --- a/auctions/tests.py +++ b/auctions/tests.py @@ -1,5 +1,4 @@ import datetime -import socket from django.contrib.auth.models import User from django.test import TestCase @@ -886,20 +885,19 @@ def test_small_labels(self): assert response.status_code == 200 assert "attachment; filename=" in response.headers["Content-Disposition"] - # The test below will fail in ci because tests do not run in the docker container - # thermal labels cause a 'Paragraph' object has no attribute 'blPara' error - # See https://github.com/virantha/pypdfocr/issues/80 - # This is the reason we are using a hacked version of platypus/paragraph.py in python_file_hack.sh - - # def test_thermal_labels(self): - # """Test that a regular user can print their own labels.""" - # user_label_prefs, created = UserLabelPrefs.objects.get_or_create(user=self.user) - # user_label_prefs.preset = "thermal_sm" - # user_label_prefs.save() - # self.client.login(username=self.user, password="testpassword") - # response = self.client.get(self.url) - # assert response.status_code == 200 - # assert "attachment; filename=" in response.headers["Content-Disposition"] + def test_thermal_labels(self): + """Test that a regular user can print their own labels.""" + # If this test is failing, it's likely that the issue is not in this code, but in a library + # thermal labels cause a 'Paragraph' object has no attribute 'blPara' error + # See https://github.com/virantha/pypdfocr/issues/80 + # This is the reason we are using a hacked version of platypus/paragraph.py in python_file_hack.sh + user_label_prefs, created = UserLabelPrefs.objects.get_or_create(user=self.user) + user_label_prefs.preset = "thermal_sm" + user_label_prefs.save() + self.client.login(username=self.user, password="testpassword") + response = self.client.get(self.url) + assert response.status_code == 200 + assert "attachment; filename=" in response.headers["Content-Disposition"] def test_non_admin_cannot_print_others_labels(self): """Test that a non-admin user cannot print labels for other users.""" @@ -966,100 +964,91 @@ def test_non_admin_user(self): assert response.status_code == 403 def test_admin_user(self): - try: - self.client.login(username=self.admin_user.username, password="testpassword") - response = self.client.get(self.get_url()) - assert response.status_code == 200 - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "5", "winner": "555", "action": "validate"} - ) - data = response.json() - assert data.get("price") == "valid" - assert data.get("winner") == "valid" - assert data.get("lot") == "valid" - - self.in_person_lot.reserve_price = 10 - self.in_person_lot.save() - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "5", "winner": "556", "action": "validate"} - ) - data = response.json() - assert data.get("price") != "valid" - assert data.get("winner") != "valid" - assert data.get("lot") == "valid" - - response = self.client.post(self.get_url(), data={"lot": "102-1", "action": "validate"}) - data = response.json() - assert data.get("lot") != "valid" - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} - ) - data = response.json() - assert data.get("price") == "valid" - assert data.get("winner") == "valid" - assert data.get("lot") == "valid" - assert data.get("last_sold_lot_number") == "101-1" - assert data.get("success_message") is not None - - lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() - assert lot.winning_price == 10 - assert lot.auctiontos_winner is not None - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} - ) - data = response.json() - assert data.get("lot") != "valid" - - invoice, created = Invoice.objects.get_or_create(auctiontos_user=self.in_person_lot.auctiontos_seller) - invoice.status = "UNPAID" - invoice.save() - - self.in_person_lot.auctiontos_winner = None - self.in_person_lot.winning_price = None - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} - ) - data = response.json() - assert data.get("lot") != "valid" - assert self.in_person_lot.auctiontos_winner is None - assert self.in_person_lot.winning_price is None - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "7", "winner": "555", "action": "force_save"} - ) - data = response.json() - assert data.get("lot") == "valid" - - lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() - assert lot.winning_price == 7 - assert lot.auctiontos_winner is not None - - Bid.objects.create(user=self.admin_user, lot_number=self.in_person_lot, amount=100) - self.in_person_auction.allow_bidding_on_lots = True - self.in_person_auction.save() - invoice.status = "OPEN" - invoice.save() - - lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() - lot.winning_price = None - lot.auctiontos_winner = None - lot.winner = None - lot.save() - - response = self.client.post( - self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} - ) - data = response.json() - assert data.get("price") != "valid" - assert data.get("winner") != "valid" - except socket.gaierror: - # this happens because channels cannot connect to redis -- tests aren't running in a container - pass - - -class TestThatDoesntWorkTestCase(StandardTestCase): - def test_this_test_should_fail(self): - assert 1 == False + self.client.login(username=self.admin_user.username, password="testpassword") + response = self.client.get(self.get_url()) + assert response.status_code == 200 + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "5", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("price") == "valid" + assert data.get("winner") == "valid" + assert data.get("lot") == "valid" + + self.in_person_lot.reserve_price = 10 + self.in_person_lot.save() + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "5", "winner": "556", "action": "validate"} + ) + data = response.json() + assert data.get("price") != "valid" + assert data.get("winner") != "valid" + assert data.get("lot") == "valid" + + response = self.client.post(self.get_url(), data={"lot": "102-1", "action": "validate"}) + data = response.json() + assert data.get("lot") != "valid" + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} + ) + data = response.json() + assert data.get("price") == "valid" + assert data.get("winner") == "valid" + assert data.get("lot") == "valid" + assert data.get("last_sold_lot_number") == "101-1" + assert data.get("success_message") is not None + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + assert lot.winning_price == 10 + assert lot.auctiontos_winner is not None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("lot") != "valid" + + invoice, created = Invoice.objects.get_or_create(auctiontos_user=self.in_person_lot.auctiontos_seller) + invoice.status = "UNPAID" + invoice.save() + + self.in_person_lot.auctiontos_winner = None + self.in_person_lot.winning_price = None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "save"} + ) + data = response.json() + assert data.get("lot") != "valid" + assert self.in_person_lot.auctiontos_winner is None + assert self.in_person_lot.winning_price is None + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "7", "winner": "555", "action": "force_save"} + ) + data = response.json() + assert data.get("lot") == "valid" + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + assert lot.winning_price == 7 + assert lot.auctiontos_winner is not None + + Bid.objects.create(user=self.admin_user, lot_number=self.in_person_lot, amount=100) + self.in_person_auction.allow_bidding_on_lots = True + self.in_person_auction.save() + invoice.status = "OPEN" + invoice.save() + + lot = Lot.objects.filter(pk=self.in_person_lot.pk).first() + lot.winning_price = None + lot.auctiontos_winner = None + lot.winner = None + lot.save() + + response = self.client.post( + self.get_url(), data={"lot": "101-1", "price": "10", "winner": "555", "action": "validate"} + ) + data = response.json() + assert data.get("price") != "valid" + assert data.get("winner") != "valid"