brush the dust off of the event system, add a new eventtype ticket_stats, add a management command to post ticket stats to irc, add a backoffice view showing ticket stats

This commit is contained in:
Thomas Steen Rasmussen 2021-07-21 18:53:53 +02:00
parent 4d06e647a2
commit a8c4894b8f
13 changed files with 151 additions and 70 deletions

View file

@ -149,7 +149,11 @@
</a> </a>
<a href="{% url 'backoffice:irc_overview' camp_slug=camp.slug %}" class="list-group-item"> <a href="{% url 'backoffice:irc_overview' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">IRC Overview</h4> <h4 class="list-group-item-heading">IRC Overview</h4>
<p class="list-group-item-text">Use this view to see all IRC channels for this years teams</p> <p class="list-group-item-text">Use this view to see IRC channels for this years teams</p>
</a>
<a href="{% url 'backoffice:shop_ticket_stats' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Webshop Ticket Stats</h4>
<p class="list-group-item-text">Use this view to see stats for tickets created from webshop sales. This includes tickets for people (adults and children, full week and oneday tickets), merchandise, village gear, parking and so on. This view does not include sponsor tickets.</p>
</a> </a>
{% endif %} {% endif %}

View file

@ -0,0 +1,37 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load static %}
{% load bornhack %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="h3">BackOffice - Ticket Stats for {{ camp.title }}</span>
</div>
<div class="panel-body">
<p class="lead">This view shows a list of all {{ camp.title }} ticket types with at least one ticket sold, along with the number of tickets and average price per type.</p>
<table class="table table-hover datatable">
<thead>
<tr>
<th>Ticket Type</th>
<th class="text-center">Number Sold</th>
<th class="text-right">Total Income</th>
<th class="text-right">Average Price</th>
</tr>
</thead>
<tbody>
{% for tt in tickettype_list %}
<tr>
<td>{{ tt.name }}</td>
<td class="text-center">{{ tt.shopticket_count }}</td>
<td class="text-right">{{ tt.total_price|floatformat:"2" }} DKK</td>
<td class="text-right">{{ tt.average_price|floatformat:"2" }} DKK</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
</div>
</div>
{% endblock content %}

View file

@ -85,6 +85,7 @@ from .views import (
RevenueListView, RevenueListView,
ScanTicketsView, ScanTicketsView,
ShopTicketOverview, ShopTicketOverview,
ShopTicketStatsView,
SpeakerDeleteView, SpeakerDeleteView,
SpeakerDetailView, SpeakerDetailView,
SpeakerListView, SpeakerListView,
@ -808,4 +809,8 @@ urlpatterns = [
"irc/", "irc/",
include([path("overview/", IrcOverView.as_view(), name="irc_overview")]), include([path("overview/", IrcOverView.as_view(), name="irc_overview")]),
), ),
path(
"shop_ticket_stats/",
include([path("", ShopTicketStatsView.as_view(), name="shop_ticket_stats")]),
),
] ]

View file

@ -12,6 +12,7 @@ from camps.mixins import CampViewMixin
from profiles.models import Profile from profiles.models import Profile
from shop.models import OrderProductRelation from shop.models import OrderProductRelation
from teams.models import Team from teams.models import Team
from tickets.models import TicketType
from utils.models import OutgoingEmail from utils.models import OutgoingEmail
from ..mixins import OrgaTeamPermissionMixin from ..mixins import OrgaTeamPermissionMixin
@ -178,6 +179,8 @@ class OutgoingEmailMassUpdateView(CampViewMixin, OrgaTeamPermissionMixin, FormVi
###################### ######################
# IRCBOT RELATED VIEWS # IRCBOT RELATED VIEWS
class IrcOverView(CampViewMixin, OrgaTeamPermissionMixin, ListView): class IrcOverView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
model = Team model = Team
template_name = "irc_overview.html" template_name = "irc_overview.html"
@ -191,3 +194,13 @@ class IrcOverView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
private_irc_channel_name__isnull=True, private_irc_channel_name__isnull=True,
) )
) )
##############
# TICKET STATS
class ShopTicketStatsView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
model = TicketType
template_name = "ticket_stats.html"
def get_queryset(self):
return TicketType.objects.with_price_stats().filter(camp=self.camp)

View file

@ -36,7 +36,7 @@ def handle_team_event(
# loop over routes (teams) for this eventtype # loop over routes (teams) for this eventtype
for team in eventtype.teams: for team in eventtype.teams:
logger.info("Handling eventtype %s for team %s" % (eventtype, team)) logger.debug("Handling eventtype %s for team %s" % (eventtype, team))
team_irc_notification( team_irc_notification(
team=team, team=team,
eventtype=eventtype, eventtype=eventtype,
@ -53,7 +53,7 @@ def team_irc_notification(team, eventtype, irc_message=None, irc_timeout=60):
""" """
Sends IRC notifications for events to team IRC channels Sends IRC notifications for events to team IRC channels
""" """
logger.info("Inside team_irc_notification, message %s" % irc_message) logger.debug("Inside team_irc_notification, message %s" % irc_message)
if not irc_message: if not irc_message:
logger.error("No IRC message found") logger.error("No IRC message found")
return return
@ -63,14 +63,16 @@ def team_irc_notification(team, eventtype, irc_message=None, irc_timeout=60):
return return
if not team.private_irc_channel_name or not team.private_irc_channel_bot: if not team.private_irc_channel_name or not team.private_irc_channel_bot:
logger.error("team %s does not have a private IRC channel" % team) logger.error(
f"team {team} does not have a private IRC channel, or does not have the bot in the channel"
)
return return
# send an IRC message to the the channel for this team # send an IRC message to the the channel for this team
add_irc_message( add_irc_message(
target=team.private_irc_channel_name, message=irc_message, timeout=60 target=team.private_irc_channel_name, message=irc_message, timeout=60
) )
logger.info("Added new IRC message for channel %s" % team.irc_channel_name) logger.debug(f"Added new IRC message for channel {team.private_irc_channel_name}")
def team_email_notification( def team_email_notification(

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2018-03-25 14:16
from __future__ import unicode_literals
from django.db import migrations
def create_eventtype(apps, schema_editor):
Type = apps.get_model("events", "Type")
Type.objects.create(name="ticket_stats")
class Migration(migrations.Migration):
dependencies = [("events", "0004_auto_20180403_1228")]
operations = [migrations.RunPython(create_eventtype)]

View file

@ -506,6 +506,13 @@ class Product(CreatedUpdatedModel, UUIDModel):
# If there is no stock defined the product is generally available. # If there is no stock defined the product is generally available.
return True return True
@property
def profit(self):
try:
return self.price - self.cost
except ValueError:
return 0
class OrderProductRelation(CreatedUpdatedModel): class OrderProductRelation(CreatedUpdatedModel):
order = models.ForeignKey("shop.Order", on_delete=models.PROTECT) order = models.ForeignKey("shop.Order", on_delete=models.PROTECT)

View file

@ -1,9 +1,6 @@
import logging import logging
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_save
from .signals import ticket_changed
logger = logging.getLogger("bornhack.%s" % __name__) logger = logging.getLogger("bornhack.%s" % __name__)
@ -12,9 +9,4 @@ class TicketsConfig(AppConfig):
name = "tickets" name = "tickets"
def ready(self): def ready(self):
# connect the post_save signal, including a dispatch_uid to prevent it being called multiple times in corner cases pass
post_save.connect(
ticket_changed,
sender="tickets.ShopTicket",
dispatch_uid="shopticket_save_signal",
)

View file

View file

@ -0,0 +1,49 @@
# coding: utf-8
import logging
from django.core.management.base import BaseCommand
from camps.models import Camp
from events.handler import handle_team_event
from .models import TicketType
logger = logging.getLogger("bornhack.%s" % __name__)
class Command(BaseCommand):
args = "none"
help = "Post ticket stats to IRC"
def add_arguments(self, parser):
parser.add_argument(
"campslug",
type=str,
help="The slug of the camp to process",
)
def handle(self, *args, **options):
camp = Camp.objects.get(slug=options["campslug"])
output = self.format_shop_ticket_stats_for_irc(camp)
for line in output:
handle_team_event("ticket_stats", line)
def format_shop_ticket_stats_for_irc(camp):
"""Get stats for all tickettypes and return a list of strings max 200 chars long."""
tickettypes = TicketType.objects.with_price_stats().filter(camp=camp)
output = []
# loop over tickettypes and generate lines of max 200 chars
for line in [
f"{tt.name}: {tt.total_price} DKK/{tt.shopticket_count}={round(tt.average_price,2)} DKK"
for tt in tickettypes
]:
if not output:
# this is the start of the output
output.append(line)
elif len(output[-1]) + len(line) < 200:
# add this line to the latest line of output
output[-1] += f" ::: {line}"
else:
# add a new line of output to avoid going over linelength of 200
output.append(line)
return output

View file

@ -16,8 +16,20 @@ from utils.pdf import generate_pdf_letter
logger = logging.getLogger("bornhack.%s" % __name__) logger = logging.getLogger("bornhack.%s" % __name__)
# TicketType can be full week, one day. etc. class TicketTypeManager(models.Manager):
def with_price_stats(self):
return (
self.annotate(shopticket_count=models.Count("shopticket"))
.annotate(average_price=models.Avg("shopticket__product__price"))
.annotate(total_price=models.Sum("shopticket__product__price"))
.exclude(shopticket_count=0)
.order_by("total_price")
)
# TicketType can be full week, one day, cabins, parking, merch, hax, massage, etc.
class TicketType(CampRelatedModel, UUIDModel): class TicketType(CampRelatedModel, UUIDModel):
objects = TicketTypeManager()
name = models.TextField() name = models.TextField()
camp = models.ForeignKey("camps.Camp", on_delete=models.PROTECT) camp = models.ForeignKey("camps.Camp", on_delete=models.PROTECT)
includes_badge = models.BooleanField(default=False) includes_badge = models.BooleanField(default=False)

View file

@ -1,55 +0,0 @@
from datetime import datetime
from django.db.models import Count
from events.handler import handle_team_event
def ticket_changed(sender, instance, created, **kwargs):
"""
This signal is called every time a ShopTicket is saved
"""
# only trigger an event when a new ticket is created
if not created:
return
# get ticket stats
from .models import ShopTicket
# TODO: this is nasty, get the prefix some other way
ticket_prefix = "BornHack {}".format(datetime.now().year)
stats = ", ".join(
[
"{}: {}".format(
tickettype["product__name"].replace("{} ".format(ticket_prefix), ""),
tickettype["total"],
)
for tickettype in ShopTicket.objects.filter(
product__name__startswith=ticket_prefix
)
.exclude(product__name__startswith="{} One Day".format(ticket_prefix))
.values("product__name")
.annotate(total=Count("product__name"))
.order_by("-total")
]
)
onedaystats = ShopTicket.objects.filter(
product__name__startswith="{} One Day Ticket".format(ticket_prefix)
).count()
onedaychildstats = ShopTicket.objects.filter(
product__name__startswith="{} One Day Children".format(ticket_prefix)
).count()
# queue the messages
handle_team_event(
eventtype="ticket_created", irc_message="%s sold!" % instance.product.name
)
# limit this one to a length of 200 because IRC is nice
handle_team_event(
eventtype="ticket_created",
irc_message="Totals: {}, 1day: {}, 1day child: {}".format(
stats, onedaystats, onedaychildstats
)[:200],
)