Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement a self-service bidder registration portal and notifications #131

Merged
merged 14 commits into from
Jan 4, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
@@ -8,7 +8,6 @@ django = "==5.1.*"
django-ajax-selects = "*"
django-appconf = "*"
django-celery-results = "*"
django-celery-email = "*"
django-formtools = "*"
django-ses = "*"
gunicorn = "*"
223 changes: 101 additions & 122 deletions Pipfile.lock

Large diffs are not rendered by default.

36 changes: 35 additions & 1 deletion artshow/admin.py
Original file line number Diff line number Diff line change
@@ -27,7 +27,8 @@
Agent, Allocation, Artist, BatchScan, Bid, Bidder, BidderId, Checkoff,
ChequePayment, EmailSignature, EmailTemplate, Invoice, InvoiceItem,
InvoicePayment, Payment, PaymentType, Piece, Location, Space,
SquareInvoicePayment, SquarePayment, SquareTerminal, SquareWebhook
SquareInvoicePayment, SquarePayment, SquareTerminal, SquareWebhook,
TelegramWebhook
)

User = get_user_model()
@@ -654,3 +655,36 @@ def pretty_json(self, webhook):
'<pre>{}</pre>',
json.dumps(webhook.body, sort_keys=True, indent=2),
)


@admin.register(TelegramWebhook)
class TelegramWebhookAdmin(admin.ModelAdmin):
list_display = ('timestamp', 'webhook_message_sender',
'webhook_message_text')
fields = ('timestamp', 'pretty_json')
readonly_fields = ('timestamp', 'pretty_json')

@admin.display(description='Message Sender')
def webhook_message_sender(self, webhook):
if 'message' in webhook.body:
message = webhook.body['message']
if 'from' in message:
user = message['from']
if 'username' in user:
return user['username']
return '(unknown)'

@admin.display(description='Message Text')
def webhook_message_text(self, webhook):
if 'message' in webhook.body:
message = webhook.body['message']
if 'text' in message:
return message['text']
return '(unknown)'

@admin.display(description='Body')
def pretty_json(self, webhook):
return format_html(
'<pre>{}</pre>',
json.dumps(webhook.body, sort_keys=True, indent=2),
)
231 changes: 231 additions & 0 deletions artshow/bid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
from django.contrib.auth.decorators import login_required
from django.core.mail import send_mail
from django.http import HttpResponseRedirect
from django.shortcuts import render, redirect
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.timezone import now
from django.views.decorators.http import require_POST
from django import forms

from .models import Bid, Bidder
from .telegram import send_message as send_telegram_message
from .utils import artshow_settings

from datetime import datetime, timedelta, timezone
import hashlib
import hmac
from random import randint

LOGIN_URL = '/bid/login/'


class RegisterForm(forms.Form):
name = forms.CharField(
label="Legal name", label_suffix="",
max_length=100,
widget=forms.TextInput(attrs={'class': 'form-control'}))
email = forms.CharField(
label="E-mail address", label_suffix="", max_length=100, required=True,
widget=forms.TextInput(attrs={'class': 'form-control'}))
phone = forms.CharField(
label="Phone number", label_suffix="", max_length=40, required=True,
widget=forms.TextInput(attrs={'class': 'form-control'}))
address1 = forms.CharField(
label="Address", label_suffix="", max_length=100, required=True,
widget=forms.TextInput(attrs={'class': 'form-control'}))
address2 = forms.CharField(
label="Address 2", label_suffix="", max_length=100, required=False,
widget=forms.TextInput(attrs={'class': 'form-control'}))
city = forms.CharField(
label_suffix="", max_length=100, required=True,
widget=forms.TextInput(attrs={'class': 'form-control'}))
state = forms.CharField(
label_suffix="", max_length=40, required=True,
widget=forms.TextInput(attrs={'class': 'form-control'}))
postcode = forms.CharField(
label="ZIP code", label_suffix="", max_length=20, required=True,
widget=forms.TextInput(attrs={'class': 'form-control'}))
country = forms.CharField(
label_suffix="", max_length=40, required=False, empty_value='USA',
widget=forms.TextInput(attrs={'class': 'form-control'}))
at_con_contact = forms.CharField(
label="At-con contact (optional)", label_suffix="", max_length=100,
required=False, widget=forms.TextInput(attrs={'class': 'form-control'}))


class ConfirmationForm(forms.Form):
code = forms.CharField(
max_length=40, required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Code',
}))


@login_required(login_url=LOGIN_URL)
def index(request):
try:
bidder = Bidder.objects.get(person__user=request.user)
except Bidder.DoesNotExist:
return redirect('artshow-bid-register')

pieces_won, pieces_not_won, pieces_in_voice_auction = bidder.get_results()
show_has_bids = Bid.objects.filter(invalid=False).exists()

email_confirmation_form = None
if bidder.person.email_confirmation_code:
email_confirmation_form = ConfirmationForm()

response = render(request, "artshow/bid_index.html", {
'bidder': bidder,
'show_has_bids': show_has_bids,
'pieces_won': pieces_won,
'pieces_not_won': pieces_not_won,
'pieces_in_voice_auction': pieces_in_voice_auction,
'email_confirmation_form': email_confirmation_form,
})
# The Telegram login widget opens a popup window and needs access to the
# opener property.
response['Cross-Origin-Opener-Policy'] = 'unsafe-none'
return response


def login(request):
if request.user.is_authenticated:
return HttpResponseRedirect(request.GET.get('next', reverse('artshow-bid')))

return render(request, "artshow/bid_login.html")


@login_required(login_url=LOGIN_URL)
def register(request):
if Bidder.objects.filter(person__user=request.user).exists():
return redirect('artshow-bid')

person = request.user.person
if request.method == "POST":
form = RegisterForm(request.POST)
if form.is_valid():
person.name = form.cleaned_data['name']
person.address1 = form.cleaned_data['address1']
person.address2 = form.cleaned_data['address2']
person.city = form.cleaned_data['city']
person.state = form.cleaned_data['state']
person.postcode = form.cleaned_data['postcode']
person.country = form.cleaned_data['country']
person.phone = form.cleaned_data['phone']
person.email = form.cleaned_data['email']
person.save()

bidder = Bidder.objects.create(
person=request.user.person,
at_con_contact=form.cleaned_data['at_con_contact']
)
bidder.save()

return redirect('artshow-bid')
else:
form = RegisterForm({
'name': person.name,
'email': person.email,
'phone': person.phone,
'address1': person.address1,
'address2': person.address2,
'city': person.city,
'state': person.state,
'postcode': person.postcode,
'country': person.country,
})

return render(request, "artshow/bid_register.html", {'form': form})


@login_required(login_url=LOGIN_URL)
@require_POST
def send_email_code(request):
person = request.user.person
if person.email_confirmed:
return redirect('artshow-bid')

person.email_confirmation_code = str(randint(0, 999999)).zfill(6)
person.save()

text_content = render_to_string('artshow/bid_email_confirmation.txt', {
'code': person.email_confirmation_code,
})
send_mail(
subject='Confirm your e-mail address',
message=text_content,
from_email=artshow_settings.ARTSHOW_EMAIL_SENDER,
recipient_list=[person.email],
)

return redirect('artshow-bid')


@login_required(login_url=LOGIN_URL)
@require_POST
def confirm_email(request):
person = request.user.person
if person.email_confirmed:
return redirect('artshow-bid')

error = 'Invalid form data.'
form = ConfirmationForm(request.POST)
if form.is_valid():
error = 'Invalid confirmation code.'
if form.cleaned_data['code'] == person.email_confirmation_code:
person.email_confirmed = True
person.email_confirmation_code = ''
person.save()

return redirect('artshow-bid')

form = ConfirmationForm()
return render(request, "artshow/bid_confirm_email.html", {
'error': error,
'form': form
})


@login_required(login_url=LOGIN_URL)
def telegram(request):
hash = request.GET.get('hash')
if not hash:
return render(request, 'artshow/bid_telegram.html', {
'error': 'Telegram response has missing hash.'
})

secret_key = hashlib.sha256(artshow_settings.ARTSHOW_TELEGRAM_BOT_TOKEN.encode('utf-8'))
data_check_string = '\n'.join(f'{k}={v}' for k, v in sorted(request.GET.items()) if k != 'hash')
digest = hmac.new(secret_key.digest(), data_check_string.encode("utf-8"), hashlib.sha256)
print(data_check_string)
print(digest.hexdigest())
print(hash)
if digest.hexdigest() != hash:
return render(request, 'artshow/bid_telegram.html', {
'error': 'Invalid response from Telegram.'
})

auth_date = datetime.fromtimestamp(int(request.GET.get('auth_date', 0)),
timezone.utc)
if auth_date < now() - timedelta(minutes=5):
return render(request, 'artshow/bid_telegram.html', {
'error': 'Authentication expired.'
})

person = request.user.person
person.telegram_chat_id = request.GET.get('id')
person.telegram_username = request.GET.get('username')
person.save()

send_telegram_message(
person.telegram_chat_id,
render_to_string('artshow/telegram_welcome_message.txt', {
'artshow_settings': artshow_settings,
}))

return render(request, 'artshow/bid_telegram.html', {
'person': person,
})
12 changes: 12 additions & 0 deletions artshow/bid_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.urls import re_path

from . import bid

urlpatterns = [
re_path(r'^$', bid.index, name='artshow-bid'),
re_path(r'^login/$', bid.login, name='artshow-bid-login'),
re_path(r'^register/$', bid.register, name='artshow-bid-register'),
re_path(r'^telegram/$', bid.telegram, name='artshow-bid-telegram'),
re_path(r'^email/send_code/$', bid.send_email_code, name='artshow-bid-send-email-code'),
re_path(r'^email/confirm/$', bid.confirm_email, name='artshow-bid-confirm-email'),
]
21 changes: 21 additions & 0 deletions artshow/migrations/0015_telegramwebhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.1.4 on 2024-12-30 01:26

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('artshow', '0014_squareinvoicepayment_invoicepayment_complete_and_more'),
]

operations = [
migrations.CreateModel(
name='TelegramWebhook',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField()),
('body', models.JSONField()),
],
),
]
22 changes: 22 additions & 0 deletions artshow/migrations/0016_bulkmessagingtask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.1.4 on 2025-01-03 02:04

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('artshow', '0015_telegramwebhook'),
]

operations = [
migrations.CreateModel(
name='BulkMessagingTask',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('message_count', models.IntegerField(default=0)),
('sent_count', models.IntegerField(default=0)),
],
),
]
70 changes: 69 additions & 1 deletion artshow/models.py
Original file line number Diff line number Diff line change
@@ -10,7 +10,9 @@
from decimal import Decimal

from django.db import models
from django.db.models import Count, IntegerField, Max, Sum, Q, Value as V
from django.db.models import (
Count, IntegerField, Max, OuterRef, Subquery, Sum, Q, Value as V
)
from django.db.models.functions import Cast, Coalesce, Substr
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
@@ -395,6 +397,53 @@ def top_bids(self, unsold_only=False):
results.append(b)
return results

def get_results(self):
pieces_won = []
pieces_not_won = []
pieces_in_voice_auction = []

winning_bid_query = Bid.objects.filter(
piece=OuterRef('pk'), invalid=False).order_by('-amount')[:1]
pieces = Piece.objects.filter(bid__bidder=self).annotate(
top_bidder=Subquery(winning_bid_query.values('bidder')),
top_bid=Subquery(winning_bid_query.values('amount'))
).order_by('artist', 'code').distinct()

for piece in pieces:
if piece.status == Piece.StatusInShow and piece.voice_auction:
pieces_in_voice_auction.append(piece)
elif piece.status == Piece.StatusWon or piece.status == Piece.StatusSold:
if piece.top_bidder == self.pk:
pieces_won.append(piece)
else:
pieces_not_won.append(piece)

return pieces_won, pieces_not_won, pieces_in_voice_auction

def unsold_pieces(self):
winning_bid_query = Bid.objects.filter(
piece=OuterRef('pk'), invalid=False).order_by('-amount')[:1]
return Piece.objects.filter(
status=Piece.StatusWon,
bid__bidder=self
).annotate(
top_bidder=Subquery(winning_bid_query.values('bidder')),
top_bid=Subquery(winning_bid_query.values('amount'))
).filter(top_bidder=self.pk).order_by('artist', 'code').distinct()

def voice_auction_wins(self, adult):
winning_bid_query = Bid.objects.filter(
piece=OuterRef('pk'), invalid=False).order_by('-amount')[:1]
return Piece.objects.filter(
status=Piece.StatusWon,
voice_auction=True,
adult=adult,
bid__bidder=self
).annotate(
top_bidder=Subquery(winning_bid_query.values('bidder')),
top_bid=Subquery(winning_bid_query.values('amount'))
).filter(top_bidder=self.pk).order_by('artist', 'code').distinct()

def __str__(self):
return "%s (%s)" % (self.person.name, ", ".join(self.bidder_ids()))

@@ -790,3 +839,22 @@ class SquareTerminal(models.Model):
class SquareWebhook(models.Model):
timestamp = models.DateTimeField()
body = models.JSONField()


class TelegramWebhook(models.Model):
timestamp = models.DateTimeField()
body = models.JSONField()


class BulkMessagingTask(models.Model):
name = models.CharField(max_length=100)
message_count = models.IntegerField(default=0)
sent_count = models.IntegerField(default=0)

@property
def percentage(self):
return float(self.sent_count) / self.message_count * 100

@property
def remaining(self):
return self.message_count - self.sent_count
200 changes: 200 additions & 0 deletions artshow/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
from django.core import mail
from django.db.models import F
from django.template.loader import render_to_string

from artshowjockey.celery import app

from .models import Bidder, BulkMessagingTask
from .utils import artshow_settings
from . import telegram


@app.task(rate_limit='1/s', autoretry_for=(Exception,), retry_backoff=True)
def send_email(task_pk, recipient, subject, message):
mail.send_mail(
subject=subject,
message=message,
from_email=artshow_settings.ARTSHOW_EMAIL_SENDER,
recipient_list=[recipient],
)

BulkMessagingTask.objects.filter(pk=task_pk).update(sent_count=F('sent_count') + 1)


@app.task(rate_limit='1/s', autoretry_for=(Exception,), retry_backoff=True)
def send_telegram_message(task_pk, chat_id, text):
telegram.send_message(chat_id, text)

BulkMessagingTask.objects.filter(pk=task_pk).update(sent_count=F('sent_count') + 1)


@app.task
def email_results():
bidders = Bidder.objects.filter(person__email_confirmed=True)

messages_to_send = []
for bidder in bidders:
pieces_won, pieces_not_won, pieces_in_voice_auction = \
bidder.get_results()
text_content = render_to_string('artshow/bid_results_email.txt', {
'artshow_settings': artshow_settings,
'bidder': bidder,
'pieces_won': pieces_won,
'pieces_not_won': pieces_not_won,
'pieces_in_voice_auction': pieces_in_voice_auction,
})
messages_to_send.append((bidder.person.email, text_content))

task = BulkMessagingTask(name='Send results via email')
task.message_count = len(messages_to_send)
task.save()

for (email, text_content) in messages_to_send:
send_email.delay(
task_pk=task.pk,
recipient=email,
subject=f'{artshow_settings.SITE_NAME} results',
message=text_content,
)


@app.task
def telegram_results():
bidders = Bidder.objects.filter(person__telegram_chat_id__isnull=False)

messages_to_send = []
for bidder in bidders:
pieces_won, pieces_not_won, pieces_in_voice_auction = \
bidder.get_results()
text_content = render_to_string('artshow/bid_results_message.txt', {
'artshow_settings': artshow_settings,
'bidder': bidder,
'pieces_won': pieces_won,
'pieces_not_won': pieces_not_won,
'pieces_in_voice_auction': pieces_in_voice_auction,
})
messages_to_send.append((bidder.person.telegram_chat_id, text_content))

task = BulkMessagingTask(name='Send results via Telegram')
task.message_count = len(messages_to_send)
task.save()

for (chat_id, text_content) in messages_to_send:
send_telegram_message.delay(
task_pk=task.pk,
chat_id=chat_id,
text=text_content)


@app.task
def email_voice_results(adult):
bidders = Bidder.objects.filter(person__email_confirmed=True)
type = 'adult' if adult else 'general'

messages_to_send = []
for bidder in bidders:
pieces_won = bidder.voice_auction_wins(adult=adult)
if pieces_won.count() > 0:
text_content = render_to_string('artshow/voice_auction_results_email.txt', {
'artshow_settings': artshow_settings,
'bidder': bidder,
'type': type,
'pieces_won': pieces_won,
})
messages_to_send.append((bidder.person.email, text_content))

task = BulkMessagingTask(name=f'Send {type} voice auction results via email')
task.message_count = len(messages_to_send)
task.save()

for (email, text_content) in messages_to_send:
send_email.delay(
task_pk=task.pk,
recipient=email,
subject=f'{artshow_settings.SITE_NAME} {type} voice auction results',
message=text_content,
)


@app.task
def telegram_voice_results(adult):
bidders = Bidder.objects.filter(person__telegram_chat_id__isnull=False)
type = 'adult' if adult else 'general'

messages_to_send = []
for bidder in bidders:
piece_won_count = bidder.voice_auction_wins(adult=adult).count()
if piece_won_count > 0:
text_content = render_to_string('artshow/voice_auction_results_message.txt', {
'artshow_settings': artshow_settings,
'bidder': bidder,
'type': type,
'piece_won_count': piece_won_count,
})
messages_to_send.append((bidder.person.telegram_chat_id, text_content))

task = BulkMessagingTask(name=f'Send {type} voice auction results via Telegram')
task.message_count = len(messages_to_send)
task.save()

for (chat_id, text_content) in messages_to_send:
send_telegram_message.delay(
task_pk=task.pk,
chat_id=chat_id,
text=text_content,
)


@app.task
def email_reminder():
bidders = Bidder.objects.filter(person__email_confirmed=True)

messages_to_send = []
for bidder in bidders:
unsold_pieces = bidder.unsold_pieces()
if unsold_pieces.count() > 0:
text_content = render_to_string('artshow/unsold_pieces_email.txt', {
'artshow_settings': artshow_settings,
'bidder': bidder,
'unsold_pieces': unsold_pieces,
})
messages_to_send.append((bidder.person.email, text_content))

task = BulkMessagingTask(name='Send reminder for unsold pieces via email')
task.message_count = len(messages_to_send)
task.save()

for (email, text_content) in messages_to_send:
send_email.delay(
task_pk=task.pk,
recipient=email,
subject=f'Reminder: {artshow_settings.SITE_NAME} pick-up available',
message=text_content,
)


@app.task
def telegram_reminder():
bidders = Bidder.objects.filter(person__email_confirmed=True)

messages_to_send = []
for bidder in bidders:
unsold_piece_count = bidder.unsold_pieces().count()
if unsold_piece_count > 0:
text_content = render_to_string('artshow/unsold_pieces_message.txt', {
'artshow_settings': artshow_settings,
'bidder': bidder,
'unsold_piece_count': unsold_piece_count,
})
messages_to_send.append((bidder.person.telegram_chat_id, text_content))

task = BulkMessagingTask(name='Send reminder for unsold pieces via Telegram')
task.message_count = len(messages_to_send)
task.save()

for (chat_id, text_content) in messages_to_send:
send_telegram_message.delay(
task_pk=task.pk,
chat_id=chat_id,
text=text_content,
)
104 changes: 104 additions & 0 deletions artshow/telegram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.http import HttpResponse
from django.shortcuts import render, redirect
from django.urls import reverse
from django.utils.timezone import now
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST

from functools import cache

import json
import logging
import requests

from .conf import settings
from .models import TelegramWebhook

logger = logging.getLogger(__name__)


@cache
def api_url():
return f'https://api.telegram.org/bot{settings.ARTSHOW_TELEGRAM_BOT_TOKEN}'


@permission_required('artshow.is_artshow_staff')
@require_http_methods(["GET", "POST"])
def set_webhook(request):
if request.method == 'GET':
response = requests.get(f'{api_url()}/getWebhookInfo')
try:
webhook_info = response.json()
except requests.exceptions.JSONDecodeError:
webhook_info = "Invalid response JSON."
return render(request, 'artshow/workflows_telegram_webhook.html',
{'webhook_info': webhook_info})
elif request.method == 'POST':
response = requests.get(f'{api_url()}/setWebhook', {
'url': f'{settings.SITE_ROOT_URL}{reverse('telegram-webhook')}',
'secret_token': settings.ARTSHOW_TELEGRAM_WEBHOOK_SECRET
})
if response.status_code == 200:
messages.success(request, 'Webhook configured successfully')
else:
messages.error(request, f'Failed to configure webhook: {response.text}')
return redirect('telegram-configure-webhook')


@permission_required('artshow.is_artshow_staff')
@require_POST
def delete_webhook(request):
response = requests.get(f'{api_url()}/deleteWebhook')
if response.status_code == 200:
messages.success(request, 'Webhook deleted successfully')
else:
messages.error(request, f'Failed to delete webhook: {response.text}')
return redirect('telegram-configure-webhook')


def send_message(chat_id, text):
response = requests.post(f'{api_url()}/sendMessage', {
'chat_id': chat_id,
'parse_mode': 'HTML',
'text': text
})
if response.status_code != 200:
raise Exception(f'Failed to send Telegram message ({response.status_code}): {response.text}')


def process_message(message):
if 'text' in message:
chat_id = message['chat']['id']
send_message(chat_id, f'Messages to this bot are not monitored. Please e-mail <a href="mailto:{settings.ARTSHOW_ADMIN_EMAIL}">{settings.ARTSHOW_ADMIN_EMAIL}</a>.')


def process_update(body):
if 'message' in body:
process_message(body['message'])


@csrf_exempt
@require_POST
def webhook(request):
if request.headers.get('X-Telegram-Bot-Api-Secret-Token') != settings.ARTSHOW_TELEGRAM_WEBHOOK_SECRET:
logger.debug('Received webhook with invalid secret!')
return HttpResponse(status=403)

body = request.body.decode('utf-8')
try:
body = json.loads(body)
except json.JSONDecodeError:
logger.exception('Received webhook with invalid JSON!')
return HttpResponse(status=400)

webhook = TelegramWebhook(timestamp=now(), body=body)
webhook.save()

try:
process_update(body)
except Exception:
logger.exception('Failed to process webhook!')

return HttpResponse(status=200)
62 changes: 62 additions & 0 deletions artshow/templates/artshow/bid_confirm_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ SITE_NAME }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand">{{ SITE_NAME }}</a>
{% if user.is_active %}
<form class="d-flex" method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="btn btn-outline-danger" type="submit">Log out</button>
</form>
{% endif %}
</div>
</nav>

<div class="container">
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>

<p>
<form action="" method="post" class="row row-cols-lg-auto g-3 align-items-center">
{% csrf_token %}

{{ form.non_field_errors }}
<div class="col-6">
{{ form.code }}
</div>

<div class="col-3">
<button type="submit" class="btn btn-primary">Confirm</button>
</div>

<div class="col-3">
<button type="submit" formaction="{% url 'artshow-bid-send-email-code' %}" class="btn btn-secondary">Resend</button>
</div>
</form>
</p>
{% else %}
<p>
You have been successfully registered to receive email notifications
at <strong>{{ person.email }}</strong>.
</p>
{% endif %}
<p>
<a href="{% url 'artshow-bid' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M14.5 1.5a.5.5 0 0 1 .5.5v4.8a2.5 2.5 0 0 1-2.5 2.5H2.707l3.347 3.346a.5.5 0 0 1-.708.708l-4.2-4.2a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 8.3H12.5A1.5 1.5 0 0 0 14 6.8V2a.5.5 0 0 1 .5-.5"/>
</svg>
Back
</a>
</p>
</div>
</body>
</html>
1 change: 1 addition & 0 deletions artshow/templates/artshow/bid_email_confirmation.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Your e-mail confirmation code is: {{ code }}
141 changes: 141 additions & 0 deletions artshow/templates/artshow/bid_index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ SITE_NAME }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand">{{ SITE_NAME }}</a>
{% if user.is_active %}
<form class="d-flex" method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="btn btn-outline-danger" type="submit">Log out</button>
</form>
{% endif %}
</div>
</nav>

<div class="container">
{% if bidder.bidder_ids %}
<p>
Your bidder ID is <strong>{{ bidder.bidder_ids|join:', ' }}</strong>.
For additional stickers, see art show staff.
</p>
{% if show_has_bids %}
{% if pieces_in_voice_auction %}
<h3>Pieces awaiting voice auction</h3>
<ul>
{% for piece in pieces_in_voice_auction %}
<li>
{{ piece }},
Top bid: ${{ piece.top_bid }}
{% if piece.top_bidder == bidder.pk %}(Yours){% endif %}
</li>
{% endfor %}
</ul>
{% endif %}

<h3>Pieces won</h3>
<ul>
{% for piece in pieces_won %}
<li>
{{ piece }},
Winning bid: ${{ piece.top_bid }}
</li>
{% empty %}
<li><em>None</em></li>
{% endfor %}
</ul>

{% if pieces_not_won %}
<h3>Losing bids</h3>
<ul>
{% for piece in pieces_not_won %}
<li>
{{ piece }},
Winning bid: ${{ piece.top_bid }}
</li>
{% empty %}
<li><em>None</em></li>
{% endfor %}
</ul>
{% endif %}
{% else %}
<p>
No auction results to display.
</p>
{% endif %}
{% else %}
<p>
See art show staff to receive a bidder ID and your bid stickers.
</p>
{% endif %}

<h3>Notifications</h3>
{% if email_confirmation_form %}
<p>
A confirmation code has been sent to <strong>{{ bidder.person.email }}</strong>. Enter it here:
</p>
<p>
<form action="{% url 'artshow-bid-confirm-email' %}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
{% csrf_token %}

<div class="col-6">
{{ email_confirmation_form.code }}
</div>

<div class="col-3">
<button type="submit" class="btn btn-primary">Confirm</button>
</div>

<div class="col-3">
<button type="submit" formaction="{% url 'artshow-bid-send-email-code' %}" class="btn btn-secondary">Resend</button>
</div>
</form>
</p>
{% endif %}

{% if bidder.person.telegram_username %}
<p>
Telegram notifications will be sent to <strong>@{{ bidder.person.telegram_username }}</strong>.
</p>
{% endif %}

{% if bidder.person.email_confirmed %}
<p>
E-mail notifications will be sent to <strong>{{ bidder.person.email }}</strong>.
</p>
{% endif %}

{% if artshow_settings.ARTSHOW_TELEGRAM_BOT_USERNAME %}
{% if not bidder.person.telegram_username %}
<p>
To receive Telegram notifications, click the button below:
</p>
<script async src="https://telegram.org/js/telegram-widget.js?22"
data-telegram-login="{{ artshow_settings.ARTSHOW_TELEGRAM_BOT_USERNAME }}"
data-size="medium"
data-auth-url="{{ artshow_settings.SITE_ROOT_URL }}{% url 'artshow-bid-telegram' %}"
data-request-access="write">
</script>
{% endif %}
{% endif %}

{% if not email_confirmation_form and not bidder.person.email_confirmed %}
<p>
To receive e-mail notifications, click this button:
</p>
<p>
<form action="{% url 'artshow-bid-send-email-code' %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-primary">Verify e-mail address</button>
</form>
</p>
{% endif %}
</div>
</body>
</html>
28 changes: 28 additions & 0 deletions artshow/templates/artshow/bid_login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ SITE_NAME }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand">{{ SITE_NAME }}</a>
</div>
</nav>

<div class="container">
<p>
Click the button below to log in, you will be asked to log in with your
convention registration account and then you will be redirected back to
this site.
</p>

<div class="d-grid gap-2 mx-auto">
<a class="btn btn-primary btn-lg" href="{% url 'oauth-redirect' %}?next={% url 'artshow-bid' %}" role="button">Log in</a>
</div>
</div>
</body>
</html>
106 changes: 106 additions & 0 deletions artshow/templates/artshow/bid_register.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ SITE_NAME }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand">{{ SITE_NAME }}</a>
{% if user.is_active %}
<form class="d-flex" method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="btn btn-outline-danger" type="submit">Log out</button>
</form>
{% endif %}
</div>
</nav>

<div class="container">
<p>
This form has been populated with the information from your convention
registration. Please verify that it is up to date.
</p>

<form action="" method="post" class="row g-3">
{% csrf_token %}

{{ form.non_field_errors }}
<div class="col-sm-12">
{{ form.name.errors }}
<label class="form-label" id="{{ form.name.auto_id }}" aria-describedby="nameHelp">{{ form.name.label }}</label>
{{ form.name }}
<small class="form-text text-muted" id="nameHelp">
This must match your identification.
</small>
</div>

<div class="col-sm-6">
{{ form.email.errors }}
<label class="form-label" id="{{ form.email.auto_id }}">{{ form.email.label }}</label>
{{ form.email }}
</div>

<div class="col-sm-6">
{{ form.phone.errors }}
<label class="form-label" id="{{ form.phone.auto_id }}">{{ form.phone.label }}</label>
{{ form.phone }}
</div>

<div class="col-sm-12">
{{ form.address1.errors }}
<label class="form-label" id="{{ form.address1.auto_id }}">{{ form.address1.label }}</label>
{{ form.address1 }}
</div>

<div class="col-sm-12">
{{ form.address2.errors }}
<label class="form-label" id="{{ form.address2.auto_id }}">{{ form.address2.label }}</label>
{{ form.address2 }}
</div>

<div class="col-sm-6">
{{ form.city.errors }}
<label class="form-label" id="{{ form.city.auto_id }}">{{ form.city.label }}</label>
{{ form.city }}
</div>

<div class="col-sm-4">
{{ form.state.errors }}
<label class="form-label" id="{{ form.state.auto_id }}">{{ form.state.label }}</label>
{{ form.state }}
</div>

<div class="col-sm-2">
{{ form.postcode.errors }}
<label class="form-label" id="{{ form.postcode.auto_id }}">{{ form.postcode.label }}</label>
{{ form.postcode }}
</div>

<div class="col-sm-12">
{{ form.country.errors }}
<label class="form-label" id="{{ form.country.auto_id }}">{{ form.country.label }}</label>
{{ form.country }}
</div>

<div class="col-sm-12">
{{ form.at_con_contact.errors }}
<label class="form-label" id="{{ form.at_con_contact.auto_id }}" aria-describedby="atConContactHelp">{{ form.at_con_contact.label }}</label>
{{ form.at_con_contact }}
<small class="form-text text-muted" id="atConContactHelp">
If there is a better way to contact you at the convention,
such as a friend's cell phone or a hotel room number.
Please enter it here.
</small>
</div>

<div class="d-grid gap-2 mx-auto">
<button type="submit" class="btn btn-primary">Register</button>
</div>
</form>
</div>
</body>
</html>
14 changes: 14 additions & 0 deletions artshow/templates/artshow/bid_results_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% autoescape off %}The silent auction results are now available. {% if pieces_won %}You have won {{ pieces_won|length }} piece{{ pieces_won|pluralize }}{% if pieces_in_voice_auction %} and {{ pieces_in_voice_auction|length }} piece{{ pieces_in_voice_auction|pluralize }} you bid on moved to the voice auction{% endif %}.{% else %}You have not won any pieces{% if pieces_in_voice_auction %} however {{ pieces_in_voice_auction|length }} piece{{ pieces_in_voice_auction|pluralize }} you bid on moved to the voice auction{% endif %}.{% endif %}
{% if pieces_in_voice_auction %}
Pieces awaiting voice auction:{% for piece in pieces_in_voice_auction %}
* {{ piece }}, Top bid: ${{ piece.top_bid }} {% if piece.top_bidder == bidder.pk %}(Yours){% endif %}{% endfor %}{% endif %}
{% if pieces_won %}
Pieces won:{% for piece in pieces_won %}
* {{ piece }}, Winning bid: ${{ piece.top_bid }}{% endfor %}{% endif %}
{% if pieces_not_won %}
Losing bids:{% for piece in pieces_not_won %}
* {{ piece }}, Winning bid: ${{ piece.top_bid }}{% endfor %}{% endif %}

For details, visit {{ artshow_settings.SITE_ROOT_URL }}{% url 'artshow-bid' %}.

For pick-up and voice auction times, check the convention schedule: {{ artshow_settings.ARTSHOW_SCHEDULE_URL }}{% endautoescape %}
5 changes: 5 additions & 0 deletions artshow/templates/artshow/bid_results_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
The silent auction results are now available. {% if pieces_won %}You have won {{ pieces_won|length }} piece{{ pieces_won|pluralize }}{% if pieces_in_voice_auction %} and {{ pieces_in_voice_auction|length }} piece{{ pieces_in_voice_auction|pluralize }} you bid on moved to the voice auction{% endif %}.{% else %}You have not won any pieces{% if pieces_in_voice_auction %} however {{ pieces_in_voice_auction|length }} piece{{ pieces_in_voice_auction|pluralize }} you bid on moved to the voice auction{% endif %}.{% endif %}

For details, visit {{ artshow_settings.SITE_ROOT_URL }}{% url 'artshow-bid' %}.

For pick-up and voice auction times, check the convention schedule: {{ artshow_settings.ARTSHOW_SCHEDULE_URL }}
42 changes: 42 additions & 0 deletions artshow/templates/artshow/bid_telegram.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ SITE_NAME }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand">{{ SITE_NAME }}</a>
{% if user.is_active %}
<form class="d-flex" method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="btn btn-outline-danger" type="submit">Log out</button>
</form>
{% endif %}
</div>
</nav>

<div class="container">
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% else %}
<p>
You have been successfully registered to receive Telegram notifications
at <strong>@{{ person.telegram_username }}</strong>.
</p>
{% endif %}

<a href="{% url 'artshow-bid' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M14.5 1.5a.5.5 0 0 1 .5.5v4.8a2.5 2.5 0 0 1-2.5 2.5H2.707l3.347 3.346a.5.5 0 0 1-.708.708l-4.2-4.2a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 8.3H12.5A1.5 1.5 0 0 0 14 6.8V2a.5.5 0 0 1 .5-.5"/>
</svg>
Back
</a>
</div>
</body>
</html>
3 changes: 3 additions & 0 deletions artshow/templates/artshow/telegram_welcome_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Thank you for signing up for notifications from the {{ artshow_settings.SITE_NAME }}.

This converation may be recorded for quality assurance purposes.
7 changes: 7 additions & 0 deletions artshow/templates/artshow/unsold_pieces_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% autoescape off %}This is a reminder that you still have {{ unsold_pieces|length }} piece{{ unsold_pieces|pluralize }} waiting to pick up at the {{ artshow_settings.SITE_NAME }}:
{% for piece in unsold_pieces %}
* {{ piece }}, Winning bid: ${{ piece.top_bid }}{% endfor %}

For details, visit {{ artshow_settings.SITE_ROOT_URL }}{% url 'artshow-bid' %}.

For pick-up times, check the convention schedule: {{ artshow_settings.ARTSHOW_SCHEDULE_URL }}{% endautoescape %}
5 changes: 5 additions & 0 deletions artshow/templates/artshow/unsold_pieces_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
This is a reminder that you still have {{ unsold_piece_count }} piece{{ unsold_piece_count|pluralize }} waiting to pick up at the {{ artshow_settings.SITE_NAME}}:

For details, visit {{ artshow_settings.SITE_ROOT_URL }}{% url 'artshow-bid' %}.

For pick-up times, check the convention schedule: {{ artshow_settings.ARTSHOW_SCHEDULE_URL }}
7 changes: 7 additions & 0 deletions artshow/templates/artshow/voice_auction_results_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% autoescape off %}The results of the {{ type }} voice auction have been recorded and you have {{ pieces_won|length }} piece{{ pieces_won|pluralize }} ready to pick up at the {{ artshow_settings.SITE_NAME }}:
{% for piece in pieces_won %}
* {{ piece }}, Winning bid: ${{ piece.top_bid }}{% endfor %}

For details, visit {{ artshow_settings.SITE_ROOT_URL }}{% url 'artshow-bid' %}.

For pick-up times, check the convention schedule: {{ artshow_settings.ARTSHOW_SCHEDULE_URL }}{% endautoescape %}
5 changes: 5 additions & 0 deletions artshow/templates/artshow/voice_auction_results_message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
The results of the {{ type }} voice auction have been recorded and you have {{ piece_won_count }} piece{{ piece_won_count|pluralize }} ready to pick up.

For details, visit {{ artshow_settings.SITE_ROOT_URL }}{% url 'artshow-bid' %}.

For pick-up times, check the convention schedule: {{ artshow_settings.ARTSHOW_SCHEDULE_URL }}
34 changes: 34 additions & 0 deletions artshow/templates/artshow/workflows_bulk_messaging.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{% extends "artshow/base_generic.html" %}
{% block title %}Bulk Messaging{% endblock %}
{% block breadcrumbs %}
<ul class="breadcrumbs">
<li><a href="{% url 'artshow-home' %}">Home</a></li>
<li><a href="{% url 'artshow-workflows' %}">Workflows</a></li>
<li class="current">Bulk Messaging</li>
</ul>
{% endblock %}
{% block content %}
<p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Send</button>
</form>
</p>
<p>Active tasks:</p>
<ul>
{% for task in active_tasks %}
<li>{{ task.name }}: {{ task.percentage }}% ({{ task.remaining }} remaining)</li>
{% empty %}
<li><em>None</em></li>
{% endfor %}
</ul>
<p>Completed tasks:</p>
<ul>
{% for task in completed_tasks %}
<li>{{ task.name }} ({{ task.message_count }} message{{ task.message_count|pluralize }})</li>
{% empty %}
<li><em>None</em></li>
{% endfor %}
</ul>
{% endblock %}
14 changes: 10 additions & 4 deletions artshow/templates/artshow/workflows_index.html
Original file line number Diff line number Diff line change
@@ -7,15 +7,21 @@
</ul>
{% endblock %}
{% block content %}
<h1>At-con</h1>
<ul>
<li><a href="{% url 'artshow-workflow-create-locations' %}">Create Locations</a></li>
<li><a href="{% url 'artshow-workflow-printing' %}">Bid Sheet and Control Form Printing</a></li>
<li><a href="{% url 'artshow-workflow-artist-checkin-lookup' %}">Artist Check-in</a></li>
<li><a href="{% url 'artshow-workflow-create-bidder-ids' %}">Create Bidder IDs</a></li>
<li><a href="{% url 'artshow-workflow-bidder-lookup' %}">Bidder Check-in</a></li>
<li><a href="{% url 'artshow-workflow-close-show' %}">Close Show</a></li>
<li><a href="{% url 'artshow-workflow-bulk-messaging' %}">Bulk Messaging</a></li>
<li><a href="{% url 'artshow-workflow-print-cheques' %}">Print Cheques</a></li>
<li><a href="{% url 'artshow-workflow-pair-terminal' %}">Pair Square Terminal</a></li>
<li><a href="{% url 'artshow-workflow-artist-checkout-lookup' %}">Artist Check-out</a></li>
</ul>
<h1>Pre-con</h1>
<ul>
<li><a href="{% url 'artshow-workflow-create-locations' %}">Create Locations</a></li>
<li><a href="{% url 'artshow-workflow-printing' %}">Bid Sheet and Control Form Printing</a></li>
<li><a href="{% url 'artshow-workflow-create-bidder-ids' %}">Create Bidder IDs</a></li>
<li><a href="{% url 'telegram-configure-webhook' %}">Configure Telegram Webhooks</a></li>
<li><a href="{% url 'artshow-workflow-pair-terminal' %}">Pair Square Terminal</a></li>
</ul>
{% endblock %}
21 changes: 21 additions & 0 deletions artshow/templates/artshow/workflows_telegram_webhook.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% extends "artshow/base_generic.html" %}
{% block breadcrumbs %}
<ul class="breadcrumbs">
<li><a href="{% url 'artshow-home' %}">Home</a></li>
<li><a href="{% url 'artshow-workflows' %}">Workflows</a></li>
<li class="current">Configure Telegram Webhook</li>
</ul>
{% endblock %}
{% block content %}
<h1>Current webhook info:</h1>
<pre>{{ webhook_info }}</pre>
<form method="post">
{% csrf_token %}
<button type="submit">Set Webhook</button>
</form>
<form action="{% url 'telegram-delete-webhook' %}" method="post">
{% csrf_token %}
<button type="submit">Delete Webhook</button>
</form>
{% endblock %}

237 changes: 237 additions & 0 deletions artshow/tests/test_bid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse

from ..models import Artist, Bid, Bidder, BidderId, Piece
from peeps.models import Person


class BidTests(TestCase):

def setUp(self):
artist_person = Person()
artist_person.save()

artist = Artist(person=artist_person, publicname='Artist', artistid=1)
artist.save()

self.piece1 = Piece(
artist=artist, pieceid=1, min_bid=5, buy_now=50,
status=Piece.StatusInShow, location='A1')
self.piece1.save()

bidder_user = User.objects.create_user(
username='bidder', email='bidder@example.com', password='test')
bidder_user.save()

self.bidder_person = Person(user=bidder_user, reg_id='42')
self.bidder_person.save()

person2 = Person(reg_id='69')
person2.save()

self.bidder2 = Bidder(person=person2)
self.bidder2.save()

self.bidderid2 = BidderId(id='0019', bidder=self.bidder2)
self.bidderid2.save()

self.client = Client()

def logIn(self):
self.client.login(username='bidder', password='test')

def register(self):
self.bidder = Bidder(person=self.bidder_person)
self.bidder.save()

self.bidderid = BidderId(id='0365327', bidder=self.bidder)
self.bidderid.save()

def testBidLoggedOut(self):
index_url = reverse('artshow-bid')
response = self.client.get(index_url)
self.assertRedirects(
response, reverse('artshow-bid-login') + '?next=' + index_url)

def testBidUnregistered(self):
self.logIn()
response = self.client.get(reverse('artshow-bid'))
self.assertRedirects(response, reverse('artshow-bid-register'))

def testBid(self):
self.logIn()
self.register()
response = self.client.get(reverse('artshow-bid'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['bidder'], self.bidder)
self.assertFalse(response.context['show_has_bids'])

def testNoBids(self):
self.logIn()
self.register()
Bid.objects.bulk_create([
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=10),
])
self.piece1.apply_won_status()

response = self.client.get(reverse('artshow-bid'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['bidder'], self.bidder)
self.assertTrue(response.context['show_has_bids'])
self.assertListEqual(response.context['pieces_won'], [])
self.assertListEqual(response.context['pieces_not_won'], [])
self.assertListEqual(response.context['pieces_in_voice_auction'], [])

def testNoWinningBids(self):
self.logIn()
self.register()
Bid.objects.bulk_create([
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=10),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=20),
])
self.piece1.apply_won_status()

response = self.client.get(reverse('artshow-bid'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['bidder'], self.bidder)
self.assertTrue(response.context['show_has_bids'])
self.assertListEqual(response.context['pieces_won'], [])
self.assertListEqual(response.context['pieces_in_voice_auction'], [])

pieces_not_won = response.context['pieces_not_won']
self.assertListEqual(pieces_not_won, [self.piece1])
self.assertEqual(pieces_not_won[0].top_bid, 20)
self.assertEqual(pieces_not_won[0].top_bidder, self.bidder2.pk)

def testWinningBid(self):
self.logIn()
self.register()
Bid.objects.bulk_create([
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=10),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=20),
])
self.piece1.apply_won_status()

response = self.client.get(reverse('artshow-bid'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['bidder'], self.bidder)
self.assertTrue(response.context['show_has_bids'])
self.assertListEqual(response.context['pieces_not_won'], [])
self.assertListEqual(response.context['pieces_in_voice_auction'], [])

pieces_won = response.context['pieces_won']
self.assertListEqual(pieces_won, [self.piece1])
self.assertEqual(pieces_won[0].top_bid, 20)
self.assertEqual(pieces_won[0].top_bidder, self.bidder.pk)

def testWaitingVoiceAuction(self):
self.logIn()
self.register()
Bid.objects.bulk_create([
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=10),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=20),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=30),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=40),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=50),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=60),
])
self.piece1.apply_won_status()

response = self.client.get(reverse('artshow-bid'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['bidder'], self.bidder)
self.assertTrue(response.context['show_has_bids'])
self.assertListEqual(response.context['pieces_won'], [])
self.assertListEqual(response.context['pieces_not_won'], [])

pieces_in_voice_auction = response.context['pieces_in_voice_auction']
self.assertListEqual(pieces_in_voice_auction, [self.piece1])
self.assertEqual(pieces_in_voice_auction[0].top_bid, 60)
self.assertEqual(pieces_in_voice_auction[0].top_bidder, self.bidder.pk)

def testWonInVoiceAuction(self):
self.logIn()
self.register()
Bid.objects.bulk_create([
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=10),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=20),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=30),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=40),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=50),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=60),
])
self.piece1.apply_won_status()
Bid.objects.bulk_create([
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=70),
])
self.piece1.status = Piece.StatusWon
self.piece1.save()

response = self.client.get(reverse('artshow-bid'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['bidder'], self.bidder)
self.assertTrue(response.context['show_has_bids'])
self.assertListEqual(response.context['pieces_not_won'], [])
self.assertListEqual(response.context['pieces_in_voice_auction'], [])

pieces_won = response.context['pieces_won']
self.assertListEqual(pieces_won, [self.piece1])
self.assertEqual(pieces_won[0].top_bid, 70)
self.assertEqual(pieces_won[0].top_bidder, self.bidder.pk)

def testLostInVoiceAuction(self):
self.logIn()
self.register()
Bid.objects.bulk_create([
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=10),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=20),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=30),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=40),
Bid(bidder=self.bidder, bidderid=self.bidderid, piece=self.piece1, amount=50),
Bid(bidder=self.bidder2, bidderid=self.bidderid2, piece=self.piece1, amount=60),
])
self.piece1.apply_won_status()
Bid.objects.bulk_create([
Bid(bidder=self.bidder2, bidderid=self.bidderid, piece=self.piece1, amount=100),
])
self.piece1.status = Piece.StatusWon
self.piece1.save()

response = self.client.get(reverse('artshow-bid'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['bidder'], self.bidder)
self.assertTrue(response.context['show_has_bids'])
self.assertListEqual(response.context['pieces_won'], [])
self.assertListEqual(response.context['pieces_in_voice_auction'], [])

pieces_not_won = response.context['pieces_not_won']
self.assertListEqual(pieces_not_won, [self.piece1])
self.assertEqual(pieces_not_won[0].top_bid, 100)
self.assertEqual(pieces_not_won[0].top_bidder, self.bidder2.pk)

def testBidLoggedInAlready(self):
self.logIn()
response = self.client.get(reverse('artshow-bid-login'), follow=True)
self.assertRedirects(response, reverse('artshow-bid-register'))

def testBidLogin(self):
response = self.client.get(reverse('artshow-bid-login'))
self.assertEqual(response.status_code, 200)

def testBidRegisterLoggedOut(self):
register_url = reverse('artshow-bid-register')
response = self.client.get(register_url)
self.assertRedirects(
response, reverse('artshow-bid-login') + '?next=' + register_url)

def testBidRegisterAlready(self):
self.logIn()
self.register()
response = self.client.get(reverse('artshow-bid-register'))
self.assertRedirects(response, reverse('artshow-bid'))

def testBidRegister(self):
self.logIn()
response = self.client.get(reverse('artshow-bid-register'))
self.assertEqual(response.status_code, 200)
8 changes: 8 additions & 0 deletions artshow/urls.py
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@
from . import pdfreports
from . import reports
from . import square
from . import telegram
from . import views
from . import voice_auction
from . import workflows
@@ -147,5 +148,12 @@
re_path(r'^workflows/artist_checkout/(?P<artistid>\d+)/control_form$',
workflows.artist_print_checkout_control_form,
name='artshow-workflow-artist-checkout-control-form'),
re_path(r'^workflows/telegram_webhook/$', telegram.set_webhook,
name='telegram-configure-webhook'),
re_path(r'^workflows/telegram_webhook/delete/$', telegram.delete_webhook,
name='telegram-delete-webhook'),
re_path(r'^workflows/bulk_messaging/$', workflows.bulk_messaging,
name='artshow-workflow-bulk-messaging'),
re_path(r'^webhook/square/$', square.webhook, name='square-webhook'),
re_path(r'^webhook/telegram/$', telegram.webhook, name='telegram-webhook'),
]
59 changes: 57 additions & 2 deletions artshow/workflows.py
Original file line number Diff line number Diff line change
@@ -11,9 +11,10 @@
from .conf import settings
from .mod11codes import make_check
from .models import (
Artist, BidderId, ChequePayment, Location, Piece, Space, SquareTerminal
Artist, BidderId, BulkMessagingTask, ChequePayment, Location, Piece, Space,
SquareTerminal
)
from . import square
from . import square, tasks


@permission_required('artshow.is_artshow_staff')
@@ -565,3 +566,57 @@ def select_terminal(request, pk):
request.session['terminal'] = device.pk

return redirect(pair_terminal)


BULK_MESSAGE_TYPES = {
'email_results': 'Send results via email',
'telegram_results': 'Send results via Telegram',
'email_adult_voice_results': 'Send adult voice auction results via email',
'telegram_adult_voice_results': 'Send adult voice auction results via Telegram',
'email_general_voice_results': 'Send general voice auction results via email',
'telegram_general_voice_results': 'Send general voice auction results via Telegram',
'email_reminder': 'Send reminder for unsold pieces via email',
'telegram_reminder': 'Send reminder for unsold pieces via Telegram',
}


class BulkMessagingForm(forms.Form):
message_type = forms.ChoiceField(choices=BULK_MESSAGE_TYPES.items())


@permission_required('artshow.is_artshow_staff')
def bulk_messaging(request):
if request.method == 'POST':
form = BulkMessagingForm(request.POST)
if form.is_valid():
if form.cleaned_data['message_type'] == 'email_results':
tasks.email_results.delay()
elif form.cleaned_data['message_type'] == 'telegram_results':
tasks.telegram_results.delay()
elif form.cleaned_data['message_type'] == 'email_adult_voice_results':
tasks.email_voice_results.delay(adult=True)
elif form.cleaned_data['message_type'] == 'telegram_adult_voice_results':
tasks.telegram_voice_results.delay(adult=True)
elif form.cleaned_data['message_type'] == 'email_general_voice_results':
tasks.email_voice_results.delay(adult=False)
elif form.cleaned_data['message_type'] == 'telegram_general_voice_results':
tasks.telegram_voice_results.delay(adult=False)
elif form.cleaned_data['message_type'] == 'email_reminder':
tasks.email_reminder.delay()
elif form.cleaned_data['message_type'] == 'telegram_reminder':
tasks.telegram_reminder.delay()
return redirect('artshow-workflow-bulk-messaging')

active_tasks = []
completed_tasks = []
for task in BulkMessagingTask.objects.all():
if task.sent_count < task.message_count:
active_tasks.append(task)
else:
completed_tasks.append(task)

return render(request, 'artshow/workflows_bulk_messaging.html', {
'form': BulkMessagingForm(),
'active_tasks': active_tasks,
'completed_tasks': completed_tasks,
})
13 changes: 7 additions & 6 deletions artshowjockey/settings.py
Original file line number Diff line number Diff line change
@@ -55,11 +55,6 @@
env.str('CELERY_QUEUE_PREFIX', default='artshowjockey-'),
}

# Configure mail sent with the backend above to go through the Celery task
# queue.
CELERY_EMAIL_BACKEND = EMAIL_BACKEND
EMAIL_BACKEND = 'djcelery_email.backends.CeleryEmailBackend'

# Local time zone for this installation. Choices can be found here:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# although not all choices may be available on all operating systems.
@@ -175,7 +170,6 @@
'ajax_select',
'tinyannounce',
'django_celery_results',
'djcelery_email',
# Uncomment the next line to enable admin documentation:
# 'django.contrib.admindocs',
# Uncomment the next line to enable the debug toolbar.
@@ -245,6 +239,8 @@
ARTSHOW_EMAIL_FOOTER = env.str('EMAIL_FOOTER', default="")
ARTSHOW_ARTIST_AGREEMENT_URL = \
env.str('ARTIST_AGREEMENT_URL', default='https://example.com')
ARTSHOW_SCHEDULE_URL = \
env.str('SCHEDULE_URL', default='https://example.com/schedule/')

ARTSHOW_CHEQUE_THANK_YOU = \
"Thank you for exhibiting at the " + ARTSHOW_SHOW_NAME
@@ -265,6 +261,11 @@
ARTSHOW_SQUARE_SIGNATURE_KEY = env.str('SIGNATURE_KEY', default='')
ARTSHOW_SQUARE_ENVIRONMENT = env.str('ENVIRONMENT', default='sandbox')

with env.prefixed('ARTSHOW_TELEGRAM_'):
ARTSHOW_TELEGRAM_BOT_USERNAME = env.str('BOT_USERNAME', default=None)
ARTSHOW_TELEGRAM_BOT_TOKEN = env.str('BOT_TOKEN', default=None)
ARTSHOW_TELEGRAM_WEBHOOK_SECRET = env.str('WEBHOOK_SECRET', default=None)

SITE_ID = 1
SITE_NAME = ARTSHOW_SHOW_NAME
SITE_ROOT_URL = env.str('SITE_ROOT_URL', default='http://localhost:8000')
1 change: 1 addition & 0 deletions artshowjockey/urls.py
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@
re_path(r'^admin/', admin.site.urls),
re_path(r'^artshow/', include('artshow.urls')),
re_path(r'^manage/', include('artshow.manage_urls')),
re_path(r'^bid/', include('artshow.bid_urls')),
re_path(r'^accounts/', include('tinyreg.urls')),
# Uncomment when using the debug toolbar.
# re_path(r'^__debug__/', include('debug_toolbar.urls')),
18 changes: 18 additions & 0 deletions peeps/migrations/0005_person_telegram_username.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2024-12-30 22:50

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('peeps', '0004_person_preferred_name'),
]

operations = [
migrations.AddField(
model_name='person',
name='telegram_username',
field=models.CharField(blank=True, max_length=100),
),
]
33 changes: 33 additions & 0 deletions peeps/migrations/0006_person_email_phone_confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 5.1.4 on 2024-12-31 02:07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('peeps', '0005_person_telegram_username'),
]

operations = [
migrations.AddField(
model_name='person',
name='email_confirmation_code',
field=models.CharField(blank=True, max_length=40),
),
migrations.AddField(
model_name='person',
name='email_confirmed',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='person',
name='phone_confirmation_code',
field=models.CharField(blank=True, max_length=40),
),
migrations.AddField(
model_name='person',
name='phone_confirmed',
field=models.BooleanField(default=False),
),
]
18 changes: 18 additions & 0 deletions peeps/migrations/0007_person_telegram_chat_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-01-03 02:36

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('peeps', '0006_person_email_phone_confirmation'),
]

operations = [
migrations.AddField(
model_name='person',
name='telegram_chat_id',
field=models.BigIntegerField(blank=True, default=None, null=True),
),
]
6 changes: 6 additions & 0 deletions peeps/models.py
Original file line number Diff line number Diff line change
@@ -13,8 +13,14 @@ class Person (models.Model):
postcode = models.CharField(max_length=20, blank=True)
country = models.CharField(max_length=40, blank=True)
phone = models.CharField(max_length=40, blank=True)
phone_confirmed = models.BooleanField(default=False)
phone_confirmation_code = models.CharField(max_length=40, blank=True)
email = models.CharField(max_length=100, blank=True)
email_confirmed = models.BooleanField(default=False)
email_confirmation_code = models.CharField(max_length=40, blank=True)
reg_id = models.CharField(max_length=40, blank=True, verbose_name="Reg ID")
telegram_username = models.CharField(max_length=100, blank=True)
telegram_chat_id = models.BigIntegerField(null=True, blank=True, default=None)
preferred_name = models.CharField(max_length=100, blank=True)
comment = models.CharField(max_length=100, blank=True)

30 changes: 20 additions & 10 deletions tinyreg/views.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@
from django.shortcuts import redirect
from django.urls import reverse

from oauthlib.oauth2 import MismatchingStateError
from oauthlib.oauth2 import AccessDeniedError, MismatchingStateError, MissingTokenError

from requests_oauthlib import OAuth2Session

@@ -25,23 +25,30 @@ def get_oauth_session(request):


def oauth_redirect(request):
next = request.GET.get('next', '/')

if request.user.is_authenticated:
return HttpResponseRedirect(next)

oauth = get_oauth_session(request)
authorization_url, state = \
oauth.authorization_url(settings.OAUTH_AUTHORIZE_URL)

request.session['oauth_state'] = state
request.session['oauth_next'] = request.GET.get('next', '/')
request.session['oauth_next'] = next
return redirect(authorization_url)


def oauth_complete(request):
state = request.session.get('oauth_state')
if state is None:
if request.user.is_authenticated:
return HttpResponseRedirect(request.session.get('oauth_next', '/'))
state = request.session.pop('oauth_state', None)
next = request.session.pop('oauth_next', '/')

if request.user.is_authenticated:
return HttpResponseRedirect(next)

if state is None:
messages.error(request, 'Something went wrong. Please try again.')
return redirect(reverse('login'))
return HttpResponseRedirect(f'{settings.LOGIN_URL}?next={next}')

oauth = get_oauth_session(request)

@@ -51,9 +58,12 @@ def oauth_complete(request):
client_secret=settings.OAUTH_CLIENT_SECRET,
authorization_response=get_absolute_url(request.get_full_path()),
include_client_id=True)
except MismatchingStateError:
except AccessDeniedError:
messages.error(request, 'Access denied. Please try again.')
return HttpResponseRedirect(f'{settings.LOGIN_URL}?next={next}')
except MismatchingStateError | MissingTokenError:
messages.error(request, 'Something went wrong. Please try again.')
return redirect(reverse('login'))
return HttpResponseRedirect(f'{settings.LOGIN_URL}?next={next}')

user_info = oauth.get(settings.CONCAT_API + '/users/current').json()
reg_id = str(user_info['id'])
@@ -88,4 +98,4 @@ def oauth_complete(request):
person.save()

auth.login(request, user)
return HttpResponseRedirect(request.session.get('oauth_next', '/'))
return HttpResponseRedirect(next)