diff --git a/tabbycat/actionlog/migrations/0014_alter_actionlogentry_type.py b/tabbycat/actionlog/migrations/0014_alter_actionlogentry_type.py
new file mode 100644
index 00000000000..cb013ab6a28
--- /dev/null
+++ b/tabbycat/actionlog/migrations/0014_alter_actionlogentry_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.7 on 2026-03-20 23:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('actionlog', '0013_actionlogentry_agent_alter_actionlogentry_type'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='actionlogentry',
+ name='type',
+ field=models.CharField(choices=[('br.aj.set', 'Changed adjudicator breaking status'), ('aj.crea', 'Created adjudicator'), ('aj.edit', 'Edited adjudicator'), ('aj.note', 'Set adjudicator note'), ('aa.auto', 'Auto-allocated adjudicators'), ('aa.save', 'Saved adjudicator allocation'), ('av.aj.save', 'Edited adjudicators availability'), ('av.save', 'Edited availability'), ('av.tm.save', 'Edited teams availability'), ('av.ve.save', 'Edited room availability'), ('ba.ckin', 'Checked in ballot set'), ('ba.conf', 'Confirmed ballot set'), ('ba.crea', 'Created ballot set'), ('ba.disc', 'Discarded ballot set'), ('ba.edit', 'Edited ballot set'), ('ba.subm', 'Submitted ballot set from the public form'), ('br.ca.edit', 'Edited break categories'), ('br.del', 'Deleted team break for category'), ('br.rm.edit', 'Edited breaking team remarks'), ('br.el.edit', 'Edited break eligibility'), ('br.gene', 'Generated the team break for all categories'), ('br.gen1', 'Generated the team break for one category'), ('br.upda', 'Edited breaking team remarks and updated all team breaks'), ('br.upd1', 'Edited breaking team remarks and updated this team break'), ('ch.aj.gene', 'Generated check in identifiers for adjudicators'), ('ch.sp.gene', 'Generated check in identifiers for speakers'), ('ch.ve.gene', 'Generated check in identifiers for rooms'), ('ac.aa.edit', 'Edited adjudicator-adjudicator conflicts'), ('ac.ai.edit', 'Edited adjudicator-institution conflicts'), ('ac.at.edit', 'Edited adjudicator-team conflicts'), ('ac.ti.edit', 'Edited team-institution conflicts'), ('db.crea', 'Created debate'), ('db.edit', 'Edited debate'), ('db.im.auto', 'Auto-prioritized debate importance'), ('db.im.edit', 'Edited debate importance'), ('dv.save', 'Saved divisions'), ('dr.conf', 'Confirmed draw'), ('dr.crea', 'Created draw'), ('dr.rege', 'Regenerated draw'), ('dr.rele', 'Released draw'), ('dr.unre', 'Unreleased draw'), ('fq.crea', 'Created feedback question'), ('fq.edit', 'Edited feedback question'), ('fb.save', 'Saved feedback'), ('fb.subm', 'Submitted feedback from the public form'), ('in.crea', 'Created institution'), ('in.edit', 'Edited institution'), ('mu.save', 'Saved a matchup manual edit'), ('mo.edit', 'Added/edited motion'), ('mo.rele', 'Released motions'), ('mo.unre', 'Unreleased motions'), ('op.edit', 'Edited tournament options'), ('pp.aj.auto', 'Auto-allocated adjudicators to preformed panels'), ('pp.aj.edit', 'Edited preformed panel adjudicator'), ('pp.crea', 'Created preformed panels'), ('pp.db.auto', 'Auto-allocated preformed panels to debates'), ('pp.del', 'Deleted preformed panels'), ('pp.im.auto', 'Auto-prioritized preformed panels'), ('pp.im.edit', 'Edited preformed panel importance'), ('rd.adva', 'Advanced the current round to'), ('rd.comp', 'Marked round as completed'), ('rd.crea', 'Created round'), ('rd.edit', 'Edited round'), ('rd.st.set', 'Set start time'), ('ms.save', 'Saved the sides status of a matchup'), ('si.adju', 'Imported adjudicators using the simple importer'), ('si.inst', 'Imported institutions using the simple importer'), ('si.team', 'Imported teams using the simple importer'), ('si.venu', 'Imported rooms using the simple importer'), ('se.ca.edit', 'Edited speaker categories'), ('sp.crea', 'Created speaker'), ('sp.del', 'Deleted speaker'), ('sp.edit', 'Edited speaker'), ('se.edit', 'Edited speaker category eligibility'), ('te.crea', 'Created team'), ('te.edit', 'Edited team'), ('ts.edit', 'Edited adjudicator base score'), ('to.crea', 'Created tournament'), ('to.edit', 'Edited tournament'), ('aj.sc.upda', 'Updated adjudicator scores in bulk'), ('ur.inv', 'Invited user to the instance'), ('ve.ca.edit', 'Edited room categories'), ('ve.ca.crea', 'Created room category'), ('ve.co.edit', 'Edited room constraints'), ('ve.crea', 'Created room'), ('ve.edit', 'Edited room'), ('ve.auto', 'Auto-allocated rooms'), ('ve.save', 'Saved a room manual edit'), ('qu.crea', 'Created question'), ('qu.edit', 'Edited question'), ('inst.reg', 'Registered institution'), ('te.reg', 'Registered team'), ('aj.reg', 'Registered adjudicator'), ('sp.reg', 'Registered speaker'), ('re.conf', 'Confirmed registration'), ('sc.crea', 'Created schedule event'), ('sc.edit', 'Edited schedule event')], max_length=10, verbose_name='type'),
+ ),
+ ]
diff --git a/tabbycat/actionlog/models.py b/tabbycat/actionlog/models.py
index 327f5fc2ffb..02d8f6ee5dc 100644
--- a/tabbycat/actionlog/models.py
+++ b/tabbycat/actionlog/models.py
@@ -93,6 +93,7 @@ class ActionType(models.TextChoices):
SIMPLE_IMPORT_VENUES = 'si.venu', _("Imported rooms using the simple importer")
SPEAKER_CATEGORIES_EDIT = 'se.ca.edit', _("Edited speaker categories")
SPEAKER_CREATE = 'sp.crea', _("Created speaker")
+ SPEAKER_DELETE = 'sp.del', _("Deleted speaker")
SPEAKER_EDIT = 'sp.edit', _("Edited speaker")
SPEAKER_ELIGIBILITY_EDIT = 'se.edit', _("Edited speaker category eligibility")
TEAM_CREATE = 'te.crea', _("Created team")
diff --git a/tabbycat/participants/forms.py b/tabbycat/participants/forms.py
new file mode 100644
index 00000000000..e0d9b15d47b
--- /dev/null
+++ b/tabbycat/participants/forms.py
@@ -0,0 +1,16 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+
+class ConfirmSpeakerDeletionForm(forms.Form):
+ speaker_name = forms.CharField(label=_("Speaker's full name"), required=True)
+
+ def __init__(self, *args, speaker=None, **kwargs):
+ self.speaker = speaker
+ super().__init__(*args, **kwargs)
+
+ def clean_speaker_name(self):
+ if self.cleaned_data['speaker_name'] != self.speaker.name:
+ raise forms.ValidationError(
+ _("You must type '%(name)s' exactly to confirm deletion.") % {'name': self.speaker.name},
+ )
diff --git a/tabbycat/participants/templates/speaker_confirm_delete.html b/tabbycat/participants/templates/speaker_confirm_delete.html
new file mode 100644
index 00000000000..60b65b088ed
--- /dev/null
+++ b/tabbycat/participants/templates/speaker_confirm_delete.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+{% load debate_tags i18n %}
+
+{% block head-title %}🗑 {% trans "Confirm Speaker Deletion" %}{% endblock %}
+{% block page-title %}{% trans "Confirm Speaker Deletion" %}{% endblock %}
+
+{% block page-subnav-sections %}
+
+ {% trans "Back to Team Record" %}
+
+{% endblock %}
+
+{% block page-alerts %}
+
+
+ {% blocktrans trimmed with name=speaker.name team=team.short_name %}
+ Do you really want to delete {{ name }} from {{ team }}?
+ This will permanently remove the speaker and cannot be undone.
+ Any ballot scores attributed to this speaker will also be deleted.
+ {% endblocktrans %}
+
+
+
+
+{% endblock %}
diff --git a/tabbycat/participants/templates/speaker_create.html b/tabbycat/participants/templates/speaker_create.html
new file mode 100644
index 00000000000..e9568a5ed39
--- /dev/null
+++ b/tabbycat/participants/templates/speaker_create.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block head-title %}👤 {% trans "Add Speaker" %}{% endblock %}
+{% block page-title %}{% trans "Add Speaker" %}{% endblock %}
+{% block page-subtitle %}{% blocktrans trimmed with name=team.short_name %}for {{ name }}{% endblocktrans %}{% endblock %}
+
+{% block page-subnav-sections %}
+ {% include "participants_subnav.html" %}
+{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock content %}
diff --git a/tabbycat/participants/templates/team_registration_card.html b/tabbycat/participants/templates/team_registration_card.html
index e9b55f02f37..2c160348235 100644
--- a/tabbycat/participants/templates/team_registration_card.html
+++ b/tabbycat/participants/templates/team_registration_card.html
@@ -3,10 +3,15 @@
-
+
{{ card_title|safe }}
+ {% if admin_page %}
+
+ {% trans "Edit Database" %}
+
+ {% endif %}
{% if team.registration_status == 'U' %}
@@ -109,6 +114,8 @@
{% endif %}
{% haspermission "view.answers" as answers_perm %}
+ {% haspermission "add.team" as add_team_perm %}
+ {% haspermission "delete.speaker" as delete_speaker_perm %}
{% if admin_page and answers_perm %}
@@ -126,7 +133,6 @@
{% for speaker in team.speakers %}
{{ speaker.name }}
-
{% for answer in speaker.answers.all %}
- {{ answer.question.name }}: {{ answer.answer }}
@@ -136,6 +142,33 @@
{% endfor %}
+ {% endif %}
+
+ {% if admin_page and add_team_perm %}
+
+
+ {% trans "Speakers:" %}
+
+ {% for speaker in team.speakers %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if admin_page and answers_perm %}
diff --git a/tabbycat/participants/urls_admin.py b/tabbycat/participants/urls_admin.py
index 4257186ae9e..adf3e67cdd4 100644
--- a/tabbycat/participants/urls_admin.py
+++ b/tabbycat/participants/urls_admin.py
@@ -36,6 +36,12 @@
path('team//',
views.TeamRecordView.as_view(),
name='participants-team-record'),
+ path('team//speaker/add/',
+ views.AdminCreateSpeakerView.as_view(),
+ name='participants-team-speaker-add'),
+ path('team//speaker//delete/',
+ views.AdminDeleteSpeakerView.as_view(),
+ name='participants-team-speaker-delete'),
path('adjudicator//',
views.AdjudicatorRecordView.as_view(),
name='participants-adjudicator-record'),
diff --git a/tabbycat/participants/views.py b/tabbycat/participants/views.py
index 4e2af155784..9c66bcfacbe 100644
--- a/tabbycat/participants/views.py
+++ b/tabbycat/participants/views.py
@@ -13,6 +13,7 @@
from django.utils.html import escape
from django.utils.translation import gettext as _, gettext_lazy, ngettext
from django.views.generic.base import View
+from django.views.generic.edit import FormView
from actionlog.mixins import LogActionMixin
from actionlog.models import ActionLogEntry
@@ -23,6 +24,7 @@
from notifications.models import BulkNotification
from notifications.views import TournamentTemplateEmailCreateView
from options.utils import use_team_code_names
+from registration.forms import AdminSpeakerForm
from tournaments.mixins import (PublicTournamentPageMixin,
SingleObjectFromTournamentMixin, TournamentMixin)
from tournaments.models import Round
@@ -32,6 +34,7 @@
from utils.tables import TabbycatTableBuilder
from utils.views import ModelFormSetView, VueTableTemplateView
+from .forms import ConfirmSpeakerDeletionForm
from .models import Adjudicator, Institution, Person, Speaker, SpeakerCategory, Team
from .serializers import SpeakerSerializer
from .tables import AdjudicatorDebateTable, TeamDebateTable
@@ -364,6 +367,86 @@ def get_queryset(self):
)
+class AdminCreateSpeakerView(LogActionMixin, AdministratorMixin, TournamentMixin, FormView):
+ template_name = 'speaker_create.html'
+ action_log_type = ActionLogEntry.ActionType.SPEAKER_CREATE
+ edit_permission = Permission.ADD_TEAMS
+ form_class = AdminSpeakerForm
+
+ def get_team(self):
+ if not hasattr(self, '_team'):
+ self._team = Team.objects.get(tournament=self.tournament, pk=self.kwargs['pk'])
+ return self._team
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['team'] = self.get_team()
+ return kwargs
+
+ def get_action_log_content_object(self):
+ return self.object
+
+ def get_page_title(self):
+ return _("Add Speaker to %(team)s") % {'team': self.get_team().short_name}
+
+ def get_context_data(self, **kwargs):
+ kwargs['team'] = self.get_team()
+ kwargs['team_record_url'] = reverse_tournament('participants-team-record', self.tournament, kwargs={'pk': self.get_team().pk})
+ kwargs['cancel_text'] = _("Cancel and return to team record")
+ return super().get_context_data(**kwargs)
+
+ def form_valid(self, form):
+ self.object = form.save()
+ messages.success(self.request, _("%(name)s was added as a speaker.") % {'name': self.object.name})
+ return super().form_valid(form)
+
+ def get_success_url(self):
+ return reverse_tournament('participants-team-record', self.tournament, kwargs={'pk': self.get_team().pk})
+
+
+class AdminDeleteSpeakerView(LogActionMixin, AdministratorMixin, TournamentMixin, FormView):
+ template_name = 'speaker_confirm_delete.html'
+ action_log_type = ActionLogEntry.ActionType.SPEAKER_DELETE
+ edit_permission = Permission.DELETE_SPEAKER
+ form_class = ConfirmSpeakerDeletionForm
+
+ def get_speaker(self):
+ if not hasattr(self, '_speaker'):
+ self._speaker = Speaker.objects.select_related('team__tournament').get(
+ pk=self.kwargs['speaker_pk'], team__tournament=self.tournament,
+ )
+ return self._speaker
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['speaker'] = self.get_speaker()
+ return kwargs
+
+ def get_action_log_content_object(self):
+ return self.object
+
+ def get_page_title(self):
+ return _("Delete %(name)s") % {'name': self.get_speaker().name}
+
+ def get_context_data(self, **kwargs):
+ kwargs['speaker'] = self.get_speaker()
+ kwargs['team'] = self.get_speaker().team
+ return super().get_context_data(**kwargs)
+
+ def form_valid(self, form):
+ self.object = self.get_speaker()
+ team_pk = self.object.team_id
+ speaker_name = self.object.name
+ self.log_action()
+ self.object.delete()
+ messages.success(self.request, _("%(name)s was deleted.") % {'name': speaker_name})
+ return redirect_tournament('participants-team-record', self.tournament, pk=team_pk)
+
+ def get_success_url(self):
+ return reverse_tournament('participants-team-record', self.tournament,
+ kwargs={'pk': self.get_speaker().team_id})
+
+
class AdjudicatorRecordView(AdministratorMixin, BaseAdjudicatorRecordView):
admin = True
view_permission = Permission.VIEW_ADJUDICATORS
diff --git a/tabbycat/registration/forms.py b/tabbycat/registration/forms.py
index cf03a7c9fdb..09ea0b0a6b8 100644
--- a/tabbycat/registration/forms.py
+++ b/tabbycat/registration/forms.py
@@ -257,6 +257,23 @@ def save(self, commit=True):
return obj
+class AdminSpeakerForm(forms.ModelForm):
+
+ def __init__(self, *args, team=None, **kwargs):
+ self.team = team
+ super().__init__(*args, **kwargs)
+
+ class Meta:
+ model = Speaker
+ fields = ('name', 'email')
+
+ def save(self, commit=True):
+ self.instance.team = self.team
+ obj = super().save(commit=commit)
+ populate_url_keys([obj])
+ return obj
+
+
class AdjudicatorForm(CustomQuestionsFormMixin, forms.ModelForm):
key = forms.CharField(widget=forms.HiddenInput(), required=False)
diff --git a/tabbycat/users/groups.py b/tabbycat/users/groups.py
index 551e0d8e09b..67fd11409e7 100644
--- a/tabbycat/users/groups.py
+++ b/tabbycat/users/groups.py
@@ -137,6 +137,7 @@ class Registration(BaseGroup):
name = _("Registration")
permissions = [
Permission.ADD_TEAMS,
+ Permission.DELETE_SPEAKER,
Permission.VIEW_DECODED_TEAMS,
Permission.VIEW_ANONYMOUS,
Permission.ADD_ADJUDICATORS,
diff --git a/tabbycat/users/permissions.py b/tabbycat/users/permissions.py
index ef1868076aa..042fe36cfc1 100644
--- a/tabbycat/users/permissions.py
+++ b/tabbycat/users/permissions.py
@@ -28,6 +28,7 @@ class Permission(TextChoices):
VIEW_TEAMS = 'view.team', _("view teams")
ADD_TEAMS = 'add.team', _("add teams")
+ DELETE_SPEAKER = 'delete.speaker', _("delete speakers")
VIEW_DECODED_TEAMS = 'view.teamname', _("view decoded team names")
VIEW_ANONYMOUS = 'view.anonymous', _("View names of anonymized participants")
VIEW_ADJUDICATORS = 'view.adj', _("view adjudicators")