diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html index 8203164d..0b109fbb 100644 --- a/src/backoffice/templates/index.html +++ b/src/backoffice/templates/index.html @@ -149,7 +149,11 @@

IRC Overview

-

Use this view to see all IRC channels for this years teams

+

Use this view to see IRC channels for this years teams

+
+ +

Webshop Ticket Stats

+

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.

{% endif %} diff --git a/src/backoffice/templates/ticket_stats.html b/src/backoffice/templates/ticket_stats.html new file mode 100644 index 00000000..e0cc2540 --- /dev/null +++ b/src/backoffice/templates/ticket_stats.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load static %} +{% load bornhack %} + +{% block content %} +
+
+ BackOffice - Ticket Stats for {{ camp.title }} +
+
+

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.

+ + + + + + + + + + + {% for tt in tickettype_list %} + + + + + + + {% endfor %} + +
Ticket TypeNumber SoldTotal IncomeAverage Price
{{ tt.name }}{{ tt.shopticket_count }}{{ tt.total_price|floatformat:"2" }} DKK{{ tt.average_price|floatformat:"2" }} DKK
+ Backoffice +

+
+
+{% endblock content %} diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index 1348998e..62dd3091 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -85,6 +85,7 @@ from .views import ( RevenueListView, ScanTicketsView, ShopTicketOverview, + ShopTicketStatsView, SpeakerDeleteView, SpeakerDetailView, SpeakerListView, @@ -808,4 +809,8 @@ urlpatterns = [ "irc/", include([path("overview/", IrcOverView.as_view(), name="irc_overview")]), ), + path( + "shop_ticket_stats/", + include([path("", ShopTicketStatsView.as_view(), name="shop_ticket_stats")]), + ), ] diff --git a/src/backoffice/views/orga.py b/src/backoffice/views/orga.py index 0c73f239..0c402fd5 100644 --- a/src/backoffice/views/orga.py +++ b/src/backoffice/views/orga.py @@ -12,6 +12,7 @@ from camps.mixins import CampViewMixin from profiles.models import Profile from shop.models import OrderProductRelation from teams.models import Team +from tickets.models import TicketType from utils.models import OutgoingEmail from ..mixins import OrgaTeamPermissionMixin @@ -178,6 +179,8 @@ class OutgoingEmailMassUpdateView(CampViewMixin, OrgaTeamPermissionMixin, FormVi ###################### # IRCBOT RELATED VIEWS + + class IrcOverView(CampViewMixin, OrgaTeamPermissionMixin, ListView): model = Team template_name = "irc_overview.html" @@ -191,3 +194,13 @@ class IrcOverView(CampViewMixin, OrgaTeamPermissionMixin, ListView): 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) diff --git a/src/events/handler.py b/src/events/handler.py index cbe5ba2d..a19fddf0 100644 --- a/src/events/handler.py +++ b/src/events/handler.py @@ -36,7 +36,7 @@ def handle_team_event( # loop over routes (teams) for this eventtype 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=team, 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 """ - logger.info("Inside team_irc_notification, message %s" % irc_message) + logger.debug("Inside team_irc_notification, message %s" % irc_message) if not irc_message: logger.error("No IRC message found") return @@ -63,14 +63,16 @@ def team_irc_notification(team, eventtype, irc_message=None, irc_timeout=60): return 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 # send an IRC message to the the channel for this team add_irc_message( 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( diff --git a/src/events/migrations/0005_create_another_eventtype.py b/src/events/migrations/0005_create_another_eventtype.py new file mode 100644 index 00000000..6177379a --- /dev/null +++ b/src/events/migrations/0005_create_another_eventtype.py @@ -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)] diff --git a/src/shop/models.py b/src/shop/models.py index 69f22629..211df9d8 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -506,6 +506,13 @@ class Product(CreatedUpdatedModel, UUIDModel): # If there is no stock defined the product is generally available. return True + @property + def profit(self): + try: + return self.price - self.cost + except ValueError: + return 0 + class OrderProductRelation(CreatedUpdatedModel): order = models.ForeignKey("shop.Order", on_delete=models.PROTECT) diff --git a/src/tickets/apps.py b/src/tickets/apps.py index 89880986..07fc0461 100644 --- a/src/tickets/apps.py +++ b/src/tickets/apps.py @@ -1,9 +1,6 @@ import logging from django.apps import AppConfig -from django.db.models.signals import post_save - -from .signals import ticket_changed logger = logging.getLogger("bornhack.%s" % __name__) @@ -12,9 +9,4 @@ class TicketsConfig(AppConfig): name = "tickets" def ready(self): - # connect the post_save signal, including a dispatch_uid to prevent it being called multiple times in corner cases - post_save.connect( - ticket_changed, - sender="tickets.ShopTicket", - dispatch_uid="shopticket_save_signal", - ) + pass diff --git a/src/tickets/management/__init__.py b/src/tickets/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tickets/management/commands/__init__.py b/src/tickets/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tickets/management/commands/post_shop_ticket_stats_to_irc.py b/src/tickets/management/commands/post_shop_ticket_stats_to_irc.py new file mode 100644 index 00000000..cefd4ecb --- /dev/null +++ b/src/tickets/management/commands/post_shop_ticket_stats_to_irc.py @@ -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 diff --git a/src/tickets/models.py b/src/tickets/models.py index 1c7661e0..767e4271 100644 --- a/src/tickets/models.py +++ b/src/tickets/models.py @@ -16,8 +16,20 @@ from utils.pdf import generate_pdf_letter 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): + objects = TicketTypeManager() name = models.TextField() camp = models.ForeignKey("camps.Camp", on_delete=models.PROTECT) includes_badge = models.BooleanField(default=False) diff --git a/src/tickets/signals.py b/src/tickets/signals.py deleted file mode 100644 index 40ba19db..00000000 --- a/src/tickets/signals.py +++ /dev/null @@ -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], - )