diff --git a/scripts/schemagif.sh b/scripts/schemagif.sh new file mode 100755 index 00000000..41407832 --- /dev/null +++ b/scripts/schemagif.sh @@ -0,0 +1,69 @@ +#!/bin/sh +################################# +# Loop over migrations in the +# BornHack website project, apply +# one by one, and run +# postgresql_autodoc for each. +# +# Use the generated .dot files +# to generate PNGs and watermark +# the PNG with the migration name. +# +# Finally use $whatever to combine +# all the PNGs to an animation and +# marvel at the ingenuity of Man. +# +# This scripts makes a million +# assumptions about the local env. +# and installed packages. Enjoy! +# +# /Tykling, April 2018 +################################# +#set -x + +# warn the user +read -p "WARNING: This scripts deletes and recreates the local pg database named bornhackdb several times. Continue? " + +# wipe database +sudo su postgres -c "dropdb bornhackdb; createdb -O bornhack bornhackdb" + +# run migrate with --fake to get list of migrations +MIGRATIONS=$(python manage.py migrate --fake | grep FAKED | cut -d " " -f 4 | cut -d "." -f 1-2) + +# wipe database again +sudo su postgres -c "dropdb bornhackdb; createdb -O bornhack bornhackdb" + +# create output folder +sudo rm -rf postgres_autodoc +mkdir postgres_autodoc +sudo chown postgres:postgres postgres_autodoc + +# loop over migrations +COUNTER=0 +for MIGRATION in $MIGRATIONS; do + COUNTER=$(( $COUNTER + 1 )) + ALFACOUNTER=$(printf "%04d" $COUNTER) + + echo "processing migration #${COUNTER}: $MIG" + APP=$(echo $MIGRATION | cut -d "." -f 1) + MIG=$(echo $MIGRATION | cut -d "." -f 2) + + echo "--- running migration: APP: $APP MIGRATION: $MIG ..." + python manage.py migrate --no-input $APP $MIG + + echo "--- running postgresql_autodoc and dot..." + cd postgres_autodoc + sudo su postgres -c "mkdir ${ALFACOUNTER}-$MIGRATION" + cd "${ALFACOUNTER}-${MIGRATION}" + # run postgresql_autodoc + sudo su postgres -c "postgresql_autodoc -d bornhackdb" + # create PNG from .dot file + sudo su postgres -c "dot -Tpng bornhackdb.dot -o bornhackdb.png" + # create watermark image with migration name as white on black text + sudo su postgres -c "convert -background none -undercolor black -fill white -font DejaVu-Sans-Mono-Bold -size 5316x4260 -pointsize 72 -gravity SouthEast label:${ALFACOUNTER}-${MIGRATION} background.png" + # combine the images + sudo su postgres -c "composite -gravity center bornhackdb.png background.png final.png" + cd .. + cd .. +done + diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index 6dd63947..8f2bc189 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -36,6 +36,9 @@ CAMP_REDIRECT_PERCENT=25 ### changes below here are only needed for production # email settings +{% if not django_email_realworld | default(False) %} +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +{% endif %} EMAIL_HOST='{{ django_email_host }}' EMAIL_PORT={{ django_email_port }} EMAIL_HOST_USER='{{ django_email_user }}' @@ -76,13 +79,13 @@ SCHEDULE_EVENT_NOTIFICATION_MINUTES=10 # irc bot settings IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS=10 IRCBOT_NICK='{{ django_ircbot_nickname }}' +IRCBOT_CHANSERV_MASK='{{ django_ircbot_chanserv_mask }}' +IRCBOT_NICKSERV_MASK='{{ django_ircbot_nickserv_mask }}' IRCBOT_NICKSERV_PASSWORD='{{ django_ircbot_nickserv_password }}' +IRCBOT_NICKSERV_EMAIL='{{ django_ircbot_nickserv_email }}' +IRCBOT_NICKSERV_IDENTIFY_STRING="This nickname is registered. Please choose a different nickname, or identify via \x02/msg NickServ identify \x02." IRCBOT_SERVER_HOSTNAME='{{ django_ircbot_server }}' IRCBOT_SERVER_PORT=6697 IRCBOT_SERVER_USETLS=True -IRCBOT_CHANNELS={ - 'default': '{{ django_ircbot_default_channel }}', - 'orga': '{{ django_ircbot_orga_channel }}', - 'public': '{{ django_ircbot_public_channel }}' -} +IRCBOT_PUBLIC_CHANNEL='{{ django_ircbot_public_channel }}' diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index 1fa0e4f8..560455b1 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -45,6 +45,7 @@ INSTALLED_APPS = [ 'tickets', 'bar', 'backoffice', + 'events', 'allauth', 'allauth.account', diff --git a/src/camps/migrations/0023_camp_shortslug.py b/src/camps/migrations/0023_camp_shortslug.py new file mode 100644 index 00000000..27f715aa --- /dev/null +++ b/src/camps/migrations/0023_camp_shortslug.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:44 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0022_camp_colour'), + ] + + operations = [ + migrations.AddField( + model_name='camp', + name='shortslug', + field=models.SlugField(blank=True, help_text='Abbreviated version of the slug. Used in IRC channel names and other places with restricted name length.', verbose_name='Short Slug'), + ), + ] diff --git a/src/camps/migrations/0024_populate_camp_shortslugs.py b/src/camps/migrations/0024_populate_camp_shortslugs.py new file mode 100644 index 00000000..2033c633 --- /dev/null +++ b/src/camps/migrations/0024_populate_camp_shortslugs.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:45 +from __future__ import unicode_literals + +from django.db import migrations + +def populate_camp_shortslugs(apps, schema_editor): + Camp = apps.get_model('camps', 'Camp') + for camp in Camp.objects.all(): + if not camp.shortslug: + camp.shortslug = camp.slug + camp.save() + +class Migration(migrations.Migration): + dependencies = [ + ('camps', '0023_camp_shortslug'), + ] + + operations = [ + migrations.RunPython(populate_camp_shortslugs), + ] + diff --git a/src/camps/migrations/0025_auto_20180318_1250.py b/src/camps/migrations/0025_auto_20180318_1250.py new file mode 100644 index 00000000..d7c596e1 --- /dev/null +++ b/src/camps/migrations/0025_auto_20180318_1250.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0024_populate_camp_shortslugs'), + ] + + operations = [ + migrations.AlterField( + model_name='camp', + name='shortslug', + field=models.SlugField(help_text='Abbreviated version of the slug. Used in IRC channel names and other places with restricted name length.', verbose_name='Short Slug'), + ), + ] diff --git a/src/camps/models.py b/src/camps/models.py index e9886edd..d390aa13 100644 --- a/src/camps/models.py +++ b/src/camps/models.py @@ -34,6 +34,11 @@ class Camp(CreatedUpdatedModel, UUIDModel): help_text='The url slug to use for this camp' ) + shortslug = models.SlugField( + verbose_name='Short Slug', + help_text='Abbreviated version of the slug. Used in IRC channel names and other places with restricted name length.', + ) + buildup = DateTimeRangeField( verbose_name='Buildup Period', help_text='The camp buildup period.', @@ -189,9 +194,3 @@ class Camp(CreatedUpdatedModel, UUIDModel): else: return True - @property - def teams(self): - """ Return a queryset with all teams under all TeamAreas under this Camp """ - from teams.models import Team - return Team.objects.filter(area__in=self.teamareas.all()) - diff --git a/src/events/__init__.py b/src/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/events/admin.py b/src/events/admin.py new file mode 100644 index 00000000..b0d4fd2d --- /dev/null +++ b/src/events/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import Type, Routing + +@admin.register(Type) +class TypeAdmin(admin.ModelAdmin): + pass + +@admin.register(Routing) +class RoutingAdmin(admin.ModelAdmin): + pass + + diff --git a/src/events/apps.py b/src/events/apps.py new file mode 100644 index 00000000..38546443 --- /dev/null +++ b/src/events/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + name = 'events' diff --git a/src/events/handler.py b/src/events/handler.py new file mode 100644 index 00000000..b38cb152 --- /dev/null +++ b/src/events/handler.py @@ -0,0 +1,81 @@ +from django.utils import timezone +from datetime import timedelta +from ircbot.utils import add_irc_message +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +def handle_team_event(eventtype, irc_message=None, irc_timeout=60, email_template=None, email_formatdict=None): + """ + This method is our basic event handler. + The type of event determines which teams receive notifications. + TODO: Add some sort of priority to messages + """ + logger.info("Inside handle_team_event, eventtype %s" % eventtype) + + # get event type from database + from .models import Type + try: + eventtype = Type.objects.get(name=eventtype) + except Type.DoesNotExist: + # unknown event type, do nothing + logger.error("Unknown eventtype %s" % eventtype) + return + + if not eventtype.teams: + # no routes found for this eventtype, do nothing + logger.error("No routes round for eventtype %s" % eventtype) + return + + # loop over routes (teams) for this eventtype + for team in eventtype.teams: + logger.info("Handling eventtype %s for team %s" % (eventtype, team)) + team_irc_notification(team=team, eventtype=eventtype, irc_message=irc_message, irc_timeout=irc_timeout) + team_email_notification(team=team, eventtype=eventtype, email_template=None, email_formatdict=None) + # handle any future notification types here.. + + +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) + if not irc_message: + logger.error("No IRC message found") + return + + if not eventtype.irc_notification: + logger.error("IRC notifications not enabled for eventtype %s" % eventtype) + return + + if not team.irc_channel or not team.irc_channel_name: + logger.error("team %s is not IRC enabled" % team) + return + + # send an IRC message to the the channel for this team + add_irc_message( + target=team.irc_channel_name, + message=irc_message, + timeout=60 + ) + logger.info("Added new IRC message for channel %s" % team.irc_channel_name) + + +def team_email_notification(team, eventtype, email_template=None, email_formatdict=None): + """ + Sends email notifications for events to team mailinglists (if possible, + otherwise directly to the team responsibles) + """ + if not email_template or not email_formatdict or not eventtype.email_notification: + # no email message found, or email notifications are not enabled for this event type + return + + if team.mailing_list: + # send notification to the team mailing list + recipient_list = [team.mailing_list] + else: + # no team mailinglist, send to the team responsibles instead + recipient_list = [resp.email for resp in team.responsible_members.all()] + + # TODO: actually send the email here + diff --git a/src/events/migrations/0001_initial.py b/src/events/migrations/0001_initial.py new file mode 100644 index 00000000..3c3c00f8 --- /dev/null +++ b/src/events/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 13:16 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('teams', '0025_auto_20180318_1318'), + ] + + operations = [ + migrations.CreateModel( + name='Routing', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Type', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.TextField(help_text='The type of event', unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='routing', + name='eventtype', + field=models.ForeignKey(help_text='The type of event to route', on_delete=django.db.models.deletion.PROTECT, related_name='eventroutes', to='events.Type'), + ), + migrations.AddField( + model_name='routing', + name='team', + field=models.ForeignKey(help_text='The team which should receive events of this type.', on_delete=django.db.models.deletion.PROTECT, related_name='eventroutes', to='teams.Team'), + ), + ] diff --git a/src/events/migrations/0002_create_eventtype.py b/src/events/migrations/0002_create_eventtype.py new file mode 100644 index 00000000..a84eb0ef --- /dev/null +++ b/src/events/migrations/0002_create_eventtype.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 13:18 +from __future__ import unicode_literals + +from django.db import migrations + +def create_eventtypes(apps, schema_editor): + Type = apps.get_model('events', 'Type') + Type.objects.create(name='public_credit_name_changed') + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_eventtypes), + ] + diff --git a/src/events/migrations/0003_create_another_eventtype.py b/src/events/migrations/0003_create_another_eventtype.py new file mode 100644 index 00000000..1259799c --- /dev/null +++ b/src/events/migrations/0003_create_another_eventtype.py @@ -0,0 +1,20 @@ +# -*- 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_created') + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0002_create_eventtype'), + ] + + operations = [ + migrations.RunPython(create_eventtype), + ] diff --git a/src/events/migrations/0004_auto_20180403_1228.py b/src/events/migrations/0004_auto_20180403_1228.py new file mode 100644 index 00000000..23fe4e15 --- /dev/null +++ b/src/events/migrations/0004_auto_20180403_1228.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-03 10:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_create_another_eventtype'), + ] + + operations = [ + migrations.AddField( + model_name='type', + name='email_notification', + field=models.BooleanField(default=False, help_text='Check to send email notifications for this type of event.'), + ), + migrations.AddField( + model_name='type', + name='irc_notification', + field=models.BooleanField(default=False, help_text='Check to send IRC notifications for this type of event.'), + ), + ] diff --git a/src/events/migrations/__init__.py b/src/events/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/events/models.py b/src/events/models.py new file mode 100644 index 00000000..74ba03a8 --- /dev/null +++ b/src/events/models.py @@ -0,0 +1,61 @@ +from django.db import models +from utils.models import CreatedUpdatedModel +from teams.models import Team + +class Type(CreatedUpdatedModel): + """ + The events.Type model contains different types of system events which can happen. + New event types should be added in data migrations. + The following types are currently used in the codebase: + - ticket_created: Whenever a new ShopTicket is created + - public_credit_name_changed: Whenever a user changes public_credit_name in the profile + """ + name = models.TextField( + unique=True, + help_text='The type of event' + ) + + irc_notification = models.BooleanField( + default=False, + help_text='Check to send IRC notifications for this type of event.', + ) + + email_notification = models.BooleanField( + default=False, + help_text='Check to send email notifications for this type of event.', + ) + def __str__(self): + return self.name + + @property + def teams(self): + """ + This property returns a queryset with all the teams that should receive this type of events + """ + team_ids = Routing.objects.filter(eventtype=self).values_list('team', flat=True) + return Team.objects.filter(pk__in=team_ids) + + +class Routing(CreatedUpdatedModel): + """ + The events.Routing model contains routings for system events. + Add a new entry to route events of a certain type to a team. + Several teams can receive the same type of event. + """ + eventtype = models.ForeignKey( + 'events.Type', + related_name='eventroutes', + on_delete=models.PROTECT, + help_text='The type of event to route', + ) + + team = models.ForeignKey( + 'teams.Team', + related_name='eventroutes', + on_delete=models.PROTECT, + help_text='The team which should receive events of this type.' + ) + + def __str__(self): + return "%s -> %s" % (self.eventtype, self.team) + diff --git a/src/events/tests.py b/src/events/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/src/events/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/src/events/views.py b/src/events/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/src/events/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/src/ircbot/irc3module.py b/src/ircbot/irc3module.py index 97844016..e8c461a3 100644 --- a/src/ircbot/irc3module.py +++ b/src/ircbot/irc3module.py @@ -1,7 +1,9 @@ -import irc3 +import irc3, re from ircbot.models import OutgoingIrcMessage +from teams.models import Team, TeamMember from django.conf import settings from django.utils import timezone +from events.models import Routing import logging logger = logging.getLogger("bornhack.%s" % __name__) @@ -26,6 +28,12 @@ class Plugin(object): """triggered after the server sent the MOTD (require core plugin)""" logger.debug("inside server_ready(), kwargs: %s" % kwargs) + logger.info("Identifying with %s" % settings.IRCBOT_NICKSERV_MASK) + self.bot.privmsg(settings.IRCBOT_NICKSERV_MASK, "identify %s %s" % (settings.IRCBOT_NICK, settings.IRCBOT_NICKSERV_PASSWORD)) + + logger.info("Calling self.bot.do_stuff() in %s seconds.." % settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS) + self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.do_stuff) + def connection_lost(self, **kwargs): """triggered when connection is lost""" @@ -36,9 +44,6 @@ class Plugin(object): """triggered when connection is up""" logger.debug("inside connection_made(), kwargs: %s" % kwargs) - # wait 5 secs before starting the loop to check for outgoing messages - self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.get_outgoing_messages) - ############################################################################################### ### decorated irc3 event methods @@ -48,16 +53,38 @@ class Plugin(object): """triggered when there is a join part or quit on a channel the bot is in""" logger.debug("inside on_join_part_quit(), kwargs: %s" % kwargs) + # TODO: on part or quit check if the bot is the only remaining member of a channel, + # if so, check if the channel should be managed, and if so, part and join the channel + # to gain @ and register with ChanServ + + + @irc3.event(irc3.rfc.JOIN) + def on_join(self, mask, channel, **kwargs): + """Triggered when a channel is joined by someone, including the bot itself""" + if mask.nick == self.bot.nick: + # the bot just joined a channel + if channel in self.get_managed_team_channels(): + logger.debug("Just joined a channel I am supposed to be managing, asking ChanServ for info about %s" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "info %s" % channel) + return + @irc3.event(irc3.rfc.PRIVMSG) def on_privmsg(self, **kwargs): """triggered when a privmsg is sent to the bot or to a channel the bot is in""" logger.debug("inside on_privmsg(), kwargs: %s" % kwargs) - # nickserv - if kwargs['mask'] == "NickServ!NickServ@services.baconsvin.org" and kwargs['event'] == "NOTICE" and kwargs['data'] == "This nickname is registered. Please choose a different nickname, or identify via \x02/msg NickServ identify \x02.": - logger.info("Nickserv identify needed, fixing...") - self.bot.privmsg("NickServ@services.baconsvin.org", "identify %s %s" % (settings.IRCBOT_NICK, settings.IRCBOT_NICKSERV_PASSWORD)) + # we only handle NOTICEs for now + if kwargs['event'] != "NOTICE": + return + + # check if this is a message from nickserv + if kwargs['mask'] == "NickServ!%s" % settings.IRCBOT_NICKSERV_MASK: + self.bot.handle_nickserv_privmsg(**kwargs) + + # check if this is a message from chanserv + if kwargs['mask'] == "ChanServ!%s" % settings.IRCBOT_CHANSERV_MASK: + self.bot.handle_chanserv_privmsg(**kwargs) @irc3.event(irc3.rfc.KICK) @@ -68,13 +95,28 @@ class Plugin(object): ############################################################################################### ### custom irc3 methods + @irc3.extend + def do_stuff(self): + """ + Main periodic method called every N seconds. + """ + #logger.debug("inside do_stuff()") + + # call the methods we need to + self.bot.check_irc_channels() + self.bot.fix_missing_acls() + self.bot.get_outgoing_messages() + + # schedule a call of this function again in N seconds + self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.do_stuff) + + @irc3.extend def get_outgoing_messages(self): """ This method gets unprocessed OutgoingIrcMessage objects and attempts to send them to the target channel. Messages are skipped if the bot is not in the channel. """ - #logger.debug("inside get_outgoing_messages()") for msg in OutgoingIrcMessage.objects.filter(processed=False).order_by('created'): logger.info("processing irc message to %s: %s" % (msg.target, msg.message)) # if this message expired mark it as expired and processed without doing anything @@ -99,7 +141,198 @@ class Plugin(object): else: logger.warning("skipping message to %s" % msg.target) - # call this function again in X seconds - self.bot.loop.call_later(settings.IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS, self.bot.get_outgoing_messages) + + ############################################################################################### + ### irc channel methods + + @irc3.extend + def check_irc_channels(self): + """ + Compare the list of IRC channels the bot is currently in with the list of IRC channels the bot is supposed to be in. + Join or part channels as needed. + """ + desired_channel_list = list(set(list(self.get_managed_team_channels()) + list(self.get_unmanaged_team_channels()) + [settings.IRCBOT_PUBLIC_CHANNEL])) + #logger.debug("Inside check_irc_channels(), desired_channel_list is: %s and self.bot.channels is: %s" % (desired_channel_list, self.bot.channels.keys())) + + # loop over desired_channel_list, join as needed + for channel in desired_channel_list: + if channel not in self.bot.channels: + logger.debug("I should be in %s but I am not, attempting to join..." % channel) + self.bot.join(channel) + + # loop over self.bot.channels, part as needed + for channel in self.bot.channels: + if channel not in desired_channel_list: + logger.debug("I am in %s but I shouldn't be, parting..." % channel) + self.bot.part(channel, "I am no longer needed here") + @irc3.extend + def get_managed_team_channels(self): + """ + Return a unique list of team IRC channels which the bot is supposed to be managing. + """ + return Team.objects.filter( + irc_channel=True, + irc_channel_managed=True + ).values_list("irc_channel_name", flat=True) + + + @irc3.extend + def get_unmanaged_team_channels(self): + """ + Return a unique list of team IRC channels which the bot is not supposed to be managing. + """ + return Team.objects.filter( + irc_channel=True, + irc_channel_managed=False + ).values_list("irc_channel_name", flat=True) + + + @irc3.extend + def setup_private_channel(self, team): + """ + Configures a private team IRC channel by setting modes and adding all members to ACL + """ + # basic private channel modes + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s mlock +inpst" % team.irc_channel_name) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s SECURE on" % team.irc_channel_name) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "SET %s RESTRICTED on" % team.irc_channel_name) + + # add the bot to the ACL + self.bot.add_user_to_team_channel_acl( + username=settings.IRCBOT_NICK, + channel=team.irc_channel_name + ) + + # add all members to the acl + for membership in team.memberships.all(): + if membership.approved and membership.user.profile.nickserv_username: + self.bot.add_user_to_team_channel_acl( + username=membership.user.profile.nickserv_username, + channel=membership.team.irc_channel_name, + ) + + # mark membership as irc_channel_acl_ok=True and save + membership.irc_channel_acl_ok=True + membership.save() + + + @irc3.extend + def add_user_to_team_channel_acl(self, username, channel): + """ + Add user to team IRC channel ACL + """ + # set autoop for this username + self.bot.privmsg( + settings.IRCBOT_CHANSERV_MASK, + "flags %(channel)s %(user)s +oO" % { + 'channel': channel, + 'user': username, + }, + ) + + # also add autoinvite for this username + self.bot.mode(channel, '+I', '$a:%s' % username) + + + @irc3.extend + def fix_missing_acls(self): + """ + Called periodically by do_stuff() + Loops over TeamMember objects and adds and removes ACL entries as needed + """ + missing_acls = TeamMember.objects.filter( + team__irc_channel=True, + team__irc_channel_managed=True, + team__irc_channel_private=True, + irc_channel_acl_ok=False + ).exclude( + user__profile__nickserv_username='' + ) + + if not missing_acls: + return + + logger.debug("Found %s memberships which need IRC ACL fixing.." % missing_acls.count()) + for membership in missing_acls: + self.bot.add_user_to_team_channel_acl( + username=membership.user.profile.nickserv_username, + channel=membership.team.irc_channel_name, + ) + # mark membership as irc_channel_acl_ok=True and save + membership.irc_channel_acl_ok=True + membership.save() + + + ############################################################################################### + ### services (ChanServ & NickServ) methods + + @irc3.extend + def handle_chanserv_privmsg(self, **kwargs): + """ + Handle messages from ChanServ on networks with Services. + """ + logger.debug("Got a message from ChanServ") + + ############################################### + # handle "Channel \x02#example\x02 is not registered." message + ############################################### + match = re.compile("Channel (#[a-zA-Z0-9-]+) is not registered.").match(kwargs['data'].replace("\x02", "")) + if match: + # the irc channel is not registered + channel = match.group(1) + # get a list of the channels we are supposed to be managing + if channel in self.bot.get_managed_team_channels(): + # we want to register this channel! but we can only do so if we have a @ in the channel + if self.bot.nick in self.bot.channels[channel].modes['@']: + logger.debug("ChanServ says channel %s is not registered, bot is supposed to be managing this channel, registering it with chanserv" % channel) + self.bot.privmsg(settings.IRCBOT_CHANSERV_MASK, "register %s" % channel) + else: + logger.debug("ChanServ says channel %s is not registered, bot is supposed to be managing this channel, but the bot cannot register without @ in the channel" % channel) + self.bot.privmsg(channel, "I need @ before I can register this channel with ChanServ") + return + + ############################################### + # handle "\x02#example\x02 is now registered to \x02tykbhdev\x02" message + ############################################### + match = re.compile("(#[a-zA-Z0-9-]+) is now registered to ([a-zA-Z0-9-]+)\\.").match(kwargs['data'].replace("\x02", "")) + if match: + # the irc channel is now registered + channel = match.group(1) + botnick = match.group(2) + logger.debug("Channel %s was registered with ChanServ, looking up Team..." % channel) + + # if this channel is a private team IRC channel set modes and add initial ACL + try: + team = Team.objects.get(irc_channel_name=channel) + except Team.DoesNotExist: + logger.debug("Unable to find Team matching IRC channel %s" % channel) + return + + if not team.irc_channel_private: + # this channel is not private, no mode change and ACL needed + return + + # set channel modes and ACL + self.bot.setup_private_channel(team) + return + + logger.debug("Unhandled ChanServ message: %s" % kwargs['data']) + + + @irc3.extend + def handle_nickserv_privmsg(self, **kwargs): + """th + Handles messages from NickServ on networks with Services. + """ + logger.debug("Got a message from NickServ") + + # handle "\x02botnick\x02 is not a registered nickname." message + if kwargs['data'] == '\x02%s\x02 is not a registered nickname.' % self.bot.nick: + # the bots nickname is not registered, register new account with nickserv + self.bot.privmsg(settings.IRCBOT_NICKSERV_MASK, "register %s %s" % (settings.IRCBOT_NICKSERV_PASSWORD, settings.IRCBOT_NICKSERV_EMAIL)) + return + + logger.debug("Unhandled NickServ message: %s" % kwargs['data']) + diff --git a/src/ircbot/ircworker.py b/src/ircbot/ircworker.py index b0afd363..f3864d8d 100644 --- a/src/ircbot/ircworker.py +++ b/src/ircbot/ircworker.py @@ -1,6 +1,7 @@ from django.conf import settings import logging import irc3 +from events.models import Routing logging.basicConfig(level=logging.INFO) logger = logging.getLogger('bornhack.%s' % __name__) @@ -9,9 +10,13 @@ def do_work(): """ Run irc3 module code, wait for events on IRC and wait for messages in OutgoingIrcMessage """ + if hasattr(settings, 'IRCBOT_CHANNELS'): + logger.error("settings.IRCBOT_CHANNELS is deprecated. Please define settings.IRCBOT_PUBLIC_CHANNEL and use team channels for the rest.") + return False + config = { 'nick': settings.IRCBOT_NICK, - 'autojoins': list(set(settings.IRCBOT_CHANNELS.values())), + 'autojoins': [], 'host': settings.IRCBOT_SERVER_HOSTNAME, 'port': settings.IRCBOT_SERVER_PORT, 'ssl': settings.IRCBOT_SERVER_USETLS, diff --git a/src/ircbot/utils.py b/src/ircbot/utils.py new file mode 100644 index 00000000..dfb27b69 --- /dev/null +++ b/src/ircbot/utils.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.utils import timezone +from datetime import timedelta +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +def add_irc_message(target, message, timeout=10): + """ + Convenience function for adding OutgoingIrcMessage objects. + Defaults to a message timeout of 10 minutes + """ + from .models import OutgoingIrcMessage + OutgoingIrcMessage.objects.create( + target=target, + message=message, + timeout=timezone.now()+timedelta(minutes=timeout), + ) + + + diff --git a/src/people/templates/people.html b/src/people/templates/people.html index aaa2e70c..076b59bd 100644 --- a/src/people/templates/people.html +++ b/src/people/templates/people.html @@ -21,6 +21,7 @@ People | {{ block.super }} Team Name + Team Responsible Team Members @@ -31,19 +32,20 @@ People | {{ block.super }} {{ team.name }} Team - {% if team.anoncount == 0 and team.approvedmembers.count == 0 %} - No team member(s) - {% elif team.approvedmembers.count == team.anoncount %} - {{ team.anoncount }} anonymous member(s) - {% endif %} - - {% for member in team.approvedmembers.all %} - {% if member.user.profile.approved_public_credit_name %} - {{ member.user.profile.approved_public_credit_name }}{% if member in team.responsible.all %} (responsible){% endif %}
- {% endif %} + {% for resp in team.responsible_members.all %} + {{ resp.profile.get_public_credit_name }}
{% endfor %} - {% if team.anoncount and team.anoncount != team.approvedmembers.count %} - plus {{ team.anoncount }} anonymous member(s). + + + {% for member in team.regular_members.all %} + {% if member.profile.get_public_credit_name != "Unnamed" %} + {{ member.profile.get_public_credit_name }}
+ {% endif %} + {% empty %} + No team members + {% endfor %} + {% if team.unnamed_members %} + {% if team.unnamed_members.count < team.regular_members.count %}Plus {% endif %}{{ team.unnamed_members.count }} anonymous member(s). {% endif %} diff --git a/src/profiles/__init__.py b/src/profiles/__init__.py index e69de29b..562016c4 100644 --- a/src/profiles/__init__.py +++ b/src/profiles/__init__.py @@ -0,0 +1,2 @@ +default_app_config = 'profiles.apps.ProfilesConfig' + diff --git a/src/profiles/admin.py b/src/profiles/admin.py index 012a4199..795ccad4 100644 --- a/src/profiles/admin.py +++ b/src/profiles/admin.py @@ -14,6 +14,10 @@ class OrderAdmin(admin.ModelAdmin): 'public_credit_name_approved', ] + list_filter = [ + 'public_credit_name_approved', + ] + def approve_public_credit_names(self, request, queryset): for profile in queryset.filter(public_credit_name_approved=False): profile.approve_public_credit_name() diff --git a/src/profiles/apps.py b/src/profiles/apps.py new file mode 100644 index 00000000..307b5f7c --- /dev/null +++ b/src/profiles/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig +from django.db.models.signals import pre_save, post_save +from .signal_handlers import create_profile, profile_pre_save +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +class ProfilesConfig(AppConfig): + name = 'profiles' + + def ready(self): + # remember to include a dispatch_uid to prevent signals being called multiple times in certain corner cases + from django.contrib.auth.models import User + post_save.connect(create_profile, sender=User, dispatch_uid='user_post_save_signal') + pre_save.connect(profile_pre_save, sender='profiles.Profile', dispatch_uid='profile_pre_save_signal') + diff --git a/src/profiles/migrations/0008_auto_20180325_2022.py b/src/profiles/migrations/0008_auto_20180325_2022.py new file mode 100644 index 00000000..efe72731 --- /dev/null +++ b/src/profiles/migrations/0008_auto_20180325_2022.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-25 18:22 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0007_auto_20170711_2025'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='public_credit_name', + field=models.CharField(blank=True, help_text='The name you want to appear on in the credits section of the public website (on Team and People pages). Leave this empty if you want your name hidden on the public webpages.', max_length=100), + ), + ] diff --git a/src/profiles/migrations/0009_profile_nickserv_username.py b/src/profiles/migrations/0009_profile_nickserv_username.py new file mode 100644 index 00000000..0b5bca3c --- /dev/null +++ b/src/profiles/migrations/0009_profile_nickserv_username.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-03 00:01 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0008_auto_20180325_2022'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='nickserv_username', + field=models.CharField(blank=True, help_text='Your NickServ username is used to manage team IRC channel access lists.', max_length=50), + ), + ] diff --git a/src/profiles/models.py b/src/profiles/models.py index c7fcb2ec..e4e8f068 100644 --- a/src/profiles/models.py +++ b/src/profiles/models.py @@ -1,9 +1,5 @@ from django.contrib.auth.models import User from django.db import models -from django.db.models.signals import ( - post_save, - pre_save -) from django.conf import settings from django.utils import timezone from django.dispatch import receiver @@ -11,7 +7,6 @@ from django.utils.translation import ugettext_lazy as _ from datetime import timedelta -from ircbot.models import OutgoingIrcMessage from utils.models import UUIDModel, CreatedUpdatedModel @@ -42,7 +37,7 @@ class Profile(CreatedUpdatedModel, UUIDModel): public_credit_name = models.CharField( blank=True, max_length=100, - help_text='The name you want to appear on in the credits section of the public website (the People pages). Leave empty if you want no public credit.' + help_text='The name you want to appear on in the credits section of the public website (on Team and People pages). Leave this empty if you want your name hidden on the public webpages.' ) public_credit_name_approved = models.BooleanField( @@ -50,6 +45,12 @@ class Profile(CreatedUpdatedModel, UUIDModel): help_text='Check this box to approve this users public_credit_name. This will be unchecked automatically when the user edits public_credit_name' ) + nickserv_username = models.CharField( + blank=True, + max_length=50, + help_text='Your NickServ username is used to manage team IRC channel access lists.', + ) + @property def email(self): return self.user.email @@ -58,37 +59,21 @@ class Profile(CreatedUpdatedModel, UUIDModel): return self.user.username def approve_public_credit_name(self): + """ + This method just sets profile.public_credit_name_approved=True and calls save() + It is used in an admin action + """ self.public_credit_name_approved = True self.save() @property - def approved_public_credit_name(self): + def get_public_credit_name(self): + """ + Convenience method to return profile.public_credit_name if it is approved, + and the string "Unnamed" otherwise + """ if self.public_credit_name_approved: return self.public_credit_name else: - return False + return "Unnamed" - -@receiver(post_save, sender=User) -def create_profile(sender, created, instance, **kwargs): - if created: - Profile.objects.create(user=instance) - - -@receiver(pre_save, sender=Profile) -def changed_public_credit_name(sender, instance, **kwargs): - try: - original = sender.objects.get(pk=instance.pk) - except sender.DoesNotExist: - # newly created object, just pass - pass - else: - if not original.public_credit_name == instance.public_credit_name: - OutgoingIrcMessage.objects.create( - target=settings.IRCBOT_CHANNELS['orga'] if 'orga' in settings.IRCBOT_CHANNELS else settings.IRCBOT_CHANNELS['default'], - message='User {username} changed public credit name. please review and act accordingly: https://bornhack.dk/admin/profiles/profile/{uuid}/change/'.format( - username=instance.name, - uuid=instance.uuid - ), - timeout=timezone.now()+timedelta(minutes=60) - ) diff --git a/src/profiles/signal_handlers.py b/src/profiles/signal_handlers.py new file mode 100644 index 00000000..5fb38c6d --- /dev/null +++ b/src/profiles/signal_handlers.py @@ -0,0 +1,81 @@ +from django.db.models.signals import ( + post_save, + pre_save +) +from events.handler import handle_team_event +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +def create_profile(sender, created, instance, **kwargs): + """ + Signal handler called after a User object is saved. + Creates a Profile object when the User object was just created. + """ + from .models import Profile + if created: + Profile.objects.create(user=instance) + + +def profile_pre_save(sender, instance, **kwargs): + """ + Signal handler called before a Profile object is saved. + """ + try: + original = sender.objects.get(pk=instance.pk) + except sender.DoesNotExist: + original = None + logger.debug("inside profile_pre_save with instance.nickserv_username=%s and original.nickserv_username=%s" % (instance.nickserv_username, original.nickserv_username)) + + public_credit_name_changed(instance, original) + nickserv_username_changed(instance, original) + + +def public_credit_name_changed(instance, original): + """ + Checks if a users public_credit_name has been changed, and triggers a public_credit_name_changed event if so + """ + if original.public_credit_name == instance.public_credit_name: + # public_credit_name has not been changed + return + + if original.public_credit_name and not original.public_credit_name_approved: + # the original.public_credit_name was not approved, no need to notify again + return + + # put the message together + message='User {username} changed public credit name. please review and act accordingly: https://bornhack.dk/admin/profiles/profile/{uuid}/change/'.format( + username=instance.name, + uuid=instance.uuid + ) + + # trigger the event + handle_team_event( + eventtype='public_credit_name_changed', + irc_message=message, + ) + + +def nickserv_username_changed(instance, original): + """ + Check if profile.nickserv_username was changed, and uncheck irc_channel_acl_ok if so + This will be picked up by the IRC bot and fixed as needed + """ + if instance.nickserv_username and instance.nickserv_username != original.nickserv_username: + logger.debug("profile.nickserv_username changed for user %s, setting irc_channel_acl_ok=False" % instance.user.username) + + # find team memberships for this user + from teams.models import TeamMember + memberships = TeamMember.objects.filter( + user=instance.user, + approved=True, + team__irc_channel=True, + team__irc_channel_managed=True, + team__irc_channel_private=True, + ) + + # loop over memberships + for membership in memberships: + membership.irc_channel_acl_ok = False + membership.save() + diff --git a/src/profiles/templates/profile_detail.html b/src/profiles/templates/profile_detail.html index eca56d17..84ba1938 100644 --- a/src/profiles/templates/profile_detail.html +++ b/src/profiles/templates/profile_detail.html @@ -14,9 +14,13 @@ {{ profile.description|default:"N/A" }} - Public Credit Name (visible to the public, leave empty if you want no credits) + Public Credit Name (visible to the public, leave empty if you want no credits on this website) {{ profile.public_credit_name|default:"N/A" }} {% if profile.public_credit_name %}({% if profile.public_credit_name_approved %}approved{% else %}pending approval{% endif %}){% endif %} + + NickServ username (visible to the public on IRC, used to handle team channel ACLs) + {{ profile.nickserv_username|default:"N/A" }} + Edit Profile {% endblock profile_content %} diff --git a/src/profiles/views.py b/src/profiles/views.py index 79ae29bb..4c991aff 100644 --- a/src/profiles/views.py +++ b/src/profiles/views.py @@ -16,7 +16,7 @@ class ProfileDetail(LoginRequiredMixin, DetailView): class ProfileUpdate(LoginRequiredMixin, UpdateView): model = models.Profile - fields = ['name', 'description', 'public_credit_name'] + fields = ['name', 'description', 'public_credit_name', 'nickserv_username'] success_url = reverse_lazy('profiles:detail') template_name = 'profile_form.html' @@ -28,6 +28,6 @@ class ProfileUpdate(LoginRequiredMixin, UpdateView): # user changed the name (to something non blank) form.instance.public_credit_name_approved = False form.instance.save() - messages.info(self.request, 'Your profile has been updated.') + messages.success(self.request, 'Your profile has been updated.') return super().form_valid(form, **kwargs) diff --git a/src/program/apps.py b/src/program/apps.py index a7ec74a0..8a5ef090 100644 --- a/src/program/apps.py +++ b/src/program/apps.py @@ -14,12 +14,10 @@ class ProgramConfig(AppConfig): from .signal_handlers import ( check_speaker_event_camp_consistency, check_speaker_camp_change, - notify_proposal_submitted ) m2m_changed.connect( check_speaker_event_camp_consistency, sender=Speaker.events.through ) pre_save.connect(check_speaker_camp_change, sender=Speaker) - pre_save.connect(notify_proposal_submitted, sender=SpeakerProposal) - pre_save.connect(notify_proposal_submitted, sender=EventProposal) + diff --git a/src/program/signal_handlers.py b/src/program/signal_handlers.py index 08fbb4d5..55434053 100644 --- a/src/program/signal_handlers.py +++ b/src/program/signal_handlers.py @@ -8,7 +8,6 @@ from django.conf import settings from .email import add_new_speakerproposal_email, add_new_eventproposal_email from .models import EventProposal, SpeakerProposal -from ircbot.models import OutgoingIrcMessage logger = logging.getLogger("bornhack.%s" % __name__) @@ -37,42 +36,3 @@ def check_speaker_camp_change(sender, instance, **kwargs): if event.camp != instance.camp: raise ValidationError({'camp': 'You cannot change the camp a speaker belongs to if the speaker is associated with one or more events.'}) - -# pre_save signal that notifies if a proposal changes status from draft to -# pending i.e. is submitted. -def notify_proposal_submitted(sender, instance, **kwargs): - try: - original = sender.objects.get(pk=instance.pk) - except sender.DoesNotExist: - return False - - target = settings.IRCBOT_CHANNELS['orga'] if 'orga' in settings.IRCBOT_CHANNELS else settings.IRCBOT_CHANNELS['default'] - - if original.proposal_status == 'draft' and instance.proposal_status == 'pending': - if isinstance(instance, EventProposal): - if not add_new_eventproposal_email(instance): - logger.error( - 'Error adding event proposal email to outgoing queue for {}'.format(instance) - ) - OutgoingIrcMessage.objects.create( - target=target, - message="New event proposal: {} - https://bornhack.dk/admin/program/eventproposal/{}/change/".format( - instance.title, - instance.uuid - ), - timeout=timezone.now()+timedelta(minutes=10) - ) - - if isinstance(instance, SpeakerProposal): - if not add_new_speakerproposal_email(instance): - logger.error( - 'Error adding speaker proposal email to outgoing queue for {}'.format(instance) - ) - OutgoingIrcMessage.objects.create( - target=target, - message="New speaker proposal: {} - https://bornhack.dk/admin/program/speakerproposal/{}/change/".format( - instance.name, - instance.uuid - ), - timeout=timezone.now()+timedelta(minutes=10) - ) diff --git a/src/teams/admin.py b/src/teams/admin.py index e479c0cc..139d214d 100644 --- a/src/teams/admin.py +++ b/src/teams/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Team, TeamArea, TeamMember, TeamTask +from .models import Team, TeamMember, TeamTask from .email import add_added_membership_email, add_removed_membership_email from camps.utils import CampPropertyListFilter @@ -17,12 +17,12 @@ class TeamTaskAdmin(admin.ModelAdmin): @admin.register(Team) class TeamAdmin(admin.ModelAdmin): def get_responsible(self, obj): - return ", ".join([resp.get_full_name() for resp in obj.responsible]) + return ", ".join([resp.profile.public_credit_name for resp in obj.responsible_members.all()]) get_responsible.short_description = 'Responsible' list_display = [ 'name', - 'area', + 'camp', 'get_responsible', 'needs_members', ] @@ -78,9 +78,3 @@ class TeamMemberAdmin(admin.ModelAdmin): ) remove_member.description = 'Remove a user from the team.' - -@admin.register(TeamArea) -class TeamAreaAdmin(admin.ModelAdmin): - list_filter = [ - 'camp' - ] diff --git a/src/teams/apps.py b/src/teams/apps.py index 17954d66..3dd023c4 100644 --- a/src/teams/apps.py +++ b/src/teams/apps.py @@ -1,5 +1,13 @@ from django.apps import AppConfig +from django.db.models.signals import post_save, post_delete +from .signal_handlers import teammember_saved, teammember_deleted class TeamsConfig(AppConfig): name = 'teams' + + def ready(self): + # connect the post_save signal, always including a dispatch_uid to prevent it being called multiple times in corner cases + post_save.connect(teammember_saved, sender='teams.TeamMember', dispatch_uid='teammember_save_signal') + post_delete.connect(teammember_deleted, sender='teams.TeamMember', dispatch_uid='teammember_save_signal') + diff --git a/src/teams/email.py b/src/teams/email.py index 62d997db..944f0edb 100644 --- a/src/teams/email.py +++ b/src/teams/email.py @@ -49,10 +49,11 @@ def add_new_membership_email(membership): return add_outgoing_email( text_template='emails/new_membership_email.txt', html_template='emails/new_membership_email.html', - to_recipients=[resp.email for resp in membership.team.responsible], + to_recipients=[resp.email for resp in membership.team.responsible_members.all()], formatdict=formatdict, subject='New membership request for {} at {}'.format( membership.team.name, membership.team.camp.title ) ) + diff --git a/src/teams/forms.py b/src/teams/forms.py deleted file mode 100644 index dab9796c..00000000 --- a/src/teams/forms.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.forms import ModelForm -from .models import Team - - -class ManageTeamForm(ModelForm): - class Meta: - model = Team - fields = ['description', 'needs_members'] diff --git a/src/teams/migrations/0022_auto_20180318_1135.py b/src/teams/migrations/0022_auto_20180318_1135.py new file mode 100644 index 00000000..d2707376 --- /dev/null +++ b/src/teams/migrations/0022_auto_20180318_1135.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 10:35 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0021_auto_20180318_0906'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='irc_channel', + field=models.BooleanField(default=False, help_text='Check to make the IRC bot join the team IRC channel. Leave unchecked to disable IRC bot functionality for this team entirely.'), + ), + migrations.AddField( + model_name='team', + name='irc_channel_managed', + field=models.BooleanField(default=True, help_text='Check to make the bot manage the team IRC channel. The bot will register the channel with ChanServ if possible, and manage ACLs as needed.'), + ), + migrations.AddField( + model_name='team', + name='irc_channel_name', + field=models.TextField(blank=True, default='', help_text='Team IRC channel. Leave blank to generate channel name automatically, based on camp slug and team slug.'), + ), + migrations.AddField( + model_name='team', + name='irc_channel_private', + field=models.BooleanField(default=True, help_text='Check to make the IRC channel private for team members only, also sets +s. Leave unchecked to make the IRC channel public and open for everyone.'), + ), + ] diff --git a/src/teams/migrations/0023_auto_20180318_1256.py b/src/teams/migrations/0023_auto_20180318_1256.py new file mode 100644 index 00000000..3a8983cd --- /dev/null +++ b/src/teams/migrations/0023_auto_20180318_1256.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:56 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0022_auto_20180318_1135'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='shortslug', + field=models.SlugField(blank=True, help_text='Abbreviated version of the slug. Used in places like IRC channel names where space is limited'), + ), + migrations.AlterField( + model_name='team', + name='name', + field=models.CharField(help_text='The team name', max_length=255), + ), + migrations.AlterField( + model_name='team', + name='slug', + field=models.SlugField(blank=True, help_text='Url slug for this team. Leave blank to generate based on team name', max_length=255), + ), + ] diff --git a/src/teams/migrations/0024_populate_shortslugs.py b/src/teams/migrations/0024_populate_shortslugs.py new file mode 100644 index 00000000..4babd51e --- /dev/null +++ b/src/teams/migrations/0024_populate_shortslugs.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 11:45 +from __future__ import unicode_literals + +from django.db import migrations + +def populate_team_shortslugs(apps, schema_editor): + Team = apps.get_model('teams', 'Team') + for team in Team.objects.all(): + if not team.shortslug: + team.shortslug = team.slug + team.save() + +class Migration(migrations.Migration): + dependencies = [ + ('teams', '0023_auto_20180318_1256'), + ] + + operations = [ + migrations.RunPython(populate_team_shortslugs), + ] + diff --git a/src/teams/migrations/0025_auto_20180318_1318.py b/src/teams/migrations/0025_auto_20180318_1318.py new file mode 100644 index 00000000..2ad4a528 --- /dev/null +++ b/src/teams/migrations/0025_auto_20180318_1318.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-18 12:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0024_populate_shortslugs'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='shortslug', + field=models.SlugField(help_text='Abbreviated version of the slug. Used in places like IRC channel names where space is limited'), + ), + ] diff --git a/src/teams/migrations/0026_team_camp.py b/src/teams/migrations/0026_team_camp.py new file mode 100644 index 00000000..577bdd71 --- /dev/null +++ b/src/teams/migrations/0026_team_camp.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-25 13:45 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0025_auto_20180318_1250'), + ('teams', '0025_auto_20180318_1318'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='camp', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='camps.Camp'), + ), + ] diff --git a/src/teams/migrations/0027_fixup_teams.py b/src/teams/migrations/0027_fixup_teams.py new file mode 100644 index 00000000..879090e0 --- /dev/null +++ b/src/teams/migrations/0027_fixup_teams.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-25 13:45 +from __future__ import unicode_literals + +from django.db import migrations + +def add_team_camp(apps, schema_editor): + Team = apps.get_model('teams', 'Team') + TeamArea = apps.get_model('teams', 'TeamArea') + TeamMember = apps.get_model('teams', 'TeamMember') + + for team in Team.objects.all(): + print("camp processing team %s..." % team.name) + team.camp = team.area.camp + team.save() + print("set camp %s for team %s" % (team.camp.slug, team.name)) + +def add_missing_team_responsibles(apps, schema_editor): + Team = apps.get_model('teams', 'Team') + TeamArea = apps.get_model('teams', 'TeamArea') + TeamMember = apps.get_model('teams', 'TeamMember') + + for team in Team.objects.all(): + print("responsible processing team %s..." % team.name) + responsibles = TeamMember.objects.filter(team=team, responsible=True) + if not responsibles: + # get the area responsibles instead + responsibles = team.area.responsible.all() + for responsible in responsibles: + if isinstance(responsible, TeamMember): + # we need User objects instead of TeamMember objects + responsible = responsible.user + try: + membership = TeamMember.objects.get(team=team, user=responsible) + if not membership.responsible: + # already a member of the team, but not responsible + membership.responsible=True + membership.save() + print("%s is now marked as responsible" % membership.user.username) + except TeamMember.DoesNotExist: + # add the responsible as a member of the team + membership = TeamMember.objects.create( + team=team, + user=responsible, + responsible=True, + approved=True + ) + print("new membership has been created for team %s" % team.name) + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0026_team_camp'), + ] + + operations = [ + migrations.RunPython(add_team_camp), + migrations.RunPython(add_missing_team_responsibles), + ] + diff --git a/src/teams/migrations/0028_auto_20180331_1416.py b/src/teams/migrations/0028_auto_20180331_1416.py new file mode 100644 index 00000000..919fe0cf --- /dev/null +++ b/src/teams/migrations/0028_auto_20180331_1416.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-31 12:16 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0027_fixup_teams'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='area', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='teams.TeamArea'), + ), + ] diff --git a/src/teams/migrations/0029_remove_team_area.py b/src/teams/migrations/0029_remove_team_area.py new file mode 100644 index 00000000..1d86f49e --- /dev/null +++ b/src/teams/migrations/0029_remove_team_area.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-03-31 12:31 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0028_auto_20180331_1416'), + ] + + operations = [ + migrations.RemoveField( + model_name='team', + name='area', + ), + ] diff --git a/src/teams/migrations/0030_auto_20180402_1514.py b/src/teams/migrations/0030_auto_20180402_1514.py new file mode 100644 index 00000000..b7abdabd --- /dev/null +++ b/src/teams/migrations/0030_auto_20180402_1514.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-02 13:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0029_remove_team_area'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='irc_channel_name', + field=models.TextField(blank=True, default='', help_text='Team IRC channel. Leave blank to generate channel name automatically, based on camp shortslug and team shortslug.'), + ), + migrations.AlterField( + model_name='team', + name='irc_channel_private', + field=models.BooleanField(default=True, help_text='Check to make the IRC channel secret and +i (private for team members only using an ACL). Leave unchecked to make the IRC channel public and open for everyone.'), + ), + migrations.AlterField( + model_name='team', + name='needs_members', + field=models.BooleanField(default=True, help_text='Check to indicate that this team needs more members'), + ), + ] diff --git a/src/teams/migrations/0031_auto_20180402_2146.py b/src/teams/migrations/0031_auto_20180402_2146.py new file mode 100644 index 00000000..22cef6cb --- /dev/null +++ b/src/teams/migrations/0031_auto_20180402_2146.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-02 19:46 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0030_auto_20180402_1514'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='mailing_list_archive_public', + field=models.BooleanField(default=False, help_text='Check if the mailing list archive is public'), + ), + migrations.AddField( + model_name='team', + name='mailing_list_nonmember_posts', + field=models.BooleanField(default=False, help_text='Check if the mailinglist allows non-list-members to post'), + ), + ] diff --git a/src/teams/migrations/0032_auto_20180402_2148.py b/src/teams/migrations/0032_auto_20180402_2148.py new file mode 100644 index 00000000..421e6683 --- /dev/null +++ b/src/teams/migrations/0032_auto_20180402_2148.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-02 19:48 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0031_auto_20180402_2146'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='camp', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='camps.Camp'), + ), + ] diff --git a/src/teams/migrations/0033_auto_20180402_2204.py b/src/teams/migrations/0033_auto_20180402_2204.py new file mode 100644 index 00000000..338cc17c --- /dev/null +++ b/src/teams/migrations/0033_auto_20180402_2204.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-02 20:04 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0032_auto_20180402_2148'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='teamarea', + unique_together=set([]), + ), + migrations.RemoveField( + model_name='teamarea', + name='camp', + ), + migrations.RemoveField( + model_name='teamarea', + name='responsible', + ), + migrations.DeleteModel( + name='TeamArea', + ), + ] diff --git a/src/teams/migrations/0034_auto_20180402_2334.py b/src/teams/migrations/0034_auto_20180402_2334.py new file mode 100644 index 00000000..eb4a7718 --- /dev/null +++ b/src/teams/migrations/0034_auto_20180402_2334.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-02 21:34 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0033_auto_20180402_2204'), + ] + + operations = [ + migrations.AlterModelOptions( + name='teammember', + options={'ordering': ['-responsible', 'approved']}, + ), + ] diff --git a/src/teams/migrations/0035_auto_20180402_2344.py b/src/teams/migrations/0035_auto_20180402_2344.py new file mode 100644 index 00000000..78ea2a64 --- /dev/null +++ b/src/teams/migrations/0035_auto_20180402_2344.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-02 21:44 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0034_auto_20180402_2334'), + ] + + operations = [ + migrations.AlterModelOptions( + name='teammember', + options={'ordering': ['-responsible', '-approved']}, + ), + ] diff --git a/src/teams/migrations/0036_auto_20180403_0201.py b/src/teams/migrations/0036_auto_20180403_0201.py new file mode 100644 index 00000000..ad6bede4 --- /dev/null +++ b/src/teams/migrations/0036_auto_20180403_0201.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-03 00:01 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('camps', '0025_auto_20180318_1250'), + ('teams', '0035_auto_20180402_2344'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='team', + unique_together=set([('slug', 'camp'), ('name', 'camp')]), + ), + ] diff --git a/src/teams/migrations/0037_auto_20180408_1416.py b/src/teams/migrations/0037_auto_20180408_1416.py new file mode 100644 index 00000000..6f7f30c9 --- /dev/null +++ b/src/teams/migrations/0037_auto_20180408_1416.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2018-04-08 12:16 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0036_auto_20180403_0201'), + ] + + operations = [ + migrations.AddField( + model_name='teammember', + name='irc_channel_acl_ok', + field=models.BooleanField(default=False, help_text='Maintained by the IRC bot, do not edit manually. True if the teammembers NickServ username has been added to the Team IRC channels ACL.'), + ), + migrations.AlterField( + model_name='teammember', + name='approved', + field=models.BooleanField(default=False, help_text='True if this membership is approved. False if not.'), + ), + migrations.AlterField( + model_name='teammember', + name='responsible', + field=models.BooleanField(default=False, help_text='True if this teammember is responsible for this Team. False if not.'), + ), + migrations.AlterField( + model_name='teammember', + name='team', + field=models.ForeignKey(help_text='The Team this membership relates to', on_delete=django.db.models.deletion.PROTECT, to='teams.Team'), + ), + migrations.AlterField( + model_name='teammember', + name='user', + field=models.ForeignKey(help_text='The User object this team membership relates to', on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/teams/models.py b/src/teams/models.py index 5599f27a..aaaa550b 100644 --- a/src/teams/models.py +++ b/src/teams/models.py @@ -3,7 +3,6 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.text import slugify from utils.models import CampRelatedModel -from .email import add_new_membership_email from django.core.exceptions import ValidationError from django.contrib.auth.models import User from django.core.urlresolvers import reverse_lazy @@ -11,123 +10,219 @@ import logging logger = logging.getLogger("bornhack.%s" % __name__) -class TeamArea(CampRelatedModel): - class Meta: - ordering = ['name'] - unique_together = ('name', 'camp') - - name = models.CharField(max_length=255) - description = models.TextField(default='') - camp = models.ForeignKey('camps.Camp', related_name="teamareas", on_delete=models.PROTECT) - responsible = models.ManyToManyField( - 'auth.User', - related_name='responsible_team_areas' - ) - - def __str__(self): - return '{} ({})'.format(self.name, self.camp) - - class Team(CampRelatedModel): - name = models.CharField(max_length=255) - slug = models.SlugField(max_length=255, blank=True) - area = models.ForeignKey( - 'teams.TeamArea', - related_name='teams', - on_delete=models.PROTECT + camp = models.ForeignKey( + 'camps.Camp', + related_name="teams", + on_delete=models.PROTECT, ) + + name = models.CharField( + max_length=255, + help_text='The team name', + ) + + slug = models.SlugField( + max_length=255, + blank=True, + help_text='Url slug for this team. Leave blank to generate based on team name', + ) + + shortslug = models.SlugField( + help_text='Abbreviated version of the slug. Used in places like IRC channel names where space is limited', + ) + description = models.TextField() - needs_members = models.BooleanField(default=True) + + needs_members = models.BooleanField( + default=True, + help_text='Check to indicate that this team needs more members', + ) + members = models.ManyToManyField( 'auth.User', related_name='teams', through='teams.TeamMember' ) - mailing_list = models.EmailField(blank=True) + + # mailing list related fields + mailing_list = models.EmailField( + blank=True + ) + + mailing_list_archive_public = models.BooleanField( + default=False, + help_text='Check if the mailing list archive is public' + ) + + mailing_list_nonmember_posts = models.BooleanField( + default=False, + help_text='Check if the mailinglist allows non-list-members to post' + ) + + # IRC related fields + irc_channel = models.BooleanField( + default=False, + help_text='Check to make the IRC bot join the team IRC channel. Leave unchecked to disable IRC bot functionality for this team entirely.', + ) + + irc_channel_name = models.TextField( + default='', + blank=True, + help_text='Team IRC channel. Leave blank to generate channel name automatically, based on camp shortslug and team shortslug.', + ) + + irc_channel_managed = models.BooleanField( + default=True, + help_text='Check to make the bot manage the team IRC channel. The bot will register the channel with ChanServ if possible, and manage ACLs as needed.', + ) + + irc_channel_private = models.BooleanField( + default=True, + help_text='Check to make the IRC channel secret and +i (private for team members only using an ACL). Leave unchecked to make the IRC channel public and open for everyone.' + ) class Meta: ordering = ['name'] + unique_together = (('name', 'camp'), ('slug', 'camp')) def __str__(self): return '{} ({})'.format(self.name, self.camp) - def validate_unique(self, exclude): - """ - We cannot use unique_together with the camp field because it is a property, - so check uniqueness of team name and slug here instead - """ - # check if this team name is in use under this camp - if self.camp.teams.filter(name=self.name).exists(): - raise ValidationError("This Team name already exists for this Camp") - if self.camp.teams.filter(slug=self.slug).exists(): - raise ValidationError("This Team slug already exists for this Camp") - return True - - @property - def camp(self): - return self.area.camp - def save(self, **kwargs): - if ( - not self.pk or - not self.slug - ): + # generate slug if needed + if not self.pk or not self.slug: slug = slugify(self.name) self.slug = slug + if not self.shortslug: + self.shortslug = self.slug + + # generate IRC channel name if needed + if self.irc_channel and not self.irc_channel_name: + self.irc_channel_name = "#%s-%s" % (self.camp.shortslug, self.shortslug) + super().save(**kwargs) - def memberstatus(self, member): - if member not in self.members.all(): - return "Not member" - else: - if TeamMember.objects.get(team=self, user=member).approved: - return "Member" - else: - return "Membership Pending" + def clean(self): + # make sure the irc channel name is prefixed with a # if it is set + if self.irc_channel_name and self.irc_channel_name[0] != "#": + self.irc_channel_name = "#%s" % self.irc_channel_name + + if self.irc_channel_name: + if Team.objects.filter(irc_channel_name=self.irc_channel_name).exclude(pk=self.pk).exists(): + raise ValidationError("This IRC channel name is already in use") @property - def responsible(self): - if TeamMember.objects.filter(team=self, responsible=True).exists(): - return User.objects.filter( - teammember__team=self, - teammember__responsible=True - ) - else: - return self.area.responsible.all() + def memberships(self): + """ + Returns all TeamMember objects for this team. + Use self.members.all() to get User objects for all members, + or use self.memberships.all() to get TeamMember objects for all members. + """ + return TeamMember.objects.filter( + team=self + ) @property - def anoncount(self): - return self.approvedmembers.filter(user__profile__public_credit_name_approved=False).count() + def approved_members(self): + """ + Returns only approved members (returns User objects, not TeamMember objects) + """ + return self.members.filter( + teammember__approved=True + ) @property - def approvedmembers(self): - return TeamMember.objects.filter(team=self, approved=True) + def unapproved_members(self): + """ + Returns only unapproved members (returns User objects, not TeamMember objects) + """ + return self.members.filter( + teammember__approved=False + ) + + @property + def responsible_members(self): + """ + Return only approved and responsible members + Used to handle permissions for team management + """ + return self.members.filter( + teammember__approved=True, + teammember__responsible=True + ) + + @property + def regular_members(self): + """ + Return only approved and not responsible members with + an approved public_credit_name. + Used on the people pages. + """ + return self.members.filter( + teammember__approved=True, + teammember__responsible=False, + ) + + @property + def unnamed_members(self): + """ + Returns only approved and not responsible members, + without an approved public_credit_name. + """ + return self.members.filter( + teammember__approved=True, + teammember__responsible=False, + profile__public_credit_name_approved=False + ) class TeamMember(CampRelatedModel): - user = models.ForeignKey('auth.User', on_delete=models.PROTECT) - team = models.ForeignKey('teams.Team', on_delete=models.PROTECT) - approved = models.BooleanField(default=False) - responsible = models.BooleanField(default=False) + user = models.ForeignKey( + 'auth.User', + on_delete=models.PROTECT, + help_text="The User object this team membership relates to", + ) + + team = models.ForeignKey( + 'teams.Team', + on_delete=models.PROTECT, + help_text="The Team this membership relates to" + ) + + approved = models.BooleanField( + default=False, + help_text="True if this membership is approved. False if not." + ) + + responsible = models.BooleanField( + default=False, + help_text="True if this teammember is responsible for this Team. False if not." + ) + + irc_channel_acl_ok = models.BooleanField( + default=False, + help_text="Maintained by the IRC bot, do not edit manually. True if the teammembers NickServ username has been added to the Team IRC channels ACL.", + ) + + class Meta: + ordering = ['-responsible', '-approved'] def __str__(self): - return '{} is {} member of team {}'.format( - self.user, '' if self.approved else 'an unapproved', self.team + return '{} is {} {} member of team {}'.format( + self.user, + '' if self.approved else 'an unapproved', + '' if not self.responsible else 'a responsible', + self.team ) @property def camp(self): + """ All CampRelatedModels must have a camp FK or a camp property """ return self.team.camp -@receiver(post_save, sender=TeamMember) -def add_responsible_email(sender, instance, created, **kwargs): - if created: - if not add_new_membership_email(instance): - logger.error('Error adding email to outgoing queue') - - class TeamTask(CampRelatedModel): team = models.ForeignKey( 'teams.Team', @@ -157,14 +252,12 @@ class TeamTask(CampRelatedModel): @property def camp(self): + """ All CampRelatedModels must have a camp FK or a camp property """ return self.team.camp def save(self, **kwargs): + # generate slug if needed if not self.slug: self.slug = slugify(self.name) super().save(**kwargs) - @property - def responsible(self): - return self.team.responsible.all() - diff --git a/src/teams/signal_handlers.py b/src/teams/signal_handlers.py new file mode 100644 index 00000000..c55a2af7 --- /dev/null +++ b/src/teams/signal_handlers.py @@ -0,0 +1,31 @@ +from .email import add_new_membership_email +from ircbot.utils import add_irc_message +from django.conf import settings +import logging +logger = logging.getLogger("bornhack.%s" % __name__) + + +def teammember_saved(sender, instance, created, **kwargs): + """ + This signal handler is called whenever a TeamMember instance is saved + """ + # if this is a new unapproved teammember send a mail to team responsibles + if created and not instance.approved: + # call the mail sending function + if not add_new_membership_email(instance): + logger.error('Error adding email to outgoing queue') + + # if this team has a private and bot-managed IRC channel check if we need to add this member to ACL + if instance.team.irc_channel and instance.team.irc_channel_managed and instance.team.irc_channel_private: + # if this membership is approved and the member has entered a nickserv_username which not yet been added to the ACL + if instance.approved and instance.user.profile.nickserv_username and not instance.irc_channel_acl_ok: + add_team_channel_acl(instance) + +def teammember_deleted(sender, instance, **kwargs): + """ + This signal handler is called whenever a TeamMember instance is deleted + """ + if instance.irc_channel_acl_ok and instance.team.irc_channel and instance.team.irc_channel_managed and instance.team.irc_channel_private: + # TODO: we have an ACL entry that needs to be deleted but the bot does not handle it automatically + add_irc_message(instance.team.irc_channel_name, "Teammember %s removed from team. Please remove NickServ user %s from IRC channel ACL manually!" % (instance.user.get_public_credit_name, instance.user.profile.nickserv_username)) + diff --git a/src/teams/templates/team_detail.html b/src/teams/templates/team_detail.html index 44972367..690dca3b 100644 --- a/src/teams/templates/team_detail.html +++ b/src/teams/templates/team_detail.html @@ -1,27 +1,47 @@ {% extends 'base.html' %} {% load commonmark %} -{% load teams_tags %} {% load bootstrap3 %} +{% load teams_tags %} {% block title %} Team: {{ team.name }} | {{ block.super }} {% endblock %} {% block content %} -
-

{{ team.name }} Team

+

{{ team.name }} Team Details

{{ team.description|unsafecommonmark }} - {% if request.user in team.responsible.all %} - Manage Team + +
+ +

{{ team.name }} Team Communications

+ {{ team.camp.title }} teams primarily use mailing lists and IRC to communicate. The {{ team.name }} team can be contacted in the following ways:

+ +
Mailing List
+ {% if team.mailing_list and request.user in team.approved_members.all %} +

The {{ team.name }} Team mailinglist is {{ team.mailing_list }}{% if team.mailing_list_archive_public %}, and the archives are publicly available{% endif %}. You should sign up for the list if you haven't already.

+ {% elif team.mailing_list and team.mailinglist_nonmember_posts %} +

The {{ team.name }} Team mailinglist is {{ team.mailing_list }}{% if team.mailing_list_archive_public %}, and the archives are publicly available{% endif %}. You do not need to be a member of the list to post to it.

+ {% else %} +

The {{ team.name }} Team does not have a public mailing list, but it can be contacted through our main email info@bornhack.dk. + {% endif %} + +

IRC Channel
+ {% if team.irc_channel and request.user in team.approved_members.all %} +

The {{ team.name }} Team IRC channel is {{ team.irc_channel_name }} on {{ IRCBOT_SERVER_HOSTNAME }}. + {% if team.irc_channel_private %}The channel is not open to the public. Enter your nickserv username in your Profile to get access.{% else %}The IRC channel is open for everyone to join.{% endif %}

+ {% elif team.irc_channel and not team.irc_channel_private %} +

The {{ team.name }} Team IRC channel is {{ team.irc_channel_name }} on {{ IRCBOT_SERVER_HOSTNAME }} and it is open for everyone to join.

+ {% else %} +

The {{ team.name }} Team does not have a public IRC channel, but it can be reached through our main IRC channel {{ IRCBOT_PUBLIC_CHANNEL }} on {{ IRCBOT_SERVER_HOSTNAME }}.

{% endif %}
-

Members

-

The following {{ team.approvedmembers.count }} people are members of the {{ team.name }} team:

- +

{{ team.name }} Team Members

+

The following {{ team.approved_members.count }} people {% if team.unapproved_members.count %}(and {{ team.unapproved_members.count }} pending){% endif %} are members of the {{ team.name }} Team:

+
- {% for teammember in team.approvedmembers.all %} + {% for teammember in team.memberships.all %} {% endfor %}
@@ -33,40 +53,39 @@ Team: {{ team.name }} | {{ block.super }}
- {% if teammember.user.profile.approved_public_credit_name %} - {{ teammember.user.profile.approved_public_credit_name }} - {% else %} - anonymous - {% endif %} + {{ teammember.user.profile.get_public_credit_name }} {% if teammember.user == request.user %}(this is you!){% endif %} - {% if teammember.responsible %}Team Responsible{% else %}Team Member{% endif %} + Team {% if teammember.responsible %}Responsible{% else %}Member{% endif %} + {% if not teammember.approved %}(pending approval){% endif %}
- {% if request.user in team.members.all %} -

Your membership status: {% membershipstatus request.user team %}

- {% endif %} +

Your membership status: {% membershipstatus user team %}

{% if request.user in team.members.all %} - Leave Team + Leave Team {% else %} {% if team.needs_members %} - This team is looking for members! Join Team + This team is looking for members! Join Team {% endif %} {% endif %} + {% if request.user in team.responsible_members.all %} + Manage Team + {% endif %} +
-

Tasks

-

This team is responsible for the following tasks

- +

{{ team.name }} Team Tasks

+

The {{ team.name }} Team is responsible for the following tasks

+
@@ -80,17 +99,17 @@ Team: {{ team.name }} | {{ block.super }} {% endfor %}
Name{{ task.name }} {{ task.description }} - Details - {% if request.user in team.responsible.all %} - Edit Task + Details + {% if request.user in team.responsible_members.all %} + Edit Task {% endif %}
- {% if request.user in team.responsible.all %} - Create Task + {% if request.user in team.responsible_members.all %} + Create Task {% endif %}
diff --git a/src/teams/templates/team_join.html b/src/teams/templates/team_join.html index ac4e8fd0..021bfdd8 100644 --- a/src/teams/templates/team_join.html +++ b/src/teams/templates/team_join.html @@ -7,12 +7,17 @@ Join Team: {{ team.name }} | {{ block.super }} {% block content %} -

{{ team.name }} Team

-

Really join the {{ team.name }} team? You will receive a message when your membership has been approved.

+

Really join the {{ team.name }} Team for {{ team.camp.title }}?

+ +

Your membership will need to be approved by a team responsible. You will receive an email when your membership request has been processed.

+ +

{% csrf_token %} {{ form }} Cancel
+

+ {% endblock %} diff --git a/src/teams/templates/team_list.html b/src/teams/templates/team_list.html index c72df85d..d5be8eb2 100644 --- a/src/teams/templates/team_list.html +++ b/src/teams/templates/team_list.html @@ -12,7 +12,7 @@ Teams | {{ block.super }}

This is a list of the teams for {{ camp.title }}. To join a team just press the Join button, but please put some info in your profile first, so the team responsible has some idea who you are.

You can also leave a team of course, but please let the team responsible know why :)

Team memberships need to be approved by a team responsible. You will receive a message when your membership has been approved.

-

At {{ camp.title }} all organisers and volunteers buy full tickets like everyone else. In the future our budget may allow for discounts or free tickets for volunteers, but not this year. However: Please let us know if you can't afford a ticket - we will figure something out!

+

At {{ camp.title }} all organisers and volunteers buy full tickets like everyone else. At future events our budget may allow for discounts or free tickets for volunteers, but currently it does not.

We currently have {{ teams.count }} teams for {{ camp.title }}:

{% if teams %} @@ -42,13 +42,13 @@ Teams | {{ block.super }} @@ -58,30 +58,23 @@ Teams | {{ block.super }} {% if request.user.is_authenticated %} diff --git a/src/teams/templates/team_manage.html b/src/teams/templates/team_manage.html index 57c80348..99002cc0 100644 --- a/src/teams/templates/team_manage.html +++ b/src/teams/templates/team_manage.html @@ -1,6 +1,5 @@ {% extends 'base.html' %} {% load commonmark %} -{% load teams_tags %} {% load bootstrap3 %} {% block title %} @@ -8,81 +7,92 @@ Manage Team: {{ team.name }} | {{ block.super }} {% endblock %} {% block content %} -

Manage {{ team.name }} Team

- - {% csrf_token %} +
+

Manage {{ team.name }} Team

+
+
+ + {% csrf_token %} - {% bootstrap_form form %} + {% bootstrap_form form %} + {% buttons %} + + Cancel  + {% endbuttons %} + +
+
+
- {% buttons %} - - {% endbuttons %} - - -

{{ team.name }} Team Members

-{% if team.teammember_set.exists %} -
- {% for resp in team.responsible.all %} - {{ resp.profile.approved_public_credit_name|default:"Unnamed" }}{% if not forloop.last %},{% endif %}
+ {% for resp in team.responsible_members.all %} + {{ resp.profile.get_public_credit_name }}{% if not forloop.last %},{% endif %}
{% endfor %}
- {{ team.approvedmembers.count }}
+ {{ team.members.count }}
{% if team.needs_members %}(more needed){% endif %}
- {% membershipstatus request.user team as membership_status %} - {% if membership_status == 'Membership Pending' %} - - {% else %} - {% if membership_status == 'Member' %} - - {% else %} - - {% endif %} - {% endif %} + {% membershipstatus request.user team True %} - {% if request.user in team.members.all %} - Leave - {% else %} - {% if team.needs_members %} - Join - {% endif %} - {% endif %} - - {% if request.user in team.responsible.all %} +
+ Details + {% if request.user in team.responsible_members.all %} Manage {% endif %} + {% if request.user in team.members.all %} + Leave + {% else %} + {% if team.needs_members %} + Join + {% endif %} + {% endif %} +
{% endif %}
- - - - - - - - - - - - - {% for membership in team.teammember_set.all %} - - - - - - - - - - {% endfor %} - -
- Profile - - Name - - Email - - Description - - Public Credit Name - - Membership - - Action -
- {{ membership.user }} - - {{ membership.user.profile.name }} - - {{ membership.user.profile.email }} - - {{ membership.user.profile.description }} - - {{ membership.user.profile.public_credit_name|default:"N/A" }} - - {% if membership.approved %}member{% else %}pending{% endif %} - - {% if membership.approved %} - Remove - {% else %} - Remove - Approve - {% endif %} -
-{% else %} -

No members found!

-{% endif %} +
+

Manage {{ team.name }} Team Members

+
+ {% if team.teammember_set.exists %} + + + + + + + + + + + + + + {% for membership in team.teammember_set.all %} + + + + + + + + + + {% endfor %} + +
+ Username + + Name + + Email + + Description + + Public Credit Name + + Membership + + Action +
+ {{ membership.user }} + + {{ membership.user.profile.name }} + + {{ membership.user.profile.email }} + + {{ membership.user.profile.description }} + + {{ membership.user.profile.public_credit_name|default:"N/A" }} + {% if membership.user.profile.public_credit_name and not membership.user.profile.public_credit_name_approved %}(name not approved){% endif %} + + {% if membership.approved %}member{% else %}pending{% endif %} + +
+ Remove Member + {% if not membership.approved %} + Approve Member + {% endif %} +
+
+ {% else %} +

No members found!

+ {% endif %} +
+
{% endblock %} diff --git a/src/teams/templatetags/teams_tags.py b/src/teams/templatetags/teams_tags.py index 090d3d66..47c5d262 100644 --- a/src/teams/templatetags/teams_tags.py +++ b/src/teams/templatetags/teams_tags.py @@ -1,8 +1,24 @@ from django import template - +from django.utils.safestring import mark_safe register = template.Library() - @register.simple_tag -def membershipstatus(user, team): - return team.memberstatus(user) +def membershipstatus(user, team, showicon=False): + if user in team.responsible_members.all(): + text = "Responsible" + icon = "fa-star" + elif user in team.approved_members.all(): + text = "Member" + icon = "fa-thumbs-o-up" + elif user in team.unapproved_members.all(): + text = "Membership pending approval" + icon = "fa-clock-o" + else: + text = "Not member" + icon = "fa-times" + + if showicon: + return mark_safe("" % (icon, text)) + else: + return text + diff --git a/src/teams/views.py b/src/teams/views.py index a5056af8..b16ed10b 100644 --- a/src/teams/views.py +++ b/src/teams/views.py @@ -9,6 +9,7 @@ from django.contrib import messages from django.http import HttpResponseRedirect from django.views.generic.detail import SingleObjectMixin from django.core.urlresolvers import reverse_lazy +from django.conf import settings from profiles.models import Profile @@ -17,9 +18,12 @@ logger = logging.getLogger("bornhack.%s" % __name__) class EnsureTeamResponsibleMixin(object): + """ + Use to make sure request.user is responsible for the team specified by kwargs['team_slug'] + """ def dispatch(self, request, *args, **kwargs): self.team = Team.objects.get(slug=kwargs['team_slug'], camp=self.camp) - if request.user not in self.team.responsible.all(): + if request.user not in self.team.responsible_members.all(): messages.error(request, 'No thanks') return redirect('teams:detail', camp_slug=self.camp.slug, team_slug=self.team.slug) @@ -28,6 +32,22 @@ class EnsureTeamResponsibleMixin(object): ) +class EnsureTeamMemberResponsibleMixin(SingleObjectMixin): + """ + Use to make sure request.user is responsible for the team which TeamMember belongs to + """ + model = TeamMember + + def dispatch(self, request, *args, **kwargs): + if request.user not in self.get_object().team.responsible_members.all(): + messages.error(request, 'No thanks') + return redirect('teams:detail', camp_slug=self.get_object().team.camp.slug, team_slug=self.get_object().team.slug) + + return super().dispatch( + request, *args, **kwargs + ) + + class TeamListView(CampViewMixin, ListView): template_name = "team_list.html" model = Team @@ -40,16 +60,26 @@ class TeamDetailView(CampViewMixin, DetailView): model = Team slug_url_kwarg = 'team_slug' + def get_context_data(self, **kwargs): + context = super(TeamDetailView, self).get_context_data(**kwargs) + context['IRCBOT_SERVER_HOSTNAME'] = settings.IRCBOT_SERVER_HOSTNAME + context['IRCBOT_PUBLIC_CHANNEL'] = settings.IRCBOT_PUBLIC_CHANNEL + return context + class TeamManageView(CampViewMixin, EnsureTeamResponsibleMixin, UpdateView): model = Team template_name = "team_manage.html" - fields = ['description', 'needs_members'] + fields = ['description', 'needs_members', 'irc_channel', 'irc_channel_name', 'irc_channel_managed', 'irc_channel_private'] slug_url_kwarg = 'team_slug' def get_success_url(self): return reverse_lazy('teams:detail', kwargs={'camp_slug': self.camp.slug, 'team_slug': self.get_object().slug}) + def form_valid(self, form): + messages.success(self.request, "Team has been saved") + return super().form_valid(form) + class TeamJoinView(LoginRequiredMixin, CampViewMixin, UpdateView): template_name = "team_join.html" @@ -100,18 +130,6 @@ class TeamLeaveView(LoginRequiredMixin, CampViewMixin, UpdateView): return redirect('teams:list', camp_slug=self.get_object().camp.slug) -class EnsureTeamMemberResponsibleMixin(SingleObjectMixin): - model = TeamMember - - def dispatch(self, request, *args, **kwargs): - if request.user not in self.get_object().team.responsible.all(): - messages.error(request, 'No thanks') - return redirect('teams:detail', camp_slug=self.get_object().team.camp.slug, team_slug=self.get_object().team.slug) - - return super().dispatch( - request, *args, **kwargs - ) - class TeamMemberRemoveView(LoginRequiredMixin, CampViewMixin, EnsureTeamMemberResponsibleMixin, UpdateView): template_name = "teammember_remove.html" diff --git a/src/tickets/signals.py b/src/tickets/signals.py index c7fec0c2..136923c9 100644 --- a/src/tickets/signals.py +++ b/src/tickets/signals.py @@ -5,21 +5,20 @@ from datetime import ( ) from django.db.models import Count from django.utils import timezone +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 queue an IRC message when a new ticket is created + # only trigger an event when a new ticket is created if not created: return - # queue an IRC message to the orga channel if defined, - # otherwise for the default channel - target = settings.IRCBOT_CHANNELS['orga'] if 'orga' in settings.IRCBOT_CHANNELS else settings.IRCBOT_CHANNELS['default'] - # 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( @@ -50,19 +49,17 @@ def ticket_changed(sender, instance, created, **kwargs): ).count() # queue the messages - from ircbot.models import OutgoingIrcMessage - OutgoingIrcMessage.objects.create( - target=target, - message="%s sold!" % instance.product.name, - timeout=timezone.now()+timedelta(minutes=10) + handle_team_event( + eventtype='ticket_created', + irc_message="%s sold!" % instance.product.name ) - OutgoingIrcMessage.objects.create( - target=target, - message="Totals: {}, 1day: {}, 1day child: {}".format( + # 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], - timeout=timezone.now()+timedelta(minutes=10) + )[:200] ) diff --git a/src/utils/management/commands/bootstrap-devsite.py b/src/utils/management/commands/bootstrap-devsite.py index 040274de..a3370a3d 100644 --- a/src/utils/management/commands/bootstrap-devsite.py +++ b/src/utils/management/commands/bootstrap-devsite.py @@ -23,9 +23,12 @@ from tickets.models import ( from teams.models import ( Team, TeamTask, - TeamArea, TeamMember ) +from events.models import ( + Type, + Routing +) from django.contrib.auth.models import User from allauth.account.models import EmailAddress from django.utils.text import slugify @@ -44,6 +47,7 @@ class Command(BaseCommand): title='BornHack 2016', tagline='Initial Commit', slug='bornhack-2016', + shortslug='bh2016', buildup=( timezone.datetime(2016, 8, 25, 12, 0, tzinfo=timezone.utc), timezone.datetime(2016, 8, 27, 11, 59, tzinfo=timezone.utc), @@ -63,6 +67,7 @@ class Command(BaseCommand): title='BornHack 2017', tagline='Make Tradition', slug='bornhack-2017', + shortslug='bh2017', buildup=( timezone.datetime(2017, 8, 25, 12, 0, tzinfo=timezone.utc), timezone.datetime(2017, 8, 27, 11, 59, tzinfo=timezone.utc), @@ -82,6 +87,7 @@ class Command(BaseCommand): title='BornHack 2018', tagline='Undecided', slug='bornhack-2018', + shortslug='bh2018', buildup=( timezone.datetime(2018, 8, 25, 12, 0, tzinfo=timezone.utc), timezone.datetime(2018, 8, 27, 11, 59, tzinfo=timezone.utc), @@ -104,6 +110,8 @@ class Command(BaseCommand): ) user1.profile.name = 'John Doe' user1.profile.description = 'one that once was' + user1.profile.public_credit_name = 'PublicDoe' + user1.profile.public_credit_name_approved = True user1.profile.save() email = EmailAddress.objects.create( user=user1, @@ -112,6 +120,7 @@ class Command(BaseCommand): verified=True ) email.set_as_primary() + user2 = User.objects.create_user( username='user2', password='user2', @@ -127,6 +136,7 @@ class Command(BaseCommand): verified=True ) email.set_as_primary() + user3 = User.objects.create_user( username='user3', password='user3', @@ -134,6 +144,7 @@ class Command(BaseCommand): ) user3.profile.name = 'Lorem Ipsum' user3.profile.description = 'just a user' + user3.profile.public_credit_name = 'Lorem Ipsum' user3.profile.save() email = EmailAddress.objects.create( user=user3, @@ -142,6 +153,7 @@ class Command(BaseCommand): verified=True ) email.set_as_primary() + user4 = User.objects.create_user( username='user4', password='user4', @@ -149,6 +161,8 @@ class Command(BaseCommand): ) user4.profile.name = 'Ethe Reum' user4.profile.description = 'I prefer doge' + user4.profile.public_credit_name = 'Dogefan' + user4.profile.public_credit_name_approved = True user4.profile.save() email = EmailAddress.objects.create( user=user4, @@ -157,6 +171,96 @@ class Command(BaseCommand): verified=True ) email.set_as_primary() + + user5 = User.objects.create_user( + username='user5', + password='user5', + is_staff=True + ) + user5.profile.name = 'Pyra Mid' + user5.profile.description = 'This is not a scam' + user5.profile.public_credit_name = 'Ponziarne' + user5.profile.public_credit_name_approved = True + user5.profile.save() + email = EmailAddress.objects.create( + user=user5, + email='user5@example.com', + primary=False, + verified=True + ) + email.set_as_primary() + + user6 = User.objects.create_user( + username='user6', + password='user6', + is_staff=True + ) + user6.profile.name = 'User Number 6' + user6.profile.description = 'some description' + user6.profile.public_credit_name = 'bruger 6' + user6.profile.public_credit_name_approved = True + user6.profile.save() + email = EmailAddress.objects.create( + user=user6, + email='user6@example.com', + primary=False, + verified=True + ) + email.set_as_primary() + + user7 = User.objects.create_user( + username='user7', + password='user7', + is_staff=True + ) + user7.profile.name = 'Assembly Hacker' + user7.profile.description = 'Low level is best level' + user7.profile.public_credit_name = 'asm' + user7.profile.public_credit_name_approved = True + user7.profile.save() + email = EmailAddress.objects.create( + user=user7, + email='user7@example.com', + primary=False, + verified=True + ) + email.set_as_primary() + + user8 = User.objects.create_user( + username='user8', + password='user8', + is_staff=True + ) + user8.profile.name = 'TCL' + user8.profile.description = 'Expect me' + user8.profile.public_credit_name = 'TCL lover' + user8.profile.public_credit_name_approved = True + user8.profile.save() + email = EmailAddress.objects.create( + user=user8, + email='user8@example.com', + primary=False, + verified=True + ) + email.set_as_primary() + + user9 = User.objects.create_user( + username='user9', + password='user9', + is_staff=True + ) + user9.profile.name = 'John Windows' + user9.profile.description = 'Microsoft is best soft' + user9.profile.public_credit_name = 'msboy' + user9.profile.save() + email = EmailAddress.objects.create( + user=user9, + email='user9@example.com', + primary=False, + verified=True + ) + email.set_as_primary() + admin = User.objects.create_superuser( username='admin', email='admin@example.com', @@ -1373,44 +1477,21 @@ Please note that sleeping in the parking lot is not permitted. If you want to sl description='This village is representing TheCamp.dk, an annual danish tech camp held in July. The official subjects for this event is open source software, network and security. In reality we are interested in anything from computers to illumination soap bubbles and irish coffee' ) - self.output("Creating team areas for {}...".format(year)) - pr_area = TeamArea.objects.create( - name='PR', - description="The Public Relations area covers website, social media and marketing related tasks.", - camp=camp - ) - content_area = TeamArea.objects.create( - name='Content', - description="The Content area handles talks, A/V and photos.", - camp=camp - ) - infrastructure_area = TeamArea.objects.create( - name='Infrastructure', - description="The Infrastructure area covers network/NOC, power, villages, CERT, logistics.", - camp=camp - ) - bar_area = TeamArea.objects.create( - name='Bar', - description="The Bar area covers building and running the IRL bar, DJ booth and related tasks.", - camp=camp - ) - - self.output("Setting teamarea responsibles for {}...".format(year)) - pr_area.responsible.add(user2) - content_area.responsible.add(user2, user3) - infrastructure_area.responsible.add(user3, user4) - bar_area.responsible.add(user4) - self.output("Creating teams for {}...".format(year)) + orga_team = Team.objects.create( + name="Orga", + description="The Orga team are the main organisers. All tasks are Orga responsibility until they are delegated to another team", + camp=camp + ) noc_team = Team.objects.create( name="NOC", - description="The NOC team is in charge of establishing and running a network onsite.".format(year), - area=infrastructure_area, + description="The NOC team is in charge of establishing and running a network onsite.", + camp=camp ) bar_team = Team.objects.create( name="Bar", description="The Bar team plans, builds and run the IRL bar!", - area=bar_area + camp=camp ) self.output("Creating TeamTasks for {}...".format(year)) @@ -1461,29 +1542,93 @@ Please note that sleeping in the parking lot is not permitted. If you want to sl ) self.output("Setting team members for {}...".format(year)) + # noc team TeamMember.objects.create( team=noc_team, user=user4, - approved=True + approved=True, + responsible=True ) TeamMember.objects.create( team=noc_team, - user=user1 + user=user1, + approved=True, ) + TeamMember.objects.create( + team=noc_team, + user=user5, + approved=True, + ) + TeamMember.objects.create( + team=noc_team, + user=user6, + ) + + # bar team TeamMember.objects.create( team=bar_team, user=user1, - approved=True + approved=True, + responsible=True ) TeamMember.objects.create( team=bar_team, user=user3, - approved=True + approved=True, + responsible=True ) TeamMember.objects.create( team=bar_team, - user=user2 + user=user2, + approved=True, ) + TeamMember.objects.create( + team=bar_team, + user=user7, + approved=True, + ) + TeamMember.objects.create( + team=bar_team, + user=user8, + ) + + # orga team + TeamMember.objects.create( + team=orga_team, + user=user1, + approved=True, + responsible=True + ) + TeamMember.objects.create( + team=orga_team, + user=user3, + approved=True, + responsible=True + ) + TeamMember.objects.create( + team=orga_team, + user=user8, + approved=True, + ) + TeamMember.objects.create( + team=orga_team, + user=user9, + approved=True, + ) + TeamMember.objects.create( + team=orga_team, + user=user4, + ) + + self.output("Adding event routing...") + Routing.objects.create( + team=orga_team, + eventtype=Type.objects.get(name="public_credit_name_changed") + ) + Routing.objects.create( + team=orga_team, + eventtype=Type.objects.get(name="ticket_created") + ) self.output("marking 2016 as read_only...")