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:
parent
4d06e647a2
commit
a8c4894b8f
|
@ -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 %}
|
||||||
|
|
||||||
|
|
37
src/backoffice/templates/ticket_stats.html
Normal file
37
src/backoffice/templates/ticket_stats.html
Normal 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 %}
|
|
@ -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")]),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
15
src/events/migrations/0005_create_another_eventtype.py
Normal file
15
src/events/migrations/0005_create_another_eventtype.py
Normal 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)]
|
|
@ -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)
|
||||||
|
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
0
src/tickets/management/__init__.py
Normal file
0
src/tickets/management/__init__.py
Normal file
0
src/tickets/management/commands/__init__.py
Normal file
0
src/tickets/management/commands/__init__.py
Normal 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
|
|
@ -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)
|
||||||
|
|
|
@ -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],
|
|
||||||
)
|
|
Loading…
Reference in a new issue